mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
180 lines
6.5 KiB
TypeScript
180 lines
6.5 KiB
TypeScript
/**
|
||
* MCP client bridge plugin: connects to an external MCP server and registers
|
||
* its tools on `ctx.tools` under server-qualified public names
|
||
* (`mcp__<serverName>__<rawName>`). 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<Context, Set<string>>()
|
||
|
||
// ---- 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__<serverName>__<rawName>`). 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<string, string>
|
||
/** 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__<serverName>__<rawName>`). 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<string, string>
|
||
/** 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<Config>
|
||
|
||
// ---- 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<string, () => 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')
|
||
}
|