chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
|
||||
export interface McpCacheEntry {
|
||||
tools: McpTool[]
|
||||
expiry: number // Unix timestamp ms
|
||||
}
|
||||
|
||||
export interface McpCacheStorageAdapter {
|
||||
get(key: string): Promise<McpCacheEntry | null>
|
||||
set(key: string, tools: McpTool[], ttlMs: number): Promise<void>
|
||||
delete(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import type { McpCacheStorageAdapter } from './adapter'
|
||||
import { MemoryMcpCache } from './memory-cache'
|
||||
import { RedisMcpCache } from './redis-cache'
|
||||
|
||||
const logger = createLogger('McpCacheFactory')
|
||||
|
||||
let cachedAdapter: McpCacheStorageAdapter | null = null
|
||||
|
||||
/**
|
||||
* Create MCP cache storage adapter.
|
||||
* Uses Redis if available, falls back to in-memory cache.
|
||||
*
|
||||
* Unlike rate-limiting (which fails if Redis is configured but unavailable),
|
||||
* MCP caching gracefully falls back to memory since it's an optimization.
|
||||
*/
|
||||
export function createMcpCacheAdapter(): McpCacheStorageAdapter {
|
||||
if (cachedAdapter) {
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
|
||||
if (redis) {
|
||||
logger.info('MCP cache: Using Redis')
|
||||
cachedAdapter = new RedisMcpCache(redis)
|
||||
} else {
|
||||
logger.info('MCP cache: Using in-memory (Redis not configured)')
|
||||
cachedAdapter = new MemoryMcpCache()
|
||||
}
|
||||
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current adapter type for logging/debugging
|
||||
*/
|
||||
export function getMcpCacheType(): 'redis' | 'memory' {
|
||||
const redis = getRedisClient()
|
||||
return redis ? 'redis' : 'memory'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached adapter.
|
||||
* Only use for testing purposes.
|
||||
*/
|
||||
export function resetMcpCacheAdapter(): void {
|
||||
if (cachedAdapter) {
|
||||
cachedAdapter.dispose()
|
||||
cachedAdapter = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { McpCacheStorageAdapter } from './adapter'
|
||||
export { createMcpCacheAdapter, getMcpCacheType } from './factory'
|
||||
@@ -0,0 +1,367 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
|
||||
|
||||
const logger = createLogger('McpMemoryCache')
|
||||
|
||||
export class MemoryMcpCache implements McpCacheStorageAdapter {
|
||||
private cache = new Map<string, McpCacheEntry>()
|
||||
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE
|
||||
private cleanupInterval: NodeJS.Timeout | null = null
|
||||
|
||||
constructor() {
|
||||
this.startPeriodicCleanup()
|
||||
}
|
||||
|
||||
private startPeriodicCleanup(): void {
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredEntries()
|
||||
},
|
||||
5 * 60 * 1000 // 5 minutes
|
||||
)
|
||||
// Don't keep Node process alive just for cache cleanup
|
||||
this.cleanupInterval.unref()
|
||||
}
|
||||
|
||||
private cleanupExpiredEntries(): void {
|
||||
const now = Date.now()
|
||||
const expiredKeys: string[] = []
|
||||
|
||||
this.cache.forEach((entry, key) => {
|
||||
if (entry.expiry <= now) {
|
||||
expiredKeys.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
expiredKeys.forEach((key) => this.cache.delete(key))
|
||||
|
||||
if (expiredKeys.length > 0) {
|
||||
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
|
||||
}
|
||||
}
|
||||
|
||||
private evictIfNeeded(): void {
|
||||
if (this.cache.size <= this.maxCacheSize) {
|
||||
return
|
||||
}
|
||||
|
||||
// Evict oldest entries (by insertion order - Map maintains order)
|
||||
const entriesToRemove = this.cache.size - this.maxCacheSize
|
||||
const keys = Array.from(this.cache.keys()).slice(0, entriesToRemove)
|
||||
keys.forEach((key) => this.cache.delete(key))
|
||||
|
||||
logger.debug(`Evicted ${entriesToRemove} cache entries`)
|
||||
}
|
||||
|
||||
async get(key: string): Promise<McpCacheEntry | null> {
|
||||
const entry = this.cache.get(key)
|
||||
const now = Date.now()
|
||||
|
||||
if (!entry || entry.expiry <= now) {
|
||||
if (entry) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Return copy to prevent caller from mutating cache
|
||||
return {
|
||||
tools: entry.tools,
|
||||
expiry: entry.expiry,
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
|
||||
const now = Date.now()
|
||||
const entry: McpCacheEntry = {
|
||||
tools,
|
||||
expiry: now + ttlMs,
|
||||
}
|
||||
|
||||
this.cache.set(key, entry)
|
||||
this.evictIfNeeded()
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
this.cache.clear()
|
||||
logger.info('Memory cache disposed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type Redis from 'ioredis'
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
|
||||
|
||||
const logger = createLogger('McpRedisCache')
|
||||
|
||||
const REDIS_KEY_PREFIX = 'mcp:tools:'
|
||||
|
||||
export class RedisMcpCache implements McpCacheStorageAdapter {
|
||||
constructor(private redis: Redis) {}
|
||||
|
||||
private getKey(key: string): string {
|
||||
return `${REDIS_KEY_PREFIX}${key}`
|
||||
}
|
||||
|
||||
async get(key: string): Promise<McpCacheEntry | null> {
|
||||
try {
|
||||
const redisKey = this.getKey(key)
|
||||
const data = await this.redis.get(redisKey)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as McpCacheEntry
|
||||
} catch {
|
||||
// Corrupted data - delete and treat as miss
|
||||
logger.warn('Corrupted cache entry, deleting:', redisKey)
|
||||
await this.redis.del(redisKey)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Redis cache get error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const entry: McpCacheEntry = {
|
||||
tools,
|
||||
expiry: now + ttlMs,
|
||||
}
|
||||
|
||||
await this.redis.set(this.getKey(key), JSON.stringify(entry), 'PX', ttlMs)
|
||||
} catch (error) {
|
||||
logger.error('Redis cache set error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.del(this.getKey(key))
|
||||
} catch (error) {
|
||||
logger.error('Redis cache delete error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
let cursor = '0'
|
||||
let deletedCount = 0
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${REDIS_KEY_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = nextCursor
|
||||
|
||||
if (keys.length > 0) {
|
||||
await this.redis.del(...keys)
|
||||
deletedCount += keys.length
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
logger.debug(`Cleared ${deletedCount} MCP cache entries from Redis`)
|
||||
} catch (error) {
|
||||
logger.error('Redis cache clear error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Redis client is managed externally, nothing to dispose
|
||||
logger.info('Redis cache adapter disposed')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user