/** * Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry * under deterministic server-qualified public names, and handles re-sync when * the server's tool list changes. * * Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool * has the stable identity `(serverName, rawName)`; the model-facing public name * is `mcp____`, normalized to the DeepSeek function-name * constraints. The raw name is only ever sent on the wire (`tools/call`); the * public name is never parsed to recover it. * * @module */ import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { Context } from 'cordis' import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' /** Resolved options relevant to tool bridging. */ export interface ToolBridgeOptions { serverName: string toolCallTimeoutMs: number } /** State for one sync generation: the current set of disposers keyed by public name. */ export type ToolDisposers = Map void> /** * DeepSeek function-name contract: at most 64 characters. Wire-protocol * constant, not configuration. */ const MAX_PUBLIC_NAME_LENGTH = 64 /** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g /** Hex chars of the SHA-256 identity hash appended on lossy normalization. */ const HASH_LENGTH = 12 /** * Derive the model-facing public name for one MCP tool. * * Deterministic pure function of `(serverName, rawName)`: the clean case is * `mcp____` verbatim. When character replacement or * truncation to the DeepSeek function-name contract (64 chars, * `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the * identity is appended so distinct MCP identities never collapse into the * same public name. * * @param serverName - Stable local namespace from plugin config. * @param rawName - The MCP server's own tool name. * @returns The globally unique, model-facing ToolRegistry name. */ export function publicToolName(serverName: string, rawName: string): string { const joined = `mcp__${serverName}__${rawName}` const normalized = joined.replace(INVALID_NAME_CHARS, '_') if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH) return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}` } /** * Sync the MCP server's tool list into the harness ToolRegistry. * * Two phases keep the swap safe: * * 1. Fetch: drain `client.listTools()` pagination and build the full next * generation of `ToolDefinition`s under public names. Any failure here * (network error, duplicate raw name in the server's list) rejects and * leaves the previous generation registered untouched. * 2. Swap: dispose the previous generation, register the new one. A registry * conflict here can only mean a foreign registration squats on this * server's `mcp____` namespace — the partial generation is * rolled back (zero tools from this server), the error is logged, and an * empty map is returned. * * @param client - Connected MCP Client instance used to list and call tools. * @param ctx - Cordis context providing the `tools` service for registration. * @param opts - Bridge options: server namespace and per-call timeout. * @param previous - Disposer map from the prior sync generation; disposed * during the swap phase (only after the fetch phase succeeded). * @returns A map of registered public tool names to their unregister * disposers — the exact set of live registrations owned by this server. */ export async function syncTools( client: Client, ctx: Context, opts: ToolBridgeOptions, previous: ToolDisposers, ): Promise { // Phase 1: fetch and build the next generation without touching the registry. const definitions = new Map() let cursor: string | undefined do { const response = await client.listTools(cursor ? { cursor } : undefined) for (const tool of response.tools) { const publicName = publicToolName(opts.serverName, tool.name) if (definitions.has(publicName)) { throw new Error( `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`, ) } definitions.set(publicName, { name: publicName, description: tool.description ?? '', parameters: tool.inputSchema, execute: createExecutor(client, tool.name, opts), }) } cursor = response.nextCursor } while (cursor) // Phase 2: swap generations. for (const dispose of previous.values()) dispose() const disposers: ToolDisposers = new Map() try { for (const [publicName, definition] of definitions) { disposers.set(publicName, ctx.tools.register(definition)) } } catch (error) { // A conflict on an `mcp____`-qualified name means a foreign // registration occupies this server's namespace. Roll back so the model // sees either the full generation or none of it — never a partial set. for (const dispose of disposers.values()) dispose() ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`) return new Map() } return disposers } /** * The shape we read from each MCP content block. Intentionally looser than the * SDK's `ContentBlock` type: we're at a network trust boundary (data arrives * from an external MCP server process via JSON-RPC), so fields that the SDK * declares required may be absent at runtime if the server is buggy. */ interface McpContentBlock { type: string text?: string mimeType?: string } /** * Create an execute function for one MCP tool. The executor closes over the * raw MCP tool name and calls `client.callTool` with it (never the public * name), with abort signal and timeout, then maps the result to harness * ContentBlocks. * * When the MCP server returns `isError: true`, the executor throws so that * the ToolRegistry's catch path produces an `isError` result for the model. */ function createExecutor( client: Client, rawName: string, opts: ToolBridgeOptions, ): ToolDefinition['execute'] { return async (args: unknown, exec: ToolExecution) => { // The agent loop passes `JSON.parse(model_arguments)` which is usually an // object, but can be any JSON value if the model misbehaves (outputs a bare // string/number/null). Fallback to {} lets the MCP server produce a // specific "missing required param" error the model can learn from. const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record const result = await client.callTool( { name: rawName, arguments: argsObj }, undefined, { ...exec.signal ? { signal: exec.signal } : {}, timeout: opts.toolCallTimeoutMs, }, ) // The SDK may return a legacy `toolResult` shape; normalize to content array. if (!('content' in result) || !Array.isArray(result.content)) { const text = 'toolResult' in result ? JSON.stringify(result.toolResult) : '(no output)' return [{ type: 'text' as const, text }] } // Trust boundary: the SDK's return type erases to `any[]` due to the // union of CallToolResult | CompatibilityCallToolResult. We process each // element defensively in extractText (reading only .type/.text/.mimeType // with optional fallbacks). // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const content: McpContentBlock[] = result.content const text = extractText(content, rawName) // MCP isError → throw so ToolRegistry produces an isError result for the model. if ('isError' in result && result.isError === true) { throw new Error(text) } return [{ type: 'text', text }] } } /** * Extract text from an MCP content array into a single string. * - text blocks: join with '\n' * - image/audio/resource blocks: replaced with a placeholder * * Defensive: fields that the MCP spec declares required (mimeType, text) are * guarded with fallbacks because this is a network trust boundary. */ function extractText(mcpContent: McpContentBlock[], toolName: string): string { const parts: string[] = [] for (const block of mcpContent) { switch (block.type) { case 'text': if (block.text !== undefined) parts.push(block.text) break case 'image': parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`) break case 'audio': parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`) break case 'resource': case 'resource_link': parts.push('[resource: content discarded]') break default: parts.push(`[unsupported content type: ${block.type}]`) } } return parts.join('\n') || `(${toolName} returned no text content)` }