import type { AdapterContext, ConnectorCall, ConnectorEvent, ConnectorProvider, ConnectorTool, } from "@rakazo/adapter-kit"; import { isLocalMcpHost } from "@rakazo/contracts"; import type { McpServer, PrismaClient } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { catalogToolPrefix } from "./approval-effect.js"; import { redactConnectorPayload, sanitizeConnectorError } from "./connector-safety.js"; import { CATALOG_EXECUTE, catalogEntries, DIRECT_TOOL_LIMIT, executeLazyCatalogControl, isLazyCatalogControlRoute, lazyCatalogTools, resolveCatalogCall, } from "./lazy-tool-catalog.js"; import type { McpOAuthBroker, OAuthMaterial } from "./mcp-oauth.js"; import { oauthMaterialSecrets } from "./mcp-oauth.js"; import { McpSession } from "./mcp-transport.js"; import type { RemoteTransportDependencies } from "./remote-mcp.js"; import type { EncryptedSecretStore } from "./secrets.js"; type SessionEntry = { session: McpSession; revision: number; material: OAuthMaterial }; type PendingSession = { revision: number; promise: Promise }; /** Runtime MCP connector. Authorization is re-checked against the bot assignment on every call. */ /** * Stale allowlist entries are filtered out below with no error. Discovery already has the * live tool list, so compare once and warn when string allowlist names are missing from the * server. Non-string JSON values are ignored for both the missing list and the ratio. */ export function allowlistDrift( allowedTools: unknown, offered: Array<{ name: string }>, ): { missing: string[]; offered: number; stringAllowedCount: number } { const names = new Set(offered.map((tool) => tool.name)); const allowed = Array.isArray(allowedTools) ? allowedTools : []; const stringAllowed = allowed.filter((name): name is string => typeof name === "string"); return { missing: stringAllowed.filter((name) => !names.has(name)), offered: names.size, stringAllowedCount: stringAllowed.length, }; } function reportAllowlistDrift( assignment: { allowAllTools: boolean; allowedTools: unknown; server: { slug: string } }, offered: Array<{ name: string }>, context: { spaceId: string; botId?: string }, ): void { if (assignment.allowAllTools) return; const drift = allowlistDrift(assignment.allowedTools, offered); if (drift.missing.length === 0) return; getLogger().warn( `mcp allowlist drift on ${assignment.server.slug}: ${drift.missing.length}/${drift.stringAllowedCount} allowed tools are not offered (server offers ${drift.offered})`, { spaceId: context.spaceId, botId: context.botId, // Cap the list: the point is to name the drift, not to print an allowlist. missing: drift.missing.slice(0, 10), }, ); } export class McpConnector implements ConnectorProvider { private readonly sessions = new Map(); private readonly connecting = new Map(); constructor( private readonly prisma: PrismaClient, private readonly secrets: EncryptedSecretStore, private readonly options: { stdioEnabled?: boolean; allowedCommands?: string[]; network?: RemoteTransportDependencies; } = {}, private readonly oauth?: McpOAuthBroker, ) {} describe() { return { id: "mcp", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { discover: true, oauth: true, secretsBrokered: true }, }; } async discoverTools(context: AdapterContext): Promise { const tools = await this.authorizedTools(context); if (tools.length <= DIRECT_TOOL_LIMIT) return tools; // The catalog wrappers must not be named `mcp_*`: Anthropic rejects Claude Code OAuth // requests that carry a tool whose name starts with "mcp_" (single underscore) with a // misleading "You're out of extra usage" 400, on every model. Direct MCP tools are // `mcp__server__tool` (double underscore) and are accepted; keep the wrappers off "mcp_". return lazyCatalogTools(catalogToolPrefix("mcp"), "mcp", "MCP", catalogEntries(tools)); } async resolveCall( call: ConnectorCall, context: AdapterContext, ): Promise<{ call: ConnectorCall; tool: ConnectorTool } | undefined> { // Wrappers have no resourceId; real tools always do. if (call.route?.resourceId || call.route?.toolName !== CATALOG_EXECUTE) return undefined; return resolveCatalogCall(call, catalogEntries(await this.authorizedTools(context))); } private async authorizedTools(context: AdapterContext): Promise { if (!context.botId) return []; const assignments = await this.prisma.botMcpServer.findMany({ where: { botId: context.botId, spaceId: context.spaceId, userId: context.userId, server: { enabled: true }, }, include: { server: true }, }); const groups = await Promise.all( assignments.map(async (assignment): Promise => { try { const session = await this.sessionFor(assignment.server, context); const listed = await session.listTools({ signal: context.signal }); reportAllowlistDrift(assignment, listed.tools, context); return listed.tools .filter( (tool) => assignment.allowAllTools || (assignment.allowedTools as unknown[]).includes(tool.name), ) .map((tool) => ({ name: `mcp__${assignment.server.slug}__${tool.name}`, description: tool.description ?? tool.name, inputSchema: tool.inputSchema as Record, route: { connectorId: "mcp", resourceId: assignment.serverId, resourceRevision: assignment.server.revision, toolName: tool.name, catalogGroup: assignment.server.slug, }, })); } catch (error) { // A single unavailable server must not hide tools from other connectors. getLogger().error( `mcp discovery failed for server ${assignment.server.slug}:`, sanitizeConnectorError(error), ); await this.evict(this.sessionKey(assignment.server, context)); return []; } }), ); return groups.flat(); } async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable { if (call.route?.connectorId !== "mcp") { yield { type: "error", message: `MCP route required for ${call.tool}` }; return; } if (isLazyCatalogControlRoute(call.route)) { try { yield* executeLazyCatalogControl( call, catalogEntries(await this.authorizedTools(context)), (resolved) => this.execute(resolved, context), ); } catch (error) { yield { type: "error", message: sanitizeConnectorError(error) }; } return; } if (!call.route.resourceId) { yield { type: "error", message: `MCP route required for ${call.tool}` }; return; } if (!context.botId) { yield { type: "error", message: "MCP tools require a bot context" }; return; } const assignment = await this.prisma.botMcpServer.findFirst({ where: { botId: context.botId, serverId: call.route.resourceId, spaceId: context.spaceId, userId: context.userId, server: { enabled: true }, }, include: { server: true }, }); if ( !assignment || (!assignment.allowAllTools && !(assignment.allowedTools as unknown[]).includes(call.route.toolName)) ) { yield { type: "error", message: "MCP tool is not assigned to this bot" }; return; } // Capture material before callTool so a concurrent eviction/replace cannot // drop this call's OAuth secrets from model-visible redaction. Recompute via // oauthMaterialSecrets(material) so in-place token refresh stays covered. let material: OAuthMaterial | undefined; try { const session = await this.sessionFor(assignment.server, context); material = this.sessions.get(this.sessionKey(assignment.server, context))?.material; const result = await session.callTool(call.route.toolName, call.args, { signal: context.signal, }); const secrets = material ? oauthMaterialSecrets(material) : []; yield { type: "result", data: redactConnectorPayload(result, secrets) }; } catch (error) { // A thrown call means the transport or auth broke; drop the session so the next call reconnects. const secrets = material ? oauthMaterialSecrets(material) : []; await this.evict(this.sessionKey(assignment.server, context)); yield { type: "error", message: sanitizeConnectorError(error, secrets) }; } } async close(): Promise { await Promise.allSettled([...this.connecting.values()].map(({ promise }) => promise)); await Promise.all([...this.sessions.values()].map(({ session }) => session.close())); this.sessions.clear(); this.connecting.clear(); } private sessionKey(server: McpServer, context: AdapterContext): string { // Identity headers are applied once, at connect time, so a session is only // valid for the identity it connected as. The key has to carry that identity. return `${server.id} ${context.spaceId} ${context.userId}`; } private async evict(sessionKey: string): Promise { const entry = this.sessions.get(sessionKey); if (!entry) return; this.sessions.delete(sessionKey); await entry.session.close(); } private async sessionFor(server: McpServer, context: AdapterContext): Promise { const sessionKey = this.sessionKey(server, context); const existing = this.sessions.get(sessionKey); if (existing && existing.revision === server.revision) return existing.session; const pending = this.connecting.get(sessionKey); if (pending?.revision === server.revision) return pending.promise; if (pending) { await pending.promise.catch(() => undefined); await this.evict(sessionKey); return this.sessionFor(server, context); } if (existing) await this.evict(sessionKey); const promise = this.connectSession(server, context).then(({ session, material }) => { this.sessions.set(sessionKey, { session, revision: server.revision, material }); return session; }); this.connecting.set(sessionKey, { revision: server.revision, promise }); try { return await promise; } finally { if (this.connecting.get(sessionKey)?.promise === promise) this.connecting.delete(sessionKey); } } private async connectSession( server: McpServer, context: AdapterContext, ): Promise<{ session: McpSession; material: OAuthMaterial }> { const session = new McpSession({ name: `rakazo-${server.slug}` }); try { const secret = server.secretId ? await this.prisma.secret.findFirst({ where: { id: server.secretId, spaceId: context.spaceId, userId: context.userId, }, }) : null; const material = secret ? (JSON.parse(this.secrets.load(secret.ciphertext, secret.id)) as OAuthMaterial) : {}; const loaded = { material, ...(secret ? { secretId: secret.id } : {}) }; const args = Array.isArray(server.args) ? server.args.map(String) : []; const env = { ...(material.env ?? {}) }; if (server.transport === "stdio") { if (!this.options.stdioEnabled) throw new Error("MCP stdio is disabled"); await session.connectStdio({ command: String(server.command ?? ""), args, env, allowedCommands: this.options.allowedCommands ?? [], signal: context.signal, }); } else { if (!server.endpoint) throw new Error("MCP endpoint is required"); const endpoint = new URL(server.endpoint); const localHttp = endpoint.protocol === "http:" && isLocalMcpHost(endpoint.hostname); const authProvider = !localHttp && this.oauth ? await this.oauth.providerFor(server, context, loaded) : undefined; const staticToken = material.secret ? material.secret.startsWith("Bearer ") ? material.secret : `Bearer ${material.secret}` : undefined; const headers = { ...(material.headers ?? {}), ...(staticToken ? { Authorization: staticToken } : {}), }; await session.connectRemote({ url: server.endpoint, urlPolicy: { allowHttpLocalhost: localHttp, allowLocalHttpCredentials: localHttp }, transport: server.transport === "sse" ? "sse" : "streamable-http", allowLegacySse: server.transport === "sse", headerPolicy: { headers }, fallbackToSse: false, authProvider, network: this.options.network, signal: context.signal, }); } return { session, material }; } catch (error) { await session.close().catch(() => undefined); throw error; } } }