d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
import { sleep } from '@sim/utils/helpers'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import type { McpTool } from '@/lib/mcp/types'
|
|
import { MemoryMcpCache } from './memory-cache'
|
|
|
|
describe('MemoryMcpCache', () => {
|
|
let cache: MemoryMcpCache
|
|
|
|
const createTool = (name: string): McpTool => ({
|
|
name,
|
|
description: `Test tool: ${name}`,
|
|
inputSchema: { type: 'object' },
|
|
serverId: 'server-1',
|
|
serverName: 'Test Server',
|
|
})
|
|
|
|
beforeEach(() => {
|
|
cache = new MemoryMcpCache()
|
|
})
|
|
|
|
afterEach(() => {
|
|
cache.dispose()
|
|
})
|
|
|
|
describe('get', () => {
|
|
it('returns null for non-existent key', async () => {
|
|
const result = await cache.get('non-existent-key')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns cached entry when valid', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 60000)
|
|
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.tools).toEqual(tools)
|
|
})
|
|
|
|
it('returns null for expired entry', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
// Set with 0 TTL so it expires immediately
|
|
await cache.set('key-1', tools, 0)
|
|
|
|
// Wait a tiny bit to ensure expiry
|
|
await sleep(5)
|
|
|
|
const result = await cache.get('key-1')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('removes expired entry from cache on get', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 1) // 1ms TTL
|
|
|
|
// Wait for expiry
|
|
await sleep(10)
|
|
|
|
// First get should return null and remove entry
|
|
await cache.get('key-1')
|
|
|
|
// Entry should be removed (internal state)
|
|
const result = await cache.get('key-1')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns a copy of tools to prevent mutation', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 60000)
|
|
|
|
const result1 = await cache.get('key-1')
|
|
const result2 = await cache.get('key-1')
|
|
|
|
expect(result1).not.toBe(result2)
|
|
expect(result1?.tools).toEqual(result2?.tools)
|
|
})
|
|
})
|
|
|
|
describe('set', () => {
|
|
it('stores tools with correct expiry', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
const ttl = 60000
|
|
|
|
const beforeSet = Date.now()
|
|
await cache.set('key-1', tools, ttl)
|
|
const afterSet = Date.now()
|
|
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.expiry).toBeGreaterThanOrEqual(beforeSet + ttl)
|
|
expect(result?.expiry).toBeLessThanOrEqual(afterSet + ttl)
|
|
})
|
|
|
|
it('overwrites existing entry with same key', async () => {
|
|
const tools1 = [createTool('tool-1')]
|
|
const tools2 = [createTool('tool-2'), createTool('tool-3')]
|
|
|
|
await cache.set('key-1', tools1, 60000)
|
|
await cache.set('key-1', tools2, 60000)
|
|
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result?.tools).toEqual(tools2)
|
|
expect(result?.tools.length).toBe(2)
|
|
})
|
|
|
|
it('handles empty tools array', async () => {
|
|
await cache.set('key-1', [], 60000)
|
|
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.tools).toEqual([])
|
|
})
|
|
|
|
it('handles multiple keys', async () => {
|
|
const tools1 = [createTool('tool-1')]
|
|
const tools2 = [createTool('tool-2')]
|
|
|
|
await cache.set('key-1', tools1, 60000)
|
|
await cache.set('key-2', tools2, 60000)
|
|
|
|
const result1 = await cache.get('key-1')
|
|
const result2 = await cache.get('key-2')
|
|
|
|
expect(result1?.tools).toEqual(tools1)
|
|
expect(result2?.tools).toEqual(tools2)
|
|
})
|
|
})
|
|
|
|
describe('delete', () => {
|
|
it('removes entry from cache', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 60000)
|
|
|
|
await cache.delete('key-1')
|
|
|
|
const result = await cache.get('key-1')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('does not throw for non-existent key', async () => {
|
|
// Should complete without throwing
|
|
await cache.delete('non-existent')
|
|
// If we get here, it worked
|
|
expect(true).toBe(true)
|
|
})
|
|
|
|
it('does not affect other entries', async () => {
|
|
const tools1 = [createTool('tool-1')]
|
|
const tools2 = [createTool('tool-2')]
|
|
|
|
await cache.set('key-1', tools1, 60000)
|
|
await cache.set('key-2', tools2, 60000)
|
|
|
|
await cache.delete('key-1')
|
|
|
|
const result1 = await cache.get('key-1')
|
|
const result2 = await cache.get('key-2')
|
|
|
|
expect(result1).toBeNull()
|
|
expect(result2?.tools).toEqual(tools2)
|
|
})
|
|
})
|
|
|
|
describe('clear', () => {
|
|
it('removes all entries from cache', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
|
|
await cache.set('key-1', tools, 60000)
|
|
await cache.set('key-2', tools, 60000)
|
|
await cache.set('key-3', tools, 60000)
|
|
|
|
await cache.clear()
|
|
|
|
expect(await cache.get('key-1')).toBeNull()
|
|
expect(await cache.get('key-2')).toBeNull()
|
|
expect(await cache.get('key-3')).toBeNull()
|
|
})
|
|
|
|
it('works on empty cache', async () => {
|
|
// Should complete without throwing
|
|
await cache.clear()
|
|
// If we get here, it worked
|
|
expect(true).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('dispose', () => {
|
|
it('clears the cache', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 60000)
|
|
|
|
cache.dispose()
|
|
|
|
const result = await cache.get('key-1')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('can be called multiple times', () => {
|
|
cache.dispose()
|
|
expect(() => cache.dispose()).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('eviction policy', () => {
|
|
it('evicts oldest entries when max size is exceeded', async () => {
|
|
// Create a cache and add more entries than MAX_CACHE_SIZE (1000)
|
|
const tools = [createTool('tool')]
|
|
|
|
// Add 1005 entries (5 over the limit of 1000)
|
|
for (let i = 0; i < 1005; i++) {
|
|
await cache.set(`key-${i}`, tools, 60000)
|
|
}
|
|
|
|
// The oldest entries (first 5) should be evicted
|
|
expect(await cache.get('key-0')).toBeNull()
|
|
expect(await cache.get('key-1')).toBeNull()
|
|
expect(await cache.get('key-2')).toBeNull()
|
|
expect(await cache.get('key-3')).toBeNull()
|
|
expect(await cache.get('key-4')).toBeNull()
|
|
|
|
// Newer entries should still exist
|
|
expect(await cache.get('key-1004')).not.toBeNull()
|
|
expect(await cache.get('key-1000')).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('TTL behavior', () => {
|
|
it('entry is valid before expiry', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 10000) // 10 seconds
|
|
|
|
// Should be valid immediately
|
|
const result = await cache.get('key-1')
|
|
expect(result).not.toBeNull()
|
|
})
|
|
|
|
it('entry expires with very short TTL', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 1) // 1 millisecond
|
|
|
|
// Wait past expiry
|
|
await sleep(10)
|
|
|
|
const result = await cache.get('key-1')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('supports long TTL', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
const oneHour = 60 * 60 * 1000
|
|
await cache.set('key-1', tools, oneHour)
|
|
|
|
// Should be valid immediately
|
|
const result = await cache.get('key-1')
|
|
expect(result).not.toBeNull()
|
|
expect(result?.expiry).toBeGreaterThan(Date.now())
|
|
})
|
|
})
|
|
|
|
describe('complex tool data', () => {
|
|
it('handles tools with complex schemas', async () => {
|
|
const complexTool: McpTool = {
|
|
name: 'complex-tool',
|
|
description: 'A tool with complex schema',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
config: {
|
|
type: 'object',
|
|
properties: {
|
|
nested: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
required: ['config'],
|
|
},
|
|
serverId: 'server-1',
|
|
serverName: 'Test Server',
|
|
}
|
|
|
|
await cache.set('key-1', [complexTool], 60000)
|
|
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result?.tools[0]).toEqual(complexTool)
|
|
})
|
|
|
|
it('handles tools with special characters in names', async () => {
|
|
const tools = [
|
|
createTool('tool/with/slashes'),
|
|
createTool('tool:with:colons'),
|
|
createTool('tool.with.dots'),
|
|
]
|
|
|
|
await cache.set('workspace:user-123', tools, 60000)
|
|
|
|
const result = await cache.get('workspace:user-123')
|
|
|
|
expect(result?.tools).toEqual(tools)
|
|
})
|
|
|
|
it('handles large number of tools', async () => {
|
|
const tools: McpTool[] = []
|
|
for (let i = 0; i < 100; i++) {
|
|
tools.push(createTool(`tool-${i}`))
|
|
}
|
|
|
|
await cache.set('key-1', tools, 60000)
|
|
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result?.tools.length).toBe(100)
|
|
expect(result?.tools[0].name).toBe('tool-0')
|
|
expect(result?.tools[99].name).toBe('tool-99')
|
|
})
|
|
})
|
|
|
|
describe('concurrent operations', () => {
|
|
it('handles concurrent reads', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
await cache.set('key-1', tools, 60000)
|
|
|
|
const results = await Promise.all([
|
|
cache.get('key-1'),
|
|
cache.get('key-1'),
|
|
cache.get('key-1'),
|
|
])
|
|
|
|
results.forEach((result) => {
|
|
expect(result).not.toBeNull()
|
|
expect(result?.tools).toEqual(tools)
|
|
})
|
|
})
|
|
|
|
it('handles concurrent writes to different keys', async () => {
|
|
const tools = [createTool('tool')]
|
|
|
|
await Promise.all([
|
|
cache.set('key-1', tools, 60000),
|
|
cache.set('key-2', tools, 60000),
|
|
cache.set('key-3', tools, 60000),
|
|
])
|
|
|
|
expect(await cache.get('key-1')).not.toBeNull()
|
|
expect(await cache.get('key-2')).not.toBeNull()
|
|
expect(await cache.get('key-3')).not.toBeNull()
|
|
})
|
|
|
|
it('handles read after immediate write', async () => {
|
|
const tools = [createTool('tool-1')]
|
|
|
|
// Write then immediately read
|
|
await cache.set('key-1', tools, 60000)
|
|
const result = await cache.get('key-1')
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.tools).toEqual(tools)
|
|
})
|
|
})
|
|
})
|