/** * MCP client bridge plugin: connects to an external MCP server and registers * its tools on `ctx.tools` under server-qualified public names * (`mcp____`). Each plugin instance connects to one MCP * server; load multiple instances in `cordis.yml` for multiple servers. * * Namespace plugin (named exports, no default export). Lifecycle is * effect-scoped: disposal disconnects from the server, unregisters all tools, * and releases the `serverName` namespace reservation. HMR hot-swaps by * disposing the old instance and creating a new one; identical `serverName` * reproduces identical public tool names. * * @module @deepseek-ai/dsh-mcp-client */ import type { Context } from 'cordis' import z from 'schemastery' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' import { createTransport } from './transport.ts' import { syncTools } from './tools.ts' // Side-effect type import: declaration-merges `ctx.tools` onto Context. import type {} from '@deepseek-ai/dsh-tools' export type { McpResult } from './tools.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'mcp-client' /** Services required by this plugin. */ export const inject = ['tools'] /** Default timeout for individual MCP tool calls (ms). */ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 /** * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the * 64-char public-name budget so typical raw tool names survive unhashed. */ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps * in one process — tests — must not see each other's names). A duplicate * namespace is a configuration error surfaced at plugin load, never silent * shadowing. */ const activeServerNames = new WeakMap>() // ---- Config ---- /** Config for connecting to an MCP server via a spawned child process over stdio. */ export interface StdioConfig { /** Selects child-process stdio transport. */ transport: 'stdio' /** * Stable local namespace for this server's model-facing tool names * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be * unique across live mcp-client instances. */ serverName: string /** Executable used to start the server. */ command: string /** Arguments passed directly, without shell interpolation. */ args: string[] /** Extra env vars merged on top of scrubbed ambient env. */ env: Record /** Working directory for the child process. */ cwd: string /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ export interface StreamableHttpConfig { /** Selects Streamable HTTP transport. */ transport: 'streamable-http' /** * Stable local namespace for this server's model-facing tool names * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be * unique across live mcp-client instances. */ serverName: string /** MCP endpoint URL. */ url: string /** Additional headers attached to MCP requests. */ headers: Record /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number } /** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig export const Config = z.union([ z.object({ transport: z.const('stdio'), serverName: z.string().required().pattern(SERVER_NAME_PATTERN), command: z.string().required(), args: z.array(String).default([]), env: z.dict(String).default({}), cwd: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), z.object({ transport: z.const('streamable-http'), serverName: z.string().required().pattern(SERVER_NAME_PATTERN), url: z.string().required(), headers: z.dict(String).default({}), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), }), ]) as unknown as z // ---- Plugin apply ---- export function apply(ctx: Context, config: Config): void { // Reserve the namespace first: a duplicate `serverName` fails THIS instance // at load with an actionable error and leaves the earlier instance intact. ctx.effect(() => { let names = activeServerNames.get(ctx.root) if (!names) { names = new Set() activeServerNames.set(ctx.root, names) } if (names.has(config.serverName)) { throw new Error( `mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`, ) } names.add(config.serverName) return () => void names.delete(config.serverName) }, 'mcp-client.serverName') const transport = createTransport(config) const client = new Client( { name: 'dsh-mcp-client', version: '0.0.1' }, { capabilities: {} }, ) const opts = { serverName: config.serverName, toolCallTimeoutMs: config.toolCallTimeoutMs, } // Connect and set up tools. Errors during connect/first sync are logged, // not thrown (the plugin simply has no tools registered). `ready` resolves // to an accessor for the CURRENT disposer generation, so the effect // disposer below always unregisters the live set, not the first one. const ready = (async () => { await client.connect(transport) let disposers = await syncTools(client, ctx, opts, new Map()) client.setNotificationHandler( ToolListChangedNotificationSchema, async () => { ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`) try { disposers = await syncTools(client, ctx, opts, disposers) } catch (error) { // Fetch-phase failure: the previous generation is still registered // and `disposers` still owns it — keep serving the last good list. ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`) } }, ) return () => disposers })().catch((error: unknown) => { ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`) return () => new Map void>() }) ctx.effect(() => async () => { const live = await ready for (const dispose of live().values()) dispose() try { await client.close() } catch { /* transport already gone */ } }, 'mcp-client.connection') }