Files
suaveplan/openspec/archive/2026-05-25-enterprise-mcp/design.md
2026-05-27 23:29:53 +01:00

9.7 KiB

Design — enterprise-mcp

Goal

Land @suaveplan/mcp@0.1.0 as a universal-tier MCP host-side library implementing the Model Context Protocol's full method surface over a pluggable transport.

Architecture

       ┌───────────────────────────┐                  ┌───────────────────────────┐
       │        MCPClient          │                  │        MCPServer          │
       │ (consumer of remote tools)│                  │ (owner of tools/resources)│
       └─────────────┬─────────────┘                  └─────────────┬─────────────┘
                     │                                              │
                     ▼                                              ▼
            ┌────────────────┐  send(JsonRpcMessage)       ┌────────────────┐
            │ JsonRpcDispatch│ ◄──────────────────────────►│ JsonRpcDispatch│
            └────────┬───────┘                             └────────┬───────┘
                     │                                              │
                     ▼                                              ▼
            ┌────────────────┐  ── Uint8Array frames ──►   ┌────────────────┐
            │   Transport    │ ◄──────────────────────────►│   Transport    │
            │ (in-mem|stdio| │                             │ (in-mem|stdio| │
            │   http+sse)    │                             │   http+sse)    │
            └────────────────┘                             └────────────────┘

Both ends share the same JsonRpcDispatcher. The dispatcher owns id allocation, the pending-request map (Map<JsonRpcId, { resolve, reject, signal }>), method handler registry, batch+notification handling, and timeout enforcement. MCPServer and MCPClient are thin domain wrappers around a dispatcher: they expose registration / call surfaces and translate method names ↔ Zod-validated params.

Universal-tier transport strategy

stdio and http+sse would naively pull Node/browser-only modules into a universal package. We dodge that by structurally typing the transport's dependencies:

  • StdioTransport accepts readable: AsyncIterable<Uint8Array> and writable: { write(chunk: Uint8Array): Promise<void>; close(): Promise<void> }. Consumers wire Readable.toWeb(process.stdin), Bun.stdin.stream(), or any other source.
  • HttpSseTransport accepts fetchImpl: typeof fetch and eventSourceImpl: EventSourceCtor. Production code can default both to globalThis.fetch and globalThis.EventSource; the package itself imports neither.

This is the same dependency-injection pattern @suaveplan/health-check uses for its HTTP / TCP probes.

JSON-RPC framing

MCP rides JSON-RPC 2.0:

{ "jsonrpc": "2.0", "id": "req-1", "method": "tools/call", "params": { … } }
{ "jsonrpc": "2.0", "id": "req-1", "result": { … } }
{ "jsonrpc": "2.0", "id": "req-1", "error": { "code": -32601, "message": "Method not found" } }
{ "jsonrpc": "2.0", "method": "notifications/progress", "params": { … } }   // no id => notification

Standard error codes from JSON-RPC 2.0 (-32700 parse, -32600 invalid request, -32601 method-not-found, -32602 invalid-params, -32603 internal) plus MCP-reserved codes (-32001 unauthorized, -32002 request-cancelled). The dispatcher exposes these as constants and maps them to MCP_CODES namespace strings.

Method registry

The dispatcher carries handlers: Map<MethodName, (params, ctx) => Promise<unknown>>. MCPServer.start(transport) registers handlers for all server-side methods; MCPClient.connect(transport) registers handlers for sampling, roots, and notification listeners. Symmetric handler registration means the same dispatcher code runs on both ends.

Abort + progress

When a caller passes an AbortSignal:

  1. The dispatcher sends a notifications/cancelled message with the request id.
  2. The pending-request entry is rejected with MCPError(REQUEST_CANCELLED).
  3. Server-side: on receiving notifications/cancelled, the dispatcher looks up the in-flight handler invocation by id and aborts its AbortController. The handler observes the abort and stops.

Progress is the inverse: when a tool handler is invoked it receives a progress(token, value, total?) callback. Calling it sends a notifications/progress to the client. The client's callTool accepts a onProgress callback in its options bag.

Resource subscriptions

MCPServer.markResourceUpdated(uri) fans out notifications/resources/updated to every subscribed dispatcher session. Subscriptions live in Map<DispatcherId, Set<URI>>. The server tracks dispatcher ids on transport attach.

File layout

