Files
simstudioai--sim/apps/sim/lib/mcp/service.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

835 lines
27 KiB
TypeScript

import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { and, eq, isNull } from 'drizzle-orm'
import { isTest } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { McpClient } from '@/lib/mcp/client'
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
import {
isMcpDomainAllowed,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import {
getOrCreateOauthRow,
loadPreregisteredClient,
SimMcpOauthProvider,
withMcpOauthRefreshLock,
} from '@/lib/mcp/oauth'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import {
createMcpCacheAdapter,
getMcpCacheType,
type McpCacheStorageAdapter,
} from '@/lib/mcp/storage'
import {
McpConnectionError,
McpOauthAuthorizationRequiredError,
type McpServerConfig,
type McpServerStatusConfig,
type McpServerSummary,
type McpTool,
type McpToolCall,
type McpToolResult,
type McpTransport,
} from '@/lib/mcp/types'
import { MCP_CLIENT_CONSTANTS, MCP_CONSTANTS } from '@/lib/mcp/utils'
const logger = createLogger('McpService')
function serverCacheKey(workspaceId: string, serverId: string): string {
return `workspace:${workspaceId}:server:${serverId}`
}
function failureCacheKey(workspaceId: string, serverId: string): string {
return `workspace:${workspaceId}:server:${serverId}:failure`
}
const FAILURE_CACHE_SENTINEL: McpTool[] = []
type DiscoveryOutcome =
| { kind: 'cached'; tools: McpTool[] }
| {
kind: 'fetched'
tools: McpTool[]
resolvedConfig: McpServerConfig
resolvedIP: string | null
}
| { kind: 'oauth-pending' }
| { kind: 'unhealthy' }
// originalError preserves the type so markServerUnhealthy's instanceof
// exemption survives the getErrorMessage call.
| { kind: 'error'; message: string; originalError: unknown }
class McpService {
private cacheAdapter: McpCacheStorageAdapter
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT
private unsubscribeConnectionManager?: () => void
// Keyed on (workspaceId, serverId, userId) — OAuth-scoped tokens vary per user.
private inflightServerDiscovery = new Map<string, Promise<McpTool[]>>()
constructor() {
this.cacheAdapter = createMcpCacheAdapter()
logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
if (mcpConnectionManager) {
this.unsubscribeConnectionManager = mcpConnectionManager.subscribe((event) => {
this.cacheAdapter
.delete(serverCacheKey(event.workspaceId, event.serverId))
.catch((err) =>
logger.warn(`Failed to invalidate cache for ${event.serverName} on listChanged:`, err)
)
this.cacheAdapter
.delete(failureCacheKey(event.workspaceId, event.serverId))
.catch((err) =>
logger.warn(
`Failed to invalidate failure cache for ${event.serverName} on listChanged:`,
err
)
)
})
}
}
dispose(): void {
this.unsubscribeConnectionManager?.()
this.cacheAdapter.dispose()
logger.info('MCP Service disposed')
}
private async resolveConfigEnvVars(
config: McpServerConfig,
userId: string,
workspaceId?: string
): Promise<{ config: McpServerConfig; resolvedIP: string | null }> {
const { config: resolvedConfig } = await resolveMcpConfigEnvVars(config, userId, workspaceId, {
strict: true,
})
validateMcpDomain(resolvedConfig.url)
const resolvedIP = await validateMcpServerSsrf(resolvedConfig.url)
return { config: resolvedConfig, resolvedIP }
}
private async getServerConfig(
serverId: string,
workspaceId: string
): Promise<McpServerConfig | null> {
const [server] = await db
.select()
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
eq(mcpServers.enabled, true),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!server) {
return null
}
if (!isMcpDomainAllowed(server.url || undefined)) {
return null
}
return {
id: server.id,
name: server.name,
description: server.description || undefined,
transport: 'streamable-http' as const,
url: server.url || undefined,
authType: (server.authType as McpServerConfig['authType']) ?? 'headers',
workspaceId: server.workspaceId,
headers: (server.headers as Record<string, string>) || {},
timeout: server.timeout || 30000,
retries: server.retries || 3,
enabled: server.enabled,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
}
}
private async getWorkspaceServers(workspaceId: string): Promise<McpServerConfig[]> {
const whereConditions = [
eq(mcpServers.workspaceId, workspaceId),
eq(mcpServers.enabled, true),
isNull(mcpServers.deletedAt),
]
const servers = await db
.select()
.from(mcpServers)
.where(and(...whereConditions))
return servers
.map((server) => ({
id: server.id,
name: server.name,
description: server.description || undefined,
transport: server.transport as McpTransport,
url: server.url || undefined,
authType: (server.authType as McpServerConfig['authType']) ?? 'headers',
workspaceId: server.workspaceId,
headers: (server.headers as Record<string, string>) || {},
timeout: server.timeout || 30000,
retries: server.retries || 3,
enabled: server.enabled,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
}))
.filter((config) => isMcpDomainAllowed(config.url))
}
private async createClient(
config: McpServerConfig,
resolvedIP: string | null,
userId?: string
): Promise<McpClient> {
const securityPolicy = {
requireConsent: true,
auditLevel: 'basic' as const,
maxToolExecutionsPerHour: 1000,
allowedOrigins: config.url ? [new URL(config.url).origin] : undefined,
}
if (config.authType !== 'oauth') {
const client = new McpClient({
config,
securityPolicy,
resolvedIP: resolvedIP ?? undefined,
})
await client.connect()
return client
}
if (!userId || !config.workspaceId) {
throw new Error('OAuth MCP server requires both userId and workspaceId')
}
const workspaceId = config.workspaceId
// Load the row inside the refresh lock so concurrent callers observe tokens
// written by a predecessor refresh, rather than a stale snapshot. Without
// this, the second caller's provider would hold a rotated-out refresh token
// and the SDK would trip `invalid_grant`. The lock is keyed on serverId
// since the row is per-server.
return withMcpOauthRefreshLock(config.id, async () => {
const row = await getOrCreateOauthRow({
mcpServerId: config.id,
userId,
workspaceId,
})
if (!row.tokens) {
throw new McpOauthAuthorizationRequiredError(config.id, config.name)
}
const preregistered = await loadPreregisteredClient(config.id)
const authProvider = new SimMcpOauthProvider({ row, preregistered })
const client = new McpClient({
config,
securityPolicy,
authProvider,
resolvedIP: resolvedIP ?? undefined,
})
await client.connect()
return client
})
}
/**
* Execute a tool on a specific server with retry logic for session errors.
* Retries once on session-related errors (400, 404, session ID issues).
*/
async executeTool(
userId: string,
serverId: string,
toolCall: McpToolCall,
workspaceId: string,
extraHeaders?: Record<string, string>
): Promise<McpToolResult> {
const requestId = generateRequestId()
const maxRetries = 2
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
logger.info(
`[${requestId}] Executing MCP tool ${toolCall.name} on server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`
)
const config = await this.getServerConfig(serverId, workspaceId)
if (!config) {
throw new Error(`Server ${serverId} not found or not accessible`)
}
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
}
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
try {
const result = await client.callTool(toolCall)
logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`)
return result
} finally {
await client.disconnect()
}
} catch (error) {
if (this.isSessionError(error) && attempt < maxRetries - 1) {
logger.warn(
`[${requestId}] Session error executing tool ${toolCall.name}, retrying (attempt ${attempt + 1}):`,
error
)
await sleep(100)
continue
}
throw error
}
}
throw new Error(`Failed to execute tool ${toolCall.name} after ${maxRetries} attempts`)
}
/** MCP spec: server returns 404 for unknown session id, 400 for malformed header. */
private isSessionError(error: unknown): boolean {
if (error instanceof StreamableHTTPError) {
return error.code === 404 || error.code === 400
}
return false
}
private async updateServerStatus(
serverId: string,
workspaceId: string,
success: boolean,
error?: string,
toolCount?: number
): Promise<void> {
try {
const [currentServer] = await db
.select({ statusConfig: mcpServers.statusConfig })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const storedConfig = currentServer?.statusConfig as Partial<McpServerStatusConfig> | null
const currentConfig: McpServerStatusConfig = {
consecutiveFailures:
typeof storedConfig?.consecutiveFailures === 'number'
? storedConfig.consecutiveFailures
: 0,
lastSuccessfulDiscovery: storedConfig?.lastSuccessfulDiscovery ?? null,
}
const now = new Date()
if (success) {
await db
.update(mcpServers)
.set({
connectionStatus: 'connected',
lastConnected: now,
lastError: null,
toolCount: toolCount ?? 0,
lastToolsRefresh: now,
statusConfig: {
consecutiveFailures: 0,
lastSuccessfulDiscovery: now.toISOString(),
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
} else {
const newFailures = currentConfig.consecutiveFailures + 1
const isErrorState = newFailures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES
await db
.update(mcpServers)
.set({
connectionStatus: isErrorState ? 'error' : 'disconnected',
lastError: error || 'Unknown error',
statusConfig: {
consecutiveFailures: newFailures,
lastSuccessfulDiscovery: currentConfig.lastSuccessfulDiscovery,
},
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
if (isErrorState) {
logger.warn(
`Server ${serverId} marked as error after ${newFailures} consecutive failures`
)
}
}
} catch (err) {
logger.error(`Failed to update server status for ${serverId}:`, err)
}
}
/**
* Negative-cache a discovery failure. OAuth-required errors are exempt so
* reconnects retry immediately.
*/
private async markServerUnhealthy(
workspaceId: string,
serverId: string,
error: unknown
): Promise<void> {
if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) {
return
}
try {
await this.cacheAdapter.set(
failureCacheKey(workspaceId, serverId),
FAILURE_CACHE_SENTINEL,
MCP_CLIENT_CONSTANTS.FAILURE_CACHE_TTL_MS
)
} catch (err) {
logger.warn(`Failed to write failure cache for server ${serverId}:`, err)
}
}
private async isServerUnhealthy(workspaceId: string, serverId: string): Promise<boolean> {
try {
const entry = await this.cacheAdapter.get(failureCacheKey(workspaceId, serverId))
return entry !== null
} catch {
return false
}
}
private async clearServerFailure(workspaceId: string, serverId: string): Promise<void> {
try {
await this.cacheAdapter.delete(failureCacheKey(workspaceId, serverId))
} catch (err) {
logger.warn(`Failed to clear failure cache for server ${serverId}:`, err)
}
}
async discoverTools(
userId: string,
workspaceId: string,
forceRefresh = false
): Promise<McpTool[]> {
const requestId = generateRequestId()
try {
logger.info(`[${requestId}] Discovering MCP tools for workspace ${workspaceId}`)
const servers = await this.getWorkspaceServers(workspaceId)
if (servers.length === 0) {
logger.info(`[${requestId}] No servers found for workspace ${workspaceId}`)
return []
}
const outcomes = await Promise.all(
servers.map(async (config): Promise<DiscoveryOutcome> => {
const cacheKey = serverCacheKey(workspaceId, config.id)
if (!forceRefresh) {
try {
const cached = await this.cacheAdapter.get(cacheKey)
if (cached) return { kind: 'cached', tools: cached.tools }
} catch (error) {
logger.warn(
`[${requestId}] Cache read failed for ${config.name}, proceeding with discovery:`,
error
)
}
if (await this.isServerUnhealthy(workspaceId, config.id)) {
logger.info(
`[${requestId}] Skipping recently-failed server ${config.name} (negative-cache hit)`
)
return { kind: 'unhealthy' }
}
}
try {
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
try {
const tools = await client.listTools()
logger.debug(
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
)
return { kind: 'fetched', tools, resolvedConfig, resolvedIP }
} finally {
await client.disconnect()
}
} catch (error) {
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof UnauthorizedError
) {
return { kind: 'oauth-pending' }
}
return {
kind: 'error',
message: getErrorMessage(error, 'Unknown error'),
originalError: error,
}
}
})
)
const allTools: McpTool[] = []
const cacheWrites: Promise<unknown>[] = []
const deferredSideEffects: Promise<unknown>[] = []
const liveConnections: Array<{
resolvedConfig: McpServerConfig
resolvedIP: string | null
}> = []
let cachedCount = 0
let fetchedCount = 0
let failedCount = 0
outcomes.forEach((outcome, index) => {
const server = servers[index]
if (outcome.kind === 'cached') {
cachedCount++
allTools.push(...outcome.tools)
return
}
if (outcome.kind === 'fetched') {
fetchedCount++
allTools.push(...outcome.tools)
deferredSideEffects.push(
this.updateServerStatus(server.id, workspaceId, true, undefined, outcome.tools.length)
)
cacheWrites.push(
this.cacheAdapter
.set(serverCacheKey(workspaceId, server.id), outcome.tools, this.cacheTimeout)
.catch((err) =>
logger.warn(`[${requestId}] Cache write failed for ${server.name}:`, err)
)
)
deferredSideEffects.push(this.clearServerFailure(workspaceId, server.id))
liveConnections.push({
resolvedConfig: outcome.resolvedConfig,
resolvedIP: outcome.resolvedIP,
})
return
}
if (outcome.kind === 'oauth-pending') {
// Mark disconnected so the UI surfaces the re-auth button.
logger.info(`[${requestId}] Skipping server ${server.name}: OAuth authorization pending`)
deferredSideEffects.push(
db
.update(mcpServers)
.set({
connectionStatus: 'disconnected',
lastError: null,
updatedAt: new Date(),
})
.where(eq(mcpServers.id, server.id))
.then(() => undefined)
.catch((err) => {
logger.warn(`[${requestId}] Failed to mark server ${server.id} disconnected:`, err)
})
)
return
}
if (outcome.kind === 'unhealthy') {
// Status was persisted on the original failure; nothing to re-write.
failedCount++
return
}
failedCount++
logger.warn(
`[${requestId}] Failed to discover tools from server ${server.name}: ${outcome.message}`
)
deferredSideEffects.push(
this.updateServerStatus(server.id, workspaceId, false, outcome.message),
this.markServerUnhealthy(workspaceId, server.id, outcome.originalError),
this.cacheAdapter
.delete(serverCacheKey(workspaceId, server.id))
.catch((err) =>
logger.warn(`[${requestId}] Cache delete failed for ${server.name}:`, err)
)
)
})
// Await cache writes so a follow-up discoverTools sees consistent state.
await Promise.allSettled(cacheWrites)
// Each deferred side-effect self-logs failures, so we just mark the
// promises as handled to avoid unhandled-rejection warnings.
for (const p of deferredSideEffects) p.catch(() => {})
if (mcpConnectionManager) {
for (const conn of liveConnections) {
mcpConnectionManager
.connect(conn.resolvedConfig, userId, workspaceId, conn.resolvedIP)
.catch((err) => {
logger.warn(
`[${requestId}] Persistent connection failed for ${conn.resolvedConfig.name}:`,
err
)
})
}
}
logger.info(
`[${requestId}] Discovered ${allTools.length} tools from ${servers.length} servers (cached=${cachedCount} fetched=${fetchedCount} failed=${failedCount})`
)
return allTools
} catch (error) {
logger.error(`[${requestId}] Failed to discover MCP tools for user ${userId}:`, error)
throw error
}
}
/**
* Discover tools from one server. Cache-aside by default; pass
* `forceRefresh: true` from explicit-refresh paths (refresh button, OAuth
* callback) to bypass both positive and negative caches. Concurrent callers
* for the same `(workspaceId, serverId, userId, forceRefresh)` share one
* upstream request.
*/
async discoverServerTools(
userId: string,
serverId: string,
workspaceId: string,
forceRefresh = false
): Promise<McpTool[]> {
const inflightKey = `${workspaceId}:${serverId}:${userId}:${forceRefresh ? 'force' : 'cache'}`
const existing = this.inflightServerDiscovery.get(inflightKey)
if (existing) return existing
const promise = this.discoverServerToolsImpl(
userId,
serverId,
workspaceId,
forceRefresh
).finally(() => {
this.inflightServerDiscovery.delete(inflightKey)
})
this.inflightServerDiscovery.set(inflightKey, promise)
return promise
}
private async discoverServerToolsImpl(
userId: string,
serverId: string,
workspaceId: string,
forceRefresh: boolean
): Promise<McpTool[]> {
const requestId = generateRequestId()
const maxRetries = 2
if (!forceRefresh) {
try {
const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId))
if (cached) {
logger.debug(`[${requestId}] Cache hit for server ${serverId}`)
return cached.tools
}
} catch (error) {
logger.warn(`[${requestId}] Cache read failed for server ${serverId}:`, error)
}
if (await this.isServerUnhealthy(workspaceId, serverId)) {
logger.info(`[${requestId}] Skipping recently-failed server ${serverId} (negative-cache)`)
throw new McpConnectionError(
'Server recently failed and is in cooldown — try again shortly.',
serverId
)
}
}
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
logger.info(
`[${requestId}] Discovering tools from server ${serverId} for user ${userId}${attempt > 0 ? ` (attempt ${attempt + 1})` : ''}`
)
const config = await this.getServerConfig(serverId, workspaceId)
if (!config) {
throw new Error(`Server ${serverId} not found or not accessible`)
}
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
try {
const tools = await client.listTools()
logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`)
await Promise.allSettled([
this.cacheAdapter
.set(serverCacheKey(workspaceId, serverId), tools, this.cacheTimeout)
.catch((err) =>
logger.warn(`[${requestId}] Cache write failed for ${config.name}:`, err)
),
this.clearServerFailure(workspaceId, serverId),
this.updateServerStatus(serverId, workspaceId, true, undefined, tools.length),
])
return tools
} finally {
await client.disconnect()
}
} catch (error) {
if (this.isSessionError(error) && attempt < maxRetries - 1) {
logger.warn(
`[${requestId}] Session error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1}):`,
error
)
await sleep(100)
continue
}
// Drop positive cache so a follow-up doesn't return stale tools.
await Promise.allSettled([
this.cacheAdapter
.delete(serverCacheKey(workspaceId, serverId))
.catch((err) =>
logger.warn(`[${requestId}] Cache delete failed for ${serverId}:`, err)
),
this.markServerUnhealthy(workspaceId, serverId, error),
])
throw error
}
}
throw new Error(`Failed to discover tools from server ${serverId} after ${maxRetries} attempts`)
}
async getServerSummaries(userId: string, workspaceId: string): Promise<McpServerSummary[]> {
const requestId = generateRequestId()
try {
logger.info(`[${requestId}] Getting server summaries for workspace ${workspaceId}`)
const servers = await this.getWorkspaceServers(workspaceId)
const summaries: McpServerSummary[] = []
for (const config of servers) {
try {
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
config,
userId,
workspaceId
)
const client = await this.createClient(resolvedConfig, resolvedIP, userId)
const tools = await client.listTools()
await client.disconnect()
summaries.push({
id: config.id,
name: config.name,
url: config.url,
transport: config.transport,
status: 'connected',
toolCount: tools.length,
lastSeen: new Date(),
error: undefined,
})
} catch (error) {
if (
error instanceof McpOauthAuthorizationRequiredError ||
error instanceof UnauthorizedError
) {
summaries.push({
id: config.id,
name: config.name,
url: config.url,
transport: config.transport,
status: 'disconnected',
toolCount: 0,
lastSeen: undefined,
error: undefined,
})
continue
}
summaries.push({
id: config.id,
name: config.name,
url: config.url,
transport: config.transport,
status: 'error',
toolCount: 0,
lastSeen: undefined,
error: getErrorMessage(error, 'Connection failed'),
})
}
}
return summaries
} catch (error) {
logger.error(`[${requestId}] Failed to get server summaries for user ${userId}:`, error)
throw error
}
}
async clearCache(workspaceId?: string): Promise<void> {
try {
if (workspaceId) {
// No enabled/deletedAt filter so disabled and soft-deleted rows are
// cleared too. Hard-deleted rows are gone from the table; their keys
// expire via TTL.
const rows = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(eq(mcpServers.workspaceId, workspaceId))
await Promise.allSettled(
rows.flatMap((r) => [
this.cacheAdapter.delete(serverCacheKey(workspaceId, r.id)),
this.cacheAdapter.delete(failureCacheKey(workspaceId, r.id)),
])
)
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId} (${rows.length} servers)`)
} else {
await this.cacheAdapter.clear()
logger.debug('Cleared all MCP tool cache')
}
} catch (error) {
logger.warn('Failed to clear cache:', error)
}
}
}
export const mcpService = new McpService()
/**
* Setup process signal handlers for graceful shutdown
*/
export function setupMcpServiceCleanup() {
if (isTest) {
return
}
const cleanup = () => {
mcpService.dispose()
}
process.on('SIGTERM', cleanup)
process.on('SIGINT', cleanup)
return () => {
process.removeListener('SIGTERM', cleanup)
process.removeListener('SIGINT', cleanup)
}
}