165 lines
9.7 KiB
Markdown
165 lines
9.7 KiB
Markdown
# 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<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:
|
|
|
|
```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<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
|
|
|
|
```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`.
|