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 was still future work at that checkpoint. It is now implemented by
src/plugins/loader.tsand server assembly; see ADR-004.
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 and closes the server, disposing every session, when the host closes stdin, so a library embedder gets the same cleanup as the CLI;cli.ts(the realbin) parses--allow-eval, starts the server, closes it on SIGINT/SIGTERM, and exits once teardown settles 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.
Status update (2026-10-03): portable evidence and output schemas
Generated screenshot and trace evidence can be fetched through opaque MCP resource links. A
server-owned byte store keeps immutable snapshots (8 MiB each, 32 MiB and 32 entries total, 15-minute
TTL), releases them on shutdown and never exposes a filesystem-read API. Producers supply their
already-generated bytes; traces capture a bounded copy while streaming. Oversized/full stores leave
the existing local artifact intact and return an additive artifact_unavailable reason. Resource
links are issued only for registered artifacts and are isolated per server. Publication participates
in the request cancellation lifetime: cancellation revokes a newly published snapshot until dispatch
completion; late publication attempts are rejected. They are not listed by
default; unknown/expired URIs return resource-not-found.
ToolDefinition.outputSchema optionally describes a success payload. The dispatcher wraps it with
the shared success/error envelope, advertises the complete object schema in tools/list, and maps
invalid handler results to INTERNAL_ERROR before observers see them. The first adoption covers
electron_screenshot, trace_stop, trace_view, and trace_promote. No schema is claimed for
unconverted tools. Successful legacy text/path results remain valid and additional fields are
allowed for compatibility.
A declared output schema requires structured results, so those tools always return
structuredContent, even above the 50,000-character duplicate-suppression threshold. The identical
JSON text remains the first content block for older clients; a resource link may follow. Tools
without output schemas keep the previous large-response behavior. This intentionally incurs dual
encoding for unusually large typed responses; image and trace bytes are fetched separately.
Clients negotiating a protocol older than 2025-06-18 receive text-only tool content, with
artifact.uri still available in the JSON envelope for resources/read. The server observes the
SDK initialization response to gate resource_link blocks without replacing the SDK handshake.
Dispatch completion and evidence ownership
Completion has two internal collaborators with separate responsibilities:
ToolResultCodecserializes a handler result into a JSON snapshot, validates that snapshot against the shared envelope and any declared success schema, and retains the text for MCP encoding.RequestArtifactsrecords every publication made through that dispatch's producer capability. A valid success commits its publications; any failed outcome releases them, including handler throws, returned errors, cancellation, timeout, schema violations and JSON serialization failures.
Validation precedes status recording and observer notification. A circular value, BigInt or throwing
serialization hook becomes INTERNAL_ERROR in the ordinary envelope rather than a later JSON-RPC
encoding failure after observers have recorded success. Declared schemas validate the representation
that will actually reach the client, including any toJSON conversion. Additional JSON fields remain
intact; the dispatcher does not replace the result with Zod's transformed projection.
The JSON snapshot must also satisfy the exact advertised draft-2020-12 schema, checked without
coercion, default insertion or field removal. A missing field is rejected only when that schema marks
it required; an optional default is an annotation and need not appear in the result. Additional
properties remain valid wherever the schema allows them. Use a loose nested object when additional
fields are part of that declared contract, and construct required fields in the handler.
Format and pattern keywords are checked by Zod on the same snapshot, because the advertised
pattern omits regex flags and need not compile as a Unicode expression. Error envelopes are checked
against the shared envelope only, so an output contract never masks a tool's own error.
The request commits its outcome and closes progress/cancellation observation before dispatch observers run. Observers are advisory and must treat records as read-only. MCP text and structured content are built from the retained JSON snapshot, so an observer cannot introduce a second serialization failure or make those two representations disagree.
Evidence cleanup uses the request's own publication set, never a URI supplied in its result. Failed
requests cannot revoke another concurrent request's resource or a previously committed resource.
Completed nested dispatches retain their own publication lifetime if a parent later fails; this is
consistent with the existing rule that completed nested calls are not rolled back. After completion,
the request's publisher returns artifact_unavailable: "closed"; cancellation still throws the
request's cancellation reason before further work.
This adds JSON snapshot construction to direct dispatch completion. It makes no latency improvement claim. Tool names, discovery schemas, profile membership, portable byte/count/TTL limits and the 50,000-character structured-content rule are unchanged. Ordinary tests and in-memory MCP tests cover the completion contract; actual Electron clients and native framework qualification remain separate validation stages.
Status Update (2026-10-15) — stdin shutdown moved into connectStdio
The MCP stdio transport ends when the host closes the server's stdin, but the SDK transport does not
react to end-of-input, and a launched Electron app keeps the Node event loop alive. The CLI used to
listen for stdin end / close itself, so an embedder calling createServer() + connectStdio()
kept orphaned apps running after the host exited. connectStdio() now owns that listener and closes
the server (disposing every session); it accepts optional stdin / stdout streams. close() is
memoized, so a signal and a stdin close share one teardown, and the new closed promise settles with
its outcome however it was triggered.
The CLI waits on closed and exits 0 after a clean teardown or 1 when teardown threw (it used to
exit 0 either way). Usage errors (CliUsageError) now exit 2, matching the production
subcommand; other startup failures and failed doctor checks still exit 1.