feat(mcp): adopt mainstream server-qualified MCP tool naming

Research across 8 multi-server agent clients (Claude Code, Codex, Gemini
CLI, VS Code, Cline, Roo Code, Goose, OpenCode) showed all of them keep
the server namespace in model-facing MCP tool names; the RFC's premise
for raw names ("servers already prefix their tools") is false for the
official GitHub/filesystem/Sentry servers.

- Config: drop toolPrefix; require serverName ([A-Za-z0-9_-]{1,32}),
  duplicate serverName fails the later instance at load (per-root
  reservation, released on dispose)
- Names: always mcp__<serverName>__<rawName>; normalize to the DeepSeek
  64-char [A-Za-z0-9_-] contract with a deterministic 12-hex identity
  hash on lossy normalization; raw name is the only thing sent on the
  wire (tools/call)
- Sync: two-phase fetch/swap — fetch failure keeps the previous
  generation; a swap conflict rolls back the whole generation (never a
  partial set); duplicate raw names reject the tool list
- RFC: moved to implemented/ (status + skeleton rewritten per the
  format contract), naming design + tier-level test coverage recorded
- Tests: naming algorithm unit suite; keyless Streamable HTTP e2e
  against an in-process StreamableHTTPServerTransport (namespace
  discovery, execution, per-request auth headers); dotted-name
  normalization e2e via a new fixture tool
This commit is contained in:
lintianle
2026-07-13 23:39:02 +08:00
parent 7fff51d848
commit af3152fefe
11 changed files with 923 additions and 422 deletions

View File

@@ -1,11 +1,14 @@
/**
* MCP client bridge plugin: connects to an external MCP server and registers
* its tools on `ctx.tools`. Each plugin instance connects to one MCP server;
* load multiple instances in `cordis.yml` for multiple servers.
* 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 and unregisters all
* tools. HMR hot-swaps by disposing the old instance and creating a new one.
* 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
*/
@@ -28,12 +31,32 @@ 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 {
/** Transport type: spawn a child process and communicate over stdio. */
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 to spawn. */
command: string
/** Arguments passed to the command. */
@@ -42,8 +65,6 @@ export interface StdioConfig {
env: Record<string, string>
/** Working directory for the child process. */
cwd: string
/** Prefix prepended to each tool name before registration. */
toolPrefix: string
/** Timeout per callTool invocation (ms). */
toolCallTimeoutMs: number
}
@@ -52,12 +73,16 @@ export interface StdioConfig {
export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
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 server URL. */
url: string
/** Extra headers (e.g. auth tokens). */
headers: Record<string, string>
/** Prefix prepended to each tool name before registration. */
toolPrefix: string
/** Timeout per callTool invocation (ms). */
toolCallTimeoutMs: number
}
@@ -68,18 +93,18 @@ 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(''),
toolPrefix: 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({}),
toolPrefix: z.string().default(''),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
}),
]) as unknown as z<Config>
@@ -87,42 +112,66 @@ export const Config = z.union([
// ---- 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: {} },
)
// Connect and set up tools. Errors during connect are logged, not thrown
// (the plugin simply has no tools registered).
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, {
toolPrefix: config.toolPrefix,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}, new Map())
let disposers = await syncTools(client, ctx, opts, new Map())
client.setNotificationHandler(
ToolListChangedNotificationSchema,
async () => {
ctx.logger.info('mcp-client: tool list changed, re-syncing')
disposers = await syncTools(client, ctx, {
toolPrefix: config.toolPrefix,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}, disposers)
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
return () => disposers
})().catch((error: unknown) => {
ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`)
return new Map<string, () => void>()
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
return () => new Map<string, () => void>()
})
ctx.effect(() => async () => {
const disposers = await ready
for (const dispose of disposers.values()) dispose()
const live = await ready
for (const dispose of live().values()) dispose()
try { await client.close() } catch { /* transport already gone */ }
}, 'mcp-client.connection')
}