# 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 ```text ┌───────────────────────────┐ ┌───────────────────────────┐ │ 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`), 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` and `writable: { write(chunk: Uint8Array): Promise; close(): Promise }`. 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: ```jsonc { "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 Promise>`. `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>`. The server tracks dispatcher ids on transport attach. ## File layout ```text 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`.