js/src/mcp.ts, mirrored in
golang/mcp.go + python/java/csharp; SPEC.md §2) against a mature, long-lived MCP host —
VS Code's src/vs/workbench/contrib/mcp/ contribution. toolnexus's MCP plumbing is clean where
it exists (bounded connect/list, cursor-safe pagination, failure isolation, env-safe headers),
but it treats a server connection as a one-shot load: connect every server up front, list
its tools once, and never touch the transport's health or tool set again. That is correct for a
short CLI run and fragile for a long-lived agent. This ADR records the four gaps that separate
"fine for a script" from "safe for a resident agent," each with the additive API to close it./opsx:propose) with SPEC.md §2 deltas where the cross-language contract moves (M1, M2, M4
move it; M3 is a per-port idiom over the same observable contract), a per-language parity
checklist, and a shared examples/ fixture where behavior must be byte-identical. All proposals
are additive and backward-compatible: a consumer calling loadMcp(config) and nothing else
gets today's behavior unchanged.Today the entire MCP source is reachable as one call:
// js/src/mcp.ts
export async function loadMcp(input, opts?): Promise<McpSource> // :262
// → { tools: Tool[]; status: Record<string,McpStatus>; close() }
loadMcp fans out over every enabled server with Promise.all (mcp.ts:273, Go
mcp.go:511/wg :536), and for each: connectServer opens a transport once (mcp.ts:228),
paginateTools lists the tools once (mcp.ts:171), convertTool wraps each as a uniform Tool
(mcp.ts:194), and the client is stashed in a slice held until close(). After that first pass
there is no code path that observes the transport again — no reconnect on a dropped stdio
pipe, no handler for notifications/tools/list_changed, no lazy start, and every stdio server is
launched with the entire parent environment (mcp.ts:251 { ...process.env }, Go mcp.go:404
os.Environ()). VS Code, by contrast, models a connection as a small state machine it can start,
stop, restart, and gate. Priority order (highest impact first): M1 connection lifecycle → M2
live tool list → M3 lazy start → M4 least-privilege env.
connectServer runs exactly once inside loadMcp. If a stdio server crashes, or an HTTP server
drops the stream, mid-session, the client object is dead: every subsequent tool.execute returns
{ isError: true } forever, with no detection and no recovery. A resident agent that ran fine
at boot silently loses a whole tool source an hour in. VS Code treats a connection as a state
machine — McpConnectionState.Kind = Stopped | Starting | Running | Error — with explicit
start() / stop() and a _waitForState gate, and canBeStarted() guarding re-entry
(src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts:43-65, stop :123-127). That is
exactly the machinery that makes restart-on-crash possible.
// McpSource additions (js/src/mcp.ts) — the source becomes observable + recoverable
interface McpSource {
tools: Tool[]
status: Record<string, McpStatus> // existing: "connected" | "failed" | "disabled"
close(): Promise<void>
// NEW — per-server connection state, and a manual restart.
state(server: string): "stopped" | "starting" | "running" | "error"
restart(server: string): Promise<void> // stop (if any) → reconnect → re-list → re-wrap tools
onStateChange?(cb: (server: string, state: string) => void): () => void // optional observer
}
// loadMcp opts additions
interface LoadMcpOptions {
waitFor?: (r: Request) => Promise<Answer>
signal?: AbortSignal
// NEW — auto-restart a server whose transport closes unexpectedly.
autoRestart?: boolean | { maxRetries?: number; backoffMs?: number } // default false ⇒ today
}
When autoRestart is set, a transport close/error after a successful start transitions the
server to error, then attempts reconnect with bounded exponential backoff, re-lists, and swaps
the server's tools in place (same prefixed names). On give-up it stays error and
tool.execute returns the same isolated error as today.
autoRestart:true a subsequent execute
succeeds after ≤ maxRetries, and state(server) walks running→error→starting→running.autoRestart unset, behavior is byte-identical to today (dead client, isolated error).restart(server) on a healthy server is idempotent (stop→start, tools unchanged).signal abort during a restart cancels it promptly (reuse the raceTimeout path).Contract moves in SPEC.md §2: define the four state names and the restart observable. Go
already has the concurrency primitives (sync.WaitGroup mcp.go:536); each port keeps its own
idiom for the backoff timer but must agree on state names + retry semantics. Shared fixture:
examples/mcp-restart/ with a crash-once stub server.
MCP servers may send notifications/tools/list_changed when their tool set changes at runtime
(feature flags, auth state, a plugin loading). toolnexus lists tools exactly once in
paginateTools (mcp.ts:171) and subscribes to nothing — grep the source: there is no
list_changed handler in any port. A server that adds a tool after connect is invisible; one that
removes a tool leaves a stale wrapper whose execute fails. This is a spec-conformance gap, not
just a nicety.
interface McpSource {
// ...M1 additions...
refresh(server?: string): Promise<void> // re-list one server (or all) and reconcile tools
onToolsChange?(cb: (server: string) => void): () => void
}
// loadMcp opts
interface LoadMcpOptions {
liveToolList?: boolean // default false ⇒ today. true ⇒ subscribe to list_changed → refresh()
}
refresh diffs the new listing against the current wrappers for that server and adds/removes so
the flattened tools array stays current; liveToolList wires the server's list_changed
notification straight into refresh(server).
list_changed after adding a tool: with liveToolList:true the new
prefixed tool appears in source.tools without a reload; with it false, it does not.refresh() is safe to call concurrently with an in-flight execute on the same server.refresh() drops exactly that wrapper, others untouched.Moves SPEC.md §2: the refresh/liveToolList contract and the reconcile-by-name rule. Shared
fixture examples/mcp-list-changed/.
loadMcp pays N transport spawns / N HTTP handshakes up front via Promise.all (mcp.ts:273),
and a slow server delays the whole load up to its timeout even if the agent never calls its tools
this turn. VS Code starts a server only when its tools are first needed (lazy start, e.g.
McpStartPromptingServerCommand, src/vs/workbench/contrib/mcp/browser/mcpCommands.ts:1553). For
an agent with a dozen configured servers and a short conversation, most of that boot cost is
wasted.
// per-server config (LocalServer / RemoteServer)
interface ServerConfigCommon {
lazy?: boolean // default false ⇒ connect at load (today). true ⇒ connect on first execute.
}
A lazy server still contributes its tools to the registry — via a cached listing done once, or
(stricter) a placeholder whose first execute triggers connectServer + paginateTools,
then proceeds. Recommend: connect-and-list on first execute, cache thereafter; a lazy server
reports state:"stopped" until first use.
lazy:true server is not connected after loadMcp returns (assert no child process / no
socket); it connects on the first execute of one of its tools and succeeds.lazy is bounded by the fastest server, not the slowest.close() tears down only the servers actually started.Observable contract identical across ports; the lazy mechanism is a per-port idiom (closure vs
struct). No SPEC.md wire change beyond documenting the lazy config key. Fixture optional.
connectServer launches every stdio server with { ...process.env, ...serverEnv } (mcp.ts:251,
Go os.Environ() mcp.go:404). That hands all ambient secrets — API keys, tokens,
cloud creds sitting in the parent env — to every third-party MCP binary the config names. A
mcp.json a user pasted from the internet gets your whole keyring. This violates least-privilege
and, for this workspace specifically, the standing rule that a secret's value must not leak into
processes that don't need it. The header path already does the right thing (expandEnvHeaders
pulls only named ${VAR}s); the stdio path does not.
interface ServerConfigCommon {
// Whitelist of parent env var NAMES to pass through, in addition to explicit `env`.
// undefined ⇒ today's behavior (inherit all) for back-compat.
// [] ⇒ inherit nothing but a documented safe base (PATH, HOME, TMPDIR, LANG…).
// [names] ⇒ safe base + only these names from process.env.
inheritEnv?: string[]
}
// loadMcp opts — flip the global default without editing every server
interface LoadMcpOptions {
defaultInheritEnv?: string[] // applies to servers that omit inheritEnv
}
Values still come from the environment at launch (never from the config file, never logged) — this only narrows which names cross the boundary.
inheritEnv: [], a stub server prints its env; it contains the safe base + explicit env,
and not an unrelated SECRET_TOKEN present in the parent.inheritEnv unset, the child sees the full parent env (back-compat).defaultInheritEnv applies to servers that omit their own inheritEnv and is overridden by it.Moves SPEC.md §2: the three-state semantics of inheritEnv (undefined/empty/list) and the
documented "safe base" set — this must be byte-identical across ports or the same config leaks
differently per language. Shared fixture examples/mcp-scoped-env/.
autoRestart be the default once implemented, or stay opt-in? A resident
agent wants it on; a one-shot CLI does not care. Leaning opt-in to preserve byte-parity, with a
doc recommending it for long-lived hosts.lazy server's tools are absent
from the very first system prompt. Which does the platform need?Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |