Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
# 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`.