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:
StdioTransportacceptsreadable: AsyncIterable<Uint8Array>andwritable: { write(chunk: Uint8Array): Promise<void>; close(): Promise<void> }. Consumers wireReadable.toWeb(process.stdin),Bun.stdin.stream(), or any other source.HttpSseTransportacceptsfetchImpl: typeof fetchandeventSourceImpl: EventSourceCtor. Production code can default both toglobalThis.fetchandglobalThis.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:
- The dispatcher sends a
notifications/cancelledmessage with the request id. - The pending-request entry is rejected with
MCPError(REQUEST_CANCELLED). - Server-side: on receiving
notifications/cancelled, the dispatcher looks up the in-flight handler invocation by id and aborts itsAbortController. 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.tswires aMCPServer+MCPClientoverInMemoryTransport.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/WritableStreampairs in memory — no realprocess.stdin. - HTTP+SSE transport tested with injected
fetchImplthat returns canned responses andeventSourceImplthat 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_totalcounter —{ direction, method, status }(status: success | error | cancelled).mcp_notification_totalcounter —{ direction, method }.mcp_request_duration_mshistogram —{ 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 addstreamable-http.