/** * Helper to define MCP servers with automatic metadata extraction * * Stores both the Claude SDK server instance AND raw tool definitions * so the same source can be used for Claude Code (in-process) and * Open Code (remote HTTP MCP via @modelcontextprotocol/sdk). * * Claude Code: createSdkMcpServer() → in-process MCP server * Open Code: createRemoteMcpServer() → HTTP MCP server (same process, same handlers) */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { z } from "zod"; import { projectContextService } from '../project-context'; import { validateMcpOutput } from '../output-validator'; import type { McpExecutionContext } from '$backend/engine/types'; import type { EngineType } from '$shared/types/unified'; /** * Infer argument types from Zod schema */ type InferArgs>> = { [K in keyof TSchema]: z.infer; }; /** * Content types for MCP responses */ type MCPContent = | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; /** * Tool handler type - infers args type from schema */ type ToolHandler> | undefined> = TSchema extends Record> ? (args: InferArgs) => Promise<{ content: Array; isError?: boolean }> : () => Promise<{ content: Array; isError?: boolean }>; /** * Raw tool definition — schema, description, and handler. * Single source of truth used by: * - Claude Code: in-process via createSdkMcpServer * - Open Code: remote HTTP MCP via createRemoteMcpServer (in-process handlers) */ export interface RawToolDef { description: string; schema: Record>; handler: (args: any) => Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }>; } /** * Server instance with metadata */ interface ServerWithMeta< TName extends string, TToolNames extends readonly string[] > { meta: { readonly name: TName; /** Human-facing title shown in Settings → MCP and the Chat tool header. */ readonly title: string; /** Short description of what this server provides. */ readonly description: string; /** Semantic version, e.g. `1.0.0`. */ readonly version: string; readonly tools: TToolNames; /** Raw tool definitions (schema + description) for reuse by other transports */ readonly toolDefs: Record; }; } /** * Define an MCP server with automatic metadata extraction and full type inference */ export function defineServer< const TConfig extends { name: string; title: string; description: string; version: string; tools: Record; } >( config: TConfig & { tools: { [K in keyof TConfig['tools']]: TConfig['tools'][K] extends { schema: infer S extends Record> } ? { description: string; schema: S; handler: ToolHandler } : { description: string; handler: ToolHandler } } } ): ServerWithMeta> { // Extract tool names const toolNames = Object.keys(config.tools) as Array; // Build raw tool definitions (engine-agnostic) and store for reuse. const toolDefs: Record = {}; toolNames.forEach((toolName) => { const toolDef = config.tools[toolName] as any; const schema = toolDef.schema || {}; toolDefs[toolName as string] = { description: toolDef.description, schema, handler: toolDef.handler, }; }); // Return metadata + raw tool defs only. The SDK-shaped in-process server // instances (Claude Code) are built lazily in mcp/internal/config.ts, so the // Claude Agent SDK is not imported unless the Claude engine actually streams. return { meta: { name: config.name, title: config.title, description: config.description, version: config.version, tools: toolNames as any, toolDefs, } }; } /** * Build server registries from array of servers */ export function buildServerRegistries< T extends readonly ServerWithMeta[] >(servers: T) { const metadata = {} as any; for (const server of servers) { metadata[server.meta.name] = server.meta; } return { metadata: metadata as { [K in T[number]['meta']['name']]: Extract['meta'] } }; } // ============================================================================ // Remote MCP Server for Open Code (HTTP transport, in-process execution) // ============================================================================ /** * Who the bridge is talking to. * * `context` is the calling stream, named by the query string its engine's * config builder put on the bridge URL — the HTTP equivalent of the * `McpExecutionContext` the in-process engines bind directly. `engine` alone is * the fallback for a config that cannot name the caller (Open Code's pooled * server), and is resolved per call rather than pinned, because the stream it * refers to changes while the MCP session stays open. */ export interface RemoteMcpCaller { context?: McpExecutionContext; engine?: EngineType; } /** * Create a McpServer instance (from @modelcontextprotocol/sdk) with tools registered * from the same RawToolDef definitions used by Claude Code. * * This is the Open Code equivalent of createSdkMcpServer() for Claude Code. * Handlers execute directly in-process — no subprocess, no bridge. * * @param servers - Server definitions from defineServer() * @param enabledConfig - Which servers/tools are enabled (from mcpServersConfig) * @param caller - Identity of the engine/stream on the other end of the bridge */ export function createRemoteMcpServer( servers: readonly ServerWithMeta[], enabledConfig: Record, caller?: RemoteMcpCaller ): McpServer { const mcpServer = new McpServer({ name: 'clopen-mcp', version: '1.0.0', }); for (const srv of servers) { const config = enabledConfig[srv.meta.name]; if (!config?.enabled) continue; for (const toolName of config.tools) { const def = srv.meta.toolDefs[toolName as string]; if (!def) continue; mcpServer.registerTool(toolName as string, { description: def.description, // MCP SDK 1.29 typed `inputSchema` as `ZodRawShapeCompat` (Record of // z3 | z4-core schemas). Our shapes are zod v4 instances whose // nominal type doesn't satisfy that union, but they are accepted // at runtime. Cast to bridge the mismatch. inputSchema: def.schema as Record, }, async (args: Record) => { const run = async () => { // Fast-fail when the owning chat stream has already been // cancelled — the handler never runs. Without this, the // engine subprocess dies on cancel but in-flight HTTP-MCP // tool calls continue to drive puppeteer ops, surfacing as // "preview keeps moving by itself" after interrupt. const signal = projectContextService.getCurrentSignal(); if (signal?.aborted) { return { content: [{ type: 'text' as const, text: `Tool ${String(toolName)} was cancelled because the chat stream was interrupted.` }], isError: true, } as any; } const result = await def.handler(args) as any; if (result?.content) { result.content = validateMcpOutput(result.content, toolName as string); } return result; }; // Bind the caller for the duration of the handler, exactly as the // in-process path does. Without it every handler that asks "which // project is this?" answers with the most recently started stream // anywhere in the app. const bound = caller?.context ?? (caller?.engine ? projectContextService.getContextForEngine(caller.engine) : undefined); return bound ? projectContextService.runWithContextAsync(bound, run) : run(); }); } } return mcpServer; }