first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
# @suaveplan/mcp - Universal Model Context Protocol (MCP) server + client with JSON-RPC dispatcher, pluggable transports (in-memory, stdio, HTTP+SSE), AbortSignal cancellation, progress notifications, authorize hook, OTel meter, and Zod-derived wire types.
Universal Model Context Protocol (MCP) server + client with a pluggable transport stack, JSON-RPC dispatcher, AbortSignal cancellation, progress notifications, authorize hook, and OTel meter.
<!-- begin:auto-docs -->
## Documentation tree
- [mcp-client](./src/client/mcp-client/mcp-client.md) — `MCPClient` is the consumer-side surface of the Model Context Protocol. `connect(transport)` performs the MCP initializ…
- [mcp-client](./src/client/mcp-client/mcp-client.md) — `MCPClient` is the consumer-side surface of the Model Context Protocol. `connect(transport)` performs the MCP initializ…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/mcp` raises three typed error classes — `MCPError`, `MCPTransportError`, and `MCPProtocolError` — for every…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/mcp` raises three typed error classes — `MCPError`, `MCPTransportError`, and `MCPProtocolError` — for every…
- [events](./src/events/events.md) — `events.ts` declares `MCPEventMap` — the typed payload map shared by `MCPServer` and `MCPClient` via the `EventBus<MCPE…
- [events](./src/events/events.md) — `events.ts` declares `MCPEventMap` — the typed payload map shared by `MCPServer` and `MCPClient` via the `EventBus<MCPE…
- [dispatcher](./src/jsonrpc/dispatcher/dispatcher.md) — `JsonRpcDispatcher` is the symmetric JSON-RPC 2.0 engine sitting under `MCPServer` and `MCPClient`. It owns id allocati…
- [dispatcher](./src/jsonrpc/dispatcher/dispatcher.md) — `JsonRpcDispatcher` is the symmetric JSON-RPC 2.0 engine sitting under `MCPServer` and `MCPClient`. It owns id allocati…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` is the single source of truth for every wire shape in `@suaveplan/mcp`. Every public TypeScript type for a…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` is the single source of truth for every wire shape in `@suaveplan/mcp`. Every public TypeScript type for a…
- [mcp-server](./src/server/mcp-server/mcp-server.md) — `MCPServer` is the host-side surface of the Model Context Protocol. Owners register tools, resources, prompts, and root…
- [mcp-server](./src/server/mcp-server/mcp-server.md) — `MCPServer` is the host-side surface of the Model Context Protocol. Owners register tools, resources, prompts, and root…
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OpenTelemetry meter `@suaveplan/mcp` and exposes three instruments (`mcp_request_total`, `mcp…
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OpenTelemetry meter `@suaveplan/mcp` and exposes three instruments (`mcp_request_total`, `mcp…
- [http-sse](./src/transport/http-sse/http-sse.md) — `HttpSseTransport` rides MCP over a duplex pair: outbound requests via HTTP POST to `endpoint` and inbound server-pushe…
- [http-sse](./src/transport/http-sse/http-sse.md) — `HttpSseTransport` rides MCP over a duplex pair: outbound requests via HTTP POST to `endpoint` and inbound server-pushe…
- [in-memory](./src/transport/in-memory/in-memory.md) — `InMemoryTransport.pair()` creates two `Transport` instances wired head-to-head. A `send` on one is immediately dispatc…
- [in-memory](./src/transport/in-memory/in-memory.md) — `InMemoryTransport.pair()` creates two `Transport` instances wired head-to-head. A `send` on one is immediately dispatc…
- [stdio](./src/transport/stdio/stdio.md) — `StdioTransport` frames JSON-RPC messages over a newline-delimited JSON byte stream — the MCP spec default for sub-proc…
- [stdio](./src/transport/stdio/stdio.md) — `StdioTransport` frames JSON-RPC messages over a newline-delimited JSON byte stream — the MCP spec default for sub-proc…
- [transport](./src/transport/transport/transport.md) — The `Transport` interface is the contract that decouples `JsonRpcDispatcher` from any specific I/O backend. Concrete tr…
- [transport](./src/transport/transport/transport.md) — The `Transport` interface is the contract that decouples `JsonRpcDispatcher` from any specific I/O backend. Concrete tr…
- [types](./src/types/types.md) — `types/types.ts` collects the structural / scalar types `@suaveplan/mcp` uses across modules that are NOT part of the w…
- [types](./src/types/types.md) — `types/types.ts` collects the structural / scalar types `@suaveplan/mcp` uses across modules that are NOT part of the w…
<!-- end:auto-docs -->
## Quick start
```ts
import { InMemoryTransport, MCPClient, MCPServer } from "@suaveplan/mcp";
const server = new MCPServer({ info: { name: "demo-server", version: "0.1.0" } });
server.registerTool({
name: "echo",
description: "Echo back the input",
inputSchema: { type: "object", properties: { msg: { type: "string" } } },
async handler({ msg }) {
return { content: [{ type: "text", text: String(msg) }] };
},
});
const client = new MCPClient({ info: { name: "demo-client", version: "0.1.0" } });
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
const result = await client.callTool("echo", { msg: "hello" });
console.log(result.content);
await client.disconnect();
await server.stop();
```
## Transport options
| Transport | Universal? | Subpath export | Notes |
| --- | --- | --- | --- |
| `InMemoryTransport.pair()` | yes | `./transport/in-memory` | In-process bridge; used by tests and embedded hosts. |
| `createStdioTransport({ readable, writable })` | yes | `./transport/stdio` | Newline-delimited JSON. Caller injects byte streams; no Node imports. |
| `createHttpSseTransport({ endpoint, fetchImpl, eventSourceImpl })` | yes | `./transport/http-sse` | HTTP POST out, SSE in. Caller injects platform globals or fakes. |
The `Transport` interface is exported so consumers can ship their own (e.g. WebSocket once `@suaveplan/network-websockets` lands).
## Surface
| Module | Subpath | Highlights |
| --- | --- | --- |
| Errors | `./error-codes` | `MCP_CODES` (15 codes), `MCPError`, `MCPTransportError`, `MCPProtocolError`. |
| Schemas | `./schemas` | Every wire type as a Zod schema + `z.infer` type. |
| JSON-RPC | `./jsonrpc` | `JsonRpcDispatcher` — symmetric request/notification engine. |
| Server | `./server` | `MCPServer` — tool/resource/prompt/root registration, authorize hook, sampling+completion providers, progress, abort. |
| Client | `./client` | `MCPClient` — initialize handshake, cached lists, `callTool` / `readResource` / `getPrompt` / `complete` / `subscribeResource`. |
| Telemetry | `./telemetry` | `mcp_request_total` counter, `mcp_notification_total` counter, `mcp_request_duration_ms` histogram. |
| Events | `./events` | `MCPEventMap` + `createMCPEventBus()`. |
## MCP methods supported
All host-side methods of the MCP 2024-11-05 spec: `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/templates/list`, `resources/read`, `resources/subscribe`, `resources/unsubscribe`, `prompts/list`, `prompts/get`, `completion/complete`, `sampling/createMessage`, `roots/list`, `logging/setLevel`. All notifications: `notifications/initialized`, `notifications/cancelled`, `notifications/progress`, `notifications/message`, `notifications/resources/updated`, `notifications/resources/list_changed`, `notifications/tools/list_changed`, `notifications/prompts/list_changed`, `notifications/roots/list_changed`.
## Error codes
`MCP.PROTOCOL_INVALID`, `MCP.METHOD_NOT_FOUND`, `MCP.INVALID_PARAMS`, `MCP.INTERNAL_ERROR`, `MCP.UNAUTHORIZED`, `MCP.REQUEST_CANCELLED`, `MCP.TIMEOUT`, `MCP.TRANSPORT_CLOSED`, `MCP.TRANSPORT_ERROR`, `MCP.NOT_INITIALIZED`, `MCP.ALREADY_CONNECTED`, `MCP.DUPLICATE_REGISTRATION`, `MCP.NOT_FOUND`, `MCP.PROTOCOL_VERSION_MISMATCH`, `MCP.HANDLER_FAILED`.
## License
MIT — © Suaveplan Team.

View File

@@ -0,0 +1,76 @@
# mcp-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MCPClient` is the consumer-side surface of the Model Context Protocol. `connect(transport)` performs the MCP initialize handshake, negotiates the protocol version, populates the cached lists of remote tools / resources / prompts / roots, and then exposes every standard method (`callTool`, `readResource`, `subscribeResource`, `getPrompt`, `complete`, `setLogLevel`, `ping`) plus sampling + roots + completion provider slots the server can call back into.
## Features
- Initialize handshake: protocol-version negotiation, capabilities exchange, `notifications/initialized` emission, automatic refresh of every cached list.
- `callTool` with optional `onProgress` callback — auto-generates a progress token, attaches it to the outgoing request, and forwards `notifications/progress` events to the callback.
- `AbortSignal` cancellation — propagates as `notifications/cancelled` over the wire.
- `subscribeResource(uri)` — registers interest; the client auto-refreshes the resource and re-emits a `notification:emitted` event on `notifications/resources/updated`.
- Sampling / roots / completion providers — call-backs the server invokes; the client side of the bidirectional MCP surface.
- Cached lists with auto-refresh on `notifications/{tools,resources,prompts}/list_changed`.
- Public `events: EventBus<MCPEventMap>` for lifecycle observability.
## Usage: Basic Example
```ts
import { InMemoryTransport, MCPClient, MCPServer } from "@suaveplan/mcp";
const server = new MCPServer({ info: { name: "demo-server", version: "0.1.0" } });
const client = new MCPClient({ info: { name: "demo-client", version: "0.1.0" } });
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
console.log(client.tools());
const result = await client.callTool("echo", { msg: "hi" });
```
## Usage: Advanced Example
```ts
import { MCPClient } from "@suaveplan/mcp";
const client = new MCPClient({
info: { name: "ide", version: "0.1.0" },
capabilities: { sampling: {}, roots: { listChanged: true } },
defaultTimeoutMs: 5_000,
});
client.setSamplingProvider(async (params, ctx) => {
const completion = await myLLM.complete(params, { signal: ctx.signal });
return { role: "assistant", content: { type: "text", text: completion }, model: "demo" };
});
const ac = new AbortController();
setTimeout(() => ac.abort(), 1_000);
const result = await client.callTool(
"slow",
{ ms: 5_000 },
{ signal: ac.signal, onProgress: (p, total) => log(`progress ${p}/${total ?? "?"}`) },
);
```
## API Reference
- `new MCPClient(options: MCPClientOptions)` — `info`, optional `capabilities`, `acceptedProtocolVersions`, `events`, `clock`, `idGenerator`, `defaultTimeoutMs`.
- Lifecycle: `connect(transport)`, `disconnect(reason?)`, `initialized`.
- Refresh: `refreshTools`, `refreshResources`, `refreshResourceTemplates`, `refreshPrompts`, `refreshRoots`, `refreshAll`.
- Methods: `ping`, `callTool`, `readResource`, `subscribeResource`, `unsubscribeResource`, `getPrompt`, `complete`, `setLogLevel`.
- Providers: `setSamplingProvider`, `setRootsProvider`, `setCompletionProvider`.
- Snapshots: `tools`, `resources`, `resourceTemplates`, `prompts`, `roots`, `snapshot`.
- Hooks: `onLogMessage`, `registerRoot`.
- `events: EventBus<MCPEventMap>`.
## Implementation Notes
`connect` does not return until the initialize handshake completes and the four list refreshes finish; if any step throws the client disconnects the transport cleanly before propagating the error. Progress tokens are auto-generated only when the caller supplies an `onProgress` callback — otherwise no token is sent (per MCP spec: clients must not send progress tokens they don't intend to consume). The client never re-enters `callTool` from inside an `onProgress` callback; the progress correlation table is keyed by token and lookups are O(n) across pending requests, which is fine for the spec's expected concurrency (a few in-flight requests, not thousands). `disconnect` aborts in-flight requests by closing the dispatcher first, so the rejection cascade fires before the `connection:closed` event is emitted.

View File

@@ -0,0 +1,56 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/mcp` raises three typed error classes — `MCPError`, `MCPTransportError`, and `MCPProtocolError` — for every failure mode the package exposes. Codes are registered via `defineCodes` from `@suaveplan/error`, giving consumers a stable fully-qualified string (e.g. `"MCP.UNAUTHORIZED"`) they can pattern-match on without `instanceof` chains. Discriminating on `error.code` keeps consumers, retries, dashboards, and i18n catalogues in lockstep with the package no matter how the class hierarchy evolves.
## Features
- Stable code registry: 15 codes spanning protocol, transport, lifecycle, registration, and handler failures.
- Three classes (`MCPError`, `MCPTransportError`, `MCPProtocolError`) — extends `SuaveplanError<MCPCode>` so callers get the cause-chain, redaction, OTel adapter, and i18n resolver hook for free.
- Type-safe `code` discriminator — no `instanceof` required.
- Public constructors delegate to `super` per `@suaveplan/error` policy.
## Usage: Basic Example
```ts
import { MCP_CODES, MCPError } from "@suaveplan/mcp/error-codes";
throw new MCPError(MCP_CODES.NOT_FOUND, 'Tool "echo" not found');
```
## Usage: Advanced Example
```ts
import { MCP_CODES, MCPError, MCPTransportError } from "@suaveplan/mcp";
try {
await client.callTool("slow", { ms: 10_000 }, { timeoutMs: 100 });
} catch (error) {
if (error instanceof MCPTransportError) {
metrics.record("mcp.transport_error");
return;
}
if (error instanceof MCPError && error.code === MCP_CODES.TIMEOUT) {
logger.warn("call timed out", { code: error.code });
return;
}
throw error;
}
```
## API Reference
- `MCP_CODES` — frozen object whose values are the fully-qualified code strings (`"MCP.<LEAF>"`).
- `MCPCode` — string-literal union of every value of `MCP_CODES`.
- `MCPError extends SuaveplanError<MCPCode>` — constructor: `(code, message, options?)`.
- `MCPTransportError extends MCPError` — same constructor; thrown when the underlying transport rejects a `send` (`TRANSPORT_CLOSED`) or surfaces an upstream failure (`TRANSPORT_ERROR`).
- `MCPProtocolError extends MCPError` — thrown for `PROTOCOL_INVALID` and `PROTOCOL_VERSION_MISMATCH`.
## Implementation Notes
The registry is declared via `defineCodes("MCP", [...] as const)` which asserts the array is a literal tuple and produces a `Record<Key, "MCP.Key">` mapping. Adding a code anywhere except the tuple breaks both the type and the runtime registry — the type-system guarantees the two stay in sync. Every registered code is reachable from a real call site exercised by a test (see `error-codes.test.ts`). The constructor delegates to `SuaveplanError`'s protected constructor; subclasses keep `name` distinct so logs and traces can still distinguish transport from protocol failures even though they share the typed-code surface.

View File

@@ -0,0 +1,59 @@
# events
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`events.ts` declares `MCPEventMap` — the typed payload map shared by `MCPServer` and `MCPClient` via the `EventBus<MCPEventMap>` surface. Subscribers can observe connection lifecycle (`opened` / `closed`), request lifecycle (`received` / `completed`), and notification fan-out without reaching into private dispatcher state. `createMCPEventBus()` returns a ready-to-use `EventBus` instance carrying this map — the recommended way for callers to share a bus across multiple servers or clients in the same process.
## Features
- `connection:opened` — fired when a dispatcher attaches to a transport. Payload: `{ side, dispatcherId }`.
- `connection:closed` — fired when a dispatcher closes. Payload: `{ side, dispatcherId, reason? }`.
- `request:received` — fired when an incoming request arrives. Payload: `{ side, method, id }`.
- `request:completed` — fired when a request handler finishes (success, error, or cancellation). Payload: `{ side, method, id, durationMs, outcome }`.
- `notification:emitted` — fired when a notification is sent. Payload: `{ side, method, params }`.
## Usage: Basic Example
```ts
import { createMCPEventBus } from "@suaveplan/mcp/events";
import { MCPServer } from "@suaveplan/mcp";
const events = createMCPEventBus();
const server = new MCPServer({ info: { name: "demo", version: "0.1.0" }, events });
events.on("request:completed", (e) => {
console.log(`${e.method} ${String(e.id)} ${e.outcome} ${String(e.durationMs)}ms`);
});
```
## Usage: Advanced Example
```ts
import { createMCPEventBus, type MCPEventMap } from "@suaveplan/mcp";
import { MCPClient, MCPServer, InMemoryTransport } from "@suaveplan/mcp";
const bus = createMCPEventBus();
const server = new MCPServer({ info: { name: "s", version: "0.1.0" }, events: bus });
const client = new MCPClient({ info: { name: "c", version: "0.1.0" }, events: bus });
const records: Array<MCPEventMap["request:completed"]> = [];
bus.on("request:completed", (e) => records.push(e));
const [a, b] = InMemoryTransport.pair();
await server.start(a);
await client.connect(b);
// records now carries the initialize + tools/list + resources/list + prompts/list + roots/list events.
```
## API Reference
- `MCPEventMap` — `Record<string, unknown>` with explicit per-key payload shapes.
- `createMCPEventBus()` — returns `EventBus<MCPEventMap>` using the canonical defaults from `@suaveplan/events`.
## Implementation Notes
Both `MCPServer` and `MCPClient` accept an `events` option in their constructor — callers may share one bus across a fleet to drive monitors and dashboards. When not provided, each side constructs its own bus via `createMCPEventBus()`. The `side` field carries `"client"` or `"server"` so downstream subscribers can split metrics per direction without parsing the dispatcher id. `request:completed` always fires exactly once per request — on success, on error, or on cancellation — making it the safe place to bind per-request latency dashboards.

View File

@@ -0,0 +1,68 @@
# dispatcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`JsonRpcDispatcher` is the symmetric JSON-RPC 2.0 engine sitting under `MCPServer` and `MCPClient`. It owns id allocation, the pending-request map, request/response correlation, notification fan-out, abort propagation (in both directions), per-request timeout enforcement, and Zod-validated handler dispatch. Both the server and client wire one dispatcher to a `Transport`; the only difference between the two sides is which methods each one registers — the dispatcher itself is direction-agnostic.
## Features
- `request(method, params, options?)` — async, returns `Promise<unknown>`; correlates with the matching response or rejects on abort/timeout/transport-close.
- `notify(method, params?)` — fire-and-forget notifications.
- `registerHandler(method, fn)` / `unregisterHandler(method)` — server-side method handlers.
- `onNotification(method, fn)` — subscribe to incoming notifications.
- `paramSchemas` map — per-method Zod schemas validated before the handler runs; rejections respond with JSON-RPC `-32602`.
- Cancellation: `AbortSignal` on a caller sends `notifications/cancelled` and rejects with `MCPError(REQUEST_CANCELLED)`. Incoming `notifications/cancelled` aborts the matching in-flight handler.
- Progress: handlers receive `ctx.sendProgress(token, progress, total?)` which emits `notifications/progress`; callers' `onProgress` hook is invoked when correlated by token.
- OTel + telemetry: every request/notification increments the package meter via `recordRequest` / `recordNotification`.
## Usage: Basic Example
```ts
import { JsonRpcDispatcher, InMemoryTransport } from "@suaveplan/mcp";
const [a, b] = InMemoryTransport.pair();
const left = new JsonRpcDispatcher({ side: "server", transport: a, dispatcherId: "L" });
const right = new JsonRpcDispatcher({ side: "client", transport: b, dispatcherId: "R" });
left.registerHandler("ping", async () => ({ ok: true }));
const result = await right.request("ping", undefined);
```
## Usage: Advanced Example
```ts
import { JsonRpcDispatcher, MCPError, MCP_CODES } from "@suaveplan/mcp";
import z from "zod";
const schema = z.object({ msg: z.string() });
const d = new JsonRpcDispatcher({
side: "server",
transport,
dispatcherId: "demo",
paramSchemas: { "echo": schema },
});
d.registerHandler("echo", async (params, ctx) => {
const ac = new AbortController();
ctx.signal.addEventListener("abort", () => ac.abort());
ctx.sendProgress("p", 1, 3);
if (ctx.signal.aborted) throw new MCPError(MCP_CODES.REQUEST_CANCELLED, "cancelled");
return { echoed: (params as { msg: string }).msg };
});
```
## API Reference
- `new JsonRpcDispatcher(options: DispatcherOptions)` — constructor takes the transport, dispatcher id, optional clock + id-generator, optional listeners + param-schema map.
- `request(method, params, options?: { signal?, timeoutMs?, onProgress? })` → `Promise<unknown>`.
- `notify(method, params?)` → `Promise<void>`.
- `setProgressToken(method, token)` — used by `MCPClient` to attach an auto-generated token to the most-recent pending request for forward-progress correlation.
- `close(reason?)` — rejects every pending request with `MCPTransportError(TRANSPORT_CLOSED)`, aborts every in-flight handler, and closes the transport.
## Implementation Notes
The pending map keys requests by `stringifyId(id)` so numeric and string ids never collide. `request()` schedules the abort+timeout cleanup inside the executor so cancellation paths can synchronously remove the pending entry before resolution. Notifications never carry an id; the type guards in `handleMessage` discriminate request/response/notification by the `id` + `method` + `result|error` shape. The `paramSchemas` registry is keyed by the JSON-RPC method name so adding a new validated method is a one-line registration on the server side — the dispatcher itself stays unaware of MCP-specific semantics.

View File

@@ -0,0 +1,62 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` is the single source of truth for every wire shape in `@suaveplan/mcp`. Every public TypeScript type for an MCP wire payload — JSON-RPC envelopes, initialize, tool/resource/prompt records, sampling, completion, roots, logging, progress, cancellation — is derived via `z.infer<>` from a schema declared here. The schema IS the contract, so the runtime parser and the static TS type can never drift apart: changing one is changing the other.
## Features
- JSON-RPC 2.0 envelope schemas: `JsonRpcRequestSchema`, `JsonRpcResponseSchema`, `JsonRpcNotificationSchema`, and the `JsonRpcMessageSchema` discriminated union.
- Initialize handshake: `InitializeParamsSchema`, `InitializeResultSchema`, `ClientCapabilitiesSchema`, `ServerCapabilitiesSchema`, `ImplementationSchema`.
- Tools: `ToolSchema`, `ToolsListResultSchema`, `ToolCallParamsSchema`, `ToolCallResultSchema`.
- Resources: `ResourceSchema`, `ResourceTemplateSchema`, `ResourceContentSchema`, plus `*Read` / `*Subscribe` params.
- Prompts: `PromptSchema`, `PromptArgumentSchema`, `PromptMessageSchema`, `PromptGetParamsSchema`, `PromptGetResultSchema`.
- Sampling + completion + roots + logging schemas.
- Notification params: `ProgressNotificationParamsSchema`, `CancelledNotificationParamsSchema`, `LoggingMessageParamsSchema`.
## Usage: Basic Example
```ts
import { JsonRpcRequestSchema } from "@suaveplan/mcp/schemas";
const request = JsonRpcRequestSchema.parse({
jsonrpc: "2.0",
id: 1,
method: "tools/list",
});
```
## Usage: Advanced Example
```ts
import {
type ToolCallParams,
ToolCallParamsSchema,
} from "@suaveplan/mcp";
function safeToolCall(raw: unknown): ToolCallParams | null {
const parsed = ToolCallParamsSchema.safeParse(raw);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
metrics.record("mcp.invalid_params", { path: issue.path.join(".") });
}
return null;
}
return parsed.data;
}
```
## API Reference
- Every schema is exported alongside its inferred TypeScript type (`<Name>Schema` + `<Name>`).
- Schemas validate strictly — extra fields are preserved but the inferred type only includes declared shapes.
- `JsonRpcMessageSchema` is a `z.union` over request/response/notification; downstream code uses `safeParse` to discriminate.
- `ContentItemSchema` is a `z.discriminatedUnion("type", ...)` over text, image, and inline-resource variants.
## Implementation Notes
The `import z from "zod"` form is the Bun-safe default-import idiom mandated by the suaveplan guidelines; the named `{ z }` form fails under Bun's CJS interop for the Zod 4 build. `_meta.progressToken` is `string | number | undefined` because the MCP spec allows both shapes and a missing token means progress is not requested. Server- and client-side dispatchers register a subset of these schemas as the params-validation map for the JSON-RPC dispatcher: invalid params produce a JSON-RPC `-32602` response mapped to `MCPError(INVALID_PARAMS)` carrying the failing field path.

View File

@@ -0,0 +1,87 @@
# mcp-server
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MCPServer` is the host-side surface of the Model Context Protocol. Owners register tools, resources, prompts, and roots; the server then exposes them to a remote `MCPClient` (or any spec-compliant peer) over the JSON-RPC dispatcher and a pluggable transport. Every public method documented here either mutates the registration tables, configures a hook (authorize, sampling, completion, log level), or drives the dispatcher (start / stop / direct invocation).
## Features
- Register / unregister tools, resources, resource templates, prompts, and roots — every collision raises `MCPError(DUPLICATE_REGISTRATION)`.
- Lifecycle: `start(transport)` attaches a JSON-RPC dispatcher and registers handlers for every MCP method; `stop(reason?)` rejects pending requests and aborts in-flight handlers.
- Authorize hook: `setAuthorizer(fn)` gates every method; rejection yields `-32001` mapped to `MCPError(UNAUTHORIZED)`.
- Sampling + completion providers — surfaces are pluggable so consumers can wire LLM backends.
- Progress + abort: tool handlers receive `progress(value, total?)` and an `AbortSignal`; clients see `notifications/progress` and `notifications/cancelled` over the wire.
- Resource subscriptions: `markResourceUpdated(uri)` fans out `notifications/resources/updated` to subscribed peers.
- Direct invocation API (`invokeTool`, `readResource`, `getPromptMessages`) — useful in tests and in-process callers.
## Usage: Basic Example
```ts
import { InMemoryTransport, MCPServer } from "@suaveplan/mcp";
const server = new MCPServer({ info: { name: "demo", version: "0.1.0" } });
server.registerTool({
name: "echo",
description: "Echo back the input",
inputSchema: { type: "object", properties: { msg: { type: "string" } } },
async handler({ msg }) {
return { content: [{ type: "text", text: String(msg) }] };
},
});
const [a, b] = InMemoryTransport.pair();
await server.start(a);
```
## Usage: Advanced Example
```ts
import { MCPServer } from "@suaveplan/mcp";
import z from "zod";
const server = new MCPServer({ info: { name: "admin", version: "0.1.0" } });
server.setAuthorizer(async (req) => req.method !== "tools/call" || hasPermission(req.params));
server.registerTool({
name: "deploy",
description: "Deploy a release",
inputSchema: { type: "object", properties: { tag: { type: "string" } }, required: ["tag"] },
paramsSchema: z.object({ tag: z.string().min(1) }),
async handler(args, ctx) {
for (let p = 0; p < 100 && !ctx.signal.aborted; p += 10) {
await sleep(50);
ctx.progress(p, 100);
}
return { content: [{ type: "text", text: `Deployed ${(args as { tag: string }).tag}` }] };
},
});
server.registerResource({
uri: "config://current",
name: "Active configuration",
mimeType: "application/json",
async handler() {
return { uri: "config://current", mimeType: "application/json", text: JSON.stringify(currentConfig) };
},
});
```
## API Reference
- `new MCPServer(options: MCPServerOptions)`.
- Registration: `registerTool`, `unregisterTool`, `registerResource`, `unregisterResource`, `registerResourceTemplate`, `unregisterResourceTemplate`, `registerPrompt`, `unregisterPrompt`, `registerRoot`, `unregisterRoot`.
- Lists: `listTools`, `listResources`, `listResourceTemplates`, `listPrompts`, `listRoots`.
- Hooks: `setAuthorizer`, `setSamplingProvider`, `setCompletionProvider`, `setLogLevel`.
- Direct API: `invokeTool`, `readResource`, `getPromptMessages`, `createSamplingMessage`, `listClientRoots`, `emitLog`.
- Lifecycle: `start(transport)`, `stop(reason?)`, `running`, `initialized`, `getInfo()`.
- `events: EventBus<MCPEventMap>` — subscribe to connection / request / notification lifecycle.
## Implementation Notes
`start` builds a `paramSchemas` map keyed by method name so the dispatcher validates incoming params before invoking the handler — invalid params respond with JSON-RPC `-32602` mapped to `MCPError(INVALID_PARAMS)`. List-changed notifications fan out from registration mutators only when a dispatcher is attached; the rejection is swallowed because the transport may have closed between the registration and the notify. `invokeTool` wraps any thrown error into a `MCPToolResult` with `isError: true` so JSON-RPC level errors are reserved for protocol failures, not tool-handler failures (matching the MCP spec convention). `createSamplingMessage` and `listClientRoots` use the dispatcher to call out to the connected client — these methods exist on the server side because MCP's sampling and roots flows are client-side capabilities that the server invokes.

View File

@@ -0,0 +1,58 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`telemetry` registers the OpenTelemetry meter `@suaveplan/mcp` and exposes three instruments (`mcp_request_total`, `mcp_notification_total`, `mcp_request_duration_ms`) that the dispatcher writes to on every request, response, and notification. Instruments resolve lazily through `metrics.getMeter`, so the module is safe to import in production builds that have not opted into OTel — when no provider is registered the API returns a no-op meter, the instruments stay alive, and consumers pay no cost.
## Features
- `requestCounter` — total JSON-RPC requests, tagged with `direction` (`client` / `server`), `method`, and `status` (`success` / `error` / `cancelled`).
- `notificationCounter` — total notifications crossing the dispatcher, tagged with `direction` and `method`.
- `requestDurationHistogram` — duration in ms with `unit: "ms"`, tagged with `direction` and `method`.
- Two helper functions (`recordRequest`, `recordNotification`) keep call sites tiny.
## Usage: Basic Example
```ts
import { recordRequest } from "@suaveplan/mcp/telemetry";
recordRequest("client", "tools/call", "success", 12.3);
```
## Usage: Advanced Example
```ts
import {
notificationCounter,
recordRequest,
requestDurationHistogram,
} from "@suaveplan/mcp";
// Tag the histogram from inside a wrapper that adds your own dimension.
function withTenant(tenant: string) {
return {
record(method: string, ms: number) {
requestDurationHistogram.record(ms, { direction: "client", method, tenant });
},
};
}
// Manually emit a notification metric from a custom transport bridge.
notificationCounter.add(1, { direction: "server", method: "notifications/initialized" });
```
## API Reference
- `requestCounter: Counter` — created from `meter.createCounter("mcp_request_total", { description })`.
- `notificationCounter: Counter` — `"mcp_notification_total"`.
- `requestDurationHistogram: Histogram` — `"mcp_request_duration_ms"` with `unit: "ms"`.
- `recordRequest(direction, method, outcome, durationMs)` — increments the counter and records the histogram in one call.
- `recordNotification(direction, method)` — increments the notification counter.
## Implementation Notes
`metrics.getMeter` returns a `Meter` even when no provider has been globally registered; instrument writes then become no-ops. Tests install a real `MeterProvider` in `tests/setup/polyfills.ts` BEFORE any source module imports, so the lazy binding inside this module captures the test exporter the first time it runs. Status tags use the dispatcher's `MCPRequestOutcome` type union (`success` / `error` / `cancelled`) so downstream dashboards can pivot on the same value the EventBus carries on `request:completed` events.

View File

@@ -0,0 +1,61 @@
# http-sse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HttpSseTransport` rides MCP over a duplex pair: outbound requests via HTTP POST to `endpoint` and inbound server-pushed messages via Server-Sent Events on the same endpoint. The transport is universal — it imports neither `fetch` nor `EventSource` directly; both are passed in as `fetchImpl` and `eventSourceImpl`. Production callers in browsers / Node / Bun / Deno bind the platform globals; tests bind fakes.
## Features
- Injectable `fetchImpl` and `eventSourceImpl` — no platform-global dependence.
- POSTs JSON-stringified `JsonRpcMessage` bodies with `content-type: application/json`.
- Parses each incoming SSE event payload through `JsonRpcMessageSchema` — malformed events are dropped silently.
- Maps non-2xx HTTP responses to `MCPTransportError(TRANSPORT_ERROR)` with the status code in the message.
- Inherits the closed-guard / listener registry / idempotent close from `BaseTransport`.
## Usage: Basic Example
```ts
import { createHttpSseTransport } from "@suaveplan/mcp/transport/http-sse";
const transport = createHttpSseTransport({
endpoint: "https://mcp.example.com/v1",
fetchImpl: globalThis.fetch,
eventSourceImpl: globalThis.EventSource,
});
```
## Usage: Advanced Example
```ts
import { createHttpSseTransport, type FetchLike } from "@suaveplan/mcp";
// Inject a bearer token via a wrapping fetch.
function authedFetch(token: string): FetchLike {
return async (url, init) => {
return await globalThis.fetch(url, {
...init,
headers: { ...init.headers, authorization: `Bearer ${token}` },
});
};
}
const transport = createHttpSseTransport({
endpoint: "https://mcp.example.com/v1",
fetchImpl: authedFetch(process.env.MCP_TOKEN ?? ""),
eventSourceImpl: globalThis.EventSource,
});
```
## API Reference
- `interface HttpSseTransportOptions` — `{ endpoint, fetchImpl, eventSourceImpl }`.
- `class HttpSseTransport extends BaseTransport` — constructor opens an `EventSource` against `endpoint` and subscribes to `"message"` events.
- `createHttpSseTransport(options)` — convenience factory.
## Implementation Notes
The transport opens its EventSource synchronously in the constructor; the spec semantics of EventSource say the connection is established asynchronously, so callers can `send()` before the first event arrives. `sendInternal` checks `response.ok` and surfaces non-2xx as `MCPTransportError(TRANSPORT_ERROR)` carrying the status code — the dispatcher then rejects the corresponding pending request. The `EventSourceLike` interface in `types/types.ts` accepts only what the transport actually uses (`addEventListener("message", h)` + `close()`), so callers can supply any class with that surface — a polyfill, a mock, or a polling stub.

View File

@@ -0,0 +1,55 @@
# in-memory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InMemoryTransport.pair()` creates two `Transport` instances wired head-to-head. A `send` on one is immediately dispatched on the other after a microtask boundary, giving callers a fully working transport without I/O. The package's tests use it to wire `MCPServer` + `MCPClient` in the same process, and production callers embedding both sides reach for it for the same reason.
## Features
- Static `InMemoryTransport.pair()` factory returns `[a, b]`.
- Microtask-deferred delivery: callers see `send()` as truly async (consistent with stdio and HTTP transports).
- Idempotent close: closing one half closes its peer as well.
- Inherits the `closed` guard from `BaseTransport`.
## Usage: Basic Example
```ts
import { InMemoryTransport } from "@suaveplan/mcp/transport/in-memory";
const [a, b] = InMemoryTransport.pair();
a.onMessage((m) => console.log("from b:", m));
await b.send({ jsonrpc: "2.0", id: 1, method: "ping" });
```
## Usage: Advanced Example
```ts
import {
InMemoryTransport,
MCPClient,
MCPServer,
} from "@suaveplan/mcp";
const [serverSide, clientSide] = InMemoryTransport.pair();
const server = new MCPServer({ info: { name: "demo", version: "0.1.0" } });
const client = new MCPClient({ info: { name: "agent", version: "0.1.0" } });
await server.start(serverSide);
await client.connect(clientSide);
const tools = client.tools(); // Populated by the initialize-handshake refresh.
await client.disconnect();
await server.stop();
```
## API Reference
- `class InMemoryTransport extends BaseTransport` — only public API is the static factory and the inherited `Transport` methods.
- `static InMemoryTransport.pair(): [InMemoryTransport, InMemoryTransport]` — returns a wired pair.
## Implementation Notes
`sendInternal` `await`s `Promise.resolve()` before invoking `peer.dispatch(message)` so callers observe send semantics consistent with network transports: a `send` returning before the peer's listener has run. If the peer has been closed, `sendInternal` returns silently (dispatcher's `request()` then times out or remains pending until its abort signal fires) — this mirrors a half-closed pipe rather than a hard failure. `closeInternal` closes the peer too, but guards against re-entrant cascades by clearing its own `peer` reference before the recursive `close()`.

View File

@@ -0,0 +1,66 @@
# stdio
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StdioTransport` frames JSON-RPC messages over a newline-delimited JSON byte stream — the MCP spec default for sub-process child servers. The transport is universal: it accepts an injectable `readable: AsyncIterable<Uint8Array>` and a `writable: UniversalWritable`, so callers wire `Readable.toWeb(process.stdin)`, `Bun.stdin.stream()`, browser `ReadableStream`, or an in-memory queue without the package depending on any Node-only module.
## Features
- Newline-delimited JSON framing — one message per `\n`-terminated line.
- Injectable streams: no `node:fs` / `node:process` / `node:stream` imports.
- Streaming parser tolerates partial chunks via a buffer + `TextDecoder({ stream: true })`.
- Malformed lines are skipped silently; framing recovers on the next newline.
- Inherits `closed` guard, listener registry, and idempotent close from `BaseTransport`.
## Usage: Basic Example
```ts
import { createStdioTransport } from "@suaveplan/mcp/transport/stdio";
import type { JsonRpcMessage } from "@suaveplan/mcp";
const sentLines: string[] = [];
const inbox = new ReadableStream<Uint8Array>({ start() { /* … */ } });
const transport = createStdioTransport({
readable: inbox,
writable: {
async write(chunk) { sentLines.push(new TextDecoder().decode(chunk)); },
async close() {},
},
});
transport.onMessage((m: JsonRpcMessage) => console.log("rx", m));
await transport.send({ jsonrpc: "2.0", id: 1, method: "ping" });
```
## Usage: Advanced Example
```ts
import { createStdioTransport } from "@suaveplan/mcp";
// Bridge to a child process spawned by your test harness.
async function bridgeProcess(child: { stdout: AsyncIterable<Uint8Array>; stdin: WritableStream }) {
const writer = child.stdin.getWriter();
return createStdioTransport({
readable: child.stdout,
writable: {
async write(chunk) { await writer.write(chunk); },
async close() { await writer.close(); },
},
});
}
```
## API Reference
- `interface StdioTransportOptions` — `{ readable: AsyncIterable<Uint8Array>; writable: UniversalWritable }`.
- `class StdioTransport extends BaseTransport` — constructor takes `StdioTransportOptions`; starts reading asynchronously.
- `createStdioTransport(options): StdioTransport` — convenience factory mirroring the documented public API.
## Implementation Notes
The reader loop maintains an internal text buffer; each chunk is decoded with `TextDecoder({ stream: true })` so multi-byte sequences split across reads still decode cleanly. After each newline the line is `trim`-ed and `JSON.parse`-d; failures or schema rejections are dropped because line framing makes the next message independent. Send framing writes `JSON.stringify(message) + "\n"` — `JSON.stringify` always escapes control characters in strings, so a literal newline in payload text becomes `\\n` and never breaks the framing. `closeInternal` flips a `stopped` flag the reader checks on each iteration and then closes the writable side; calling close while a long read is pending lets the reader exit on its next `for await` step.

View File

@@ -0,0 +1,68 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `Transport` interface is the contract that decouples `JsonRpcDispatcher` from any specific I/O backend. Concrete transports — `InMemoryTransport`, `StdioTransport`, `HttpSseTransport` — implement `send` / `onMessage` / `close` and rely on `BaseTransport` for closed-flag bookkeeping, listener registration, and the guarded send path that raises `MCPTransportError(TRANSPORT_CLOSED)` when callers send after close.
## Features
- `Transport` interface: `send(message)`, `onMessage(handler)`, `close()`, `closed` getter.
- `BaseTransport` abstract class: implements the closed flag, listener set, and `send` guard; subclasses only implement `sendInternal` and call `dispatch` when they receive an inbound message.
- Idempotent `close()` — calling twice is a no-op.
## Usage: Basic Example
```ts
import { BaseTransport } from "@suaveplan/mcp/transport";
class LoopbackTransport extends BaseTransport {
private peer?: LoopbackTransport;
static pair() {
const a = new LoopbackTransport();
const b = new LoopbackTransport();
a.peer = b; b.peer = a;
return [a, b] as const;
}
protected async sendInternal(message: unknown) {
this.peer?.["dispatch"](message as never);
}
}
```
## Usage: Advanced Example
```ts
import { BaseTransport, type Transport } from "@suaveplan/mcp";
import type { JsonRpcMessage } from "@suaveplan/mcp/schemas";
class LoggingTransport extends BaseTransport {
constructor(private readonly inner: Transport) {
super();
inner.onMessage((m) => this["dispatch"](m as JsonRpcMessage));
}
protected async sendInternal(message: JsonRpcMessage) {
console.log("→", message);
await this.inner.send(message);
}
protected async closeInternal() {
await this.inner.close();
}
}
```
## API Reference
- `interface Transport` — see source for the four members.
- `abstract class BaseTransport implements Transport`.
- `BaseTransport.send(message)` — guarded: raises `MCPTransportError(MCP_CODES.TRANSPORT_CLOSED)` if `closed === true`.
- `BaseTransport.onMessage(handler)` — returns an unsubscribe function.
- `BaseTransport.close()` — idempotent; calls `closeInternal()` and clears the listener set.
- Subclasses override `sendInternal(message)` and optionally `closeInternal()`; they call the protected `dispatch(message)` when an inbound frame arrives.
## Implementation Notes
`dispatch` is `protected` rather than public so external callers can never spoof incoming messages — only the subclass that parses the wire can publish a message to the listener set. The closed flag is set before `closeInternal` runs so any reentrant `send` raised by cleanup logic still fails cleanly. The dispatcher passes itself directly to `Transport.onMessage`; the returned unsubscribe is stored and invoked on `dispatcher.close()` so an idle transport stops fanning messages back into a dead dispatcher.

View File

@@ -0,0 +1,70 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types/types.ts` collects the structural / scalar types `@suaveplan/mcp` uses across modules that are NOT part of the wire format. Wire shapes live in `schemas/schemas.ts` and are derived from Zod schemas; this module is the home for transport-layer plumbing types — `UniversalWritable`, `EventSourceLike`, `EventSourceCtor`, `FetchLike` — plus scalar aliases that recur in many signatures (`JsonRpcId`, `ProgressToken`, `Cursor`, `MCPDirection`, `MCPRequestOutcome`, `MCPLoggingLevel`).
## Features
- `JsonRpcId`, `ProgressToken`, `Cursor` — scalar aliases keeping public signatures self-documenting.
- `MCPLoggingLevel` — string-literal union mirroring the wire `LoggingLevelSchema` (`debug`/`info`/`notice`/`warning`/`error`/`critical`/`alert`/`emergency`).
- `MCPDirection` (`"client" | "server"`) — used by telemetry tags and event payloads.
- `MCPRequestOutcome` (`"success" | "error" | "cancelled"`) — used by telemetry tags and the `request:completed` event.
- `UniversalWritable` — minimal `{ write(chunk): Promise<void>; close(): Promise<void> }` surface accepted by `StdioTransport`.
- `EventSourceLike` + `EventSourceCtor` — minimal contracts that `HttpSseTransport` accepts.
- `FetchLike` — narrowed `fetch` signature accepted by `HttpSseTransport`.
## Usage: Basic Example
```ts
import type { MCPDirection, MCPRequestOutcome } from "@suaveplan/mcp";
function recordOutcome(direction: MCPDirection, outcome: MCPRequestOutcome): void {
metrics.counter("mcp.requests", { direction, outcome });
}
```
## Usage: Advanced Example
```ts
import type { FetchLike, UniversalWritable } from "@suaveplan/mcp";
// Wrap node:fs streams behind the UniversalWritable shape so we never import them
// at the package boundary.
async function fileSink(path: string): Promise<UniversalWritable> {
const { createWriteStream } = await import("node:fs");
const handle = createWriteStream(path);
return {
async write(chunk: Uint8Array): Promise<void> {
await new Promise<void>((resolve, reject) =>
handle.write(chunk, (err) => (err ? reject(err) : resolve())),
);
},
async close() {
await new Promise<void>((resolve) => handle.end(resolve));
},
};
}
const stubFetch: FetchLike = async () => ({ ok: true, status: 200, async text() { return "{}"; } });
```
## API Reference
- `JsonRpcId = string | number`.
- `ProgressToken = string | number`.
- `Cursor = string`.
- `MCPLoggingLevel` — see above.
- `MCPDirection`, `MCPRequestOutcome` — see above.
- `UniversalWritable` — `{ write(chunk: Uint8Array): Promise<void>; close(): Promise<void> }`.
- `EventSourceLike` — `{ addEventListener("message", h): void; close(): void }`.
- `EventSourceCtor` — `new (url: string) => EventSourceLike`.
- `FetchLike` — narrowed fetch signature returning `{ ok, status, text() }`.
## Implementation Notes
Keeping these structural types out of the Zod-schema module is intentional: schemas describe **wire** types, this file describes **runtime plumbing**. Universal purity is enforced by NOT importing platform-globals here — `FetchLike` is structurally narrower than the DOM `fetch` because the package only POSTs JSON, never multipart, never streamed bodies, so the narrower contract preserves drop-in compatibility with the platform global while keeping the type self-documenting.