packages/devtools/tooling/mcp/
  package.json
  README.md
  tsconfig.lib.json
  vite.config.ts
  vitest.config.ts
  src/
    index.ts                              # barrel — explicit named exports
    types/types.ts                        # shared scalar types (JsonRpcId, ProgressToken, Cursor, MethodName)
    constants/index.ts                    # method-name constants, default protocol version
    error-codes/error-codes.ts (.md, .test.ts)
    schemas/schemas.ts (.md, .test.ts)    # every Zod schema for wire types
    telemetry/telemetry.ts (.md, .test.ts)
    events/events.ts (.md, .test.ts)
    jsonrpc/
      dispatcher/
        dispatcher.ts (.md, .types.ts, .test.ts, index.ts)
    transport/
      transport/
        transport.ts (.md, .types.ts, index.ts)
      in-memory/
        in-memory.ts (.md, .test.ts, index.ts)
      stdio/
        stdio.ts (.md, .test.ts, index.ts)
      http-sse/
        http-sse.ts (.md, .test.ts, index.ts)
    server/
      mcp-server/
        mcp-server.ts (.md, .types.ts, .test.ts, index.ts)
    client/
      mcp-client/
        mcp-client.ts (.md, .types.ts, .test.ts, index.ts)
    e2e/
      handshake.test.ts                   # full server↔client over in-memory + stdio (no production .ts)

Test strategy

  • Per-feature unit tests cover happy + error paths exhaustively (100 % coverage gate).
  • e2e/handshake.test.ts wires a MCPServer + MCPClient over InMemoryTransport.pair() and walks initialize → tools/list → tools/call (with progress + abort) → resources/list → resources/read → resources/subscribe → mark-updated → notification observed → prompts/list → prompts/get → completion/complete → sampling/createMessage → roots/list → logging/setLevel → disconnect.
  • Stdio transport tested with two ReadableStream / WritableStream pairs in memory — no real process.stdin.
  • HTTP+SSE transport tested with injected fetchImpl that returns canned responses and eventSourceImpl that emits events from a queue.

Error code budget

MCP.PROTOCOL_INVALID          - malformed JSON-RPC envelope
MCP.METHOD_NOT_FOUND          - method not registered
MCP.INVALID_PARAMS            - Zod validation failure on params
MCP.INTERNAL_ERROR            - handler threw
MCP.UNAUTHORIZED              - authorize() returned false
MCP.REQUEST_CANCELLED         - AbortSignal aborted
MCP.TIMEOUT                   - request exceeded timeoutMs
MCP.TRANSPORT_CLOSED          - send() after close()
MCP.TRANSPORT_ERROR           - underlying transport raised
MCP.NOT_INITIALIZED           - method called before initialize handshake
MCP.ALREADY_CONNECTED         - connect() called twice
MCP.DUPLICATE_REGISTRATION    - tool/resource/prompt name collision
MCP.NOT_FOUND                 - lookup miss (tool, resource, prompt, root)
MCP.PROTOCOL_VERSION_MISMATCH - initialize negotiated incompatible version
MCP.HANDLER_FAILED            - generic handler raise (wraps cause)

15 codes — every one reachable from a real call site exercised by tests (per AGENTS §0a parity rule and rule 7).

Telemetry

Meter name: @suaveplan/mcp. Instruments:

  • mcp_request_total counter — { direction, method, status } (status: success | error | cancelled).
  • mcp_notification_total counter — { direction, method }.
  • mcp_request_duration_ms histogram — { direction, method }.

direction is client (initiated by MCPClient) or server (initiated by MCPServer).

EventBus payloads

MCPEventMap:

"connection:opened"    -> { side: "client" | "server", dispatcherId: string }
"connection:closed"    -> { side; dispatcherId; reason?: string }
"request:received"     -> { side; method: string; id: JsonRpcId }
"request:completed"    -> { side; method; id; durationMs: number; outcome: "success" | "error" | "cancelled" }
"notification:emitted" -> { side; method; params: unknown }

Trade-offs

  • No WebSocket today. Adding it would force a pre-emptive port of @suaveplan/network-websockets (multi-day work). The Transport interface accepts the eventual adapter without source changes.
  • Newline-delimited JSON over stdio is the MCP spec default. It cannot frame embedded newlines inside string content, which Zod-encoded JSON never produces (JSON strings escape control characters), so this is safe.
  • HTTP+SSE rather than streamable-http. MCP 2024-11-05 supports both; SSE is the more widely deployed and is straightforward over injected EventSource. A future change can add streamable-http.