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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+14
View File
@@ -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
}
+53
View File
@@ -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
}
}
+2
View File
@@ -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)
})
})
})
+103
View File
@@ -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')
}
}
+96
View File
@@ -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')
}
}