17 KiB
Agent Note: MCP client plugin — connect to external MCP servers and bridge their tools
Status: implemented
Problem
The harness had no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code.
The ToolRegistry already accepts raw JSON Schema tool definitions (documented in dsh-tools README: "Raw JSON-Schema tool definitions (from MCP servers) are still accepted by ToolRegistry.register() directly"), and the extension cookbook sketches the intended pattern ("MCP | one plugin per server: discover tools → ctx.tools.register()"). The infrastructure was ready; the bridge plugin was missing.
Decision
Package
A single package @deepseek-ai/dsh-mcp-client at packages/mcp/mcp-client/. No capability-seam three-package split — there is no foreseeable second MCP client implementation, and the convention is "don't split preemptively" (capability seams Agent Note).
SDK
Use the official @modelcontextprotocol/sdk (Client, StdioClientTransport, StreamableHTTPClientTransport). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to @agentclientprotocol/sdk.
Scope
MCP Client only (no server side — ACP already covers the "expose harness as an agent" role). Bridge Tools only — Resources and Prompts are deferred (they require harness-side consumption mechanisms that don't exist yet, and design space is large).
Plugin shape
Namespace plugin (named exports name/inject/Config/apply, no export default). inject: ['tools']. Each MCP server is one plugin instance in cordis.yml — the same package loaded N times with different configs, like dsh-tool-subagent.
Configuration
Flat discriminated union on the transport field:
interface StdioConfig {
transport: 'stdio'
serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$
command: string
args?: string[]
env?: Record<string, string>
cwd?: string
toolCallTimeoutMs?: number // default 60_000
}
interface StreamableHttpConfig {
transport: 'streamable-http'
serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$
url: string
headers?: Record<string, string>
toolCallTimeoutMs?: number // default 60_000
}
type Config = StdioConfig | StreamableHttpConfig
serverName is the stable local identity that namespaces this server's tools in the model-facing name (below). It is deliberately user configuration, NOT the remote serverInfo.name: the remote name is untrusted input, is not unique across deployments (prod and staging instances of one server report the same name), and may change on server upgrade — none of which may silently rename model-facing tools. A duplicate serverName across live instances is a configuration error: the later instance fails at load with an actionable message, never silent shadowing or skipping. A short serverName (gh) is also the knob for shortening public names.
Example cordis.yml usage:
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
- id: mcp-web
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: web
transport: streamable-http
url: http://localhost:3000/mcp
headers:
Authorization: !!js `Bearer ${process.env.MCP_TOKEN}`
The model sees mcp__github__create_issue, mcp__github__search_code, mcp__web__search.
Lifecycle
Boot-time from cordis.yml. HMR (@cordisjs/plugin-hmr) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now. Public names are pure functions of (serverName, rawName), so an HMR swap that keeps serverName recreates identical model-facing names — session history and permission rules stay valid — and adding or removing an unrelated server never renames an existing tool.
Tool discovery and registration
Every MCP tool has two names:
-
rawName— the exact MCPTool.name, used only on the wire (tools/call). -
publicName— the globally unique model-facing name registered in theToolRegistry:mcp__<serverName>__<rawName>
This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server (Claude Code mcp__github__list_issues, Codex mcp__weather__get-forecast, Gemini CLI, VS Code, Cline, Roo Code, Goose, OpenCode); the exact mcp__<server>__<tool> spelling follows Claude Code and Codex. The mcp__ marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (mcp__*, mcp__github__*).
- On connect: drain
client.listTools()pagination, derive every tool'spublicName, then register each as a rawToolDefinitionviactx.tools.register(). The MCP JSON Schema and description pass through unchanged (nodefineToolDSL conversion); only the model-facingnameis replaced. - Listen for
notifications/tools/list_changed→ re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. - The executor closes over
rawName; the public name is never sent to the server and never parsed to recover the raw name. - No
presentCall/presentResult— UI consumers use the provider-neutral generic-card fallback. - Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself.
Public name normalization
MCP allows tool names up to 128 characters including .; the DeepSeek function-name contract allows [A-Za-z0-9_-] and at most 64. Public names are normalized deterministically: invalid characters become _, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the (serverName, rawName) identity is appended so distinct MCP identities can never collapse into the same public name:
function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(/[^A-Za-z0-9_-]/g, '_')
if (normalized === joined && normalized.length <= 64) return normalized
const hash = sha256(`${serverName}\0${rawName}`).slice(0, 12)
return `${normalized.slice(0, 64 - 13)}_${hash}`
}
Name conflict handling
MCP guarantees tool-name uniqueness only within one server; cross-server collisions are the norm, not the exception (a Microsoft Research survey of 1,470 servers found 775 colliding tool names; search alone appears in 32 servers, and the official GitHub server publishes bare create_issue). The always-on namespace makes collisions structurally impossible instead of handling them at collision time:
- Two servers publishing
searchcoexist asmcp__github__searchandmcp__web__search. - A native harness tool named
searchis unaffected. - Duplicate
serverNameconfig fails the later instance at load (see Configuration). - A server listing the same tool name twice is an invalid tool list: the sync throws and the previous generation stays registered.
- A registry conflict during the swap can only mean a foreign tool squats on this server's
mcp__<serverName>__namespace: the partial generation is rolled back (zero tools from this server) and the error is logged loudly.
Tools are never silently skipped; which tools are available never depends on plugin load order.
Naming invariants
- Every MCP tool has the stable identity
(serverName, rawName); every active identity has exactly one public name. - Public names are deterministic, globally unique, and satisfy the DeepSeek 64-char
[A-Za-z0-9_-]contract. - MCP
tools/callalways receives the original raw name. - Connecting, disconnecting, or re-syncing an unrelated server never renames an existing tool.
- Registration order never determines which tool is available.
Tool execution
A unified execute handler for all tools from one MCP server:
- Resolve
rawName(the executor closes over it) and callclient.callTool({ name: rawName, arguments }, { signal: exec.signal })with the configured timeout — the public name is never sent to the server. - Map the result:
- Multiple
textcontent blocks → join with'\n'into a singleTextBlock(required:flattenTextusesjoin('')without separator, so multiple blocks would lose inter-block boundaries). imagecontent blocks → discard with actx.logger.warn(the harness has no image content block type; drop-image Agent Note).isError: true→ map to the harnessisErrorresult path ({ content: [...], isError: true }).
- Multiple
- Cancellation:
exec.signal(from the agent loop's cancel) is passed through to the MCP SDK'scallTool, which sends$/cancelRequestto the server.
Subprocess environment (stdio transport)
Replicate the buildChildEnv + SENSITIVE_ENV_PATTERN scrub from dsh-subagent-acp: filter ambient env (strip credential-shaped vars matching /KEY|SECRET|TOKEN/i), then merge config.env on top. Explicit env overrides survive the scrub.
Disconnection / crash
No auto-reconnect. If the MCP server process exits or the transport closes:
- The effect disposes → all registered tools are unregistered (fiber-scoped disposers).
- Subsequent model calls to those tools →
ToolNotFoundError→isError: true. - Recovery: user edits
cordis.yml(triggers HMR reload) or restarts the harness.
This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry."
Alternatives considered
MCP Server side (expose harness tools to external MCP clients)
Deferred. The ACP bridge already exposes the harness as an agent server. Adding an MCP server layer would duplicate that with a different protocol, and the primary user need is consuming external tools, not exposing them.
Capability-seam three-package split (interface / impl / consumer)
Rejected. There is no foreseeable alternative MCP client implementation — MCP has one protocol, one SDK. The convention is "don't split preemptively" until a second implementation appears.
Auto-reconnect with exponential backoff
Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future reconnect: boolean config if needed.
Bridge Resources and Prompts
Deferred. Resources need a harness-side mechanism to decide WHEN to inject content (system prompt? on demand? model-triggered?). Prompts need a "prompt template" concept the harness lacks. Both require their own design; Tools are the high-value, low-risk starting point.
Raw model-facing tool names with an optional toolPrefix
Rejected — this was the original proposal, built on the premise that "most MCP servers already use semantic prefixes in their tool names (e.g. github_create_issue)". The premise is false: the official GitHub server publishes create_issue, the reference filesystem server read_file, Sentry search_issues — and the Microsoft survey above shows collisions are common at ecosystem scale. Collision-time prefixing (or warn-and-skip) also makes the available tool set depend on plugin load order, and a tool could be silently renamed when an unrelated server is added — invalidating session history and permission rules mid-conversation. No surveyed multi-server agent product ships raw names.
Server-only namespace (github__create_issue, no mcp__ marker)
Rejected for v1. It prevents cross-server collisions but does not separate MCP registrations from native harness tools, and it forfeits MCP-wide policy shapes (mcp__*). The marker costs 5 characters; the mcp__<server>__<tool> spelling matches Claude Code and Codex, maximizing model familiarity. If the ToolRegistry later grows source-aware namespaces, dropping the literal marker can be revisited as a naming-policy change.
Deriving the namespace from the server-announced serverInfo.name
Rejected. The remote name is untrusted, non-unique across deployments, and changeable on upgrade; tool identity and permission rules must not silently follow it. The namespace is local configuration.
Preserve multiple TextBlocks in tool result
Rejected. flattenText() in the DeepSeek serializer uses join('') (no separator) when flattening ContentBlock[] to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit.
Testing
Coverage is named per tier; each behavior lives at the cheapest tier that can express it.
- Unit (
tests/mcp-client.spec.ts,tests/apply.spec.ts, mocked MCP SDK): thepublicToolNamealgorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-serverNameload failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package. - E2E (
tests/mcp-client.e2e.ts, keyless): the real MCP protocol against the in-repo fixture server,@modelcontextprotocol/server-everything, and@modelcontextprotocol/server-filesystemover stdio, and against an in-processStreamableHTTPServerTransportserver over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-serverNamerejection, disposal. - Snapshot: deliberately none. MCP tools introduce no new presentation shape — they register as raw
ToolDefinitions and UI consumers use the generic-card fallback already pinned by their presentation suites. Adding an MCP server to a runnable snapshot composition would mutate its pinned system-prompt fixture and make every replay depend on spawning an external MCP server process for no new behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then.
Consequences
- A
cordis.ymlentry per MCP server is the entire integration cost:serverName: filesystem+ a stdio command (or a Streamable HTTP URL) putsmcp__filesystem__read_filein the model's tool list, callable, with the rawread_fileon the wire. - Public names are part of session history and permission/config surfaces; the naming algorithm is a v1 contract pinned by tests, and changing it after release is a breaking change.
- The
mcp__<serverName>__qualifier costs tokens on every name. Accepted: descriptions and JSON schemas dominate tool-definition tokens, and the qualifier buys stable identity, collision isolation, and MCP-wide policy shapes (mcp__*,mcp__github__*). - MCP SDK stability: the
@modelcontextprotocol/sdkis still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. - Tool schema quality: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's.
- Stdio process management: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level.
- Crash recovery is manual (HMR edit or restart) — accepted for v1; a
reconnectconfig remains open as future work.