ADR-008: MCP server, tool dispatcher, and tool-definition contract
- Status: Accepted
- Date: 2026-05-28
- Deciders: johnny4young
- Note: Public ADR. Committed artifacts may cite
ADR-008; this file is the canonical design record. - Status note 2026-06-01: server, dispatcher, lifecycle, interaction, read/wait/eval, observe, dialog, and expect tool families have landed. Plugin loading remains future work.
Context
The project had, before this slice, a set of strong primitives — the transport
abstraction (ADR-003), the error code registry and response envelope (ADR-006),
the snapshot walker (ADR-005), and the agent-native UX principles (ADR-007) — but
nothing that turned them into a runnable MCP server. There was no dispatcher, no
session manager, no server entry point, and the package's bin pointed at a
dist/cli.js that did not exist. Several already-shipped modules referenced "the
dispatcher" as a known design (errors/operation-type.ts, errors/envelope.ts)
without that design being written down.
The tool slices (interaction tools, read/wait/eval tools, the ergonomic primitives) and the plugin model all need a single, stable answer to: how is a tool defined, validated, routed, and executed, and how does a tool call become a wire response? Inventing that ad-hoc per slice would produce an accordion API.
Decision
A tool is a plain
ToolDefinitionobject:{ name, title?, description, inputSchema (Zod object), operationType, requiresEvalFlag?, handler }. Thedescriptionembeds the possible error codes inline (ADR-007 Principle 1).operationTypeis internal manifest metadata, declared on the definition and NEVER on the agent-facing input (ADR-006 design).defineToolinfers the input shape so a handler's arguments are precisely typed at the definition site, and returns an erasedAnyToolDefinitionfor uniform storage.A single
Dispatcherowns the call lifecycle. At registration it validatesoperationTypeagainst the closedOperationTypeSchema, so a mis-declared tool fails at boot rather than at an agent call. Per call it: parses the raw arguments against the tool's Zod schema (a failure becomesBAD_ARGUMENT, never a raw Zod throw); routes the payload throughrouteByOperationType(the eval keyword blocklist always applies here); invokes the handler inside a session-correlation context; returns the handler's envelope as-is; maps a thrownStagewrightErrorto its code and any other throw toINTERNAL_ERROR; and logs a warning when a dispatch exceeds the slow-op threshold. The dispatcher never throws.The eval opt-in flag gates tool visibility, not per-payload safety. A tool declaring
requiresEvalFlagis not registered (and never appears intools/list) unless the server was started with the flag. The keyword blocklist still runs on every eval payload regardless of the flag.A
SessionManagerownssessionId → sessionmapping keyed by the transport-assigned session id (a stable, collision-free identifier — no second competing id is minted). It resolves "the only session" when one is live, raisesBAD_ARGUMENTon ambiguity andNOT_RUNNINGwhen none match, and tears sessions down idempotently.Session correlation flows through
AsyncLocalStorage. The dispatcher seeds an ambient context with the request'ssessionId, and the envelope helpers read it to stamp_meta.session_idwithout threading the id through every signature.Logging is stderr-only. Under the stdio transport, stdout carries the JSON-RPC protocol frames; anything else written there corrupts the stream. The logger writes exclusively to stderr.
The MCP stdio server registers tools from the dispatcher manifest.
createServer()assembles the object graph;connectStdio()attaches the transport;cli.ts(the realbin) parses--allow-eval, starts the server, and disposes all sessions on SIGINT/SIGTERM so launched Electron apps are not orphaned. AlistManifest()surface renders each tool's Zod schema to JSON Schema for offline documentation generation.
Alternatives considered
| Alternative | Why rejected |
|---|---|
Register tools directly on McpServer with no dispatcher |
The SDK validates input and serialises results, but it does not own operation-type routing, the error envelope, slow-op logging, or session correlation. Scattering those across every tool reproduces the per-slice accordion this ADR exists to prevent. |
| One macro tool with an action selector | ADR-007 already rejected this on the m13v selection-accuracy evidence; granular tools each carry their own schema and description. |
| Mint a server-side sessionId distinct from the transport's | Redundant — the transport already returns a stable unique id. A second id invites divergence and a positional-handle bug class. |
| Defer the server framework and ship only tool functions | The dogfooding goal and the broken bin both require a runnable server; deferring leaves the package non-executable. |
Consequences
- Every later tool slice (interaction, read/wait/eval, ergonomic primitives) and
the plugin model register
ToolDefinitions with this dispatcher; they inherit validation, routing, envelopes, and logging for free. - The eval tools land by setting
requiresEvalFlagandoperationType: 'eval'; no dispatcher or CLI change is needed (the seam ships here). _meta.session_idis populated when the request carriessessionId; the single-session default case may omit it (a follow-up can resolve the ambient id after session resolution if richer correlation is wanted).- This decision is revisitable only by amendment (a new ADR or a Status Update block here).