Electron Stagewrightdocs

ADR-008: MCP server, tool dispatcher, and tool-definition contract

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

  1. A tool is a plain ToolDefinition object: { name, title?, description, inputSchema (Zod object), operationType, requiresEvalFlag?, handler }. The description embeds the possible error codes inline (ADR-007 Principle 1). operationType is internal manifest metadata, declared on the definition and NEVER on the agent-facing input (ADR-006 design). defineTool infers the input shape so a handler's arguments are precisely typed at the definition site, and returns an erased AnyToolDefinition for uniform storage.

  2. A single Dispatcher owns the call lifecycle. At registration it validates operationType against the closed OperationTypeSchema, 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 becomes BAD_ARGUMENT, never a raw Zod throw); routes the payload through routeByOperationType (the eval keyword blocklist always applies here); invokes the handler inside a session-correlation context; returns the handler's envelope as-is; maps a thrown StagewrightError to its code and any other throw to INTERNAL_ERROR; and logs a warning when a dispatch exceeds the slow-op threshold. The dispatcher never throws.

  3. The eval opt-in flag gates tool visibility, not per-payload safety. A tool declaring requiresEvalFlag is not registered (and never appears in tools/list) unless the server was started with the flag. The keyword blocklist still runs on every eval payload regardless of the flag.

  4. A SessionManager owns sessionId → session mapping 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, raises BAD_ARGUMENT on ambiguity and NOT_RUNNING when none match, and tears sessions down idempotently.

  5. Session correlation flows through AsyncLocalStorage. The dispatcher seeds an ambient context with the request's sessionId, and the envelope helpers read it to stamp _meta.session_id without threading the id through every signature.

  6. 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.

  7. 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 real bin) parses --allow-eval, starts the server, closes it on SIGINT/SIGTERM, and exits once teardown settles so launched Electron apps are not orphaned. A listManifest() 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

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:

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.