ADR 0038: The MCP server runs on the official TypeScript SDK over stdio
Vocabulary amendment: Current tool pointers use
discern_map(formerlydiscern_docs) anddiscern_accept(formerlydiscern_graduate); the decision and reasoning are unchanged.
Status: accepted
Context
§discern mcp exposes the verbs to a coding agent over the Model Context Protocol — the third rendering of the one result spine, where every verb's DiscernResult becomes a tool result. The first implementation was a hand-rolled JSON-RPC 2.0 loop: ~480 lines that framed newline-delimited messages off Deno.stdin, dispatched initialize/ping/tools/list/tools/call by hand, and managed protocol-version negotiation itself. Its stated reason for existing — written into the file — was to avoid an SDK dependency "to keep discern one self-contained binary."
That rationale was never tested; it was an assumption. We are about to deepen the MCP integration substantially (newer spec features beyond a like-for-like tool surface), which means tracking the formal protocol closely. A hand-rolled wire layer makes every spec revision our maintenance burden: version negotiation, the handshake, framing, error shapes, and whatever the next revision adds. The canonical conformance source is the official TypeScript SDK (@modelcontextprotocol/sdk), and the question was whether adopting it is even possible under discern's hard constraint of shipping as one self-contained deno compile binary with no runtime and no network.
We verified it empirically before deciding (SDK v1.29.0, Deno 2.8.3):
- The SDK imports and runs under Deno via
npm:specifiers; the historicalStdioServerTransportBufferincompatibility is gone. deno compilebakes it into a standalone binary that runs with no Deno present and under discern's least-privilege permission set (--allow-read/write/env/run) — no extra permissions, no network at runtime.- It speaks the current spec revision (
2025-11-25) natively. - The project's existing jsr
@zod/zodis instance-compatible with the SDK's bundled npmzod(Zod v4's standard-schema interface bridges the two), so tool input schemas use the zod we already depend on.
The premise behind the hand-rolled loop — that the SDK would cost us the single-binary property — is therefore false.
Decision
§discern mcp runs on the official MCP TypeScript SDK: a McpServer connected to the SDK's StdioServerTransport. discern owns only the tool table (each entry a thin adapter over a verb's …Result() core) and the result rendering (serializeResult → { content, structuredContent, isError }); the SDK owns the JSON-RPC framing, the initialize/ping handshake, protocol-version negotiation, and tool dispatch. The hand-rolled wire layer is deleted.
The transport stays stdio only. discern is a local, zero-daemon tool that runs where the agent runs; it has no reason to listen on a network. We deliberately do not adopt the SDK's Streamable HTTP transport or its OAuth/auth surface, now or later.
Specifics worth recording:
- Argument-less verbs register no input schema. The SDK skips argument validation when a tool declares no schema, so those calls are accepted whether or not the client sends an (empty)
argumentsobject. Verbs that take arguments register a Zod raw shape (all fields optional) and therefore require anargumentsobject on the call — which conformant clients always send. - Feature-gated tools are simply not registered when their feature is off (
discern_map,discern_accept). They are absent fromtools/list, and a call to one gets the SDK's standard "tool not found" error result — replacing the old bespokefeature_disabledenvelope. The per-callnot_initializedenvelope (nodiscern.toml) is preserved inside each handler. - stdin EOF is bridged to a clean shutdown. The SDK's stdio transport closes only on an explicit
close(), not on stdin EOF, so the server listens for stdinendand closes the transport — otherwise the top-level await would deadlock when the client closes the pipe. node:processis imported through an aliased import-map entry ("process"), satisfying theno-external-importandno-process-globallint rules.
Consequences
§- Spec conformance is no longer our code to maintain. The handshake, framing, negotiation, and error shapes track the SDK; deepening the integration is a matter of using SDK features, not re-implementing protocol. The advertised revision jumps from
2025-06-18to2025-11-25at no cost. - The dependency surface grows and cannot be trimmed. The SDK pulls ~70 transitive npm packages (express, hono, jose, ajv, cors, …) for its HTTP + OAuth server side, which stdio never touches. They are inert at runtime (no network; runs under least privilege), but
deno compilebundles the whole npm package regardless of which subpath is imported — we measured a stdio-only import and an HTTP-only import producing a byte-identical 14.22 MB bundle, with express'shttp-errorspresent in the stdio-only graph. So importing onlyserver/stdio.jsshaves nothing; the only way to shed those deps is to fork the SDK, which would forfeit the zero-maintenance conformance that is the entire point of adopting it. We accept the cost: ~15 MB of binary growth (69 MB → 84 MB), modest against a Deno runtime floor that is already ~69 MB. "One self-contained binary" still holds — proven by compiling and running with no Deno present. - The tests are conformant-client tests. The SDK strictly validates the
initializeparams (protocolVersion/capabilities/clientInfoall required), so the engine MCP tests send a full handshake; the test that exercised the old loop's un-terminated-final-line draining is removed, because framing is now the SDK'sReadBuffer.
Alternatives considered
§- Keep the hand-rolled wire, import only SDK types. Preserves the lean dependency graph but keeps protocol conformance as our maintenance burden — the exact cost we are trying to shed ahead of a deeper integration. Strictly worse than today once the integration grows.
- Fork/patch the SDK to strip the HTTP + auth modules. The only way to actually trim the ~15 MB. Rejected: it re-introduces per-upgrade maintenance and throws away the canonical-conformance benefit that justifies the SDK at all — a bad trade for 15 MB against a 69 MB floor.