MCP & Extensions: Two Paths to External Tools
How bridging the ecosystem standard and native extensions divide the work. Core source:packages/mcp/mcp-client/ and packages/extensions/。
Play first, then we explain. The same external capability — “check the weather” — wires in on the left via the MCP bridge and on the right via a native Extension, both at once. Watch three things: how tool names are generated, what the model sees when the server disconnects, and how far the two capability surfaces differ. Hit “Play” to run through automatically, or “Step” frame by frame.
First, the terms. MCP (Model Context Protocol) is an open protocol: anyone can write a tool server, and any MCP-capable client can connect and use its tools. DSH’s dsh-mcp-client plugin is that protocol’s client — one plugin instance per server, supporting both stdio subprocess and streamable-http transports. Once connected, what it does is straightforward: listTools() pulls the tool list, registers each under a public name into ctx.tools, and from then on the model uses them as native tools.
Naming is the first design point. Every MCP tool has two names: the raw name appears only on the wire (tools/call uses it); the public name the model sees is mcp__serverName__rawName. That format matches Claude Code and Codex — the mcp-client README says so itself. Names must satisfy DeepSeek’s function-name rules: at most 64 characters, letters/digits/underscore/hyphen only. If character replacement or truncation changes the name, a 12-hex-digit SHA-256 hash is appended so two distinct tool identities never collapse into one name. The whole function is pure over (serverName, rawName): connection order, re-sync, or other servers cannot rename a tool.
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}`
}
packages/mcp/mcp-client/src/tools.ts, verified on 2026-08-13. Code blocks keep the original source text.The second design point is generation. A server’s tool list can change, and when it does you re-sync. Sync has two phases: first pull and build the full next-generation tool definitions — any failure leaves the registry untouched, so the previous generation stays as-is; only after that succeeds do you swap — unregister the old generation, then register the new.
The swap phase is worth a close look. In the registration loop, every successful register stores its unregister function in a table. If any registration throws a conflict (meaning some foreign registration has seized this server’s namespace), the catch branch unregisters everything already in that table — not a single tool left — then logs an error. The comment states the intent plainly: rollback so the model sees either a complete generation or nothing at all — never a half set.
Source: the register-and-rollback branch at packages/mcp/mcp-client/src/tools.ts lines 159–172, verified on 2026-08-13.
Reconnect-after-disconnect is also built on generations. When a stdio subprocess crashes, the supervisor restarts it with exponential backoff: first delay defaults to 500 ms, doubles each time, caps at 30 s, at most 10 attempts per outage (README.zh.md config table). During the outage the last healthy generation stays registered — calls fail, but tool names don’t vanish; after a successful reconnect, rediscovery replaces the old generation wholesale, so tools neither duplicate nor leak. If serverName is unchanged, the new generation’s names match character for character, so KV-cache prefixes survive. Budget matters too: if a connection lives past 30 s, the attempt budget resets — so an occasionally crashing server can recover forever, while a crash-looping one eventually exhausts the budget and gets unregistered instead of restarting forever.
The last design point is easiest to miss: this bridge deliberately bridges only MCP’s tools capability. The protocol also has resources and prompts (prompt templates); DSH wires up neither. The README’s “Known limitations and deferred items” section is frank:
packages/mcp/mcp-client/README.zh.md line 111, verified on 2026-08-13
The logic is easy to reconstruct: nothing inside the harness consumes an external resource or external prompt, so building those bridge piers first would be pointless. Tools have a clear consumer (the agent loop’s tool calls), so tools get bridged first. That’s bridging on demand. Non-text results like images and audio also get a lossy projection — placeholders in model context; binary payloads stay out.
The second path is entirely different. Cordis is DSH’s plugin framework; the whole harness is a Cordis plugin tree. The Extensions subsystem lets the model write a Cordis plugin in-session and run it on the spot: before coding, cordis_inspect queries which services and interfaces the current runtime exposes; then cordis_define submits source, cordis_run starts it, and when done cordis_stop or cordis_undefine. These five tools are registered by packages/extensions/tool-cordis.
A dynamic plugin has two halves. The Host half runs logic in a Node-side node:vm sandbox; the Browser half renders UI in the page; both lifecycles are owned by ctx.dynamicCordisRunner (from packages/extensions/cordis-host-runner/src/index.ts line 124 on). Starts that include a Browser half need approval: the cordis/request-run event sends the request to the page; only after the user allows does it continue, and they can also check “approve future versions” of this plugin (runHostHalf’s approveFutureVersions). Each Package version is immutable — editing code means appending a new version.
Side by side, the two paths are orthogonal — each owns one end. The MCP bridge faces ready-made capability outside the process; its trust model is isolation: crashes, garbage returns, and disconnects are kept outside the bridge by generations and error paths — but all it can give the model is tools. Extensions face model-generated code running in-process, with a much larger surface: add tools, emit events, register services, draw UI — at the cost of approval and sandbox on every run. One plugs into outside power; the other generates its own.
Names are a pure functionThe public name is decided only by (serverName, rawName). Two servers each exposing a tool called search coexist in their own namespaces; connection order and re-sync never rename tools.
Generations are all-or-nothingA failed pull leaves the registry untouched; a registration conflict rolls back the whole generation. The model always sees a complete tool set — never a half set. During disconnect the old generation stays registered: calls fail, but names remain.
The bridge bridges only toolsresources and prompts are deliberately deferred — the harness has no consumer interface for them. The capability gap is filled by Extensions: tools, events, services, and UI can all be added.
Claude Code: a full-spec MCP client
The restored-source MCP implementation is much thicker than DSH’s: six transports (stdio, sse, sse-ide, http, ws, sdk — see restored-src/src/services/mcp/types.ts lines 23–26), seven config-source layers (local, user, project, dynamic, enterprise, claudeai, managed), OAuth plus a 15-minute cache. Tool naming matches DSH’s shape, mcp__server__tool, with permission rules down to tool or server. One defense DSH lacks: tool descriptions truncated to 2048 characters, after observing OpenAPI-generated servers stuffing 15–60KB of docs into descriptions (comment at services/mcp/client.ts lines 217–219). Source: claude-code-sourcemap-main/study/chapters/08-mcp.md.
The difference is orientation. Claude Code treats MCP as the sole official extension point and goes deep and wide; DSH keeps the MCP bridge thin (tools only) and leaves heavy capability to native Extensions. The former’s extensions run out-of-process; the latter adds an in-process path.
Grok Build: the plugin-marketplace route
In the Grok Build repo, an MCP client (crates/codegen/xai-grok-mcp/) and a plugin marketplace (crates/codegen/xai-grok-plugin-marketplace/) coexist: MCP for protocol compatibility, the marketplace for distribution and trust — a centrally reviewed ecosystem route. Its MCP connect/discover/recover mechanics were already line-checked in the on-site Grok series — see MCP connect, discover & recover; marketplace discovery and trust is in Plugin Marketplace discovery & trust. We won’t repeat that here.
Put all three together and the spectrum appears: Grok centralizes trust in a marketplace; Claude Code spreads trust across seven config layers and permission rules; DSH splits the two paths, each with its own trust model — isolation outside the bridge, approval inside.
Walk through a full disconnect-and-reconnect timeline
The weather server crashes right after the model gets the tool list; 8 seconds later the supervisor brings it back, and this time the list includes an extra get_alerts. Walk the timeline in order: what’s in the registry at the crash instant? What does the model get calling mcp__weather__get_forecast during the outage? After reconnect succeeds, what operations hit the registry, and does get_forecast’s public name change? Then: if two different servers weather and weather2 both expose get_forecast, do they collide, and why? (Hint: generation swap; names are a pure function of (serverName, rawName).)
(serverName, rawName); generation swap guarantees the model’s tool set is either complete or empty — never an in-between state.