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).
Status Update — 2026-07-27: request-scoped MCP progress
Long-running calls may report request-scoped MCP progress. A client opts in
through the standard opaque _meta.progressToken; individual tool schemas do
not grow a progress argument. The MCP request handler creates one best-effort
reporter and threads it through ToolContext, including nested ctx.dispatch
calls. Direct dispatch and token-less requests receive a no-op.
The reporter accepts only finite, non-negative, strictly increasing values, enforces a 12-notification request budget, stops on request abort, and swallows notification-delivery failures. Bounded waits and expectations emit elapsed milliseconds against their clamped timeout after a 250 ms minimum, using an adaptive cadence. Reporter closure also stops its heartbeat timer when the underlying operation remains pending.
Progress is advisory: it neither cancels the underlying operation nor replaces the final success/error envelope. Hosts that request progress can surface bounded wait/expect activity without changing the result contract. Hosts that ignore progress receive the same complete envelope; callers must always use that final envelope as the completion and outcome signal.
Status Update — 2026-07-27: semantic progress for long operations
The same request-scoped reporter now supports semantic phases for work whose duration is not a useful progress total. A phase remains buffered for the first 250 ms, so quick calls stay silent; transitions after that threshold advance a reporter-owned monotonic sequence. Pre-threshold transitions coalesce to the latest phase, nested dispatches share the sequence, and the original 12-notification request budget still applies.
Semantic progress covers lifecycle launch, attach, inspector injection, loopback discovery, and graceful stop, plus accessibility audits, visual capture/comparison, production validation, and trace flush/replay. IPC invoke uses elapsed progress only when the caller supplies its bounded timeout. Operations without a meaningful long-running phase remain silent rather than emitting synthetic activity.
Phase messages describe Stagewright-controlled work only. They do not include application content, artifact paths, IPC channel names, or replayed tool names. As with bounded progress, delivery is best-effort and the final envelope remains the sole completion and outcome signal.