From 0bc985723bed95f82fd8dcf94cf770b4c04066c3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 22:17:16 -0700 Subject: [PATCH 01/28] feat(client): speak MCP 2026-07-28 --- AGENTS.md | 38 +- README.md | 49 +- e2e/protocol-2026.test.ts | 87 ++ src/client.test.ts | 3 + src/client.ts | 1874 +++++++++++++++++++++++++++++++++-- src/hub/discover.ts | 9 +- src/index.ts | 48 + src/oauth-discovery.test.ts | 197 ++++ src/oauth-discovery.ts | 228 +++++ src/protocol-2026.test.ts | 925 +++++++++++++++++ src/sidecar-bundle.ts | 64 +- src/tool.ts | 61 +- 12 files changed, 3447 insertions(+), 136 deletions(-) create mode 100644 e2e/protocol-2026.test.ts create mode 100644 src/protocol-2026.test.ts diff --git a/AGENTS.md b/AGENTS.md index a8c7f80..05ea6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,15 @@ ## Purpose -`@corbits/mcp` turns each tool on a remote MCP server (streamable HTTP, 2025-03-26 spec) into its own `@intx/agent` tool for the Interchange sidecar. It owns the MCP client, tool naming and the ask floor, the sidecar-bundle factory, the hub catalog-discovery route and MCP OAuth discovery. It does not own the OAuth login itself (`@corbits/oauth-core`) or credential storage (the hub). +`@corbits/mcp` turns each tool on a remote MCP server into its own `@intx/agent` tool for the Interchange sidecar. The client speaks MCP **2026-07-28** (stateless `server/discover`, per-request `_meta` and `Mcp-*` headers). A server that does not implement `server/discover`, times out, or only speaks `2025-11-25` / `2025-06-18` / `2025-03-26` / `2024-11-05` falls back to `initialize` plus `Mcp-Session-Id`. It owns the MCP client, tool naming and the ask floor, the sidecar-bundle factory, the hub catalog-discovery route and MCP OAuth discovery. It does not own the OAuth login itself (`@corbits/oauth-core`) or credential storage (the hub). ## Layout -- `src/client.ts`: streamable-HTTP JSON-RPC client (`initialize`, `tools/list`, `tools/call`). +- `src/client.ts`: streamable-HTTP JSON-RPC client. `mcpConnect` probes `server/discover`. Modern calls are `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/templates/list`, `completion/complete`, and `subscriptions/listen`. `mcpInitialize` is the explicit legacy handshake. Extensions (`io.modelcontextprotocol/tasks`, `io.modelcontextprotocol/ui`, `io.modelcontextprotocol/skills`) are advertised and sent only when the caller opts in by extension id. - `src/tool.ts`: `mcpTools`, one `@intx/agent` tool per remote MCP tool, named `.`. - `src/naming.ts`: qualified names and the ask floor, shared by `mcpTools` and the sidecar bundle. - `src/url.ts`: which MCP endpoints a token may be sent to. -- `src/oauth-discovery.ts`: MCP OAuth metadata discovery and dynamic client registration. +- `src/oauth-discovery.ts`: MCP OAuth metadata discovery, Client ID Metadata Documents, dynamic client registration, issuer-keyed stored credentials, and RFC 9207 `iss` checks before a code is read. - `src/sidecar-bundle.ts`: the `./sidecar-bundle` entry a deployed agent's bundle loads. - `src/hub/discover.ts`, `src/hub/index.ts`: the `./hub` entry, server-side catalog discovery. - `src/test-server.ts`: in-test MCP server used by the tests. @@ -38,21 +38,29 @@ never hold a server's bearer. ### Why not `@modelcontextprotocol/sdk` -The client in `src/client.ts` is hand-rolled on purpose. It only sends -`initialize`, `tools/list` and `tools/call`, each as one request awaiting one -reply, plus the `notifications/initialized` notification; in the sidecar bundle that request goes through a fetch the mediated -credential handle pins to the server's origin. That needs only JSON-RPC -framing and reading the matching `data:` frame from an event-stream reply. +The client in `src/client.ts` is hand-rolled on purpose. A 2026 server gets +one POST per call after `server/discover`: protocol version, client info, and +client capabilities travel in `_meta`, and `Mcp-Method` / `Mcp-Name` / +`MCP-Protocol-Version` travel as headers. There is no session id, no +`initialize`, and no `notifications/initialized` on that path. `input_required` +comes back as a typed result and `mcpContinue` retries the original method +with `inputResponses`. `subscriptions/listen` is the long-lived stream. +`notifications/progress` stays on the response stream of the call that asked +for it. Aborting an in-flight call posts `notifications/cancelled`. + +A legacy server still gets `initialize`, `notifications/initialized`, and +`Mcp-Session-Id`. In the sidecar bundle every request goes through a fetch +the mediated credential handle pins to the server's origin. The SDK's streamable-HTTP client transport is built for long-lived sessions (resumable streams, server-initiated requests) and installs `zod`, `ajv`, -`express` and more, none of which this package uses. - -The session a stateful server issues on `initialize` is carried in -`McpSession` and sent as `Mcp-Session-Id` with `MCP-Protocol-Version` on -every later request. Resumable streams and server-to-client requests are not -supported. `e2e/` runs discovery and tool calls against the SDK's stateless -and stateful server transports; the SDK is a devDependency only. +`express` and more, none of which this package uses. Resumable streams +(`Last-Event-ID`), the removed HTTP+SSE transport, `ping`, and the deprecated +roots, sampling, and logging methods are not implemented. `e2e/` runs +discovery and tool calls against the SDK's stateless and stateful 2025 +transports and against a small 2026 fixture; the SDK is a devDependency only. +Prompts, resources, and subscriptions are client methods. They are not +projected into agent tools, and tools stay the only grantable unit. ## Local development diff --git a/README.md b/README.md index 7ae024c..af15a4c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @corbits/mcp -A Corbits tool pack for the Interchange sidecar that turns each tool on a remote Model Context Protocol server into its own `@intx/agent` tool, over streamable HTTP (2025-03-26 spec). It also ships a hub route for catalog discovery and OAuth discovery helpers, and the client works standalone. +A Corbits tool pack for the Interchange sidecar that turns each tool on a remote Model Context Protocol server into its own `@intx/agent` tool, over streamable HTTP. The client speaks the 2026-07-28 revision and falls back to the 2025 `initialize` handshake when the server does not. It also ships a hub route for catalog discovery and OAuth discovery helpers, and the client works standalone. ## Why @corbits/mcp? @@ -8,7 +8,11 @@ A Corbits tool pack for the Interchange sidecar that turns each tool on a remote 2. **The agent never holds the server's token.** Calls go through a mediated credential: a fetch pinned to the server's origin that adds the bearer per request. 3. **Destructive tools stay behind approval.** Every generated tool starts `ask`-marked, and a tool the server flags `destructiveHint: true` cannot be lowered to `allow`. -It supports `initialize`, `tools/list` and `tools/call` only, against stateless and stateful (`Mcp-Session-Id`) servers. +`mcpConnect` probes `server/discover`. A 2026-07-28 server is stateless: later calls send `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`, and carry `io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities` in `_meta`. They do not send `initialize`, `notifications/initialized`, or `Mcp-Session-Id`. If `server/discover` is missing, times out, or the server only speaks a 2025 (or earlier) version, the client keeps the `initialize` plus `Mcp-Session-Id` path. + +Core methods, paginated where the spec uses a cursor: `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/templates/list`, `completion/complete`. A missing `resultType` is `complete`. `input_required` is returned to the caller (method, `inputRequests`, `requestState`) and retried with `mcpContinue`. `subscriptions/listen` is a long-lived POST; `notifications/progress` stays on the call that asked for it; aborting an in-flight call sends `notifications/cancelled`. + +Extensions are off unless named in `extensions`: `io.modelcontextprotocol/tasks` (`tasks/get`, `tasks/update`, `tasks/cancel`, and a `task` result), `io.modelcontextprotocol/ui` (capability advertisement and passthrough of tool-result UI metadata; no widget renderer), and `io.modelcontextprotocol/skills` (`skills/list`, `skills/get`, with `resources/directory/read` only when the server declares `directoryRead`). Roots, sampling, logging, `ping`, HTTP+SSE, and SSE resumption are not implemented. Prompts, resources, and subscriptions are not projected into agent tools. Tools remain the only grantable unit. Catalog entries keep `outputSchema`, `annotations.title`, the four boolean hints, `ttlMs`, and `cacheScope`. JSON Schema is passed through, including unknown 2020-12 keywords. ## Install @@ -29,10 +33,10 @@ bun add @corbits/credential-http @intx/authz @intx/crypto @intx/db @intx/hub-api Lists the tools on a public MCP server: ```ts -import { mcpInitialize, mcpListTools } from "@corbits/mcp"; +import { mcpConnect, mcpListTools } from "@corbits/mcp"; const url = "https://mcp.deepwiki.com/mcp"; -const session = await mcpInitialize(url); +const session = await mcpConnect(url); for (const tool of await mcpListTools(url, { session })) { console.log(`${tool.name}: ${tool.description ?? ""}`); } @@ -50,21 +54,26 @@ Every client call takes `{ fetch, timeoutMs, signal, session }` as its last argu ## Reference -| Export | Entry | Purpose | -| ------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `mcpInitialize(url, opts?)` | `@corbits/mcp` | Send `initialize` and `notifications/initialized`; returns the `McpSession` to pass to later calls. | -| `mcpListTools(url, opts?)` | `@corbits/mcp` | Send `tools/list`; returns `McpTool[]`. | -| `mcpCallTool(url, name, args, opts?)` | `@corbits/mcp` | Send `tools/call`; returns `McpToolResult`. | -| `mcpTools(options, env?)` | `@corbits/mcp` | Discover each server live, then build tools. For hosts that can await; servers use `name` and `credentialHandle`. | -| `qualifiedName`, `toolDescription`, `isAskExempt` | `@corbits/mcp` | Naming and `ask`-floor rules the tool builders use. | -| `discoverMcpLoginEntry({ resourceUrl })` | `@corbits/mcp` | Resolve the authorization server (RFC 9728, then RFC 8414). | -| `registerMcpClient(opts)` | `@corbits/mcp` | Register a loopback public client (RFC 7591). | -| `mcpClientConfig(entry, opts)` | `@corbits/mcp` | Build the `OAuthClientConfig` oauth-core's login helpers take. | -| `selectMcpScopes(opts)` | `@corbits/mcp` | Pick scopes in the MCP spec's selection order. | -| `mcpServers(config)` | `@corbits/mcp/sidecar-bundle` | Build tools from stored catalogs over mediated credentials. | -| `shapeMcpContent(content)`, `SIDECAR_BUNDLE_ID` | `@corbits/mcp/sidecar-bundle` | Result shaping and the bundle's tool id. | -| `mountMcpDiscovery(app, opts)` | `@corbits/mcp/hub` | Mount `POST /mcp/discover`. | -| `discoverMcpServer(args)`, `readCredential(opts)` | `@corbits/mcp/hub` | The discovery and credential reads the route uses. | +| Export | Entry | Purpose | +| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `mcpConnect(url, opts?)` | `@corbits/mcp` | Probe `server/discover`, or fall back to `initialize`; returns the `McpSession` for later calls. | +| `mcpInitialize(url, opts?)` | `@corbits/mcp` | Explicit legacy `initialize` and `notifications/initialized`. | +| `mcpListTools(url, opts?)` | `@corbits/mcp` | Send `tools/list`; returns `McpTool[]` with `ttlMs` and `cacheScope` when the server sent them. | +| `mcpCallTool(url, name, args, opts?)` | `@corbits/mcp` | Send `tools/call`. A complete result, `input_required`, or (when tasks are enabled) a task handle. | +| `mcpContinue(url, pending, inputResponses, opts?)` | `@corbits/mcp` | Retry an `input_required` result, echoing `requestState`. | +| `mcpListPrompts`, `mcpGetPrompt`, `mcpListResources`, `mcpReadResource`, `mcpListResourceTemplates`, `mcpComplete`, `mcpListen` | `@corbits/mcp` | The rest of the core client. Prompts, resources, and subscriptions are not agent tools. | +| `mcpTools(options, env?)` | `@corbits/mcp` | Discover each server live, then build tools. For hosts that can await; servers use `name` and `credentialHandle`. | +| `qualifiedName`, `toolDescription`, `isAskExempt` | `@corbits/mcp` | Naming and `ask`-floor rules the tool builders use. | +| `discoverMcpLoginEntry({ resourceUrl })` | `@corbits/mcp` | Resolve the authorization server (RFC 9728, then RFC 8414). | +| `registerMcpClient(opts)` | `@corbits/mcp` | Register a public client (RFC 7591), `application_type` `"native"` unless set. | +| `preferredMcpClientRegistration`, `mcpClientIdMetadataDocument`, `resolveStoredMcpClient` | `@corbits/mcp` | Pre-registered, then Client ID Metadata Documents, then dynamic registration. Dynamic and pre-registered credentials are keyed by issuer. | +| `assertAuthorizationIssuer`, `readAuthorizationCallback` | `@corbits/mcp` | Check RFC 9207 `iss` against the recorded issuer before the code is read. | +| `mcpClientConfig(entry, opts)` | `@corbits/mcp` | Build the `OAuthClientConfig` oauth-core's login helpers take. | +| `selectMcpScopes(opts)` | `@corbits/mcp` | Pick scopes in the MCP spec's selection order. | +| `mcpServers(config)` | `@corbits/mcp/sidecar-bundle` | Build tools from stored catalogs over mediated credentials. | +| `shapeMcpContent(content)`, `SIDECAR_BUNDLE_ID` | `@corbits/mcp/sidecar-bundle` | Result shaping and the bundle's tool id. | +| `mountMcpDiscovery(app, opts)` | `@corbits/mcp/hub` | Mount `POST /mcp/discover`. | +| `discoverMcpServer(args)`, `readCredential(opts)` | `@corbits/mcp/hub` | The discovery and credential reads the route uses. | `McpToolSchema` validates a catalog entry (`McpTool`). Transport and protocol failures throw `McpError`. OAuth discovery failures throw `OAuthDiscoveryError` from `@corbits/oauth-core`. @@ -159,7 +168,7 @@ export const tools = mcpServers({ }); ``` -Each catalog entry becomes a tool named `.`, with the remote `inputSchema` passed through and the call proxied to `tools/call`. Text content comes back as text and anything else as JSON; `isError` passes through. A handle that does not resolve, or a server that fails `initialize`, fails only that server's calls. +Each catalog entry becomes a tool named `.`, with the remote `inputSchema` passed through and the call proxied to `tools/call`. Text content comes back as text and anything else as JSON; `isError` passes through. `input_required` comes back as an error result whose JSON carries the method, `inputRequests`, and `requestState`. A handle that does not resolve, or a server that fails the handshake, fails only that server's calls. `handle` is the credential handle the host binds this server's token to. The bundle resolves it from the runtime `credentials` capability and expects an `http` credential, such as the one `@corbits/credential-http`'s MCP provider returns. The package manifest declares the `mcp-server` credential for this. diff --git a/e2e/protocol-2026.test.ts b/e2e/protocol-2026.test.ts new file mode 100644 index 0000000..7322ad8 --- /dev/null +++ b/e2e/protocol-2026.test.ts @@ -0,0 +1,87 @@ +// A small 2026-07-28 fixture. The SDK devDependency speaks the 2025 transports +// and is covered by sdk-transport.test.ts. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { discoverMcpServer } from "../src/hub/discover.js"; + +let stop: (() => void) | undefined; +afterEach(() => { + stop?.(); + stop = undefined; +}); + +describe("hub discovery against protocol 2026-07-28", () => { + test("reads the catalog without initialize and keeps outputSchema and title", async () => { + const methods: string[] = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { + id?: number; + method?: string; + params?: { _meta?: Record }; + }; + methods.push(String(body.method)); + if (body.method === "server/discover") { + const capabilities = + body.params?._meta?.["io.modelcontextprotocol/clientCapabilities"]; + expect(capabilities).toEqual({}); + expect(req.headers.get("mcp-protocol-version")).toBe("2026-07-28"); + expect(req.headers.has("mcp-session-id")).toBe(false); + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + _meta: { + "io.modelcontextprotocol/serverInfo": { + name: "era", + version: "1", + }, + }, + }, + }); + } + if (body.method === "tools/list") { + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { + tools: [ + { + name: "echo", + description: "Echo.", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + unevaluatedProperties: false, + }, + annotations: { title: "Echo title", readOnlyHint: true }, + }, + ], + ttlMs: 15, + cacheScope: "private", + }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + stop = () => void server.stop(true); + const found = await discoverMcpServer({ url: server.url.toString() }); + expect(methods).toEqual(["server/discover", "tools/list"]); + expect(found.serverInfo).toEqual({ + protocolVersion: "2026-07-28", + serverInfo: { name: "era", version: "1" }, + }); + expect(found.tools[0]?.outputSchema).toEqual({ + type: "object", + unevaluatedProperties: false, + }); + expect(found.tools[0]?.annotations?.title).toBe("Echo title"); + expect(found.ttlMs).toBe(15); + expect(found.cacheScope).toBe("private"); + }); +}); diff --git a/src/client.test.ts b/src/client.test.ts index ce32b03..85c6d9e 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -26,6 +26,9 @@ describe("mcpInitialize / mcpListTools / mcpCallTool over JSON", () => { expect(tools[1]?.annotations?.destructiveHint).toBe(true); const result = await mcpCallTool(handle.url, "echo", { text: "hi" }); + expect(result.resultType).toBe("complete"); + if (result.resultType !== "complete") + throw new Error("expected a complete result"); expect(result.content).toBe("hi"); }); diff --git a/src/client.ts b/src/client.ts index 682a4d9..c81d19e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,29 +1,154 @@ -// MCP streamable-HTTP transport client (2025-03-26 spec). Each call sends one -// request and awaits its one reply, which is all initialize/tools-list/ -// tools-call need; the session `initialize` opens is carried in `session`. -// No resumption. +// Streamable-HTTP JSON-RPC client for MCP 2026-07-28, with a legacy +// fallback. A 2026 server is stateless: `server/discover` then one POST per +// call, carrying protocol metadata in `_meta` and in `Mcp-*` headers, and no +// session id. `notifications/cancelled` is still posted when the caller aborts +// an in-flight call — 2025 servers require that notification, and closing the +// response stream (which abort already does) is what a 2026 server treats as +// cancellation. No SSE resumption and no HTTP+SSE transport. import type { FetchLike } from "@intx/harness"; import { type } from "arktype"; +export const MCP_PROTOCOL_VERSION = "2026-07-28"; +const LEGACY_PROTOCOL_VERSION = "2025-03-26"; +/** Legacy handshake versions, newest first. */ +const LEGACY_VERSIONS = [ + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", +] as const; + +export const MCP_EXT_TASKS = "io.modelcontextprotocol/tasks"; +export const MCP_EXT_UI = "io.modelcontextprotocol/ui"; +export const MCP_EXT_SKILLS = "io.modelcontextprotocol/skills"; + +const META_PROTOCOL = "io.modelcontextprotocol/protocolVersion"; +const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"; +const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; +const META_SUBSCRIPTION = "io.modelcontextprotocol/subscriptionId"; +const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + +const CLIENT_INFO = { name: "@corbits/mcp", version: "0.3.1" } as const; +const UI_MIME_TYPES = ["text/html;profile=mcp-app"] as const; + +/** Methods that may answer `input_required` or, with the tasks extension, `task`. */ +const ROUND_TRIP_METHODS = new Set([ + "tools/call", + "prompts/get", + "resources/read", +]); + +export const McpErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + HeaderMismatch: -32020, + MissingRequiredClientCapability: -32021, + UnsupportedProtocolVersion: -32022, +} as const; + +const MODERN_ERROR_CODES = new Set([ + McpErrorCode.HeaderMismatch, + McpErrorCode.MissingRequiredClientCapability, + McpErrorCode.UnsupportedProtocolVersion, +]); + export const McpToolSchema = type({ name: "string", + "title?": "string", "description?": "string", inputSchema: "Record", + "outputSchema?": "Record", "annotations?": { + "title?": "string", "readOnlyHint?": "boolean", "destructiveHint?": "boolean", "idempotentHint?": "boolean", "openWorldHint?": "boolean", }, + "icons?": "unknown[]", + "_meta?": "Record", }); export type McpTool = typeof McpToolSchema.infer; +export type McpCacheScope = "public" | "private"; + +export type McpToolList = McpTool[] & { + ttlMs?: number; + cacheScope?: McpCacheScope; +}; + export interface McpToolResult { + resultType: "complete"; content: unknown; isError?: boolean; + structuredContent?: unknown; + /** Tool-result metadata, including MCP Apps `_meta.ui` when the server sent it. */ + _meta?: Record; +} + +export interface McpInputRequired { + resultType: "input_required"; + /** The method to retry. */ + method: string; + /** Original params, without `_meta`, to send again with `inputResponses`. */ + params: Record; + inputRequests?: Record; + /** Opaque server state. Echoed verbatim on retry; never inspected. */ + requestState?: string; +} + +export type McpTaskStatus = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +export interface McpTaskHandle { + resultType: "task"; + taskId: string; + status: McpTaskStatus; + createdAt: string; + lastUpdatedAt: string; + ttlMs: number | null; + pollIntervalMs?: number; + statusMessage?: string; + _meta?: Record; +} + +export interface McpTask { + resultType: "complete"; + taskId: string; + status: McpTaskStatus; + createdAt: string; + lastUpdatedAt: string; + ttlMs: number | null; + pollIntervalMs?: number; + statusMessage?: string; + inputRequests?: Record; + result?: Record; + error?: { code: number; message: string; data?: unknown }; + _meta?: Record; +} + +export type McpToolCallResult = + | McpToolResult + | McpInputRequired + | McpTaskHandle; + +export interface McpProgress { + progressToken: string | number; + progress: number; + total?: number; + message?: string; } +export type McpClientCapabilities = Record; + export interface McpClientOptions { /** Injectable `fetch` for tests and mediated-credential handles. */ fetch?: FetchLike; @@ -31,21 +156,37 @@ export interface McpClientOptions { timeoutMs?: number; /** Abort the request when this fires, e.g. a cancelled tool call. */ signal?: AbortSignal; - /** The session `mcpInitialize` opened; required by stateful servers. */ + /** The session `mcpConnect` or `mcpInitialize` opened. */ session?: McpSession; + /** + * Extension ids to advertise in `clientCapabilities.extensions`. + * Unlisted extensions are not advertised and their methods are not sent. + */ + extensions?: readonly string[]; + /** MIME types advertised for `io.modelcontextprotocol/ui`. */ + uiMimeTypes?: readonly string[]; + /** Receives `notifications/progress` from this call's response stream. */ + onProgress?: (progress: McpProgress) => void; + /** Progress token. Defaults to the request id when `onProgress` is set. */ + progressToken?: string | number; + /** Tool `inputSchema`, so `x-mcp-header` parameters are mirrored into headers. */ + inputSchema?: Record; } /** The run-time `tools/call` and `initialize` bound when a caller sets none. */ export const DEFAULT_TIMEOUT_MS = 60_000; -const PROTOCOL_VERSION = "2025-03-26"; - /** Largest response body or SSE frame read before the stream is cancelled. */ const MAX_MESSAGE_BYTES = 4 * 1024 * 1024; +/** A cursor loop that never ends is a misbehaving server, not a catalog. */ +const MAX_PAGES = 1_000; + +const TCHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + const JsonRpcResponse = type({ jsonrpc: "'2.0'", - id: "string | number", + id: "string | number | null", "result?": "unknown", "error?": { code: "number", message: "string", "data?": "unknown" }, }); @@ -53,16 +194,71 @@ const JsonRpcResponse = type({ export class McpError extends Error { /** The HTTP status, when the server answered with a non-2xx one. */ readonly status: number | undefined; + /** JSON-RPC error code, when the server sent one. */ + readonly code: number | undefined; + /** JSON-RPC `error.data`, when the server sent it. */ + readonly data: unknown; + readonly timedOut: boolean; + readonly cancelled: boolean; - constructor(message: string, status?: number) { + constructor( + message: string, + status?: number, + code?: number, + data?: unknown, + flags?: { timedOut?: boolean; cancelled?: boolean }, + ) { super(message); this.name = "McpError"; this.status = status; + this.code = code; + this.data = data; + this.timedOut = flags?.timedOut === true; + this.cancelled = flags?.cancelled === true; } } let nextId = 1; +function isModernSession(session: McpSession | undefined): boolean { + return session?.protocolVersion === MCP_PROTOCOL_VERSION; +} + +function enabledExtensions(opts: McpClientOptions): readonly string[] { + if (opts.extensions !== undefined) return opts.extensions; + return opts.session?.extensions ?? []; +} + +function requireExtension(opts: McpClientOptions, id: string): void { + if (!enabledExtensions(opts).includes(id)) { + throw new McpError(`extension ${id} is not enabled`); + } +} + +function clientCapabilities(opts: McpClientOptions): McpClientCapabilities { + if ( + opts.extensions === undefined && + opts.uiMimeTypes === undefined && + opts.session?.clientCapabilities !== undefined + ) { + return opts.session.clientCapabilities; + } + const extensions = enabledExtensions(opts); + if (extensions.length === 0) return {}; + const advertised: Record = {}; + for (const id of extensions) { + if (id === MCP_EXT_UI) { + const mimeTypes = opts.uiMimeTypes ?? opts.session?.uiMimeTypes; + advertised[id] = { + mimeTypes: [...(mimeTypes ?? UI_MIME_TYPES)], + }; + } else { + advertised[id] = {}; + } + } + return { extensions: advertised }; +} + /** A reply to request `id`: a message carrying `result` or `error` and no * `method`, which would make it a server request reusing the id. */ function isResponseTo(message: unknown, id: number): boolean { @@ -75,17 +271,315 @@ function isResponseTo(message: unknown, id: number): boolean { ); } -function headersFor(session: McpSession | undefined): Record { - return { +function isNotification( + message: unknown, +): message is { method: string; params?: Record } { + return ( + typeof message === "object" && + message !== null && + typeof (message as { method?: unknown }).method === "string" && + !("id" in message) + ); +} + +function isPlainHeaderValue(value: string): boolean { + if (value.startsWith("=?base64?") && value.endsWith("?=")) return false; + if (value.length === 0) return true; + if ( + value.startsWith(" ") || + value.startsWith("\t") || + value.endsWith(" ") || + value.endsWith("\t") + ) { + return false; + } + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + const ok = code === 0x09 || code === 0x20 || (code >= 0x21 && code <= 0x7e); + if (!ok) return false; + } + return true; +} + +function encodeHeaderValue(value: string): string { + if (isPlainHeaderValue(value)) return value; + return `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`; +} + +type HeaderSlot = { + path: string[]; + header: string; + type: "string" | "integer" | "boolean"; +}; + +function mentionsHeader(value: unknown): boolean { + if (Array.isArray(value)) return value.some(mentionsHeader); + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if ("x-mcp-header" in record) return true; + return Object.values(record).some(mentionsHeader); +} + +/** `undefined` means the tool definition is invalid and must be excluded. */ +function collectHeaders( + schema: Record, + toolName: string, +): HeaderSlot[] | undefined { + const slots: HeaderSlot[] = []; + const seen = new Set(); + const reject = (reason: string): undefined => { + console.warn(`excluding MCP tool ${toolName}: ${reason}`); + return undefined; + }; + if ("x-mcp-header" in schema) { + return reject("x-mcp-header is not on a property"); + } + + const visit = (node: unknown, path: string[]): boolean => { + if (typeof node !== "object" || node === null || Array.isArray(node)) { + return true; + } + const record = node as Record; + if ("x-mcp-header" in record) { + const header = record["x-mcp-header"]; + const schemaType = record["type"]; + if (typeof header !== "string" || !TCHAR.test(header)) { + reject(`invalid x-mcp-header on ${path.join(".")}`); + return false; + } + const id = header.toLowerCase(); + if (seen.has(id)) { + reject(`duplicate x-mcp-header "${header}"`); + return false; + } + if ( + schemaType !== "string" && + schemaType !== "integer" && + schemaType !== "boolean" + ) { + reject(`x-mcp-header on non-primitive ${path.join(".")}`); + return false; + } + seen.add(id); + slots.push({ path, header, type: schemaType }); + } + const nested = record["properties"]; + if ( + typeof nested === "object" && + nested !== null && + !Array.isArray(nested) + ) { + for (const [key, value] of Object.entries(nested)) { + if (!visit(value, [...path, key])) return false; + } + } + for (const [key, value] of Object.entries(record)) { + if (key === "properties" || key === "x-mcp-header") continue; + if (mentionsHeader(value)) { + reject(`x-mcp-header on ${path.join(".")} is not statically reachable`); + return false; + } + } + return true; + }; + + const properties = schema["properties"]; + if ( + typeof properties === "object" && + properties !== null && + !Array.isArray(properties) + ) { + for (const [key, value] of Object.entries(properties)) { + if (!visit(value, [key])) return undefined; + } + } + for (const [key, value] of Object.entries(schema)) { + if (key === "properties") continue; + if (mentionsHeader(value)) { + return reject( + "x-mcp-header is not statically reachable from the schema root", + ); + } + } + return slots; +} + +function valueAt(args: Record, path: string[]): unknown { + let current: unknown = args; + for (const key of path) { + if ( + typeof current !== "object" || + current === null || + Array.isArray(current) + ) { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + +function paramHeaders( + schema: Record | undefined, + args: unknown, + toolName: string, +): Record { + if (schema === undefined) return {}; + const slots = collectHeaders(schema, toolName); + if (slots === undefined) { + throw new McpError( + `MCP tool ${toolName} has an invalid x-mcp-header annotation`, + ); + } + if (typeof args !== "object" || args === null || Array.isArray(args)) + return {}; + const headers: Record = {}; + for (const slot of slots) { + const value = valueAt(args as Record, slot.path); + if (value === undefined || value === null) continue; + headers[`Mcp-Param-${slot.header}`] = encodeParam(slot, value, toolName); + } + return headers; +} + +function encodeParam( + slot: HeaderSlot, + value: unknown, + toolName: string, +): string { + if (slot.type === "string") { + if (typeof value !== "string") { + throw new McpError( + `MCP tool ${toolName} argument ${slot.path.join(".")} is not a string`, + ); + } + return encodeHeaderValue(value); + } + if (slot.type === "boolean") { + if (typeof value !== "boolean") { + throw new McpError( + `MCP tool ${toolName} argument ${slot.path.join(".")} is not a boolean`, + ); + } + return value ? "true" : "false"; + } + if ( + typeof value !== "number" || + !Number.isInteger(value) || + !Number.isSafeInteger(value) + ) { + throw new McpError( + `MCP tool ${toolName} argument ${slot.path.join(".")} is not a safe integer`, + ); + } + return String(value); +} + +function mcpNameFor( + method: string, + params: Record | undefined, +): string | undefined { + if (params === undefined) return undefined; + if (method === "tools/call" || method === "prompts/get") { + return typeof params.name === "string" ? params.name : undefined; + } + if (method === "resources/read") { + return typeof params.uri === "string" ? params.uri : undefined; + } + return undefined; +} + +function requestHeaders( + opts: McpClientOptions, + method: string, + mcpName: string | undefined, + extra: Record, +): Record { + const headers: Record = { "content-type": "application/json", accept: "application/json, text/event-stream", - ...(session === undefined - ? {} - : { "mcp-protocol-version": session.protocolVersion }), - ...(session?.sessionId === undefined - ? {} - : { "mcp-session-id": session.sessionId }), }; + if (isModernSession(opts.session)) { + headers["MCP-Protocol-Version"] = MCP_PROTOCOL_VERSION; + headers["Mcp-Method"] = method; + if (mcpName !== undefined) headers["Mcp-Name"] = encodeHeaderValue(mcpName); + for (const [key, value] of Object.entries(extra)) headers[key] = value; + return headers; + } + if (opts.session !== undefined) { + headers["mcp-protocol-version"] = opts.session.protocolVersion; + if (opts.session.sessionId !== undefined) { + headers["mcp-session-id"] = opts.session.sessionId; + } + } + return headers; +} + +function modernParams( + params: Record | undefined, + opts: McpClientOptions, + id: number, +): Record { + const progressToken = + opts.progressToken ?? (opts.onProgress !== undefined ? id : undefined); + const meta: Record = { + [META_PROTOCOL]: MCP_PROTOCOL_VERSION, + [META_CLIENT_INFO]: CLIENT_INFO, + [META_CLIENT_CAPABILITIES]: clientCapabilities(opts), + }; + if (progressToken !== undefined) meta.progressToken = progressToken; + const base = params ?? {}; + return { ...base, _meta: meta }; +} + +function stripMeta( + params: Record | undefined, +): Record { + if (params === undefined) return {}; + const rest: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (key !== "_meta") rest[key] = value; + } + return rest; +} + +/** A fresh request. The aborted call signal must not cancel this notification, + * and progress fields belong to the call that was cancelled. */ +function cancellationOpts(opts: McpClientOptions): McpClientOptions { + return { + ...(opts.fetch !== undefined ? { fetch: opts.fetch } : {}), + ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + ...(opts.session !== undefined ? { session: opts.session } : {}), + ...(opts.extensions !== undefined ? { extensions: opts.extensions } : {}), + ...(opts.uiMimeTypes !== undefined + ? { uiMimeTypes: opts.uiMimeTypes } + : {}), + }; +} + +function deliverProgress( + message: unknown, + token: string | number | undefined, + onProgress: ((progress: McpProgress) => void) | undefined, +): void { + if ( + token === undefined || + onProgress === undefined || + !isNotification(message) + ) { + return; + } + if (message.method !== "notifications/progress") return; + const params = message.params; + if (params === undefined || params.progressToken !== token) return; + if (typeof params.progress !== "number") return; + const progress: McpProgress = { + progressToken: token, + progress: params.progress, + }; + if (typeof params.total === "number") progress.total = params.total; + if (typeof params.message === "string") progress.message = params.message; + onProgress(progress); } /** @@ -93,6 +587,8 @@ function headersFor(session: McpSession | undefined): Record { * Handles both response shapes the spec allows: a direct `application/json` * body, and a `text/event-stream` whose `data:` frames each carry a * JSON-RPC message -- the first one matching this request's id wins. + * Progress notifications on that stream are delivered and not treated as the + * response. */ async function sendRequest( url: string, @@ -108,55 +604,96 @@ async function sendRequest( : AbortSignal.timeout(opts.timeoutMs); const signals = [timeout, opts.signal].filter((s) => s !== undefined); const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined; + const modern = isModernSession(opts.session); + const bodyParams = modern ? modernParams(params, opts, id) : params; + const progressToken = + opts.progressToken ?? (opts.onProgress !== undefined ? id : undefined); + const extra = + method === "tools/call" + ? paramHeaders( + opts.inputSchema, + params?.arguments, + String(params?.name ?? ""), + ) + : {}; let message: unknown; let sessionId: string | undefined; + const notifyCancel = () => { + void sendNotification( + url, + "notifications/cancelled", + cancellationOpts(opts), + { + requestId: id, + reason: "the caller aborted the request", + }, + ).catch(() => undefined); + }; + if (opts.signal !== undefined) { + opts.signal.addEventListener("abort", notifyCancel, { once: true }); + } try { const response = await fetchImpl(url, { method: "POST", - headers: headersFor(opts.session), + headers: requestHeaders(opts, method, mcpNameFor(method, params), extra), body: JSON.stringify({ jsonrpc: "2.0", id, method, - ...(params !== undefined ? { params } : {}), + ...(bodyParams !== undefined ? { params: bodyParams } : {}), }), ...(signal !== undefined ? { signal } : {}), }); if (!response.ok) { - await response.body?.cancel(); - throw new McpError( - `MCP server ${url} responded ${response.status} to ${method}`, - response.status, - ); + throw await errorFrom(response, url, method, signal); } sessionId = response.headers.get("mcp-session-id") ?? undefined; const contentType = response.headers.get("content-type") ?? ""; message = contentType.includes("text/event-stream") - ? await readSseJsonRpc(response, id, signal) + ? await readSseJsonRpc(response, id, signal, (frame) => { + deliverProgress(frame, progressToken, opts.onProgress); + }) : parseJson(await readCapped(response, signal)); } catch (cause) { if (opts.signal?.aborted === true) { - throw new McpError(`${method} to MCP server ${url} was cancelled`); + throw new McpError( + `${method} to MCP server ${url} was cancelled`, + undefined, + undefined, + undefined, + { + cancelled: true, + }, + ); } if (timeout?.aborted === true) { throw new McpError( `MCP server ${url} did not answer ${method} within ${String(opts.timeoutMs)}ms`, + undefined, + undefined, + undefined, + { timedOut: true }, ); } throw cause; + } finally { + opts.signal?.removeEventListener("abort", notifyCancel); } const parsed = JsonRpcResponse(message); - if (parsed instanceof type.errors || !isResponseTo(message, id)) { + if (!(parsed instanceof type.errors) && parsed.error !== undefined) { throw new McpError( - `MCP server ${url} sent a malformed response to ${method}${parsed instanceof type.errors ? `: ${parsed.summary}` : ""}`, + `MCP server ${url} rejected ${method}: ${parsed.error.message}`, + undefined, + parsed.error.code, + parsed.error.data, ); } - if (parsed.error !== undefined) { + if (parsed instanceof type.errors || !isResponseTo(message, id)) { throw new McpError( - `MCP server ${url} rejected ${method}: ${parsed.error.message}`, + `MCP server ${url} sent a malformed response to ${method}${parsed instanceof type.errors ? `: ${parsed.summary}` : ""}`, ); } return { result: parsed.result, sessionId }; @@ -167,24 +704,40 @@ async function sendNotification( url: string, method: string, opts: McpClientOptions, + params?: Record, ): Promise { const fetchImpl = opts.fetch ?? fetch; const timeout = opts.timeoutMs === undefined ? undefined : AbortSignal.timeout(opts.timeoutMs); + const modern = isModernSession(opts.session); + const bodyParams = + params === undefined + ? undefined + : modern + ? modernParams(params, cancellationOpts(opts), 0) + : params; let response: Response; try { response = await fetchImpl(url, { method: "POST", - headers: headersFor(opts.session), - body: JSON.stringify({ jsonrpc: "2.0", method }), + headers: requestHeaders(opts, method, undefined, {}), + body: JSON.stringify({ + jsonrpc: "2.0", + method, + ...(bodyParams !== undefined ? { params: bodyParams } : {}), + }), ...(timeout !== undefined ? { signal: timeout } : {}), }); } catch (cause) { if (timeout?.aborted === true) { throw new McpError( `MCP server ${url} did not answer ${method} within ${String(opts.timeoutMs)}ms`, + undefined, + undefined, + undefined, + { timedOut: true }, ); } throw cause; @@ -198,6 +751,54 @@ async function sendNotification( } } +async function errorFrom( + response: Response, + url: string, + method: string, + signal: AbortSignal | undefined, +): Promise { + let code: number | undefined; + let data: unknown; + let detail: string | undefined; + try { + const text = await readCapped(response, signal); + if (text.length > 0) { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (typeof parsed === "object" && parsed !== null) { + const error = (parsed as { error?: unknown }).error; + if (typeof error === "object" && error !== null) { + const record = error as { + code?: unknown; + message?: unknown; + data?: unknown; + }; + if ( + typeof record.code === "number" && + typeof record.message === "string" + ) { + code = record.code; + detail = record.message; + data = record.data; + } + } + } + } + } catch (cause) { + if (cause instanceof McpError && cause.message.includes("larger than")) + throw cause; + } + const message = + detail === undefined + ? `MCP server ${url} responded ${response.status} to ${method}` + : `MCP server ${url} rejected ${method}: ${detail}`; + return new McpError(message, response.status, code, data); +} + function parseJson(text: string): unknown { try { return JSON.parse(text); @@ -251,12 +852,10 @@ async function readCapped( return text + decoder.decode(); } -/** Read an SSE body and return the first JSON-RPC message whose id matches. */ -async function readSseJsonRpc( +async function* sseMessages( response: Response, - id: number, signal: AbortSignal | undefined, -): Promise { +): AsyncGenerator { const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of chunks(response, signal)) { @@ -271,15 +870,26 @@ async function readSseJsonRpc( .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trim()); if (dataLines.length === 0) continue; - let candidate: unknown; try { - candidate = JSON.parse(dataLines.join("\n")); + yield JSON.parse(dataLines.join("\n")); } catch { - continue; + // A non-JSON data frame is not the response; keep reading. } - if (isResponseTo(candidate, id)) return candidate; } } +} + +/** Read an SSE body and return the first JSON-RPC message whose id matches. */ +async function readSseJsonRpc( + response: Response, + id: number, + signal: AbortSignal | undefined, + onMessage: (message: unknown) => void, +): Promise { + for await (const candidate of sseMessages(response, signal)) { + if (isResponseTo(candidate, id)) return candidate; + onMessage(candidate); + } throw new McpError( "MCP server closed the event stream without a matching response", ); @@ -294,23 +904,50 @@ export type McpServerInfo = typeof InitializeResult.infer; export type McpSession = { /** The negotiated version, sent as `MCP-Protocol-Version`. */ readonly protocolVersion: string; - /** The `Mcp-Session-Id` a stateful server assigned, if any. */ + /** The `Mcp-Session-Id` a stateful legacy server assigned, if any. */ readonly sessionId?: string; /** The server's own identification, for a caller that shows it. */ readonly serverInfo?: NonNullable; + /** Server capabilities from `server/discover`. Absent on the legacy path. */ + readonly capabilities?: Record; + /** Capabilities this client advertised for the modern session. */ + readonly clientCapabilities?: McpClientCapabilities; + /** Extension ids opted in when the session was opened. */ + readonly extensions?: readonly string[]; + readonly uiMimeTypes?: readonly string[]; + readonly instructions?: string; }; +function serverInfoOf( + meta: Record | undefined, +): McpSession["serverInfo"] | undefined { + const info = meta?.[META_SERVER_INFO]; + if (typeof info !== "object" || info === null) return undefined; + const name = (info as { name?: unknown }).name; + if (typeof name !== "string") return undefined; + const version = (info as { version?: unknown }).version; + return typeof version === "string" ? { name, version } : { name }; +} + /** `initialize` handshake followed by `notifications/initialized`. Pass the - * returned session to every later call on this server. */ + * returned session to every later call on a legacy server. */ export async function mcpInitialize( url: string, opts: McpClientOptions = {}, +): Promise { + return initializeLegacy(url, opts, LEGACY_PROTOCOL_VERSION); +} + +async function initializeLegacy( + url: string, + opts: McpClientOptions, + protocolVersion: string, ): Promise { const { result, sessionId } = await sendRequest( url, "initialize", { - protocolVersion: PROTOCOL_VERSION, + protocolVersion, capabilities: {}, clientInfo: { name: "@corbits/mcp", version: "0.2.0" }, }, @@ -332,7 +969,7 @@ export async function mcpInitialize( ); } const session: McpSession = { - protocolVersion: info.protocolVersion ?? PROTOCOL_VERSION, + protocolVersion: info.protocolVersion ?? protocolVersion, ...(sessionId !== undefined ? { sessionId } : {}), ...(info.serverInfo !== undefined ? { serverInfo: info.serverInfo } : {}), }; @@ -343,52 +980,1133 @@ export async function mcpInitialize( return session; } -const ToolsListResult = type({ tools: McpToolSchema.array() }); +const DiscoverResult = type({ + supportedVersions: "string[]", + capabilities: "Record", + "instructions?": "string", + "_meta?": "Record", +}); -/** `tools/list`: the remote server's tool catalog. */ -export async function mcpListTools( +function supportedFrom(data: unknown): string[] { + if (typeof data !== "object" || data === null) return []; + const supported = (data as { supported?: unknown }).supported; + if (!Array.isArray(supported)) return []; + return supported.filter( + (version): version is string => typeof version === "string", + ); +} + +function legacyVersion(supported: readonly string[]): string | undefined { + return LEGACY_VERSIONS.find((version) => supported.includes(version)); +} + +function shouldFallback(error: unknown): boolean { + if (!(error instanceof McpError)) return false; + if (error.cancelled) return false; + if (error.timedOut) return true; + if (error.code !== undefined && MODERN_ERROR_CODES.has(error.code)) + return false; + return true; +} + +/** + * Probe `server/discover`. A 2026-07-28 server returns a stateless session: + * later calls send no `initialize`, no `notifications/initialized`, and no + * `Mcp-Session-Id`. Method-not-found, a timeout, any non-modern error, or a + * server that only speaks a 2025 (or earlier) version falls back to + * `initialize`. + */ +export async function mcpConnect( url: string, opts: McpClientOptions = {}, -): Promise { - const { result } = await sendRequest(url, "tools/list", undefined, opts); - const parsed = ToolsListResult(result); - if (parsed instanceof type.errors) { - throw new McpError( - `MCP server ${url} sent a malformed tools/list result: ${parsed.summary}`, +): Promise { + const probing: McpSession = { + protocolVersion: MCP_PROTOCOL_VERSION, + clientCapabilities: clientCapabilities(opts), + ...(opts.extensions !== undefined && opts.extensions.length > 0 + ? { extensions: opts.extensions } + : {}), + ...(opts.uiMimeTypes !== undefined + ? { uiMimeTypes: opts.uiMimeTypes } + : {}), + }; + const probeOpts: McpClientOptions = { ...opts, session: probing }; + try { + const { result } = await sendRequest( + url, + "server/discover", + undefined, + probeOpts, ); - } - const names = new Set(parsed.tools.map((tool) => tool.name)); - if (names.size !== parsed.tools.length) { + const kind = resultKind(result); + if (kind !== "complete") { + throw new McpError( + `MCP server ${url} sent a ${kind} result for server/discover`, + ); + } + const parsed = DiscoverResult(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed server/discover result: ${parsed.summary}`, + ); + } + if (parsed.supportedVersions.includes(MCP_PROTOCOL_VERSION)) { + const meta = parsed._meta; + const serverInfo = serverInfoOf(meta); + return { + ...probing, + ...(serverInfo !== undefined ? { serverInfo } : {}), + capabilities: parsed.capabilities, + ...(parsed.instructions !== undefined + ? { instructions: parsed.instructions } + : {}), + }; + } + const legacy = legacyVersion(parsed.supportedVersions); + if (legacy !== undefined) return initializeLegacy(url, opts, legacy); throw new McpError( - `MCP server ${url} sent a tools/list result with duplicate tool names`, + `MCP server ${url} does not speak ${MCP_PROTOCOL_VERSION}`, + undefined, + McpErrorCode.UnsupportedProtocolVersion, + { supported: parsed.supportedVersions, requested: MCP_PROTOCOL_VERSION }, ); + } catch (cause) { + if ( + cause instanceof McpError && + cause.code === McpErrorCode.UnsupportedProtocolVersion + ) { + const legacy = legacyVersion(supportedFrom(cause.data)); + if (legacy !== undefined) return initializeLegacy(url, opts, legacy); + throw cause; + } + if (shouldFallback(cause)) + return initializeLegacy(url, opts, LEGACY_PROTOCOL_VERSION); + throw cause; } - return parsed.tools; } -/** `tools/call`: invoke a remote tool by name with JSON arguments. */ -const ToolCallResult = type({ - content: "unknown", - "isError?": "boolean", -}); +type ResultKind = "complete" | "input_required" | "task" | "invalid"; -export async function mcpCallTool( +function resultKind(result: unknown): ResultKind { + if ( + typeof result !== "object" || + result === null || + !("resultType" in result) + ) { + return "complete"; + } + const value = (result as { resultType?: unknown }).resultType; + if (value === undefined) return "complete"; + if (value === "complete" || value === "input_required" || value === "task") { + return value; + } + return "invalid"; +} + +function parseInputRequired( + method: string, + params: Record | undefined, + result: unknown, url: string, - name: string, - toolArguments: Record, - opts: McpClientOptions = {}, -): Promise { - const { result } = await sendRequest( - url, - "tools/call", - { name, arguments: toolArguments }, - opts, - ); - const parsed = ToolCallResult(result); - if (parsed instanceof type.errors) { +): McpInputRequired { + if (typeof result !== "object" || result === null) { throw new McpError( - `MCP server ${url} sent a malformed tools/call result for ${name}: ${parsed.summary}`, + `MCP server ${url} sent a malformed input_required result`, ); } - return parsed; + const record = result as { inputRequests?: unknown; requestState?: unknown }; + if (record.inputRequests === undefined && record.requestState === undefined) { + throw new McpError( + `MCP server ${url} sent an input_required result with neither inputRequests nor requestState`, + ); + } + if ( + record.requestState !== undefined && + typeof record.requestState !== "string" + ) { + throw new McpError(`MCP server ${url} sent a non-string requestState`); + } + if (record.inputRequests !== undefined) { + if ( + typeof record.inputRequests !== "object" || + record.inputRequests === null || + Array.isArray(record.inputRequests) + ) { + throw new McpError( + `MCP server ${url} sent a malformed inputRequests map`, + ); + } + for (const value of Object.values(record.inputRequests)) { + if ( + typeof value !== "object" || + value === null || + typeof (value as { method?: unknown }).method !== "string" + ) { + throw new McpError(`MCP server ${url} sent a malformed input request`); + } + } + } + return { + resultType: "input_required", + method, + params: stripMeta(params), + ...(record.inputRequests !== undefined + ? { inputRequests: record.inputRequests as Record } + : {}), + ...(typeof record.requestState === "string" + ? { requestState: record.requestState } + : {}), + }; +} + +const TaskHandleSchema = type({ + taskId: "string", + status: "'working' | 'input_required' | 'completed' | 'failed' | 'cancelled'", + createdAt: "string", + lastUpdatedAt: "string", + ttlMs: "number | null", + "pollIntervalMs?": "number", + "statusMessage?": "string", + "_meta?": "Record", +}); + +function parseTask(result: unknown, url: string): McpTaskHandle { + const parsed = TaskHandleSchema(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed task handle: ${parsed.summary}`, + ); + } + return { resultType: "task", ...parsed }; +} + +async function requestResult( + url: string, + method: string, + params: Record | undefined, + opts: McpClientOptions, +): Promise { + const { result } = await sendRequest(url, method, params, opts); + const kind = resultKind(result); + if (kind === "invalid") { + throw new McpError( + `MCP server ${url} sent an unrecognized resultType for ${method}`, + ); + } + if (kind === "input_required" || kind === "task") { + if (!ROUND_TRIP_METHODS.has(method)) { + throw new McpError( + `MCP server ${url} sent ${kind} for ${method}, which does not accept it`, + ); + } + if (kind === "task" && !enabledExtensions(opts).includes(MCP_EXT_TASKS)) { + throw new McpError( + `MCP server ${url} sent a task result but ${MCP_EXT_TASKS} is not enabled`, + ); + } + return kind === "task" + ? parseTask(result, url) + : parseInputRequired(method, params, result, url); + } + return result; +} + +function isInputRequired(result: unknown): result is McpInputRequired { + return ( + typeof result === "object" && + result !== null && + (result as { resultType?: unknown }).resultType === "input_required" + ); +} + +function isTaskHandle(result: unknown): result is McpTaskHandle { + return ( + typeof result === "object" && + result !== null && + (result as { resultType?: unknown }).resultType === "task" + ); +} + +type PageCache = { ttlMs?: number; cacheScope?: McpCacheScope }; + +function readCache(result: unknown, url: string, method: string): PageCache { + if (typeof result !== "object" || result === null) return {}; + const record = result as { ttlMs?: unknown; cacheScope?: unknown }; + const cache: PageCache = {}; + if ("ttlMs" in record && record.ttlMs !== undefined) { + if (typeof record.ttlMs !== "number") { + throw new McpError( + `MCP server ${url} sent a non-numeric ttlMs for ${method}`, + ); + } + cache.ttlMs = record.ttlMs < 0 ? 0 : record.ttlMs; + } + if ("cacheScope" in record && record.cacheScope !== undefined) { + if (record.cacheScope !== "public" && record.cacheScope !== "private") { + throw new McpError( + `MCP server ${url} sent an invalid cacheScope for ${method}`, + ); + } + cache.cacheScope = record.cacheScope; + } + return cache; +} + +function mergeCache(pages: readonly PageCache[]): PageCache { + let sawTtl = false; + let omittedTtl = false; + let ttl = Number.POSITIVE_INFINITY; + let scope: McpCacheScope | undefined; + let scopeOnEveryPage = pages.length > 0; + for (const page of pages) { + if (page.ttlMs === undefined) omittedTtl = true; + else { + sawTtl = true; + ttl = Math.min(ttl, page.ttlMs); + } + if (page.cacheScope === undefined) scopeOnEveryPage = false; + else if (scope === undefined) scope = page.cacheScope; + else if (scope !== page.cacheScope) { + throw new McpError("MCP server changed cacheScope between pages"); + } + } + const merged: PageCache = {}; + if (sawTtl) merged.ttlMs = omittedTtl ? 0 : ttl; + if (scopeOnEveryPage && scope !== undefined) merged.cacheScope = scope; + return merged; +} + +function attachCache(items: T[], cache: PageCache): T[] & PageCache { + if (cache.ttlMs !== undefined) { + Object.defineProperty(items, "ttlMs", { + value: cache.ttlMs, + enumerable: false, + }); + } + if (cache.cacheScope !== undefined) { + Object.defineProperty(items, "cacheScope", { + value: cache.cacheScope, + enumerable: false, + }); + } + return items; +} + +function nextCursorOf( + result: unknown, + url: string, + method: string, +): string | undefined { + if ( + typeof result !== "object" || + result === null || + !("nextCursor" in result) + ) { + return undefined; + } + const cursor = (result as { nextCursor?: unknown }).nextCursor; + if (cursor === undefined) return undefined; + if (typeof cursor !== "string") { + throw new McpError( + `MCP server ${url} sent a non-string nextCursor for ${method}`, + ); + } + return cursor; +} + +async function eachPage( + url: string, + method: string, + base: Record | undefined, + opts: McpClientOptions, +): Promise<{ pages: unknown[]; cache: PageCache }> { + const pages: unknown[] = []; + const caches: PageCache[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page++) { + const params = cursor === undefined ? base : { ...base, cursor }; + const result = await requestResult( + url, + method, + params !== undefined && Object.keys(params).length > 0 + ? params + : undefined, + opts, + ); + if (isInputRequired(result) || isTaskHandle(result)) { + throw new McpError( + `MCP server ${url} sent ${result.resultType} for ${method}`, + ); + } + pages.push(result); + caches.push(readCache(result, url, method)); + const next = nextCursorOf(result, url, method); + if (next === undefined) return { pages, cache: mergeCache(caches) }; + cursor = next; + } + throw new McpError( + `MCP server ${url} sent more than ${String(MAX_PAGES)} ${method} pages`, + ); +} + +const ToolsListResult = type({ tools: McpToolSchema.array() }); + +/** `tools/list`: the remote server's tool catalog, following cursors. */ +export async function mcpListTools( + url: string, + opts: McpClientOptions = {}, +): Promise { + const { pages, cache } = await eachPage(url, "tools/list", undefined, opts); + const tools: McpTool[] = []; + for (const result of pages) { + const parsed = ToolsListResult(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed tools/list result: ${parsed.summary}`, + ); + } + for (const tool of parsed.tools) { + if (collectHeaders(tool.inputSchema, tool.name) === undefined) continue; + tools.push(tool); + } + } + const names = new Set(tools.map((tool) => tool.name)); + if (names.size !== tools.length) { + throw new McpError( + `MCP server ${url} sent a tools/list result with duplicate tool names`, + ); + } + return attachCache(tools, cache); +} + +const ToolCallResult = type({ + content: "unknown", + "isError?": "boolean", + "structuredContent?": "unknown", + "_meta?": "Record", +}); + +/** `tools/call`: invoke a remote tool by name with JSON arguments. */ +export async function mcpCallTool( + url: string, + name: string, + toolArguments: Record, + opts: McpClientOptions = {}, +): Promise { + const params = { name, arguments: toolArguments }; + const result = await requestResult(url, "tools/call", params, opts); + if (isInputRequired(result) || isTaskHandle(result)) return result; + const parsed = ToolCallResult(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed tools/call result for ${name}: ${parsed.summary}`, + ); + } + return { + resultType: "complete", + content: parsed.content, + ...(parsed.isError !== undefined ? { isError: parsed.isError } : {}), + ...(parsed.structuredContent !== undefined + ? { structuredContent: parsed.structuredContent } + : {}), + ...(parsed._meta !== undefined ? { _meta: parsed._meta } : {}), + }; +} + +/** Retry a call that came back `input_required`, echoing `requestState`. */ +export async function mcpContinue( + url: string, + pending: McpInputRequired, + inputResponses: Record, + opts: McpClientOptions = {}, +): Promise { + const params: Record = { ...pending.params, inputResponses }; + if (pending.requestState !== undefined) + params.requestState = pending.requestState; + if (pending.method === "tools/call") { + return continueMethod(url, pending.method, params, opts, (value) => { + const parsed = ToolCallResult(value); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed tools/call result: ${parsed.summary}`, + ); + } + return { + resultType: "complete" as const, + content: parsed.content, + ...(parsed.isError !== undefined ? { isError: parsed.isError } : {}), + ...(parsed.structuredContent !== undefined + ? { structuredContent: parsed.structuredContent } + : {}), + ...(parsed._meta !== undefined ? { _meta: parsed._meta } : {}), + }; + }); + } + if (pending.method === "prompts/get") { + return continueMethod(url, pending.method, params, opts, (value) => { + const parsed = PromptResult(value); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed prompts/get result: ${parsed.summary}`, + ); + } + return completePrompt(parsed); + }); + } + if (pending.method === "resources/read") { + return continueMethod(url, pending.method, params, opts, (value) => + completeRead(value, url), + ); + } + throw new McpError(`cannot retry ${pending.method}`); +} + +async function continueMethod( + url: string, + method: string, + params: Record, + opts: McpClientOptions, + complete: (result: unknown) => T, +): Promise { + const result = await requestResult(url, method, params, opts); + if (isInputRequired(result) || isTaskHandle(result)) return result; + return complete(result); +} + +const PromptSchema = type({ + name: "string", + "title?": "string", + "description?": "string", + "arguments?": type({ + name: "string", + "description?": "string", + "required?": "boolean", + }).array(), + "icons?": "unknown[]", + "_meta?": "Record", +}); +export type McpPrompt = typeof PromptSchema.infer; +export type McpPromptList = McpPrompt[] & PageCache; + +const PromptResult = type({ + "description?": "string", + messages: "unknown[]", + "_meta?": "Record", +}); + +export type McpPromptResult = + | { + resultType: "complete"; + description?: string; + messages: unknown[]; + _meta?: Record; + } + | McpInputRequired + | McpTaskHandle; + +function completePrompt(parsed: typeof PromptResult.infer): McpPromptResult { + return { + resultType: "complete", + messages: parsed.messages, + ...(parsed.description !== undefined + ? { description: parsed.description } + : {}), + ...(parsed._meta !== undefined ? { _meta: parsed._meta } : {}), + }; +} + +/** `prompts/list`, following cursors. */ +export async function mcpListPrompts( + url: string, + opts: McpClientOptions = {}, +): Promise { + const { pages, cache } = await eachPage(url, "prompts/list", undefined, opts); + const prompts: McpPrompt[] = []; + for (const result of pages) { + const parsed = type({ prompts: PromptSchema.array() })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed prompts/list result: ${parsed.summary}`, + ); + } + prompts.push(...parsed.prompts); + } + return attachCache(prompts, cache); +} + +/** `prompts/get`. */ +export async function mcpGetPrompt( + url: string, + name: string, + args: Record | undefined, + opts: McpClientOptions = {}, +): Promise { + const params: Record = { name }; + if (args !== undefined) params.arguments = args; + return continueMethod(url, "prompts/get", params, opts, (result) => { + const parsed = PromptResult(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed prompts/get result: ${parsed.summary}`, + ); + } + return completePrompt(parsed); + }); +} + +const ResourceSchema = type({ + uri: "string", + name: "string", + "title?": "string", + "description?": "string", + "mimeType?": "string", + "annotations?": "Record", + "icons?": "unknown[]", + "_meta?": "Record", +}); +export type McpResource = typeof ResourceSchema.infer; +export type McpResourceList = McpResource[] & PageCache; + +const TemplateSchema = type({ + uriTemplate: "string", + name: "string", + "title?": "string", + "description?": "string", + "mimeType?": "string", + "annotations?": "Record", + "_meta?": "Record", +}); +export type McpResourceTemplate = typeof TemplateSchema.infer; +export type McpResourceTemplateList = McpResourceTemplate[] & PageCache; + +export type McpResourceReadResult = + | { + resultType: "complete"; + contents: unknown[]; + ttlMs?: number; + cacheScope?: McpCacheScope; + } + | McpInputRequired + | McpTaskHandle; + +function completeRead(result: unknown, url: string): McpResourceReadResult { + const parsed = type({ contents: "unknown[]" })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed resources/read result: ${parsed.summary}`, + ); + } + const cache = readCache(result, url, "resources/read"); + return { + resultType: "complete", + contents: parsed.contents, + ...(cache.ttlMs !== undefined ? { ttlMs: cache.ttlMs } : {}), + ...(cache.cacheScope !== undefined ? { cacheScope: cache.cacheScope } : {}), + }; +} + +/** `resources/list`, following cursors. */ +export async function mcpListResources( + url: string, + opts: McpClientOptions = {}, +): Promise { + const { pages, cache } = await eachPage( + url, + "resources/list", + undefined, + opts, + ); + const resources: McpResource[] = []; + for (const result of pages) { + const parsed = type({ resources: ResourceSchema.array() })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed resources/list result: ${parsed.summary}`, + ); + } + resources.push(...parsed.resources); + } + return attachCache(resources, cache); +} + +/** `resources/read`. */ +export async function mcpReadResource( + url: string, + uri: string, + opts: McpClientOptions = {}, +): Promise { + return continueMethod(url, "resources/read", { uri }, opts, (result) => + completeRead(result, url), + ); +} + +/** `resources/templates/list`, following cursors. */ +export async function mcpListResourceTemplates( + url: string, + opts: McpClientOptions = {}, +): Promise { + const { pages, cache } = await eachPage( + url, + "resources/templates/list", + undefined, + opts, + ); + const templates: McpResourceTemplate[] = []; + for (const result of pages) { + const parsed = type({ resourceTemplates: TemplateSchema.array() })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed resources/templates/list result: ${parsed.summary}`, + ); + } + templates.push(...parsed.resourceTemplates); + } + return attachCache(templates, cache); +} + +const CompletionResult = type({ + completion: { + values: "string[]", + "total?": "number", + "hasMore?": "boolean", + }, +}); +export type McpCompletion = typeof CompletionResult.infer; + +export type McpCompletionRef = + | { readonly type: "ref/prompt"; readonly name: string } + | { readonly type: "ref/resource"; readonly uri: string }; + +/** `completion/complete`. */ +export async function mcpComplete( + url: string, + ref: McpCompletionRef, + argument: { name: string; value: string }, + opts: McpClientOptions & { + context?: Record; + } = {}, +): Promise { + const params: Record = { ref, argument }; + if (opts.context !== undefined) params.context = { arguments: opts.context }; + const result = await requestResult(url, "completion/complete", params, opts); + if (isInputRequired(result) || isTaskHandle(result)) { + throw new McpError( + `MCP server ${url} sent ${result.resultType} for completion/complete`, + ); + } + const parsed = CompletionResult(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed completion/complete result: ${parsed.summary}`, + ); + } + return parsed; +} + +export type McpSubscriptionFilter = { + toolsListChanged?: boolean; + promptsListChanged?: boolean; + resourcesListChanged?: boolean; + resourceSubscriptions?: readonly string[]; + /** Requires `io.modelcontextprotocol/tasks`. */ + taskIds?: readonly string[]; +}; + +export type McpNotification = { + method: string; + params?: Record; + subscriptionId?: string | number; +}; + +/** Extension methods and the listen stream are 2026-only. A missing session + * still speaks 2026; a legacy session does not. */ +function asModern(opts: McpClientOptions): McpClientOptions { + if (opts.session === undefined) { + return { + ...opts, + session: { + protocolVersion: MCP_PROTOCOL_VERSION, + clientCapabilities: clientCapabilities(opts), + ...(opts.extensions !== undefined && opts.extensions.length > 0 + ? { extensions: opts.extensions } + : {}), + ...(opts.uiMimeTypes !== undefined + ? { uiMimeTypes: opts.uiMimeTypes } + : {}), + }, + }; + } + if (!isModernSession(opts.session)) { + throw new McpError(`this method requires protocol ${MCP_PROTOCOL_VERSION}`); + } + return opts; +} + +function subscriptionParams( + filter: McpSubscriptionFilter, +): Record { + const notifications: Record = {}; + if (filter.toolsListChanged !== undefined) { + notifications.toolsListChanged = filter.toolsListChanged; + } + if (filter.promptsListChanged !== undefined) { + notifications.promptsListChanged = filter.promptsListChanged; + } + if (filter.resourcesListChanged !== undefined) { + notifications.resourcesListChanged = filter.resourcesListChanged; + } + if (filter.resourceSubscriptions !== undefined) { + notifications.resourceSubscriptions = [...filter.resourceSubscriptions]; + } + if (filter.taskIds !== undefined) notifications.taskIds = [...filter.taskIds]; + return { notifications }; +} + +function asNotification(message: { + method: string; + params?: Record; +}): McpNotification { + const meta = message.params?._meta; + let subscriptionId: string | number | undefined; + if (typeof meta === "object" && meta !== null) { + const id = (meta as Record)[META_SUBSCRIPTION]; + if (typeof id === "string" || typeof id === "number") subscriptionId = id; + } + return { + method: message.method, + ...(message.params !== undefined ? { params: message.params } : {}), + ...(subscriptionId !== undefined ? { subscriptionId } : {}), + }; +} + +/** + * `subscriptions/listen`. Resolves when the server closes the stream with a + * result, or rejects when `signal` aborts (the stream is closed and + * `notifications/cancelled` is sent). + */ +export async function mcpListen( + url: string, + filter: McpSubscriptionFilter, + onNotification: (notification: McpNotification) => void, + opts: McpClientOptions = {}, +): Promise { + if (filter.taskIds !== undefined) requireExtension(opts, MCP_EXT_TASKS); + const callOpts = asModern(opts); + const fetchImpl = callOpts.fetch ?? fetch; + const id = nextId++; + const timeout = + callOpts.timeoutMs === undefined + ? undefined + : AbortSignal.timeout(callOpts.timeoutMs); + const signals = [timeout, opts.signal].filter((s) => s !== undefined); + const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined; + const notifyCancel = () => { + void sendNotification( + url, + "notifications/cancelled", + cancellationOpts(callOpts), + { + requestId: id, + reason: "the caller aborted the request", + }, + ).catch(() => undefined); + }; + if (opts.signal !== undefined) { + opts.signal.addEventListener("abort", notifyCancel, { once: true }); + } + try { + const response = await fetchImpl(url, { + method: "POST", + headers: requestHeaders(callOpts, "subscriptions/listen", undefined, {}), + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "subscriptions/listen", + params: modernParams(subscriptionParams(filter), callOpts, id), + }), + ...(signal !== undefined ? { signal } : {}), + }); + if (!response.ok) + throw await errorFrom(response, url, "subscriptions/listen", signal); + const contentType = response.headers.get("content-type") ?? ""; + const finish = (message: unknown): void => { + const parsed = JsonRpcResponse(message); + if (!(parsed instanceof type.errors) && parsed.error !== undefined) { + throw new McpError( + `MCP server ${url} rejected subscriptions/listen: ${parsed.error.message}`, + undefined, + parsed.error.code, + parsed.error.data, + ); + } + if (parsed instanceof type.errors || !isResponseTo(message, id)) { + throw new McpError( + `MCP server ${url} sent a malformed response to subscriptions/listen`, + ); + } + }; + if (!contentType.includes("text/event-stream")) { + finish(parseJson(await readCapped(response, signal))); + return; + } + for await (const message of sseMessages(response, signal)) { + if (isNotification(message)) { + onNotification(asNotification(message)); + continue; + } + if (isResponseTo(message, id)) { + finish(message); + return; + } + } + throw new McpError( + "MCP server closed the event stream without a matching response", + ); + } catch (cause) { + if (opts.signal?.aborted === true) { + throw new McpError( + `subscriptions/listen to MCP server ${url} was cancelled`, + undefined, + undefined, + undefined, + { cancelled: true }, + ); + } + if (timeout?.aborted === true) { + throw new McpError( + `MCP server ${url} did not answer subscriptions/listen within ${String(callOpts.timeoutMs)}ms`, + undefined, + undefined, + undefined, + { timedOut: true }, + ); + } + throw cause; + } finally { + opts.signal?.removeEventListener("abort", notifyCancel); + } +} + +const TaskStateSchema = type({ + taskId: "string", + status: "'working' | 'input_required' | 'completed' | 'failed' | 'cancelled'", + createdAt: "string", + lastUpdatedAt: "string", + ttlMs: "number | null", + "pollIntervalMs?": "number", + "statusMessage?": "string", + "inputRequests?": "Record", + "result?": "Record", + "error?": { code: "number", message: "string", "data?": "unknown" }, + "_meta?": "Record", +}); + +function parseTaskState(result: unknown, url: string, method: string): McpTask { + const parsed = TaskStateSchema(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed ${method} result: ${parsed.summary}`, + ); + } + if ( + parsed.status === "input_required" && + parsed.inputRequests === undefined + ) { + throw new McpError( + `MCP server ${url} sent an input_required task without inputRequests`, + ); + } + if (parsed.status === "completed" && parsed.result === undefined) { + throw new McpError( + `MCP server ${url} sent a completed task without a result`, + ); + } + if (parsed.status === "failed" && parsed.error === undefined) { + throw new McpError(`MCP server ${url} sent a failed task without an error`); + } + return { resultType: "complete", ...parsed }; +} + +/** `tasks/get`. Requires the tasks extension. */ +export async function mcpGetTask( + url: string, + taskId: string, + opts: McpClientOptions = {}, +): Promise { + requireExtension(opts, MCP_EXT_TASKS); + const result = await requestResult( + url, + "tasks/get", + { taskId }, + asModern(opts), + ); + if (isInputRequired(result) || isTaskHandle(result)) { + throw new McpError( + `MCP server ${url} sent ${result.resultType} for tasks/get`, + ); + } + return parseTaskState(result, url, "tasks/get"); +} + +/** `tasks/update`. Requires the tasks extension. */ +export async function mcpUpdateTask( + url: string, + taskId: string, + inputResponses: Record, + opts: McpClientOptions = {}, +): Promise { + requireExtension(opts, MCP_EXT_TASKS); + const result = await requestResult( + url, + "tasks/update", + { taskId, inputResponses }, + asModern(opts), + ); + if (isInputRequired(result) || isTaskHandle(result)) { + throw new McpError( + `MCP server ${url} sent ${result.resultType} for tasks/update`, + ); + } +} + +/** `tasks/cancel`. Requires the tasks extension. Cooperative: the server only acknowledges. */ +export async function mcpCancelTask( + url: string, + taskId: string, + opts: McpClientOptions = {}, +): Promise { + requireExtension(opts, MCP_EXT_TASKS); + const result = await requestResult( + url, + "tasks/cancel", + { taskId }, + asModern(opts), + ); + if (isInputRequired(result) || isTaskHandle(result)) { + throw new McpError( + `MCP server ${url} sent ${result.resultType} for tasks/cancel`, + ); + } +} + +const SkillSchema = type({ + uri: "string", + frontmatter: "Record", + resources: "unknown", +}); +export type McpSkill = typeof SkillSchema.infer; +export type McpSkillList = McpSkill[] & PageCache; + +function requireSkills(opts: McpClientOptions): void { + requireExtension(opts, MCP_EXT_SKILLS); +} + +/** `skills/list`, following cursors. Requires the skills extension. */ +export async function mcpListSkills( + url: string, + opts: McpClientOptions = {}, +): Promise { + requireSkills(opts); + const { pages, cache } = await eachPage( + url, + "skills/list", + undefined, + asModern(opts), + ); + const skills: McpSkill[] = []; + for (const result of pages) { + const parsed = type({ skills: SkillSchema.array() })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed skills/list result: ${parsed.summary}`, + ); + } + skills.push(...parsed.skills); + } + return attachCache(skills, cache); +} + +export type McpSkillResult = { + resultType: "complete"; + skill: McpSkill; + ttlMs?: number; + cacheScope?: McpCacheScope; +}; + +/** `skills/get`. Requires the skills extension. */ +export async function mcpGetSkill( + url: string, + uri: string, + opts: McpClientOptions = {}, +): Promise { + requireSkills(opts); + const result = await requestResult( + url, + "skills/get", + { uri }, + asModern(opts), + ); + if (isInputRequired(result) || isTaskHandle(result)) { + throw new McpError( + `MCP server ${url} sent ${result.resultType} for skills/get`, + ); + } + const parsed = type({ skill: SkillSchema })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed skills/get result: ${parsed.summary}`, + ); + } + const cache = readCache(result, url, "skills/get"); + return { + resultType: "complete", + skill: parsed.skill, + ...(cache.ttlMs !== undefined ? { ttlMs: cache.ttlMs } : {}), + ...(cache.cacheScope !== undefined ? { cacheScope: cache.cacheScope } : {}), + }; +} + +function directoryReadEnabled(session: McpSession | undefined): boolean { + const capabilities = session?.capabilities; + if (typeof capabilities !== "object" || capabilities === null) return false; + const extensions = (capabilities as { extensions?: unknown }).extensions; + if (typeof extensions !== "object" || extensions === null) return false; + const skills = (extensions as Record)[MCP_EXT_SKILLS]; + if (typeof skills !== "object" || skills === null) return false; + return (skills as { directoryRead?: unknown }).directoryRead === true; +} + +/** + * `resources/directory/read`. Sent only when the skills extension is enabled + * and the server declared `directoryRead`. + */ +export async function mcpReadResourceDirectory( + url: string, + uri: string, + opts: McpClientOptions = {}, +): Promise { + requireSkills(opts); + const modern = asModern(opts); + if (!directoryReadEnabled(modern.session)) { + throw new McpError( + `MCP server did not declare ${MCP_EXT_SKILLS} directoryRead`, + ); + } + const { pages, cache } = await eachPage( + url, + "resources/directory/read", + { uri }, + modern, + ); + const resources: McpResource[] = []; + for (const result of pages) { + const parsed = type({ resources: ResourceSchema.array() })(result); + if (parsed instanceof type.errors) { + throw new McpError( + `MCP server ${url} sent a malformed resources/directory/read result: ${parsed.summary}`, + ); + } + resources.push(...parsed.resources); + } + return attachCache(resources, cache); } diff --git a/src/hub/discover.ts b/src/hub/discover.ts index dab21d0..0312148 100644 --- a/src/hub/discover.ts +++ b/src/hub/discover.ts @@ -18,8 +18,9 @@ import type { Hono, MiddlewareHandler } from "hono"; import { McpError, - mcpInitialize, + mcpConnect, mcpListTools, + type McpCacheScope, type McpTool, type McpServerInfo, } from "../client.js"; @@ -133,6 +134,8 @@ const DISCOVERY_TIMEOUT_MS = 30_000; export type McpDiscovery = { readonly serverInfo: McpServerInfo; readonly tools: readonly McpTool[]; + readonly ttlMs?: number; + readonly cacheScope?: McpCacheScope; }; /** @@ -183,7 +186,7 @@ export async function discoverMcpServer(args: { fetch: pinned, timeoutMs: args.timeoutMs ?? DISCOVERY_TIMEOUT_MS, }; - const session = await mcpInitialize(args.url, client); + const session = await mcpConnect(args.url, client); const tools = await mcpListTools(args.url, { ...client, session }); return { serverInfo: { @@ -193,6 +196,8 @@ export async function discoverMcpServer(args: { : {}), }, tools, + ...(tools.ttlMs !== undefined ? { ttlMs: tools.ttlMs } : {}), + ...(tools.cacheScope !== undefined ? { cacheScope: tools.cacheScope } : {}), }; } diff --git a/src/index.ts b/src/index.ts index b10a352..b7f8726 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,14 +8,53 @@ export { export { qualifiedName, isAskExempt, toolDescription } from "./naming.js"; export { + MCP_PROTOCOL_VERSION, + MCP_EXT_TASKS, + MCP_EXT_UI, + MCP_EXT_SKILLS, + McpErrorCode, + mcpConnect, mcpInitialize, mcpListTools, mcpCallTool, + mcpContinue, + mcpListPrompts, + mcpGetPrompt, + mcpListResources, + mcpReadResource, + mcpListResourceTemplates, + mcpComplete, + mcpListen, + mcpGetTask, + mcpUpdateTask, + mcpCancelTask, + mcpListSkills, + mcpGetSkill, + mcpReadResourceDirectory, McpError, McpToolSchema, type McpTool, + type McpToolList, type McpToolResult, + type McpToolCallResult, + type McpInputRequired, + type McpTask, + type McpTaskHandle, + type McpProgress, + type McpPrompt, + type McpPromptResult, + type McpResource, + type McpResourceReadResult, + type McpResourceTemplate, + type McpCompletion, + type McpCompletionRef, + type McpSubscriptionFilter, + type McpNotification, + type McpSkill, + type McpSkillResult, + type McpCacheScope, type McpClientOptions, + type McpClientCapabilities, type McpSession, } from "./client.js"; @@ -23,12 +62,21 @@ export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, + mcpClientIdMetadataDocument, + preferredMcpClientRegistration, + resolveStoredMcpClient, + assertAuthorizationIssuer, + readAuthorizationCallback, selectMcpScopes, type DiscoverMcpLoginEntryOptions, type McpAuthorizationServer, type McpClientConfigOptions, + type McpClientIdMetadataDocument, type McpClientRegistration, + type McpClientRegistrationPlan, type McpLoginEntry, type RegisterMcpClientOptions, type SelectMcpScopesOptions, + type StoredMcpClient, + type AuthorizationIssuerOptions, } from "./oauth-discovery.js"; diff --git a/src/oauth-discovery.test.ts b/src/oauth-discovery.test.ts index 3b36d60..37ed9ea 100644 --- a/src/oauth-discovery.test.ts +++ b/src/oauth-discovery.test.ts @@ -9,9 +9,14 @@ import { import type { FetchLike } from "@intx/harness"; import { + assertAuthorizationIssuer, discoverMcpLoginEntry, mcpClientConfig, + mcpClientIdMetadataDocument, + preferredMcpClientRegistration, + readAuthorizationCallback, registerMcpClient, + resolveStoredMcpClient, selectMcpScopes, type McpLoginEntry, } from "./index.js"; @@ -474,6 +479,198 @@ describe("MCP OAuth discovery", () => { const body: unknown = JSON.parse(String(requests[0]?.init?.body ?? "{}")); expect(body).not.toHaveProperty("scope"); expect(result.scope).toBeUndefined(); + expect(body).toMatchObject({ application_type: "native" }); + expect(result.issuer).toBeUndefined(); + }); + + test("records the issuer on a dynamic registration and can ask for application_type web", async () => { + const { fetchImpl, requests } = fakeFetch({ + "https://auth.example.com/register": { + status: 200, + body: { client_id: "web-client" }, + }, + }); + const result = await registerMcpClient({ + registrationEndpoint: "https://auth.example.com/register", + redirectUris: ["https://app.example.com/callback"], + clientName: "Corbits", + applicationType: "web", + issuer: "https://auth.example.com", + fetchImpl, + }); + const body: unknown = JSON.parse(String(requests[0]?.init?.body ?? "{}")); + expect(body).toMatchObject({ application_type: "web" }); + expect(result.issuer).toBe("https://auth.example.com"); + }); + + test("keeps client_id_metadata_document_supported when the authorization server sends it", async () => { + const { fetchImpl } = fakeFetch({ + "https://mcp.example.com/.well-known/oauth-protected-resource/mcp": { + status: 200, + body: { + resource: resourceUrl, + authorization_servers: ["https://auth.example.com"], + }, + }, + "https://auth.example.com/.well-known/oauth-authorization-server": { + status: 200, + body: { + ...asMetadata, + client_id_metadata_document_supported: true, + authorization_response_iss_parameter_supported: true, + }, + }, + }); + const entry = await discoverMcpLoginEntry({ resourceUrl, fetchImpl }); + expect(entry.authorizationServer.clientIdMetadataDocumentSupported).toBe( + true, + ); + expect( + entry.authorizationServer.authorizationResponseIssParameterSupported, + ).toBe(true); + }); +}); + +describe("MCP client registration choice", () => { + const metadataUrl = "https://app.example.com/oauth/client.json"; + + test("builds a metadata document whose client_id is the document URL", () => { + const document = mcpClientIdMetadataDocument({ + metadataUrl, + clientName: "Corbits", + redirectUris: ["http://127.0.0.1:18080/callback"], + }); + expect(document.client_id).toBe(metadataUrl); + expect(document.token_endpoint_auth_method).toBe("none"); + expect(document.redirect_uris).toEqual(["http://127.0.0.1:18080/callback"]); + }); + + test("refuses a metadata URL without a path", () => { + expect(() => + mcpClientIdMetadataDocument({ + metadataUrl: "https://app.example.com", + clientName: "Corbits", + redirectUris: ["http://127.0.0.1/callback"], + }), + ).toThrow(/path/); + }); + + test("prefers pre-registered, then CIMD, then dynamic registration", () => { + expect( + preferredMcpClientRegistration({ + authorizationServer: { + clientIdMetadataDocumentSupported: true, + registrationEndpoint: "https://auth.example.com/register", + }, + preRegisteredClientId: "pre", + clientMetadataUrl: metadataUrl, + }), + ).toEqual({ mechanism: "pre-registered", clientId: "pre" }); + expect( + preferredMcpClientRegistration({ + authorizationServer: { + clientIdMetadataDocumentSupported: true, + registrationEndpoint: "https://auth.example.com/register", + }, + clientMetadataUrl: metadataUrl, + }), + ).toEqual({ mechanism: "client-id-metadata", clientId: metadataUrl }); + expect( + preferredMcpClientRegistration({ + authorizationServer: { + registrationEndpoint: "https://auth.example.com/register", + }, + clientMetadataUrl: metadataUrl, + }), + ).toEqual({ + mechanism: "dynamic", + registrationEndpoint: "https://auth.example.com/register", + }); + expect(() => + preferredMcpClientRegistration({ authorizationServer: {} }), + ).toThrow(OAuthDiscoveryError); + }); + + test("reuses CIMD across issuers and refuses a dynamic client for another issuer", () => { + expect( + resolveStoredMcpClient("https://other.example.com", { + kind: "cimd", + clientId: metadataUrl, + issuer: "https://auth.example.com", + }).clientId, + ).toBe(metadataUrl); + expect( + resolveStoredMcpClient("https://auth.example.com/", { + kind: "dynamic", + clientId: "dyn", + issuer: "https://auth.example.com", + }).clientId, + ).toBe("dyn"); + expect(() => + resolveStoredMcpClient("https://other.example.com", { + kind: "pre-registered", + clientId: "pre", + issuer: "https://auth.example.com", + }), + ).toThrow(/re-register/); + }); +}); + +describe("authorization response iss", () => { + test("requires an exact iss match before the code can be read", () => { + const params = new URLSearchParams({ + code: "auth-code", + iss: "https://auth.example.com", + error: "access_denied", + }); + expect(() => + readAuthorizationCallback(params, { + recordedIssuer: "https://auth.example.com/", + }), + ).toThrow(/does not match/); + try { + readAuthorizationCallback(params, { + recordedIssuer: "https://auth.example.com/", + }); + } catch (error) { + expect(error).toBeInstanceOf(OAuthDiscoveryError); + expect((error as Error).message).not.toContain("access_denied"); + } + expect( + readAuthorizationCallback(new URLSearchParams({ code: "auth-code" }), { + recordedIssuer: "https://auth.example.com", + }), + ).toEqual({ code: "auth-code" }); + }); + + test("rejects a missing iss only when the authorization server requires it", () => { + assertAuthorizationIssuer({ + recordedIssuer: "https://auth.example.com", + }); + expect(() => + assertAuthorizationIssuer({ + recordedIssuer: "https://auth.example.com", + iss: "", + }), + ).toThrow(/does not match/); + expect(() => + readAuthorizationCallback( + new URLSearchParams({ error: "access_denied", code: "auth-code" }), + { + recordedIssuer: "https://auth.example.com", + authorizationResponseIssParameterSupported: true, + }, + ), + ).toThrow(/omitted iss/); + expect(() => + readAuthorizationCallback( + new URLSearchParams({ + iss: "https://auth.example.com", + error: "access_denied", + }), + { recordedIssuer: "https://auth.example.com" }, + ), + ).toThrow(/access_denied/); }); }); diff --git a/src/oauth-discovery.ts b/src/oauth-discovery.ts index f11be76..cc8b449 100644 --- a/src/oauth-discovery.ts +++ b/src/oauth-discovery.ts @@ -39,6 +39,8 @@ const AuthorizationServerMetadata = type({ "scopes_supported?": "string[]", "token_endpoint_auth_methods_supported?": "string[]", "code_challenge_methods_supported?": "string[]", + "client_id_metadata_document_supported?": "boolean", + "authorization_response_iss_parameter_supported?": "boolean", }); // RFC 7591 §3.2.1 — registration response, narrowed to the assigned client id @@ -61,6 +63,10 @@ export type McpAuthorizationServer = { scopesSupported?: readonly string[]; tokenEndpointAuthMethodsSupported?: readonly string[]; codeChallengeMethodsSupported?: readonly string[]; + /** Present only when the server sent `client_id_metadata_document_supported`. */ + clientIdMetadataDocumentSupported?: boolean; + /** Present only when the server sent `authorization_response_iss_parameter_supported`. */ + authorizationResponseIssParameterSupported?: boolean; }; // A login entry derived from a resource URL: everything the host needs to @@ -88,6 +94,13 @@ export type RegisterMcpClientOptions = { // §2), used to decide whether to request refresh_token. Omit when unknown. grantTypesSupported?: readonly string[]; scope?: string; + /** + * OIDC defaults an omitted `application_type` to `"web"`, which rejects + * loopback redirect URIs. Loopback and other public clients use `"native"`. + */ + applicationType?: "native" | "web"; + /** Recorded on the result so the caller can key the credential by issuer. */ + issuer?: string; fetchImpl?: FetchLike; timeoutMs?: number; }; @@ -98,6 +111,36 @@ export type McpClientRegistration = { clientId: string; grantTypes: string[]; scope?: string; + /** Set when `registerMcpClient` was given an issuer to persist with the credential. */ + issuer?: string; +}; + +export type McpClientIdMetadataDocument = { + client_id: string; + client_name: string; + redirect_uris: string[]; + grant_types: string[]; + response_types: ["code"]; + token_endpoint_auth_method: "none"; +}; + +export type McpClientRegistrationPlan = + | { mechanism: "pre-registered"; clientId: string } + | { mechanism: "client-id-metadata"; clientId: string } + | { mechanism: "dynamic"; registrationEndpoint: string }; + +export type StoredMcpClient = { + kind: "cimd" | "dynamic" | "pre-registered"; + clientId: string; + /** Required for `dynamic` and `pre-registered`. CIMD credentials are portable. */ + issuer?: string; +}; + +export type AuthorizationIssuerOptions = { + recordedIssuer: string; + /** Absent when the authorization response omitted `iss`. An empty string is present. */ + iss?: string; + authorizationResponseIssParameterSupported?: boolean; }; export type McpClientConfigOptions = { @@ -318,6 +361,12 @@ export async function discoverMcpLoginEntry( if (metadata.code_challenge_methods_supported !== undefined) authorizationServer.codeChallengeMethodsSupported = metadata.code_challenge_methods_supported; + if (metadata.client_id_metadata_document_supported !== undefined) + authorizationServer.clientIdMetadataDocumentSupported = + metadata.client_id_metadata_document_supported; + if (metadata.authorization_response_iss_parameter_supported !== undefined) + authorizationServer.authorizationResponseIssParameterSupported = + metadata.authorization_response_iss_parameter_supported; const entry: McpLoginEntry = { resourceUrl, authorizationServer }; if (resourceScopesSupported !== undefined) @@ -386,6 +435,7 @@ export async function registerMcpClient( grant_types: requestedGrantTypes, response_types: ["code"], token_endpoint_auth_method: "none", + application_type: opts.applicationType ?? "native", scope: opts.scope, }), signal: AbortSignal.timeout(timeoutMs), @@ -420,9 +470,187 @@ export async function registerMcpClient( grantTypes: grantedGrantTypes(payload.grant_types, requestedGrantTypes), }; if (scope !== undefined) registration.scope = scope; + if (opts.issuer !== undefined) registration.issuer = opts.issuer; return registration; } +function assertClientMetadataUrl(metadataUrl: string): void { + let url: URL; + try { + url = new URL(metadataUrl); + } catch { + throw new OAuthDiscoveryError( + `client metadata URL is not a valid URL: ${metadataUrl}`, + ); + } + if (url.protocol !== "https:") + throw new OAuthDiscoveryError( + `client metadata URL must use https: ${metadataUrl}`, + ); + if (url.pathname === "" || url.pathname === "/") + throw new OAuthDiscoveryError( + `client metadata URL must contain a path: ${metadataUrl}`, + ); + if (url.hash !== "" || url.username !== "" || url.password !== "") + throw new OAuthDiscoveryError( + `client metadata URL must not contain a fragment or userinfo: ${metadataUrl}`, + ); +} + +/** + * Build a Client ID Metadata Document whose `client_id` is the document URL. + * The caller hosts the returned JSON at that exact URL. + */ +export function mcpClientIdMetadataDocument(opts: { + metadataUrl: string; + clientName: string; + redirectUris: readonly string[]; + grantTypes?: readonly string[]; +}): McpClientIdMetadataDocument { + assertClientMetadataUrl(opts.metadataUrl); + return { + client_id: opts.metadataUrl, + client_name: opts.clientName, + redirect_uris: [...opts.redirectUris], + grant_types: [...(opts.grantTypes ?? ["authorization_code"])], + response_types: ["code"], + token_endpoint_auth_method: "none", + }; +} + +/** + * Registration priority: pre-registered client id, then a Client ID Metadata + * Document when the authorization server advertises support and a metadata + * URL is available, then RFC 7591. Anything else is an error. + */ +export function preferredMcpClientRegistration(opts: { + authorizationServer: Pick< + McpAuthorizationServer, + "clientIdMetadataDocumentSupported" | "registrationEndpoint" + >; + preRegisteredClientId?: string; + clientMetadataUrl?: string; +}): McpClientRegistrationPlan { + if ( + opts.preRegisteredClientId !== undefined && + opts.preRegisteredClientId.length > 0 + ) { + return { + mechanism: "pre-registered", + clientId: opts.preRegisteredClientId, + }; + } + if ( + opts.authorizationServer.clientIdMetadataDocumentSupported === true && + opts.clientMetadataUrl !== undefined + ) { + assertClientMetadataUrl(opts.clientMetadataUrl); + return { + mechanism: "client-id-metadata", + clientId: opts.clientMetadataUrl, + }; + } + const registrationEndpoint = opts.authorizationServer.registrationEndpoint; + if (registrationEndpoint !== undefined) + return { mechanism: "dynamic", registrationEndpoint }; + throw new OAuthDiscoveryError( + "authorization server offers no usable client registration mechanism", + ); +} + +/** + * CIMD client ids are portable across issuers. Dynamic and pre-registered + * credentials are keyed by issuer and must be registered again when it changes. + * Trailing slashes are ignored here; RFC 9207 `iss` comparison is exact and + * lives in `assertAuthorizationIssuer`. + */ +export function resolveStoredMcpClient( + issuer: string, + stored: StoredMcpClient, +): { clientId: string } { + if (stored.kind === "cimd") return { clientId: stored.clientId }; + if ( + stored.issuer === undefined || + normalizedIssuer(stored.issuer) !== normalizedIssuer(issuer) + ) { + throw new OAuthDiscoveryError( + `stored ${stored.kind} client credentials are for a different authorization server; re-register`, + ); + } + return { clientId: stored.clientId }; +} + +/** + * RFC 9207 via the MCP authorization spec. A present `iss` (including an + * empty string) must equal the recorded issuer with no normalization. When + * the server advertises `authorization_response_iss_parameter_supported`, a + * missing `iss` is rejected. Otherwise a missing `iss` is allowed. + */ +export function assertAuthorizationIssuer( + opts: AuthorizationIssuerOptions, +): void { + if (opts.iss === undefined) { + if (opts.authorizationResponseIssParameterSupported === true) + throw new OAuthDiscoveryError( + "authorization response omitted iss but the authorization server requires it", + ); + return; + } + if (opts.iss !== opts.recordedIssuer) + throw new OAuthDiscoveryError( + "authorization response iss does not match the authorization server issuer", + ); +} + +function callbackField( + params: URLSearchParams | Record, + key: string, +): string | undefined { + if (params instanceof URLSearchParams) { + if (!params.has(key)) return undefined; + return params.get(key) ?? ""; + } + if (!Object.prototype.hasOwnProperty.call(params, key)) return undefined; + const value = params[key]; + if (value === undefined || value === null) return undefined; + return value; +} + +/** + * Read an authorization callback. `iss` is checked before `error` or `code` + * is acted on, and this function does not redeem the code. + */ +export function readAuthorizationCallback( + params: URLSearchParams | Record, + opts: { + recordedIssuer: string; + authorizationResponseIssParameterSupported?: boolean; + }, +): { code: string } { + const iss = callbackField(params, "iss"); + const issuerOpts: AuthorizationIssuerOptions = { + recordedIssuer: opts.recordedIssuer, + }; + if (iss !== undefined) issuerOpts.iss = iss; + if (opts.authorizationResponseIssParameterSupported !== undefined) + issuerOpts.authorizationResponseIssParameterSupported = + opts.authorizationResponseIssParameterSupported; + assertAuthorizationIssuer(issuerOpts); + const error = callbackField(params, "error"); + if (error !== undefined) { + const description = callbackField(params, "error_description"); + throw new OAuthDiscoveryError( + `authorization server rejected the request: ${error}${description !== undefined ? `: ${description}` : ""}`, + ); + } + const code = callbackField(params, "code"); + if (code === undefined || code.length === 0) + throw new OAuthDiscoveryError( + "authorization response did not include a code", + ); + return { code }; +} + // MCP authorization spec, "Scope Selection Strategy": priority order is (1) // the `scope` parameter from a 401 WWW-Authenticate challenge, (2) all scopes // in the protected resource's `scopes_supported`, (3) omit the scope diff --git a/src/protocol-2026.test.ts b/src/protocol-2026.test.ts new file mode 100644 index 0000000..db6ec13 --- /dev/null +++ b/src/protocol-2026.test.ts @@ -0,0 +1,925 @@ +import { describe, expect, test } from "bun:test"; + +import { + MCP_EXT_SKILLS, + MCP_EXT_TASKS, + MCP_EXT_UI, + MCP_PROTOCOL_VERSION, + McpError, + McpErrorCode, + mcpCallTool, + mcpCancelTask, + mcpComplete, + mcpConnect, + mcpContinue, + mcpGetPrompt, + mcpGetSkill, + mcpGetTask, + mcpListPrompts, + mcpListResourceTemplates, + mcpListResources, + mcpListSkills, + mcpListTools, + mcpListen, + mcpReadResource, + mcpReadResourceDirectory, + mcpUpdateTask, + type McpSession, +} from "./client.js"; + +type RpcBody = { + id?: number | null; + method?: string; + params?: Record; +}; + +type Seen = { method: string; headers: Headers; body: RpcBody }; + +function rpc( + id: number | null | undefined, + result: unknown, + status = 200, +): Response { + return Response.json({ jsonrpc: "2.0", id: id ?? null, result }, { status }); +} + +function rpcError( + id: number | null | undefined, + code: number, + message: string, + data?: unknown, + status = 200, +): Response { + return Response.json( + { + jsonrpc: "2.0", + id: id ?? null, + error: { + code, + message, + ...(data !== undefined ? { data } : {}), + }, + }, + { status }, + ); +} + +function jsonServer(handler: (seen: Seen) => Response | Promise): { + url: string; + seen: Seen[]; + stop: () => void; +} { + const seen: Seen[] = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as RpcBody; + const entry = { method: String(body.method), headers: req.headers, body }; + seen.push(entry); + return handler(entry); + }, + }); + return { + url: server.url.toString(), + seen, + stop: () => void server.stop(true), + }; +} + +function meta(seen: Seen): Record { + const value = seen.body.params?._meta; + if (typeof value !== "object" || value === null) { + throw new Error("request is missing _meta"); + } + return value as Record; +} + +const modern: McpSession = { protocolVersion: MCP_PROTOCOL_VERSION }; + +const META_PROTOCOL = "io.modelcontextprotocol/protocolVersion"; +const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"; +const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; + +describe("MCP 2026-07-28", () => { + test("discover opens a stateless session and later calls carry metadata and headers", async () => { + const srv = jsonServer((seen) => { + if (seen.method === "server/discover") { + return rpc(seen.body.id, { + supportedVersions: [MCP_PROTOCOL_VERSION, "2025-03-26"], + capabilities: { tools: {} }, + instructions: "be brief", + _meta: { + "io.modelcontextprotocol/serverInfo": { name: "era", version: "1" }, + }, + }); + } + if (seen.method === "tools/list") return rpc(seen.body.id, { tools: [] }); + return rpcError(seen.body.id, McpErrorCode.MethodNotFound, "no"); + }); + try { + const session = await mcpConnect(srv.url); + expect(session.protocolVersion).toBe(MCP_PROTOCOL_VERSION); + expect(session.sessionId).toBeUndefined(); + expect(session.serverInfo).toEqual({ name: "era", version: "1" }); + expect(session.instructions).toBe("be brief"); + await mcpListTools(srv.url, { session }); + expect(srv.seen.map((s) => s.method)).toEqual([ + "server/discover", + "tools/list", + ]); + for (const seen of srv.seen) { + expect(seen.headers.get("mcp-protocol-version")).toBe( + MCP_PROTOCOL_VERSION, + ); + expect(seen.headers.get("mcp-method")).toBe(seen.method); + expect(seen.headers.has("mcp-session-id")).toBe(false); + expect(seen.headers.get("accept")).toContain("application/json"); + expect(seen.headers.get("accept")).toContain("text/event-stream"); + const sent = meta(seen); + expect(sent[META_PROTOCOL]).toBe(MCP_PROTOCOL_VERSION); + expect(sent[META_CLIENT_INFO]).toEqual({ + name: "@corbits/mcp", + version: "0.3.1", + }); + expect(sent[META_CLIENT_CAPABILITIES]).toEqual({}); + } + } finally { + srv.stop(); + } + }); + + test("method-not-found, a legacy version list, and a timeout fall back to initialize", async () => { + const notFound = jsonServer((seen) => { + if (seen.method === "server/discover") { + return rpcError(seen.body.id, McpErrorCode.MethodNotFound, "unknown"); + } + if (seen.method === "initialize") { + expect(seen.headers.has("mcp-protocol-version")).toBe(false); + expect(seen.body.params?.protocolVersion).toBe("2025-03-26"); + return rpc(seen.body.id, { + protocolVersion: "2025-03-26", + capabilities: {}, + }); + } + if (seen.body.id === undefined) + return new Response(null, { status: 202 }); + return rpc(seen.body.id, { tools: [] }); + }); + try { + const session = await mcpConnect(notFound.url); + expect(session).toEqual({ protocolVersion: "2025-03-26" }); + expect(notFound.seen.map((s) => s.method)).toEqual([ + "server/discover", + "initialize", + "notifications/initialized", + ]); + } finally { + notFound.stop(); + } + + const legacy = jsonServer((seen) => { + if (seen.method === "server/discover") { + return rpc(seen.body.id, { + supportedVersions: ["2025-11-25"], + capabilities: {}, + }); + } + if (seen.method === "initialize") { + expect(seen.body.params?.protocolVersion).toBe("2025-11-25"); + expect(seen.headers.has("mcp-protocol-version")).toBe(false); + return rpc(seen.body.id, { protocolVersion: "2025-11-25" }); + } + if (seen.body.id === undefined) + return new Response(null, { status: 202 }); + return rpc(seen.body.id, {}); + }); + try { + const session = await mcpConnect(legacy.url); + expect(session.protocolVersion).toBe("2025-11-25"); + } finally { + legacy.stop(); + } + + let discoverHung = false; + const slow = jsonServer((seen) => { + if (!discoverHung) { + discoverHung = true; + return new Promise(() => undefined); + } + if (seen.method === "initialize") { + return rpc(seen.body.id, { protocolVersion: "2025-03-26" }); + } + if (seen.body.id === undefined) + return new Response(null, { status: 202 }); + return rpc(seen.body.id, {}); + }); + try { + const session = await mcpConnect(slow.url, { timeoutMs: 30 }); + expect(session.protocolVersion).toBe("2025-03-26"); + expect(slow.seen.map((entry) => entry.method)).toContain("initialize"); + } finally { + slow.stop(); + } + }); + + test("header mismatch and an unsupported modern version do not fall back", async () => { + const mismatch = jsonServer((seen) => + rpcError( + seen.body.id, + McpErrorCode.HeaderMismatch, + "header", + undefined, + 400, + ), + ); + try { + const failure = await mcpConnect(mismatch.url).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(McpError); + expect((failure as McpError).code).toBe(McpErrorCode.HeaderMismatch); + expect(mismatch.seen.map((s) => s.method)).toEqual(["server/discover"]); + } finally { + mismatch.stop(); + } + + const unsupported = jsonServer(() => + rpcError( + null, + McpErrorCode.UnsupportedProtocolVersion, + "version", + { supported: [MCP_PROTOCOL_VERSION], requested: MCP_PROTOCOL_VERSION }, + 400, + ), + ); + try { + const failure = await mcpConnect(unsupported.url).catch( + (error: unknown) => error, + ); + expect((failure as McpError).code).toBe( + McpErrorCode.UnsupportedProtocolVersion, + ); + expect(unsupported.seen).toHaveLength(1); + } finally { + unsupported.stop(); + } + + const downgrade = jsonServer((seen) => { + if (seen.method === "server/discover") { + return rpcError( + seen.body.id, + McpErrorCode.UnsupportedProtocolVersion, + "version", + { + supported: ["2025-06-18"], + }, + ); + } + if (seen.method === "initialize") { + expect(seen.body.params?.protocolVersion).toBe("2025-06-18"); + return rpc(seen.body.id, { protocolVersion: "2025-06-18" }); + } + return new Response(null, { status: 202 }); + }); + try { + const session = await mcpConnect(downgrade.url); + expect(session.protocolVersion).toBe("2025-06-18"); + } finally { + downgrade.stop(); + } + }); + + test("surfaces standard and modern JSON-RPC codes", async () => { + const codes = [ + McpErrorCode.ParseError, + McpErrorCode.InvalidRequest, + McpErrorCode.InvalidParams, + McpErrorCode.InternalError, + McpErrorCode.MissingRequiredClientCapability, + ]; + for (const code of codes) { + const srv = jsonServer((seen) => + rpcError(seen.body.id, code, `code ${String(code)}`), + ); + try { + const failure = await mcpListTools(srv.url, { session: modern }).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(McpError); + expect((failure as McpError).code).toBe(code); + } finally { + srv.stop(); + } + } + }); + + test("a missing resultType is complete, and input_required retries the same method", async () => { + let call = 0; + const srv = jsonServer((seen) => { + call += 1; + if (call === 1) return rpc(seen.body.id, { content: "plain" }); + if (call === 2) { + return rpc(seen.body.id, { + resultType: "input_required", + requestState: "opaque", + inputRequests: { + a: { method: "elicitation/create", message: "name" }, + }, + }); + } + expect(seen.body.params?.inputResponses).toEqual({ a: { text: "ada" } }); + expect(seen.body.params?.requestState).toBe("opaque"); + expect(seen.body.params?.name).toBe("echo"); + expect(seen.headers.get("mcp-name")).toBe("echo"); + return rpc(seen.body.id, { resultType: "complete", content: "ada" }); + }); + try { + const first = await mcpCallTool( + srv.url, + "echo", + { text: "hi" }, + { session: modern }, + ); + expect(first).toMatchObject({ resultType: "complete", content: "plain" }); + const pending = await mcpCallTool( + srv.url, + "echo", + { text: "hi" }, + { session: modern }, + ); + expect(pending.resultType).toBe("input_required"); + if (pending.resultType !== "input_required") + throw new Error("expected input"); + const continued = await mcpContinue( + srv.url, + pending, + { a: { text: "ada" } }, + { + session: modern, + }, + ); + expect(continued).toMatchObject({ + resultType: "complete", + content: "ada", + }); + } finally { + srv.stop(); + } + }); + + test("follows cursors, including an empty one, and keeps cache fields", async () => { + let page = 0; + const srv = jsonServer((seen) => { + page += 1; + if (page === 1) { + expect(seen.body.params?.cursor).toBeUndefined(); + return rpc(seen.body.id, { + tools: [{ name: "one", inputSchema: { type: "object" } }], + nextCursor: "", + ttlMs: 100, + cacheScope: "public", + }); + } + expect(seen.body.params?.cursor).toBe(""); + return rpc(seen.body.id, { + tools: [ + { + name: "two", + title: "Two", + inputSchema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + unevaluatedProperties: false, + prefixItems: [{ type: "string" }], + }, + outputSchema: { type: "object", unevaluatedProperties: true }, + annotations: { + title: "Two title", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { "ui/resourceUri": "ui://two" }, + }, + ], + ttlMs: 40, + cacheScope: "public", + }); + }); + try { + const tools = await mcpListTools(srv.url, { session: modern }); + expect(tools.map((tool) => tool.name)).toEqual(["one", "two"]); + expect(tools.ttlMs).toBe(40); + expect(tools.cacheScope).toBe("public"); + expect(Object.keys(tools)).toEqual(["0", "1"]); + expect(JSON.stringify(tools)).not.toContain("ttlMs"); + const two = tools[1]; + expect(two?.title).toBe("Two"); + expect(two?.annotations?.title).toBe("Two title"); + expect(two?.outputSchema).toMatchObject({ unevaluatedProperties: true }); + expect(two?.inputSchema).toMatchObject({ + unevaluatedProperties: false, + prefixItems: [{ type: "string" }], + }); + expect(two?._meta).toEqual({ "ui/resourceUri": "ui://two" }); + } finally { + srv.stop(); + } + }); + + test("drops a tool whose x-mcp-header is not a primitive and mirrors a valid one", async () => { + const warned: string[] = []; + const original = console.warn; + console.warn = (message?: unknown) => { + warned.push(String(message)); + }; + const srv = jsonServer((seen) => { + if (seen.method === "tools/list") { + return rpc(seen.body.id, { + tools: [ + { + name: "bad", + inputSchema: { + type: "object", + properties: { n: { type: "number", "x-mcp-header": "N" } }, + }, + }, + { + name: "good", + inputSchema: { + type: "object", + properties: { + note: { type: "string", "x-mcp-header": "Note" }, + }, + }, + }, + ], + }); + } + expect(seen.headers.get("mcp-param-note")).toMatch(/^=\?base64\?.+\?=$/); + return rpc(seen.body.id, { content: "ok" }); + }); + try { + const tools = await mcpListTools(srv.url, { session: modern }); + expect(tools.map((tool) => tool.name)).toEqual(["good"]); + expect(warned.some((line) => line.includes("bad"))).toBe(true); + const good = tools[0]; + if (good === undefined) throw new Error("missing tool"); + const result = await mcpCallTool( + srv.url, + "good", + { note: "héllo" }, + { session: modern, inputSchema: good.inputSchema }, + ); + expect(result).toMatchObject({ resultType: "complete", content: "ok" }); + } finally { + console.warn = original; + srv.stop(); + } + }); + + test("lists and reads prompts, resources, templates, and completions", async () => { + let promptPage = 0; + const fixed = jsonServer((seen) => { + if (seen.method === "prompts/list") { + promptPage += 1; + return promptPage === 1 + ? rpc(seen.body.id, { + prompts: [{ name: "greet" }], + nextCursor: "p2", + }) + : rpc(seen.body.id, { prompts: [{ name: "later" }] }); + } + if (seen.method === "prompts/get") { + expect(seen.headers.get("mcp-name")).toBe("greet"); + return rpc(seen.body.id, { + description: "hi", + messages: [{ role: "user" }], + }); + } + if (seen.method === "resources/list") { + return rpc(seen.body.id, { + resources: [ + { uri: "file://a", name: "a", _meta: { ui: { csp: {} } } }, + ], + }); + } + if (seen.method === "resources/read") { + expect(seen.headers.get("mcp-name")).toBe("file://a"); + return rpc(seen.body.id, { + contents: [{ uri: "file://a", text: "body" }], + ttlMs: 5, + cacheScope: "private", + }); + } + if (seen.method === "resources/templates/list") { + return rpc(seen.body.id, { + resourceTemplates: [{ uriTemplate: "file://{id}", name: "file" }], + }); + } + if (seen.method === "completion/complete") { + expect(seen.body.params?.ref).toEqual({ + type: "ref/prompt", + name: "greet", + }); + return rpc(seen.body.id, { + completion: { values: ["ada"], hasMore: false }, + }); + } + return rpcError(seen.body.id, McpErrorCode.MethodNotFound, seen.method); + }); + try { + const prompts = await mcpListPrompts(fixed.url, { session: modern }); + expect(prompts.map((prompt) => prompt.name)).toEqual(["greet", "later"]); + const prompt = await mcpGetPrompt( + fixed.url, + "greet", + { name: "ada" }, + { session: modern }, + ); + expect(prompt).toMatchObject({ + resultType: "complete", + description: "hi", + }); + const resources = await mcpListResources(fixed.url, { session: modern }); + expect(resources[0]?._meta).toEqual({ ui: { csp: {} } }); + const read = await mcpReadResource(fixed.url, "file://a", { + session: modern, + }); + expect(read).toMatchObject({ + resultType: "complete", + ttlMs: 5, + cacheScope: "private", + }); + const templates = await mcpListResourceTemplates(fixed.url, { + session: modern, + }); + expect(templates[0]?.uriTemplate).toBe("file://{id}"); + const completion = await mcpComplete( + fixed.url, + { type: "ref/prompt", name: "greet" }, + { name: "name", value: "a" }, + { session: modern }, + ); + expect(completion.completion.values).toEqual(["ada"]); + } finally { + fixed.stop(); + } + }); + + test("caller abort sends notifications/cancelled and a timeout does not", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const live = jsonServer(async (seen) => { + if (seen.body.id === undefined) + return new Response(null, { status: 202 }); + await gate; + return rpc(seen.body.id, { content: "late" }); + }); + try { + const ac = new AbortController(); + const pending = mcpCallTool( + live.url, + "echo", + {}, + { session: modern, signal: ac.signal }, + ); + while (live.seen.length === 0) await Bun.sleep(5); + ac.abort(); + release?.(); + const failure = await pending.catch((error: unknown) => error); + expect(failure).toBeInstanceOf(McpError); + expect((failure as McpError).cancelled).toBe(true); + for ( + let i = 0; + i < 20 && + !live.seen.some((s) => s.method === "notifications/cancelled"); + i++ + ) { + await Bun.sleep(10); + } + const note = live.seen.find( + (s) => s.method === "notifications/cancelled", + ); + expect(note?.body.params?.reason).toBe("the caller aborted the request"); + expect(note?.headers.get("mcp-method")).toBe("notifications/cancelled"); + } finally { + live.stop(); + } + + const stalled = jsonServer(() => new Promise(() => undefined)); + try { + const failure = await mcpCallTool( + stalled.url, + "echo", + {}, + { + session: modern, + timeoutMs: 40, + }, + ).catch((error: unknown) => error); + expect((failure as McpError).timedOut).toBe(true); + await Bun.sleep(30); + expect(stalled.seen.map((s) => s.method)).toEqual(["tools/call"]); + } finally { + stalled.stop(); + } + }); + + test("progress stays on the call stream and subscriptions/listen closes on abort", async () => { + const progress: number[] = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as RpcBody; + const enc = new TextEncoder(); + const token = ( + body.params?._meta as { progressToken?: number } | undefined + )?.progressToken; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + enc.encode( + `data: ${JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: token, + progress: 1, + total: 2, + message: "go", + }, + })}\n\n`, + ), + ); + controller.enqueue( + enc.encode( + `data: ${JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { content: "done" }, + })}\n\n`, + ), + ); + controller.close(); + }, + }); + return new Response(stream, { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + try { + const result = await mcpCallTool( + server.url.toString(), + "echo", + {}, + { + session: modern, + onProgress: (update) => progress.push(update.progress), + }, + ); + expect(progress).toEqual([1]); + expect(result).toMatchObject({ resultType: "complete", content: "done" }); + } finally { + void server.stop(true); + } + + const notes: string[] = []; + let listenId: number | undefined; + const ac = new AbortController(); + const listen = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as RpcBody; + if (body.id === undefined) return new Response(null, { status: 202 }); + listenId = body.id ?? undefined; + const enc = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const ack = { + jsonrpc: "2.0", + method: "notifications/subscriptions/acknowledged", + params: { + _meta: { "io.modelcontextprotocol/subscriptionId": body.id }, + }, + }; + controller.enqueue( + enc.encode(`: keepalive\n\ndata: ${JSON.stringify(ack)}\n\n`), + ); + controller.enqueue( + enc.encode( + `data: ${JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/tools/list_changed", + })}\n\n`, + ), + ); + }, + }); + return new Response(stream, { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + try { + const pending = mcpListen( + listen.url.toString(), + { toolsListChanged: true }, + (notification) => notes.push(notification.method), + { signal: ac.signal }, + ); + for (let i = 0; i < 50 && notes.length < 2; i++) await Bun.sleep(10); + expect(notes).toEqual([ + "notifications/subscriptions/acknowledged", + "notifications/tools/list_changed", + ]); + expect(listenId).toEqual(expect.any(Number)); + ac.abort(); + const failure = await pending.catch((error: unknown) => error); + expect((failure as McpError).cancelled).toBe(true); + } finally { + void listen.stop(true); + } + }); + + test("tasks, UI metadata, and skills stay off unless the extension id is enabled", async () => { + const srv = jsonServer((seen) => { + if (seen.method === "tools/call") { + const capabilities = meta(seen)[META_CLIENT_CAPABILITIES] as { + extensions?: Record; + }; + if (capabilities.extensions?.[MCP_EXT_TASKS] !== undefined) { + return rpc(seen.body.id, { + resultType: "task", + taskId: "t1", + status: "working", + createdAt: "2026-07-28T00:00:00Z", + lastUpdatedAt: "2026-07-28T00:00:00Z", + ttlMs: 1000, + }); + } + return rpc(seen.body.id, { + content: "ui", + _meta: { ui: { resourceUri: "ui://app" } }, + }); + } + if (seen.method === "tasks/get") { + return rpc(seen.body.id, { + resultType: "complete", + taskId: "t1", + status: "completed", + createdAt: "2026-07-28T00:00:00Z", + lastUpdatedAt: "2026-07-28T00:00:01Z", + ttlMs: null, + result: { content: "done" }, + }); + } + if (seen.method === "tasks/update" || seen.method === "tasks/cancel") { + return rpc(seen.body.id, {}); + } + if (seen.method === "skills/list") { + return rpc(seen.body.id, { + skills: [ + { + uri: "skill://a", + frontmatter: { name: "a" }, + resources: "dynamic", + }, + ], + }); + } + if (seen.method === "skills/get") { + return rpc(seen.body.id, { + skill: { + uri: "skill://a", + frontmatter: { name: "a" }, + resources: [], + }, + }); + } + if (seen.method === "resources/directory/read") { + return rpc(seen.body.id, { + resources: [{ uri: "skill://a/SKILL.md", name: "skill" }], + }); + } + return rpcError(seen.body.id, McpErrorCode.MethodNotFound, seen.method); + }); + try { + await expect( + mcpGetTask(srv.url, "t1", { session: modern }), + ).rejects.toThrow(/not enabled/); + await expect(mcpListSkills(srv.url, { session: modern })).rejects.toThrow( + /not enabled/, + ); + const plain = await mcpCallTool(srv.url, "echo", {}, { session: modern }); + expect(plain).toMatchObject({ + resultType: "complete", + _meta: { ui: { resourceUri: "ui://app" } }, + }); + expect(meta(srv.seen.at(-1) as Seen)[META_CLIENT_CAPABILITIES]).toEqual( + {}, + ); + + const task = await mcpCallTool( + srv.url, + "echo", + {}, + { + session: modern, + extensions: [MCP_EXT_TASKS], + }, + ); + expect(task).toMatchObject({ + resultType: "task", + taskId: "t1", + status: "working", + }); + const detailed = await mcpGetTask(srv.url, "t1", { + extensions: [MCP_EXT_TASKS], + }); + expect(detailed).toMatchObject({ + resultType: "complete", + status: "completed", + }); + await mcpUpdateTask( + srv.url, + "t1", + { a: { text: "ok" } }, + { extensions: [MCP_EXT_TASKS] }, + ); + await mcpCancelTask(srv.url, "t1", { extensions: [MCP_EXT_TASKS] }); + + const ui = await mcpCallTool( + srv.url, + "echo", + {}, + { + session: modern, + extensions: [MCP_EXT_UI], + }, + ); + expect(ui.resultType).toBe("complete"); + const advertised = meta(srv.seen.at(-1) as Seen)[ + META_CLIENT_CAPABILITIES + ] as { + extensions: Record; + }; + expect(advertised.extensions[MCP_EXT_UI]?.mimeTypes).toEqual([ + "text/html;profile=mcp-app", + ]); + + const skills = await mcpListSkills(srv.url, { + extensions: [MCP_EXT_SKILLS], + }); + expect(skills[0]?.resources).toBe("dynamic"); + const skill = await mcpGetSkill(srv.url, "skill://a", { + extensions: [MCP_EXT_SKILLS], + }); + expect(skill.skill.uri).toBe("skill://a"); + await expect( + mcpReadResourceDirectory(srv.url, "skill://a", { + extensions: [MCP_EXT_SKILLS], + }), + ).rejects.toThrow(/directoryRead/); + const withDirectory: McpSession = { + protocolVersion: MCP_PROTOCOL_VERSION, + extensions: [MCP_EXT_SKILLS], + capabilities: { + extensions: { [MCP_EXT_SKILLS]: { directoryRead: true } }, + }, + }; + const directory = await mcpReadResourceDirectory(srv.url, "skill://a", { + session: withDirectory, + }); + expect(directory.map((resource) => resource.uri)).toEqual([ + "skill://a/SKILL.md", + ]); + } finally { + srv.stop(); + } + }); + + test("a task result is rejected when the tasks extension is off", async () => { + const srv = jsonServer((seen) => + rpc(seen.body.id, { + resultType: "task", + taskId: "t1", + status: "working", + createdAt: "2026-07-28T00:00:00Z", + lastUpdatedAt: "2026-07-28T00:00:00Z", + ttlMs: null, + }), + ); + try { + await expect( + mcpCallTool(srv.url, "echo", {}, { session: modern }), + ).rejects.toThrow(/not enabled/); + } finally { + srv.stop(); + } + }); +}); diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts index 0c39744..3ef9333 100644 --- a/src/sidecar-bundle.ts +++ b/src/sidecar-bundle.ts @@ -21,10 +21,11 @@ import { type } from "arktype"; import { DEFAULT_TIMEOUT_MS, mcpCallTool, - mcpInitialize, + mcpConnect, type McpClientOptions, McpToolSchema, type McpTool, + type McpToolCallResult, } from "./client.js"; import { assertCatalogNames, @@ -76,8 +77,10 @@ export interface McpBoundServer { export interface McpServersConfig { readonly servers: readonly McpBoundServer[]; - /** Per-request bound on `initialize` and `tools/call`; defaults to 60s. */ + /** Per-request bound on connect and `tools/call`; defaults to 60s. */ readonly timeoutMs?: number; + /** Extension ids advertised on every request. Off unless listed. */ + readonly extensions?: readonly string[]; } const BoundServerSchema = type({ @@ -90,6 +93,7 @@ const BoundServerSchema = type({ const ConfigSchema = type({ servers: BoundServerSchema.array(), "timeoutMs?": "number > 0", + "extensions?": "string[]", }); /** Validate at construction: a malformed deploy config is a deploy bug, not a @@ -120,6 +124,36 @@ const TextBlock = type({ type: "'text'", text: "string" }).array(); * MCP results are never interpreted: text blocks come back as their text so * the model reads prose, and anything else is handed over verbatim as JSON. */ +function shapedToolResult( + callId: string, + result: McpToolCallResult, +): { callId: string; isError: boolean; content: string } { + if (result.resultType === "input_required") { + return { + callId, + isError: true, + content: JSON.stringify({ + resultType: result.resultType, + method: result.method, + ...(result.inputRequests !== undefined + ? { inputRequests: result.inputRequests } + : {}), + ...(result.requestState !== undefined + ? { requestState: result.requestState } + : {}), + }), + }; + } + if (result.resultType === "task") { + return { callId, isError: false, content: JSON.stringify(result) }; + } + return { + callId, + isError: result.isError === true, + content: shapeMcpContent(result.content), + }; +} + export function shapeMcpContent(content: unknown): string { if (typeof content === "string") return content; const blocks = TextBlock(content); @@ -204,11 +238,20 @@ export function mcpServers( } const mediated: MediatedFetch = (input, init) => credential.fetch(input, init); - const session = await mcpInitialize(requestPath(runtime.server.url), { + const session = await mcpConnect(requestPath(runtime.server.url), { fetch: mediated, timeoutMs, + ...(config.extensions !== undefined + ? { extensions: config.extensions } + : {}), }); - return { fetch: mediated, session }; + return { + fetch: mediated, + session, + ...(config.extensions !== undefined + ? { extensions: config.extensions } + : {}), + }; })().catch((cause: unknown) => { runtime.ready = undefined; runtime.handle = undefined; @@ -236,17 +279,18 @@ export function mcpServers( requestPath(found.server.url), found.tool.name, call.arguments, - { ...client, timeoutMs, signal }, + { + ...client, + timeoutMs, + signal, + inputSchema: found.tool.inputSchema, + }, ).catch((cause: unknown) => { // The session may have expired; the next call opens a new one. runtime.ready = undefined; throw cause; }); - return { - callId: call.id, - isError: result.isError === true, - content: shapeMcpContent(result.content), - }; + return shapedToolResult(call.id, result); } catch (cause) { // One server's failure is that server's tool error, never the // agent's: every other bound server keeps working. diff --git a/src/tool.ts b/src/tool.ts index a267cfe..90b005d 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -15,10 +15,11 @@ import type { CredentialCapability } from "@intx/types"; import { DEFAULT_TIMEOUT_MS, mcpCallTool, - mcpInitialize, + mcpConnect, mcpListTools, type McpClientOptions, type McpTool, + type McpToolCallResult, } from "./client.js"; import { assertCatalogNames, @@ -57,6 +58,44 @@ export interface McpToolsOptions { allowWithoutAsk?: string[]; /** Per-request bound on every MCP round trip; defaults to 60s. */ timeoutMs?: number; + /** + * Extension ids advertised on every request. Off unless listed. Tools stay + * the only grantable unit; extensions are not projected into tools. + */ + extensions?: readonly string[]; +} + +function toolResult( + callId: string, + result: McpToolCallResult, +): { callId: string; isError: boolean; content: string } { + if (result.resultType === "input_required") { + return { + callId, + isError: true, + content: JSON.stringify({ + resultType: result.resultType, + method: result.method, + ...(result.inputRequests !== undefined + ? { inputRequests: result.inputRequests } + : {}), + ...(result.requestState !== undefined + ? { requestState: result.requestState } + : {}), + }), + }; + } + if (result.resultType === "task") { + return { callId, isError: false, content: JSON.stringify(result) }; + } + return { + callId, + isError: result.isError === true, + content: + typeof result.content === "string" + ? result.content + : JSON.stringify(result.content), + }; } async function resolveFetch( @@ -101,8 +140,11 @@ export async function mcpTools( const clientOpts = { ...(clientFetch !== undefined ? { fetch: clientFetch } : {}), timeoutMs, + ...(options.extensions !== undefined + ? { extensions: options.extensions } + : {}), }; - const session = await mcpInitialize(server.url, clientOpts); + const session = await mcpConnect(server.url, clientOpts); const tools = await mcpListTools(server.url, { ...clientOpts, session }); assertCatalogNames(server.name, tools); for (const tool of tools) { @@ -153,8 +195,11 @@ export async function mcpTools( const opts = { ...(clientFetch !== undefined ? { fetch: clientFetch } : {}), timeoutMs, + ...(options.extensions !== undefined + ? { extensions: options.extensions } + : {}), }; - const session = await mcpInitialize(server.url, opts); + const session = await mcpConnect(server.url, opts); return { ...opts, session }; })(); clientByServer.set(server.name, client); @@ -179,20 +224,14 @@ export async function mcpTools( mcpCallTool(found.server.url, found.tool.name, call.arguments, { ...client, signal, + inputSchema: found.tool.inputSchema, }), ) .catch((cause: unknown) => { clientByServer.delete(found.server.name); throw cause; }); - return { - callId: call.id, - isError: result.isError === true, - content: - typeof result.content === "string" - ? result.content - : JSON.stringify(result.content), - }; + return toolResult(call.id, result); } catch (err) { return { callId: call.id, From a14acc4f93083c0785beccd1fea87691bafe622a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 01:21:53 -0700 Subject: [PATCH 02/28] fix(client): restore mcpCallTool public return-type contract --- src/client.test.ts | 12 ++++++++++++ src/client.ts | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/client.test.ts b/src/client.test.ts index 85c6d9e..2b08d34 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -6,6 +6,7 @@ import { mcpCallTool, mcpInitialize, mcpListTools, + type McpToolResult, } from "./client.js"; import { startTestMcpServer, type TestServerHandle } from "./test-server.js"; @@ -32,6 +33,17 @@ describe("mcpInitialize / mcpListTools / mcpCallTool over JSON", () => { expect(result.content).toBe("hi"); }); + test("a resolved tools/call result keeps the previous McpToolResult contract", async () => { + handle = startTestMcpServer(); + const result = await mcpCallTool(handle.url, "echo", { text: "hi" }); + expect(result.resultType).toBe("complete"); + if (result.resultType === "input_required" || result.resultType === "task") + throw new Error("expected a complete result"); + const resolved: McpToolResult = result; + expect(resolved.content).toBe("hi"); + expect(resolved.isError).toBeUndefined(); + }); + test("surfaces a JSON-RPC error as McpError", async () => { handle = startTestMcpServer({ requireAuth: "Bearer secret" }); await expect(mcpInitialize(handle.url)).rejects.toThrow(); diff --git a/src/client.ts b/src/client.ts index c81d19e..2f4ef06 100644 --- a/src/client.ts +++ b/src/client.ts @@ -82,7 +82,9 @@ export type McpToolList = McpTool[] & { }; export interface McpToolResult { - resultType: "complete"; + /** `"complete"` on a resolved result; absent while the call awaits input + * or runs a task. Optional so resolved results keep the previous contract. */ + resultType?: "complete"; content: unknown; isError?: boolean; structuredContent?: unknown; From 528833c671aa7d3ee5052e048a5bc82da20e47af Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 01:21:58 -0700 Subject: [PATCH 03/28] fix(client): unify legacy initialize client identity with modern client info --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 2f4ef06..4aa41f7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -951,7 +951,7 @@ async function initializeLegacy( { protocolVersion, capabilities: {}, - clientInfo: { name: "@corbits/mcp", version: "0.2.0" }, + clientInfo: { name: "@corbits/mcp", version: "0.3.1" }, }, { ...(opts.fetch !== undefined ? { fetch: opts.fetch } : {}), From 7e759d4f615f12282194a096a6dade9bbe32c9f5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 01:22:01 -0700 Subject: [PATCH 04/28] fix(client): enforce stateless metadata for session-less core calls --- src/client.ts | 3 ++- src/protocol-2026.test.ts | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 4aa41f7..2ff7bbd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1188,7 +1188,8 @@ async function requestResult( params: Record | undefined, opts: McpClientOptions, ): Promise { - const { result } = await sendRequest(url, method, params, opts); + const callOpts = opts.session === undefined ? asModern(opts) : opts; + const { result } = await sendRequest(url, method, params, callOpts); const kind = resultKind(result); if (kind === "invalid") { throw new McpError( diff --git a/src/protocol-2026.test.ts b/src/protocol-2026.test.ts index db6ec13..aaf5646 100644 --- a/src/protocol-2026.test.ts +++ b/src/protocol-2026.test.ts @@ -148,6 +148,49 @@ describe("MCP 2026-07-28", () => { } }); + test("a core call with no session speaks the stateless protocol directly", async () => { + const srv = jsonServer((seen) => { + if (seen.method === "server/discover") { + return rpc(seen.body.id, { + supportedVersions: [MCP_PROTOCOL_VERSION], + capabilities: { tools: {} }, + }); + } + if (seen.method === "tools/list") return rpc(seen.body.id, { tools: [] }); + if (seen.method === "tools/call") { + return rpc(seen.body.id, { resultType: "complete", content: "plain" }); + } + return rpcError(seen.body.id, McpErrorCode.MethodNotFound, "no"); + }); + try { + const tools = await mcpListTools(srv.url); + expect(tools).toEqual([]); + const result = await mcpCallTool(srv.url, "echo", { text: "hi" }); + expect(result.resultType).toBe("complete"); + if (result.resultType !== "complete") + throw new Error("expected a complete result"); + expect(result.content).toBe("plain"); + + for (const seen of srv.seen) { + expect(seen.headers.get("MCP-Protocol-Version")).toBe( + MCP_PROTOCOL_VERSION, + ); + expect(seen.headers.get("Mcp-Method")).toBe(seen.method); + expect(seen.headers.has("mcp-session-id")).toBe(false); + } + const call = srv.seen.find((entry) => entry.method === "tools/call"); + if (call === undefined) throw new Error("expected a tools/call"); + expect(call.headers.get("Mcp-Name")).toBe("echo"); + expect(meta(call)).toEqual({ + [META_PROTOCOL]: MCP_PROTOCOL_VERSION, + [META_CLIENT_INFO]: { name: "@corbits/mcp", version: "0.3.1" }, + [META_CLIENT_CAPABILITIES]: {}, + }); + } finally { + srv.stop(); + } + }); + test("method-not-found, a legacy version list, and a timeout fall back to initialize", async () => { const notFound = jsonServer((seen) => { if (seen.method === "server/discover") { From 63f0c7de9665372f8bd1f1688bc21a9f77033803 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 01:22:03 -0700 Subject: [PATCH 05/28] docs: cover stateless client calls, options, and upgrade path --- AGENTS.md | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05ea6e2..15aa740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ The client in `src/client.ts` is hand-rolled on purpose. A 2026 server gets one POST per call after `server/discover`: protocol version, client info, and client capabilities travel in `_meta`, and `Mcp-Method` / `Mcp-Name` / `MCP-Protocol-Version` travel as headers. There is no session id, no -`initialize`, and no `notifications/initialized` on that path. `input_required` +`initialize`, and no `notifications/initialized` on that path. A call made with +no session speaks this stateless path directly. `input_required` comes back as a typed result and `mcpContinue` retries the original method with `inputResponses`. `subscriptions/listen` is the long-lived stream. `notifications/progress` stays on the response stream of the call that asked diff --git a/README.md b/README.md index af15a4c..b6e6ded 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Corbits tool pack for the Interchange sidecar that turns each tool on a remote 2. **The agent never holds the server's token.** Calls go through a mediated credential: a fetch pinned to the server's origin that adds the bearer per request. 3. **Destructive tools stay behind approval.** Every generated tool starts `ask`-marked, and a tool the server flags `destructiveHint: true` cannot be lowered to `allow`. -`mcpConnect` probes `server/discover`. A 2026-07-28 server is stateless: later calls send `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`, and carry `io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities` in `_meta`. They do not send `initialize`, `notifications/initialized`, or `Mcp-Session-Id`. If `server/discover` is missing, times out, or the server only speaks a 2025 (or earlier) version, the client keeps the `initialize` plus `Mcp-Session-Id` path. +`mcpConnect` probes `server/discover`. A 2026-07-28 server is stateless: later calls send `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`, and carry `io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities` in `_meta`. They do not send `initialize`, `notifications/initialized`, or `Mcp-Session-Id`. A call made with no session speaks this stateless path directly; a session handed over from `mcpConnect` is stateless too. If `server/discover` is missing, times out, or the server only speaks a 2025 (or earlier) version, the client keeps the `initialize` plus `Mcp-Session-Id` path. Core methods, paginated where the spec uses a cursor: `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/templates/list`, `completion/complete`. A missing `resultType` is `complete`. `input_required` is returned to the caller (method, `inputRequests`, `requestState`) and retried with `mcpContinue`. `subscriptions/listen` is a long-lived POST; `notifications/progress` stays on the call that asked for it; aborting an in-flight call sends `notifications/cancelled`. From 47c1772b7ddc667eaab9c16c6ca22bdcec32e3e3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 09:47:25 -0700 Subject: [PATCH 06/28] fix(client): do not fall back on authz/server/rate-limit or malformed discover errors --- src/client.ts | 23 +++++++++++++++++++---- src/protocol-2026.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 2ff7bbd..ba4c7e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1006,17 +1006,32 @@ function shouldFallback(error: unknown): boolean { if (!(error instanceof McpError)) return false; if (error.cancelled) return false; if (error.timedOut) return true; + // Fall back only when the server may speak a legacy version: it answers the + // `server/discover` probe with method-not-found, at the transport edge with an + // HTTP 404 for a method it does not know, or reports that it cannot serve the + // stateless protocol version we asked for. Authz failures, rate limits, server + // errors, malformed responses, and other JSON-RPC errors are real failures that + // surface to the caller instead of triggering the legacy handshake; modern + // errors reject the stateless metadata outright. + if (error.code === McpErrorCode.MethodNotFound) return true; + if (error.status === 404) return true; + if (/unsupported protocol version/i.test(error.message)) return true; + if (/server not initialized/i.test(error.message)) return true; + if (error.status !== undefined) return false; if (error.code !== undefined && MODERN_ERROR_CODES.has(error.code)) return false; - return true; + // Malformed responses and any other error are real failures, not legacy. + return false; } /** * Probe `server/discover`. A 2026-07-28 server returns a stateless session: * later calls send no `initialize`, no `notifications/initialized`, and no - * `Mcp-Session-Id`. Method-not-found, a timeout, any non-modern error, or a - * server that only speaks a 2025 (or earlier) version falls back to - * `initialize`. + * `Mcp-Session-Id`. A method-not-found, a timeout, or a server that only + * speaks a 2025 (or earlier) version falls back to `initialize`. Authz + * failures, rate limits, server errors, and malformed responses are real + * failures and surface to the caller instead of triggering the legacy + * handshake. */ export async function mcpConnect( url: string, diff --git a/src/protocol-2026.test.ts b/src/protocol-2026.test.ts index aaf5646..75c5c01 100644 --- a/src/protocol-2026.test.ts +++ b/src/protocol-2026.test.ts @@ -265,6 +265,30 @@ describe("MCP 2026-07-28", () => { } }); + test("authz, rate-limit, server, and malformed discover errors do not fall back", async () => { + const failures: Array<{ status: number; body: string }> = [ + { status: 403, body: "forbidden" }, + { status: 429, body: "slow down" }, + { status: 503, body: "boom" }, + { status: 200, body: "not json" }, + ]; + for (const { status, body } of failures) { + const srv = jsonServer(() => new Response(body, { status })); + try { + const failure = await mcpConnect(srv.url).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(McpError); + expect(srv.seen.map((s) => s.method)).toEqual(["server/discover"]); + expect((failure as McpError).status).toBe( + status === 200 ? undefined : status, + ); + } finally { + srv.stop(); + } + } + }); + test("header mismatch and an unsupported modern version do not fall back", async () => { const mismatch = jsonServer((seen) => rpcError( From 4fd8ef9c915aae686549478977610d8f8c3d4db7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 24 Oct 2026 09:47:26 -0700 Subject: [PATCH 07/28] fix(oauth): reject dot-segment client metadata urls --- src/oauth-discovery.test.ts | 19 +++++++++++++++++++ src/oauth-discovery.ts | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/oauth-discovery.test.ts b/src/oauth-discovery.test.ts index 37ed9ea..a9b38ad 100644 --- a/src/oauth-discovery.test.ts +++ b/src/oauth-discovery.test.ts @@ -555,6 +555,25 @@ describe("MCP client registration choice", () => { ).toThrow(/path/); }); + test("refuses a client metadata URL with dot segments", () => { + expect(() => + mcpClientIdMetadataDocument({ + metadataUrl: "https://app.example.com/oauth/../client.json", + clientName: "Corbits", + redirectUris: ["http://127.0.0.1/callback"], + }), + ).toThrow(/dot segments/); + expect(() => + preferredMcpClientRegistration({ + authorizationServer: { + clientIdMetadataDocumentSupported: true, + registrationEndpoint: "https://auth.example.com/register", + }, + clientMetadataUrl: "https://app.example.com/oauth/../client.json", + }), + ).toThrow(/dot segments/); + }); + test("prefers pre-registered, then CIMD, then dynamic registration", () => { expect( preferredMcpClientRegistration({ diff --git a/src/oauth-discovery.ts b/src/oauth-discovery.ts index cc8b449..11240e5 100644 --- a/src/oauth-discovery.ts +++ b/src/oauth-discovery.ts @@ -495,6 +495,16 @@ function assertClientMetadataUrl(metadataUrl: string): void { throw new OAuthDiscoveryError( `client metadata URL must not contain a fragment or userinfo: ${metadataUrl}`, ); + // The URL parser normalizes away "." and ".." path segments, so inspect the + // raw string to refuse metadata URLs that use dot segments. + if ( + metadataUrl + .split("/") + .some((segment) => segment === "." || segment === "..") + ) + throw new OAuthDiscoveryError( + `client metadata URL must not contain dot segments: ${metadataUrl}`, + ); } /** From 3757b234f4dbae51ca55a5b5a4f05340c68b38be Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 07:38:11 -0700 Subject: [PATCH 08/28] fix: fall back on unrecognized HTTP 400 discovery errors --- src/client.ts | 16 +++++----- src/protocol-2026.test.ts | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index ba4c7e2..a74180b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1006,13 +1006,12 @@ function shouldFallback(error: unknown): boolean { if (!(error instanceof McpError)) return false; if (error.cancelled) return false; if (error.timedOut) return true; - // Fall back only when the server may speak a legacy version: it answers the - // `server/discover` probe with method-not-found, at the transport edge with an - // HTTP 404 for a method it does not know, or reports that it cannot serve the - // stateless protocol version we asked for. Authz failures, rate limits, server - // errors, malformed responses, and other JSON-RPC errors are real failures that - // surface to the caller instead of triggering the legacy handshake; modern - // errors reject the stateless metadata outright. + // Streamable HTTP backward compatibility: inspect HTTP 400 bodies before + // falling back. Recognized modern errors require correcting the request, + // while empty or unrecognized bodies may come from a legacy session server. + if (error.status === 400) + return error.code === undefined || !MODERN_ERROR_CODES.has(error.code); + // Preserve the existing fallback rules for other transport responses. if (error.code === McpErrorCode.MethodNotFound) return true; if (error.status === 404) return true; if (/unsupported protocol version/i.test(error.message)) return true; @@ -1028,7 +1027,8 @@ function shouldFallback(error: unknown): boolean { * Probe `server/discover`. A 2026-07-28 server returns a stateless session: * later calls send no `initialize`, no `notifications/initialized`, and no * `Mcp-Session-Id`. A method-not-found, a timeout, or a server that only - * speaks a 2025 (or earlier) version falls back to `initialize`. Authz + * speaks a 2025 (or earlier) version falls back to `initialize`. HTTP 400 + * also falls back unless its body contains a recognized modern error. Authz * failures, rate limits, server errors, and malformed responses are real * failures and surface to the caller instead of triggering the legacy * handshake. diff --git a/src/protocol-2026.test.ts b/src/protocol-2026.test.ts index 75c5c01..f0cd464 100644 --- a/src/protocol-2026.test.ts +++ b/src/protocol-2026.test.ts @@ -265,8 +265,71 @@ describe("MCP 2026-07-28", () => { } }); + test("unrecognized HTTP 400 discovery responses use a legacy session", async () => { + for (const body of [ + "", + "Bad Request", + JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { code: -32000, message: "Bad Request: Missing session ID" }, + }), + ]) { + const srv = jsonServer((seen) => { + if (seen.method === "server/discover") + return new Response(body, { status: 400 }); + if (seen.method === "initialize") { + const response = rpc(seen.body.id, { protocolVersion: "2025-03-26" }); + response.headers.set("Mcp-Session-Id", "legacy-session"); + return response; + } + expect(seen.headers.get("mcp-session-id")).toBe("legacy-session"); + if (seen.method === "notifications/initialized") + return new Response(null, { status: 202 }); + return rpc(seen.body.id, { + tools: [{ name: "echo", inputSchema: {} }], + }); + }); + try { + const session = await mcpConnect(srv.url); + expect( + (await mcpListTools(srv.url, { session })).map((tool) => tool.name), + ).toEqual(["echo"]); + expect(srv.seen.map((seen) => seen.method)).toEqual([ + "server/discover", + "initialize", + "notifications/initialized", + "tools/list", + ]); + } finally { + srv.stop(); + } + } + }); + + test("recognized modern HTTP 400 errors do not trigger legacy fallback", async () => { + for (const code of [ + McpErrorCode.HeaderMismatch, + McpErrorCode.MissingRequiredClientCapability, + McpErrorCode.UnsupportedProtocolVersion, + ]) { + const srv = jsonServer((seen) => + rpcError(seen.body.id, code, "modern error", undefined, 400), + ); + try { + await expect(mcpConnect(srv.url)).rejects.toBeInstanceOf(McpError); + expect(srv.seen.map((seen) => seen.method)).toEqual([ + "server/discover", + ]); + } finally { + srv.stop(); + } + } + }); + test("authz, rate-limit, server, and malformed discover errors do not fall back", async () => { const failures: Array<{ status: number; body: string }> = [ + { status: 401, body: "unauthorized" }, { status: 403, body: "forbidden" }, { status: 429, body: "slow down" }, { status: 503, body: "boom" }, From 1fdac37c2f98aebb0dde23b0ecb60a1121c82d8e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 07:38:40 -0700 Subject: [PATCH 09/28] fix: omit stale state from MRTR continuations --- src/client.ts | 1 + src/protocol-2026.test.ts | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/client.ts b/src/client.ts index a74180b..b4fcebc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1441,6 +1441,7 @@ export async function mcpContinue( const params: Record = { ...pending.params, inputResponses }; if (pending.requestState !== undefined) params.requestState = pending.requestState; + else delete params.requestState; if (pending.method === "tools/call") { return continueMethod(url, pending.method, params, opts, (value) => { const parsed = ToolCallResult(value); diff --git a/src/protocol-2026.test.ts b/src/protocol-2026.test.ts index f0cd464..c176ced 100644 --- a/src/protocol-2026.test.ts +++ b/src/protocol-2026.test.ts @@ -497,6 +497,57 @@ describe("MCP 2026-07-28", () => { } }); + test("continuations use only the latest result's requestState", async () => { + for (const nextState of [undefined, "", "round-two"]) { + let round = 0; + const srv = jsonServer((seen) => { + round += 1; + if (round === 3) return rpc(seen.body.id, { content: "done" }); + return rpc(seen.body.id, { + resultType: "input_required", + inputRequests: { answer: { method: "roots/list" } }, + ...(round === 1 + ? { requestState: "round-one" } + : nextState === undefined + ? {} + : { requestState: nextState }), + }); + }); + try { + const first = await mcpCallTool(srv.url, "echo", { text: "hi" }); + if (first.resultType !== "input_required") + throw new Error("expected first input"); + const second = await mcpContinue(srv.url, first, { + answer: { roots: [] }, + }); + if (second.resultType !== "input_required") + throw new Error("expected second input"); + const responses = { answer: { roots: [{ uri: "file:///latest" }] } }; + const final = await mcpContinue(srv.url, second, responses); + expect(final).toMatchObject({ + resultType: "complete", + content: "done", + }); + expect(srv.seen[1]?.body.params?.requestState).toBe("round-one"); + const last = srv.seen[2]; + expect(last?.body.params?.requestState).toBe(nextState); + expect(Object.hasOwn(last?.body.params ?? {}, "requestState")).toBe( + nextState !== undefined, + ); + expect(last?.body.params?.inputResponses).toEqual(responses); + for (const seen of srv.seen) { + expect(seen.method).toBe("tools/call"); + expect(seen.body.params?.name).toBe("echo"); + expect(seen.body.params?.arguments).toEqual({ text: "hi" }); + } + expect(new Set(srv.seen.map((seen) => seen.body.id)).size).toBe(3); + expect(second.requestState).toBe(nextState); + } finally { + srv.stop(); + } + } + }); + test("follows cursors, including an empty one, and keeps cache fields", async () => { let page = 0; const srv = jsonServer((seen) => { From 8217114427dc90e17107c8e0469c5a9995b8bc38 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 07:39:17 -0700 Subject: [PATCH 10/28] docs: explain union result migration and protocol test scope --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index b6e6ded..890767d 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,50 @@ A host records how a server authenticates in the credential's `metadata.mcp.auth Grant the agent's principal `tool:deepwiki.*` for the whole server, or `tool:deepwiki.` per tool, with `allow`, `ask` or `deny`. +## Migrating to the 2026 client API + +The 2026 client changes the public return contract: `mcpCallTool` returns +`McpToolCallResult`, not only a resolved `McpToolResult`. Callers must narrow +`resultType` before reading `content`, `isError`, or `structuredContent`: + +```ts +const result = await mcpCallTool(url, "echo", { text: "hi" }, { session }); +if (result.resultType === "input_required") { + // Fulfill result.inputRequests, then pass this result and the collected + // inputResponses to mcpContinue(url, result, inputResponses, { session }). +} else if (result.resultType === "task") { + // Handle the task through the opted-in tasks extension. +} else { + console.log(result.content); +} +``` + +`mcpContinue` can return another `input_required` result; always use the latest +pending result for the next retry. Prompt and resource reads also expose their +pending variants. Do not cast a union result to a resolved-only type. This is a +breaking API change: the release containing it must be the next minor release, +with a `!` marker in the breaking-change PR title as required by CONTRIBUTING. + +### Protocol regression coverage and limits + +The focused regressions in `src/protocol-2026.test.ts` check these requirements: + +- [Streamable HTTP backward compatibility](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility): + an HTTP 400 discovery response with an empty, non-JSON, or legacy missing-session + error body falls back to `initialize`; later requests carry the legacy session. + Recognized modern errors do not take that fallback. Ordinary HTTP 401/403, + rate-limit, server, and malformed successful responses remain failures. +- [MRTR client requirements](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow): + retries echo only the latest `requestState`, including an empty string, and + omit it when the latest response omits it. Tests also check fresh request IDs, + original tool arguments, and the latest input responses across multiple rounds. + +These tests and the SDK transport e2e suite are targeted compatibility evidence, +not comprehensive MCP certification. The transport and extension limits listed +above still apply, including no deprecated HTTP+SSE transport or SSE resumption. +This scoped change does not audit or change authorization-error precedence or +OAuth URL handling. + ## Upgrading from 0.1 - Existing server configs, stored catalogs and the `mcp-server` credential keep working unchanged. From f6d2b99afaef4a33e6633aed4acb43e5459e0a9b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:37 -0700 Subject: [PATCH 11/28] feat(client)!: validate shared protocol and continuation contracts Centralize wire schemas, headers, result applicability and resource bounds. Harden stateless transport and legacy negotiation with regression coverage. Expose repeated continuation and polymorphic resource results explicitly. --- src/client.test.ts | 6 +- src/client.ts | 941 ++++++++++++-------------- src/client/contracts.test.ts | 140 ++++ src/client/gaps.test.ts | 262 +++++++ src/client/resource-budget.test.ts | 282 ++++++++ src/client/resource-budget.ts | 4 + src/client/review-regressions.test.ts | 536 +++++++++++++++ src/client/transport.ts | 21 + src/protocol-2026.test.ts | 251 +++++-- src/protocol/content.ts | 125 ++++ src/protocol/envelopes.ts | 48 ++ src/protocol/errors.ts | 43 ++ src/protocol/extensions.ts | 98 +++ src/protocol/headers-codec.test.ts | 106 +++ src/protocol/headers.ts | 177 +++++ src/protocol/index.ts | 11 + src/protocol/json-schema.ts | 180 +++++ src/protocol/legacy.test.ts | 19 + src/protocol/legacy.ts | 25 + src/protocol/methods.ts | 92 +++ src/protocol/protocol.test.ts | 346 ++++++++++ src/protocol/resource-bounds.test.ts | 96 +++ src/protocol/resource-budget.ts | 212 ++++++ src/protocol/schemas.ts | 671 ++++++++++++++++++ src/protocol/types.ts | 258 +++++++ src/protocol/ui.ts | 37 + src/test-server.ts | 15 +- 27 files changed, 4406 insertions(+), 596 deletions(-) create mode 100644 src/client/contracts.test.ts create mode 100644 src/client/gaps.test.ts create mode 100644 src/client/resource-budget.test.ts create mode 100644 src/client/resource-budget.ts create mode 100644 src/client/review-regressions.test.ts create mode 100644 src/client/transport.ts create mode 100644 src/protocol/content.ts create mode 100644 src/protocol/envelopes.ts create mode 100644 src/protocol/errors.ts create mode 100644 src/protocol/extensions.ts create mode 100644 src/protocol/headers-codec.test.ts create mode 100644 src/protocol/headers.ts create mode 100644 src/protocol/index.ts create mode 100644 src/protocol/json-schema.ts create mode 100644 src/protocol/legacy.test.ts create mode 100644 src/protocol/legacy.ts create mode 100644 src/protocol/methods.ts create mode 100644 src/protocol/protocol.test.ts create mode 100644 src/protocol/resource-bounds.test.ts create mode 100644 src/protocol/resource-budget.ts create mode 100644 src/protocol/schemas.ts create mode 100644 src/protocol/types.ts create mode 100644 src/protocol/ui.ts diff --git a/src/client.test.ts b/src/client.test.ts index 2b08d34..99ffc3c 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -30,7 +30,7 @@ describe("mcpInitialize / mcpListTools / mcpCallTool over JSON", () => { expect(result.resultType).toBe("complete"); if (result.resultType !== "complete") throw new Error("expected a complete result"); - expect(result.content).toBe("hi"); + expect(result.content).toEqual([{ type: "text", text: "hi" }]); }); test("a resolved tools/call result keeps the previous McpToolResult contract", async () => { @@ -40,7 +40,7 @@ describe("mcpInitialize / mcpListTools / mcpCallTool over JSON", () => { if (result.resultType === "input_required" || result.resultType === "task") throw new Error("expected a complete result"); const resolved: McpToolResult = result; - expect(resolved.content).toBe("hi"); + expect(resolved.content).toEqual([{ type: "text", text: "hi" }]); expect(resolved.isError).toBeUndefined(); }); @@ -376,7 +376,7 @@ describe("tools/list", () => { const parsed = RequestIdOnly(await req.json()); if (parsed instanceof type.errors) return new Response("", { status: 400 }); - const tool = { name: "dup", inputSchema: {} }; + const tool = { name: "dup", inputSchema: { type: "object" } }; return Response.json({ jsonrpc: "2.0", id: parsed.id, diff --git a/src/client.ts b/src/client.ts index b4fcebc..c993b63 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,15 +1,69 @@ // Streamable-HTTP JSON-RPC client for MCP 2026-07-28, with a legacy // fallback. A 2026 server is stateless: `server/discover` then one POST per // call, carrying protocol metadata in `_meta` and in `Mcp-*` headers, and no -// session id. `notifications/cancelled` is still posted when the caller aborts -// an in-flight call — 2025 servers require that notification, and closing the -// response stream (which abort already does) is what a 2026 server treats as -// cancellation. No SSE resumption and no HTTP+SSE transport. +// session id. Modern cancellation closes the response stream; only legacy +// requests send `notifications/cancelled`. No SSE resumption or HTTP+SSE. -import type { FetchLike } from "@intx/harness"; import { type } from "arktype"; - -export const MCP_PROTOCOL_VERSION = "2026-07-28"; +import { + createResourceBudget, + type ResourceBudget, +} from "./client/resource-budget.js"; +import { + MCP_PROTOCOL_VERSION, + type FetchLike, + type McpValidationContext, + type TaskStatus, + type McpSubscriptionFilter, +} from "./protocol/types.js"; +import { McpError, McpErrorCode } from "./protocol/errors.js"; +import { + MCP_EXT_TASKS, + MCP_EXT_UI, + MCP_EXT_SKILLS, + validateExtensionSettings, +} from "./protocol/extensions.js"; +import { + McpToolSchema, + type McpTool, + PromptSchema, + PromptResult, + ResourceSchema, + TemplateSchema, + CompletionResult, + SkillSchema, + TaskHandleSchema, + ToolCallResult, + DiscoverResult, + JsonRpcResponse, + validateRequest, + validateResult, + validateNotification, + validateTask, + record, + validateProgressToken, +} from "./protocol/schemas.js"; +import { + encodeHeaderValue, + paramHeaders, + collectHeaderSlots, +} from "./protocol/headers.js"; +import { mcpNameFor } from "./protocol/methods.js"; +import type { McpRequestTransport } from "./client/transport.js"; +export { + MCP_PROTOCOL_VERSION, + McpError, + McpErrorCode, + MCP_EXT_TASKS, + MCP_EXT_UI, + MCP_EXT_SKILLS, + McpToolSchema, +}; +export type { McpTool, McpSubscriptionFilter }; +export type { + McpRequestTransport, + McpRequestOptions, +} from "./client/transport.js"; const LEGACY_PROTOCOL_VERSION = "2025-03-26"; /** Legacy handshake versions, newest first. */ const LEGACY_VERSIONS = [ @@ -19,10 +73,6 @@ const LEGACY_VERSIONS = [ "2024-11-05", ] as const; -export const MCP_EXT_TASKS = "io.modelcontextprotocol/tasks"; -export const MCP_EXT_UI = "io.modelcontextprotocol/ui"; -export const MCP_EXT_SKILLS = "io.modelcontextprotocol/skills"; - const META_PROTOCOL = "io.modelcontextprotocol/protocolVersion"; const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"; const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; @@ -32,48 +82,12 @@ const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; const CLIENT_INFO = { name: "@corbits/mcp", version: "0.3.1" } as const; const UI_MIME_TYPES = ["text/html;profile=mcp-app"] as const; -/** Methods that may answer `input_required` or, with the tasks extension, `task`. */ -const ROUND_TRIP_METHODS = new Set([ - "tools/call", - "prompts/get", - "resources/read", -]); - -export const McpErrorCode = { - ParseError: -32700, - InvalidRequest: -32600, - MethodNotFound: -32601, - InvalidParams: -32602, - InternalError: -32603, - HeaderMismatch: -32020, - MissingRequiredClientCapability: -32021, - UnsupportedProtocolVersion: -32022, -} as const; - const MODERN_ERROR_CODES = new Set([ McpErrorCode.HeaderMismatch, McpErrorCode.MissingRequiredClientCapability, McpErrorCode.UnsupportedProtocolVersion, ]); -export const McpToolSchema = type({ - name: "string", - "title?": "string", - "description?": "string", - inputSchema: "Record", - "outputSchema?": "Record", - "annotations?": { - "title?": "string", - "readOnlyHint?": "boolean", - "destructiveHint?": "boolean", - "idempotentHint?": "boolean", - "openWorldHint?": "boolean", - }, - "icons?": "unknown[]", - "_meta?": "Record", -}); -export type McpTool = typeof McpToolSchema.infer; - export type McpCacheScope = "public" | "private"; export type McpToolList = McpTool[] & { @@ -101,14 +115,10 @@ export interface McpInputRequired { inputRequests?: Record; /** Opaque server state. Echoed verbatim on retry; never inspected. */ requestState?: string; + _meta?: Record; } -export type McpTaskStatus = - | "working" - | "input_required" - | "completed" - | "failed" - | "cancelled"; +export type McpTaskStatus = TaskStatus; export interface McpTaskHandle { resultType: "task"; @@ -154,7 +164,8 @@ export type McpClientCapabilities = Record; export interface McpClientOptions { /** Injectable `fetch` for tests and mediated-credential handles. */ fetch?: FetchLike; - /** Abort the request, including reading its body, after this long. */ + /** Abort the request and body after this long (default 60s). + * subscriptions/listen has no default deadline; set this to bound its lifetime. */ timeoutMs?: number; /** Abort the request when this fires, e.g. a cancelled tool call. */ signal?: AbortSignal; @@ -173,6 +184,17 @@ export interface McpClientOptions { progressToken?: string | number; /** Tool `inputSchema`, so `x-mcp-header` parameters are mirrored into headers. */ inputSchema?: Record; + /** Validate successful structured tool output when its catalog schema is known. */ + outputSchema?: Record; + /** resources/read only: bound aggregate decoded text/blob bytes while streaming. + * Capped at 16 MiB decoded, with bounded JSON/base64 encoding overhead and + * a separate 64 KiB envelope cap. Unbounded reads retain the 4 MiB default. */ + maxDecodedBytes?: number; + /** Optional host capabilities; never inferred from installed code. */ + clientCapabilities?: McpClientCapabilities; + /** Advertise only modes for which the caller has configured user-facing host services. */ + elicitation?: { form?: boolean; url?: boolean }; + validation?: McpValidationContext; } /** The run-time `tools/call` and `initialize` bound when a caller sets none. */ @@ -184,43 +206,52 @@ const MAX_MESSAGE_BYTES = 4 * 1024 * 1024; /** A cursor loop that never ends is a misbehaving server, not a catalog. */ const MAX_PAGES = 1_000; -const TCHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - -const JsonRpcResponse = type({ - jsonrpc: "'2.0'", - id: "string | number | null", - "result?": "unknown", - "error?": { code: "number", message: "string", "data?": "unknown" }, -}); +let nextId = 1; +const activeProgressTokens = new Set(); +function reserveProgressToken(token: string | number | undefined): void { + if (token === undefined) return; + if (activeProgressTokens.has(token)) + throw new McpError("progress token is already active"); + activeProgressTokens.add(token); +} -export class McpError extends Error { - /** The HTTP status, when the server answered with a non-2xx one. */ - readonly status: number | undefined; - /** JSON-RPC error code, when the server sent one. */ - readonly code: number | undefined; - /** JSON-RPC `error.data`, when the server sent it. */ - readonly data: unknown; - readonly timedOut: boolean; - readonly cancelled: boolean; - - constructor( - message: string, - status?: number, - code?: number, - data?: unknown, - flags?: { timedOut?: boolean; cancelled?: boolean }, - ) { - super(message); - this.name = "McpError"; - this.status = status; - this.code = code; - this.data = data; - this.timedOut = flags?.timedOut === true; - this.cancelled = flags?.cancelled === true; - } +function validationContext(opts: McpClientOptions): McpValidationContext { + return { + ...opts.validation, + era: + opts.session !== undefined && !isModernSession(opts.session) + ? "legacy" + : "modern", + extensions: enabledExtensions(opts), + ...(opts.session?.capabilities !== undefined + ? { serverCapabilities: opts.session.capabilities } + : {}), + ...(opts.inputSchema !== undefined + ? { inputSchema: opts.inputSchema } + : {}), + ...(opts.outputSchema !== undefined + ? { outputSchema: opts.outputSchema } + : {}), + clientCapabilities: clientCapabilities(opts), + }; } -let nextId = 1; +export function createMcpRequestTransport( + url: string, + defaults: McpClientOptions = {}, +): McpRequestTransport { + return { + async request(method, params, options = {}) { + const opts = { ...defaults, ...options }; + const callOpts = opts.session === undefined ? asModern(opts) : opts; + const { result } = await sendRequest(url, method, params, callOpts); + return result as Record; + }, + listen(filter, observer, options = {}) { + return mcpListen(url, filter, observer, { ...defaults, ...options }); + }, + }; +} function isModernSession(session: McpSession | undefined): boolean { return session?.protocolVersion === MCP_PROTOCOL_VERSION; @@ -238,7 +269,21 @@ function requireExtension(opts: McpClientOptions, id: string): void { } function clientCapabilities(opts: McpClientOptions): McpClientCapabilities { + if (opts.elicitation !== undefined) { + const { elicitation, ...base } = opts; + const capabilities = clientCapabilities(base); + const modes = { + ...(elicitation.form === true ? { form: {} } : {}), + ...(elicitation.url === true ? { url: {} } : {}), + }; + const { elicitation: _inherited, ...rest } = capabilities; + return { + ...rest, + ...(Object.keys(modes).length > 0 ? { elicitation: modes } : {}), + }; + } if ( + opts.clientCapabilities === undefined && opts.extensions === undefined && opts.uiMimeTypes === undefined && opts.session?.clientCapabilities !== undefined @@ -246,7 +291,10 @@ function clientCapabilities(opts: McpClientOptions): McpClientCapabilities { return opts.session.clientCapabilities; } const extensions = enabledExtensions(opts); - if (extensions.length === 0) return {}; + validateExtensionSettings( + Object.fromEntries(extensions.map((id) => [id, {}])), + ); + if (extensions.length === 0) return opts.clientCapabilities ?? {}; const advertised: Record = {}; for (const id of extensions) { if (id === MCP_EXT_UI) { @@ -258,7 +306,7 @@ function clientCapabilities(opts: McpClientOptions): McpClientCapabilities { advertised[id] = {}; } } - return { extensions: advertised }; + return { ...opts.clientCapabilities, extensions: advertised }; } /** A reply to request `id`: a message carrying `result` or `error` and no @@ -279,218 +327,22 @@ function isNotification( return ( typeof message === "object" && message !== null && + (message as { jsonrpc?: unknown }).jsonrpc === "2.0" && typeof (message as { method?: unknown }).method === "string" && !("id" in message) ); } -function isPlainHeaderValue(value: string): boolean { - if (value.startsWith("=?base64?") && value.endsWith("?=")) return false; - if (value.length === 0) return true; - if ( - value.startsWith(" ") || - value.startsWith("\t") || - value.endsWith(" ") || - value.endsWith("\t") - ) { - return false; - } - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - const ok = code === 0x09 || code === 0x20 || (code >= 0x21 && code <= 0x7e); - if (!ok) return false; - } - return true; -} - -function encodeHeaderValue(value: string): string { - if (isPlainHeaderValue(value)) return value; - return `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`; -} - -type HeaderSlot = { - path: string[]; - header: string; - type: "string" | "integer" | "boolean"; -}; - -function mentionsHeader(value: unknown): boolean { - if (Array.isArray(value)) return value.some(mentionsHeader); - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - if ("x-mcp-header" in record) return true; - return Object.values(record).some(mentionsHeader); -} - -/** `undefined` means the tool definition is invalid and must be excluded. */ -function collectHeaders( - schema: Record, - toolName: string, -): HeaderSlot[] | undefined { - const slots: HeaderSlot[] = []; - const seen = new Set(); - const reject = (reason: string): undefined => { - console.warn(`excluding MCP tool ${toolName}: ${reason}`); - return undefined; - }; - if ("x-mcp-header" in schema) { - return reject("x-mcp-header is not on a property"); - } - - const visit = (node: unknown, path: string[]): boolean => { - if (typeof node !== "object" || node === null || Array.isArray(node)) { - return true; - } - const record = node as Record; - if ("x-mcp-header" in record) { - const header = record["x-mcp-header"]; - const schemaType = record["type"]; - if (typeof header !== "string" || !TCHAR.test(header)) { - reject(`invalid x-mcp-header on ${path.join(".")}`); - return false; - } - const id = header.toLowerCase(); - if (seen.has(id)) { - reject(`duplicate x-mcp-header "${header}"`); - return false; - } - if ( - schemaType !== "string" && - schemaType !== "integer" && - schemaType !== "boolean" - ) { - reject(`x-mcp-header on non-primitive ${path.join(".")}`); - return false; - } - seen.add(id); - slots.push({ path, header, type: schemaType }); - } - const nested = record["properties"]; - if ( - typeof nested === "object" && - nested !== null && - !Array.isArray(nested) - ) { - for (const [key, value] of Object.entries(nested)) { - if (!visit(value, [...path, key])) return false; - } - } - for (const [key, value] of Object.entries(record)) { - if (key === "properties" || key === "x-mcp-header") continue; - if (mentionsHeader(value)) { - reject(`x-mcp-header on ${path.join(".")} is not statically reachable`); - return false; - } - } - return true; - }; - - const properties = schema["properties"]; - if ( - typeof properties === "object" && - properties !== null && - !Array.isArray(properties) - ) { - for (const [key, value] of Object.entries(properties)) { - if (!visit(value, [key])) return undefined; - } - } - for (const [key, value] of Object.entries(schema)) { - if (key === "properties") continue; - if (mentionsHeader(value)) { - return reject( - "x-mcp-header is not statically reachable from the schema root", - ); - } - } - return slots; -} - -function valueAt(args: Record, path: string[]): unknown { - let current: unknown = args; - for (const key of path) { - if ( - typeof current !== "object" || - current === null || - Array.isArray(current) - ) { - return undefined; - } - current = (current as Record)[key]; - } - return current; -} - -function paramHeaders( - schema: Record | undefined, - args: unknown, - toolName: string, -): Record { - if (schema === undefined) return {}; - const slots = collectHeaders(schema, toolName); - if (slots === undefined) { - throw new McpError( - `MCP tool ${toolName} has an invalid x-mcp-header annotation`, - ); - } - if (typeof args !== "object" || args === null || Array.isArray(args)) - return {}; - const headers: Record = {}; - for (const slot of slots) { - const value = valueAt(args as Record, slot.path); - if (value === undefined || value === null) continue; - headers[`Mcp-Param-${slot.header}`] = encodeParam(slot, value, toolName); - } - return headers; -} - -function encodeParam( - slot: HeaderSlot, - value: unknown, - toolName: string, -): string { - if (slot.type === "string") { - if (typeof value !== "string") { - throw new McpError( - `MCP tool ${toolName} argument ${slot.path.join(".")} is not a string`, - ); - } - return encodeHeaderValue(value); - } - if (slot.type === "boolean") { - if (typeof value !== "boolean") { - throw new McpError( - `MCP tool ${toolName} argument ${slot.path.join(".")} is not a boolean`, - ); - } - return value ? "true" : "false"; - } - if ( - typeof value !== "number" || - !Number.isInteger(value) || - !Number.isSafeInteger(value) - ) { - throw new McpError( - `MCP tool ${toolName} argument ${slot.path.join(".")} is not a safe integer`, +function collectHeaders(schema: Record, toolName: string) { + try { + return collectHeaderSlots(schema); + } catch (cause) { + console.warn( + `excluding MCP tool ${toolName}: ${cause instanceof Error ? cause.message : String(cause)}`, ); + return undefined; } - return String(value); -} - -function mcpNameFor( - method: string, - params: Record | undefined, -): string | undefined { - if (params === undefined) return undefined; - if (method === "tools/call" || method === "prompts/get") { - return typeof params.name === "string" ? params.name : undefined; - } - if (method === "resources/read") { - return typeof params.uri === "string" ? params.uri : undefined; - } - return undefined; } - function requestHeaders( opts: McpClientOptions, method: string, @@ -517,21 +369,45 @@ function requestHeaders( return headers; } +function progressParams( + params: Record | undefined, + opts: McpClientOptions, + id: number, +): Record { + const metadata = + params?._meta === undefined ? {} : record(params._meta, "request _meta"); + const token = + opts.progressToken !== undefined + ? opts.progressToken + : (metadata.progressToken ?? + (opts.onProgress !== undefined ? id : undefined)); + if (Object.hasOwn(metadata, "progressToken")) + validateProgressToken(metadata.progressToken); + if (token !== undefined) validateProgressToken(token); + return { + ...params, + _meta: { + ...metadata, + ...(token !== undefined ? { progressToken: token } : {}), + }, + }; +} + function modernParams( params: Record | undefined, opts: McpClientOptions, id: number, ): Record { - const progressToken = - opts.progressToken ?? (opts.onProgress !== undefined ? id : undefined); - const meta: Record = { - [META_PROTOCOL]: MCP_PROTOCOL_VERSION, - [META_CLIENT_INFO]: CLIENT_INFO, - [META_CLIENT_CAPABILITIES]: clientCapabilities(opts), + const base = progressParams(params, opts, id); + return { + ...base, + _meta: { + ...record(base._meta), + [META_PROTOCOL]: MCP_PROTOCOL_VERSION, + [META_CLIENT_INFO]: CLIENT_INFO, + [META_CLIENT_CAPABILITIES]: clientCapabilities(opts), + }, }; - if (progressToken !== undefined) meta.progressToken = progressToken; - const base = params ?? {}; - return { ...base, _meta: meta }; } function stripMeta( @@ -563,25 +439,26 @@ function deliverProgress( message: unknown, token: string | number | undefined, onProgress: ((progress: McpProgress) => void) | undefined, -): void { - if ( - token === undefined || - onProgress === undefined || - !isNotification(message) - ) { - return; + previous: number | undefined, +): number | undefined { + if (token === undefined || !isNotification(message)) { + return previous; } - if (message.method !== "notifications/progress") return; + if (message.method !== "notifications/progress") return previous; const params = message.params; - if (params === undefined || params.progressToken !== token) return; - if (typeof params.progress !== "number") return; + if (params === undefined || params.progressToken !== token) return previous; + validateNotification("notifications/progress", params); + if (typeof params.progress !== "number") return previous; const progress: McpProgress = { progressToken: token, progress: params.progress, }; if (typeof params.total === "number") progress.total = params.total; if (typeof params.message === "string") progress.message = params.message; - onProgress(progress); + if (previous !== undefined && progress.progress <= previous) + throw new McpError("progress must strictly increase"); + onProgress?.(progress); + return progress.progress; } /** @@ -598,18 +475,29 @@ async function sendRequest( params: Record | undefined, opts: McpClientOptions, ): Promise<{ result: unknown; sessionId: string | undefined }> { + if ( + method === "resources/read" && + opts.maxDecodedBytes !== undefined && + (!Number.isSafeInteger(opts.maxDecodedBytes) || opts.maxDecodedBytes < 0) + ) + throw new McpError("maxDecodedBytes must be a nonnegative safe integer"); const fetchImpl = opts.fetch ?? fetch; const id = nextId++; - const timeout = - opts.timeoutMs === undefined - ? undefined - : AbortSignal.timeout(opts.timeoutMs); + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timeout = AbortSignal.timeout(timeoutMs); const signals = [timeout, opts.signal].filter((s) => s !== undefined); const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined; const modern = isModernSession(opts.session); - const bodyParams = modern ? modernParams(params, opts, id) : params; - const progressToken = - opts.progressToken ?? (opts.onProgress !== undefined ? id : undefined); + const bodyParams = modern + ? modernParams(params, opts, id) + : progressParams(params, opts, id); + if (method !== "initialize") + validateRequest(method, bodyParams, validationContext(opts)); + const progressToken = record(bodyParams._meta).progressToken as + | string + | number + | undefined; + let previousProgress: number | undefined; const extra = method === "tools/call" ? paramHeaders( @@ -631,7 +519,8 @@ async function sendRequest( }, ).catch(() => undefined); }; - if (opts.signal !== undefined) { + reserveProgressToken(progressToken); + if (!modern && method !== "initialize" && opts.signal !== undefined) { opts.signal.addEventListener("abort", notifyCancel, { once: true }); } try { @@ -653,11 +542,29 @@ async function sendRequest( sessionId = response.headers.get("mcp-session-id") ?? undefined; const contentType = response.headers.get("content-type") ?? ""; + const budget = + method === "resources/read" && opts.maxDecodedBytes !== undefined + ? createResourceBudget( + opts.maxDecodedBytes, + contentType.includes("text/event-stream"), + ) + : undefined; message = contentType.includes("text/event-stream") - ? await readSseJsonRpc(response, id, signal, (frame) => { - deliverProgress(frame, progressToken, opts.onProgress); - }) - : parseJson(await readCapped(response, signal)); + ? await readSseJsonRpc( + response, + id, + signal, + (frame) => { + previousProgress = deliverProgress( + frame, + progressToken, + opts.onProgress, + previousProgress, + ); + }, + budget, + ) + : parseJson(await readCapped(response, signal, budget)); } catch (cause) { if (opts.signal?.aborted === true) { throw new McpError( @@ -672,7 +579,7 @@ async function sendRequest( } if (timeout?.aborted === true) { throw new McpError( - `MCP server ${url} did not answer ${method} within ${String(opts.timeoutMs)}ms`, + `MCP server ${url} did not answer ${method} within ${String(timeoutMs)}ms`, undefined, undefined, undefined, @@ -682,22 +589,35 @@ async function sendRequest( throw cause; } finally { opts.signal?.removeEventListener("abort", notifyCancel); + if (progressToken !== undefined) activeProgressTokens.delete(progressToken); } const parsed = JsonRpcResponse(message); - if (!(parsed instanceof type.errors) && parsed.error !== undefined) { + if ( + parsed instanceof type.errors || + !isResponseTo(message, id) || + (parsed.error !== undefined && Object.hasOwn(parsed, "result")) + ) { + throw new McpError( + `MCP server ${url} sent a malformed response to ${method}`, + ); + } + if (parsed.error !== undefined) { throw new McpError( `MCP server ${url} rejected ${method}: ${parsed.error.message}`, undefined, parsed.error.code, parsed.error.data, + { source: "peer" }, ); } - if (parsed instanceof type.errors || !isResponseTo(message, id)) { - throw new McpError( - `MCP server ${url} sent a malformed response to ${method}${parsed instanceof type.errors ? `: ${parsed.summary}` : ""}`, - ); - } + if (method !== "initialize") + validateResult(method, parsed.result, { + ...validationContext(opts), + // Never accept a caller-supplied validation context as proof of a wire budget. + maxDecodedBytes: + method === "resources/read" ? opts.maxDecodedBytes : undefined, + }); return { result: parsed.result, sessionId }; } @@ -709,10 +629,8 @@ async function sendNotification( params?: Record, ): Promise { const fetchImpl = opts.fetch ?? fetch; - const timeout = - opts.timeoutMs === undefined - ? undefined - : AbortSignal.timeout(opts.timeoutMs); + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timeout = AbortSignal.timeout(timeoutMs); const modern = isModernSession(opts.session); const bodyParams = params === undefined @@ -759,6 +677,17 @@ async function errorFrom( method: string, signal: AbortSignal | undefined, ): Promise { + if (response.status === 401 || response.status === 403) { + void response.body?.cancel().catch(() => undefined); + const challenge = response.headers.get("www-authenticate"); + return new McpError( + `MCP server ${url} responded ${response.status} to ${method}`, + response.status, + undefined, + undefined, + challenge === null ? undefined : { wwwAuthenticate: challenge }, + ); + } let code: number | undefined; let data: unknown; let detail: string | undefined; @@ -798,7 +727,9 @@ async function errorFrom( detail === undefined ? `MCP server ${url} responded ${response.status} to ${method}` : `MCP server ${url} rejected ${method}: ${detail}`; - return new McpError(message, response.status, code, data); + return new McpError(message, response.status, code, data, { + source: code === undefined ? "local" : "peer", + }); } function parseJson(text: string): unknown { @@ -819,7 +750,9 @@ function overflow(): McpError { async function* chunks( response: Response, signal: AbortSignal | undefined, + budget?: ResourceBudget, ): AsyncGenerator { + signal?.throwIfAborted(); const body = response.body; if (body === null) { throw new McpError("MCP server sent a response with no body"); @@ -830,7 +763,11 @@ async function* chunks( try { for (;;) { const { done, value } = await reader.read(); - if (done) return; + if (done) { + budget?.finish(); + return; + } + budget?.push(value); yield value; } } finally { @@ -842,13 +779,14 @@ async function* chunks( async function readCapped( response: Response, signal: AbortSignal | undefined, + budget?: ResourceBudget, ): Promise { const decoder = new TextDecoder(); let text = ""; let bytes = 0; - for await (const chunk of chunks(response, signal)) { + for await (const chunk of chunks(response, signal, budget)) { bytes += chunk.byteLength; - if (bytes > MAX_MESSAGE_BYTES) throw overflow(); + if (budget === undefined && bytes > MAX_MESSAGE_BYTES) throw overflow(); text += decoder.decode(chunk, { stream: true }); } return text + decoder.decode(); @@ -857,16 +795,19 @@ async function readCapped( async function* sseMessages( response: Response, signal: AbortSignal | undefined, + budget?: ResourceBudget, ): AsyncGenerator { const decoder = new TextDecoder(); let buffer = ""; - for await (const chunk of chunks(response, signal)) { + for await (const chunk of chunks(response, signal, budget)) { buffer += decoder.decode(chunk, { stream: true }); const frames = buffer.split(/\r\n\r\n|\n\n|\r\r/); buffer = frames.pop() ?? ""; - if (buffer.length > MAX_MESSAGE_BYTES) throw overflow(); + if (budget === undefined && buffer.length > MAX_MESSAGE_BYTES) + throw overflow(); for (const frame of frames) { - if (frame.length > MAX_MESSAGE_BYTES) throw overflow(); + if (budget === undefined && frame.length > MAX_MESSAGE_BYTES) + throw overflow(); const dataLines = frame .split(/\r\n|\r|\n/) .filter((line) => line.startsWith("data:")) @@ -887,8 +828,9 @@ async function readSseJsonRpc( id: number, signal: AbortSignal | undefined, onMessage: (message: unknown) => void, + budget?: ResourceBudget, ): Promise { - for await (const candidate of sseMessages(response, signal)) { + for await (const candidate of sseMessages(response, signal, budget)) { if (isResponseTo(candidate, id)) return candidate; onMessage(candidate); } @@ -982,13 +924,6 @@ async function initializeLegacy( return session; } -const DiscoverResult = type({ - supportedVersions: "string[]", - capabilities: "Record", - "instructions?": "string", - "_meta?": "Record", -}); - function supportedFrom(data: unknown): string[] { if (typeof data !== "object" || data === null) return []; const supported = (data as { supported?: unknown }).supported; @@ -1004,6 +939,12 @@ function legacyVersion(supported: readonly string[]): string | undefined { function shouldFallback(error: unknown): boolean { if (!(error instanceof McpError)) return false; + if ( + error.status !== undefined && + error.status !== 400 && + error.status !== 404 + ) + return false; if (error.cancelled) return false; if (error.timedOut) return true; // Streamable HTTP backward compatibility: inspect HTTP 400 bodies before @@ -1090,7 +1031,10 @@ export async function mcpConnect( } catch (cause) { if ( cause instanceof McpError && - cause.code === McpErrorCode.UnsupportedProtocolVersion + cause.code === McpErrorCode.UnsupportedProtocolVersion && + (cause.status === undefined || + cause.status === 400 || + cause.status === 404) ) { const legacy = legacyVersion(supportedFrom(cause.data)); if (legacy !== undefined) return initializeLegacy(url, opts, legacy); @@ -1131,7 +1075,11 @@ function parseInputRequired( `MCP server ${url} sent a malformed input_required result`, ); } - const record = result as { inputRequests?: unknown; requestState?: unknown }; + const record = result as { + inputRequests?: unknown; + requestState?: unknown; + _meta?: Record; + }; if (record.inputRequests === undefined && record.requestState === undefined) { throw new McpError( `MCP server ${url} sent an input_required result with neither inputRequests nor requestState`, @@ -1167,6 +1115,7 @@ function parseInputRequired( resultType: "input_required", method, params: stripMeta(params), + ...(record._meta !== undefined ? { _meta: record._meta } : {}), ...(record.inputRequests !== undefined ? { inputRequests: record.inputRequests as Record } : {}), @@ -1176,17 +1125,6 @@ function parseInputRequired( }; } -const TaskHandleSchema = type({ - taskId: "string", - status: "'working' | 'input_required' | 'completed' | 'failed' | 'cancelled'", - createdAt: "string", - lastUpdatedAt: "string", - ttlMs: "number | null", - "pollIntervalMs?": "number", - "statusMessage?": "string", - "_meta?": "Record", -}); - function parseTask(result: unknown, url: string): McpTaskHandle { const parsed = TaskHandleSchema(result); if (parsed instanceof type.errors) { @@ -1203,29 +1141,14 @@ async function requestResult( params: Record | undefined, opts: McpClientOptions, ): Promise { - const callOpts = opts.session === undefined ? asModern(opts) : opts; - const { result } = await sendRequest(url, method, params, callOpts); + const result = await createMcpRequestTransport(url, opts).request( + method, + params, + ); const kind = resultKind(result); - if (kind === "invalid") { - throw new McpError( - `MCP server ${url} sent an unrecognized resultType for ${method}`, - ); - } - if (kind === "input_required" || kind === "task") { - if (!ROUND_TRIP_METHODS.has(method)) { - throw new McpError( - `MCP server ${url} sent ${kind} for ${method}, which does not accept it`, - ); - } - if (kind === "task" && !enabledExtensions(opts).includes(MCP_EXT_TASKS)) { - throw new McpError( - `MCP server ${url} sent a task result but ${MCP_EXT_TASKS} is not enabled`, - ); - } - return kind === "task" - ? parseTask(result, url) - : parseInputRequired(method, params, result, url); - } + if (kind === "task") return parseTask(result, url); + if (kind === "input_required") + return parseInputRequired(method, params, result, url); return result; } @@ -1397,13 +1320,6 @@ export async function mcpListTools( return attachCache(tools, cache); } -const ToolCallResult = type({ - content: "unknown", - "isError?": "boolean", - "structuredContent?": "unknown", - "_meta?": "Record", -}); - /** `tools/call`: invoke a remote tool by name with JSON arguments. */ export async function mcpCallTool( url: string, @@ -1492,27 +1408,9 @@ async function continueMethod( return complete(result); } -const PromptSchema = type({ - name: "string", - "title?": "string", - "description?": "string", - "arguments?": type({ - name: "string", - "description?": "string", - "required?": "boolean", - }).array(), - "icons?": "unknown[]", - "_meta?": "Record", -}); export type McpPrompt = typeof PromptSchema.infer; export type McpPromptList = McpPrompt[] & PageCache; -const PromptResult = type({ - "description?": "string", - messages: "unknown[]", - "_meta?": "Record", -}); - export type McpPromptResult = | { resultType: "complete"; @@ -1573,28 +1471,9 @@ export async function mcpGetPrompt( }); } -const ResourceSchema = type({ - uri: "string", - name: "string", - "title?": "string", - "description?": "string", - "mimeType?": "string", - "annotations?": "Record", - "icons?": "unknown[]", - "_meta?": "Record", -}); export type McpResource = typeof ResourceSchema.infer; export type McpResourceList = McpResource[] & PageCache; -const TemplateSchema = type({ - uriTemplate: "string", - name: "string", - "title?": "string", - "description?": "string", - "mimeType?": "string", - "annotations?": "Record", - "_meta?": "Record", -}); export type McpResourceTemplate = typeof TemplateSchema.infer; export type McpResourceTemplateList = McpResourceTemplate[] & PageCache; @@ -1602,6 +1481,7 @@ export type McpResourceReadResult = | { resultType: "complete"; contents: unknown[]; + _meta?: Record; ttlMs?: number; cacheScope?: McpCacheScope; } @@ -1609,7 +1489,10 @@ export type McpResourceReadResult = | McpTaskHandle; function completeRead(result: unknown, url: string): McpResourceReadResult { - const parsed = type({ contents: "unknown[]" })(result); + const parsed = type({ + contents: "unknown[]", + "_meta?": "Record", + })(result); if (parsed instanceof type.errors) { throw new McpError( `MCP server ${url} sent a malformed resources/read result: ${parsed.summary}`, @@ -1619,6 +1502,7 @@ function completeRead(result: unknown, url: string): McpResourceReadResult { return { resultType: "complete", contents: parsed.contents, + ...(parsed._meta !== undefined ? { _meta: parsed._meta } : {}), ...(cache.ttlMs !== undefined ? { ttlMs: cache.ttlMs } : {}), ...(cache.cacheScope !== undefined ? { cacheScope: cache.cacheScope } : {}), }; @@ -1683,13 +1567,6 @@ export async function mcpListResourceTemplates( return attachCache(templates, cache); } -const CompletionResult = type({ - completion: { - values: "string[]", - "total?": "number", - "hasMore?": "boolean", - }, -}); export type McpCompletion = typeof CompletionResult.infer; export type McpCompletionRef = @@ -1722,15 +1599,6 @@ export async function mcpComplete( return parsed; } -export type McpSubscriptionFilter = { - toolsListChanged?: boolean; - promptsListChanged?: boolean; - resourcesListChanged?: boolean; - resourceSubscriptions?: readonly string[]; - /** Requires `io.modelcontextprotocol/tasks`. */ - taskIds?: readonly string[]; -}; - export type McpNotification = { method: string; params?: Record; @@ -1800,8 +1668,8 @@ function asNotification(message: { /** * `subscriptions/listen`. Resolves when the server closes the stream with a - * result, or rejects when `signal` aborts (the stream is closed and - * `notifications/cancelled` is sent). + * result after acknowledgement, or rejects when `signal` aborts and closes + * the stream. Long-lived by default; timeoutMs explicitly bounds its lifetime. */ export async function mcpListen( url: string, @@ -1813,26 +1681,23 @@ export async function mcpListen( const callOpts = asModern(opts); const fetchImpl = callOpts.fetch ?? fetch; const id = nextId++; + const bodyParams = modernParams(subscriptionParams(filter), callOpts, id); + validateRequest( + "subscriptions/listen", + bodyParams, + validationContext(callOpts), + ); + const progressToken = record(bodyParams._meta).progressToken as + | string + | number + | undefined; const timeout = callOpts.timeoutMs === undefined ? undefined : AbortSignal.timeout(callOpts.timeoutMs); const signals = [timeout, opts.signal].filter((s) => s !== undefined); const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined; - const notifyCancel = () => { - void sendNotification( - url, - "notifications/cancelled", - cancellationOpts(callOpts), - { - requestId: id, - reason: "the caller aborted the request", - }, - ).catch(() => undefined); - }; - if (opts.signal !== undefined) { - opts.signal.addEventListener("abort", notifyCancel, { once: true }); - } + reserveProgressToken(progressToken); try { const response = await fetchImpl(url, { method: "POST", @@ -1841,28 +1706,44 @@ export async function mcpListen( jsonrpc: "2.0", id, method: "subscriptions/listen", - params: modernParams(subscriptionParams(filter), callOpts, id), + params: bodyParams, }), ...(signal !== undefined ? { signal } : {}), }); if (!response.ok) throw await errorFrom(response, url, "subscriptions/listen", signal); const contentType = response.headers.get("content-type") ?? ""; + let acknowledged: Record | undefined; const finish = (message: unknown): void => { const parsed = JsonRpcResponse(message); - if (!(parsed instanceof type.errors) && parsed.error !== undefined) { + if ( + parsed instanceof type.errors || + !isResponseTo(message, id) || + (parsed.error !== undefined && Object.hasOwn(parsed, "result")) + ) { + throw new McpError( + `MCP server ${url} sent a malformed response to subscriptions/listen`, + ); + } + if (parsed.error !== undefined) { throw new McpError( `MCP server ${url} rejected subscriptions/listen: ${parsed.error.message}`, undefined, parsed.error.code, parsed.error.data, + { source: "peer" }, ); } - if (parsed instanceof type.errors || !isResponseTo(message, id)) { - throw new McpError( - `MCP server ${url} sent a malformed response to subscriptions/listen`, - ); - } + if (acknowledged === undefined) + throw new McpError("subscription result preceded acknowledgement"); + const result = validateResult( + "subscriptions/listen", + parsed.result, + validationContext(callOpts), + ); + const metadata = result._meta as Record | undefined; + if (metadata?.[META_SUBSCRIPTION] !== id) + throw new McpError("subscription result has mismatched subscriptionId"); }; if (!contentType.includes("text/event-stream")) { finish(parseJson(await readCapped(response, signal))); @@ -1870,7 +1751,62 @@ export async function mcpListen( } for await (const message of sseMessages(response, signal)) { if (isNotification(message)) { - onNotification(asNotification(message)); + validateNotification( + message.method, + message.params, + validationContext(callOpts), + ); + const notification = asNotification(message); + if (notification.subscriptionId !== id) + throw new McpError( + "subscription notification has mismatched subscriptionId", + ); + if (message.method === "notifications/subscriptions/acknowledged") { + if (acknowledged !== undefined) + throw new McpError("duplicate subscription acknowledgement"); + acknowledged = message.params?.notifications as Record< + string, + unknown + >; + for (const [key, value] of Object.entries(acknowledged)) { + const requested = (filter as Record)[key]; + if ( + Array.isArray(value) + ? !Array.isArray(requested) || + value.some((item) => !requested.includes(item)) + : value === true && requested !== true + ) + throw new McpError( + "subscription acknowledged an unrequested filter", + ); + } + } else { + if (acknowledged === undefined) + throw new McpError( + "subscription notification preceded acknowledgement", + ); + const keys: Record = { + "notifications/tools/list_changed": "toolsListChanged", + "notifications/prompts/list_changed": "promptsListChanged", + "notifications/resources/list_changed": "resourcesListChanged", + }; + const key = keys[message.method]; + const allowed = + key !== undefined + ? acknowledged[key] === true + : message.method === "notifications/resources/updated" + ? Array.isArray(acknowledged.resourceSubscriptions) && + acknowledged.resourceSubscriptions.includes( + message.params?.uri, + ) + : message.method === "notifications/tasks" + ? Array.isArray(acknowledged.taskIds) && + acknowledged.taskIds.includes(message.params?.taskId) + : false; + if (!allowed) + throw new McpError("unrequested subscription notification"); + } + onNotification(notification); continue; } if (isResponseTo(message, id)) { @@ -1902,48 +1838,16 @@ export async function mcpListen( } throw cause; } finally { - opts.signal?.removeEventListener("abort", notifyCancel); + if (progressToken !== undefined) activeProgressTokens.delete(progressToken); } } -const TaskStateSchema = type({ - taskId: "string", - status: "'working' | 'input_required' | 'completed' | 'failed' | 'cancelled'", - createdAt: "string", - lastUpdatedAt: "string", - ttlMs: "number | null", - "pollIntervalMs?": "number", - "statusMessage?": "string", - "inputRequests?": "Record", - "result?": "Record", - "error?": { code: "number", message: "string", "data?": "unknown" }, - "_meta?": "Record", -}); - -function parseTaskState(result: unknown, url: string, method: string): McpTask { - const parsed = TaskStateSchema(result); - if (parsed instanceof type.errors) { - throw new McpError( - `MCP server ${url} sent a malformed ${method} result: ${parsed.summary}`, - ); - } - if ( - parsed.status === "input_required" && - parsed.inputRequests === undefined - ) { - throw new McpError( - `MCP server ${url} sent an input_required task without inputRequests`, - ); - } - if (parsed.status === "completed" && parsed.result === undefined) { - throw new McpError( - `MCP server ${url} sent a completed task without a result`, - ); - } - if (parsed.status === "failed" && parsed.error === undefined) { - throw new McpError(`MCP server ${url} sent a failed task without an error`); - } - return { resultType: "complete", ...parsed }; +function parseTaskState( + result: unknown, + _url: string, + _method: string, +): McpTask { + return { ...validateTask(result, true), resultType: "complete" }; } /** `tasks/get`. Requires the tasks extension. */ @@ -2008,16 +1912,21 @@ export async function mcpCancelTask( } } -const SkillSchema = type({ - uri: "string", - frontmatter: "Record", - resources: "unknown", -}); export type McpSkill = typeof SkillSchema.infer; export type McpSkillList = McpSkill[] & PageCache; function requireSkills(opts: McpClientOptions): void { requireExtension(opts, MCP_EXT_SKILLS); + const extensions = opts.session?.capabilities?.extensions; + if ( + typeof extensions !== "object" || + extensions === null || + !Object.hasOwn(extensions, MCP_EXT_SKILLS) + ) { + throw new McpError( + "server has not declared the skills extension; discover before calling skills methods", + ); + } } /** `skills/list`, following cursors. Requires the skills extension. */ diff --git a/src/client/contracts.test.ts b/src/client/contracts.test.ts new file mode 100644 index 0000000..93e2a98 --- /dev/null +++ b/src/client/contracts.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { + mcpCallTool, + MCP_EXT_TASKS, + MCP_PROTOCOL_VERSION, + McpError, +} from "../client.js"; +import { + validateJsonRpcRequest, + validateUiToolMeta, + validateContentBlock, + validateResult, +} from "../protocol/index.js"; +import type { FetchLike } from "../protocol/types.js"; + +test("shared envelope rejects batches, null IDs, response/request mixtures and invalid params", () => { + const good = { + jsonrpc: "2.0" as const, + id: "r", + method: "tools/list", + params: {}, + }; + expect(validateJsonRpcRequest(good)).toEqual(good); + for (const value of [ + [good], + { ...good, id: null }, + { ...good, params: [] }, + { ...good, params: ["positional"] }, + { ...good, params: null }, + { ...good, result: {} }, + { ...good, jsonrpc: "1.0" }, + ]) + expect(() => validateJsonRpcRequest(value)).toThrow(McpError); + expect(() => + validateJsonRpcRequest({ + jsonrpc: "2.0", + method: "notifications/cancelled", + }), + ).toThrow(McpError); + expect( + validateJsonRpcRequest( + { jsonrpc: "2.0", method: "notifications/cancelled" }, + true, + ), + ).toHaveProperty("method", "notifications/cancelled"); +}); +test("shared UI visibility parser preserves metadata and rejects invented visibility", () => { + const metadata = { + resourceUri: "ui://test", + visibility: ["app" as const], + vendor: { key: true }, + }; + expect(validateUiToolMeta(metadata)).toBe(metadata); + expect(() => validateUiToolMeta({ visibility: ["everyone"] })).toThrow( + McpError, + ); + expect(() => + validateUiToolMeta({ resourceUri: "https://example.test" }), + ).toThrow(McpError); +}); +test("modern content is strict even when resultType is omitted", async () => { + for (const content of [ + "old nonstandard text", + [{ type: "text" }], + [{ type: "invented", text: "x" }], + ]) { + const fetch: FetchLike = async (_url, init) => { + const request = JSON.parse(String(init?.body)) as { id: number }; + return Response.json({ + jsonrpc: "2.0", + id: request.id, + result: { content }, + }); + }; + await expect( + mcpCallTool("https://example.test/mcp", "tool", {}, { fetch }), + ).rejects.toBeInstanceOf(McpError); + } + expect( + validateResult("tools/call", { + content: [{ type: "text", text: "valid" }], + }), + ).toHaveProperty("content"); + expect(() => + validateResult("prompts/get", { messages: [{ role: "user" }] }), + ).toThrow(McpError); +}); +test("binary and embedded resource content remain lossless", () => { + const blocks = [ + { type: "image" as const, data: "aGk=", mimeType: "image/png" }, + { type: "audio" as const, data: "aGk=", mimeType: "audio/wav" }, + { + type: "resource" as const, + resource: { + uri: "test://bytes", + blob: "aGk=", + mimeType: "application/octet-stream", + }, + }, + { + type: "resource_link" as const, + name: "file", + uri: "test://file", + _meta: { "example/key": true }, + }, + ]; + for (const block of blocks) expect(validateContentBlock(block)).toBe(block); +}); +test("elicitation capability option advertises only configured modes without dropping extensions", async () => { + const fetch: FetchLike = async (_url, init) => { + const request = JSON.parse(String(init?.body)) as { + id: number; + params: { _meta: Record }; + }; + expect( + request.params._meta["io.modelcontextprotocol/protocolVersion"], + ).toBe(MCP_PROTOCOL_VERSION); + expect( + request.params._meta["io.modelcontextprotocol/clientCapabilities"], + ).toEqual({ + elicitation: { form: {} }, + extensions: { [MCP_EXT_TASKS]: {} }, + }); + return Response.json({ + jsonrpc: "2.0", + id: request.id, + result: { content: [] }, + }); + }; + await mcpCallTool( + "https://example.test/mcp", + "tool", + {}, + { + fetch, + elicitation: { form: true, url: false }, + extensions: [MCP_EXT_TASKS], + }, + ); +}); diff --git a/src/client/gaps.test.ts b/src/client/gaps.test.ts new file mode 100644 index 0000000..8138c8b --- /dev/null +++ b/src/client/gaps.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test"; +import { + mcpConnect, + mcpGetTask, + mcpGetPrompt, + mcpListen, + mcpReadResource, + mcpReadResourceDirectory, + createMcpRequestTransport, + MCP_EXT_SKILLS, + McpError, + MCP_EXT_TASKS, + MCP_PROTOCOL_VERSION, + mcpCallTool, +} from "../client"; +import type { FetchLike } from "../protocol/types"; +const url = "https://example.test/mcp"; +function reply( + result: unknown, + inspect?: (body: Record, headers: Headers) => void, +): FetchLike { + return async (_input, init) => { + const body = JSON.parse(String(init?.body)) as Record; + inspect?.(body, new Headers(init?.headers)); + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }; +} +const task = { + resultType: "task", + taskId: "t", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:00Z", + ttlMs: null, +}; +describe("shared client audit regressions", () => { + test("task RPC names mirror taskId", async () => { + await mcpGetTask(url, "task/日本語", { + extensions: [MCP_EXT_TASKS], + fetch: reply({ ...task, resultType: "complete" }, (_body, headers) => { + expect(headers.get("Mcp-Name")).toBe( + `=?base64?${Buffer.from("task/日本語").toString("base64")}?=`, + ); + }), + }); + }); + test("tasks cannot replace prompt results", async () => { + await expect( + mcpGetPrompt(url, "p", undefined, { + extensions: [MCP_EXT_TASKS], + fetch: reply(task), + }), + ).rejects.toBeInstanceOf(McpError); + }); + test("modern polymorphic results are rejected on legacy sessions", async () => { + await expect( + mcpCallTool( + url, + "t", + {}, + { + session: { protocolVersion: "2025-03-26" }, + extensions: [MCP_EXT_TASKS], + fetch: reply(task), + }, + ), + ).rejects.toBeInstanceOf(McpError); + }); + test("subscription termination validates resultType", async () => { + await expect( + mcpListen(url, {}, () => {}, { fetch: reply({ resultType: "mystery" }) }), + ).rejects.toBeInstanceOf(McpError); + }); + test("401 challenge wins over an RPC fallback error", async () => { + let calls = 0; + const fetch: FetchLike = async () => { + calls++; + return Response.json( + { error: { code: -32601, message: "server not initialized" } }, + { + status: 401, + headers: { + "www-authenticate": + 'Bearer resource_metadata="https://example.test/meta"', + }, + }, + ); + }; + try { + await mcpConnect(url, { fetch }); + throw new Error("expected failure"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + const failure = error as McpError & { wwwAuthenticate?: string }; + expect(failure.status).toBe(401); + expect(failure.wwwAuthenticate).toContain("resource_metadata"); + expect(calls).toBe(1); + } + }); + test("error responses must match the request ID", async () => { + const fetch: FetchLike = async () => + Response.json({ + jsonrpc: "2.0", + id: "other", + error: { code: -32601, message: "wrong request" }, + }); + await expect(mcpConnect(url, { fetch })).rejects.toThrow( + "malformed response", + ); + }); + test("resource result metadata survives projection", async () => { + const result = await mcpReadResource(url, "test://a", { + fetch: reply({ + resultType: "complete", + contents: [{ uri: "test://a", text: "a" }], + _meta: { "example/trace": "trace" }, + }), + }); + expect(result).toHaveProperty("_meta", { "example/trace": "trace" }); + }); + test("structured output is checked against an explicitly supplied schema", async () => { + await expect( + mcpCallTool( + url, + "t", + {}, + { + fetch: reply({ content: [], structuredContent: { count: "bad" } }), + outputSchema: { + type: "object", + properties: { count: { type: "integer" } }, + required: ["count"], + }, + }, + ), + ).rejects.toBeInstanceOf(McpError); + }); + test("subscription ack, notifications, and termination share one identity", async () => { + const fetch: FetchLike = async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { id: number }; + const _meta = { "io.modelcontextprotocol/subscriptionId": body.id }; + const messages = [ + { + jsonrpc: "2.0", + method: "notifications/subscriptions/acknowledged", + params: { notifications: { toolsListChanged: true }, _meta }, + }, + { + jsonrpc: "2.0", + method: "notifications/tools/list_changed", + params: { _meta }, + }, + { + jsonrpc: "2.0", + id: body.id, + result: { resultType: "complete", _meta }, + }, + ]; + return new Response( + messages + .map((message) => `data: ${JSON.stringify(message)}\n\n`) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ); + }; + const notes: string[] = []; + await mcpListen( + url, + { toolsListChanged: true }, + (note) => notes.push(note.method), + { fetch }, + ); + expect(notes).toEqual([ + "notifications/subscriptions/acknowledged", + "notifications/tools/list_changed", + ]); + }); + test("subscription notification before ack is rejected", async () => { + const fetch: FetchLike = async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { id: number }; + return new Response( + `data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: { _meta: { "io.modelcontextprotocol/subscriptionId": body.id } } })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }; + await expect( + mcpListen(url, { toolsListChanged: true }, () => {}, { fetch }), + ).rejects.toThrow("preceded acknowledgement"); + }); + test("default stateless calls still use modern protocol", async () => { + await mcpCallTool( + url, + "t", + {}, + { + fetch: reply({ content: [] }, (_body, headers) => + expect(headers.get("MCP-Protocol-Version")).toBe( + MCP_PROTOCOL_VERSION, + ), + ), + }, + ); + }); +}); + +describe("discovered directory capabilities", () => { + for (const directoryRead of [true, false, undefined]) { + test(`directoryRead=${String(directoryRead)} gates facade and transport`, async () => { + const methods: unknown[] = []; + const resources = [{ uri: "skill://demo/SKILL.md", name: "SKILL.md" }]; + const fetch: FetchLike = async (_url, init) => { + const body = JSON.parse(String(init?.body)) as Record; + methods.push(body.method); + const result = + body.method === "server/discover" + ? { + supportedVersions: [MCP_PROTOCOL_VERSION], + capabilities: { + resources: {}, + extensions: { + [MCP_EXT_SKILLS]: + directoryRead === undefined ? {} : { directoryRead }, + }, + }, + } + : { resources }; + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }; + const session = await mcpConnect(url, { + fetch, + extensions: [MCP_EXT_SKILLS], + }); + const opts = { fetch, session }; + const transport = createMcpRequestTransport(url, opts); + if (directoryRead === true) { + expect( + await mcpReadResourceDirectory(url, "skill://demo/", opts), + ).toEqual(resources); + expect( + await transport.request("resources/directory/read", { + uri: "skill://demo/", + }), + ).toEqual({ resources }); + expect(methods).toEqual([ + "server/discover", + "resources/directory/read", + "resources/directory/read", + ]); + } else { + await expect( + mcpReadResourceDirectory(url, "skill://demo/", opts), + ).rejects.toThrow("directoryRead"); + await expect( + transport.request("resources/directory/read", { + uri: "skill://demo/", + }), + ).rejects.toThrow("directoryRead"); + expect(methods).toEqual(["server/discover"]); + } + }); + } +}); diff --git a/src/client/resource-budget.test.ts b/src/client/resource-budget.test.ts new file mode 100644 index 0000000..24d9a4d --- /dev/null +++ b/src/client/resource-budget.test.ts @@ -0,0 +1,282 @@ +import { expect, test } from "bun:test"; +import { createResourceBudget } from "./resource-budget.js"; +import { mcpReadResource, McpError } from "../client.js"; +import type { FetchLike } from "../protocol/types.js"; + +const uri = "test://bounded"; +function streamed(result: unknown, sse = false, escape = false): FetchLike { + return async (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id: number }; + let json = JSON.stringify({ jsonrpc: "2.0", id, result }); + if (escape) + json = json.replace( + /[^\x20-\x7e]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + const wire = new TextEncoder().encode( + sse ? `: comment\r\nevent: message\r\ndata: ${json}\r\n\r\n` : json, + ); + let offset = 0; + return new Response( + new ReadableStream({ + pull(controller) { + if (offset === wire.length) { + controller.close(); + return; + } + controller.enqueue(wire.slice(offset, offset + 1)); + offset++; + }, + }), + { + headers: { + "content-type": sse ? "text/event-stream" : "application/json", + }, + }, + ); + }; +} +for (const sse of [false, true]) { + for (const text of ["", "a", "é", "😀", "\ud800", '"\\\n', "漢字😀"]) { + test(`bounded UTF-8 ${JSON.stringify(text)} (SSE=${sse})`, async () => { + const result = { + contents: [{ uri, text, _meta: { source: "kept" } }], + _meta: { trace: "kept" }, + }; + const maxDecodedBytes = Buffer.byteLength(text); + for (const escape of [false, true]) { + const actual = await mcpReadResource("/mcp", uri, { + fetch: streamed(result, sse, escape), + maxDecodedBytes, + }); + expect(actual).toMatchObject(result); + if (maxDecodedBytes > 0) + await expect( + mcpReadResource("/mcp", uri, { + fetch: streamed(result, sse, escape), + maxDecodedBytes: maxDecodedBytes - 1, + }), + ).rejects.toThrow(/limit|budget/); + } + }); + } + for (const text of ["", "a", "ab", "abc", "abcd"]) { + test(`bounded base64 length ${text.length} (SSE=${sse})`, async () => { + const result = { contents: [{ uri, blob: btoa(text) }] }; + expect( + await mcpReadResource("/mcp", uri, { + fetch: streamed(result, sse), + maxDecodedBytes: text.length, + }), + ).toMatchObject(result); + if (text.length) + await expect( + mcpReadResource("/mcp", uri, { + fetch: streamed(result, sse), + maxDecodedBytes: text.length - 1, + }), + ).rejects.toThrow(/limit|budget/); + }); + } +} +test("bounded reads preserve RPC errors and authentication challenges", async () => { + const fetch: FetchLike = async (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id: number }; + return Response.json({ + jsonrpc: "2.0", + id, + error: { code: -32602, message: "resource unavailable" }, + }); + }; + await expect( + mcpReadResource("/mcp", uri, { fetch, maxDecodedBytes: 0 }), + ).rejects.toThrow("resource unavailable"); + try { + await mcpReadResource("/mcp", uri, { + maxDecodedBytes: 0, + fetch: async () => + new Response("no", { + status: 401, + headers: { "www-authenticate": "Bearer" }, + }), + }); + throw new Error("expected authentication error"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + expect(error).toMatchObject({ status: 401, wwwAuthenticate: "Bearer" }); + } +}); + +for (const sse of [false, true]) { + for (const kind of ["text", "blob", "metadata"] as const) { + test(`incremental ${kind} overflow cancels before EOF (SSE=${sse})`, async () => { + let consumed = 0; + let eof = false; + let cancelled = false; + const fetch: FetchLike = async (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id: number }; + const large = "x".repeat(200 * 1024); + const result = + kind === "metadata" + ? { _meta: { large }, contents: [{ uri, text: "" }] } + : { + contents: [ + { uri, [kind]: kind === "blob" ? btoa(large) : large }, + ], + }; + const json = JSON.stringify({ jsonrpc: "2.0", id, result }); + const wire = new TextEncoder().encode(sse ? `data: ${json}\n\n` : json); + return new Response( + new ReadableStream({ + pull(controller) { + if (consumed === wire.length) { + eof = true; + controller.close(); + return; + } + const end = Math.min(consumed + 1024, wire.length); + controller.enqueue(wire.slice(consumed, end)); + consumed = end; + }, + cancel() { + cancelled = true; + }, + }), + { + headers: { + "content-type": sse ? "text/event-stream" : "application/json", + }, + }, + ); + }; + await expect( + mcpReadResource("/mcp", uri, { fetch, maxDecodedBytes: 63 }), + ).rejects.toThrow(/limit|budget/); + expect(cancelled).toBe(true); + expect(eof).toBe(false); + expect(consumed).toBeLessThan(kind === "metadata" ? 68 * 1024 : 4 * 1024); + }); + } +} +test("escaped protocol keys and aggregate resource bytes cannot evade limits", async () => { + const fetch: FetchLike = async (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id: number }; + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + contents: [ + { uri, text: "ab" }, + { uri, text: "cd" }, + ], + }, + }).replace('"text"', '"te\\u0078t"'), + ); + }; + await expect( + mcpReadResource("/mcp", uri, { fetch, maxDecodedBytes: 3 }), + ).rejects.toThrow("decoded byte limit"); +}); + +test("invalid decoded limits fail before mediated fetch", async () => { + let calls = 0; + for (const maxDecodedBytes of [-1, 0.5, Infinity, NaN]) { + await expect( + mcpReadResource("/mcp", uri, { + maxDecodedBytes, + fetch: async () => { + calls++; + return Response.json({}); + }, + }), + ).rejects.toThrow("nonnegative safe integer"); + } + expect(calls).toBe(0); +}); + +test("bounded wire allowance includes fully JSON-escaped base64", () => { + const size = 128 * 1024; + const blob = Buffer.alloc(size).toString("base64"); + const wire = JSON.stringify({ result: { contents: [{ blob }] } }).replace( + /[A=]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + const budget = createResourceBudget(size, false); + expect(() => budget.push(new TextEncoder().encode(wire))).not.toThrow(); +}); + +test("large declared budgets do not enlarge the metadata allowance", () => { + const budget = createResourceBudget(16 * 1024 * 1024, false); + expect(() => + budget.push( + new TextEncoder().encode( + JSON.stringify({ + result: { _meta: { large: "x".repeat(65 * 1024) }, contents: [] }, + }), + ), + ), + ).toThrow(/metadata|envelope/); +}); + +test("decoded hard maximum applies even to oversized explicit budgets", () => { + const budget = createResourceBudget(Number.MAX_SAFE_INTEGER, false); + budget.push(new TextEncoder().encode('{"result":{"contents":[{"text":"')); + const chunk = new TextEncoder().encode("x".repeat(64 * 1024)); + for (let i = 0; i < 256; i++) budget.push(chunk); + expect(() => budget.push(new TextEncoder().encode("x"))).toThrow( + "decoded byte limit", + ); +}); + +for (const sse of [false, true]) { + test(`large bounded read cancels oversized metadata (SSE=${sse})`, async () => { + let consumed = 0; + let cancelled = false; + let eof = false; + await expect( + mcpReadResource("/mcp", uri, { + maxDecodedBytes: 16 * 1024 * 1024, + fetch: async (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id: number }; + const json = JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + contents: [{ uri, text: "" }], + _meta: { large: "x".repeat(200 * 1024) }, + }, + }); + const wire = new TextEncoder().encode( + sse ? `data: ${json}\n\n` : json, + ); + return new Response( + new ReadableStream({ + pull(controller) { + if (consumed === wire.length) { + eof = true; + controller.close(); + return; + } + const end = Math.min(consumed + 1024, wire.length); + controller.enqueue(wire.subarray(consumed, end)); + consumed = end; + }, + cancel() { + cancelled = true; + }, + }), + { + headers: { + "content-type": sse ? "text/event-stream" : "application/json", + }, + }, + ); + }, + }), + ).rejects.toThrow("metadata/envelope byte limit"); + expect(cancelled).toBe(true); + expect(eof).toBe(false); + expect(consumed).toBeLessThan(68 * 1024); + }); +} diff --git a/src/client/resource-budget.ts b/src/client/resource-budget.ts new file mode 100644 index 0000000..0b25b40 --- /dev/null +++ b/src/client/resource-budget.ts @@ -0,0 +1,4 @@ +export { + createResourceBudget, + type ResourceBudget, +} from "../protocol/resource-budget.js"; diff --git a/src/client/review-regressions.test.ts b/src/client/review-regressions.test.ts new file mode 100644 index 0000000..72e123f --- /dev/null +++ b/src/client/review-regressions.test.ts @@ -0,0 +1,536 @@ +import { expect, spyOn, test } from "bun:test"; +import { + createMcpRequestTransport, + DEFAULT_TIMEOUT_MS, + mcpCallTool, + mcpConnect, + mcpListen, + MCP_EXT_TASKS, + MCP_PROTOCOL_VERSION, + McpError, + type McpClientOptions, +} from "../client.js"; +import { + validateInputRequests, + validateNotification, + validateRequest, + validateResult, +} from "../protocol/schemas.js"; +import type { FetchLike } from "../protocol/types.js"; + +const url = "https://example.test/mcp"; +const subscriptionKey = "io.modelcontextprotocol/subscriptionId"; +type Request = { + id: number; + method: string; + params: Record & { _meta?: Record }; +}; +const mock = + (reply: (request: Request) => Response | Promise): FetchLike => + async (_url, init) => + reply(JSON.parse(String(init?.body)) as Request); +const rpc = (id: number, result: unknown) => ({ jsonrpc: "2.0", id, result }); +const sse = (messages: unknown[]) => + new Response( + messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }, + ); +const form = (property: unknown = { type: "string" }) => ({ + method: "elicitation/create", + params: { + message: "Choose", + requestedSchema: { type: "object", properties: { choice: property } }, + }, +}); +const pending = (request: unknown) => ({ + resultType: "input_required", + inputRequests: { choice: request }, +}); +const task = { + taskId: "t", + status: "completed", + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:00Z", + ttlMs: null, +}; + +test("F2 facade distinguishes absent from invalid result discriminators", async () => { + for (const resultType of [null, 3, "unknown"]) { + await expect( + mcpCallTool( + url, + "t", + {}, + { + fetch: mock((r) => + Response.json(rpc(r.id, { resultType, content: [] })), + ), + }, + ), + ).rejects.toBeInstanceOf(McpError); + } + await expect( + mcpCallTool( + url, + "t", + {}, + { fetch: mock((r) => Response.json(rpc(r.id, { content: [] }))) }, + ), + ).resolves.toHaveProperty("content", []); +}); + +test("F3 completed tasks contain only final complete tool results, including notifications", () => { + const context = { extensions: [MCP_EXT_TASKS] }; + for (const result of [ + { resultType: "input_required", requestState: "pending" }, + { ...task, resultType: "task" }, + ]) { + expect(() => + validateResult("tasks/get", { ...task, result }, context), + ).toThrow(); + expect(() => + validateNotification("notifications/tasks", { ...task, result }, context), + ).toThrow(); + } + expect(() => + validateResult("tasks/get", { ...task, result: { content: [] } }, context), + ).not.toThrow(); +}); + +test("F4 response validation uses effective advertised capabilities including inherited and empty", async () => { + const session = await mcpConnect(url, { + elicitation: { form: true }, + fetch: mock((r) => + Response.json( + rpc(r.id, { + supportedVersions: [MCP_PROTOCOL_VERSION], + capabilities: { tools: {} }, + }), + ), + ), + }); + const urlRequest = { + method: "elicitation/create", + params: { + mode: "url", + message: "Login", + url: "https://example.test/login", + }, + }; + for (const opts of [{ session }, { session, clientCapabilities: {} }, {}]) { + const request = + "session" in opts && !("clientCapabilities" in opts) + ? urlRequest + : form(); + await expect( + mcpCallTool( + url, + "t", + {}, + { + ...opts, + fetch: mock((r) => Response.json(rpc(r.id, pending(request)))), + }, + ), + ).rejects.toBeInstanceOf(McpError); + } + await expect( + mcpCallTool( + url, + "t", + {}, + { + session, + fetch: mock((r) => Response.json(rpc(r.id, pending(form())))), + }, + ), + ).resolves.toHaveProperty("resultType", "input_required"); +}); + +test("F5 modern cancellation closes streams without legacy notification; legacy still notifies", async () => { + for (const mode of ["modern", "legacy", "subscription"]) { + const controller = new AbortController(); + const methods: string[] = []; + let closed = false; + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const fetch = mock((r) => { + methods.push(r.method); + if (r.method === "notifications/cancelled") + return new Response(null, { status: 202 }); + return new Response( + new ReadableStream({ + start() { + started(); + }, + cancel() { + closed = true; + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + }); + const opts: McpClientOptions = { + fetch, + signal: controller.signal, + ...(mode === "legacy" + ? { session: { protocolVersion: "2025-11-25" } } + : {}), + }; + const result = ( + mode === "subscription" + ? mcpListen(url, {}, () => {}, opts) + : mcpCallTool(url, "t", {}, opts) + ).catch((e: unknown) => e); + await ready; + await Bun.sleep(0); + controller.abort(); + expect(await result).toHaveProperty("cancelled", true); + expect(closed).toBe(true); + expect(methods.includes("notifications/cancelled")).toBe(mode === "legacy"); + } +}); + +test("F6 request extension metadata and pending result metadata survive client boundaries", async () => { + const metadata = { "example.org/trace": "trace" }; + const transport = createMcpRequestTransport(url, { + fetch: mock((r) => { + expect(r.params._meta).toMatchObject(metadata); + return Response.json(rpc(r.id, { content: [] })); + }), + }); + await transport.request("tools/call", { name: "t", _meta: metadata }); + const result = await mcpCallTool( + url, + "t", + {}, + { + fetch: mock((r) => + Response.json( + rpc(r.id, { + resultType: "input_required", + requestState: "pending", + _meta: metadata, + }), + ), + ), + }, + ); + expect(result).toHaveProperty("_meta", metadata); +}); + +test("F7 subscription errors validate envelope and correlation before peer classification", async () => { + for (const shape of ["wrong-id", "mixed", "peer"]) { + const error = await mcpListen(url, {}, () => {}, { + fetch: mock((r) => + Response.json({ + jsonrpc: "2.0", + id: shape === "wrong-id" ? r.id + 1 : r.id, + error: { code: -32603, message: "peer failure" }, + ...(shape === "mixed" ? { result: {} } : {}), + }), + ), + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(McpError); + expect(error).toHaveProperty("source", shape === "peer" ? "peer" : "local"); + } +}); + +test("F7 successful subscriptions require acknowledgement first", async () => { + for (const acknowledged of [false, true]) { + const result = mcpListen(url, { toolsListChanged: true }, () => {}, { + fetch: mock((r) => { + const _meta = { [subscriptionKey]: r.id }; + return sse([ + ...(acknowledged + ? [ + { + jsonrpc: "2.0", + method: "notifications/subscriptions/acknowledged", + params: { notifications: { toolsListChanged: true }, _meta }, + }, + ] + : []), + rpc(r.id, { _meta }), + ]); + }), + }); + if (acknowledged) await expect(result).resolves.toBeUndefined(); + else await expect(result).rejects.toThrow("acknowledgement"); + } +}); + +test("F8 recognized version errors cannot override auth/rate-limit/server HTTP status", async () => { + for (const status of [401, 403, 429, 500, 503]) { + const methods: string[] = []; + const error = await mcpConnect(url, { + fetch: mock((r) => { + methods.push(r.method); + return Response.json( + { + jsonrpc: "2.0", + id: r.id, + error: { + code: -32022, + message: "Unsupported protocol version", + data: { supported: ["2025-11-25"] }, + }, + }, + { status }, + ); + }), + }).catch((e: unknown) => e); + expect(error).toHaveProperty("status", status); + expect(methods).toEqual(["server/discover"]); + } +}); + +test("F9 elicitation accepts only primitive and string-enum forms", () => { + for (const property of [ + { type: "string" }, + { type: "number" }, + { type: "integer" }, + { type: "boolean" }, + { type: "string", enum: ["a"] }, + { type: "string", oneOf: [{ const: "a", title: "A" }] }, + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "array", items: { anyOf: [{ const: "a", title: "A" }] } }, + ]) { + expect(() => + validateInputRequests({ input: form(property) }), + ).not.toThrow(); + } + for (const property of [ + {}, + { type: "null" }, + { type: "object" }, + { + type: "array", + items: { type: "object", properties: { nested: { type: "string" } } }, + }, + { type: "array", items: { type: "array" } }, + { type: "array", items: { type: "string" } }, + { type: "string", enum: [3] }, + ]) { + expect(() => validateInputRequests({ input: form(property) })).toThrow(); + } +}); + +test("F10 standalone requests install 60s default; subscriptions remain explicitly long-lived", async () => { + const timeout = spyOn(AbortSignal, "timeout"); + try { + await mcpCallTool( + url, + "t", + {}, + { fetch: mock((r) => Response.json(rpc(r.id, { content: [] }))) }, + ); + expect(timeout).toHaveBeenCalledWith(DEFAULT_TIMEOUT_MS); + timeout.mockClear(); + await mcpListen(url, {}, () => {}, { + fetch: mock((r) => + Response.json({ + jsonrpc: "2.0", + id: r.id, + error: { code: -32603, message: "stop" }, + }), + ), + }).catch(() => {}); + expect(timeout).not.toHaveBeenCalled(); + } finally { + timeout.mockRestore(); + } +}); + +test("F11 progress token shape is canonical for requests and notifications; legacy advertises progress", async () => { + for (const token of [0.5, NaN, Infinity, {}, null]) { + expect(() => + validateRequest( + "tools/call", + { name: "t", _meta: { progressToken: token } }, + { era: "legacy" }, + ), + ).toThrow(); + expect(() => + validateNotification("notifications/progress", { + progressToken: token, + progress: 1, + }), + ).toThrow(); + } + for (const token of [-1, 0, "token"]) + expect(() => + validateNotification("notifications/progress", { + progressToken: token, + progress: 0.5, + }), + ).not.toThrow(); + await mcpCallTool( + url, + "t", + {}, + { + session: { protocolVersion: "2025-11-25" }, + onProgress() {}, + fetch: mock((r) => { + expect(r.params._meta?.progressToken).toBe(r.id); + return Response.json(rpc(r.id, { content: [] })); + }), + }, + ); +}); + +test("F11 active progress tokens cannot collide and are released on completion", async () => { + let release!: () => void; + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const opts = { + progressToken: "unique", + fetch: mock(async (r) => { + calls++; + started(); + await gate; + return Response.json(rpc(r.id, { content: [] })); + }), + }; + const first = mcpCallTool(url, "t", {}, opts); + await ready; + const second = mcpCallTool(url, "t", {}, opts).catch((e: unknown) => e); + await Bun.sleep(0); + release(); + await first; + expect(await second).toBeInstanceOf(McpError); + expect(calls).toBe(1); + await mcpCallTool(url, "t", {}, opts); + expect(calls).toBe(2); +}); + +test("F11 progress must strictly increase, with floating point progress allowed", async () => { + for (const values of [ + [0.5, 1.5], + [1, 1], + [2, 1], + ]) { + const seen: number[] = []; + const result = mcpCallTool( + url, + "t", + {}, + { + progressToken: "p", + onProgress(p) { + seen.push(p.progress); + }, + fetch: mock((r) => + sse([ + ...values.map((progress) => ({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { progressToken: "p", progress }, + })), + rpc(r.id, { content: [] }), + ]), + ), + }, + ); + if (values[0]! < values[1]!) { + await result; + expect(seen).toEqual(values); + } else await expect(result).rejects.toThrow("progress"); + } +}); + +test("F11 subscriptions reserve their advertised progress token until cancellation", async () => { + const controller = new AbortController(); + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const stream = mcpListen(url, {}, () => {}, { + signal: controller.signal, + progressToken: "subscription-progress", + fetch: mock( + () => + new Response( + new ReadableStream({ + start() { + started(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + }).catch((error: unknown) => error); + await ready; + const opts = { + progressToken: "subscription-progress", + fetch: mock((r) => Response.json(rpc(r.id, { content: [] }))), + }; + const collision = await mcpCallTool(url, "t", {}, opts).catch( + (error: unknown) => error, + ); + controller.abort(); + await stream; + expect(collision).toBeInstanceOf(McpError); + await expect(mcpCallTool(url, "t", {}, opts)).resolves.toHaveProperty( + "content", + [], + ); +}); + +test("F4 per-request capabilities override inherited host support, including explicit disabling", async () => { + const session = { + protocolVersion: MCP_PROTOCOL_VERSION, + clientCapabilities: { elicitation: { form: {} } }, + }; + const fetch = mock((r) => Response.json(rpc(r.id, pending(form())))); + await expect( + mcpCallTool(url, "t", {}, { session, elicitation: { form: false }, fetch }), + ).rejects.toThrow("capability"); + await expect( + mcpCallTool( + url, + "t", + {}, + { session, clientCapabilities: { elicitation: {} }, fetch }, + ), + ).resolves.toHaveProperty("resultType", "input_required"); +}); + +test("F10 explicit request and subscription timeouts override their defaults", async () => { + const timeout = spyOn(AbortSignal, "timeout"); + try { + await mcpCallTool( + url, + "t", + {}, + { + timeoutMs: 1234, + fetch: mock((r) => Response.json(rpc(r.id, { content: [] }))), + }, + ); + expect(timeout).toHaveBeenCalledWith(1234); + timeout.mockClear(); + await mcpListen(url, {}, () => {}, { + timeoutMs: 4321, + fetch: mock((r) => + Response.json({ + jsonrpc: "2.0", + id: r.id, + error: { code: -32603, message: "stop" }, + }), + ), + }).catch(() => {}); + expect(timeout).toHaveBeenCalledWith(4321); + } finally { + timeout.mockRestore(); + } +}); diff --git a/src/client/transport.ts b/src/client/transport.ts new file mode 100644 index 0000000..6020368 --- /dev/null +++ b/src/client/transport.ts @@ -0,0 +1,21 @@ +import type { McpClientOptions, McpNotification } from "../client.js"; +import type { + McpSubscriptionFilter, + McpValidationContext, +} from "../protocol/types.js"; +export interface McpRequestOptions extends McpClientOptions { + validation?: McpValidationContext; +} +/** Extensions receive this interface rather than importing the client facade. */ +export interface McpRequestTransport { + request( + method: string, + params?: Record, + options?: McpRequestOptions, + ): Promise>; + listen( + filter: McpSubscriptionFilter, + observer: (notification: McpNotification) => void, + options?: McpRequestOptions, + ): Promise; +} diff --git a/src/protocol-2026.test.ts b/src/protocol-2026.test.ts index c176ced..278dd19 100644 --- a/src/protocol-2026.test.ts +++ b/src/protocol-2026.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { MCP_EXT_SKILLS, @@ -158,7 +158,10 @@ describe("MCP 2026-07-28", () => { } if (seen.method === "tools/list") return rpc(seen.body.id, { tools: [] }); if (seen.method === "tools/call") { - return rpc(seen.body.id, { resultType: "complete", content: "plain" }); + return rpc(seen.body.id, { + resultType: "complete", + content: [{ type: "text", text: "plain" }], + }); } return rpcError(seen.body.id, McpErrorCode.MethodNotFound, "no"); }); @@ -169,7 +172,7 @@ describe("MCP 2026-07-28", () => { expect(result.resultType).toBe("complete"); if (result.resultType !== "complete") throw new Error("expected a complete result"); - expect(result.content).toBe("plain"); + expect(result.content).toEqual([{ type: "text", text: "plain" }]); for (const seen of srv.seen) { expect(seen.headers.get("MCP-Protocol-Version")).toBe( @@ -287,7 +290,7 @@ describe("MCP 2026-07-28", () => { if (seen.method === "notifications/initialized") return new Response(null, { status: 202 }); return rpc(seen.body.id, { - tools: [{ name: "echo", inputSchema: {} }], + tools: [{ name: "echo", inputSchema: { type: "object" } }], }); }); try { @@ -447,35 +450,55 @@ describe("MCP 2026-07-28", () => { let call = 0; const srv = jsonServer((seen) => { call += 1; - if (call === 1) return rpc(seen.body.id, { content: "plain" }); + if (call === 1) + return rpc(seen.body.id, { + content: [{ type: "text", text: "plain" }], + }); if (call === 2) { return rpc(seen.body.id, { resultType: "input_required", requestState: "opaque", inputRequests: { - a: { method: "elicitation/create", message: "name" }, + a: { + method: "elicitation/create", + params: { + message: "name", + requestedSchema: { + type: "object", + properties: { text: { type: "string" } }, + }, + }, + }, }, }); } - expect(seen.body.params?.inputResponses).toEqual({ a: { text: "ada" } }); + expect(seen.body.params?.inputResponses).toEqual({ + a: { action: "accept", content: { text: "ada" } }, + }); expect(seen.body.params?.requestState).toBe("opaque"); expect(seen.body.params?.name).toBe("echo"); expect(seen.headers.get("mcp-name")).toBe("echo"); - return rpc(seen.body.id, { resultType: "complete", content: "ada" }); + return rpc(seen.body.id, { + resultType: "complete", + content: [{ type: "text", text: "ada" }], + }); }); try { const first = await mcpCallTool( srv.url, "echo", { text: "hi" }, - { session: modern }, + { session: modern, elicitation: { form: true } }, ); - expect(first).toMatchObject({ resultType: "complete", content: "plain" }); + expect(first).toMatchObject({ + resultType: "complete", + content: [{ type: "text", text: "plain" }], + }); const pending = await mcpCallTool( srv.url, "echo", { text: "hi" }, - { session: modern }, + { session: modern, elicitation: { form: true } }, ); expect(pending.resultType).toBe("input_required"); if (pending.resultType !== "input_required") @@ -483,14 +506,15 @@ describe("MCP 2026-07-28", () => { const continued = await mcpContinue( srv.url, pending, - { a: { text: "ada" } }, + { a: { action: "accept", content: { text: "ada" } } }, { session: modern, + elicitation: { form: true }, }, ); expect(continued).toMatchObject({ resultType: "complete", - content: "ada", + content: [{ type: "text", text: "ada" }], }); } finally { srv.stop(); @@ -502,10 +526,24 @@ describe("MCP 2026-07-28", () => { let round = 0; const srv = jsonServer((seen) => { round += 1; - if (round === 3) return rpc(seen.body.id, { content: "done" }); + if (round === 3) + return rpc(seen.body.id, { + content: [{ type: "text", text: "done" }], + }); return rpc(seen.body.id, { resultType: "input_required", - inputRequests: { answer: { method: "roots/list" } }, + inputRequests: { + answer: { + method: "elicitation/create", + params: { + message: "answer", + requestedSchema: { + type: "object", + properties: { text: { type: "string" } }, + }, + }, + }, + }, ...(round === 1 ? { requestState: "round-one" } : nextState === undefined @@ -514,19 +552,31 @@ describe("MCP 2026-07-28", () => { }); }); try { - const first = await mcpCallTool(srv.url, "echo", { text: "hi" }); + const first = await mcpCallTool( + srv.url, + "echo", + { text: "hi" }, + { elicitation: { form: true } }, + ); if (first.resultType !== "input_required") throw new Error("expected first input"); - const second = await mcpContinue(srv.url, first, { - answer: { roots: [] }, - }); + const second = await mcpContinue( + srv.url, + first, + { + answer: { action: "accept", content: { text: "first" } }, + }, + { elicitation: { form: true } }, + ); if (second.resultType !== "input_required") throw new Error("expected second input"); - const responses = { answer: { roots: [{ uri: "file:///latest" }] } }; + const responses = { + answer: { action: "accept", content: { text: "latest" } }, + }; const final = await mcpContinue(srv.url, second, responses); expect(final).toMatchObject({ resultType: "complete", - content: "done", + content: [{ type: "text", text: "done" }], }); expect(srv.seen[1]?.body.params?.requestState).toBe("round-one"); const last = srv.seen[2]; @@ -639,7 +689,7 @@ describe("MCP 2026-07-28", () => { }); } expect(seen.headers.get("mcp-param-note")).toMatch(/^=\?base64\?.+\?=$/); - return rpc(seen.body.id, { content: "ok" }); + return rpc(seen.body.id, { content: [{ type: "text", text: "ok" }] }); }); try { const tools = await mcpListTools(srv.url, { session: modern }); @@ -653,7 +703,10 @@ describe("MCP 2026-07-28", () => { { note: "héllo" }, { session: modern, inputSchema: good.inputSchema }, ); - expect(result).toMatchObject({ resultType: "complete", content: "ok" }); + expect(result).toMatchObject({ + resultType: "complete", + content: [{ type: "text", text: "ok" }], + }); } finally { console.warn = original; srv.stop(); @@ -676,7 +729,9 @@ describe("MCP 2026-07-28", () => { expect(seen.headers.get("mcp-name")).toBe("greet"); return rpc(seen.body.id, { description: "hi", - messages: [{ role: "user" }], + messages: [ + { role: "user", content: { type: "text", text: "Hello" } }, + ], }); } if (seen.method === "resources/list") { @@ -749,49 +804,76 @@ describe("MCP 2026-07-28", () => { } }); - test("caller abort sends notifications/cancelled and a timeout does not", async () => { - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); - const live = jsonServer(async (seen) => { - if (seen.body.id === undefined) - return new Response(null, { status: 202 }); - await gate; - return rpc(seen.body.id, { content: "late" }); - }); - try { - const ac = new AbortController(); - const pending = mcpCallTool( - live.url, - "echo", - {}, - { session: modern, signal: ac.signal }, + test("modern caller abort and timeout never send notifications/cancelled", async () => { + for (const source of ["caller", "timeout"] as const) { + const caller = new AbortController(); + const deadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue( + deadline.signal, ); - while (live.seen.length === 0) await Bun.sleep(5); - ac.abort(); - release?.(); - const failure = await pending.catch((error: unknown) => error); - expect(failure).toBeInstanceOf(McpError); - expect((failure as McpError).cancelled).toBe(true); - for ( - let i = 0; - i < 20 && - !live.seen.some((s) => s.method === "notifications/cancelled"); - i++ - ) { - await Bun.sleep(10); + const methods: string[] = []; + let observe!: (signal: AbortSignal) => void; + const observed = new Promise((resolve) => { + observe = resolve; + }); + try { + const pending = mcpCallTool( + "https://example.test/mcp", + "echo", + {}, + { + session: modern, + signal: caller.signal, + timeoutMs: 40, + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as RpcBody; + methods.push(String(body.method)); + if (body.method !== "tools/call") + return new Response(null, { status: 202 }); + const signal = init?.signal; + if (signal == null) + throw new Error("request must propagate its abort signal"); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + observe(signal); + }); + }, + }, + ).catch((error: unknown) => error); + // Start the controlled deadline only after fetch has observed the request. + // No socket delivery or wall-clock scheduling assumption is involved. + const signal = await observed; + expect(timeout).toHaveBeenCalledWith(40); + expect(signal.aborted).toBe(false); + if (source === "caller") caller.abort(); + else + deadline.abort( + new DOMException("controlled deadline", "TimeoutError"), + ); + const failure = await pending; + expect(signal.aborted).toBe(true); + expect(failure).toBeInstanceOf(McpError); + expect(failure).toHaveProperty("cancelled", source === "caller"); + expect(failure).toHaveProperty("timedOut", source === "timeout"); + // Records all fetch invocations, including any attempted notification. + expect(methods).toEqual(["tools/call"]); + expect(timeout).toHaveBeenCalledTimes(1); + } finally { + caller.abort(); + deadline.abort(); + timeout.mockRestore(); } - const note = live.seen.find( - (s) => s.method === "notifications/cancelled", - ); - expect(note?.body.params?.reason).toBe("the caller aborted the request"); - expect(note?.headers.get("mcp-method")).toBe("notifications/cancelled"); - } finally { - live.stop(); } + }); - const stalled = jsonServer(() => new Promise(() => undefined)); + test("modern timeout before socket delivery does not require server receipt", async () => { + const stalled = jsonServer((seen) => rpc(seen.body.id, { content: [] })); + const expired = AbortSignal.abort( + new DOMException("controlled deadline", "TimeoutError"), + ); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(expired); try { const failure = await mcpCallTool( stalled.url, @@ -802,10 +884,14 @@ describe("MCP 2026-07-28", () => { timeoutMs: 40, }, ).catch((error: unknown) => error); - expect((failure as McpError).timedOut).toBe(true); - await Bun.sleep(30); - expect(stalled.seen.map((s) => s.method)).toEqual(["tools/call"]); + expect(timeout).toHaveBeenCalledWith(40); + expect(failure).toBeInstanceOf(McpError); + expect(failure).toHaveProperty("timedOut", true); + expect(failure).toHaveProperty("cancelled", false); + // A deadline starts before fetch; an expired signal prevents delivery. + expect(stalled.seen).toEqual([]); } finally { + timeout.mockRestore(); stalled.stop(); } }); @@ -841,7 +927,7 @@ describe("MCP 2026-07-28", () => { `data: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, - result: { content: "done" }, + result: { content: [{ type: "text", text: "done" }] }, })}\n\n`, ), ); @@ -864,7 +950,10 @@ describe("MCP 2026-07-28", () => { }, ); expect(progress).toEqual([1]); - expect(result).toMatchObject({ resultType: "complete", content: "done" }); + expect(result).toMatchObject({ + resultType: "complete", + content: [{ type: "text", text: "done" }], + }); } finally { void server.stop(true); } @@ -885,6 +974,7 @@ describe("MCP 2026-07-28", () => { jsonrpc: "2.0", method: "notifications/subscriptions/acknowledged", params: { + notifications: { toolsListChanged: true }, _meta: { "io.modelcontextprotocol/subscriptionId": body.id }, }, }; @@ -896,6 +986,11 @@ describe("MCP 2026-07-28", () => { `data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed", + params: { + _meta: { + "io.modelcontextprotocol/subscriptionId": body.id, + }, + }, })}\n\n`, ), ); @@ -944,7 +1039,7 @@ describe("MCP 2026-07-28", () => { }); } return rpc(seen.body.id, { - content: "ui", + content: [{ type: "text", text: "ui" }], _meta: { ui: { resourceUri: "ui://app" } }, }); } @@ -956,7 +1051,7 @@ describe("MCP 2026-07-28", () => { createdAt: "2026-07-28T00:00:00Z", lastUpdatedAt: "2026-07-28T00:00:01Z", ttlMs: null, - result: { content: "done" }, + result: { content: [{ type: "text", text: "done" }] }, }); } if (seen.method === "tasks/update" || seen.method === "tasks/cancel") { @@ -967,7 +1062,7 @@ describe("MCP 2026-07-28", () => { skills: [ { uri: "skill://a", - frontmatter: { name: "a" }, + frontmatter: { name: "a", description: "A skill" }, resources: "dynamic", }, ], @@ -977,8 +1072,8 @@ describe("MCP 2026-07-28", () => { return rpc(seen.body.id, { skill: { uri: "skill://a", - frontmatter: { name: "a" }, - resources: [], + frontmatter: { name: "a", description: "A skill" }, + resources: "dynamic", }, }); } @@ -1029,7 +1124,7 @@ describe("MCP 2026-07-28", () => { await mcpUpdateTask( srv.url, "t1", - { a: { text: "ok" } }, + { a: { action: "accept", content: { text: "ok" } } }, { extensions: [MCP_EXT_TASKS] }, ); await mcpCancelTask(srv.url, "t1", { extensions: [MCP_EXT_TASKS] }); @@ -1053,16 +1148,23 @@ describe("MCP 2026-07-28", () => { "text/html;profile=mcp-app", ]); + const skillsSession = { + ...modern, + capabilities: { resources: {}, extensions: { [MCP_EXT_SKILLS]: {} } }, + }; const skills = await mcpListSkills(srv.url, { + session: skillsSession, extensions: [MCP_EXT_SKILLS], }); expect(skills[0]?.resources).toBe("dynamic"); const skill = await mcpGetSkill(srv.url, "skill://a", { + session: skillsSession, extensions: [MCP_EXT_SKILLS], }); expect(skill.skill.uri).toBe("skill://a"); await expect( mcpReadResourceDirectory(srv.url, "skill://a", { + session: skillsSession, extensions: [MCP_EXT_SKILLS], }), ).rejects.toThrow(/directoryRead/); @@ -1070,6 +1172,7 @@ describe("MCP 2026-07-28", () => { protocolVersion: MCP_PROTOCOL_VERSION, extensions: [MCP_EXT_SKILLS], capabilities: { + resources: {}, extensions: { [MCP_EXT_SKILLS]: { directoryRead: true } }, }, }; diff --git a/src/protocol/content.ts b/src/protocol/content.ts new file mode 100644 index 0000000..27a2c67 --- /dev/null +++ b/src/protocol/content.ts @@ -0,0 +1,125 @@ +import { type } from "arktype"; +import { McpError, McpErrorCode } from "./errors.js"; +import { assertJsonBounds } from "./json-schema.js"; +import type { McpResourceContents, MetaObject } from "./types.js"; +export type ContentBlock = + | { + type: "text"; + text: string; + annotations?: ContentAnnotations; + _meta?: MetaObject; + } + | { + type: "image" | "audio"; + data: string; + mimeType: string; + annotations?: ContentAnnotations; + _meta?: MetaObject; + } + | { + type: "resource"; + resource: McpResourceContents; + annotations?: ContentAnnotations; + _meta?: MetaObject; + } + | { + type: "resource_link"; + uri: string; + name: string; + title?: string; + description?: string; + mimeType?: string; + size?: number; + annotations?: ContentAnnotations; + _meta?: MetaObject; + }; +export interface ContentAnnotations { + audience?: ("user" | "assistant")[]; + priority?: number; + lastModified?: string; +} +const AnnotationsSchema = type({ + "audience?": "('user' | 'assistant')[]", + "priority?": "number", + "lastModified?": "string", +}); +const ResourceContentsSchema = type({ + uri: "string", + "mimeType?": "string", + "text?": "string", + "blob?": "string", + "_meta?": "Record", +}); +export function validateResourceContents(value: unknown): McpResourceContents { + const parsed = ResourceContentsSchema(value); + if ( + parsed instanceof type.errors || + (typeof parsed.text !== "string" && typeof parsed.blob !== "string") || + (parsed.text !== undefined && parsed.blob !== undefined) + ) + throw new McpError( + "invalid resource contents", + undefined, + McpErrorCode.InvalidParams, + ); + return parsed as McpResourceContents; +} +export function validateContentBlock(value: unknown): ContentBlock { + assertJsonBounds(value); + const base = type({ + type: "string", + "annotations?": AnnotationsSchema, + "_meta?": "Record", + })(value); + if (base instanceof type.errors) + throw new McpError( + "invalid content block", + undefined, + McpErrorCode.InvalidParams, + ); + if ( + base.annotations?.priority !== undefined && + (base.annotations.priority < 0 || base.annotations.priority > 1) + ) + throw new McpError( + "invalid content priority", + undefined, + McpErrorCode.InvalidParams, + ); + let parsed: unknown; + switch (base.type) { + case "text": + parsed = type({ text: "string" })(value); + break; + case "image": + case "audio": + parsed = type({ data: "string", mimeType: "string" })(value); + break; + case "resource": + validateResourceContents((value as { resource?: unknown }).resource); + return value as ContentBlock; + case "resource_link": + parsed = type({ + uri: "string", + name: "string", + "title?": "string", + "description?": "string", + "mimeType?": "string", + "size?": "number", + })(value); + break; + default: + throw new McpError( + "unknown content block type", + undefined, + McpErrorCode.InvalidParams, + ); + } + if (parsed instanceof type.errors) + throw new McpError( + `invalid ${base.type} content block`, + undefined, + McpErrorCode.InvalidParams, + ); + return value as ContentBlock; +} diff --git a/src/protocol/envelopes.ts b/src/protocol/envelopes.ts new file mode 100644 index 0000000..bf9dbe6 --- /dev/null +++ b/src/protocol/envelopes.ts @@ -0,0 +1,48 @@ +import { type } from "arktype"; +import { McpError, McpErrorCode } from "./errors.js"; +import { assertJsonBounds } from "./json-schema.js"; +import type { RequestId } from "./types.js"; +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: RequestId; + method: string; + params?: Record; +} +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: Record; +} +const EnvelopeSchema = type({ + jsonrpc: "'2.0'", + method: "string", + "params?": "Record", + "id?": "string | number", +}); +export function validateJsonRpcRequest(value: unknown): JsonRpcRequest; +export function validateJsonRpcRequest( + value: unknown, + allowNotification: true, +): JsonRpcRequest | JsonRpcNotification; +export function validateJsonRpcRequest( + value: unknown, + allowNotification = false, +): JsonRpcRequest | JsonRpcNotification { + assertJsonBounds(value); + const parsed = EnvelopeSchema(value); + if ( + parsed instanceof type.errors || + Array.isArray(parsed.params) || + Object.hasOwn(parsed, "result") || + Object.hasOwn(parsed, "error") || + (!allowNotification && !Object.hasOwn(parsed, "id")) || + (typeof parsed.id === "number" && !Number.isSafeInteger(parsed.id)) + ) { + throw new McpError( + "invalid JSON-RPC request envelope", + 400, + McpErrorCode.InvalidRequest, + ); + } + return parsed as JsonRpcRequest | JsonRpcNotification; +} diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts new file mode 100644 index 0000000..8b6d84b --- /dev/null +++ b/src/protocol/errors.ts @@ -0,0 +1,43 @@ +export const McpErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + HeaderMismatch: -32020, + MissingRequiredClientCapability: -32021, + UnsupportedProtocolVersion: -32022, +} as const; + +export class McpError extends Error { + readonly status: number | undefined; + readonly code: number | undefined; + readonly data: unknown; + readonly timedOut: boolean; + readonly cancelled: boolean; + readonly wwwAuthenticate: string | undefined; + /** Peer RPC errors are distinct from local validation/transport failures. */ + readonly source: "local" | "peer"; + constructor( + message: string, + status?: number, + code?: number, + data?: unknown, + flags?: { + timedOut?: boolean; + cancelled?: boolean; + wwwAuthenticate?: string; + source?: "local" | "peer"; + }, + ) { + super(message); + this.name = "McpError"; + this.status = status; + this.code = code; + this.data = data; + this.timedOut = flags?.timedOut === true; + this.cancelled = flags?.cancelled === true; + this.wwwAuthenticate = flags?.wwwAuthenticate; + this.source = flags?.source ?? "local"; + } +} diff --git a/src/protocol/extensions.ts b/src/protocol/extensions.ts new file mode 100644 index 0000000..18c81a5 --- /dev/null +++ b/src/protocol/extensions.ts @@ -0,0 +1,98 @@ +import { McpError } from "./errors.js"; +import type { McpValidationContext, McpExtensionSettings } from "./types.js"; +export const MCP_EXT_TASKS = "io.modelcontextprotocol/tasks"; +export const MCP_EXT_SKILLS = "io.modelcontextprotocol/skills"; +export const MCP_EXT_UI = "io.modelcontextprotocol/ui"; +export const MCP_EXTENSIONS = { + [MCP_EXT_TASKS]: { + id: MCP_EXT_TASKS, + coreRevision: "2026-07-28", + extensionRevision: "2026-07-28", + sourceCommit: "b3d28aceaa6d49f5f92258e3d06b61ad6f111e84", + maturity: "stable", + enabledByDefault: false, + }, + [MCP_EXT_SKILLS]: { + id: MCP_EXT_SKILLS, + coreRevision: "2026-07-28", + extensionRevision: "stable", + sourceCommit: "c1aa87e4c381c82b99aa37471d0eae4ab50f9985", + maturity: "stable", + enabledByDefault: false, + }, + [MCP_EXT_UI]: { + id: MCP_EXT_UI, + coreRevision: "2026-07-28", + extensionRevision: "2026-01-26", + sourceCommit: "266a9e3b70dbd4a4f0546dda95da2a2c1a8be4b7", + maturity: "released", + enabledByDefault: false, + }, +} as const; +export function extensionEnabled( + context: McpValidationContext, + id: string, +): boolean { + const extensions = context.extensions; + if (extensions === undefined) return false; + return Array.isArray(extensions) + ? extensions.includes(id) + : Object.hasOwn(extensions, id); +} +export function validateExtensionSettings( + settings: McpExtensionSettings, +): McpExtensionSettings { + for (const [id, value] of Object.entries(settings)) { + if (!Object.hasOwn(MCP_EXTENSIONS, id)) + throw new McpError(`unsupported extension ${id}`); + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new McpError(`invalid settings for ${id}`); + const allowed = + id === MCP_EXT_SKILLS + ? ["directoryRead"] + : id === MCP_EXT_UI + ? ["mimeTypes"] + : []; + if (Object.keys(value).some((key) => !allowed.includes(key))) + throw new McpError(`unsupported settings for ${id}`); + if ( + value.directoryRead !== undefined && + typeof value.directoryRead !== "boolean" + ) + throw new McpError("directoryRead must be boolean"); + if ( + value.mimeTypes !== undefined && + (!Array.isArray(value.mimeTypes) || + !value.mimeTypes.every((v) => typeof v === "string")) + ) + throw new McpError("mimeTypes must be strings"); + } + return settings; +} +/** Negotiate only explicitly requested, known extensions advertised by the peer. */ +export function negotiateExtensions( + requested: McpExtensionSettings, + peer: McpExtensionSettings, +): McpExtensionSettings { + validateExtensionSettings(requested); + const result: Record>> = {}; + for (const [id, settings] of Object.entries(requested)) { + if (!Object.hasOwn(peer, id)) continue; + const remote = peer[id]; + if (remote === undefined) continue; + validateExtensionSettings({ [id]: remote }); + if (id === MCP_EXT_UI) { + const localTypes = settings.mimeTypes as readonly string[] | undefined; + const remoteTypes = remote.mimeTypes as readonly string[] | undefined; + result[id] = { + mimeTypes: (localTypes ?? ["text/html;profile=mcp-app"]).filter( + (mime) => + (remoteTypes ?? ["text/html;profile=mcp-app"]).includes(mime), + ), + }; + } else if (id === MCP_EXT_SKILLS) + result[id] = { directoryRead: remote.directoryRead === true }; + else result[id] = {}; + } + return result; +} diff --git a/src/protocol/headers-codec.test.ts b/src/protocol/headers-codec.test.ts new file mode 100644 index 0000000..0adb330 --- /dev/null +++ b/src/protocol/headers-codec.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { McpError, McpErrorCode } from "./errors.js"; +import { + collectHeaderSlots, + decodeHeaderValue, + encodeHeaderValue, +} from "./headers.js"; + +describe("MCP header value codec", () => { + test.each([ + "", + "plain ASCII ~!", + "internal space\tallowed", + "=?base64?literal", + "=?base64?", + "=?base64?YQ==", + "literal?=", + "x=?base64?YQ==?=", + "=?BASE64?YQ==?=", + "=?base64?YQ==?=suffix", + ])("preserves plain or incomplete sentinel value %j", (value) => { + expect(encodeHeaderValue(value)).toBe(value); + expect(decodeHeaderValue(value)).toBe(value); + expect(decodeHeaderValue(encodeHeaderValue(value))).toBe(value); + }); + + test.each([ + "=?base64?literal?=", + "=?base64?YQ==?=", + "=?base64??=", + "=?base64?%%%?=", + "Hello, 世界", + "café", + "\u{1D11E}", + " leading", + "trailing ", + "\tleading", + "trailing\t", + "line1\r\nInjected: yes", + "nul\0value", + "delete\x7f", + ])("encodes unsafe or complete sentinel value %j exactly once", (value) => { + const encoded = encodeHeaderValue(value); + expect(encoded).toBe(`=?base64?${Buffer.from(value).toString("base64")}?=`); + expect(decodeHeaderValue(encoded)).toBe(value); + expect(new Headers({ "Mcp-Name": encoded }).get("Mcp-Name")).toBe(encoded); + expect(encoded).not.toMatch(/[\r\n\0]/); + }); + + test("decodes normative UTF-8 and empty payload examples", () => { + expect(decodeHeaderValue("=?base64?SGVsbG8sIOS4lueVjA==?=")).toBe( + "Hello, 世界", + ); + expect(decodeHeaderValue("=?base64??=")).toBe(""); + }); + + test.each([ + "%%%", + "YQ", + "YQ=", + "YQ===", + "YR==", + "A", + "Y Q==", + "YQ==\r\nInjected: yes", + "_w==", + "/w==", + "wK8=", + "7aCA", + "4oI=", + ])("rejects malformed base64 or UTF-8 payload %j", (payload) => { + expect(() => decodeHeaderValue(`=?base64?${payload}?=`)).toThrow(McpError); + try { + decodeHeaderValue(`=?base64?${payload}?=`); + } catch (error) { + expect(error).toMatchObject({ + status: 400, + code: McpErrorCode.HeaderMismatch, + }); + } + }); + + test("encodes every ASCII control except internal horizontal tab", () => { + for (let code = 0; code <= 0x7f; code++) { + const value = `a${String.fromCharCode(code)}z`; + const encoded = encodeHeaderValue(value); + expect(decodeHeaderValue(encoded)).toBe(value); + if ((code < 0x20 && code !== 0x09) || code === 0x7f) { + expect(encoded.startsWith("=?base64?")).toBe(true); + } else { + expect(encoded).toBe(value); + } + } + }); + + test.each(["Region\r\nInjected", "Region: injected", "", "Region\0"])( + "rejects unsafe header annotation %j", + (header) => { + expect(() => + collectHeaderSlots({ + properties: { region: { type: "string", "x-mcp-header": header } }, + }), + ).toThrow(McpError); + }, + ); +}); diff --git a/src/protocol/headers.ts b/src/protocol/headers.ts new file mode 100644 index 0000000..034b657 --- /dev/null +++ b/src/protocol/headers.ts @@ -0,0 +1,177 @@ +import { McpError, McpErrorCode } from "./errors.js"; +import { mcpNameFor } from "./methods.js"; +import { MCP_PROTOCOL_VERSION, type McpValidationContext } from "./types.js"; +export function encodeHeaderValue(value: string): string { + const plain = + [...value].every( + (character) => + character === "\t" || + (character.charCodeAt(0) >= 0x20 && character.charCodeAt(0) <= 0x7e), + ) && + !/^[ \t]|[ \t]$/.test(value) && + !(value.startsWith("=?base64?") && value.endsWith("?=")); + return plain + ? value + : `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`; +} +export function decodeHeaderValue(value: string): string { + if (!(value.startsWith("=?base64?") && value.endsWith("?="))) return value; + const match = /^=\?base64\?([A-Za-z0-9+/]*={0,2})\?=$/.exec(value); + if (match?.[1] === undefined) + throw new McpError( + "invalid encoded MCP header", + 400, + McpErrorCode.HeaderMismatch, + ); + const bytes = Buffer.from(match[1], "base64"); + if (bytes.toString("base64") !== match[1]) + throw new McpError( + "invalid base64 MCP header", + 400, + McpErrorCode.HeaderMismatch, + ); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new McpError( + "invalid UTF-8 MCP header", + 400, + McpErrorCode.HeaderMismatch, + ); + } +} +export type HeaderSlot = { + path: string[]; + header: string; + type: "string" | "integer" | "boolean"; +}; +/** Throws for annotations that cannot be statically resolved through properties. */ +export function collectHeaderSlots( + schema: Record, +): HeaderSlot[] { + const slots: HeaderSlot[] = []; + const seen = new Set(); + let nodes = 0; + const walk = ( + node: unknown, + path: string[], + reachable: boolean, + depth: number, + ): void => { + if (++nodes > 10_000 || depth > 64) + throw new McpError("header schema complexity limit exceeded"); + if (typeof node !== "object" || node === null) return; + if (Array.isArray(node)) { + for (const item of node) walk(item, path, false, depth + 1); + return; + } + const record = node as Record; + if (Object.hasOwn(record, "x-mcp-header")) { + const header = record["x-mcp-header"]; + const kind = record.type; + if ( + !reachable || + path.length === 0 || + typeof header !== "string" || + !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(header) || + (kind !== "string" && kind !== "integer" && kind !== "boolean") + ) + throw new McpError("invalid x-mcp-header annotation"); + if (seen.has(header.toLowerCase())) + throw new McpError("duplicate x-mcp-header annotation"); + seen.add(header.toLowerCase()); + slots.push({ path, header, type: kind }); + } + for (const [key, child] of Object.entries(record)) { + if ( + key === "properties" && + typeof child === "object" && + child !== null && + !Array.isArray(child) + ) { + for (const [name, value] of Object.entries(child)) + walk(value, [...path, name], reachable, depth + 1); + } else if (key !== "x-mcp-header") walk(child, path, false, depth + 1); + } + }; + walk(schema, [], true, 0); + return slots; +} +export function paramHeaders( + schema: Record | undefined, + args: unknown, + _toolName?: string, +): Record { + if (schema === undefined) return {}; + const headers: Record = {}; + for (const slot of collectHeaderSlots(schema)) { + let value = args; + for (const key of slot.path) + value = + typeof value === "object" && value !== null && Object.hasOwn(value, key) + ? (value as Record)[key] + : undefined; + if (value === undefined || value === null) continue; + if ( + slot.type === "integer" + ? typeof value !== "number" || !Number.isSafeInteger(value) + : typeof value !== slot.type + ) + throw new McpError(`invalid header argument ${slot.path.join(".")}`); + headers[`Mcp-Param-${slot.header}`] = encodeHeaderValue(String(value)); + } + return headers; +} +export function validateRequestHeaders( + headers: Headers, + method: string, + params: Record, + context: McpValidationContext = {}, +): void { + if (context.era === "legacy") return; + const meta = params._meta as Record | undefined; + const version = meta?.["io.modelcontextprotocol/protocolVersion"]; + const fail = (header: string): never => { + throw new McpError( + `MCP header mismatch: ${header}`, + 400, + McpErrorCode.HeaderMismatch, + ); + }; + if ( + typeof version !== "string" || + headers.get("MCP-Protocol-Version") !== version + ) + fail("MCP-Protocol-Version"); + if (version !== (context.protocolVersion ?? MCP_PROTOCOL_VERSION)) + throw new McpError( + "unsupported protocol version", + 400, + McpErrorCode.UnsupportedProtocolVersion, + { requested: version, supported: [MCP_PROTOCOL_VERSION] }, + ); + if (headers.get("Mcp-Method") !== method) fail("Mcp-Method"); + const name = mcpNameFor(method, params); + const actual = headers.get("Mcp-Name"); + if ( + name !== undefined && + (actual === null || decodeHeaderValue(actual) !== name) + ) + fail("Mcp-Name"); + if (name === undefined && actual !== null) fail("Mcp-Name"); + const expected = paramHeaders(context.inputSchema, params.arguments); + for (const [key, value] of Object.entries(expected)) { + const received = headers.get(key); + if ( + received === null || + decodeHeaderValue(received) !== decodeHeaderValue(value) + ) + fail(key); + } + for (const key of headers.keys()) + if ( + key.startsWith("mcp-param-") && + !Object.keys(expected).some((name) => name.toLowerCase() === key) + ) + fail(key); +} diff --git a/src/protocol/index.ts b/src/protocol/index.ts new file mode 100644 index 0000000..0a98447 --- /dev/null +++ b/src/protocol/index.ts @@ -0,0 +1,11 @@ +export * from "./types.js"; +export * from "./errors.js"; +export * from "./extensions.js"; +export * from "./methods.js"; +export * from "./headers.js"; +export * from "./json-schema.js"; +export * from "./schemas.js"; +export * from "./envelopes.js"; +export * from "./ui.js"; +export * from "./content.js"; +export * from "./legacy.js"; diff --git a/src/protocol/json-schema.ts b/src/protocol/json-schema.ts new file mode 100644 index 0000000..dcdf2d2 --- /dev/null +++ b/src/protocol/json-schema.ts @@ -0,0 +1,180 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { Ajv } from "ajv"; +import type { ValidateFunction } from "ajv"; +import { McpError, McpErrorCode } from "./errors.js"; +import type { JsonSchema } from "./types.js"; + +const options = { + strict: false, + allErrors: false, + validateFormats: false, + addUsedSchema: false, + ownProperties: true, + code: { optimize: false }, + loopRequired: 100, + loopEnum: 100, +} as const; +const modern = new Ajv2020(options); +const draft7 = new Ajv(options); +const cache = new Map(); +/** Limits apply before AJV compilation and before data validation. No network resolver is installed. */ +export function assertJsonBounds(value: unknown, schema = false): void { + assertBounds(value, schema); +} + +/** Resource payloads alone get an explicit allowance; metadata and JSON structure + * remain bounded. */ +export function assertResourceJsonBounds( + value: unknown, + maxDecodedBytes: number, +): void { + if (!Number.isSafeInteger(maxDecodedBytes) || maxDecodedBytes < 0) + throw new McpError("maxDecodedBytes must be a nonnegative safe integer"); + assertBounds(value, false, Math.min(maxDecodedBytes, 16 * 1024 * 1024)); +} + +function assertBounds( + value: unknown, + schema: boolean, + resourceBytes?: number, +): void { + let nodes = 0; + let bytes = 0; + let decodedBytes = 0; + const active = new Set(); + const visit = (item: unknown, depth: number, path: string[]): void => { + if (++nodes > (schema ? 10_000 : 100_000) || depth > 64) + throw new McpError("JSON complexity limit exceeded"); + if (typeof item === "string") { + const payloadPath = path; + const payload = + resourceBytes !== undefined && + payloadPath.length === 3 && + payloadPath[0] === "contents" && + payloadPath[1] === "*" && + (payloadPath[2] === "text" || payloadPath[2] === "blob"); + if (payload) { + decodedBytes += + payloadPath[2] === "text" + ? Buffer.byteLength(item) + : Math.max( + 0, + Math.floor((item.length * 3) / 4) - + (item.endsWith("==") ? 2 : item.endsWith("=") ? 1 : 0), + ); + if (decodedBytes > resourceBytes!) + throw new McpError("Resource decoded byte limit exceeded"); + bytes += 2; // JSON string delimiters, not payload encoding + } else { + bytes += Buffer.byteLength(item); + if (resourceBytes !== undefined) { + if (bytes > 64 * 1024) + throw new McpError( + "Resource metadata/envelope byte limit exceeded", + ); + bytes += + Buffer.byteLength(JSON.stringify(item)) - Buffer.byteLength(item); + } + } + } else if (typeof item === "number" && !Number.isFinite(item)) + throw new McpError("JSON numbers must be finite"); + else if (typeof item === "object" && item !== null) { + if (active.has(item)) throw new McpError("cyclic JSON value"); + active.add(item); + if (resourceBytes !== undefined) bytes += 2; // braces/brackets + let first = true; + for (const [key, child] of Object.entries(item)) { + if (resourceBytes === undefined) bytes += Buffer.byteLength(key); + else { + if (!first) bytes++; + if (!Array.isArray(item)) { + if (Buffer.byteLength(key) > 64 * 1024) + throw new McpError( + "Resource metadata/envelope byte limit exceeded", + ); + bytes += Buffer.byteLength(JSON.stringify(key)) + 1; + } + } + first = false; + visit(child, depth + 1, [...path, Array.isArray(item) ? "*" : key]); + } + active.delete(item); + } else if ( + item !== null && + typeof item !== "boolean" && + typeof item !== "number" + ) + throw new McpError("non-JSON value"); + if (resourceBytes !== undefined) { + if ( + item === null || + typeof item === "boolean" || + typeof item === "number" + ) + bytes += String(item).length; + if (bytes > 64 * 1024) + throw new McpError("Resource metadata/envelope byte limit exceeded"); + } + if ( + resourceBytes === undefined && + bytes > (schema ? 256 * 1024 : 4 * 1024 * 1024) + ) + throw new McpError("JSON byte limit exceeded"); + }; + visit(value, 0, []); +} +function compile(schema: unknown): ValidateFunction { + assertJsonBounds(schema, true); + if ( + typeof schema !== "boolean" && + (typeof schema !== "object" || schema === null || Array.isArray(schema)) + ) + throw new McpError("JSON Schema must be an object or boolean"); + const key = JSON.stringify(schema); + const existing = cache.get(key); + if (existing !== undefined) return existing; + const dialect = + typeof schema === "object" && schema !== null + ? (schema as Record).$schema + : undefined; + const validator = + dialect === undefined || + dialect === "https://json-schema.org/draft/2020-12/schema" + ? modern + : dialect === "http://json-schema.org/draft-07/schema#" || + dialect === "http://json-schema.org/draft-07/schema" + ? draft7 + : undefined; + if (validator === undefined) + throw new McpError(`unsupported JSON Schema dialect ${String(dialect)}`); + try { + // Compile an immutable snapshot: caller mutation cannot change a cached validator. + const result = validator.compile(JSON.parse(key) as JsonSchema); + if (cache.size >= 128) { + const first = cache.keys().next().value; + if (first !== undefined) cache.delete(first); + } + cache.set(key, result); + return result; + } catch (cause) { + throw new McpError( + `invalid JSON Schema: ${cause instanceof Error ? cause.message : String(cause)}`, + undefined, + McpErrorCode.InvalidParams, + ); + } +} +export function validateSchema(schema: unknown): JsonSchema { + compile(schema); + return schema as JsonSchema; +} +export function validateInstance(schema: unknown, value: unknown): void { + assertJsonBounds(value); + const validator = compile(schema); + if (!validator(value)) + throw new McpError( + `JSON Schema instance validation failed: ${modern.errorsText(validator.errors)}`, + undefined, + McpErrorCode.InvalidParams, + ); +} diff --git a/src/protocol/legacy.test.ts b/src/protocol/legacy.test.ts new file mode 100644 index 0000000..112f98c --- /dev/null +++ b/src/protocol/legacy.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { validateLegacyInitialize } from "./legacy.js"; + +test("legacy initialization validates lifecycle fields and retains unknown capability metadata", () => { + const valid = { + protocolVersion: "2025-06-18", + capabilities: { experimental: { vendor: {} } }, + clientInfo: { name: "client", version: "1" }, + }; + expect(validateLegacyInitialize(valid)).toEqual(valid); + for (const invalid of [ + {}, + { ...valid, protocolVersion: 12 }, + { ...valid, capabilities: [] }, + { ...valid, capabilities: null }, + { ...valid, clientInfo: { name: "client" } }, + ]) + expect(() => validateLegacyInitialize(invalid)).toThrow(); +}); diff --git a/src/protocol/legacy.ts b/src/protocol/legacy.ts new file mode 100644 index 0000000..7817ace --- /dev/null +++ b/src/protocol/legacy.ts @@ -0,0 +1,25 @@ +import { type } from "arktype"; +import { McpError, McpErrorCode } from "./errors.js"; +import { assertJsonBounds } from "./json-schema.js"; + +/** Lifecycle request shared by the three supported Streamable HTTP revisions. */ +const Initialize = type({ + protocolVersion: "string > 0", + capabilities: "Record", + clientInfo: { name: "string", version: "string" }, + "_meta?": "Record", +}); +export function validateLegacyInitialize( + value: unknown, +): typeof Initialize.infer { + assertJsonBounds(value); + const params = Initialize(value); + if (params instanceof type.errors || Array.isArray(params.capabilities)) { + throw new McpError( + "Invalid initialize parameters", + 400, + McpErrorCode.InvalidParams, + ); + } + return params; +} diff --git a/src/protocol/methods.ts b/src/protocol/methods.ts new file mode 100644 index 0000000..7c911d0 --- /dev/null +++ b/src/protocol/methods.ts @@ -0,0 +1,92 @@ +import { MCP_EXT_SKILLS, MCP_EXT_TASKS } from "./extensions.js"; +import type { McpMethod } from "./types.js"; +export interface McpMethodDescriptor { + capability?: string; + extension?: string; + cacheable: boolean; + inputRequired: boolean; + task: boolean; + nameSource?: "name" | "uri" | "taskId"; +} +const ordinary = { + cacheable: false, + inputRequired: false, + task: false, +} as const; +export const MCP_METHODS = { + "server/discover": { ...ordinary, cacheable: true }, + "tools/list": { ...ordinary, capability: "tools", cacheable: true }, + "tools/call": { + ...ordinary, + capability: "tools", + inputRequired: true, + task: true, + nameSource: "name", + }, + "prompts/list": { ...ordinary, capability: "prompts", cacheable: true }, + "prompts/get": { + ...ordinary, + capability: "prompts", + inputRequired: true, + nameSource: "name", + }, + "resources/list": { ...ordinary, capability: "resources", cacheable: true }, + "resources/templates/list": { + ...ordinary, + capability: "resources", + cacheable: true, + }, + "resources/read": { + ...ordinary, + capability: "resources", + cacheable: true, + inputRequired: true, + nameSource: "uri", + }, + "completion/complete": { ...ordinary, capability: "completions" }, + "subscriptions/listen": { ...ordinary, capability: "subscriptions" }, + "tasks/get": { ...ordinary, extension: MCP_EXT_TASKS, nameSource: "taskId" }, + "tasks/update": { + ...ordinary, + extension: MCP_EXT_TASKS, + nameSource: "taskId", + }, + "tasks/cancel": { + ...ordinary, + extension: MCP_EXT_TASKS, + nameSource: "taskId", + }, + "skills/list": { + ...ordinary, + extension: MCP_EXT_SKILLS, + capability: "resources", + cacheable: true, + }, + "skills/get": { + ...ordinary, + extension: MCP_EXT_SKILLS, + capability: "resources", + cacheable: true, + }, + "resources/directory/read": { + ...ordinary, + extension: MCP_EXT_SKILLS, + capability: "resources", + cacheable: true, + }, +} as const satisfies Record; +export function methodDescriptor( + method: string, +): McpMethodDescriptor | undefined { + return Object.hasOwn(MCP_METHODS, method) + ? MCP_METHODS[method as McpMethod] + : undefined; +} +export function mcpNameFor( + method: string, + params?: Record, +): string | undefined { + const source = methodDescriptor(method)?.nameSource; + const value = source === undefined ? undefined : params?.[source]; + return typeof value === "string" ? value : undefined; +} diff --git a/src/protocol/protocol.test.ts b/src/protocol/protocol.test.ts new file mode 100644 index 0000000..f939eed --- /dev/null +++ b/src/protocol/protocol.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, test } from "bun:test"; +import { + MCP_EXT_SKILLS, + MCP_EXT_TASKS, + MCP_EXT_UI, + MCP_PROTOCOL_VERSION, + McpError, + McpErrorCode, + encodeHeaderValue, + decodeHeaderValue, + validateRequestHeaders, + validateRequest, + validateResult, + validateNotification, + validateSchema, + validateInstance, + validateSkill, + negotiateExtensions, + collectHeaderSlots, +} from "./index"; +const metadata = { + "io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {}, +}; +const task = { + taskId: "task", + status: "working", + createdAt: "2026-07-28T00:00:00Z", + lastUpdatedAt: "2026-07-28T00:00:00Z", + ttlMs: null, +}; +const tasks = { extensions: [MCP_EXT_TASKS] }; +describe("shared normative contracts", () => { + test("MRTR and tasks are eligible only on pinned methods", () => { + for (const method of ["tools/call", "prompts/get", "resources/read"]) + expect( + validateResult(method, { + resultType: "input_required", + requestState: "", + }), + ).toHaveProperty("requestState", ""); + for (const method of [ + "tools/list", + "completion/complete", + "subscriptions/listen", + ]) + expect(() => + validateResult(method, { + resultType: "input_required", + requestState: "s", + }), + ).toThrow(McpError); + expect( + validateResult("tools/call", { ...task, resultType: "task" }, tasks), + ).toHaveProperty("taskId", "task"); + for (const method of ["prompts/get", "resources/read", "tasks/get"]) + expect(() => + validateResult(method, { ...task, resultType: "task" }, tasks), + ).toThrow(McpError); + expect(() => + validateResult( + "tools/call", + { ...task, resultType: "task" }, + { ...tasks, era: "legacy" }, + ), + ).toThrow(McpError); + expect(() => + validateResult("subscriptions/listen", { resultType: "future" }), + ).toThrow(McpError); + }); + test("tasks require status payloads and bounded integer durations", () => { + expect( + validateResult("tasks/get", { ...task, resultType: "complete" }, tasks), + ).toHaveProperty("ttlMs", null); + for (const status of ["input_required", "completed", "failed"]) + expect(() => + validateResult("tasks/get", { ...task, status }, tasks), + ).toThrow(McpError); + for (const ttlMs of [-1, 0.1, Infinity]) + expect(() => + validateResult( + "tools/call", + { ...task, ttlMs, resultType: "task" }, + tasks, + ), + ).toThrow(McpError); + expect(() => + validateResult( + "tasks/get", + { + ...task, + status: "completed", + result: { resultType: "task", ...task }, + }, + tasks, + ), + ).toThrow(McpError); + }); + test("form and URL elicitation validate, unsupported deprecated inputs fail safely", () => { + const form = { + method: "elicitation/create", + params: { + message: "Name", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }; + const url = { + method: "elicitation/create", + params: { + mode: "url", + message: "Sign in", + url: "https://example.test/login", + }, + }; + for (const input of [form, url]) + expect( + validateResult("tools/call", { + resultType: "input_required", + inputRequests: { key: input }, + }), + ).toHaveProperty("inputRequests.key", input); + expect(() => + validateResult("tools/call", { + resultType: "input_required", + inputRequests: { key: { method: "roots/list" } }, + }), + ).toThrow(McpError); + expect(() => + validateResult( + "tools/call", + { resultType: "input_required", inputRequests: { key: form } }, + { clientCapabilities: {} }, + ), + ).toThrow("capability"); + expect(() => + validateRequest( + "tasks/update", + { + taskId: "t", + inputResponses: { key: { action: "yes" } }, + _meta: metadata, + }, + tasks, + ), + ).toThrow("action"); + }); + test("wire validation preserves extension metadata and rejects nonobjects", () => { + const result = { + content: [], + _meta: { "example/trace": { arbitrary: true } }, + }; + expect(validateResult("tools/call", result)).toBe(result); + for (const result of [null, [], 1, "ok"]) + expect(() => validateResult("tools/call", result)).toThrow(McpError); + expect(() => + validateNotification("notifications/progress", { + progressToken: 1, + progress: Infinity, + }), + ).toThrow(McpError); + }); + test("all task methods use taskId and encoded headers round trip", () => { + for (const name of [ + "", + "plain", + " 日本語 ", + "=?base64?abc?=", + "line\nbreak", + ]) + expect(decodeHeaderValue(encodeHeaderValue(name))).toBe(name); + for (const method of ["tasks/get", "tasks/update", "tasks/cancel"]) { + const params = { taskId: "日本語", _meta: metadata }; + const headers = new Headers({ + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + "Mcp-Method": method, + "Mcp-Name": encodeHeaderValue(params.taskId), + }); + expect(() => + validateRequestHeaders(headers, method, params, tasks), + ).not.toThrow(); + headers.set("Mcp-Name", "other"); + try { + validateRequestHeaders(headers, method, params, tasks); + throw new Error("expected mismatch"); + } catch (error) { + expect((error as McpError).code).toBe(McpErrorCode.HeaderMismatch); + } + } + }); + test("header annotations reject ambiguous or unreachable mappings", () => { + expect(() => + collectHeaderSlots({ + type: "object", + anyOf: [ + { + properties: { value: { type: "string", "x-mcp-header": "Value" } }, + }, + ], + }), + ).toThrow(McpError); + expect(() => + collectHeaderSlots({ + properties: { + a: { type: "string", "x-mcp-header": "X" }, + b: { type: "string", "x-mcp-header": "x" }, + }, + }), + ).toThrow(McpError); + }); + test("extension negotiation is explicit and settings are validated", () => { + expect(negotiateExtensions({}, { [MCP_EXT_TASKS]: {} })).toEqual({}); + expect( + negotiateExtensions( + { + [MCP_EXT_SKILLS]: {}, + [MCP_EXT_UI]: { mimeTypes: ["text/html;profile=mcp-app"] }, + }, + { + [MCP_EXT_SKILLS]: { directoryRead: true }, + [MCP_EXT_UI]: { mimeTypes: ["text/html;profile=mcp-app"] }, + }, + ), + ).toEqual({ + [MCP_EXT_SKILLS]: { directoryRead: true }, + [MCP_EXT_UI]: { mimeTypes: ["text/html;profile=mcp-app"] }, + }); + expect(() => negotiateExtensions({ "example/invented": {} }, {})).toThrow( + "unsupported extension", + ); + expect(() => + negotiateExtensions({ [MCP_EXT_TASKS]: { invented: true } }, {}), + ).toThrow("unsupported settings"); + }); + test("skill manifests retain frontmatter and require a valid complete digest list", () => { + const uri = "skill://test/SKILL.md"; + const skill = { + uri, + frontmatter: { name: "test", description: "Test", custom: "retained" }, + resources: [{ uri, digest: `sha256:${"a".repeat(64)}`, size: 12 }], + }; + expect(validateSkill(skill)).toBe(skill); + expect(() => validateSkill({ ...skill, resources: [] })).toThrow("omits"); + expect(() => + validateSkill({ + ...skill, + resources: [{ uri, digest: "sha256:bad", size: 12 }], + }), + ).toThrow("digest"); + }); +}); +describe("bounded JSON Schema validation", () => { + test("2020-12 keywords, boolean schemas, local refs, and draft-07 are validated", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ type: "integer" }], + items: false, + "x-custom": { retained: true }, + }; + expect(validateSchema(schema)).toBe(schema); + expect(() => validateInstance(schema, [1])).not.toThrow(); + expect(() => validateInstance(schema, ["bad"])).toThrow(McpError); + expect(() => validateInstance(false, null)).toThrow(McpError); + expect(() => + validateInstance( + { $defs: { n: { type: "number" } }, $ref: "#/$defs/n" }, + 1, + ), + ).not.toThrow(); + expect(() => + validateInstance( + { $schema: "http://json-schema.org/draft-07/schema#", type: "number" }, + "no", + ), + ).toThrow(McpError); + }); + test("schema validity is separate, no network refs, unknown dialects fail", () => { + expect(() => validateSchema({ type: "nonsense" })).toThrow(McpError); + expect(() => + validateSchema({ $schema: "https://example.test/schema" }), + ).toThrow("dialect"); + expect(() => + validateSchema({ $ref: "https://example.test/private" }), + ).toThrow(McpError); + const schema = { type: "integer" }; + validateSchema(schema); + schema.type = "string"; + expect(() => validateInstance(schema, 1)).toThrow(McpError); + }); + test("depth and data limits run before compilation/validation", () => { + let schema: unknown = true; + for (let i = 0; i < 70; i++) schema = { properties: { nested: schema } }; + expect(() => validateSchema(schema)).toThrow("complexity"); + expect(() => + validateInstance(true, "x".repeat(4 * 1024 * 1024 + 1)), + ).toThrow("byte limit"); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => validateSchema(cyclic)).toThrow("cyclic"); + }); +}); + +test("empty generic schemas remain valid but tool input schemas require object shape", () => { + const schema = {}; + expect(validateSchema(schema)).toBe(schema); + for (const value of [null, 42, "text", [], {}]) + expect(() => validateInstance(schema, value)).not.toThrow(); + expect(() => + validateResult("tools/list", { tools: [{ name: "t", inputSchema: {} }] }), + ).toThrow("inputSchema must have type object"); + expect(() => + validateResult("tools/list", { + tools: [{ name: "t", inputSchema: { type: "object" } }], + }), + ).not.toThrow(); +}); + +test("modern content is normative with or without an explicit complete resultType", () => { + for (const tag of [{}, { resultType: "complete" }]) { + const result = { ...tag, content: [{ type: "text", text: "ok" }] }; + expect(validateResult("tools/call", result)).toBe(result); + for (const content of [ + "ok", + [{ type: "text", text: 1 }], + [{ type: "invented" }], + ]) + expect(() => validateResult("tools/call", { ...tag, content })).toThrow( + McpError, + ); + } +}); + +test("only an absent result discriminator defaults to complete; null is invalid", () => { + for (const value of [ + { content: [] }, + { resultType: "complete", content: [] }, + ]) + expect(validateResult("tools/call", value)).toBe(value); + for (const resultType of [null, "unknown", false, 0]) + expect(() => + validateResult("tools/call", { resultType, content: [] }), + ).toThrow("unrecognized resultType"); +}); diff --git a/src/protocol/resource-bounds.test.ts b/src/protocol/resource-bounds.test.ts new file mode 100644 index 0000000..a4e23a5 --- /dev/null +++ b/src/protocol/resource-bounds.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { validateResult } from "./schemas.js"; +import { assertJsonBounds, validateSchema } from "./json-schema.js"; + +const maxDecodedBytes = 16 * 1024 * 1024; +const context = { maxDecodedBytes }; +const resource = (text: string) => ({ + contents: [{ uri: "test://large", text }], +}); + +test("explicit resource validation admits payloads but does not raise default or other method limits", () => { + const text = "x".repeat(4 * 1024 * 1024); + expect( + validateResult("resources/read", resource(text), context), + ).toHaveProperty("contents"); + expect(() => validateResult("resources/read", resource(text))).toThrow( + "JSON byte limit", + ); + expect(() => assertJsonBounds(resource(text))).toThrow("JSON byte limit"); + for (const method of [ + "tools/call", + "prompts/get", + "resources/list", + "server/discover", + ]) + expect(() => + validateResult(method, { ...resource(text), extra: text }, context), + ).toThrow("JSON byte limit"); +}); + +test("resource validation independently clamps budgets and counts aggregate decoded text/blob", () => { + for (const value of [-1, Infinity, NaN, 0.5]) + expect(() => + validateResult("resources/read", resource(""), { + maxDecodedBytes: value, + }), + ).toThrow("safe integer"); + expect(() => + validateResult( + "resources/read", + resource("x".repeat(maxDecodedBytes + 1)), + { maxDecodedBytes: Number.MAX_SAFE_INTEGER }, + ), + ).toThrow("decoded byte limit"); + expect(() => + validateResult( + "resources/read", + { + contents: [ + { uri: "a", text: "é" }, + { uri: "b", blob: "YWI=" }, + ], + }, + { maxDecodedBytes: 3 }, + ), + ).toThrow("decoded byte limit"); +}); + +test("resource allowance retains metadata, schema, node, depth and JSON validity bounds", () => { + for (const extra of [ + { _meta: { text: "x".repeat(64 * 1024) } }, + { extra: { contents: [{ text: "x".repeat(64 * 1024) }] } }, + { _meta: { escaped: "\u0000".repeat(12 * 1024) } }, + ]) + expect(() => + validateResult("resources/read", { ...resource(""), ...extra }, context), + ).toThrow("metadata/envelope"); + let deep: unknown = null; + for (let i = 0; i < 65; i++) deep = [deep]; + expect(() => + validateResult("resources/read", { ...resource(""), deep }, context), + ).toThrow("complexity"); + expect(() => + validateResult( + "resources/read", + { ...resource(""), nodes: Array(100_001).fill(null) }, + context, + ), + ).toThrow(); + const cycle: Record = {}; + cycle.self = cycle; + for (const extra of [cycle, { value: Infinity }, { value: () => 0 }]) + expect(() => + validateResult("resources/read", { ...resource(""), extra }, context), + ).toThrow(); + expect(() => validateSchema({ description: "x".repeat(256 * 1024) })).toThrow( + "JSON byte limit", + ); + expect(() => + validateResult( + "resources/read", + { contents: [{ uri: "a", text: "x", blob: "eA==" }] }, + context, + ), + ).toThrow("invalid resource contents"); +}); diff --git a/src/protocol/resource-budget.ts b/src/protocol/resource-budget.ts new file mode 100644 index 0000000..df4dca9 --- /dev/null +++ b/src/protocol/resource-budget.ts @@ -0,0 +1,212 @@ +import { McpError } from "./errors.js"; + +/** An incremental allocation guard, not a replacement JSON validator. Only strings + * at result.contents[*].text/blob count. The normal parser validates the envelope + * and content afterwards. Keys/escapes can cross arbitrary transport chunks. */ +function resourceCounter( + maxBytes: number, + payloadByte: (char: string) => void, +) { + const stack: { + path: string[]; + array: boolean; + key: string; + keyNext: boolean; + }[] = []; + let inString = false; + let isKey = false; + let key = ""; + let escape = false; + let hex: string | undefined; + let kind: "text" | "blob" | undefined; + let bytes = 0; + let highSurrogate = false; + let base64Characters = 0; + const valuePath = (): string[] => { + const parent = stack.at(-1); + return parent === undefined + ? [] + : [...parent.path, parent.array ? "*" : parent.key]; + }; + function character(char: string) { + if (isKey) { + // Only short protocol keys matter; don't accumulate attacker-sized keys. + if (key.length < 32) key += char; + return; + } + if (kind === "text") { + const code = char.charCodeAt(0); + if (code >= 0xdc00 && code <= 0xdfff && highSurrogate) bytes += 1; + else bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3; + highSurrogate = code >= 0xd800 && code <= 0xdbff; + } else if (kind === "blob" && char !== "=") { + // Every non-padding base64 character contributes six bits. Counting before + // atob/Buffer allocation handles all three padding cases exactly. + const previous = Math.floor((base64Characters * 6) / 8); + base64Characters++; + bytes += Math.floor((base64Characters * 6) / 8) - previous; + } + if (bytes > maxBytes) + throw new McpError("Resource decoded byte limit exceeded"); + } + return (text: string) => { + // Iterate UTF-16 units so raw and escaped surrogate pairs count identically. + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + if (inString) { + if ( + !isKey && + kind !== undefined && + (char !== '"' || escape || hex !== undefined) + ) + payloadByte(char); + if (hex !== undefined) { + hex += char; + if (hex.length === 4) { + character(String.fromCharCode(Number.parseInt(hex, 16))); + hex = undefined; + } + } else if (escape) { + escape = false; + if (char === "u") hex = ""; + else + character( + ( + { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" } as Record< + string, + string + > + )[char] ?? char, + ); + } else if (char === "\\") escape = true; + else if (char === '"') { + inString = false; + if (isKey) { + const parent = stack.at(-1); + if (parent !== undefined) parent.key = key; + } + } else character(char); + continue; + } + const parent = stack.at(-1); + if (char === '"') { + inString = true; + isKey = parent !== undefined && !parent.array && parent.keyNext; + key = ""; + highSurrogate = false; + base64Characters = 0; + const path = valuePath(); + kind = + !isKey && + path.length === 4 && + path[0] === "result" && + path[1] === "contents" && + path[2] === "*" + ? path[3] === "text" + ? "text" + : path[3] === "blob" + ? "blob" + : undefined + : undefined; + } else if (char === "{" || char === "[") { + if (stack.length >= 64) + throw new McpError("Resource JSON depth limit exceeded"); + stack.push({ + path: valuePath(), + array: char === "[", + key: "", + keyNext: true, + }); + } else if (char === "}" || char === "]") stack.pop(); + else if (char === ":" && parent !== undefined) parent.keyNext = false; + else if (char === "," && parent !== undefined) parent.keyNext = true; + } + }; +} + +export interface ResourceBudget { + push(chunk: Uint8Array): void; + finish(): void; +} + +/** Explicit resource reads support at most 16 MiB decoded. JSON-escaped base64 + * needs up to eight wire bytes per decoded byte (plus padding); text needs six. + * URI/metadata/RPC/SSE overhead has a separate 64 KiB cap, not unused payload + * allowance. Unbounded requests retain the transport's default 4 MiB cap. */ +export function createResourceBudget( + maxBytes: number, + sse: boolean, +): ResourceBudget { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) + throw new McpError("maxDecodedBytes must be a nonnegative safe integer"); + maxBytes = Math.min(maxBytes, 16 * 1024 * 1024); + const maxEnvelopeBytes = 64 * 1024; + const maxWireBytes = Math.ceil(maxBytes / 3) * 24 + maxEnvelopeBytes; + let decodedWireBytes = 0; + let payloadWireBytes = 0; + function payloadByte(char: string) { + const code = char.charCodeAt(0); + payloadWireBytes += + code < 0x80 + ? 1 + : code < 0x800 + ? 2 + : code >= 0xd800 && code <= 0xdbff + ? 0 + : code >= 0xdc00 && code <= 0xdfff + ? 4 + : 3; + } + let wireBytes = 0; + const decoder = new TextDecoder(); + let count = resourceCounter(maxBytes, payloadByte); + let prefix = ""; + let data = false; + let lineStarted = false; + let previousCR = false; + function feed(text: string) { + if (!sse) { + count(text); + return; + } + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + if (char === "\n" && previousCR) { + previousCR = false; + continue; + } + previousCR = char === "\r"; + if (char === "\r" || char === "\n") { + if (!lineStarted) count = resourceCounter(maxBytes, payloadByte); + else if (data) count("\n"); + prefix = ""; + data = false; + lineStarted = false; + } else { + lineStarted = true; + if (data) count(char); + else if (prefix.length < 5) { + prefix += char; + if (prefix === "data:") data = true; + } + } + } + } + function accept(text: string) { + decodedWireBytes += Buffer.byteLength(text, "utf8"); + feed(text); + if (decodedWireBytes - payloadWireBytes > maxEnvelopeBytes) + throw new McpError("Resource metadata/envelope byte limit exceeded"); + } + return { + push(chunk) { + wireBytes += chunk.byteLength; + if (wireBytes > maxWireBytes) + throw new McpError("Resource wire byte budget exceeded"); + accept(decoder.decode(chunk, { stream: true })); + }, + finish() { + accept(decoder.decode()); + }, + }; +} diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts new file mode 100644 index 0000000..65feae8 --- /dev/null +++ b/src/protocol/schemas.ts @@ -0,0 +1,671 @@ +import { type } from "arktype"; +import { validateContentBlock, validateResourceContents } from "./content.js"; +import { validateUiToolMeta } from "./ui.js"; +import { McpError, McpErrorCode } from "./errors.js"; +import { + extensionEnabled, + MCP_EXT_TASKS, + MCP_EXT_SKILLS, +} from "./extensions.js"; +import { methodDescriptor } from "./methods.js"; +import { + assertJsonBounds, + assertResourceJsonBounds, + validateInstance, + validateSchema, +} from "./json-schema.js"; +import { + MCP_PROTOCOL_VERSION, + type McpValidationContext, + type Skill, + type Task, + type DetailedTask, +} from "./types.js"; + +export const McpToolSchema = type({ + name: "string", + "title?": "string", + "description?": "string", + inputSchema: "Record", + "outputSchema?": "Record", + "annotations?": { + "title?": "string", + "readOnlyHint?": "boolean", + "destructiveHint?": "boolean", + "idempotentHint?": "boolean", + "openWorldHint?": "boolean", + }, + "icons?": "unknown[]", + "_meta?": "Record", +}); +export type McpTool = typeof McpToolSchema.infer; +export const PromptSchema = type({ + name: "string", + "title?": "string", + "description?": "string", + "arguments?": type({ + name: "string", + "description?": "string", + "required?": "boolean", + }).array(), + "icons?": "unknown[]", + "_meta?": "Record", +}); +export const ResourceSchema = type({ + uri: "string", + name: "string", + "title?": "string", + "description?": "string", + "mimeType?": "string", + "size?": "number", + "annotations?": "Record", + "icons?": "unknown[]", + "_meta?": "Record", +}); +export const TemplateSchema = type({ + uriTemplate: "string", + name: "string", + "title?": "string", + "description?": "string", + "mimeType?": "string", + "annotations?": "Record", + "icons?": "unknown[]", + "_meta?": "Record", +}); +export const SkillSchema = type({ + uri: "string", + frontmatter: { name: "string", description: "string", "[string]": "unknown" }, + resources: "unknown", + "_meta?": "Record", +}); +export const TaskHandleSchema = type({ + taskId: "string", + status: "'working' | 'input_required' | 'completed' | 'failed' | 'cancelled'", + createdAt: "string", + lastUpdatedAt: "string", + ttlMs: "number | null", + "pollIntervalMs?": "number", + "statusMessage?": "string", + "_meta?": "Record", +}); +export const ToolCallResult = type({ + content: "unknown", + "isError?": "boolean", + "structuredContent?": "unknown", + "_meta?": "Record", +}); +export const PromptResult = type({ + "description?": "string", + messages: "unknown[]", + "_meta?": "Record", +}); +export const CompletionResult = type({ + completion: { values: "string[]", "total?": "number", "hasMore?": "boolean" }, +}); +export const DiscoverResult = type({ + supportedVersions: "string[]", + capabilities: "Record", + "instructions?": "string", + "_meta?": "Record", +}); +export const JsonRpcResponse = type({ + jsonrpc: "'2.0'", + id: "string | number | null", + "result?": "unknown", + "error?": { code: "number", message: "string", "data?": "unknown" }, +}); + +function invalid( + message: string, + code: number = McpErrorCode.InvalidParams, +): never { + throw new McpError(message, undefined, code); +} +export function record( + value: unknown, + label = "value", +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) + invalid(`${label} must be an object`); + return value as Record; +} +function check( + validator: (value: unknown) => unknown, + value: unknown, + label: string, +): void { + const parsed = validator(value); + if (parsed instanceof type.errors) + invalid(`malformed ${label}: ${parsed.summary}`); +} +function string(value: unknown, label: string): asserts value is string { + if (typeof value !== "string") invalid(`${label} must be a string`); +} +function integer(value: unknown, label: string): void { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + invalid(`${label} must be a nonnegative safe integer`); +} +function meta(value: Record): void { + if (value._meta !== undefined) record(value._meta, "_meta"); +} +function list( + value: unknown, + label: string, + validate: (value: unknown) => void, +): void { + if (!Array.isArray(value)) invalid(`${label} must be an array`); + for (const item of value) validate(item); +} +/** Tokens are protocol integers (not fractional progress values) or strings. */ +export function validateProgressToken( + value: unknown, +): asserts value is string | number { + if ( + typeof value !== "string" && + !(typeof value === "number" && Number.isSafeInteger(value)) + ) + invalid("invalid progress token"); +} + +// Restricted elicitation union, not arbitrary JSON Schema. Reference: +// specification@271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts +const elicitationAnnotations = { + "title?": "string", + "description?": "string", +} as const; +const enumOption = type({ const: "string", title: "string" }).onUndeclaredKey( + "reject", +); +const elicitationProperty = type({ + ...elicitationAnnotations, + type: "'string'", + "default?": "string", + "minLength?": "number", + "maxLength?": "number", + "format?": "'email' | 'uri' | 'date' | 'date-time'", +}) + .onUndeclaredKey("reject") + .or( + type({ + ...elicitationAnnotations, + type: "'number' | 'integer'", + "default?": "number", + "minimum?": "number", + "maximum?": "number", + }).onUndeclaredKey("reject"), + ) + .or( + type({ + ...elicitationAnnotations, + type: "'boolean'", + "default?": "boolean", + }).onUndeclaredKey("reject"), + ) + .or( + type({ + ...elicitationAnnotations, + type: "'string'", + enum: "string[]", + "enumNames?": "string[]", + "default?": "string", + }).onUndeclaredKey("reject"), + ) + .or( + type({ + ...elicitationAnnotations, + type: "'string'", + oneOf: enumOption.array(), + "default?": "string", + }).onUndeclaredKey("reject"), + ) + .or( + type({ + ...elicitationAnnotations, + type: "'array'", + "minItems?": "number", + "maxItems?": "number", + "default?": "string[]", + items: type({ type: "'string'", enum: "string[]" }) + .onUndeclaredKey("reject") + .or(type({ anyOf: enumOption.array() }).onUndeclaredKey("reject")), + }).onUndeclaredKey("reject"), + ); + +export function validateInputRequests( + value: unknown, + context: McpValidationContext = {}, +): void { + for (const input of Object.values(record(value, "inputRequests"))) { + const request = record(input, "input request"); + if ( + request.method === "sampling/createMessage" || + request.method === "roots/list" + ) + invalid( + `unsupported input capability ${String(request.method)}`, + McpErrorCode.MissingRequiredClientCapability, + ); + if (request.method !== "elicitation/create") + invalid("unsupported input request method"); + const params = record(request.params, "elicitation params"); + string(params.message, "elicitation message"); + if (params.mode === "url") { + string(params.url, "elicitation URL"); + try { + const url = new URL(params.url); + if (url.protocol !== "https:" && url.protocol !== "http:") + invalid("unsupported elicitation URL scheme"); + } catch { + invalid("invalid elicitation URL"); + } + } else if (params.mode === undefined || params.mode === "form") { + const schema = record(params.requestedSchema, "requestedSchema"); + if (schema.type !== "object") + invalid("elicitation schema must be an object schema"); + for (const property of Object.values( + record(schema.properties, "elicitation properties"), + )) { + check(elicitationProperty, property, "elicitation property"); + } + validateSchema(schema); + } else invalid("unsupported elicitation mode"); + if (context.clientCapabilities !== undefined) { + const capability = record( + context.clientCapabilities.elicitation ?? {}, + "elicitation capability", + ); + const mode = params.mode ?? "form"; + const legacyForm = + context.clientCapabilities.elicitation !== undefined && + Object.keys(capability).length === 0 && + mode === "form"; + if (!legacyForm && !Object.hasOwn(capability, String(mode))) + invalid( + `missing elicitation ${String(mode)} capability`, + McpErrorCode.MissingRequiredClientCapability, + ); + } + } +} +export function validateInputResponses(value: unknown): void { + for (const response of Object.values(record(value, "inputResponses"))) { + const input = record(response, "input response"); + if ( + input.action !== "accept" && + input.action !== "decline" && + input.action !== "cancel" + ) + invalid("invalid elicitation action"); + if (input.content !== undefined) + for (const field of Object.values(record(input.content))) { + if ( + typeof field !== "string" && + typeof field !== "number" && + typeof field !== "boolean" && + !( + Array.isArray(field) && + field.every((item) => typeof item === "string") + ) + ) + invalid("invalid elicitation content"); + } + } +} +export function validateTask( + value: unknown, + detailed = false, + context: McpValidationContext = {}, +): Task | DetailedTask { + check(TaskHandleSchema, value, "task"); + const task = record(value); + for (const field of ["createdAt", "lastUpdatedAt"]) + if ( + typeof task[field] !== "string" || + !/^\d{4}-\d\d-\d\dT/.test(task[field]) || + !Number.isFinite(Date.parse(task[field])) + ) + invalid(`invalid task ${field}`); + if (task.ttlMs !== null) integer(task.ttlMs, "task ttlMs"); + if (task.pollIntervalMs !== undefined) + integer(task.pollIntervalMs, "task pollIntervalMs"); + if (detailed) { + if (task.status === "input_required") + validateInputRequests(task.inputRequests, context); + if (task.status === "completed") { + const result = record(task.result, "completed task result"); + if (result.resultType !== undefined && result.resultType !== "complete") + invalid("completed task requires a final complete result"); + validateResult("tools/call", result, context); + } + if (task.status === "failed") + check( + type({ code: "number", message: "string", "data?": "unknown" }), + task.error, + "task error", + ); + } + return value as Task | DetailedTask; +} +export function validateSkill(value: unknown): Skill { + check(SkillSchema, value, "skill"); + const skill = record(value); + if (skill.resources !== "dynamic") { + const uris = new Set(); + list(skill.resources, "skill resources", (item) => { + const resource = record(item); + string(resource.uri, "skill resource URI"); + string(resource.digest, "skill digest"); + integer(resource.size, "skill size"); + if (!/^sha256:[a-f0-9]{64}$/.test(resource.digest)) + invalid("invalid skill digest"); + if (uris.has(resource.uri)) invalid("duplicate skill resource URI"); + uris.add(resource.uri); + }); + if (!uris.has(String(skill.uri))) invalid("skill manifest omits SKILL.md"); + } + return value as Skill; +} +function eligibility(method: string, context: McpValidationContext): void { + const descriptor = methodDescriptor(method); + if (descriptor === undefined) + invalid(`unknown method ${method}`, McpErrorCode.MethodNotFound); + if ( + descriptor.extension !== undefined && + (context.era === "legacy" || + !extensionEnabled(context, descriptor.extension)) + ) + invalid(`extension ${descriptor.extension} is not enabled`); + if ( + context.serverCapabilities !== undefined && + descriptor.capability !== undefined && + !Object.hasOwn(context.serverCapabilities, descriptor.capability) + ) + invalid( + `server does not advertise ${descriptor.capability}`, + McpErrorCode.MethodNotFound, + ); +} +export function validateRequest( + method: string, + value: unknown, + context: McpValidationContext = {}, +): Record { + eligibility(method, context); + const params = record(value ?? {}, "params"); + assertJsonBounds(params); + meta(params); + if (params._meta !== undefined) { + const metadata = record(params._meta); + if (Object.hasOwn(metadata, "progressToken")) + validateProgressToken(metadata.progressToken); + } + if (context.era !== "legacy") { + const metadata = record(params._meta, "request _meta"); + const version = metadata["io.modelcontextprotocol/protocolVersion"]; + if (version !== (context.protocolVersion ?? MCP_PROTOCOL_VERSION)) + invalid( + "unsupported protocol version", + McpErrorCode.UnsupportedProtocolVersion, + ); + record( + metadata["io.modelcontextprotocol/clientCapabilities"], + "clientCapabilities", + ); + } + if (params.cursor !== undefined) string(params.cursor, "cursor"); + if (params.requestState !== undefined) + string(params.requestState, "requestState"); + if (params.inputResponses !== undefined) { + if (!methodDescriptor(method)?.inputRequired && method !== "tasks/update") + invalid(`inputResponses not allowed for ${method}`); + validateInputResponses(params.inputResponses); + } + if ( + context.era === "legacy" && + (params.inputResponses !== undefined || params.requestState !== undefined) + ) + invalid("MRTR is not available on legacy protocol"); + if (["tools/call", "prompts/get"].includes(method)) + string(params.name, "name"); + if ( + ["resources/read", "skills/get", "resources/directory/read"].includes( + method, + ) + ) + string(params.uri, "uri"); + if (method.startsWith("tasks/")) string(params.taskId, "taskId"); + if (method === "tasks/update") validateInputResponses(params.inputResponses); + if (method === "tools/call") { + if (params.arguments !== undefined) record(params.arguments, "arguments"); + if (context.inputSchema !== undefined) + validateInstance(context.inputSchema, params.arguments ?? {}); + } + if (method === "prompts/get" && params.arguments !== undefined) + for (const arg of Object.values(record(params.arguments))) + string(arg, "prompt argument"); + if (method === "completion/complete") { + const ref = record(params.ref); + if (ref.type === "ref/prompt") string(ref.name, "ref name"); + else if (ref.type === "ref/resource") string(ref.uri, "ref URI"); + else invalid("invalid completion ref"); + const argument = record(params.argument); + string(argument.name, "argument name"); + string(argument.value, "argument value"); + } + if (method === "subscriptions/listen") + validateFilter(params.notifications, context); + if (method === "resources/directory/read") { + const extensions = record( + context.serverCapabilities?.extensions ?? {}, + "server extensions", + ); + if (record(extensions[MCP_EXT_SKILLS] ?? {}).directoryRead !== true) + invalid("server does not advertise directoryRead"); + } + if ( + method.startsWith("skills/") && + context.serverCapabilities !== undefined && + !Object.hasOwn( + record(context.serverCapabilities.extensions ?? {}), + MCP_EXT_SKILLS, + ) + ) + invalid("server does not advertise skills"); + return params; +} +function validateFilter(value: unknown, context: McpValidationContext): void { + const filter = record(value, "notifications"); + for (const key of [ + "toolsListChanged", + "promptsListChanged", + "resourcesListChanged", + ]) + if (filter[key] !== undefined && typeof filter[key] !== "boolean") + invalid(`invalid ${key}`); + for (const key of ["resourceSubscriptions", "taskIds"]) + if (filter[key] !== undefined) + list(filter[key], key, (item) => string(item, key)); + if (filter.taskIds !== undefined && !extensionEnabled(context, MCP_EXT_TASKS)) + invalid("tasks extension is not enabled"); +} +export function validateResult( + method: string, + value: unknown, + context: McpValidationContext = {}, +): Record { + eligibility(method, context); + const result = record(value, "result"); + if (method === "resources/read" && context.maxDecodedBytes !== undefined) + assertResourceJsonBounds(result, context.maxDecodedBytes); + else assertJsonBounds(result); + meta(result); + const kind = result.resultType === undefined ? "complete" : result.resultType; + const descriptor = methodDescriptor(method); + if (kind !== "complete" && kind !== "input_required" && kind !== "task") + invalid(`unrecognized resultType for ${method}`); + if (kind !== "complete" && context.era === "legacy") + invalid("polymorphic result on legacy protocol"); + if (kind === "task") { + if (!extensionEnabled(context, MCP_EXT_TASKS)) + invalid("tasks extension is not enabled"); + if (!descriptor?.task) invalid(`task result is not allowed for ${method}`); + validateTask(result, false, context); + return result; + } + if (kind === "input_required") { + if (!descriptor?.inputRequired) + invalid(`input_required is not allowed for ${method}`); + if (result.inputRequests === undefined && result.requestState === undefined) + invalid("input_required requires inputRequests or requestState"); + if (result.requestState !== undefined) + string(result.requestState, "requestState"); + if (result.inputRequests !== undefined) + validateInputRequests(result.inputRequests, context); + return result; + } + if ( + method !== "tasks/get" && + result.ttlMs !== undefined && + (typeof result.ttlMs !== "number" || !Number.isFinite(result.ttlMs)) + ) + invalid("invalid ttlMs"); + if ( + result.cacheScope !== undefined && + result.cacheScope !== "public" && + result.cacheScope !== "private" + ) + invalid("invalid cacheScope"); + if (result.nextCursor !== undefined) string(result.nextCursor, "nextCursor"); + switch (method) { + case "server/discover": + check(DiscoverResult, result, method); + break; + case "tools/list": + list(result.tools, "tools", (tool) => { + check(McpToolSchema, tool, "tool"); + const entry = record(tool); + validateSchema(entry.inputSchema); + if (record(entry.inputSchema).type !== "object") + invalid("tool inputSchema must have type object"); + if (entry._meta !== undefined) { + const metadata = record(entry._meta); + if (metadata.ui !== undefined) validateUiToolMeta(metadata.ui); + } + if (entry.outputSchema !== undefined) + validateSchema(entry.outputSchema); + }); + break; + case "prompts/list": + list(result.prompts, "prompts", (prompt) => + check(PromptSchema, prompt, "prompt"), + ); + break; + case "resources/list": + case "resources/directory/read": + list(result.resources, "resources", (resource) => + check(ResourceSchema, resource, "resource"), + ); + break; + case "resources/templates/list": + list(result.resourceTemplates, "resourceTemplates", (template) => + check(TemplateSchema, template, "resource template"), + ); + break; + case "tools/call": + check(ToolCallResult, result, method); + list(result.content, "tool content", (block) => { + validateContentBlock(block); + }); + if (context.outputSchema !== undefined && result.isError !== true) + validateInstance(context.outputSchema, result.structuredContent); + break; + case "prompts/get": + check(PromptResult, result, method); + list(result.messages, "prompt messages", (value) => { + const message = record(value); + if (message.role !== "user" && message.role !== "assistant") + invalid("invalid prompt message role"); + validateContentBlock(message.content); + }); + break; + case "resources/read": + list(result.contents, "contents", (item) => { + validateResourceContents(item); + }); + break; + case "completion/complete": + check(CompletionResult, result, method); + if ((record(result.completion).values as unknown[]).length > 100) + invalid("too many completion values"); + break; + case "tasks/get": + validateTask(result, true, context); + break; + case "skills/list": + list(result.skills, "skills", (skill) => { + validateSkill(skill); + }); + break; + case "skills/get": + validateSkill(result.skill); + break; + } + return result; +} +export function validateNotification( + method: string, + value: unknown, + context: McpValidationContext = {}, +): Record { + const params = record(value ?? {}, "notification params"); + assertJsonBounds(params); + meta(params); + switch (method) { + case "notifications/progress": + validateProgressToken(params.progressToken); + if ( + typeof params.progress !== "number" || + !Number.isFinite(params.progress) + ) + invalid("invalid progress"); + if ( + params.total !== undefined && + (typeof params.total !== "number" || !Number.isFinite(params.total)) + ) + invalid("invalid progress total"); + if (params.message !== undefined) + string(params.message, "progress message"); + break; + case "notifications/cancelled": + if ( + typeof params.requestId !== "string" && + typeof params.requestId !== "number" + ) + invalid("invalid cancelled requestId"); + break; + case "notifications/tasks": + if (!extensionEnabled(context, MCP_EXT_TASKS)) + invalid("tasks extension is not enabled"); + validateTask(params, true, context); + break; + case "notifications/subscriptions/acknowledged": + validateFilter(params.notifications, context); + break; + case "notifications/resources/updated": + string(params.uri, "resource URI"); + break; + case "notifications/tools/list_changed": + case "notifications/prompts/list_changed": + case "notifications/resources/list_changed": + break; + case "notifications/initialized": + if (context.era !== "legacy") invalid("initialized is legacy only"); + break; + default: + invalid( + `unsupported notification ${method}`, + McpErrorCode.MethodNotFound, + ); + } + return params; +} diff --git a/src/protocol/types.ts b/src/protocol/types.ts new file mode 100644 index 0000000..1052e74 --- /dev/null +++ b/src/protocol/types.ts @@ -0,0 +1,258 @@ +// Wire contracts derived from MCP 2026-07-28 schema at +// 271ecc9accafdd9b83a3c869fa67c22953b2af80 and the extension pins in extensions.ts. +export const MCP_PROTOCOL_VERSION = "2026-07-28"; +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; +export type MetaObject = Record; +export type JsonSchema = boolean | Record; +export type RequestId = string | number; +export type McpProtocolEra = "modern" | "legacy"; +export type McpExtensionSettings = Readonly< + Record>> +>; +export interface McpValidationContext { + /** Explicit resources/read result payload budget; independently checked, capped at 16 MiB. */ + maxDecodedBytes?: number | undefined; + era?: McpProtocolEra; + protocolVersion?: string; + extensions?: readonly string[] | McpExtensionSettings; + clientCapabilities?: Record; + serverCapabilities?: Record; + inputSchema?: Record; + outputSchema?: Record; +} +export interface McpRpcError { + code: number; + message: string; + data?: unknown; +} +export interface ElicitRequestFormParams { + mode?: "form"; + message: string; + requestedSchema: { + $schema?: string; + type: "object"; + properties: Record>; + required?: string[]; + }; +} +export interface ElicitRequestURLParams { + mode: "url"; + message: string; + url: string; +} +export type ElicitRequestParams = + | ElicitRequestFormParams + | ElicitRequestURLParams; +export interface ElicitRequest { + method: "elicitation/create"; + params: ElicitRequestParams; +} +export interface ElicitResult { + action: "accept" | "decline" | "cancel"; + content?: Record; + _meta?: MetaObject; +} +/** Deprecated input kinds are recognized but not advertised or executed by this package. */ +export type InputRequest = + | ElicitRequest + | { + method: "sampling/createMessage" | "roots/list"; + params?: Record; + }; +export type InputRequests = Record; +export type InputResponses = Record< + string, + ElicitResult | Record +>; +export interface InputRequiredResult { + resultType: "input_required"; + inputRequests?: InputRequests; + requestState?: string; + _meta?: MetaObject; +} +export interface CompleteResult { + resultType?: "complete"; + _meta?: MetaObject; + [key: string]: unknown; +} +export type TaskStatus = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; +export interface Task { + taskId: string; + status: TaskStatus; + createdAt: string; + lastUpdatedAt: string; + ttlMs: number | null; + pollIntervalMs?: number; + statusMessage?: string; +} +export type DetailedTask = + | (Task & { status: "working" | "cancelled" }) + | (Task & { status: "input_required"; inputRequests: InputRequests }) + | (Task & { status: "completed"; result: Record }) + | (Task & { status: "failed"; error: McpRpcError }); +export type CreateTaskResult = Task & { + resultType: "task"; + _meta?: MetaObject; +}; +export type GetTaskResult = DetailedTask & { + resultType: "complete"; + _meta?: MetaObject; +}; +export interface SkillResource { + uri: string; + digest: string; + size: number; +} +export interface Skill { + uri: string; + frontmatter: { name: string; description: string; [key: string]: unknown }; + resources: SkillResource[] | "dynamic"; + _meta?: MetaObject; +} +export interface ResourceContents { + uri: string; + mimeType?: string; + _meta?: MetaObject; +} +export interface TextResourceContents extends ResourceContents { + text: string; +} +export interface BlobResourceContents extends ResourceContents { + blob: string; +} +export type McpResourceContents = TextResourceContents | BlobResourceContents; +export interface McpUiToolMeta { + resourceUri?: string; + visibility?: ("model" | "app")[]; + [key: string]: unknown; +} +export interface McpUiResourceMeta { + csp?: { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + baseUriDomains?: string[]; + }; + permissions?: { + camera?: Record; + microphone?: Record; + geolocation?: Record; + clipboardWrite?: Record; + }; + domain?: string; + prefersBorder?: boolean; + [key: string]: unknown; +} +export interface McpPaginationParams { + cursor?: string; + _meta?: MetaObject; +} +export interface McpRetryParams { + inputResponses?: InputResponses; + requestState?: string; + _meta?: MetaObject; +} +export interface McpSubscriptionFilter { + toolsListChanged?: boolean; + promptsListChanged?: boolean; + resourcesListChanged?: boolean; + resourceSubscriptions?: readonly string[]; + taskIds?: readonly string[]; +} +export interface McpMethodMap { + "server/discover": { + params: { _meta?: MetaObject }; + result: CompleteResult & { + supportedVersions: string[]; + capabilities: Record; + }; + }; + "tools/list": { + params: McpPaginationParams; + result: CompleteResult & { tools: Record[] }; + }; + "tools/call": { + params: McpRetryParams & { + name: string; + arguments?: Record; + }; + result: CompleteResult | InputRequiredResult | CreateTaskResult; + }; + "prompts/list": { + params: McpPaginationParams; + result: CompleteResult & { prompts: Record[] }; + }; + "prompts/get": { + params: McpRetryParams & { + name: string; + arguments?: Record; + }; + result: CompleteResult | InputRequiredResult; + }; + "resources/list": { + params: McpPaginationParams; + result: CompleteResult & { resources: Record[] }; + }; + "resources/templates/list": { + params: McpPaginationParams; + result: CompleteResult & { resourceTemplates: Record[] }; + }; + "resources/read": { + params: McpRetryParams & { uri: string }; + result: CompleteResult | InputRequiredResult; + }; + "completion/complete": { + params: { + ref: + | { type: "ref/prompt"; name: string } + | { type: "ref/resource"; uri: string }; + argument: { name: string; value: string }; + context?: { arguments?: Record }; + _meta?: MetaObject; + }; + result: CompleteResult & { + completion: { values: string[]; total?: number; hasMore?: boolean }; + }; + }; + "subscriptions/listen": { + params: { notifications: McpSubscriptionFilter; _meta?: MetaObject }; + result: CompleteResult; + }; + "tasks/get": { + params: { taskId: string; _meta?: MetaObject }; + result: GetTaskResult; + }; + "tasks/update": { + params: { + taskId: string; + inputResponses: InputResponses; + _meta?: MetaObject; + }; + result: CompleteResult; + }; + "tasks/cancel": { + params: { taskId: string; _meta?: MetaObject }; + result: CompleteResult; + }; + "skills/list": { + params: McpPaginationParams; + result: CompleteResult & { skills: Skill[] }; + }; + "skills/get": { + params: { uri: string; _meta?: MetaObject }; + result: CompleteResult & { skill: Skill }; + }; + "resources/directory/read": { + params: McpPaginationParams & { uri: string }; + result: CompleteResult & { resources: Record[] }; + }; +} +export type McpMethod = keyof McpMethodMap; diff --git a/src/protocol/ui.ts b/src/protocol/ui.ts new file mode 100644 index 0000000..2bd4205 --- /dev/null +++ b/src/protocol/ui.ts @@ -0,0 +1,37 @@ +import { McpError, McpErrorCode } from "./errors.js"; +import { assertJsonBounds } from "./json-schema.js"; +import type { McpUiToolMeta } from "./types.js"; +/** Validate the value of a tool's _meta.ui, preserving future metadata fields. */ +export function validateUiToolMeta(value: unknown): McpUiToolMeta { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new McpError( + "invalid tool UI metadata", + undefined, + McpErrorCode.InvalidParams, + ); + assertJsonBounds(value); + const metadata = value as Record; + if ( + metadata.resourceUri !== undefined && + (typeof metadata.resourceUri !== "string" || + !metadata.resourceUri.startsWith("ui://")) + ) + throw new McpError( + "UI resourceUri must use ui://", + undefined, + McpErrorCode.InvalidParams, + ); + if ( + metadata.visibility !== undefined && + (!Array.isArray(metadata.visibility) || + !metadata.visibility.every( + (entry) => entry === "model" || entry === "app", + )) + ) + throw new McpError( + "invalid UI visibility", + undefined, + McpErrorCode.InvalidParams, + ); + return metadata as McpUiToolMeta; +} diff --git a/src/test-server.ts b/src/test-server.ts index 94e492b..d15c9db 100644 --- a/src/test-server.ts +++ b/src/test-server.ts @@ -88,12 +88,21 @@ export function startTestMcpServer( return new Response("bad request", { status: 400 }); } if (params.name === "echo") { - return reply({ content: params.arguments.text }); + return reply({ + content: [{ type: "text", text: String(params.arguments.text) }], + }); } if (params.name === "delete_thing") { - return reply({ content: `deleted ${String(params.arguments.id)}` }); + return reply({ + content: [ + { type: "text", text: `deleted ${String(params.arguments.id)}` }, + ], + }); } - return reply({ content: `unknown tool ${params.name}`, isError: true }); + return reply({ + content: [{ type: "text", text: `unknown tool ${params.name}` }], + isError: true, + }); } return new Response("not found", { status: 404 }); }, From 13e83a8e891eb2dae4d2b1062f1d9c6b495ece85 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:38 -0700 Subject: [PATCH 12/28] feat(extensions): implement tasks skills and apps runtimes Add opt-in task persistence and recovery, bounded skill publication and verification, and explicit Apps host capability gates with lifecycle tests. --- src/extensions/README.md | 146 ++++++ src/extensions/apps.ts | 101 ++++ src/extensions/elicitation.ts | 83 ++++ src/extensions/host.test.ts | 136 ++++++ src/extensions/index.ts | 5 + src/extensions/maturity.ts | 52 +++ src/extensions/review-regressions.test.ts | 217 +++++++++ src/extensions/skills/contracts.ts | 60 +++ src/extensions/skills/index.ts | 434 ++++++++++++++++++ src/extensions/skills/skills.test.ts | 266 +++++++++++ .../skills/verification-race.test.ts | 95 ++++ src/extensions/tasks/contracts.ts | 45 ++ src/extensions/tasks/file-store.ts | 162 +++++++ src/extensions/tasks/index.ts | 279 +++++++++++ src/extensions/tasks/record.ts | 44 ++ src/extensions/tasks/tasks.test.ts | 249 ++++++++++ 16 files changed, 2374 insertions(+) create mode 100644 src/extensions/README.md create mode 100644 src/extensions/apps.ts create mode 100644 src/extensions/elicitation.ts create mode 100644 src/extensions/host.test.ts create mode 100644 src/extensions/index.ts create mode 100644 src/extensions/maturity.ts create mode 100644 src/extensions/review-regressions.test.ts create mode 100644 src/extensions/skills/contracts.ts create mode 100644 src/extensions/skills/index.ts create mode 100644 src/extensions/skills/skills.test.ts create mode 100644 src/extensions/skills/verification-race.test.ts create mode 100644 src/extensions/tasks/contracts.ts create mode 100644 src/extensions/tasks/file-store.ts create mode 100644 src/extensions/tasks/index.ts create mode 100644 src/extensions/tasks/record.ts create mode 100644 src/extensions/tasks/tasks.test.ts diff --git a/src/extensions/README.md b/src/extensions/README.md new file mode 100644 index 0000000..0af236c --- /dev/null +++ b/src/extensions/README.md @@ -0,0 +1,146 @@ +# Extension runtimes + +These modules consume the shared `protocol/index` wire contracts. They do not +register HTTP handlers, grant tools, authenticate callers, or enable transport +capabilities. Adapters register the corresponding methods and advertise only +services actually installed. Stable upstream extensions use ordinary API names; +wire identifiers and revisions come from the shared pinned maturity registry. +No experimental `allowed-tools` interpreter is implemented: the field is preserved +as author data and **never grants permissions**. + +## Skills + +`createSkillPublisher([{uri, files}])` builds immutable in-memory publications. +`list`, `get`, `read`, and `directoryRead` return complete MCP results. Register +these under `skills/list`, `skills/get`, `resources/read`, and optionally +`resources/directory/read`. Also advertise `resources`. Authorize catalog and +file access under the same host identity. Publication is a complete snapshot, +including nested files, not filesystem discovery or a live directory watcher. +No URI authority is resolved through DNS. Missing resources use error code -32602. + +`createSkillLoader(source, host?)` provides `hold`, `get`, `verify`, `activate`, +and `clear`. `hold` accepts a previously validated list entry; `get` confirms an +explicit URI even if absent from a listing. The source is an origin-pinned, +credential-mediated adapter. Its `read(origin, uri, maxBytes)` must enforce the +byte limit **while receiving the response**, decode one matching text/blob +resource into raw bytes, and reject input-required or mismatched resources. +The loader independently checks size, digest, frontmatter, directory containment, +and the held manifest before exposing bytes. Reads and caching are lazy. + +Limits: 512 resources and 16 MiB aggregate bytes, inclusive. Frontmatter is JSON- +compatible YAML, with no aliases, at most 32 levels and 100,000 AST nodes. Unknown +frontmatter fields are preserved. Dynamic manifests are explicitly declined: +there is no stable content to which approval could bind. A listing is not an +activation, and a generic resource read is never activation. + +Origins contain host-assigned `serverId`, `tenantId`, `principalId`, and +`authorizationContextId`; none may be inferred from serverInfo or model arguments. +Identity includes the full skill URI, not its name. Show this origin/path to both +user and model; do not allow name collisions to shadow local or other-origin +skills. Cache keys and approvals include this identity and manifest fingerprint. +The bounded in-memory cache owns copied bytes and rehashes on access. `clear` +revokes approvals/leases and removes cache data when the server is removed. +There is no disk skill cache or filesystem materialization. A host adding one +must exclude all filesystem skill discovery paths and preserve MCP provenance +across restart, with immutable host-only writes or rehashing on every read. + +Activation requires `McpSkillHostPolicy`. `approve` returns an identity- and +fingerprint-bound explicit user receipt. Execution consent is separate from +context admission, never broad grant widening. Changed manifest sets revoke +receipts and live execution; unlisted, size/digest or frontmatter failures revoke +the held entry and require refresh. Nested SKILL.md files are inert supporting +bytes until separately activated and explicitly approved. + +`openActingWindow(held, receipt, guards)` must install +`guards.assertExecutionApproved` on **every host code-execution tool**, including +arbitrary commands suggested in the body, before `admit` puts untrusted, +origin-tagged content into context. It must also preserve normal authorization. +No field, hook, or `allowed-tools` declaration may widen permissions. All +skill-driven resource reads must use the originating server. Cross-origin reads +are not provided here; a host implementing them needs explicit per-call consent +naming both servers. The host must prevent bypass through generic resource tools. + +`SkillLease.release()` calls `removeContext` before `close`; the host must not +return from removal until the skill has left model context. A failed removal +keeps the gate installed. A digest is only consistency evidence, never a trust +anchor or proof of safe instructions. Pure `verify` does not claim host activation. + +## Tasks + +`createTaskRuntime({store, executor, authorizeInput, validateTask?, now?})` exposes +`create`, `get`, `transition`, `submitInput`, `cancel`, and `recover`. Identities +are host-bound tenant/principal/authorization-context triples. Only `tools/call` +can create tasks; the server checks per-request extension capability before +calling it. Shared wire validation is the default; the optional validation hook +may add host constraints. `get` returns a flat complete detailed task, and create +returns the shared flat task handle. Updates and cancellation acknowledge complete +empty results, not optimistic completion. + +A durable create precedes dispatch. Executor `start` must use task ID as correlation +or idempotency key. Records persist original request, backend correlations, revision, +issued/answered input keys, dispatch state and cancellation intent. Do not place +bearer tokens/secrets in records. The executor must persist backend correlation +through `transition` and implement restart reconciliation. Persisted intent is not +exactly-once dispatch: never replay ambiguous non-idempotent effects blindly. +Input authorization must resolve the actual pending approval route or workflow +signal, not turn a submitted form into blanket approval. Unknown/answered keys +are ignored, partial answers remain pending, and keys cannot denote new requests. +`recover` records a JSON-RPC failure when backend recovery is unavailable. +Cancellation remains cooperative; a successful ack does not promise work stopped. + +`pollTask` persists its ID first, honors server intervals, deduplicates input keys, +and requires a host input handler. Abort stops local polling, not the remote task; +call the transport's `tasks/cancel` explicitly when cancellation is intended. +Default polling bound is 10,000 polls; exhausting it leaves the durable task intact. + +Import `createFileTaskStore` explicitly from `tasks/file-store`; it is deliberately +absent from the neutral index. This opt-in reference adapter supports one writer +on a local filesystem, a private 0700 directory, 0600 files, exclusive writer lock, +fsynced atomic snapshots, CAS and expiry. Snapshot bound is 16 MiB. Supply +`validateTaskRecord` for restart validation. Close before reopening. A crash leaves +the lock: an operator must establish that its process is dead before removing it. +No automatic lock stealing or multi-host/distributed-filesystem claim is made. +Production multi-replica hosts supply their own atomic durable store and recoverable +executor. In-memory storage does not satisfy the advertisement gate. + +## Apps and elicitation + +`isToolVisibleToModel` filters app-only tools from model catalogs. +`assertAppToolCall` enforces app visibility and same-server connection; +`createAppHostBridge` rechecks host authorization before dispatch. +`getAppHostRequirements` records requested CSP/permissions/domain plus mandatory +sandbox, authenticated message bridge and lifecycle obligations. It is **not a +renderer, HTML validator, CSP header generator, or sandbox implementation**. +Only a real mounted host satisfying these obligations can advertise Apps support. +Tool-result private metadata belongs to the app, never model context. The host +must verify message source/origin, await initialization, enforce restrictive CSP +without undeclared domains, and obtain navigation/permission consent. + +`createElicitationRuntime` exposes only installed form/URL callbacks. It uses shared +wire/schema validation, rejects absent services, validates accepted form data, +and never converts opening a URL into acceptance. URL navigation policy requires +HTTPS without embedded credentials. Sampling and roots remain unsupported. Host +callbacks must present trustworthy origin and consent UI; they are not generated +by transport negotiation flags. + +### Review hardening + +All four Skills publisher methods emit `ttlMs: 0` and `cacheScope: "private"`. +This conservative policy permits no freshness reuse and no shared/public cache +assumption, even though the publisher owns immutable byte snapshots. Hosts must +still authorize each request; manifest-verified loader caching is separate. + +A source read/stream/decoding rejection revokes the current held generation just +like a local integrity mismatch. Refresh and explicit approval are required before +activation resumes. Revocation is irreversible for existing held entries and +leases: clearing the loader or changing A to B and back to A never revives an old +acting window. Hosts must continue invoking the supplied live execution guard. + +File-store close is a one-shot operation shared by concurrent callers. It stops +accepting new operations, drains previously accepted operations, and releases its +lock once. Calling a stale store's close again cannot remove a successor's lock. + +Direct and task elicitation share request-aware response checks: URL-mode responses +must omit content; declined/cancelled responses must omit content; accepted form +responses must satisfy the requested schema. Invalid task input is rejected before +any durable mutation or executor dispatch. diff --git a/src/extensions/apps.ts b/src/extensions/apps.ts new file mode 100644 index 0000000..9b8d7b6 --- /dev/null +++ b/src/extensions/apps.ts @@ -0,0 +1,101 @@ +import type { + McpUiResourceMeta, + McpUiToolMeta, + McpResourceContents, +} from "../protocol/index.js"; + +/** Metadata helpers only: this package does not render or sandbox HTML. */ +export function isToolVisibleToModel(meta?: McpUiToolMeta): boolean { + return meta?.visibility === undefined || meta.visibility.includes("model"); +} +export function assertAppToolCall(options: { + tool?: McpUiToolMeta; + appServerId: string; + targetServerId: string; +}): void { + if (!options.appServerId || options.appServerId !== options.targetServerId) + throw new Error("Cross-server app tool call prohibited"); + if (options.tool?.visibility && !options.tool.visibility.includes("app")) + throw new Error("Tool is not app-callable"); +} +export function getAppHostRequirements(meta: McpUiResourceMeta = {}) { + return { + rendererProvided: false as const, + protocolVersion: "2026-01-26" as const, + mimeType: "text/html;profile=mcp-app" as const, + sandbox: { + separateOrigin: true as const, + proxyRequiredForWebHost: true as const, + permissions: ["allow-scripts", "allow-same-origin"] as const, + }, + // These are requested constraints, not a CSP header generator or approval. + csp: structuredClone(meta.csp ?? {}), + defaults: { + connectSrc: "'none'", + resourceSrc: "'none'", + frameSrc: "'none'", + baseUri: "'self'", + }, + requestedPermissions: structuredClone(meta.permissions ?? {}), + requestedDomain: meta.domain, + obligations: [ + "enforce-declared-csp-without-loosening", + "validate-message-origin-and-source", + "initialize-before-delivery", + "authorize-every-tool-call", + "keep-result-meta-out-of-model-context", + "user-consent-for-external-navigation", + ] as const, + }; +} +export interface McpAppHost { + /** Must implement sandbox, CSP and authenticated message bridge; not a transport flag. */ + mount( + resource: McpResourceContents, + requirements: ReturnType, + ): Promise<{ close(): Promise }>; + authorizeTool( + serverId: string, + name: string, + args: Record, + ): Promise; + callTool( + serverId: string, + name: string, + args: Record, + ): Promise; +} +export function createAppHostBridge(host: McpAppHost) { + return { + async mount(resource: McpResourceContents, metadata?: McpUiResourceMeta) { + if ( + !resource.uri.startsWith("ui://") || + resource.mimeType !== "text/html;profile=mcp-app" + ) + throw new Error("Unsupported Apps resource"); + return host.mount( + structuredClone(resource), + getAppHostRequirements(metadata), + ); + }, + async call(options: { + appServerId: string; + targetServerId: string; + name: string; + arguments: Record; + tool?: McpUiToolMeta; + }) { + assertAppToolCall(options); + await host.authorizeTool( + options.targetServerId, + options.name, + options.arguments, + ); + return host.callTool( + options.targetServerId, + options.name, + options.arguments, + ); + }, + }; +} diff --git a/src/extensions/elicitation.ts b/src/extensions/elicitation.ts new file mode 100644 index 0000000..f432c83 --- /dev/null +++ b/src/extensions/elicitation.ts @@ -0,0 +1,83 @@ +import type { + ElicitRequestParams, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + InputRequests, + InputResponses, +} from "../protocol/index.js"; +import { + validateInstance, + validateInputRequests, + validateInputResponses, +} from "../protocol/index.js"; + +/** Request-aware host semantics shared by direct and durable task input. + * Wire shape validation remains owned by the common protocol module. + */ +export function validateElicitationResponse( + request: ElicitRequestParams, + response: InputResponses[string], +): void { + validateInputResponses({ response }); + if (request.mode === "url" && response.content !== undefined) + throw new Error("URL elicitation cannot return form content"); + if (response.action !== "accept" && response.content !== undefined) + throw new Error("Non-accept elicitation must omit content"); + if (request.mode !== "url" && response.action === "accept") + validateInstance(request.requestedSchema, response.content ?? {}); +} + +export interface McpElicitationHost { + form?( + request: ElicitRequestFormParams, + signal?: AbortSignal, + ): Promise; + /** Host presents URL and obtains explicit user consent. Opening a URL is not acceptance. */ + url?( + request: ElicitRequestURLParams, + signal?: AbortSignal, + ): Promise; +} +/** Input has already passed the common wire validators. No sampling/roots capability implied. */ +export function createElicitationRuntime(host: McpElicitationHost) { + return { + capabilities: { + ...(host.form ? { form: {} } : {}), + ...(host.url ? { url: {} } : {}), + }, + async respond( + requests: InputRequests, + signal?: AbortSignal, + ): Promise { + validateInputRequests(requests); + const responses: InputResponses = {}; + for (const [key, request] of Object.entries(requests)) { + signal?.throwIfAborted(); + if (request.method !== "elicitation/create") + throw new Error(`Unsupported host input: ${request.method}`); + const params = request.params; + let response: ElicitResult; + if (params.mode === "url") { + const url = new URL(params.url); + if (url.protocol !== "https:" || url.username || url.password) + throw new Error("Unsafe elicitation URL"); + if (!host.url) throw new Error("URL elicitation host unavailable"); + response = await host.url(structuredClone(params), signal); + } else { + if (!host.form) throw new Error("Form elicitation host unavailable"); + response = await host.form(structuredClone(params), signal); + } + validateElicitationResponse(params, response); + signal?.throwIfAborted(); + Object.defineProperty(responses, key, { + value: structuredClone(response), + enumerable: true, + configurable: true, + writable: true, + }); + } + return responses; + }, + }; +} diff --git a/src/extensions/host.test.ts b/src/extensions/host.test.ts new file mode 100644 index 0000000..717aab7 --- /dev/null +++ b/src/extensions/host.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test"; +import { + assertAppToolCall, + createAppHostBridge, + getAppHostRequirements, + isToolVisibleToModel, +} from "./apps.js"; +import { createElicitationRuntime } from "./elicitation.js"; +import { extensionMaturity, supportedHostExtensions } from "./maturity.js"; +test("released maturity preserves wire IDs and absent host support stays absent", () => { + expect(extensionMaturity.tasks.maturity).toBe("stable"); + expect(extensionMaturity.skills.id).toBe("io.modelcontextprotocol/skills"); + expect(extensionMaturity.apps.extensionRevision).toBe("2026-01-26"); + expect(supportedHostExtensions({})).toEqual([]); + expect( + supportedHostExtensions({ + apps: { + sandboxRenderer: false, + cspEnforcement: true, + authenticatedBridge: true, + }, + }), + ).toEqual([]); +}); +test("Apps visibility and connection boundaries apply independently of transport", async () => { + expect(isToolVisibleToModel()).toBe(true); + expect(isToolVisibleToModel({ visibility: ["app"] })).toBe(false); + expect(() => + assertAppToolCall({ appServerId: "a", targetServerId: "b" }), + ).toThrow("Cross-server"); + expect(() => + assertAppToolCall({ + appServerId: "a", + targetServerId: "a", + tool: { visibility: ["model"] }, + }), + ).toThrow("not app-callable"); + const calls: string[] = []; + const bridge = createAppHostBridge({ + async mount(_resource, requirements) { + expect(requirements.rendererProvided).toBe(false); + return { async close() {} }; + }, + async authorizeTool() { + calls.push("authorize"); + }, + async callTool() { + calls.push("call"); + return "ok"; + }, + }); + await bridge.call({ + appServerId: "a", + targetServerId: "a", + name: "tool", + arguments: {}, + }); + expect(calls).toEqual(["authorize", "call"]); + await expect( + bridge.mount({ + uri: "https://unsafe", + mimeType: "text/html", + text: "", + }), + ).rejects.toThrow("Unsupported"); + expect(getAppHostRequirements().defaults.connectSrc).toBe("'none'"); +}); +test("elicitation requires real host services and validates accepted form content", async () => { + const form = { + method: "elicitation/create" as const, + params: { + message: "Name", + requestedSchema: { + type: "object" as const, + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }; + expect(createElicitationRuntime({}).capabilities).toEqual({}); + await expect( + createElicitationRuntime({}).respond({ q: form }), + ).rejects.toThrow("host unavailable"); + const runtime = createElicitationRuntime({ + async form() { + return { action: "accept", content: { name: "Ada" } }; + }, + }); + expect((await runtime.respond({ q: form })).q).toEqual({ + action: "accept", + content: { name: "Ada" }, + }); + await expect( + createElicitationRuntime({ + async form() { + return { action: "accept", content: { name: 4 } }; + }, + }).respond({ q: form }), + ).rejects.toThrow(); + await expect( + runtime.respond({ q: { method: "roots/list" } }), + ).rejects.toThrow("unsupported"); +}); +test("URL elicitation is explicit consent, never automatic acceptance or credential-bearing navigation", async () => { + const request = { + method: "elicitation/create" as const, + params: { + mode: "url" as const, + message: "Visit", + url: "https://example.com/consent", + }, + }; + const runtime = createElicitationRuntime({ + async url() { + return { action: "decline" }; + }, + }); + expect((await runtime.respond({ q: request })).q).toEqual({ + action: "decline", + }); + await expect( + runtime.respond({ + q: { + ...request, + params: { ...request.params, url: "https://user:secret@example.com" }, + }, + }), + ).rejects.toThrow("Unsafe"); + await expect( + createElicitationRuntime({ + async url() { + return { action: "accept", content: { token: "no" } }; + }, + }).respond({ q: request }), + ).rejects.toThrow("cannot return form content"); +}); diff --git a/src/extensions/index.ts b/src/extensions/index.ts new file mode 100644 index 0000000..f44b31f --- /dev/null +++ b/src/extensions/index.ts @@ -0,0 +1,5 @@ +export * from "./skills/index.js"; +export * from "./tasks/index.js"; +export * from "./apps.js"; +export * from "./elicitation.js"; +export * from "./maturity.js"; diff --git a/src/extensions/maturity.ts b/src/extensions/maturity.ts new file mode 100644 index 0000000..f43fb70 --- /dev/null +++ b/src/extensions/maturity.ts @@ -0,0 +1,52 @@ +/** Upstream maturity, not transport support or a claim about host deployment. */ +import { + MCP_EXTENSIONS, + MCP_EXT_TASKS, + MCP_EXT_SKILLS, + MCP_EXT_UI, +} from "../protocol/index.js"; +export const extensionMaturity = Object.freeze({ + tasks: MCP_EXTENSIONS[MCP_EXT_TASKS], + skills: MCP_EXTENSIONS[MCP_EXT_SKILLS], + apps: MCP_EXTENSIONS[MCP_EXT_UI], +}); + +/** Negotiating a wire extension does not supply a renderer, approvals or durability. */ +export function supportedHostExtensions(options: { + tasks?: { + durableStore: boolean; + recoverableExecutor: boolean; + inputHandler: boolean; + }; + skills?: { + verifiedLoader: boolean; + contextProvenance: boolean; + executionGate: boolean; + }; + apps?: { + sandboxRenderer: boolean; + cspEnforcement: boolean; + authenticatedBridge: boolean; + }; +}): string[] { + const ids: string[] = []; + if ( + options.tasks?.durableStore === true && + options.tasks.recoverableExecutor === true && + options.tasks.inputHandler === true + ) + ids.push(extensionMaturity.tasks.id); + if ( + options.skills?.verifiedLoader === true && + options.skills.contextProvenance === true && + options.skills.executionGate === true + ) + ids.push(extensionMaturity.skills.id); + if ( + options.apps?.sandboxRenderer === true && + options.apps.cspEnforcement === true && + options.apps.authenticatedBridge === true + ) + ids.push(extensionMaturity.apps.id); + return ids; +} diff --git a/src/extensions/review-regressions.test.ts b/src/extensions/review-regressions.test.ts new file mode 100644 index 0000000..cb09ac0 --- /dev/null +++ b/src/extensions/review-regressions.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createSkillLoader, createSkillPublisher } from "./skills/index.js"; +import { createFileTaskStore } from "./tasks/file-store.js"; +import { createTaskRuntime, validateTaskRecord } from "./tasks/index.js"; +import { createElicitationRuntime } from "./elicitation.js"; +import type { McpSkillHostPolicy } from "./skills/contracts.js"; +import type { ElicitRequestParams, ElicitResult } from "../protocol/index.js"; + +const uri = "skill://demo/SKILL.md"; +const support = "skill://demo/code.txt"; +const origin = { + serverId: "server", + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "auth", +}; +const bytes = new TextEncoder().encode( + "---\nname: demo\ndescription: Demo\n---\nInstructions", +); +async function publication() { + return createSkillPublisher([ + { + uri, + files: new Map([ + [uri, bytes], + [support, new Uint8Array([1])], + ]), + }, + ]); +} +const host: McpSkillHostPolicy = { + async approve(held) { + return { + identity: held.identity, + fingerprint: held.fingerprint, + approved: true, + execution: true, + }; + }, + async openActingWindow() { + return { async admit() {}, async removeContext() {}, async close() {} }; + }, +}; +for (const method of ["list", "get", "read", "directoryRead"] as const) { + test(`publisher ${method} carries conservative wire cache policy`, async () => { + const publisher = await publication(); + const result = + method === "list" + ? publisher.list() + : publisher[method](method === "directoryRead" ? "skill://demo" : uri); + const wire = JSON.parse(JSON.stringify({ jsonrpc: "2.0", id: 1, result })); + expect(wire.result.resultType).toBe("complete"); + expect(wire.result.ttlMs).toBe(0); + expect(wire.result.cacheScope).toBe("private"); + }); +} +test("source rejection revokes live execution and requires a fresh held entry", async () => { + const publisher = await publication(); + const loader = createSkillLoader( + { + async get() { + return publisher.get(uri).skill; + }, + async read(_origin, key) { + if (key === support) throw new Error("source size limit"); + return bytes; + }, + }, + host, + ); + const held = await loader.get(origin, uri); + const lease = await loader.activate(held); + await expect(lease.read(support)).rejects.toThrow("source size limit"); + expect(() => lease.assertExecutionApproved()).toThrow("revoked"); + await expect(loader.activate(held)).rejects.toThrow("revoked"); + const fresh = await loader.activate(await loader.get(origin, uri)); + fresh.assertExecutionApproved(); + expect(() => lease.assertExecutionApproved()).toThrow("revoked"); +}); +for (const change of ["clear", "rotate"] as const) { + test(`revoked lease never revives after ${change} and identical reactivation`, async () => { + const publisher = await publication(); + const loader = createSkillLoader( + { + async get() { + return publisher.get(uri).skill; + }, + async read() { + return bytes; + }, + }, + host, + ); + const lease = await loader.activate(await loader.get(origin, uri)); + if (change === "clear") loader.clear(); + else { + const changed = publisher.get(uri).skill; + changed.frontmatter.description = "changed"; + await loader.hold(origin, changed); + } + expect(() => lease.assertExecutionApproved()).toThrow("revoked"); + const fresh = await loader.activate(await loader.get(origin, uri)); + fresh.assertExecutionApproved(); + expect(() => lease.assertExecutionApproved()).toThrow("revoked"); + await expect(lease.read(uri)).rejects.toThrow("revoked"); + }); +} +test("repeated and concurrent stale closes cannot unlink a successor writer lock", async () => { + const directory = await mkdtemp(join(tmpdir(), "mcp-close-regression-")); + const options = { directory, validateRecord: validateTaskRecord }; + const a = await createFileTaskStore(options); + let b: Awaited> | undefined; + let c: Awaited> | undefined; + try { + const pendingRead = a.list(); + const closing = a.close(); + await expect(a.list()).rejects.toThrow("closed"); + await Promise.all([closing, a.close()]); + await pendingRead; + b = await createFileTaskStore(options); + await Promise.all([a.close(), a.close()]); + await expect( + createFileTaskStore(options).then((store) => { + c = store; + }), + ).rejects.toThrow(); + await Promise.all([b.close(), b.close()]); + c = await createFileTaskStore(options); + await Promise.all([a.close(), b.close()]); + await expect(createFileTaskStore(options)).rejects.toThrow(); + } finally { + await a.close(); + await b?.close(); + await c?.close(); + await rm(directory, { recursive: true, force: true }); + } +}); +const cases: { + name: string; + params: ElicitRequestParams; + response: ElicitResult; +}[] = [ + { + name: "URL content", + params: { mode: "url", url: "https://example.com", message: "Continue" }, + response: { action: "accept", content: { token: "secret" } }, + }, + { + name: "declined form content", + params: { + message: "Continue", + requestedSchema: { type: "object", properties: {} }, + }, + response: { action: "decline", content: { token: "secret" } }, + }, +]; +for (const entry of cases) + test(`direct and task elicitation reject ${entry.name} before durable mutation`, async () => { + const request = { + method: "elicitation/create" as const, + params: entry.params, + }; + await expect( + createElicitationRuntime({ + async form() { + return entry.response; + }, + async url() { + return entry.response; + }, + }).respond({ input: request }), + ).rejects.toThrow(); + const directory = await mkdtemp(join(tmpdir(), "mcp-input-regression-")); + const store = await createFileTaskStore({ + directory, + validateRecord: validateTaskRecord, + }); + let dispatched = 0; + try { + const runtime = createTaskRuntime({ + store, + async authorizeInput() {}, + executor: { + async start() {}, + async recover() { + return undefined; + }, + async requestCancel() {}, + async submitInput() { + dispatched++; + }, + }, + }); + const task = await runtime.create(origin, { name: "test" }); + await runtime.transition(origin, task.taskId, { + ...task, + status: "input_required", + inputRequests: { input: request }, + }); + const before = await store.get(task.taskId); + await expect( + runtime.submitInput(origin, task.taskId, { input: entry.response }), + ).rejects.toThrow(); + expect(await store.get(task.taskId)).toEqual(before); + expect(dispatched).toBe(0); + await runtime.submitInput(origin, task.taskId, { + input: { action: "decline" }, + }); + expect(dispatched).toBe(1); + } finally { + await store.close(); + await rm(directory, { recursive: true, force: true }); + } + }); diff --git a/src/extensions/skills/contracts.ts b/src/extensions/skills/contracts.ts new file mode 100644 index 0000000..691b0e9 --- /dev/null +++ b/src/extensions/skills/contracts.ts @@ -0,0 +1,60 @@ +import type { Skill } from "../../protocol/index.js"; + +/** Host-assigned identities, never serverInfo.name or a bearer token. */ +export interface McpSkillOrigin { + serverId: string; + tenantId: string; + principalId: string; + authorizationContextId: string; +} +export interface HeldSkill { + readonly origin: Readonly; + readonly entry: Readonly; + readonly identity: string; + readonly fingerprint: string; +} +export interface SkillApprovalReceipt { + identity: string; + fingerprint: string; + /** Explicit user consent to this skill, not to nested skills. */ + approved: true; + execution: boolean; +} +export interface VerifiedSkillBytes { + held: HeldSkill; + uri: string; + bytes: Uint8Array; + /** Always untrusted MCP data; integrity is not authorship or trust. */ + trust: "untrusted-mcp"; +} +export interface McpSkillHostPolicy { + approve(held: HeldSkill): Promise; + /** Install an all-code-execution-tool gate before admitting context. */ + openActingWindow( + held: HeldSkill, + approval: SkillApprovalReceipt, + guards: { assertExecutionApproved(): void }, + ): Promise<{ + admit(content: VerifiedSkillBytes): Promise; + /** Must remove context before returning; do not shorten the acting window. */ + removeContext(): Promise; + close(): Promise; + }>; +} +export interface SkillLease { + readonly held: HeldSkill; + read(uri: string): Promise; + /** Re-check for every host code-execution call; never widens existing grants. */ + assertExecutionApproved(): void; + release(): Promise; +} +export interface SkillSource { + /** Must use the origin's mediated transport and shared protocol validation. */ + get(origin: McpSkillOrigin, uri: string): Promise; + /** Decode a single matching resources/read text/blob to raw bytes. Enforce maxBytes while streaming. */ + read( + origin: McpSkillOrigin, + uri: string, + maxBytes: number, + ): Promise; +} diff --git a/src/extensions/skills/index.ts b/src/extensions/skills/index.ts new file mode 100644 index 0000000..345d50a --- /dev/null +++ b/src/extensions/skills/index.ts @@ -0,0 +1,434 @@ +import { parseDocument, visit, isAlias } from "yaml"; +import { type } from "arktype"; +import { validateSkill } from "../../protocol/index.js"; +import type { Skill } from "../../protocol/index.js"; +import type { + HeldSkill, + McpSkillHostPolicy, + McpSkillOrigin, + SkillApprovalReceipt, + SkillLease, + SkillSource, + VerifiedSkillBytes, +} from "./contracts.js"; +export type * from "./contracts.js"; + +export const SKILL_LIMITS = Object.freeze({ + resources: 512, + bytes: 16_777_216, +}); +const frontmatterShape = type({ name: "string", description: "string" }); +const fail = (message: string): never => { + throw Object.assign(new Error(message), { code: -32602 }); +}; +const encoder = new TextEncoder(); +const canonical = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value !== null && typeof value === "object") + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`) + .join(",")}}`; + return JSON.stringify(value) ?? fail("Non-JSON frontmatter"); +}; +async function digest(bytes: Uint8Array): Promise { + const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)); + return `sha256:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("")}`; +} +function safeUri(uri: string): void { + const parsed = new URL(uri); + if ( + parsed.search || + parsed.hash || + parsed.username || + parsed.password || + /[\\\s]/u.test(uri) + ) + fail("Unsafe skill URI"); + const segments = uri.slice(uri.indexOf(":") + 1).split("/"); + for (const segment of segments) { + const decoded = decodeURIComponent(segment); + if ( + decoded === "." || + decoded === ".." || + /[/\\]/u.test(decoded) || + decoded.includes(String.fromCharCode(0)) + ) + fail("Unsafe skill path"); + } +} +function checkFrontmatter(value: unknown, uri: string): Skill["frontmatter"] { + const parsed = frontmatterShape(value); + if (parsed instanceof type.errors) return fail("Invalid skill frontmatter"); + const { name, description } = parsed; + if ( + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) || + name.length > 64 || + !description.trim() || + description.length > 1024 + ) + fail("Invalid skill name or description"); + if (!uri.endsWith(`/${name}/SKILL.md`)) + fail("Skill name does not match directory"); + const fm = value as Skill["frontmatter"]; + if ( + fm.compatibility !== undefined && + (typeof fm.compatibility !== "string" || + !fm.compatibility.length || + fm.compatibility.length > 500) + ) + fail("Invalid compatibility"); + for (const key of ["license", "allowed-tools"]) + if (fm[key] !== undefined && typeof fm[key] !== "string") + fail(`Invalid ${key}`); + if ( + fm.metadata !== undefined && + (fm.metadata === null || + typeof fm.metadata !== "object" || + Array.isArray(fm.metadata) || + Object.values(fm.metadata).some((v) => typeof v !== "string")) + ) + fail("Invalid metadata"); + return fm; +} +export function parseSkillFrontmatter( + bytes: Uint8Array, + uri: string, +): Skill["frontmatter"] { + if (bytes.byteLength > SKILL_LIMITS.bytes) fail("Skill exceeds 16 MiB"); + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(text); + if (!match) return fail("Missing YAML frontmatter"); + // Bound parser nesting before conversion, and prohibit aliases entirely. + const doc = parseDocument(match[1]!, { schema: "core", uniqueKeys: true }); + if (doc.errors.length) fail("Invalid YAML frontmatter"); + let nodes = 0; + visit(doc, (_key, node, path) => { + if (isAlias(node) || path.length > 32 || ++nodes > 100_000) + fail("Frontmatter structure limit or alias"); + }); + const value: unknown = doc.toJS({ maxAliasCount: 0 }); + const inspect = (v: unknown, depth: number): void => { + if (depth > 32) fail("Frontmatter nesting limit"); + if (v !== null && typeof v === "object") + for (const child of Object.values(v)) inspect(child, depth + 1); + else if (!["string", "number", "boolean"].includes(typeof v) && v !== null) + fail("Non-JSON frontmatter"); + if (typeof v === "number" && !Number.isFinite(v)) fail("Non-JSON number"); + }; + inspect(value, 0); + return checkFrontmatter(value, uri); +} +function checkEntry(entry: Skill): void { + validateSkill(entry); + safeUri(entry.uri); + checkFrontmatter(entry.frontmatter, entry.uri); + if (entry.resources === "dynamic") + return fail("Dynamic skills cannot be content-bound and are not supported"); + if ( + !Array.isArray(entry.resources) || + entry.resources.length > SKILL_LIMITS.resources + ) + fail("Skill exceeds 512 resources or lacks manifest"); + const root = entry.uri.slice(0, -"SKILL.md".length); + let total = 0; + for (const file of entry.resources) { + safeUri(file.uri); + if (!file.uri.startsWith(root) || file.uri.endsWith("/")) + fail("Manifest has duplicate or out-of-directory file"); + total += file.size; + } + if (total > SKILL_LIMITS.bytes) fail("Skill exceeds 16 MiB"); +} +function freeze(value: T): T { + if (value !== null && typeof value === "object") { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +} +export function createSkillLoader( + source: SkillSource, + host?: McpSkillHostPolicy, +) { + const heldEntries = new WeakMap(); + const current = new Map< + string, + { fingerprint: string; generation: object } + >(); + const approvals = new Map(); + const cache = new Map(); + let cacheBytes = 0; + async function hold( + origin: McpSkillOrigin, + entry: Skill, + ): Promise { + if (Object.values(origin).some((v) => !v)) + fail("Host origin identity required"); + checkEntry(entry); + const identity = canonical([ + origin.serverId, + origin.tenantId, + origin.principalId, + origin.authorizationContextId, + entry.uri, + ]); + const resources = entry.resources; + if (resources === "dynamic") return fail("Dynamic skills unsupported"); + const fingerprint = await digest( + encoder.encode( + canonical({ + frontmatter: entry.frontmatter, + resources: [...resources].sort((a, b) => a.uri.localeCompare(b.uri)), + }), + ), + ); + if (current.get(identity)?.fingerprint !== fingerprint) { + approvals.delete(identity); + current.set(identity, { fingerprint, generation: {} }); + } + const generation = current.get(identity)!.generation; + const held: HeldSkill = freeze({ + origin: structuredClone(origin), + entry: structuredClone(entry), + identity, + fingerprint, + }); + heldEntries.set(held, generation); + return held; + } + function assertCurrent(held: HeldSkill): void { + if ( + !heldEntries.has(held) || + current.get(held.identity)?.generation !== heldEntries.get(held) + ) + fail("Skill entry revoked; refresh and reapprove"); + } + async function verify( + held: HeldSkill, + uri = held.entry.uri, + ): Promise { + assertCurrent(held); + try { + const files = held.entry.resources; + if (files === "dynamic") return fail("Dynamic skills unsupported"); + const file = files.find((f) => f.uri === uri); + if (!file) return fail("Unlisted skill file"); + const key = canonical([held.identity, uri, file.digest]); + const bytes = new Uint8Array( + cache.get(key) ?? (await source.read(held.origin, uri, file.size)), + ); + assertCurrent(held); + if (bytes.byteLength !== file.size) + return fail("Skill integrity mismatch"); + const actualDigest = await digest(bytes); + // A source read or digest can finish after refresh/clear. Reject stale + // work before parsing frontmatter, changing the cache, or exposing bytes. + assertCurrent(held); + if (actualDigest !== file.digest) { + cache.delete(key); + return fail("Skill integrity mismatch"); + } + if ( + uri === held.entry.uri && + canonical(parseSkillFrontmatter(bytes, uri)) !== + canonical(held.entry.frontmatter) + ) + return fail("Skill frontmatter mismatch"); + if (!cache.has(key)) { + if (cacheBytes + bytes.length > SKILL_LIMITS.bytes) { + cache.clear(); + cacheBytes = 0; + } + cache.set(key, bytes.slice()); + cacheBytes += bytes.length; + } + return { held, uri, bytes, trust: "untrusted-mcp" }; + } catch (error) { + // Every verification failure, including source, digest and YAML errors, + // may invalidate only the generation that initiated this verification. + if (current.get(held.identity)?.generation === heldEntries.get(held)) { + approvals.delete(held.identity); + current.delete(held.identity); + } + throw error; + } + } + + async function activate(held: HeldSkill): Promise { + assertCurrent(held); + if (!host) + return fail( + "Skill host policy unavailable; verified bytes are not activation", + ); + let approval = approvals.get(held.identity); + if (!approval) { + approval = await host.approve(held); + if ( + approval.approved !== true || + approval.identity !== held.identity || + approval.fingerprint !== held.fingerprint + ) + return fail("Skill approval denied or mismatched"); + approval = freeze(structuredClone(approval)); + assertCurrent(held); + approvals.set(held.identity, approval); + } + const content = await verify(held); + let active = true; + const assertExecutionApproved = () => { + assertCurrent(held); + if (!active || !approvals.get(held.identity)?.execution) + fail("Explicit per-skill execution approval required"); + }; + const window = await host.openActingWindow(held, approval, { + assertExecutionApproved, + }); + try { + assertCurrent(held); + await window.admit(content); + } catch (error) { + await window.removeContext(); + await window.close(); + throw error; + } + return { + held, + read: (uri) => { + if (!active) return Promise.reject(new Error("Skill lease closed")); + return verify(held, uri); + }, + assertExecutionApproved, + async release() { + if (active) { + await window.removeContext(); + await window.close(); + active = false; + } + }, + }; + } + return { + hold, + verify, + activate, + async get(origin: McpSkillOrigin, uri: string) { + const entry = await source.get(origin, uri); + if (entry.uri !== uri) return fail("Skill URI mismatch"); + return hold(origin, entry); + }, + clear() { + cache.clear(); + cacheBytes = 0; + approvals.clear(); + current.clear(); + }, + }; +} + +/** In-memory immutable snapshot publisher. Host must authorize all methods using the same identity. */ +export async function createSkillPublisher( + publications: readonly { + uri: string; + files: ReadonlyMap; + }[], +) { + const skills = new Map(); + const files = new Map(); + for (const original of publications) { + const publication = { + uri: original.uri, + files: new Map( + [...original.files].map(([uri, bytes]) => [uri, bytes.slice()]), + ), + }; + if (skills.has(publication.uri)) fail("Duplicate skill publication"); + const main = publication.files.get(publication.uri); + if (!main) return fail("Publication omits SKILL.md"); + const resources = await Promise.all( + [...publication.files].map(async ([uri, bytes]) => ({ + uri, + digest: await digest(bytes), + size: bytes.length, + })), + ); + const entry: Skill = { + uri: publication.uri, + frontmatter: parseSkillFrontmatter(main, publication.uri), + resources, + }; + checkEntry(entry); + skills.set(entry.uri, entry); + for (const [uri, bytes] of publication.files) { + const previous = files.get(uri); + if (previous && (await digest(previous)) !== (await digest(bytes))) + fail("Conflicting nested publication"); + files.set(uri, bytes.slice()); + } + } + for (const skill of skills.values()) { + const root = skill.uri.slice(0, -"SKILL.md".length); + if (skill.resources !== "dynamic") + for (const uri of files.keys()) + if ( + uri.startsWith(root) && + !skill.resources.some((file) => file.uri === uri) + ) + fail("Incomplete enclosing skill manifest"); + } + return { + list() { + return { + resultType: "complete" as const, + ttlMs: 0, + cacheScope: "private" as const, + skills: structuredClone([...skills.values()]), + }; + }, + get(uri: string) { + const skill = skills.get(uri); + if (!skill) return fail("Unknown skill"); + return { + resultType: "complete" as const, + ttlMs: 0, + cacheScope: "private" as const, + skill: structuredClone(skill), + }; + }, + read(uri: string) { + const bytes = files.get(uri); + if (!bytes) return fail("Unknown resource"); + return { + resultType: "complete" as const, + ttlMs: 0, + cacheScope: "private" as const, + contents: [{ uri, blob: Buffer.from(bytes).toString("base64") }], + }; + }, + directoryRead(uri: string) { + safeUri(uri); + if (uri.endsWith("/")) fail("Directory URI must not end in slash"); + const children = new Map< + string, + { uri: string; name: string; mimeType?: string } + >(); + for (const file of files.keys()) + if (file.startsWith(`${uri}/`)) { + const relative = file.slice(uri.length + 1); + const name = relative.split("/")[0]!; + children.set(name, { + uri: `${uri}/${name}`, + name, + ...(relative.includes("/") ? { mimeType: "inode/directory" } : {}), + }); + } + if (!children.size) return fail("Unknown directory"); + return { + resultType: "complete" as const, + ttlMs: 0, + cacheScope: "private" as const, + resources: [...children.values()], + }; + }, + }; +} diff --git a/src/extensions/skills/skills.test.ts b/src/extensions/skills/skills.test.ts new file mode 100644 index 0000000..241e9fc --- /dev/null +++ b/src/extensions/skills/skills.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from "bun:test"; +import { + createSkillLoader, + createSkillPublisher, + parseSkillFrontmatter, + SKILL_LIMITS, +} from "./index.js"; +import type { + McpSkillHostPolicy, + McpSkillOrigin, + SkillSource, +} from "./contracts.js"; +const uri = "skill://demo/SKILL.md"; +const encode = (text: string) => new TextEncoder().encode(text); +const main = encode( + "---\nname: demo\ndescription: Demo skill\nmetadata:\n author: example\nfuture-field: yes\n---\nUntrusted instructions\n", +); +const origin: McpSkillOrigin = { + serverId: "host-assigned-A", + tenantId: "t", + principalId: "p", + authorizationContextId: "auth", +}; +async function fixture(extra = new Map()) { + const files = new Map([[uri, main], ...extra]); + const publisher = await createSkillPublisher([{ uri, files }]); + let reads = 0; + const source: SkillSource = { + async get(_origin, key) { + return publisher.get(key).skill; + }, + async read(_origin, key) { + reads++; + return files.get(key)!.slice(); + }, + }; + return { files, publisher, source, reads: () => reads }; +} +function policy(events: string[], execution = false): McpSkillHostPolicy { + return { + async approve(held) { + events.push("approve"); + return { + identity: held.identity, + fingerprint: held.fingerprint, + approved: true, + execution, + }; + }, + async openActingWindow() { + events.push("gate"); + return { + async admit(content) { + expect(content.trust).toBe("untrusted-mcp"); + events.push("admit"); + }, + async removeContext() { + events.push("remove"); + }, + async close() { + events.push("close"); + }, + }; + }, + }; +} +describe("pinned Skills integrity and approval", () => { + test("publishes complete manifests, verbatim frontmatter and direct directory children", async () => { + const f = await fixture( + new Map([["skill://demo/scripts/run.sh", encode("echo safe")]]), + ); + expect(f.publisher.list().skills).toHaveLength(1); + const entry = f.publisher.get(uri).skill; + expect(entry.frontmatter["future-field"]).toBe("yes"); + expect(entry.resources).toHaveLength(2); + expect(f.publisher.directoryRead("skill://demo").resources).toContainEqual({ + uri: "skill://demo/scripts", + name: "scripts", + mimeType: "inode/directory", + }); + expect(() => f.publisher.get("skill://missing/SKILL.md")).toThrow( + "Unknown", + ); + expect(f.publisher.read(uri).contents[0]?.blob).toBe( + Buffer.from(main).toString("base64"), + ); + }); + test("lazy retrieval, isolated cache, immutable entry and bytes", async () => { + const f = await fixture(); + const loader = createSkillLoader(f.source); + const held = await loader.get(origin, uri); + expect(f.reads()).toBe(0); + const first = await loader.verify(held); + first.bytes.fill(0); + expect((await loader.verify(held)).bytes).toEqual(main); + expect(f.reads()).toBe(1); + const other = await loader.get({ ...origin, serverId: "B" }, uri); + expect(other.identity).not.toBe(held.identity); + await loader.verify(other); + expect(f.reads()).toBe(2); + expect(Object.isFrozen(held.entry.frontmatter)).toBe(true); + await expect(loader.activate(held)).rejects.toThrow( + "host policy unavailable", + ); + }); + test("approval precedes admission, no implicit execution and ordered teardown", async () => { + const f = await fixture(); + const events: string[] = []; + const loader = createSkillLoader(f.source, policy(events)); + const lease = await loader.activate(await loader.get(origin, uri)); + expect(events).toEqual(["approve", "gate", "admit"]); + expect(() => lease.assertExecutionApproved()).toThrow("execution approval"); + await lease.release(); + expect(events.slice(-2)).toEqual(["remove", "close"]); + await expect(lease.read(uri)).rejects.toThrow("closed"); + }); + test("manifest changes revoke live execution and require reapproval", async () => { + const f = await fixture(); + const events: string[] = []; + const loader = createSkillLoader(f.source, policy(events, true)); + const lease = await loader.activate(await loader.get(origin, uri)); + lease.assertExecutionApproved(); + const newFiles = new Map([ + [uri, main], + ["skill://demo/new.txt", encode("new")], + ]); + const changed = await createSkillPublisher([{ uri, files: newFiles }]); + const fresh = await loader.hold(origin, changed.get(uri).skill); + expect(() => lease.assertExecutionApproved()).toThrow("revoked"); + await loader.activate(fresh); + expect(events.filter((v) => v === "approve")).toHaveLength(2); + }); + test("size, digest, frontmatter and unlisted bytes fail closed", async () => { + for (const corruption of [encode("short"), new Uint8Array(main.length)]) { + const f = await fixture(); + f.files.set(uri, corruption); + const loader = createSkillLoader(f.source); + await expect( + loader.verify(await loader.get(origin, uri)), + ).rejects.toThrow("integrity"); + } + const f = await fixture(); + const entry = f.publisher.get(uri).skill; + entry.frontmatter.description = "Lie"; + const loader = createSkillLoader(f.source); + const held = await loader.hold(origin, entry); + await expect(loader.verify(held)).rejects.toThrow("frontmatter mismatch"); + const reheld = await loader.hold(origin, f.publisher.get(uri).skill); + await expect( + loader.verify(reheld, "skill://demo/unlisted"), + ).rejects.toThrow("Unlisted"); + await expect( + loader.hold(origin, { ...entry, resources: "dynamic" }), + ).rejects.toThrow("Dynamic"); + }); + test("rejects traversal, missing SKILL.md, duplicate manifests and mismatched receipts", async () => { + const f = await fixture(); + const entry = f.publisher.get(uri).skill; + if (entry.resources === "dynamic") throw new Error("fixture"); + const loader = createSkillLoader(f.source); + await expect( + loader.hold(origin, { + ...entry, + resources: [...entry.resources, ...entry.resources], + }), + ).rejects.toThrow("duplicate"); + await expect( + loader.hold(origin, { ...entry, resources: [] }), + ).rejects.toThrow("omits"); + await expect( + loader.hold(origin, { + ...entry, + resources: [ + ...entry.resources, + { ...entry.resources[0]!, uri: "skill://demo/%2e%2e/escape" }, + ], + }), + ).rejects.toThrow("Unsafe"); + const bad = policy([]); + bad.approve = async (held) => ({ + identity: "other", + fingerprint: held.fingerprint, + approved: true, + execution: true, + }); + const guarded = createSkillLoader(f.source, bad); + await expect( + guarded.activate(await guarded.get(origin, uri)), + ).rejects.toThrow("mismatched"); + }); + test("supports exact normative limits and rejects excess before reads", async () => { + const files = new Map([[uri, main]]); + for (let i = 0; i < 511; i++) + files.set( + `skill://demo/${i}.bin`, + new Uint8Array(i === 0 ? SKILL_LIMITS.bytes - main.length : 0), + ); + const publisher = await createSkillPublisher([{ uri, files }]); + const loader = createSkillLoader({ + async get() { + return publisher.get(uri).skill; + }, + async read(_origin, key) { + return files.get(key)!; + }, + }); + const held = await loader.get(origin, uri); + expect((await loader.verify(held, "skill://demo/0.bin")).bytes.length).toBe( + SKILL_LIMITS.bytes - main.length, + ); + const entry = publisher.get(uri).skill; + if (entry.resources === "dynamic") throw new Error("fixture"); + entry.resources.push({ + uri: "skill://demo/excess", + digest: `sha256:${"0".repeat(64)}`, + size: 0, + }); + await expect(loader.hold(origin, entry)).rejects.toThrow("512"); + }); + test("bounded YAML rejects aliases, malformed frontmatter and name mismatch", () => { + for (const yaml of [ + "name: demo\ndescription: &a test\nx: *a", + "name: wrong\ndescription: test", + "name: demo\nname: demo\ndescription: test", + ]) + expect(() => + parseSkillFrontmatter(encode(`---\n${yaml}\n---\n`), uri), + ).toThrow(); + }); +}); +test("unlisted reads revoke live execution approval", async () => { + const f = await fixture(); + const loader = createSkillLoader(f.source, policy([], true)); + const held = await loader.get(origin, uri); + const lease = await loader.activate(held); + await expect(lease.read("skill://demo/not-in-held-manifest")).rejects.toThrow( + "Unlisted", + ); + expect(() => lease.assertExecutionApproved()).toThrow(); +}); +test("nested supporting SKILL.md does not activate or inherit approval", async () => { + const nestedUri = "skill://demo/nested/SKILL.md"; + const nested = encode( + "---\nname: nested\ndescription: Nested instructions\nallowed-tools: Shell\n---\n", + ); + const f = await fixture(new Map([[nestedUri, nested]])); + const publisher = await createSkillPublisher([ + { uri, files: f.files }, + { uri: nestedUri, files: new Map([[nestedUri, nested]]) }, + ]); + const events: string[] = []; + const loader = createSkillLoader( + { + ...f.source, + async get(_origin, key) { + return publisher.get(key).skill; + }, + }, + policy(events, true), + ); + const outer = await loader.activate(await loader.get(origin, uri)); + await outer.read(nestedUri); + expect(events.filter((v) => v === "approve")).toHaveLength(1); + await loader.activate(await loader.get(origin, nestedUri)); + expect(events.filter((v) => v === "approve")).toHaveLength(2); +}); diff --git a/src/extensions/skills/verification-race.test.ts b/src/extensions/skills/verification-race.test.ts new file mode 100644 index 0000000..025a562 --- /dev/null +++ b/src/extensions/skills/verification-race.test.ts @@ -0,0 +1,95 @@ +import { expect, spyOn, test } from "bun:test"; +import { createSkillLoader, createSkillPublisher } from "./index.js"; +import type { McpSkillHostPolicy } from "./contracts.js"; + +for (const stale of ["frontmatter mismatch", "success"] as const) { + test(`stale verification ${stale} cannot revoke or serve after generation replacement`, async () => { + const uri = "skill://demo/SKILL.md"; + const bytes = new TextEncoder().encode( + "---\nname: demo\ndescription: Correct\n---\nInstructions", + ); + const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, + ]); + const origin = { + serverId: "server", + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "auth", + }; + let approvals = 0; + const host: McpSkillHostPolicy = { + async approve(held) { + approvals++; + return { + identity: held.identity, + fingerprint: held.fingerprint, + approved: true, + execution: true, + }; + }, + async openActingWindow() { + return { async admit() {}, async removeContext() {}, async close() {} }; + }, + }; + const loader = createSkillLoader( + { + async get() { + return publisher.get(uri).skill; + }, + async read() { + return bytes; + }, + }, + host, + ); + const entry = publisher.get(uri).skill; + if (stale === "frontmatter mismatch") + entry.frontmatter.description = "Incorrect"; + const a = await loader.hold(origin, entry); + let reached!: () => void; + let release!: () => void; + const paused = new Promise((resolve) => { + reached = resolve; + }); + const resume = new Promise((resolve) => { + release = resolve; + }); + const realDigest = crypto.subtle.digest.bind(crypto.subtle); + let first = true; + const spy = spyOn(crypto.subtle, "digest").mockImplementation( + async (algorithm, data) => { + const result = await realDigest(algorithm, data); + if (first) { + first = false; + reached(); + await resume; + } + return result; + }, + ); + const verification = loader.verify(a).then( + (value) => ({ value, error: undefined }), + (error) => ({ value: undefined, error }), + ); + try { + await paused; + if (stale === "success") loader.clear(); + const b = await loader.get(origin, uri); + const lease = await loader.activate(b); + lease.assertExecutionApproved(); + release(); + const outcome = await verification; + expect(outcome.error).toBeInstanceOf(Error); + expect(outcome.value).toBeUndefined(); + lease.assertExecutionApproved(); + expect((await lease.read(uri)).bytes).toEqual(bytes); + await loader.activate(b); + expect(approvals).toBe(1); + } finally { + release(); + await verification; + spy.mockRestore(); + } + }); +} diff --git a/src/extensions/tasks/contracts.ts b/src/extensions/tasks/contracts.ts new file mode 100644 index 0000000..2ebfeb8 --- /dev/null +++ b/src/extensions/tasks/contracts.ts @@ -0,0 +1,45 @@ +import type { + DetailedTask, + InputRequests, + InputResponses, +} from "../../protocol/index.js"; + +export interface McpTaskIdentity { + tenantId: string; + principalId: string; + authorizationContextId: string; +} +export interface McpTaskRecord { + identity: McpTaskIdentity; + task: DetailedTask; + revision: number; + original: { method: "tools/call"; params: Record }; + backend: Record; + /** Immutable history prevents key reuse even after requests are answered. */ + issued: InputRequests; + answered: InputResponses; + cancelRequested: boolean; + dispatch: "pending" | "started"; +} +/** All operations are atomic, linearizable and durable before resolving. */ +export interface McpTaskStore { + readonly durable: true; + create(record: McpTaskRecord): Promise; + get(taskId: string): Promise; + compareAndSwap( + taskId: string, + revision: number, + next: McpTaskRecord, + ): Promise; + list(): Promise; + expire(now: number): Promise; +} +export interface McpTaskExecutor { + /** Start uses taskId as idempotency/correlation key; never hide ambiguous dispatch. */ + start(record: McpTaskRecord): Promise; + /** Reconcile backend state, including pending dispatch/input/cancel; do not blindly replay. */ + recover(record: McpTaskRecord): Promise; + /** Responses already persisted. Host must authorize each approval/input binding. */ + submitInput(record: McpTaskRecord, responses: InputResponses): Promise; + requestCancel(record: McpTaskRecord): Promise; +} diff --git a/src/extensions/tasks/file-store.ts b/src/extensions/tasks/file-store.ts new file mode 100644 index 0000000..017212a --- /dev/null +++ b/src/extensions/tasks/file-store.ts @@ -0,0 +1,162 @@ +/** Explicit server-only import. Not re-exported by the neutral extensions entry. */ +import { open, mkdir, rename, unlink, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { type } from "arktype"; +import type { McpTaskRecord, McpTaskStore } from "./contracts.js"; + +const envelope = type({ version: "1", records: "unknown[]" }); +const MAX_STORE_BYTES = 16_777_216; +/** Single writer on a local filesystem only. A crash leaves the lock: host must + * prove the previous writer dead before removing it. Never auto-steal a lock. + * Directory must be host-owned, not writable by models/tools/other users. + */ +export async function createFileTaskStore(options: { + directory: string; + /** Shared boundary validation plus host record invariants on restart. */ + validateRecord(value: unknown): McpTaskRecord; +}): Promise }> { + await mkdir(options.directory, { recursive: true, mode: 0o700 }); + const directoryStat = await stat(options.directory); + if ((directoryStat.mode & 0o077) !== 0) + throw new Error("Task directory must be private (0700)"); + const lockPath = join(options.directory, "writer.lock"); + const lock = await open(lockPath, "wx", 0o600); + const dataPath = join(options.directory, "tasks.json"); + let records = new Map(); + let closed = false; + let closing: Promise | undefined; + let queue: Promise = Promise.resolve(); + const serialized = (run: () => Promise): Promise => { + if (closing) return Promise.reject(new Error("Task store closed")); + const result = queue.then(() => { + if (closed) throw new Error("Task store closed"); + return run(); + }); + queue = result.catch(() => {}); + return result; + }; + try { + await lock.writeFile(JSON.stringify({ pid: process.pid })); + await lock.sync(); + let file; + try { + file = await open(dataPath, "r"); + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; + } + if (file) + try { + if ((await file.stat()).size > MAX_STORE_BYTES) + throw new Error("Task store exceeds 16 MiB"); + const parsed = envelope( + JSON.parse(await file.readFile("utf8")) as unknown, + ); + if (parsed instanceof type.errors) + throw new Error("Invalid task store envelope"); + for (const value of parsed.records) { + const record = options.validateRecord(value); + if (records.has(record.task.taskId)) + throw new Error("Duplicate persisted task"); + records.set(record.task.taskId, structuredClone(record)); + } + } finally { + await file.close(); + } + } catch (error) { + await lock.close(); + await unlink(lockPath); + throw error; + } + async function persist(next: Map): Promise { + const bytes = JSON.stringify({ version: 1, records: [...next.values()] }); + if (Buffer.byteLength(bytes) > MAX_STORE_BYTES) + throw new Error("Task store exceeds 16 MiB"); + const tempPath = join( + options.directory, + `tasks-${crypto.randomUUID()}.tmp`, + ); + const temp = await open(tempPath, "wx", 0o600); + try { + await temp.writeFile(bytes); + await temp.sync(); + } finally { + await temp.close(); + } + try { + await rename(tempPath, dataPath); + const directory = await open(options.directory, "r"); + try { + await directory.sync(); + } finally { + await directory.close(); + } + records = next; + } catch (error) { + // An ambiguous durability failure poisons this writer; restart reconciles disk. + closed = true; + throw error; + } + } + return { + durable: true, + create(record) { + return serialized(async () => { + if (records.has(record.task.taskId) || record.revision !== 0) + throw new Error("Task exists or invalid initial revision"); + const checked = options.validateRecord(structuredClone(record)); + const next = new Map(records); + next.set(record.task.taskId, checked); + await persist(next); + }); + }, + get(taskId) { + return serialized(async () => structuredClone(records.get(taskId))); + }, + compareAndSwap(taskId, revision, record) { + return serialized(async () => { + const current = records.get(taskId); + if (!current || current.revision !== revision) return false; + if ( + record.task.taskId !== taskId || + record.revision !== revision + 1 || + JSON.stringify(record.identity) !== JSON.stringify(current.identity) + ) + throw new Error("Invalid task CAS identity/revision"); + const checked = options.validateRecord(structuredClone(record)); + const next = new Map(records); + next.set(taskId, checked); + await persist(next); + return true; + }); + }, + list() { + return serialized(async () => structuredClone([...records.values()])); + }, + expire(now) { + return serialized(async () => { + const next = new Map( + [...records].filter( + ([, r]) => + r.task.ttlMs === null || + Date.parse(r.task.createdAt) + r.task.ttlMs > now, + ), + ); + if (next.size !== records.size) await persist(next); + }); + }, + close() { + // Publish the close promise synchronously: reject new operations while + // draining accepted ones, and never unlink a successor's lock on retry. + closing ??= (async () => { + await queue; + closed = true; + await lock.close(); + await unlink(lockPath); + })(); + return closing; + }, + }; +} diff --git a/src/extensions/tasks/index.ts b/src/extensions/tasks/index.ts new file mode 100644 index 0000000..96c9855 --- /dev/null +++ b/src/extensions/tasks/index.ts @@ -0,0 +1,279 @@ +import { validateElicitationResponse } from "../elicitation.js"; +import { + validateTask as validateWireTask, + validateInputResponses, +} from "../../protocol/index.js"; +import type { + DetailedTask, + InputRequests, + InputResponses, +} from "../../protocol/index.js"; +import type { + McpTaskExecutor, + McpTaskIdentity, + McpTaskRecord, + McpTaskStore, +} from "./contracts.js"; +export type * from "./contracts.js"; +const terminal = (task: DetailedTask) => + ["completed", "failed", "cancelled"].includes(task.status); +const sameIdentity = (a: McpTaskIdentity, b: McpTaskIdentity) => + a.tenantId === b.tenantId && + a.principalId === b.principalId && + a.authorizationContextId === b.authorizationContextId; +const fail = (message: string): never => { + throw Object.assign(new Error(message), { code: -32602 }); +}; + +export function createTaskRuntime(options: { + store: McpTaskStore; + executor: McpTaskExecutor; + /** Shared protocol validator supplied by adapter, not a second wire validator. */ + validateTask?(task: DetailedTask): void; + authorizeInput( + identity: McpTaskIdentity, + record: McpTaskRecord, + responses: InputResponses, + ): Promise; + now?: () => number; +}) { + const { store, executor } = options; + if (store.durable !== true) fail("A durable task store is required"); + const now = options.now ?? Date.now; + const validateTask = + options.validateTask ?? + ((task: DetailedTask) => { + validateWireTask(task, true); + }); + async function load( + identity: McpTaskIdentity, + taskId: string, + ): Promise { + const record = await store.get(taskId); + if ( + !record || + !sameIdentity(identity, record.identity) || + (record.task.ttlMs !== null && + Date.parse(record.task.createdAt) + record.task.ttlMs <= now()) + ) + return fail("Unknown task"); + return record; + } + async function update( + identity: McpTaskIdentity, + taskId: string, + change: (record: McpTaskRecord) => McpTaskRecord, + ): Promise { + for (let attempt = 0; attempt < 16; attempt++) { + const record = await load(identity, taskId); + const next = change(structuredClone(record)); + next.revision = record.revision + 1; + validateTask(next.task); + if (await store.compareAndSwap(taskId, record.revision, next)) + return next; + } + throw new Error("Task concurrent update limit exceeded"); + } + async function transition( + identity: McpTaskIdentity, + taskId: string, + task: DetailedTask, + backend?: Record, + ) { + return update(identity, taskId, (record) => { + if (terminal(record.task)) return fail("Terminal tasks are immutable"); + if (task.taskId !== taskId || task.createdAt !== record.task.createdAt) + return fail("Task identity cannot change"); + if (task.status === "input_required") + for (const [key, request] of Object.entries(task.inputRequests)) { + if ( + Object.hasOwn(record.answered, key) || + (Object.hasOwn(record.issued, key) && + JSON.stringify(record.issued[key]) !== JSON.stringify(request)) + ) + fail("Task input key reused"); + record.issued = { ...record.issued, [key]: structuredClone(request) }; + } + record.task = structuredClone(task); + record.task.lastUpdatedAt = new Date(now()).toISOString(); + if (backend) record.backend = structuredClone(backend); + return record; + }); + } + return { + async create( + identity: McpTaskIdentity, + params: Record, + ttlMs: number | null = 86_400_000, + ) { + if (Object.values(identity).some((v) => !v)) + fail("Task identity required"); + if (ttlMs !== null && (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)) + fail("Invalid task TTL"); + const timestamp = new Date(now()).toISOString(); + const task: DetailedTask = { + taskId: crypto.randomUUID(), + status: "working", + createdAt: timestamp, + lastUpdatedAt: timestamp, + ttlMs, + pollIntervalMs: 1000, + }; + validateTask(task); + const record: McpTaskRecord = { + identity: structuredClone(identity), + task, + revision: 0, + original: { method: "tools/call", params: structuredClone(params) }, + backend: {}, + issued: {}, + answered: {}, + cancelRequested: false, + dispatch: "pending", + }; + await store.create(record); + // A crash or ambiguous start remains a durable pending intent for recover(). + await executor.start(structuredClone(record)); + await update(identity, task.taskId, (next) => ({ + ...next, + dispatch: "started", + })); + return { resultType: "task" as const, ...structuredClone(task) }; + }, + async get(identity: McpTaskIdentity, taskId: string) { + return { + ...(await load(identity, taskId)).task, + resultType: "complete" as const, + }; + }, + transition, + async submitInput( + identity: McpTaskIdentity, + taskId: string, + responses: InputResponses, + ) { + validateInputResponses(responses); + const original = await load(identity, taskId); + await options.authorizeInput(identity, original, responses); + let accepted: InputResponses = {}; + const next = await update(identity, taskId, (record) => { + accepted = {}; + if (record.task.status !== "input_required") return record; + for (const [key, response] of Object.entries(responses)) + if ( + Object.hasOwn(record.task.inputRequests, key) && + !Object.hasOwn(record.answered, key) + ) { + const request = record.task.inputRequests[key]; + if (request?.method === "elicitation/create") + validateElicitationResponse(request.params, response); + accepted = { ...accepted, [key]: structuredClone(response) }; + record.answered = { + ...record.answered, + [key]: structuredClone(response), + }; + delete record.task.inputRequests[key]; + } + if (!Object.keys(record.task.inputRequests).length) { + const { inputRequests: _requests, ...base } = record.task; + record.task = { ...base, status: "working" }; + } + return record; + }); + if (Object.keys(accepted).length) + await executor.submitInput(next, accepted); + return { resultType: "complete" as const }; + }, + async cancel(identity: McpTaskIdentity, taskId: string) { + const record = await update(identity, taskId, (next) => { + next.cancelRequested = true; + return next; + }); + if (!terminal(record.task)) await executor.requestCancel(record); + return { resultType: "complete" as const }; + }, + async recover() { + await store.expire(now()); + for (const record of await store.list()) + if (!terminal(record.task)) { + let recovered: DetailedTask | undefined; + try { + recovered = await executor.recover(record); + } catch { + /* Persist truthful JSON-RPC recovery failure below. */ + } + if (!recovered) + recovered = { + taskId: record.task.taskId, + createdAt: record.task.createdAt, + lastUpdatedAt: new Date(now()).toISOString(), + ttlMs: record.task.ttlMs, + status: "failed", + error: { + code: -32603, + message: + "Task backend recovery unavailable; execution was not replayed", + }, + }; + await transition(record.identity, record.task.taskId, recovered); + } + }, + }; +} + +export async function pollTask(options: { + taskId: string; + get(taskId: string): Promise; + update(taskId: string, responses: InputResponses): Promise; + persist(taskId: string): Promise; + input?(requests: InputRequests): Promise; + observe?(task: DetailedTask): void; + signal?: AbortSignal; + maxPolls?: number; + sleep?: (milliseconds: number, signal?: AbortSignal) => Promise; +}): Promise { + await options.persist(options.taskId); + const answered = new Set(); + const sleep = + options.sleep ?? + ((ms, signal) => + new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Polling aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort); + resolve(); + }, ms); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + })); + for (let poll = 0; poll < (options.maxPolls ?? 10_000); poll++) { + options.signal?.throwIfAborted(); + const task = await options.get(options.taskId); + if (task.taskId !== options.taskId) fail("Task polling identity mismatch"); + options.observe?.(task); + if (terminal(task)) return task; + if (task.status === "input_required") { + if (!options.input) throw new Error("Task elicitation host unavailable"); + const pending = Object.fromEntries( + Object.entries(task.inputRequests).filter( + ([key]) => !answered.has(key), + ), + ); + if (Object.keys(pending).length) { + const responses = await options.input(pending); + if (Object.keys(responses).some((key) => !Object.hasOwn(pending, key))) + fail("Unexpected task input response"); + await options.update(options.taskId, responses); + for (const key of Object.keys(responses)) answered.add(key); + } + } + await sleep(Math.max(1, task.pollIntervalMs ?? 1000), options.signal); + } + throw new Error( + "Task polling limit exceeded; durable task remains available", + ); +} +export { validateTaskRecord } from "./record.js"; diff --git a/src/extensions/tasks/record.ts b/src/extensions/tasks/record.ts new file mode 100644 index 0000000..b8165b5 --- /dev/null +++ b/src/extensions/tasks/record.ts @@ -0,0 +1,44 @@ +import { type } from "arktype"; +import { + validateTask, + validateInputRequests, + validateInputResponses, +} from "../../protocol/index.js"; +import type { McpTaskRecord } from "./contracts.js"; +const recordShape = type({ + identity: { + tenantId: "string > 0", + principalId: "string > 0", + authorizationContextId: "string > 0", + }, + task: "object", + revision: "number.integer >= 0", + original: { method: "'tools/call'", params: "object" }, + backend: { "[string]": "string" }, + issued: "object", + answered: "object", + cancelRequested: "boolean", + dispatch: "'pending' | 'started'", +}); +/** Local persisted envelope boundary; wire payloads use common protocol validators. */ +export function validateTaskRecord(value: unknown): McpTaskRecord { + const result = recordShape(value); + if (result instanceof type.errors) + throw new Error(`Invalid persisted task: ${result.summary}`); + validateTask(result.task, true); + validateInputRequests(result.issued); + validateInputResponses(result.answered); + const record = result as McpTaskRecord; + for (const key of Object.keys(record.answered)) + if (!Object.hasOwn(record.issued, key)) + throw new Error("Answered input was never issued"); + if (record.task.status === "input_required") + for (const key of Object.keys(record.task.inputRequests)) { + if ( + !Object.hasOwn(record.issued, key) || + Object.hasOwn(record.answered, key) + ) + throw new Error("Inconsistent outstanding task input"); + } + return record; +} diff --git a/src/extensions/tasks/tasks.test.ts b/src/extensions/tasks/tasks.test.ts new file mode 100644 index 0000000..e084f53 --- /dev/null +++ b/src/extensions/tasks/tasks.test.ts @@ -0,0 +1,249 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFileTaskStore } from "./file-store.js"; +import { createTaskRuntime, pollTask, validateTaskRecord } from "./index.js"; +import type { McpTaskExecutor, McpTaskIdentity } from "./contracts.js"; +import type { DetailedTask, InputRequests } from "../../protocol/index.js"; +const identity: McpTaskIdentity = { + tenantId: "t", + principalId: "p", + authorizationContextId: "auth", +}; +const requests: InputRequests = { + approval: { + method: "elicitation/create", + params: { + message: "Approve?", + requestedSchema: { + type: "object", + properties: { yes: { type: "boolean" } }, + required: ["yes"], + }, + }, + }, +}; +const executor: McpTaskExecutor = { + async start() {}, + async recover() { + return undefined; + }, + async submitInput() {}, + async requestCancel() {}, +}; +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "mcp-tasks-")); + const store = await createFileTaskStore({ + directory, + validateRecord: validateTaskRecord, + }); + return { + directory, + store, + async cleanup() { + await store.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} +test("durable creation, restart, exclusive writer, CAS and identity isolation", async () => { + const f = await fixture(); + let store = f.store; + try { + await expect( + createFileTaskStore({ + directory: f.directory, + validateRecord: validateTaskRecord, + }), + ).rejects.toThrow(); + const runtime = createTaskRuntime({ + store, + executor, + async authorizeInput() {}, + }); + const task = await runtime.create(identity, { name: "work" }); + expect(task.resultType).toBe("task"); + expect((await store.get(task.taskId))?.dispatch).toBe("started"); + await expect( + runtime.get({ ...identity, tenantId: "other" }, task.taskId), + ).rejects.toThrow("Unknown"); + const record = (await store.get(task.taskId))!; + expect( + await store.compareAndSwap(task.taskId, record.revision - 1, record), + ).toBe(false); + await store.close(); + store = await createFileTaskStore({ + directory: f.directory, + validateRecord: validateTaskRecord, + }); + expect((await store.get(task.taskId))?.original.params.name).toBe("work"); + const restarted = createTaskRuntime({ + store, + executor, + async authorizeInput() {}, + }); + await restarted.recover(); + expect((await restarted.get(identity, task.taskId)).status).toBe("failed"); + } finally { + await store.close(); + await rm(f.directory, { recursive: true, force: true }); + } +}); +test("input authorization, partial answers, lifetime key uniqueness, cooperative cancel and terminal immutability", async () => { + const f = await fixture(); + let submissions = 0; + let cancellations = 0; + let allow = false; + try { + const runtime = createTaskRuntime({ + store: f.store, + executor: { + ...executor, + async submitInput() { + submissions++; + }, + async requestCancel() { + cancellations++; + }, + }, + async authorizeInput() { + if (!allow) throw new Error("Unauthorized approver"); + }, + }); + const initial = await runtime.create(identity, { name: "work" }); + await runtime.transition(identity, initial.taskId, { + ...initial, + status: "input_required", + inputRequests: { ...requests, second: requests.approval! }, + }); + await expect( + runtime.submitInput(identity, initial.taskId, { + approval: { action: "accept", content: { yes: true } }, + }), + ).rejects.toThrow("Unauthorized"); + allow = true; + await expect( + runtime.submitInput(identity, initial.taskId, { + approval: { action: "accept", content: { yes: "invalid" } }, + }), + ).rejects.toThrow(); + await runtime.submitInput(identity, initial.taskId, { + approval: { action: "accept", content: { yes: true } }, + unknown: { action: "cancel" }, + }); + expect(submissions).toBe(1); + expect((await runtime.get(identity, initial.taskId)).status).toBe( + "input_required", + ); + await runtime.submitInput(identity, initial.taskId, { + approval: { action: "accept", content: { yes: true } }, + }); + expect(submissions).toBe(1); + await expect( + runtime.transition(identity, initial.taskId, { + ...initial, + status: "input_required", + inputRequests: requests, + }), + ).rejects.toThrow("reused"); + await runtime.cancel(identity, initial.taskId); + expect(cancellations).toBe(1); + expect((await runtime.get(identity, initial.taskId)).status).toBe( + "input_required", + ); + expect((await f.store.get(initial.taskId))?.cancelRequested).toBe(true); + await runtime.transition(identity, initial.taskId, { + ...initial, + status: "completed", + result: { content: [{ type: "text", text: "done" }] }, + }); + await expect( + runtime.transition(identity, initial.taskId, { + ...initial, + status: "working", + }), + ).rejects.toThrow("immutable"); + } finally { + await f.cleanup(); + } +}); +test("expired tasks are inaccessible and removed; ambiguous dispatch is durable before recovery", async () => { + const f = await fixture(); + let now = Date.now(); + try { + const runtime = createTaskRuntime({ + store: f.store, + executor: { + ...executor, + async start(record) { + expect(await f.store.get(record.task.taskId)).toBeDefined(); + throw new Error("ambiguous dispatch"); + }, + }, + now: () => now, + async authorizeInput() {}, + }); + await expect( + runtime.create(identity, { name: "work" }, 10), + ).rejects.toThrow("ambiguous"); + const records = await f.store.list(); + expect(records[0]?.dispatch).toBe("pending"); + now += 11; + await expect( + runtime.get(identity, records[0]!.task.taskId), + ).rejects.toThrow("Unknown"); + await runtime.recover(); + expect(await f.store.list()).toHaveLength(0); + } finally { + await f.cleanup(); + } +}); +test("polling persists identity, honors interval, deduplicates input and returns final result", async () => { + const initial: DetailedTask = { + taskId: "id", + status: "input_required", + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + ttlMs: null, + pollIntervalMs: 12, + inputRequests: requests, + }; + let polls = 0; + let inputs = 0; + let persisted = false; + const waits: number[] = []; + const result = await pollTask({ + taskId: "id", + async persist() { + persisted = true; + }, + async get() { + expect(persisted).toBe(true); + return ++polls < 3 + ? initial + : { ...initial, status: "completed", result: { content: [] } }; + }, + async input() { + inputs++; + return { approval: { action: "decline" } }; + }, + async update() {}, + async sleep(ms) { + waits.push(ms); + }, + }); + expect(result.status).toBe("completed"); + expect(inputs).toBe(1); + expect(waits).toEqual([12, 12]); + await expect( + pollTask({ + taskId: "id", + async persist() {}, + async get() { + return initial; + }, + async update() {}, + }), + ).rejects.toThrow("host unavailable"); +}); From 9e49b079e297581417465f58b9098456a37a3e01 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:38 -0700 Subject: [PATCH 13/28] feat(auth): guard discovery login and resource authorization Bind OAuth discovery and callbacks to issuer and resource policy, isolate neutral authorization contracts, and enforce mediated credential boundaries with security regressions. --- src/auth/INTEGRATION.md | 64 ++++++ src/auth/boundary.test.ts | 31 +++ src/auth/callback.test.ts | 159 ++++++++++++++ src/auth/callback.ts | 96 ++++++++ src/auth/challenge.ts | 65 ++++++ src/auth/contracts.ts | 45 ++++ src/auth/flows.test.ts | 330 ++++++++++++++++++++++++++++ src/auth/http.ts | 7 + src/auth/index.ts | 6 + src/auth/login.ts | 242 ++++++++++++++++++++ src/auth/oauth-http.ts | 47 ++++ src/auth/resource-server.ts | 129 +++++++++++ src/auth/review-regressions.test.ts | 232 +++++++++++++++++++ src/auth/security.test.ts | 98 +++++++++ src/auth/url-policy.test.ts | 74 +++++++ src/auth/url-policy.ts | 42 ++++ src/hub/discover.test.ts | 136 ++++++++++-- src/hub/discover.ts | 52 ++++- src/index.ts | 3 + src/oauth-discovery.test.ts | 27 ++- src/oauth-discovery.ts | 273 ++++++++++++++--------- src/url.ts | 13 +- 22 files changed, 2030 insertions(+), 141 deletions(-) create mode 100644 src/auth/INTEGRATION.md create mode 100644 src/auth/boundary.test.ts create mode 100644 src/auth/callback.test.ts create mode 100644 src/auth/callback.ts create mode 100644 src/auth/challenge.ts create mode 100644 src/auth/contracts.ts create mode 100644 src/auth/flows.test.ts create mode 100644 src/auth/http.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/login.ts create mode 100644 src/auth/oauth-http.ts create mode 100644 src/auth/resource-server.ts create mode 100644 src/auth/review-regressions.test.ts create mode 100644 src/auth/security.test.ts create mode 100644 src/auth/url-policy.test.ts create mode 100644 src/auth/url-policy.ts diff --git a/src/auth/INTEGRATION.md b/src/auth/INTEGRATION.md new file mode 100644 index 0000000..4bb7265 --- /dev/null +++ b/src/auth/INTEGRATION.md @@ -0,0 +1,64 @@ +# CL-11477 authorization integration + +## Supported seams + +- `McpAuthPolicy` authenticates a host identity and authorizes explicit resource/action operations. `validateMcpIdentity` copies only tenant, principal, scopes and the host-assigned authorization-context partition. Public mode belongs to the server configuration, not this policy. +- `createMcpResourceServerAuth` composes an injected **cryptographic verifier or introspector**, exact issuer/audience/expiry checks, host authorization and optional operation scope requirements. The verifier must enforce algorithm/key trust, revocation and token type. Merely decoding a JWT is not sufficient. Mount `metadataResponse()` at the advertised metadata URL and use `challenge()` for 401/403 responses. Never forward this inbound bearer upstream. +- Retain `McpError.wwwAuthenticate` from the actual outbound 401/403 and pass it to `discoverMcpLoginEntry`. Challenge metadata wins; otherwise discovery tries path then root PRM. AS lookup tries OAuth insertion, OIDC insertion, then OIDC appending. Invalid successful metadata fails closed rather than trying to conceal an issuer mismatch with another endpoint. +- `prepareMcpClient` reuses issuer-bound stored pre-registration/DCR or portable CIMD, then performs real public-client DCR only if needed. The host must actually publish the CIMD JSON at the exact client ID URL and register exact callback URIs. The client does not pretend that creating a JSON object publishes it. +- `createMcpAuthorizationSession` uses installed oauth-core's public PKCE/state/authorize/code-exchange functions. Its one-use callback consumes complete URLSearchParams, validates state and issuer before exchange, and gives secrets only to an explicit host `persist(binding,tokens)` callback. That callback must encrypt storage, verify tenant/principal credential-creation authority, and persist issuer/resource/client/provenance atomically. The session does not grant credentials, mount routes, open a browser, or return tokens to MCP/model context. Access-token-only responses are supported without inventing a refresh token. +- `refreshMcpOAuthTokens` binds refresh to the recorded issuer/resource/client and carries rotated or retained refresh material. The host selects storage under its own tenant/principal authority; do not accept a caller-supplied binding as proof of ownership. +- `createMcpCallbackAdapter` implements oauth-core's injectable CallbackServer plus a Fetch receiver. The host binds **exactly** the registered callback URI before handing it to `startOAuthLogin`, and stops that listener through `closeListener`. This is an explicit listener integration contract, not an automatically mounted network server. It checks present `iss` even when advertisement is absent. +- `mcpStepUpScopes` computes a requested scope union from an insufficient-scope challenge. The host must bound retries, get user consent and start a fresh session; no automatic operation replay or permission widening occurs here. +- All OAuth endpoints require HTTPS, including internal and loopback AS endpoints. Only callback redirects and MCP endpoints permit loopback HTTP. Injected fetch is a trusted host networking boundary: apply deployment egress/DNS/address controls to prevent SSRF. URL validation alone cannot guarantee DNS routing safety. +- Hub discovery retains tenant-qualified credential lookup and origin pinning. A stored `oauthResource` is additionally bound exactly; origin allowances cannot override it. Peer JSON-RPC errors are untrusted and cannot be exposed through hub responses or onError. Existing legacy credentials without recorded resource provenance retain origin-only checks; migrate their host metadata before claiming resource-level isolation. + +## Security migration + +Issuer strings now compare exactly, including slash, case and port spelling. CIMD rejects literal and encoded dot segments, userinfo and fragments. OAuth metadata, registration and guarded token requests refuse redirects. Security-critical extra authorization parameters cannot replace state, PKCE, client, redirect, response type or scopes. The resource parameter is set on OAuthClientConfig itself, not only authorize extras. Discovery requires PRM by default; the old direct-AS fallback requires `allowLegacyAuthorizationServerFallback: true` and is not modern MCP conformance. Callback error text and upstream hub errors are sanitized rather than echoed. URL query access tokens are rejected. + +## Installed dependency audit and companion issue briefs + +Baseline inspected: `@corbits/oauth-core` 0.3.0 and `@corbits/credential-http` 0.1.0, installed declarations and runtime JS. No external repository or node_modules edits. + +### oauth-core: token-only grant storage + +Evidence: `dist/tokens.d.ts` requires `BaseTokens.refresh`; `dist/client.js` `baseTokensFromResponse` throws `OAuthMissingRefreshTokenError` if no refresh exists. `dist/hub/credentials.js` `writeOAuthTokens` and `persistOAuthCredential` unconditionally encrypt `tokens.refresh`. Valid access-token-only responses therefore cannot use stock profile/hub persistence. Requested upstream change: optional refresh through token/profile/storage contracts; absent refresh means reauthorization on expiry, not a fabricated token. Regression: code exchange with only access_token/token_type persists an access credential without a refresh secret; refresh scheduler skips it. Local MCP sessions support this via a separate honest host storage contract; stock persistence remains blocked. + +### oauth-core: safe token transport and protected authorize parameters + +Evidence: `dist/client.js` `postToken` omits redirect policy; non-2xx errors embed the raw response body; `buildAuthorizeUrl` applies extra parameters after state/PKCE and always sets scope, even empty. Requested upstream change: refuse credential-bearing redirects, sanitize public error messages while retaining bounded structured internal error codes, reject reserved authorize overrides, omit absent scope. Regression: 307 cannot forward code/verifier/refresh, reflected token text does not appear in public errors, extra state/PKCE cannot overwrite generated values. Local guarded MCP sessions use `secureOAuthFetch`, sanitize exchange/refresh errors, reject reserved extras, and omit empty scope. Direct stock helpers remain unchanged. + +### oauth-core: stock hub discovery/registration/callback parity + +Evidence: `dist/hub/mount.js` imports its own discovery implementation, requires registration_endpoint and always calls registerMcpClient. It passes expectedIssuer to callbacks **only when** authorization_response_iss_parameter_supported is true. `dist/hub/route-callback.js` compares iss only when expectedIssuer exists, so a present wrong iss can pass when metadata omits the flag. Stock provider login uses the code-only loopback callback. Requested upstream change: compose the public guarded discovery/registration/session seams or equivalent fixes; always record issuer and compare any present iss; preserve duplicate callback parameter detection. Tests: CIMD-only server, pre-registered issuer isolation, wrong present iss with absent/false advertisement. The local Fetch callback adapter and session cover these cases; stock provider/hub login is not claimed compliant. + +### oauth-core: resource propagation audit correction + +**Not an upstream blocker:** `OAuthClientConfig.resource` exists; installed exchangeCode and refreshTokenRequest send it. `dist/hub/refresh.js` registeredClientRefresh reads `oauthResource` and forwards resource. The defect was this package's config builder, now fixed and tested against the installed helpers. Host persistence must retain oauthResource and issuer; the stock metadata keys do not themselves provide full issuer/principal provenance. + +### credential-http: RequestInit and transport policy + +Evidence: `dist/origin-pinned-fetch.js` Request branch calls `new Request(input,{headers,redirect:"manual"})` and ignores init (including abort signal, method/body/header overrides); URL branch spreads init. It does correctly pin origins, validate credential header values, and force manual redirects. Origin/extraOrigins parsing uses new URL without restricting insecure schemes, so a trusted host can configure non-loopback HTTP credential destinations. Requested upstream change: preserve standard Request+init semantics while reapplying protected headers/redirect policy; reject insecure credential destinations except an explicit loopback development policy. Tests: Request+AbortSignal cancellation, init body/method handling, HTTP non-loopback rejection and no redirected bearer. Local hub endpoint/extra-origin validation uses parseMcpEndpoint and refuses redirect responses. Standalone external provider handles still require upstream correction/host policy. + +## Verification limits + +Synthetic complete challenge → PRM → OIDC → DCR → PKCE callback → token-only persistence → authenticated authorized request is covered, plus pre-registration/CIMD selection and refresh rotation. Host production DB transactions, live OAuth services, cryptographic verifiers/introspection, deployed callback listeners, browser consent, and distributed login durability are not verified here. Login sessions are in-process and expire; a restart requires a fresh login. These results are role- and integration-specific, not product-wide or stock-dependency compliance certification. + +Normative revision: MCP 2026-07-28 basic/authorization and linked authorization-server-discovery, client-registration, security-considerations; RFC 9728, 8414, 9207 and 8707 binding rules. Tests live beside the owned auth/hub modules. + +## Review follow-up: callback lifecycle and DCR application type + +A callback with valid state and issuer consumes the one-use authorization session before its code or terminal error is processed. An authenticated denial cannot subsequently be replayed as a successful code. Wrong-state, missing-required-issuer, duplicate-issuer, and wrong-issuer attempts leave the legitimate session pending; the Fetch callback adapter uses the same provenance check and lifecycle. + +`prepareMcpClient` accepts `applicationType: "web" | "native"` and forwards it to DCR. Hosts with remote HTTPS web callbacks must select `"web"`; the existing default remains `"native"` for native/loopback clients. CIMD validation checks raw path segments before the query boundary, rejecting literal, encoded, and mixed dot segments without misclassifying query text as a path. + +CIMD identifiers must use explicit `https://authority/path` syntax. WHATWG-repaired missing/extra authority slashes, raw whitespace, controls and backslashes are rejected before parsing; validated raw-path extraction cannot silently fall back after a syntax mismatch. Valid case/port spelling, IPv6 authorities, percent-encoded path content and query text remain unchanged. + +## Neutral server dependency boundary + +The standalone server imports resource-server auth directly, which now depends on `url-policy.ts`, `contracts.ts`, `src/url.ts`, and arktype—not oauth-core, Hono, DB or hub integrations. `url-policy.ts` owns the unchanged URL checks. OAuth-only fetch and error translation live in `oauth-http.ts`; `http.ts` retains its existing exports as compatibility aliases. The aggregate auth/login facade still requires the optional OAuth integration and must not be re-exported into the neutral server runtime graph. + +Invalid server configuration URLs now throw the neutral `McpAuthUrlError` rather than requiring an installed oauth-core constructor. The diagnostic name and sanitized message remain unchanged. OAuth-facing helpers translate that error to the actual public `OAuthDiscoveryError`, preserving their exception identity. No dynamic import, lazy failure, or private dependency implementation is used. The necessary server-only exception identity distinction was raised with the director, who instructed proceeding with this permitted alternative. + +`boundary.test.ts` bundles both resource-server auth and the standalone server entry and rejects runtime edges to oauth-core, Hono, @intx/db, @intx/hub-api or drizzle-orm. `url-policy.test.ts` verifies neutral/facade validation parity, diagnostic parity, OAuth exception identity, and secure-fetch compatibility. The package integrator still owns optional-peer metadata changes and packed-package import checks. diff --git a/src/auth/boundary.test.ts b/src/auth/boundary.test.ts new file mode 100644 index 0000000..1e34da1 --- /dev/null +++ b/src/auth/boundary.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +for (const entry of ["./resource-server.ts", "../server.ts"]) { + test(`${entry} runtime graph excludes OAuth, Hono and DB integrations`, async () => { + const forbidden: string[] = []; + const result = await Bun.build({ + entrypoints: [fileURLToPath(new URL(entry, import.meta.url))], + target: "bun", + plugins: [ + { + name: "neutral-server-boundary", + setup(build) { + build.onResolve( + { + filter: + /^(?:@corbits\/oauth-core|hono|@intx\/(?:db|hub-api)|drizzle-orm)(?:\/|$)/, + }, + (args) => { + forbidden.push(args.path); + return { path: args.path, external: true }; + }, + ); + }, + }, + ], + }); + expect(result.success).toBe(true); + expect(forbidden).toEqual([]); + }); +} diff --git a/src/auth/callback.test.ts b/src/auth/callback.test.ts new file mode 100644 index 0000000..5fe7cfb --- /dev/null +++ b/src/auth/callback.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import { createMcpCallbackAdapter } from "./callback.js"; +import { secureOAuthFetch } from "./http.js"; +import { + discoverMcpLoginEntry, + registerMcpClient, +} from "../oauth-discovery.js"; + +const redirectUri = "http://127.0.0.1:8080/callback"; +test("callback adapter buffers early success and validates issuer before yielding code", async () => { + for (const iss of ["https://auth.example", "https://other.example"]) { + let closed = false; + const receiver = createMcpCallbackAdapter({ + expectedState: "state", + redirectUri, + issuer: { recordedIssuer: "https://auth.example" }, + closeListener: () => { + closed = true; + }, + }); + expect( + receiver.fetch(new Request(`${redirectUri}?state=wrong&code=code`)) + .status, + ).toBe(400); + const response = receiver.fetch( + new Request(`${redirectUri}?state=state&code=code&iss=${iss}`), + ); + if (iss === "https://auth.example") { + expect(response.status).toBe(200); + expect(await receiver.waitForCode(new AbortController().signal)).toBe( + "code", + ); + } else { + expect(response.status).toBe(400); + expect( + receiver.fetch( + new Request( + `${redirectUri}?state=state&code=legitimate&iss=https://auth.example`, + ), + ).status, + ).toBe(200); + expect(await receiver.waitForCode(new AbortController().signal)).toBe( + "legitimate", + ); + } + receiver.close(); + expect(closed).toBe(true); + } +}); + +test("callback adapter close and abort settle pending waiters", async () => { + const receiver = createMcpCallbackAdapter({ + expectedState: "state", + redirectUri, + issuer: { recordedIssuer: "https://auth.example" }, + closeListener() {}, + }); + const waiting = receiver.waitForCode(new AbortController().signal); + receiver.close(); + await expect(waiting).rejects.toThrow(/cancelled/); +}); + +test("OAuth endpoints refuse HTTP, userinfo, fragments, and redirects without leaking errors", async () => { + for (const endpoint of [ + "http://auth.example/token", + "http://127.0.0.1/token", + "https://user:private@auth.example/token", + "https://auth.example/token#private", + ]) { + let called = false; + await expect( + secureOAuthFetch(async () => { + called = true; + return Response.json({}); + })(endpoint), + ).rejects.toThrow(); + expect(called).toBe(false); + } + const fetchImpl = async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + expect(init?.redirect).toBe("error"); + return new Response(null, { + status: 307, + headers: { location: "https://other.example" }, + }); + }; + await expect( + secureOAuthFetch(fetchImpl)("https://auth.example/token"), + ).rejects.toThrow(/redirect/); + await expect( + discoverMcpLoginEntry({ + resourceUrl: "https://mcp.example/mcp", + fetchImpl, + }), + ).rejects.toThrow(/redirect/); + await expect( + registerMcpClient({ + registrationEndpoint: "https://auth.example/register", + clientName: "client", + redirectUris: [redirectUri], + fetchImpl, + }), + ).rejects.toThrow(); +}); + +test("adapter composes with installed oauth-core login and staged commit", async () => { + const { startOAuthLogin } = await import("@corbits/oauth-core"); + let receiver: ReturnType | undefined; + let saved = false; + let closed = false; + const login = await startOAuthLogin( + { profile: "profile", signal: new AbortController().signal }, + { + startCallbackServer: async (state) => { + receiver = createMcpCallbackAdapter({ + expectedState: state, + redirectUri, + issuer: { + recordedIssuer: "https://auth.example", + authorizationResponseIssParameterSupported: true, + }, + closeListener: () => { + closed = true; + }, + }); + return receiver; + }, + buildAuthorizeUrl: (pkce, state) => { + expect(pkce.method).toBe("S256"); + return `https://auth.example/authorize?state=${state}`; + }, + openInBrowser: (url) => { + const state = new URL(url).searchParams.get("state"); + expect( + receiver?.fetch( + new Request( + `${redirectUri}?state=${state}&iss=https://auth.example&code=code`, + ), + ).status, + ).toBe(200); + }, + exchangeCode: async (code, verifier) => { + expect(code).toBe("code"); + expect(verifier.length).toBeGreaterThanOrEqual(43); + return { access: "access", refresh: "refresh" }; + }, + saveProfile: async () => { + saved = true; + }, + }, + ); + const staged = await login.completed; + expect(closed).toBe(true); + expect(saved).toBe(false); + await staged.commit(); + expect(saved).toBe(true); +}); diff --git a/src/auth/callback.ts b/src/auth/callback.ts new file mode 100644 index 0000000..5c9f2d2 --- /dev/null +++ b/src/auth/callback.ts @@ -0,0 +1,96 @@ +import type { CallbackServer } from "@corbits/oauth-core"; +import { OAuthDiscoveryError } from "@corbits/oauth-core"; +import { + assertAuthorizationCallbackIssuer, + readAuthorizationCallback, + type AuthorizationIssuerOptions, +} from "../oauth-discovery.js"; +import { assertRedirectUri } from "./http.js"; + +/** Fetch-compatible receiver implementing oauth-core's injected CallbackServer. + * The host MUST bind exactly redirectUri before returning this adapter from + * startCallbackServer, and MUST stop that listener from closeListener. + * Unlike the stock callback server this checks iss before returning a code. */ +export function createMcpCallbackAdapter(opts: { + expectedState: string; + redirectUri: string; + issuer: Omit; + closeListener(): void; +}): CallbackServer & { fetch(request: Request): Response } { + const redirect = assertRedirectUri(opts.redirectUri); + let outcome: { code: string } | { error: Error } | undefined; + let notify: (() => void) | undefined; + let closed = false; + return { + port: Number(redirect.port || (redirect.protocol === "https:" ? 443 : 80)), + fetch(request) { + const url = new URL(request.url); + if ( + request.method !== "GET" || + url.origin !== redirect.origin || + url.pathname !== redirect.pathname + ) + return new Response(null, { status: 404 }); + if (closed || outcome !== undefined) + return new Response(null, { status: 409 }); + if ( + url.searchParams.getAll("state").length !== 1 || + url.searchParams.get("state") !== opts.expectedState + ) + return new Response("Invalid authorization response", { status: 400 }); + try { + assertAuthorizationCallbackIssuer(url.searchParams, opts.issuer); + } catch { + // Unauthenticated attempts must not terminate the legitimate login. + return new Response("Invalid authorization response", { status: 400 }); + } + try { + outcome = readAuthorizationCallback(url.searchParams, opts.issuer); + notify?.(); + return new Response( + "Authorization received. You may close this window.", + { + headers: { + "cache-control": "no-store", + "content-type": "text/plain", + }, + }, + ); + } catch { + outcome = { + error: new OAuthDiscoveryError("Invalid authorization response"), + }; + notify?.(); + return new Response("Invalid authorization response", { status: 400 }); + } + }, + async waitForCode(signal) { + signal.throwIfAborted(); + if (outcome === undefined) + await new Promise((resolve, reject) => { + const abort = () => { + notify = undefined; + reject(new OAuthDiscoveryError("Authorization cancelled")); + }; + notify = () => { + signal.removeEventListener("abort", abort); + notify = undefined; + resolve(); + }; + signal.addEventListener("abort", abort, { once: true }); + }); + if (outcome === undefined || "error" in outcome) + throw ( + outcome?.error ?? new OAuthDiscoveryError("Authorization cancelled") + ); + return outcome.code; + }, + close() { + if (closed) return; + closed = true; + outcome ??= { error: new OAuthDiscoveryError("Authorization cancelled") }; + notify?.(); + opts.closeListener(); + }, + }; +} diff --git a/src/auth/challenge.ts b/src/auth/challenge.ts new file mode 100644 index 0000000..fe4d0be --- /dev/null +++ b/src/auth/challenge.ts @@ -0,0 +1,65 @@ +import { OAuthDiscoveryError } from "@corbits/oauth-core"; + +export type McpAuthChallenge = { + resourceMetadata?: string; + scope?: string; + error?: string; +}; + +/** RFC 9110 challenge lists: commas inside quoted strings are not separators. */ +export function parseMcpAuthChallenge( + header: string | null | undefined, +): McpAuthChallenge | undefined { + if (!header) return undefined; + if (header.length > 16384 || /[\r\n]/.test(header)) + throw new OAuthDiscoveryError("Invalid authentication challenge"); + const parts = header.match(/(?:[^,"]|"(?:[^"\\]|\\.)*")+/g) ?? []; + let bearer = false; + let found = false; + const params = new Map(); + for (const part of parts) { + let text = part.trim(); + const scheme = /^([!#$%&'*+.^_`|~\w-]+)(?:\s+|$)(.*)$/.exec(text); + if (scheme && !/^\s*=/.test(scheme[2] ?? "")) { + bearer = scheme[1]?.toLowerCase() === "bearer"; + if (bearer && found) + throw new OAuthDiscoveryError("Ambiguous Bearer challenge"); + if (bearer) found = true; + text = scheme[2] ?? ""; + } + if (!bearer || !text) continue; + const pair = + /^([!#$%&'*+.^_`|~\w-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([!#$%&'*+.^_`|~\w-]+))$/.exec( + text, + ); + if (!pair) throw new OAuthDiscoveryError("Malformed Bearer challenge"); + const key = pair[1]!.toLowerCase(); + if (params.has(key)) + throw new OAuthDiscoveryError("Duplicate Bearer challenge parameter"); + params.set(key, (pair[2] ?? pair[3] ?? "").replace(/\\(.)/g, "$1")); + } + if (!found) return undefined; + return { + ...(params.has("resource_metadata") + ? { resourceMetadata: params.get("resource_metadata")! } + : {}), + ...(params.has("scope") ? { scope: params.get("scope")! } : {}), + ...(params.has("error") ? { error: params.get("error")! } : {}), + }; +} + +/** Step-up is an explicit host decision, never an automatic replay or grant widening. */ +export function mcpStepUpScopes( + challenge: McpAuthChallenge, + granted: readonly string[], +): string[] { + if ( + challenge.error !== "insufficient_scope" || + challenge.scope === undefined + ) { + throw new OAuthDiscoveryError("No actionable insufficient_scope challenge"); + } + return [ + ...new Set([...granted, ...challenge.scope.split(" ").filter(Boolean)]), + ]; +} diff --git a/src/auth/contracts.ts b/src/auth/contracts.ts new file mode 100644 index 0000000..49e5b53 --- /dev/null +++ b/src/auth/contracts.ts @@ -0,0 +1,45 @@ +import { type } from "arktype"; + +/** Host-authenticated identity. Never derive these fields from MCP params. */ +export type McpIdentity = { + readonly tenantId: string; + readonly principalId: string; + readonly scopes: readonly string[]; + /** Host-assigned cache partition, never a bearer token. */ + readonly authorizationContextId: string; +}; + +export type McpAuthOperation = { + readonly resource: string; + readonly action: string; +}; + +export type McpAuthPolicy = { + authenticate( + request: Request, + ): McpIdentity | null | Promise; + authorize( + identity: McpIdentity, + operation: McpAuthOperation, + ): boolean | Promise; +}; + +const Identity = type({ + tenantId: "string > 0", + principalId: "string > 0", + scopes: "string[]", + authorizationContextId: "string > 0", +}); + +/** Copies only validated identity fields; extra verifier claims cannot leak. */ +export function validateMcpIdentity(value: unknown): McpIdentity { + const identity = Identity(value); + if (identity instanceof type.errors) + throw new Error("Invalid authenticated MCP identity"); + return Object.freeze({ + tenantId: identity.tenantId, + principalId: identity.principalId, + scopes: Object.freeze([...identity.scopes]), + authorizationContextId: identity.authorizationContextId, + }); +} diff --git a/src/auth/flows.test.ts b/src/auth/flows.test.ts new file mode 100644 index 0000000..d145910 --- /dev/null +++ b/src/auth/flows.test.ts @@ -0,0 +1,330 @@ +import { expect, test } from "bun:test"; +import { discoverMcpLoginEntry } from "../oauth-discovery.js"; +import { parseMcpAuthChallenge, mcpStepUpScopes } from "./challenge.js"; +import { createMcpResourceServerAuth } from "./resource-server.js"; +import { + createMcpAuthorizationSession, + prepareMcpClient, + refreshMcpOAuthTokens, + type McpOAuthTokens, +} from "./login.js"; + +const resource = "https://mcp.example/mcp"; +const issuer = "https://auth.example/tenant"; +const metadata = { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + code_challenge_methods_supported: ["S256"], + authorization_response_iss_parameter_supported: true, +}; +const identity = { + tenantId: "tenant", + principalId: "principal", + scopes: ["read"], + authorizationContextId: "partition", +}; + +function fixture() { + const calls: { url: string; init?: RequestInit }[] = []; + const fetchImpl = async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input); + calls.push({ url, ...(init !== undefined ? { init } : {}) }); + expect(init?.redirect).toBe("error"); + if (url === "https://mcp.example/metadata") + return Response.json({ + resource, + authorization_servers: [issuer], + scopes_supported: ["read"], + }); + if (url === `${issuer}/.well-known/openid-configuration`) + return Response.json(metadata); + if (url === `${issuer}/register`) + return Response.json({ client_id: "registered" }); + if (url === `${issuer}/token`) { + expect(new URLSearchParams(String(init?.body)).get("resource")).toBe( + resource, + ); + return Response.json({ + access_token: "access", + token_type: "Bearer", + scope: "read", + }); + } + return new Response(null, { status: 404 }); + }; + return { calls, fetchImpl }; +} + +test("challenge parser handles other schemes, quoted commas and duplicates", () => { + expect( + parseMcpAuthChallenge( + 'Basic realm="a,b", Bearer resource_metadata="https://mcp.example/metadata", scope="read write"', + ), + ).toEqual({ + resourceMetadata: "https://mcp.example/metadata", + scope: "read write", + }); + expect(() => + parseMcpAuthChallenge('Bearer scope="read", scope="write"'), + ).toThrow(); + expect(() => + parseMcpAuthChallenge('Bearer scope="read", Bearer scope="write"'), + ).toThrow(); + expect( + mcpStepUpScopes({ error: "insufficient_scope", scope: "write read" }, [ + "read", + ]), + ).toEqual(["read", "write"]); +}); + +test("complete challenge/OIDC/DCR/login/credential/request flow accepts a token-only response", async () => { + const { calls, fetchImpl } = fixture(); + const auth = createMcpResourceServerAuth({ + resource, + resourceMetadataUrl: "https://mcp.example/metadata", + authorizationServers: [issuer], + verifyAccessToken: async (token) => + token === "access" + ? { + issuer, + audience: [resource], + expiresAt: Date.now() + 60000, + identity, + } + : null, + authorize: (id, op) => + id.tenantId === "tenant" && + op.resource === "tool:read" && + op.action === "execute", + requiredScopes: () => ["read"], + }); + const initial = new Request(resource); + expect(await auth.policy.authenticate(initial)).toBeNull(); + const entry = await discoverMcpLoginEntry({ + resourceUrl: resource, + wwwAuthenticate: auth + .challenge(401, ["read"]) + .headers.get("www-authenticate")!, + fetchImpl, + }); + expect(calls.map((call) => call.url)).toEqual([ + "https://mcp.example/metadata", + "https://auth.example/.well-known/oauth-authorization-server/tenant", + "https://auth.example/.well-known/openid-configuration/tenant", + `${issuer}/.well-known/openid-configuration`, + ]); + const client = await prepareMcpClient({ + entry, + clientName: "client", + redirectUri: "http://127.0.0.1:8080/callback", + fetchImpl, + }); + let stored: McpOAuthTokens | undefined; + const session = createMcpAuthorizationSession({ + entry, + client, + config: { redirectUri: "http://127.0.0.1:8080/callback" }, + tenantId: "tenant", + principalId: "principal", + fetchImpl, + persist: async (binding, tokens) => { + expect(binding.resource).toBe(resource); + expect(binding.tenantId).toBe("tenant"); + stored = tokens; + }, + }); + const authorize = new URL(session.authorizeUrl); + expect(authorize.searchParams.get("resource")).toBe(resource); + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256"); + const params = new URLSearchParams({ + state: authorize.searchParams.get("state")!, + code: "code", + iss: issuer, + }); + await session.complete(params); + expect(stored).toEqual({ access: "access", scope: "read" }); + await expect(session.complete(params)).rejects.toThrow(/consumed/); + const authenticated = await auth.policy.authenticate( + new Request(resource, { + headers: { authorization: `Bearer ${stored?.access}` }, + }), + ); + expect(authenticated).toEqual(identity); + expect( + await auth.policy.authorize(authenticated!, { + resource: "tool:read", + action: "execute", + }), + ).toBe(true); + expect( + await auth.policy.authorize(authenticated!, { + resource: "tool:delete", + action: "execute", + }), + ).toBe(false); + await expect( + refreshMcpOAuthTokens({ + entry, + client, + binding: session.binding, + tokens: stored!, + }), + ).rejects.toThrow(/new authorization/); +}); + +test("PRM path then root precedes AS lookup; exact issuer mismatches stop discovery", async () => { + const seen: string[] = []; + const fetchImpl = async (input: string | URL | Request) => { + const url = String(input); + seen.push(url); + if (url.endsWith("oauth-protected-resource/mcp")) + return new Response(null, { status: 404 }); + if (url.endsWith("oauth-protected-resource")) + return Response.json({ resource, authorization_servers: [issuer] }); + return Response.json({ ...metadata, issuer: `${issuer}/` }); + }; + await expect( + discoverMcpLoginEntry({ resourceUrl: resource, fetchImpl }), + ).rejects.toThrow(/issuer mismatch/); + expect(seen).toHaveLength(3); + expect(seen[1]).toBe( + "https://mcp.example/.well-known/oauth-protected-resource", + ); +}); + +test("inbound wrong issuer, audience, expiry, identity and query tokens fail closed", async () => { + for (const overrides of [ + { issuer: `${issuer}/` }, + { audience: ["https://other.example"] }, + { expiresAt: 0 }, + { identity: { ...identity, tenantId: "" } }, + { notBefore: Date.now() + 60000 }, + ]) { + const auth = createMcpResourceServerAuth({ + resource, + resourceMetadataUrl: "https://mcp.example/metadata", + authorizationServers: [issuer], + verifyAccessToken: async () => ({ + issuer, + audience: [resource], + expiresAt: Date.now() + 60000, + identity, + ...overrides, + }), + authorize: () => true, + }); + expect( + await auth.policy.authenticate( + new Request(resource, { headers: { authorization: "Bearer access" } }), + ), + ).toBeNull(); + expect( + await auth.policy.authenticate( + new Request(`${resource}?access_token=access`), + ), + ).toBeNull(); + } +}); + +test("wrong state/issuer and duplicate callback fields never redeem a code", async () => { + const entry = { + resourceUrl: resource, + authorizationServer: { + issuer, + authorizationEndpoint: metadata.authorization_endpoint, + tokenEndpoint: metadata.token_endpoint, + authorizationResponseIssParameterSupported: true, + }, + }; + let exchanges = 0; + const session = createMcpAuthorizationSession({ + entry, + client: { kind: "pre-registered", issuer, clientId: "pre" }, + config: { redirectUri: "http://127.0.0.1:8080/callback" }, + tenantId: "tenant", + principalId: "principal", + persist: async () => {}, + fetchImpl: async () => { + exchanges++; + return Response.json({ access_token: "access", token_type: "Bearer" }); + }, + }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + for (const params of [ + new URLSearchParams({ state: "wrong", iss: issuer, code: "code" }), + new URLSearchParams({ state, iss: `${issuer}/`, code: "code" }), + new URLSearchParams( + `state=${state}&iss=${encodeURIComponent(issuer)}&code=a&code=b`, + ), + ]) + await expect(session.complete(params)).rejects.toThrow(); + expect(exchanges).toBe(0); +}); + +test("CIMD and pre-registered clients avoid DCR; refresh retains resource and rotated token", async () => { + const entry = { + resourceUrl: resource, + authorizationServer: { + issuer, + authorizationEndpoint: metadata.authorization_endpoint, + tokenEndpoint: metadata.token_endpoint, + clientIdMetadataDocumentSupported: true, + }, + }; + const cimd = await prepareMcpClient({ + entry, + clientName: "client", + redirectUri: "https://client.example/callback", + clientMetadataUrl: "https://client.example/client.json", + }); + expect(cimd).toEqual({ + kind: "cimd", + clientId: "https://client.example/client.json", + }); + const pre = await prepareMcpClient({ + entry, + clientName: "client", + redirectUri: "https://client.example/callback", + storedClient: { kind: "pre-registered", clientId: "pre", issuer }, + clientMetadataUrl: "https://client.example/client.json", + }); + expect(pre.kind).toBe("pre-registered"); + const binding = { + tenantId: "tenant", + principalId: "principal", + issuer, + resource, + clientId: "pre", + }; + const refreshed = await refreshMcpOAuthTokens({ + entry, + client: pre, + binding, + tokens: { access: "old", refresh: "refresh" }, + fetchImpl: async (_input, init) => { + expect(init?.redirect).toBe("error"); + const body = new URLSearchParams(String(init?.body)); + expect(body.get("resource")).toBe(resource); + expect(body.get("refresh_token")).toBe("refresh"); + return Response.json({ + access_token: "new", + refresh_token: "rotated", + token_type: "Bearer", + }); + }, + }); + expect(refreshed).toEqual({ access: "new", refresh: "rotated" }); + await expect( + refreshMcpOAuthTokens({ + entry, + client: pre, + binding: { ...binding, resource: "https://other.example/mcp" }, + tokens: refreshed, + }), + ).rejects.toThrow(/binding/); +}); diff --git a/src/auth/http.ts b/src/auth/http.ts new file mode 100644 index 0000000..5cff93f --- /dev/null +++ b/src/auth/http.ts @@ -0,0 +1,7 @@ +// OAuth-only compatibility facade. Neutral server code uses url-policy directly. +export { + assertOAuthUrl, + assertRedirectUri, + secureOAuthFetch, +} from "./oauth-http.js"; +export type { FetchLike } from "../protocol/types.js"; diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..37b9c55 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,6 @@ +export * from "./contracts.js"; +export * from "./resource-server.js"; +export * from "./challenge.js"; +export * from "./login.js"; +export * from "./callback.js"; +export { secureOAuthFetch } from "./http.js"; diff --git a/src/auth/login.ts b/src/auth/login.ts new file mode 100644 index 0000000..e9b4232 --- /dev/null +++ b/src/auth/login.ts @@ -0,0 +1,242 @@ +import { + buildAuthorizeUrl, + exchangeCode, + generatePkce, + generateState, + refreshTokenRequest, + OAuthDiscoveryError, +} from "@corbits/oauth-core"; +import { type } from "arktype"; +import { + assertAuthorizationCallbackIssuer, + mcpClientConfig, + preferredMcpClientRegistration, + readAuthorizationCallback, + registerMcpClient, + resolveStoredMcpClient, + type McpClientConfigOptions, + type McpLoginEntry, + type StoredMcpClient, +} from "../oauth-discovery.js"; +import { secureOAuthFetch, type FetchLike } from "./http.js"; + +/** Host-only secret material. Never expose through MCP results or agent context. */ +export type McpOAuthTokens = { + access: string; + refresh?: string; + expiresAt?: number; + scope?: string; +}; +export type McpOAuthBinding = { + tenantId: string; + principalId: string; + issuer: string; + resource: string; + clientId: string; +}; +const Token = type({ + access_token: "string > 0", + token_type: "string", + "refresh_token?": "string > 0", + "expires_in?": "number >= 0", + "scope?": "string", +}); + +function tokensFromResponse( + raw: unknown, + now: number, + previous?: McpOAuthTokens, +): McpOAuthTokens { + const response = Token(raw); + if ( + response instanceof type.errors || + response.token_type.toLowerCase() !== "bearer" + ) + throw new OAuthDiscoveryError("Invalid bearer token response"); + const refresh = response.refresh_token ?? previous?.refresh; + return { + access: response.access_token, + ...(refresh !== undefined ? { refresh } : {}), + ...(response.expires_in !== undefined + ? { expiresAt: now + response.expires_in * 1000 } + : {}), + ...(response.scope !== undefined + ? { scope: response.scope } + : previous?.scope !== undefined + ? { scope: previous.scope } + : {}), + }; +} + +/** Performs real DCR only when neither issuer-bound pre-registration nor CIMD applies. */ +export async function prepareMcpClient(opts: { + entry: McpLoginEntry; + storedClient?: StoredMcpClient; + clientMetadataUrl?: string; + clientName: string; + redirectUri: string; + /** Hosted web callbacks use "web"; native/loopback clients default to "native". */ + applicationType?: "native" | "web"; + fetchImpl?: FetchLike; +}): Promise { + const issuer = opts.entry.authorizationServer.issuer; + if (opts.storedClient !== undefined) { + resolveStoredMcpClient(issuer, opts.storedClient); + return { ...opts.storedClient }; + } + const plan = preferredMcpClientRegistration({ + authorizationServer: opts.entry.authorizationServer, + ...(opts.clientMetadataUrl !== undefined + ? { clientMetadataUrl: opts.clientMetadataUrl } + : {}), + }); + if (plan.mechanism === "client-id-metadata") + return { kind: "cimd", clientId: plan.clientId }; + if (plan.mechanism === "pre-registered") + return { kind: "pre-registered", clientId: plan.clientId, issuer }; + const registered = await registerMcpClient({ + registrationEndpoint: plan.registrationEndpoint, + issuer, + redirectUris: [opts.redirectUri], + clientName: opts.clientName, + ...(opts.applicationType !== undefined + ? { applicationType: opts.applicationType } + : {}), + ...(opts.entry.authorizationServer.grantTypesSupported !== undefined + ? { + grantTypesSupported: + opts.entry.authorizationServer.grantTypesSupported, + } + : {}), + ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}), + }); + return { kind: "dynamic", clientId: registered.clientId, issuer }; +} + +/** A one-use callback session. The host mounts its registered callback route, + * passes the full URLSearchParams here, then persists secrets server-side. + * Unlike oauth-core BaseTokens this contract permits access-token-only grants. + * No automatic tool replay, persistence, browser opening or grant widening. */ +export function createMcpAuthorizationSession(opts: { + entry: McpLoginEntry; + client: StoredMcpClient; + config: Omit; + tenantId: string; + principalId: string; + persist(binding: McpOAuthBinding, tokens: McpOAuthTokens): Promise; + fetchImpl?: FetchLike; + now?: () => number; + expiresInMs?: number; +}) { + if (!opts.tenantId || !opts.principalId) + throw new OAuthDiscoveryError( + "Login requires host-bound tenant and principal", + ); + const client = resolveStoredMcpClient( + opts.entry.authorizationServer.issuer, + opts.client, + ); + const config = mcpClientConfig(opts.entry, { + ...opts.config, + clientId: client.clientId, + }); + const pkce = generatePkce(); + const state = generateState(); + const now = opts.now ?? Date.now; + const expiresAt = now() + (opts.expiresInMs ?? 300_000); + const binding = Object.freeze({ + tenantId: opts.tenantId, + principalId: opts.principalId, + issuer: opts.entry.authorizationServer.issuer, + resource: opts.entry.resourceUrl, + clientId: client.clientId, + }); + let consumed = false; + const authorizeUrl = new URL(buildAuthorizeUrl(config, pkce, state)); + if (config.scopes.length === 0) authorizeUrl.searchParams.delete("scope"); + return { + authorizeUrl: authorizeUrl.toString(), + binding, + cancel() { + consumed = true; + }, + async complete(params: URLSearchParams): Promise { + if (consumed || now() >= expiresAt) + throw new OAuthDiscoveryError( + "Authorization session expired or already consumed", + ); + if (params.getAll("state").length !== 1 || params.get("state") !== state) + throw new OAuthDiscoveryError("Authorization state mismatch"); + const issuerOptions = { + recordedIssuer: binding.issuer, + ...(opts.entry.authorizationServer + .authorizationResponseIssParameterSupported !== undefined + ? { + authorizationResponseIssParameterSupported: + opts.entry.authorizationServer + .authorizationResponseIssParameterSupported, + } + : {}), + }; + assertAuthorizationCallbackIssuer(params, issuerOptions); + // An authentic terminal denial must consume the same one-use session as a code. + consumed = true; + const { code } = readAuthorizationCallback(params, issuerOptions); + let tokens: McpOAuthTokens; + try { + tokens = tokensFromResponse( + await exchangeCode( + config, + code, + pkce.verifier, + secureOAuthFetch(opts.fetchImpl), + ), + now(), + ); + } catch { + throw new OAuthDiscoveryError("OAuth code exchange failed"); + } + await opts.persist(binding, tokens); + }, + }; +} + +/** Refresh under the same recorded issuer/resource/client binding. */ +export async function refreshMcpOAuthTokens(opts: { + entry: McpLoginEntry; + client: StoredMcpClient; + binding: McpOAuthBinding; + tokens: McpOAuthTokens; + fetchImpl?: FetchLike; + now?: () => number; +}): Promise { + const { clientId } = resolveStoredMcpClient( + opts.entry.authorizationServer.issuer, + opts.client, + ); + if ( + opts.binding.issuer !== opts.entry.authorizationServer.issuer || + opts.binding.resource !== opts.entry.resourceUrl || + opts.binding.clientId !== clientId + ) + throw new OAuthDiscoveryError("OAuth refresh binding mismatch"); + if (opts.tokens.refresh === undefined) + throw new OAuthDiscoveryError( + "Access-token-only grant requires a new authorization", + ); + try { + const response = await refreshTokenRequest( + { + clientId, + tokenUrl: opts.entry.authorizationServer.tokenEndpoint, + resource: opts.binding.resource, + tokenTimeoutMs: 30_000, + }, + opts.tokens.refresh, + secureOAuthFetch(opts.fetchImpl), + ); + return tokensFromResponse(response, (opts.now ?? Date.now)(), opts.tokens); + } catch { + throw new OAuthDiscoveryError("OAuth refresh failed"); + } +} diff --git a/src/auth/oauth-http.ts b/src/auth/oauth-http.ts new file mode 100644 index 0000000..4381fda --- /dev/null +++ b/src/auth/oauth-http.ts @@ -0,0 +1,47 @@ +import { OAuthDiscoveryError } from "@corbits/oauth-core"; +import type { FetchLike } from "../protocol/types.js"; +import { + assertAuthServerUrl, + assertAuthRedirectUri, + McpAuthUrlError, +} from "./url-policy.js"; + +function withOAuthError(validate: () => URL): URL { + try { + return validate(); + } catch (error) { + if (error instanceof McpAuthUrlError) + throw new OAuthDiscoveryError(error.detail); + throw error; + } +} + +/** Compatibility facade retaining oauth-core error identity for OAuth callers. */ +export function assertOAuthUrl(raw: string, issuer = false): URL { + return withOAuthError(() => assertAuthServerUrl(raw, issuer)); +} + +export function assertRedirectUri(raw: string): URL { + return withOAuthError(() => assertAuthRedirectUri(raw)); +} + +/** Use for every oauth-core token call: its default fetch follows redirects. */ +export function secureOAuthFetch( + inner: FetchLike = globalThis.fetch, +): typeof fetch { + const wrapped = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + assertOAuthUrl(input instanceof Request ? input.url : String(input)); + const response = await inner(input, { ...init, redirect: "error" }); + if ( + (response.status >= 300 && response.status < 400) || + response.redirected + ) { + throw new OAuthDiscoveryError("OAuth endpoint redirect refused"); + } + return response; + }; + return Object.assign(wrapped, { preconnect: globalThis.fetch.preconnect }); +} diff --git a/src/auth/resource-server.ts b/src/auth/resource-server.ts new file mode 100644 index 0000000..adfb629 --- /dev/null +++ b/src/auth/resource-server.ts @@ -0,0 +1,129 @@ +import { type } from "arktype"; +import { assertAuthServerUrl } from "./url-policy.js"; +import { + validateMcpIdentity, + type McpAuthOperation, + type McpAuthPolicy, + type McpIdentity, +} from "./contracts.js"; + +const VerifiedToken = type({ + issuer: "string > 0", + audience: "string[]", + expiresAt: "number", + "notBefore?": "number", + identity: "unknown", +}); + +export type McpResourceServerOptions = { + resource: string; + resourceMetadataUrl: string; + authorizationServers: readonly string[]; + scopesSupported?: readonly string[]; + /** Must verify signature/algorithm/key trust or introspection, revocation and + * token type. Decoding a JWT is NOT verification. Returns trusted claims, + * expiresAt/notBefore in epoch milliseconds, and a host-assigned identity. + * Never map identity from unverified request params. */ + verifyAccessToken( + token: string, + context: { + resource: string; + issuers: readonly string[]; + signal: AbortSignal; + }, + ): Promise; + authorize( + identity: McpIdentity, + operation: McpAuthOperation, + ): boolean | Promise; + requiredScopes?(operation: McpAuthOperation): readonly string[]; + now?: () => number; +}; + +function quote(value: string): string { + if (/[^\x20-\x7e]/.test(value)) + throw new Error("Invalid authentication challenge value"); + return `"${value.replace(/["\\]/g, "\\$&")}"`; +} + +/** No token implementation or public-mode bypass is implied by this seam. */ +export function createMcpResourceServerAuth(opts: McpResourceServerOptions): { + policy: McpAuthPolicy; + metadataResponse(): Response; + challenge(status: 401 | 403, scopes?: readonly string[]): Response; +} { + assertAuthServerUrl(opts.resource); + assertAuthServerUrl(opts.resourceMetadataUrl); + if (opts.authorizationServers.length === 0) + throw new Error("At least one authorization server is required"); + const issuers = Object.freeze([...opts.authorizationServers]); + for (const issuer of issuers) assertAuthServerUrl(issuer, true); + const policy: McpAuthPolicy = { + async authenticate(request) { + // RFC 6750 bearer use is header-only: query tokens never authenticate. + if (new URL(request.url).searchParams.has("access_token")) return null; + const match = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i.exec( + request.headers.get("authorization") ?? "", + ); + if (!match?.[1]) return null; + try { + const claims = VerifiedToken( + await opts.verifyAccessToken(match[1], { + resource: opts.resource, + issuers, + signal: request.signal, + }), + ); + if (claims instanceof type.errors) return null; + const now = (opts.now ?? Date.now)(); + if ( + !issuers.includes(claims.issuer) || + !claims.audience.includes(opts.resource) || + !Number.isFinite(claims.expiresAt) || + claims.expiresAt <= now || + (claims.notBefore !== undefined && + (!Number.isFinite(claims.notBefore) || claims.notBefore > now)) + ) + return null; + return validateMcpIdentity(claims.identity); + } catch { + return null; + } + }, + async authorize(identity, operation) { + try { + const validated = validateMcpIdentity(identity); + if (!operation.resource || !operation.action) return false; + const scopes = opts.requiredScopes?.(operation) ?? []; + if (!scopes.every((scope) => validated.scopes.includes(scope))) + return false; + return (await opts.authorize(validated, operation)) === true; + } catch { + return false; + } + }, + }; + return { + policy, + metadataResponse: () => + Response.json({ + resource: opts.resource, + authorization_servers: issuers, + ...(opts.scopesSupported !== undefined + ? { scopes_supported: [...opts.scopesSupported] } + : {}), + }), + challenge(status, scopes) { + const fields = [`resource_metadata=${quote(opts.resourceMetadataUrl)}`]; + if (status === 403) fields.push('error="insufficient_scope"'); + if (scopes !== undefined) fields.push(`scope=${quote(scopes.join(" "))}`); + return new Response(null, { + status, + headers: { + "www-authenticate": `Bearer ${fields.join(", ")}`, + "cache-control": "no-store", + }, + }); + }, + }; +} diff --git a/src/auth/review-regressions.test.ts b/src/auth/review-regressions.test.ts new file mode 100644 index 0000000..a18def8 --- /dev/null +++ b/src/auth/review-regressions.test.ts @@ -0,0 +1,232 @@ +import { expect, test } from "bun:test"; +import { type } from "arktype"; +import { createMcpAuthorizationSession, prepareMcpClient } from "./login.js"; +import { createMcpCallbackAdapter } from "./callback.js"; +import { + mcpClientIdMetadataDocument, + preferredMcpClientRegistration, + resolveStoredMcpClient, +} from "../oauth-discovery.js"; + +const issuer = "https://auth.example"; +const entry = { + resourceUrl: "https://mcp.example/mcp", + authorizationServer: { + issuer, + authorizationEndpoint: `${issuer}/authorize`, + tokenEndpoint: `${issuer}/token`, + registrationEndpoint: `${issuer}/register`, + authorizationResponseIssParameterSupported: true, + }, +}; +const redirectUri = "http://127.0.0.1:8080/callback"; + +test("authenticated denial consumes a session; wrong state and issuer do not", async () => { + let exchanges = 0; + let persisted = 0; + const session = createMcpAuthorizationSession({ + entry, + client: { kind: "pre-registered", clientId: "client", issuer }, + config: { redirectUri }, + tenantId: "tenant", + principalId: "principal", + persist: async () => { + persisted++; + }, + fetchImpl: async () => { + exchanges++; + return Response.json({ access_token: "access", token_type: "Bearer" }); + }, + }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + await expect( + session.complete( + new URLSearchParams({ + state: "wrong", + iss: issuer, + error: "access_denied", + }), + ), + ).rejects.toThrow(/state/); + await expect( + session.complete( + new URLSearchParams({ + state, + iss: "https://wrong.example", + error: "access_denied", + }), + ), + ).rejects.toThrow(/issuer/); + await expect( + session.complete( + new URLSearchParams({ state, iss: issuer, error: "access_denied" }), + ), + ).rejects.toThrow(/rejected/); + await expect( + session.complete( + new URLSearchParams({ state, iss: issuer, code: "later" }), + ), + ).rejects.toThrow(/consumed/); + expect(exchanges).toBe(0); + expect(persisted).toBe(0); +}); + +test("callback adapter preserves pending login on wrong issuer, but denial is terminal", async () => { + const adapter = createMcpCallbackAdapter({ + expectedState: "state", + redirectUri, + issuer: { + recordedIssuer: issuer, + authorizationResponseIssParameterSupported: true, + }, + closeListener() {}, + }); + expect( + adapter.fetch( + new Request( + `${redirectUri}?state=state&iss=https://wrong.example&error=access_denied`, + ), + ).status, + ).toBe(400); + expect( + adapter.fetch( + new Request( + `${redirectUri}?state=wrong&iss=${issuer}&error=access_denied`, + ), + ).status, + ).toBe(400); + expect( + adapter.fetch( + new Request( + `${redirectUri}?state=state&iss=${issuer}&error=access_denied`, + ), + ).status, + ).toBe(400); + expect( + adapter.fetch( + new Request(`${redirectUri}?state=state&iss=${issuer}&code=later`), + ).status, + ).toBe(409); + await expect( + adapter.waitForCode(new AbortController().signal), + ).rejects.toThrow(); + adapter.close(); +}); + +test("CIMD raw terminal dot segments before query are rejected at every entry point", () => { + for (const segment of [".", "..", "%2e", "%2E%2e", ".%2E", "%2e."]) { + const metadataUrl = `https://client.example/a/b/${segment}?v=1`; + expect(() => + mcpClientIdMetadataDocument({ + metadataUrl, + clientName: "client", + redirectUris: [redirectUri], + }), + ).toThrow(/dot segments/); + expect(() => + preferredMcpClientRegistration({ + authorizationServer: { clientIdMetadataDocumentSupported: true }, + clientMetadataUrl: metadataUrl, + }), + ).toThrow(/dot segments/); + expect(() => + resolveStoredMcpClient(issuer, { kind: "cimd", clientId: metadataUrl }), + ).toThrow(/dot segments/); + } + expect( + mcpClientIdMetadataDocument({ + metadataUrl: "https://client.example/client.json?v=/../", + clientName: "client", + redirectUris: [redirectUri], + }).client_id, + ).toBe("https://client.example/client.json?v=/../"); +}); + +test("DCR passes explicit web or native application type to a constraining AS", async () => { + const Registration = type({ + application_type: "'web' | 'native'", + redirect_uris: "string[]", + }); + for (const applicationType of ["web", "native"] as const) { + const callback = + applicationType === "web" + ? "https://client.example/callback" + : redirectUri; + const client = await prepareMcpClient({ + entry, + clientName: "client", + redirectUri: callback, + applicationType, + fetchImpl: async (_input, init) => { + const body = Registration(JSON.parse(String(init?.body))); + if (body instanceof type.errors) + return new Response(null, { status: 400 }); + if ( + body.application_type !== applicationType || + body.redirect_uris[0] !== callback + ) + return new Response(null, { status: 400 }); + return Response.json({ client_id: `${applicationType}-client` }); + }, + }); + expect(client.clientId).toBe(`${applicationType}-client`); + } +}); + +test("CIMD rejects normalized alternate authority syntax at every entry point", () => { + for (const metadataUrl of [ + "https:/client.example/a/b/%2e%2e?v=1", + "https:client.example/a/b/..?v=1", + "https:///client.example/client.json", + "https:////client.example/client.json", + "https:\\client.example\\client.json", + "https://client.example/a\\client.json", + " https://client.example/client.json", + "https://client.example/client.json ", + "https://client.example/cl\tient.json", + "https://client.example/cl\nient.json", + "https://client.example/client.json\u0000", + "https://client.example/client.json\u007f", + ]) { + expect(() => + mcpClientIdMetadataDocument({ + metadataUrl, + clientName: "client", + redirectUris: [redirectUri], + }), + ).toThrow(); + expect(() => + preferredMcpClientRegistration({ + authorizationServer: { clientIdMetadataDocumentSupported: true }, + clientMetadataUrl: metadataUrl, + }), + ).toThrow(); + expect(() => + resolveStoredMcpClient(issuer, { kind: "cimd", clientId: metadataUrl }), + ).toThrow(); + } + for (const metadataUrl of [ + "https://client.example/client.json?v=/../", + "HTTPS://CLIENT.example:443/client.json?v=1", + "https://client.example/a%20b/client.json?next=%2F..%2F", + "https://[2001:db8::1]:8443/client.json", + ]) { + expect( + mcpClientIdMetadataDocument({ + metadataUrl, + clientName: "client", + redirectUris: [redirectUri], + }).client_id, + ).toBe(metadataUrl); + expect( + preferredMcpClientRegistration({ + authorizationServer: { clientIdMetadataDocumentSupported: true }, + clientMetadataUrl: metadataUrl, + }), + ).toEqual({ mechanism: "client-id-metadata", clientId: metadataUrl }); + expect( + resolveStoredMcpClient(issuer, { kind: "cimd", clientId: metadataUrl }) + .clientId, + ).toBe(metadataUrl); + } +}); diff --git a/src/auth/security.test.ts b/src/auth/security.test.ts new file mode 100644 index 0000000..be91710 --- /dev/null +++ b/src/auth/security.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { exchangeCode, refreshTokenRequest } from "@corbits/oauth-core"; +import { + mcpClientConfig, + mcpClientIdMetadataDocument, + resolveStoredMcpClient, +} from "../oauth-discovery.js"; +import { parseMcpEndpoint } from "../url.js"; + +const entry = { + resourceUrl: "https://mcp.example/mcp", + authorizationServer: { + issuer: "https://auth.example", + authorizationEndpoint: "https://auth.example/authorize", + tokenEndpoint: "https://auth.example/token", + }, +}; + +test("issuer-keyed registrations reject trailing slash aliases", () => { + expect(() => + resolveStoredMcpClient("https://auth.example/", { + kind: "dynamic", + clientId: "id", + issuer: "https://auth.example", + }), + ).toThrow(); +}); + +test("CIMD rejects encoded dot segments before URL normalization", () => { + for (const path of [ + "a/%2e%2e/client.json", + "a/.%2E/client.json", + "a/%2e/client.json", + ]) { + expect(() => + mcpClientIdMetadataDocument({ + metadataUrl: `https://client.example/${path}`, + clientName: "client", + redirectUris: ["http://127.0.0.1:8080/callback"], + }), + ).toThrow(); + } +}); + +test("credential-bearing and fragment endpoints are refused without quoting secrets", () => { + for (const url of [ + "https://user:secret@mcp.example/mcp", + "https://mcp.example/mcp#secret", + ]) { + expect(() => parseMcpEndpoint(url)).toThrow(); + try { + parseMcpEndpoint(url); + } catch (error) { + expect(String(error)).not.toContain("secret"); + } + } +}); + +test("resource propagates through installed oauth-core code and refresh requests", async () => { + const config = mcpClientConfig(entry, { + clientId: "client", + redirectUri: "http://127.0.0.1:8080/callback", + }); + const bodies: URLSearchParams[] = []; + const fake = Object.assign( + async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(new URLSearchParams(String(init?.body))); + return Response.json({ access_token: "access", token_type: "Bearer" }); + }, + { preconnect: fetch.preconnect }, + ); + await exchangeCode(config, "code", "verifier", fake); + await refreshTokenRequest(config, "refresh", fake); + expect(bodies.map((body) => body.get("resource"))).toEqual([ + entry.resourceUrl, + entry.resourceUrl, + ]); +}); + +test("extra authorization parameters cannot replace PKCE, state, identity or scopes", () => { + for (const key of [ + "state", + "code_challenge", + "code_challenge_method", + "client_id", + "redirect_uri", + "response_type", + "scope", + ]) { + expect(() => + mcpClientConfig(entry, { + clientId: "client", + redirectUri: "http://127.0.0.1:8080/callback", + extraAuthorizeParams: { [key]: "attacker" }, + }), + ).toThrow(/security parameters/); + } +}); diff --git a/src/auth/url-policy.test.ts b/src/auth/url-policy.test.ts new file mode 100644 index 0000000..e5f9f44 --- /dev/null +++ b/src/auth/url-policy.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { OAuthDiscoveryError } from "@corbits/oauth-core"; +import { assertOAuthUrl, assertRedirectUri, secureOAuthFetch } from "./http.js"; +import { + assertAuthServerUrl, + assertAuthRedirectUri, + McpAuthUrlError, +} from "./url-policy.js"; + +function caught(run: () => unknown): Error { + try { + run(); + } catch (error) { + if (error instanceof Error) return error; + throw error; + } + throw new Error("Expected URL validation to fail"); +} + +test("neutral server URL checks retain OAuth facade validation and diagnostics", () => { + for (const [raw, issuer] of [ + ["not-a-url", false], + ["http://127.0.0.1/token", false], + ["http://internal.example/token", false], + ["https://user:secret@auth.example/token", false], + ["https://auth.example/token#secret", false], + ["https://auth.example/issuer?tenant=one", true], + ["https://auth.example/issuer?", true], + ["https://auth.example/a\\b", false], + ] as const) { + const neutral = caught(() => assertAuthServerUrl(raw, issuer)); + const oauth = caught(() => assertOAuthUrl(raw, issuer)); + expect(neutral).toBeInstanceOf(McpAuthUrlError); + expect(oauth).toBeInstanceOf(OAuthDiscoveryError); + expect(neutral.name).toBe(oauth.name); + expect(neutral.message).toBe(oauth.message); + expect(neutral.message).not.toContain("secret"); + } + for (const raw of [ + "https://auth.example/token?aud=mcp", + "https://auth.example/issuer", + "https://[::1]/issuer", + ]) { + expect(assertAuthServerUrl(raw).href).toBe(assertOAuthUrl(raw).href); + } +}); + +test("redirect facade retains loopback exception and oauth-core error identity", () => { + for (const raw of [ + "http://127.0.0.1:8080/callback", + "http://localhost/callback", + "https://client.example/callback", + ]) { + expect(assertAuthRedirectUri(raw).href).toBe(assertRedirectUri(raw).href); + } + const neutral = caught(() => + assertAuthRedirectUri("http://internal.example/callback"), + ); + const oauth = caught(() => + assertRedirectUri("http://internal.example/callback"), + ); + expect(neutral.message).toBe(oauth.message); + expect(oauth).toBeInstanceOf(OAuthDiscoveryError); +}); + +test("HTTP compatibility facade preserves secure fetch export and redirect error class", async () => { + const safe = secureOAuthFetch(async (_input, init) => { + expect(init?.redirect).toBe("error"); + return new Response(null, { status: 307 }); + }); + await expect(safe("https://auth.example/token")).rejects.toBeInstanceOf( + OAuthDiscoveryError, + ); +}); diff --git a/src/auth/url-policy.ts b/src/auth/url-policy.ts new file mode 100644 index 0000000..4ed6a3b --- /dev/null +++ b/src/auth/url-policy.ts @@ -0,0 +1,42 @@ +import { parseMcpEndpoint } from "../url.js"; + +/** Neutral URL-policy error. Keep the existing diagnostic name/message, without + * loading the optional OAuth implementation solely to construct an exception. + * OAuth-facing facades translate this to the actual oauth-core error class. */ +export class McpAuthUrlError extends Error { + readonly detail: string; + constructor(detail: string) { + super(`OAuth discovery failed: ${detail}`); + this.name = "OAuthDiscoveryError"; + this.detail = detail; + } +} + +/** OAuth endpoints have no cleartext exception, including internal hosts. */ +export function assertAuthServerUrl(raw: string, issuer = false): URL { + let url: URL; + try { + url = parseMcpEndpoint(raw); + } catch { + throw new McpAuthUrlError("Invalid OAuth URL"); + } + if ( + url.protocol !== "https:" || + (issuer && (url.search !== "" || raw.includes("?"))) + ) { + throw new McpAuthUrlError( + "OAuth URL must use HTTPS; issuer must not contain a query", + ); + } + return url; +} + +export function assertAuthRedirectUri(raw: string): URL { + try { + return parseMcpEndpoint(raw); + } catch { + throw new McpAuthUrlError( + "Invalid OAuth redirect URI: HTTPS or loopback HTTP required", + ); + } +} diff --git a/src/hub/discover.test.ts b/src/hub/discover.test.ts index f1824c5..1b89a3d 100644 --- a/src/hub/discover.test.ts +++ b/src/hub/discover.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, test, afterEach } from "bun:test"; +import { describe, expect, test, afterEach, spyOn } from "bun:test"; import { MCP_NO_TOKEN_SENTINEL } from "@corbits/credential-http"; import type { TenantEnv } from "@intx/hub-api"; import { Hono } from "hono"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; import { discoverMcpServer, @@ -31,6 +33,7 @@ function appWith( readonly expiresAt?: Date | null; readonly onError?: (error: unknown) => void; readonly allowLoopback?: boolean; + readonly onWhere?: (clause: SQL) => void; } = {}, ): Hono { const app = new Hono(); @@ -57,12 +60,15 @@ function appWith( select: () => ({ from: () => ({ innerJoin: () => ({ - where: (_clause: unknown) => ({ - // The stub cannot read drizzle's clause, so the id is threaded - // through the only credential the test registers. - limit: () => - Promise.resolve(row(Object.keys(secrets)[0] ?? "none")), - }), + where: (clause: SQL) => { + opts.onWhere?.(clause); + return { + // The stub cannot read drizzle's clause, so the id is threaded + // through the only credential the test registers. + limit: () => + Promise.resolve(row(Object.keys(secrets)[0] ?? "none")), + }; + }, }), }), }), @@ -385,20 +391,114 @@ describe("POST /mcp/discover", () => { expect(handle.requestsSeen).toHaveLength(0); }); - test("an upstream status is not echoed", async () => { - const server = Bun.serve({ - port: 0, - fetch: () => new Response("admin", { status: 403 }), - }); + test("an upstream status is not echoed even when the safe origin contains 403", async () => { + // No listener or DNS lookup: reproduce a benign port containing the status + // digits deterministically, without reserving a collision-prone fixed port. + const url = new URL("https://mcp403.example:64038/admin"); + const fetchImpl = Object.assign( + async (input: string | URL | Request) => { + expect(input instanceof Request ? input.url : String(input)).toBe( + url.href, + ); + return new Response("admin", { status: 403 }); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + const upstream = spyOn(globalThis, "fetch").mockImplementation(fetchImpl); + const errors: unknown[] = []; try { - const { status, json } = await post(appWith({}), { - url: new URL("/admin", server.url).href, - }); + const { status, json } = await post( + appWith({}, { onError: (error) => errors.push(error) }), + { + url: url.href, + }, + ); + expect(upstream).toHaveBeenCalled(); expect(status).toBe(422); - expect(String(json["error"])).toEndWith("the server refused the request"); - expect(String(json["error"])).not.toContain("403"); + expect(json).toEqual({ + error: `the MCP server at ${url.origin} could not be discovered: the server refused the request`, + }); + expect( + errors.map((error) => + error instanceof Error ? error.message : String(error), + ), + ).toEqual(["the server refused the request"]); } finally { - await server.stop(true); + upstream.mockRestore(); } }); }); + +test("peer JSON-RPC error text cannot reflect a bearer through response or onError", async () => { + const secret = "reflected-sensitive-token"; + const observed: unknown[] = []; + const peer = Bun.serve({ + port: 0, + async fetch(request) { + const payload = (await request.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: payload.id, + error: { code: -32603, message: request.headers.get("authorization") }, + }); + }, + }); + try { + const url = `http://127.0.0.1:${peer.port}/mcp`; + const response = await post( + appWith( + { cred_1: secret }, + { apiBaseUrl: url, onError: (error) => observed.push(error) }, + ), + { url, credentialId: "cred_1" }, + ); + expect(response.status).toBe(422); + expect(JSON.stringify(response.json)).not.toContain(secret); + expect(observed.map(String).join(" ")).not.toContain(secret); + } finally { + await peer.stop(true); + } +}); + +test("stored OAuth resource binding cannot be overridden by an origin allowance", async () => { + let requested = false; + await expect( + discoverMcpServer({ + url: "https://mcp.example/other", + credential: { + secret: "token", + origin: "https://mcp.example", + resource: "https://mcp.example/mcp", + }, + fetch: async () => { + requested = true; + return Response.json({}); + }, + }), + ).rejects.toThrow(/resource binding/); + expect(requested).toBe(false); +}); + +test("credential lookup binds both selected credential and authenticated tenant", async () => { + let checked = false; + const app = appWith( + {}, + { + onWhere(clause) { + const query = new PgDialect().sqlToQuery(clause); + expect(query.params).toEqual(["foreign-credential", "tnt_1"]); + expect(query.sql).toContain('"credential"."tenant_id"'); + checked = true; + }, + }, + ); + expect( + ( + await post(app, { + url: "https://mcp.example/mcp", + credentialId: "foreign-credential", + }) + ).status, + ).toBe(404); + expect(checked).toBe(true); +}); diff --git a/src/hub/discover.ts b/src/hub/discover.ts index 0312148..759f4d9 100644 --- a/src/hub/discover.ts +++ b/src/hub/discover.ts @@ -56,6 +56,8 @@ export type McpCredential = { readonly secret: string; /** The provider's API origin the secret is pinned to, when it has one. */ readonly origin?: string; + /** Host-stored OAuth audience; extraOrigins never overrides this binding. */ + readonly resource?: string; }; /** @@ -85,6 +87,7 @@ export async function readCredential(opts: { status: credential.status, expiresAt: credential.expiresAt, apiBaseUrl: provider.apiBaseUrl, + metadata: credential.metadata, }) .from(credential) .innerJoin(provider, eq(provider.id, credential.providerId)) @@ -103,15 +106,26 @@ export async function readCredential(opts: { ) { return undefined; } + const metadata = type({ "oauthResource?": "string > 0" })(row.metadata ?? {}); + if (metadata instanceof type.errors) return undefined; + const resource = metadata.oauthResource; + if (resource !== undefined) parseMcpEndpoint(resource); const secret = await opts.cipher.decrypt( row.secret, credentialAad(row.id, "secret"), ); - return row.apiBaseUrl === null - ? { secret } - : { secret, origin: new URL(row.apiBaseUrl).origin }; + return { + secret, + ...(row.apiBaseUrl !== null + ? { origin: parseMcpEndpoint(row.apiBaseUrl).origin } + : {}), + ...(resource !== undefined ? { resource } : {}), + }; } +/** Only this local class is safe to expose; peer McpError text is untrusted. */ +class DiscoveryPolicyError extends McpError {} + const HEADER_VALUE = /^[\t\x20-\x7e]*$/; /** A 3xx would send the bearer onward, so the pinned fetch's manual redirect @@ -120,7 +134,7 @@ function refusingRedirects(inner: FetchLike): FetchLike { return async (input, init) => { const response = await inner(input, init); if (response.status >= 300 && response.status < 400) { - throw new McpError( + throw new DiscoveryPolicyError( `the MCP server answered a ${String(response.status)} redirect; refusing to follow it`, ); } @@ -150,6 +164,14 @@ export async function discoverMcpServer(args: { readonly timeoutMs?: number; }): Promise { const target = parseMcpEndpoint(args.url); + if ( + args.credential?.resource !== undefined && + args.credential.resource !== args.url + ) { + throw new DiscoveryPolicyError( + "credential resource binding does not match the MCP endpoint", + ); + } const secret = args.credential?.secret; const token = secret === undefined || secret === MCP_NO_TOKEN_SENTINEL @@ -157,19 +179,27 @@ export async function discoverMcpServer(args: { : secret; const origin = token === undefined ? target.origin : args.credential?.origin; if (origin === undefined) { - throw new McpError("the credential's provider has no API origin to pin to"); + throw new DiscoveryPolicyError( + "the credential's provider has no API origin to pin to", + ); } // A secret that is not a valid header value makes fetch throw a message // quoting it, so it is refused here with a fixed one. if (token !== undefined && !HEADER_VALUE.test(token)) { - throw new McpError("the credential's secret is not a valid header value"); + throw new DiscoveryPolicyError( + "the credential's secret is not a valid header value", + ); } const extraOrigins = Object.entries(args.extraOrigins ?? {}).flatMap( - ([pinned, extra]) => (new URL(pinned).origin === origin ? extra : []), + ([pinned, extra]) => + parseMcpEndpoint(pinned).origin === origin ? extra : [], ); - const allowed = [origin, ...extraOrigins.map((o) => new URL(o).origin)]; + const allowed = [ + origin, + ...extraOrigins.map((o) => parseMcpEndpoint(o).origin), + ]; if (!allowed.includes(target.origin)) { - throw new McpError( + throw new DiscoveryPolicyError( `credential is pinned to ${origin}; refusing cross-origin request to ${target.origin}`, ); } @@ -254,14 +284,14 @@ export function mountMcpDiscovery( // as a fetch error, may quote the material the request carried. An // upstream status is not echoed, so the route is no port-probing oracle. const error = - cause instanceof McpError && cause.status === undefined + cause instanceof DiscoveryPolicyError ? cause : new McpError( cause instanceof McpError ? "the server refused the request" : "the handshake failed", ); - opts.onError?.(error, { url: body.url }); + opts.onError?.(error, { url: `${target.origin}${target.pathname}` }); return c.json( { error: `the MCP server at ${target.origin} could not be discovered: ${error.message}`, diff --git a/src/index.ts b/src/index.ts index b7f8726..5675533 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,3 +80,6 @@ export { type StoredMcpClient, type AuthorizationIssuerOptions, } from "./oauth-discovery.js"; + +// Guarded OAuth host APIs; standalone client imports remain dependency-light. +export * from "./auth/index.js"; diff --git a/src/oauth-discovery.test.ts b/src/oauth-discovery.test.ts index a9b38ad..08aca9f 100644 --- a/src/oauth-discovery.test.ts +++ b/src/oauth-discovery.test.ts @@ -143,21 +143,30 @@ describe("MCP OAuth discovery", () => { body: fallbackAsMetadata, }, }); - const entry = await discoverMcpLoginEntry({ resourceUrl, fetchImpl }); + const entry = await discoverMcpLoginEntry({ + resourceUrl, + fetchImpl, + allowLegacyAuthorizationServerFallback: true, + }); expect(entry.authorizationServer.tokenEndpoint).toBe( "https://auth.example.com/token", ); }); - test("accepts fallback metadata whose issuer differs only by a trailing slash", async () => { + test("rejects fallback metadata whose issuer differs only by a trailing slash", async () => { const { fetchImpl } = fakeFetch({ "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { status: 200, body: { ...fallbackAsMetadata, issuer: `${resourceUrl}/` }, }, }); - const entry = await discoverMcpLoginEntry({ resourceUrl, fetchImpl }); - expect(entry.authorizationServer.issuer).toBe(`${resourceUrl}/`); + await expect( + discoverMcpLoginEntry({ + resourceUrl, + fetchImpl, + allowLegacyAuthorizationServerFallback: true, + }), + ).rejects.toThrow(/issuer mismatch/); }); test("rejects fallback metadata naming an issuer other than the resource", async () => { @@ -171,7 +180,11 @@ describe("MCP OAuth discovery", () => { }, }); await expect( - discoverMcpLoginEntry({ resourceUrl, fetchImpl }), + discoverMcpLoginEntry({ + resourceUrl, + fetchImpl, + allowLegacyAuthorizationServerFallback: true, + }), ).rejects.toThrow(/issuer mismatch/); }); @@ -619,7 +632,7 @@ describe("MCP client registration choice", () => { }).clientId, ).toBe(metadataUrl); expect( - resolveStoredMcpClient("https://auth.example.com/", { + resolveStoredMcpClient("https://auth.example.com", { kind: "dynamic", clientId: "dyn", issuer: "https://auth.example.com", @@ -689,7 +702,7 @@ describe("authorization response iss", () => { }), { recordedIssuer: "https://auth.example.com" }, ), - ).toThrow(/access_denied/); + ).toThrow(/rejected the request/); }); }); diff --git a/src/oauth-discovery.ts b/src/oauth-discovery.ts index 11240e5..61fed9f 100644 --- a/src/oauth-discovery.ts +++ b/src/oauth-discovery.ts @@ -1,4 +1,6 @@ -import type { FetchLike } from "@intx/harness"; +import type { FetchLike } from "./auth/http.js"; +import { assertOAuthUrl, assertRedirectUri } from "./auth/http.js"; +import { parseMcpAuthChallenge } from "./auth/challenge.js"; import { type } from "arktype"; import { @@ -48,7 +50,7 @@ const AuthorizationServerMetadata = type({ // the request (e.g. drop refresh_token, trim scope); callers that need to // persist what was granted read these instead of assuming the request stuck. const ClientRegistrationResponse = type({ - client_id: "string", + client_id: "string > 0", "grant_types?": "string[]", "scope?": "string", }); @@ -78,12 +80,18 @@ export type McpLoginEntry = { // The protected resource's own `scopes_supported` (RFC 9728 §2), if it // published one. Distinct from the authorization server's scopes_supported. resourceScopesSupported?: readonly string[]; + /** Scope required by the actual MCP challenge, ahead of PRM defaults. */ + challengeScope?: string; }; export type DiscoverMcpLoginEntryOptions = { resourceUrl: string; fetchImpl?: FetchLike; timeoutMs?: number; + /** Challenge retained from the actual MCP 401/403 response. */ + wwwAuthenticate?: string; + /** Pre-PRM compatibility only; modern MCP requires protected-resource metadata. */ + allowLegacyAuthorizationServerFallback?: boolean; }; export type RegisterMcpClientOptions = { @@ -191,31 +199,24 @@ function wellKnownUrl(raw: string, suffix: string): string { return parsed.toString(); } -// RFC 8414 §3: the issuer a well-known URL was derived from, with the same -// trailing-slash normalization `wellKnownUrl` applies. -function normalizedIssuer(raw: string): string { - const parsed = new URL(raw); - const path = - parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, ""); - return `${parsed.origin}${path}`; -} - async function getJson( url: string, fetchImpl: FetchLike, timeoutMs: number, ): Promise { + assertOAuthUrl(url); let res: Response; try { res = await fetchImpl(url, { + redirect: "error", headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs), }); - } catch (error) { - throw new OAuthDiscoveryError( - `request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`, - ); + } catch { + throw new OAuthDiscoveryError("OAuth metadata request failed"); } + if (res.redirected || (res.status >= 300 && res.status < 400)) + throw new OAuthDiscoveryError("OAuth metadata redirect refused"); return res; } @@ -277,66 +278,87 @@ export async function discoverMcpLoginEntry( const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const resourceUrl = opts.resourceUrl; - try { - new URL(resourceUrl); - } catch { - throw new OAuthDiscoveryError(`not a valid URL: ${resourceUrl}`); - } - + const resource = assertOAuthUrl(resourceUrl); + const challenge = parseMcpAuthChallenge(opts.wwwAuthenticate); + const resourceUrls = + challenge?.resourceMetadata !== undefined + ? [challenge.resourceMetadata] + : [ + ...new Set([ + wellKnownUrl(resourceUrl, "oauth-protected-resource"), + `${resource.origin}/.well-known/oauth-protected-resource`, + ]), + ]; let issuer: string | undefined; let resourceScopesSupported: readonly string[] | undefined; - const resourceMetadataUrl = wellKnownUrl( - resourceUrl, - "oauth-protected-resource", - ); - const resourceRes = await getJson(resourceMetadataUrl, fetchImpl, timeoutMs); - if (resourceRes.ok) { + for (const resourceMetadataUrl of resourceUrls) { + const resourceRes = await getJson( + resourceMetadataUrl, + fetchImpl, + timeoutMs, + ); + if (resourceRes.status === 404 && challenge?.resourceMetadata === undefined) + continue; + if (!resourceRes.ok) + throw badStatus(resourceMetadataUrl, resourceRes.status); const payload = ProtectedResourceMetadata( await readJson(resourceRes, resourceMetadataUrl), ); if (payload instanceof type.errors) - throw new OAuthDiscoveryError( - `protected-resource metadata at ${resourceMetadataUrl} is malformed: ${payload.summary}`, - ); + throw new OAuthDiscoveryError("Malformed protected-resource metadata"); if (payload.resource !== resourceUrl) throw new OAuthDiscoveryError( - `protected-resource metadata resource mismatch: expected ${resourceUrl}, got ${payload.resource}.`, + "protected-resource metadata resource mismatch", ); issuer = payload.authorization_servers?.[0]; if (issuer === undefined) throw new OAuthDiscoveryError( - `protected-resource metadata at ${resourceMetadataUrl} names no authorization server.`, + "Protected-resource metadata names no authorization server", ); + for (const candidate of payload.authorization_servers ?? []) + assertOAuthUrl(candidate, true); resourceScopesSupported = payload.scopes_supported; - } else if (resourceRes.status !== 404) { - throw badStatus(resourceMetadataUrl, resourceRes.status); + break; } - - // No protected-resource document: the resource itself may serve - // authorization-server metadata (some MCP servers do). - const asMetadataUrl = - issuer === undefined - ? wellKnownUrl(resourceUrl, "oauth-authorization-server") - : wellKnownUrl(issuer, "oauth-authorization-server"); - const asRes = await getJson(asMetadataUrl, fetchImpl, timeoutMs); - if (!asRes.ok) throw badStatus(asMetadataUrl, asRes.status); - const metadata = AuthorizationServerMetadata( - await readJson(asRes, asMetadataUrl), - ); - if (metadata instanceof type.errors) - throw new OAuthDiscoveryError( - `authorization-server metadata at ${asMetadataUrl} is malformed: ${metadata.summary}`, - ); - // RFC 8414 §3.3: metadata must name the issuer it was fetched for. In the - // fallback that issuer is the one the resource URL itself identifies. - const expectedIssuer = issuer ?? normalizedIssuer(resourceUrl); - if ( - !URL.canParse(metadata.issuer) || - normalizedIssuer(metadata.issuer) !== normalizedIssuer(expectedIssuer) - ) - throw new OAuthDiscoveryError( - `authorization-server metadata issuer mismatch: expected ${expectedIssuer}, got ${metadata.issuer}.`, + if (issuer === undefined) { + if (opts.allowLegacyAuthorizationServerFallback !== true) + throw new OAuthDiscoveryError("Protected-resource metadata not found"); + issuer = resourceUrl; + } + const issuerUrl = assertOAuthUrl(issuer, true); + const metadataUrls = [ + ...new Set([ + wellKnownUrl(issuer, "oauth-authorization-server"), + wellKnownUrl(issuer, "openid-configuration"), + `${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`, + ]), + ]; + let metadata: typeof AuthorizationServerMetadata.infer | undefined; + let asMetadataUrl = metadataUrls[0]!; + for (const candidate of metadataUrls) { + asMetadataUrl = candidate; + const response = await getJson(candidate, fetchImpl, timeoutMs); + if (response.status === 404) continue; + if (!response.ok) throw badStatus(candidate, response.status); + const parsed = AuthorizationServerMetadata( + await readJson(response, candidate), ); + if (parsed instanceof type.errors) + throw new OAuthDiscoveryError("Malformed authorization-server metadata"); + if (parsed.issuer !== issuer) + throw new OAuthDiscoveryError( + "authorization-server metadata issuer mismatch", + ); + assertOAuthUrl(parsed.issuer, true); + assertOAuthUrl(parsed.authorization_endpoint); + assertOAuthUrl(parsed.token_endpoint); + if (parsed.registration_endpoint !== undefined) + assertOAuthUrl(parsed.registration_endpoint); + metadata = parsed; + break; + } + if (metadata === undefined) + throw new OAuthDiscoveryError("Authorization-server metadata not found"); assertSupportsS256(asMetadataUrl, metadata.code_challenge_methods_supported); assertSupportsPublicClients( @@ -371,6 +393,7 @@ export async function discoverMcpLoginEntry( const entry: McpLoginEntry = { resourceUrl, authorizationServer }; if (resourceScopesSupported !== undefined) entry.resourceScopesSupported = resourceScopesSupported; + if (challenge?.scope !== undefined) entry.challengeScope = challenge.scope; return entry; } @@ -419,12 +442,17 @@ export async function registerMcpClient( ): Promise { const fetchImpl = opts.fetchImpl ?? fetch; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + assertOAuthUrl(opts.registrationEndpoint); + for (const uri of opts.redirectUris) assertRedirectUri(uri); + if (opts.redirectUris.length === 0) + throw new OAuthDiscoveryError("at least one redirect URI is required"); const requestedGrantTypes = grantTypesToRequest(opts.grantTypesSupported); let res: Response; try { res = await fetchImpl(opts.registrationEndpoint, { method: "POST", + redirect: "error", headers: { "content-type": "application/json", accept: "application/json", @@ -440,22 +468,10 @@ export async function registerMcpClient( }), signal: AbortSignal.timeout(timeoutMs), }); - } catch (error) { - throw new OAuthDiscoveryError( - `registration request to ${opts.registrationEndpoint} failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - if (!res.ok) { - let detail = ""; - try { - detail = await res.text(); - } catch { - // Non-2xx is the error; the body is optional detail. - } - throw new OAuthDiscoveryError( - `registration endpoint ${opts.registrationEndpoint} returned status ${String(res.status)}${detail ? `: ${detail}` : ""}`, - ); + } catch { + throw new OAuthDiscoveryError("client registration request failed"); } + if (!res.ok) throw badStatus(opts.registrationEndpoint, res.status); const payload = ClientRegistrationResponse( await readJson(res, opts.registrationEndpoint), ); @@ -475,6 +491,25 @@ export async function registerMcpClient( } function assertClientMetadataUrl(metadataUrl: string): void { + // WHATWG URL parsing repairs missing/extra authority slashes and normalizes + // characters. A CIMD identifier must instead have an explicit HTTPS authority + // so raw-path validation cannot silently miss a parser-repaired path. + const rawUrl = /^https:\/\/([^/?#]+)(\/[^?#]*)?(?:\?[^#]*)?$/i.exec( + metadataUrl, + ); + if ( + rawUrl === null || + /[\s\\]/.test(metadataUrl) || + Array.from(metadataUrl).some((character) => { + const code = character.charCodeAt(0); + return code <= 0x20 || code === 0x7f; + }) + ) { + throw new OAuthDiscoveryError( + "client metadata URL must use explicit absolute HTTPS authority syntax without whitespace, controls or backslashes", + ); + } + assertOAuthUrl(metadataUrl); let url: URL; try { url = new URL(metadataUrl); @@ -496,12 +531,10 @@ function assertClientMetadataUrl(metadataUrl: string): void { `client metadata URL must not contain a fragment or userinfo: ${metadataUrl}`, ); // The URL parser normalizes away "." and ".." path segments, so inspect the - // raw string to refuse metadata URLs that use dot segments. - if ( - metadataUrl - .split("/") - .some((segment) => segment === "." || segment === "..") - ) + // raw path (excluding query/fragment) to refuse dot segments before URL + // normalization can hide them, including a terminal segment before "?". + const rawPath = rawUrl[2] ?? ""; + if (rawPath.split("/").some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) throw new OAuthDiscoveryError( `client metadata URL must not contain dot segments: ${metadataUrl}`, ); @@ -518,6 +551,7 @@ export function mcpClientIdMetadataDocument(opts: { grantTypes?: readonly string[]; }): McpClientIdMetadataDocument { assertClientMetadataUrl(opts.metadataUrl); + for (const uri of opts.redirectUris) assertRedirectUri(uri); return { client_id: opts.metadataUrl, client_name: opts.clientName, @@ -571,18 +605,17 @@ export function preferredMcpClientRegistration(opts: { /** * CIMD client ids are portable across issuers. Dynamic and pre-registered * credentials are keyed by issuer and must be registered again when it changes. - * Trailing slashes are ignored here; RFC 9207 `iss` comparison is exact and - * lives in `assertAuthorizationIssuer`. + * Issuers are compared exactly, including trailing slashes. */ export function resolveStoredMcpClient( issuer: string, stored: StoredMcpClient, ): { clientId: string } { - if (stored.kind === "cimd") return { clientId: stored.clientId }; - if ( - stored.issuer === undefined || - normalizedIssuer(stored.issuer) !== normalizedIssuer(issuer) - ) { + if (stored.kind === "cimd") { + assertClientMetadataUrl(stored.clientId); + return { clientId: stored.clientId }; + } + if (stored.issuer === undefined || stored.issuer !== issuer) { throw new OAuthDiscoveryError( `stored ${stored.kind} client credentials are for a different authorization server; re-register`, ); @@ -617,6 +650,10 @@ function callbackField( key: string, ): string | undefined { if (params instanceof URLSearchParams) { + if (params.getAll(key).length > 1) + throw new OAuthDiscoveryError( + "duplicate authorization response parameter", + ); if (!params.has(key)) return undefined; return params.get(key) ?? ""; } @@ -626,6 +663,23 @@ function callbackField( return value; } +/** Validate callback provenance without acting on code or terminal error fields. + * Callers may consume a one-use login only after this and their state check pass. */ +export function assertAuthorizationCallbackIssuer( + params: URLSearchParams | Record, + opts: Omit, +): void { + const iss = callbackField(params, "iss"); + const issuerOpts: AuthorizationIssuerOptions = { + recordedIssuer: opts.recordedIssuer, + }; + if (iss !== undefined) issuerOpts.iss = iss; + if (opts.authorizationResponseIssParameterSupported !== undefined) + issuerOpts.authorizationResponseIssParameterSupported = + opts.authorizationResponseIssParameterSupported; + assertAuthorizationIssuer(issuerOpts); +} + /** * Read an authorization callback. `iss` is checked before `error` or `code` * is acted on, and this function does not redeem the code. @@ -637,21 +691,10 @@ export function readAuthorizationCallback( authorizationResponseIssParameterSupported?: boolean; }, ): { code: string } { - const iss = callbackField(params, "iss"); - const issuerOpts: AuthorizationIssuerOptions = { - recordedIssuer: opts.recordedIssuer, - }; - if (iss !== undefined) issuerOpts.iss = iss; - if (opts.authorizationResponseIssParameterSupported !== undefined) - issuerOpts.authorizationResponseIssParameterSupported = - opts.authorizationResponseIssParameterSupported; - assertAuthorizationIssuer(issuerOpts); + assertAuthorizationCallbackIssuer(params, opts); const error = callbackField(params, "error"); if (error !== undefined) { - const description = callbackField(params, "error_description"); - throw new OAuthDiscoveryError( - `authorization server rejected the request: ${error}${description !== undefined ? `: ${description}` : ""}`, - ); + throw new OAuthDiscoveryError("authorization server rejected the request"); } const code = callbackField(params, "code"); if (code === undefined || code.length === 0) @@ -675,8 +718,11 @@ export function selectMcpScopes(opts: SelectMcpScopesOptions): string[] { } function selectPriorityScopes(opts: SelectMcpScopesOptions): string[] { - if (opts.challengeScope !== undefined) - return opts.challengeScope.split(" ").filter((scope) => scope.length > 0); + const challengeScope = opts.challengeScope ?? opts.entry.challengeScope; + if (challengeScope !== undefined) + return [ + ...new Set(challengeScope.split(" ").filter((scope) => scope.length > 0)), + ]; const resourceScopes = opts.entry.resourceScopesSupported; if (resourceScopes !== undefined) return [...resourceScopes]; return []; @@ -706,6 +752,28 @@ export function mcpClientConfig( entry: McpLoginEntry, opts: McpClientConfigOptions, ): OAuthClientConfig { + assertOAuthUrl(entry.resourceUrl); + assertOAuthUrl(entry.authorizationServer.issuer, true); + assertOAuthUrl(entry.authorizationServer.authorizationEndpoint); + assertOAuthUrl(entry.authorizationServer.tokenEndpoint); + assertRedirectUri(opts.redirectUri); + const reserved = new Set([ + "response_type", + "client_id", + "redirect_uri", + "state", + "code_challenge", + "code_challenge_method", + "scope", + ]); + if ( + Object.keys(opts.extraAuthorizeParams ?? {}).some((key) => + reserved.has(key), + ) + ) + throw new OAuthDiscoveryError( + "extra authorization parameters cannot override security parameters", + ); const scopeOptions: SelectMcpScopesOptions = { entry, wantRefresh: opts.wantRefresh ?? true, @@ -719,6 +787,7 @@ export function mcpClientConfig( extraAuthorizeParams.resource = entry.resourceUrl; return { clientId: opts.clientId, + resource: entry.resourceUrl, authorizeUrl: entry.authorizationServer.authorizationEndpoint, tokenUrl: entry.authorizationServer.tokenEndpoint, redirectUri: opts.redirectUri, diff --git a/src/url.ts b/src/url.ts index 7ec5ec0..8a79471 100644 --- a/src/url.ts +++ b/src/url.ts @@ -12,14 +12,21 @@ export function parseMcpEndpoint(raw: string): URL { let url: URL; try { url = new URL(raw); - } catch (cause) { + } catch { + throw new Error("MCP endpoint is not an absolute URL"); + } + if (url.username || url.password || raw.includes("#") || /[\\\s]/.test(raw)) { throw new Error( - `"${raw}" is not an absolute URL (${cause instanceof Error ? cause.message : String(cause)})`, + "MCP endpoint must not contain userinfo, fragments, whitespace or backslashes", ); } + if (url.searchParams.has("access_token")) + throw new Error( + "MCP bearer tokens must not appear in URL query parameters", + ); if (url.protocol === "https:") return url; if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return url; throw new Error( - `MCP endpoint "${raw}" must be https (http is allowed only on loopback)`, + "MCP endpoint must be https (http is allowed only on loopback)", ); } From c7c493261e0141a645bcaa6bd181f18304f871f5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:38 -0700 Subject: [PATCH 14/28] feat(server): serve modern requests and opt-in legacy sessions Add fetch-compatible providers, authorization and extension dispatch. Bound identity-scoped legacy sessions across three 2025 revisions, including cancellation, shutdown and initialization regression coverage. --- src/server.ts | 471 +++++++++ src/server/contracts.ts | 72 ++ src/server/errors.ts | 20 + src/server/extensions.test.ts | 185 ++++ src/server/extensions.ts | 84 ++ src/server/fixtures.ts | 40 + .../legacy-lifecycle-regressions.test.ts | 197 ++++ src/server/legacy.test.ts | 909 ++++++++++++++++++ src/server/legacy.ts | 571 +++++++++++ src/server/providers.test.ts | 137 +++ src/server/review-regressions.test.ts | 332 +++++++ src/server/server.test.ts | 285 ++++++ src/server/tools.ts | 101 ++ 13 files changed, 3404 insertions(+) create mode 100644 src/server.ts create mode 100644 src/server/contracts.ts create mode 100644 src/server/errors.ts create mode 100644 src/server/extensions.test.ts create mode 100644 src/server/extensions.ts create mode 100644 src/server/fixtures.ts create mode 100644 src/server/legacy-lifecycle-regressions.test.ts create mode 100644 src/server/legacy.test.ts create mode 100644 src/server/legacy.ts create mode 100644 src/server/providers.test.ts create mode 100644 src/server/review-regressions.test.ts create mode 100644 src/server/server.test.ts create mode 100644 src/server/tools.ts diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..3068dd6 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,471 @@ +import { errorPayload } from "./server/errors.js"; +import { createLegacyServer } from "./server/legacy.js"; +import { createResourceBudget } from "./protocol/resource-budget.js"; +import { validateMcpIdentity } from "./auth/contracts.js"; +import { + MCP_PROTOCOL_VERSION, + MCP_EXT_TASKS, + MCP_EXT_SKILLS, + methodDescriptor, + validateExtensionSettings, + negotiateExtensions, + McpError, + validateRequest, + validateJsonRpcRequest, + validateRequestHeaders, + validateResult, + validateNotification, + record as protocolRecord, +} from "./protocol/index.js"; +import type { + McpValidationContext, + McpExtensionSettings, + McpSubscriptionFilter, +} from "./protocol/types.js"; +import type { + McpServerOptions, + McpServer, + McpRequestContext, +} from "./server/contracts.js"; +export type * from "./server/contracts.js"; +export { + createTaskProviders, + createSkillProviders, +} from "./server/extensions.js"; +export { createToolProviders } from "./server/tools.js"; +export type { McpToolBinding } from "./server/tools.js"; + +const record = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + +/** Stateless modern endpoint with an independently gated legacy session transport. */ +export function createMcpServer(options: McpServerOptions): McpServer { + const providers = Object.freeze({ ...options.providers }); + const extensions = validateExtensionSettings(options.extensions ?? {}); + for (const [extension, methods] of [ + [MCP_EXT_TASKS, ["tasks/get", "tasks/update", "tasks/cancel"]], + [MCP_EXT_SKILLS, ["skills/list", "skills/get", "resources/read"]], + ] as const) { + if (extensions[extension] && methods.some((method) => !providers[method])) + throw new Error(`Missing providers for ${extension}`); + } + if ( + extensions[MCP_EXT_SKILLS]?.directoryRead === true && + !providers["resources/directory/read"] + ) + throw new Error("Missing directory read provider"); + const active = new Set(); + let closed = false; + const capabilities: Record = {}; + for (const method of Object.keys(providers)) { + if (method === "server/discover") + throw new Error("Discovery is server-owned"); + const descriptor = methodDescriptor(method); + if (!descriptor) throw new Error(`Unknown provider method: ${method}`); + if (descriptor.extension && !options.extensions?.[descriptor.extension]) + throw new Error(`Extension not enabled: ${descriptor.extension}`); + if (descriptor.capability) capabilities[descriptor.capability] = {}; + const family = method.split("/")[0]; + if (family === "tools" || family === "prompts" || family === "resources") + capabilities[family] = {}; + if (family === "completion") capabilities.completions = {}; + } + if (Object.keys(extensions).length) capabilities.extensions = extensions; + const legacy = options.legacy + ? createLegacyServer(options, providers) + : undefined; + + return { + async fetch(request) { + const origin = request.headers.get("origin"); + if (origin && !options.allowedOrigins?.includes(origin)) + return new Response(null, { status: 403 }); + if (closed) return new Response(null, { status: 503 }); + if (request.method !== "POST") { + if ( + legacy && + request.headers.get("MCP-Protocol-Version") !== + MCP_PROTOCOL_VERSION && + (request.method === "DELETE" || request.headers.has("Mcp-Session-Id")) + ) + return legacy.lifecycle(request); + return new Response(null, { status: 405, headers: { Allow: "POST" } }); + } + if ( + request.headers.get("content-type")?.split(";")[0]?.trim() !== + "application/json" + ) + return new Response(null, { status: 415 }); + const accept = request.headers.get("accept") ?? ""; + if ( + !accept.includes("application/json") || + !accept.includes("text/event-stream") + ) + return new Response(null, { status: 406 }); + let id: string | number | undefined; + try { + const authenticated = + options.auth === "public" + ? null + : await options.auth.authenticate(request); + if (closed) return new Response(null, { status: 503 }); + if (options.auth !== "public" && !authenticated) + return ( + options.challenge?.(401) ?? new Response(null, { status: 401 }) + ); + const identity = + authenticated === null ? null : validateMcpIdentity(authenticated); + const limit = options.maxRequestBytes ?? 1024 * 1024; + const reader = request.body?.getReader(); + if (!reader) throw new McpError("Missing request body", 400, -32600); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > limit) { + await reader.cancel(); + throw new McpError("Request too large", 413, -32600); + } + chunks.push(chunk.value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new McpError("Invalid JSON", 400, -32700); + } + const readableId = record(parsed).id; + if ( + typeof readableId === "string" || + (typeof readableId === "number" && Number.isSafeInteger(readableId)) + ) + id = readableId; + const compatibility = await legacy?.handle(request, parsed, identity); + if (closed) return new Response(null, { status: 503 }); + if (compatibility) return compatibility; + const envelope = validateJsonRpcRequest(parsed); + id = envelope.id; + const params = record(envelope.params); + const meta = record(params._meta); + const validation: McpValidationContext = { + era: "modern", + protocolVersion: MCP_PROTOCOL_VERSION, + clientCapabilities: record( + meta["io.modelcontextprotocol/clientCapabilities"], + ), + serverCapabilities: capabilities, + extensions: negotiateExtensions( + extensions, + record( + record(meta["io.modelcontextprotocol/clientCapabilities"]) + .extensions, + ) as McpExtensionSettings, + ), + ...providers[envelope.method]?.schemas?.(params), + // Explicit Skills publication supports bounded resource payloads only. + maxDecodedBytes: + envelope.method === "resources/read" && extensions[MCP_EXT_SKILLS] + ? 16 * 1024 * 1024 + : undefined, + }; + validateRequestHeaders( + request.headers, + envelope.method, + params, + validation, + ); + validateRequest(envelope.method, params, validation); + if (envelope.method === "server/discover") { + const result = validateResult( + envelope.method, + { + resultType: "complete", + supportedVersions: [ + MCP_PROTOCOL_VERSION, + ...(legacy?.versions ?? []), + ], + capabilities, + _meta: { + "io.modelcontextprotocol/serverInfo": options.serverInfo, + }, + }, + validation, + ); + return Response.json( + { jsonrpc: "2.0", id, result }, + { headers: { "Cache-Control": "no-store" } }, + ); + } + const provider = providers[envelope.method]; + if (!provider) throw new McpError("Method not found", 404, -32601); + if ( + options.auth !== "public" && + identity && + !(await options.auth.authorize(identity, { + resource: provider.resource, + action: provider.action, + })) + ) + return ( + options.challenge?.(403) ?? new Response(null, { status: 403 }) + ); + const controller = new AbortController(); + active.add(controller); + const abort = () => controller.abort(request.signal.reason); + request.signal.addEventListener("abort", abort, { once: true }); + if (request.signal.aborted) abort(); + const requestId = id; + const subscription = envelope.method === "subscriptions/listen"; + let acceptedFilter: McpSubscriptionFilter | undefined; + const correlate = ( + payload: Record, + ): Record => + subscription + ? { + ...payload, + _meta: { + ...(payload._meta === undefined + ? {} + : protocolRecord(payload._meta)), + "io.modelcontextprotocol/subscriptionId": requestId, + }, + } + : payload; + let ended = false; + let lastProgress = -Infinity; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(sink) { + const send = (message: unknown) => { + if (!ended) { + const wire = encoder.encode( + `data: ${JSON.stringify(message)}\n\n`, + ); + if ( + validation.maxDecodedBytes !== undefined && + record(message).result !== undefined + ) { + const budget = createResourceBudget( + validation.maxDecodedBytes, + true, + ); + budget.push(wire); + budget.finish(); + } + sink.enqueue(wire); + } + }; + const finish = () => { + if (!ended) { + ended = true; + sink.close(); + } + }; + controller.signal.addEventListener("abort", finish, { once: true }); + if (controller.signal.aborted) finish(); + const context: McpRequestContext = { + identity, + requestId, + protocolVersion: MCP_PROTOCOL_VERSION, + clientCapabilities: validation.clientCapabilities ?? {}, + signal: controller.signal, + reportProgress(progress, total, message) { + const token = meta.progressToken; + if ( + subscription || + token === undefined || + controller.signal.aborted + ) + return; + if (progress <= lastProgress) + throw new McpError("Progress must increase", 500, -32603); + const notification = { + progressToken: token, + progress, + ...(total === undefined ? {} : { total }), + ...(message === undefined ? {} : { message }), + }; + validateNotification( + "notifications/progress", + notification, + validation, + ); + lastProgress = progress; + send({ + jsonrpc: "2.0", + method: "notifications/progress", + params: notification, + }); + }, + notify(method, notification) { + if (envelope.method !== "subscriptions/listen") + throw new Error( + "Change notifications require subscriptions/listen", + ); + if (acceptedFilter === undefined) + throw new Error("Subscription is not yet acknowledged"); + const outgoing = correlate(notification); + validateNotification(method, outgoing, validation); + const filter = acceptedFilter; + const listFlag = ( + { + "notifications/tools/list_changed": "toolsListChanged", + "notifications/prompts/list_changed": "promptsListChanged", + "notifications/resources/list_changed": + "resourcesListChanged", + } as const + )[ + method as + | "notifications/tools/list_changed" + | "notifications/prompts/list_changed" + | "notifications/resources/list_changed" + ]; + const permitted = listFlag + ? filter[listFlag] === true + : method === "notifications/resources/updated" + ? Array.isArray(filter.resourceSubscriptions) && + filter.resourceSubscriptions.includes( + String(notification.uri), + ) + : method === "notifications/tasks" + ? Array.isArray(filter.taskIds) && + filter.taskIds.includes(String(notification.taskId)) + : false; + if (!permitted) return; + send({ jsonrpc: "2.0", method, params: outgoing }); + }, + }; + void Promise.resolve() + .then(async () => { + controller.signal.throwIfAborted(); + if (subscription) { + const requested = structuredClone( + record(params.notifications), + ); + const accepted = structuredClone( + (await provider.acceptSubscription?.(params, context)) ?? + {}, + ); + controller.signal.throwIfAborted(); + const acknowledged = correlate({ notifications: accepted }); + validateNotification( + "notifications/subscriptions/acknowledged", + acknowledged, + validation, + ); + for (const [key, value] of Object.entries(accepted)) { + const wanted = requested[key]; + if ( + Array.isArray(value) + ? !Array.isArray(wanted) || + value.some((item) => !wanted.includes(item)) + : value === true && wanted !== true + ) { + throw new McpError( + "Accepted subscription exceeds requested filter", + 500, + -32603, + ); + } + } + acceptedFilter = accepted; + send({ + jsonrpc: "2.0", + method: "notifications/subscriptions/acknowledged", + params: acknowledged, + }); + } + return provider.handle(params, context); + }) + .then((result) => { + const outgoing = { + jsonrpc: "2.0", + id: requestId, + result: validateResult( + envelope.method, + correlate({ + ...protocolRecord(result), + resultType: + result.resultType === undefined + ? "complete" + : result.resultType, + }), + validation, + ), + }; + send(outgoing); + }) + .catch((error: unknown) => { + send({ + jsonrpc: "2.0", + id: requestId, + error: errorPayload(error, "Provider execution failed"), + }); + }) + .finally(() => { + active.delete(controller); + request.signal.removeEventListener("abort", abort); + controller.signal.removeEventListener("abort", finish); + finish(); + }); + }, + cancel(reason) { + ended = true; + controller.abort(reason); + active.delete(controller); + request.signal.removeEventListener("abort", abort); + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + }); + } catch (error) { + const known = error instanceof McpError; + return Response.json( + { + jsonrpc: "2.0", + ...(id === undefined ? {} : { id }), + error: errorPayload(error, "Request failed"), + }, + { + status: known + ? (error.status ?? (error.code === -32601 ? 404 : 400)) + : 500, + headers: { "Cache-Control": "no-store" }, + }, + ); + } + }, + async close() { + closed = true; + legacy?.close(); + for (const controller of active) controller.abort(); + active.clear(); + }, + }; +} + +export { createSkillPublisher } from "./extensions/skills/index.js"; +export type * from "./extensions/skills/contracts.js"; +export { + createTaskRuntime, + validateTaskRecord, +} from "./extensions/tasks/index.js"; +export type * from "./extensions/tasks/contracts.js"; +export { createMcpResourceServerAuth } from "./auth/resource-server.js"; +export type { McpResourceServerOptions } from "./auth/resource-server.js"; +export type { McpAuthPolicy, McpIdentity } from "./auth/contracts.js"; diff --git a/src/server/contracts.ts b/src/server/contracts.ts new file mode 100644 index 0000000..b698db1 --- /dev/null +++ b/src/server/contracts.ts @@ -0,0 +1,72 @@ +import type { McpAuthPolicy, McpIdentity } from "../auth/contracts.js"; +import type { + McpExtensionSettings, + McpValidationContext, + McpMethodMap, + McpSubscriptionFilter, +} from "../protocol/types.js"; + +/** Provider values are validated by the shared protocol boundary before transmission. */ +export type McpOperationOutcome = McpMethodMap["tools/call"]["result"]; + +export type McpRequestContext = { + identity: McpIdentity | null; + requestId: string | number; + protocolVersion: string; + clientCapabilities: Record; + signal: AbortSignal; + notify: (method: string, params: Record) => void; + reportProgress: (progress: number, total?: number, message?: string) => void; +}; + +export type McpProvider = { + /** Authorization applies to both catalog and execution methods. */ + resource: string; + action: string; + schemas?: ( + params: Record, + ) => Pick; + /** subscriptions/listen only: accept a supported subset before acknowledgement. + * Without this hook no notifications are accepted. Must not emit notifications. */ + acceptSubscription?: ( + params: Record, + context: McpRequestContext, + ) => McpSubscriptionFilter | Promise; + handle: ( + params: Record, + context: McpRequestContext, + ) => McpOperationOutcome | Promise; +}; + +/** Only known protocol methods may be registered. Discovery is server-owned. */ +export type McpProviders = Readonly>; + +export type McpLegacyRevision = "2025-11-25" | "2025-06-18" | "2025-03-26"; +export type McpLegacyOptions = { + /** Explicitly supported revisions; defaults to all three 2025 Streamable HTTP revisions. */ + versions?: readonly McpLegacyRevision[]; + /** Absolute session lifetime, not renewed by traffic. Default: 30 minutes. */ + sessionTtlMs?: number; + /** Per-server bound. Defaults to 1024; host must provide affinity across replicas. */ + maxSessions?: number; +}; + +export type McpServerOptions = { + serverInfo: { name: string; version: string; title?: string }; + providers?: McpProviders; + /** Required for private servers; public access must be explicitly selected. */ + auth: McpAuthPolicy | "public"; + /** Requests with Origin are refused unless explicitly allowed here. */ + allowedOrigins?: readonly string[]; + /** Pass the resource-server auth factory challenge to preserve OAuth discovery headers. */ + challenge?: (status: 401 | 403, scopes?: readonly string[]) => Response; + extensions?: McpExtensionSettings; + maxRequestBytes?: number; + /** Opt-in, identity-bound legacy sessions. Modern requests remain stateless. */ + legacy?: McpLegacyOptions; +}; + +export type McpServer = { + fetch: (request: Request) => Promise; + close: () => Promise; +}; diff --git a/src/server/errors.ts b/src/server/errors.ts new file mode 100644 index 0000000..0f9d438 --- /dev/null +++ b/src/server/errors.ts @@ -0,0 +1,20 @@ +import { McpError, assertJsonBounds } from "../protocol/index.js"; + +/** Both HTTP and SSE errors preserve bounded JSON data, never arbitrary thrown objects. */ +export function errorPayload(error: unknown, fallback: string) { + if (!(error instanceof McpError)) return { code: -32603, message: fallback }; + let data: unknown; + if (error.data !== undefined) { + try { + assertJsonBounds(error.data); + data = JSON.parse(JSON.stringify(error.data)); + } catch { + /* Invalid provider error data must not break the response stream. */ + } + } + return { + code: error.code ?? -32603, + message: error.message, + ...(data === undefined ? {} : { data }), + }; +} diff --git a/src/server/extensions.test.ts b/src/server/extensions.test.ts new file mode 100644 index 0000000..1b843aa --- /dev/null +++ b/src/server/extensions.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createMcpServer, + createTaskProviders, + createSkillProviders, +} from "../server.js"; +import { createTaskRuntime } from "../extensions/tasks/index.js"; +import { createFileTaskStore } from "../extensions/tasks/file-store.js"; +import { createSkillPublisher } from "../extensions/skills/index.js"; +import { + validateTask, + MCP_EXT_TASKS, + MCP_EXT_SKILLS, +} from "../protocol/index.js"; +import { rpc, result } from "./fixtures.js"; +import type { McpTaskRecord } from "../extensions/tasks/contracts.js"; +import type { McpAuthPolicy } from "../auth/contracts.js"; + +const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grants", + scopes: [], +}; +const auth: McpAuthPolicy = { + authenticate: () => identity, + authorize: () => true, +}; +const serverInfo = { name: "extensions", version: "1" }; +const meta = (extensions: Record) => ({ + "io.modelcontextprotocol/clientCapabilities": { extensions }, +}); + +test("task provider uses durable runtime and rejects cross-principal and non-opted-in reads", async () => { + const directory = await mkdtemp(join(tmpdir(), "mcp-server-tasks-")); + const store = await createFileTaskStore({ + directory, + validateRecord(value) { + const record = value as McpTaskRecord; + validateTask(record.task, true); + return record; + }, + }); + let cancels = 0; + const runtime = createTaskRuntime({ + store, + validateTask: (task) => { + validateTask(task, true); + }, + authorizeInput: async () => {}, + executor: { + start: async () => {}, + recover: async (record) => record.task, + submitInput: async () => {}, + requestCancel: async () => { + cancels++; + }, + }, + }); + const task = await runtime.create(identity, { name: "work" }); + const extensions = { [MCP_EXT_TASKS]: {} }; + const server = createMcpServer({ + serverInfo, + auth, + extensions, + providers: createTaskProviders(runtime), + }); + const other = createMcpServer({ + serverInfo, + auth: { + ...auth, + authenticate: () => ({ ...identity, principalId: "other" }), + }, + extensions, + providers: createTaskProviders(runtime), + }); + try { + expect( + await result( + await server.fetch( + rpc("tasks/get", { taskId: task.taskId, _meta: meta(extensions) }), + ), + ), + ).toMatchObject({ result: { taskId: task.taskId, status: "working" } }); + expect( + await result( + await server.fetch(rpc("tasks/get", { taskId: task.taskId })), + ), + ).toHaveProperty("error"); + expect( + await result( + await other.fetch( + rpc("tasks/get", { taskId: task.taskId, _meta: meta(extensions) }), + ), + ), + ).toHaveProperty("error"); + expect( + await result( + await server.fetch( + rpc("tasks/cancel", { taskId: task.taskId, _meta: meta(extensions) }), + ), + ), + ).toMatchObject({ result: { resultType: "complete" } }); + expect(cancels).toBe(1); + expect((await store.get(task.taskId))?.cancelRequested).toBe(true); + } finally { + await server.close(); + await other.close(); + await store.close(); + await rm(directory, { recursive: true }); + } +}); + +test("skill snapshot publishes through enabled provider seam without activation", async () => { + const uri = "https://skills.example/greet/SKILL.md"; + const publisher = await createSkillPublisher([ + { + uri, + files: new Map([ + [ + uri, + new TextEncoder().encode( + "---\nname: greet\ndescription: Greet politely\n---\nSay hello.\n", + ), + ], + ]), + }, + ]); + const extensions = { [MCP_EXT_SKILLS]: { directoryRead: true } }; + const server = createMcpServer({ + serverInfo, + auth, + extensions, + providers: createSkillProviders(publisher, "skills:approved"), + }); + try { + expect( + await result( + await server.fetch(rpc("skills/list", { _meta: meta(extensions) })), + ), + ).toMatchObject({ result: { skills: [{ uri }] } }); + // Skills have no Mcp-Name source in the pinned wire contract. + const request = rpc("skills/get", { uri, _meta: meta(extensions) }); + request.headers.delete("Mcp-Name"); + expect(await result(await server.fetch(request))).toMatchObject({ + result: { skill: { uri } }, + }); + expect( + await result( + await server.fetch( + rpc("resources/read", { uri, _meta: meta(extensions) }), + ), + ), + ).toHaveProperty("result.contents"); + expect(await result(await server.fetch(rpc("skills/list")))).toHaveProperty( + "error", + ); + } finally { + await server.close(); + } +}); + +test("auth challenge factory response is preserved", async () => { + const server = createMcpServer({ + serverInfo, + auth: { ...auth, authenticate: () => null }, + challenge: (status) => + new Response(null, { + status, + headers: { + "WWW-Authenticate": + 'Bearer resource_metadata="https://auth.example/.well-known/oauth-protected-resource"', + }, + }), + }); + const response = await server.fetch(rpc("server/discover")); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain( + "resource_metadata", + ); + await server.close(); +}); diff --git a/src/server/extensions.ts b/src/server/extensions.ts new file mode 100644 index 0000000..09ef6f9 --- /dev/null +++ b/src/server/extensions.ts @@ -0,0 +1,84 @@ +import type { createTaskRuntime } from "../extensions/tasks/index.js"; +import type { createSkillPublisher } from "../extensions/skills/index.js"; +import type { InputResponses } from "../protocol/types.js"; +import { McpError } from "../protocol/index.js"; +import type { McpProviders, McpRequestContext } from "./contracts.js"; + +const identity = (context: McpRequestContext) => { + if (!context.identity) + throw new McpError( + "Authenticated extension identity required", + 401, + -32602, + ); + return context.identity; +}; + +/** Runtime owns durable state and input authorization; server owns wire validation. */ +export function createTaskProviders( + runtime: ReturnType, +): McpProviders { + return { + "tasks/get": { + resource: "mcp:tasks", + action: "read", + handle: (params, context) => + runtime.get(identity(context), String(params.taskId)), + }, + "tasks/update": { + resource: "mcp:tasks", + action: "update", + handle: (params, context) => + runtime.submitInput( + identity(context), + String(params.taskId), + params.inputResponses as InputResponses, + ), + }, + "tasks/cancel": { + resource: "mcp:tasks", + action: "cancel", + handle: (params, context) => + runtime.cancel(identity(context), String(params.taskId)), + }, + }; +} + +/** Host explicitly chooses the resource authorization domain for this snapshot. */ +export function createSkillProviders( + publisher: Awaited>, + resource: string, +): McpProviders { + const noCursor = (params: Record) => { + if (params.cursor !== undefined) + throw new McpError("Unknown cursor", 400, -32602); + }; + return { + "skills/list": { + resource, + action: "read", + handle: (params) => { + noCursor(params); + return publisher.list(); + }, + }, + "skills/get": { + resource, + action: "read", + handle: (params) => publisher.get(String(params.uri)), + }, + "resources/read": { + resource, + action: "read", + handle: (params) => publisher.read(String(params.uri)), + }, + "resources/directory/read": { + resource, + action: "read", + handle: (params) => { + noCursor(params); + return publisher.directoryRead(String(params.uri)); + }, + }, + }; +} diff --git a/src/server/fixtures.ts b/src/server/fixtures.ts new file mode 100644 index 0000000..4e26f5e --- /dev/null +++ b/src/server/fixtures.ts @@ -0,0 +1,40 @@ +export function rpc( + method: string, + params: Record = {}, + headers: Record = {}, +): Request { + const name = params.name ?? params.uri ?? params.taskId; + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": method, + ...(typeof name === "string" ? { "Mcp-Name": name } : {}), + ...headers, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "test", version: "1" }, + "io.modelcontextprotocol/clientCapabilities": {}, + ...(params._meta as object), + }, + }, + }), + }); +} +export async function result( + response: Response, +): Promise> { + if (response.headers.get("content-type")?.includes("application/json")) + return (await response.json()) as Record; + const events = (await response.text()).trim().split("\n\n"); + return JSON.parse(events.at(-1)!.slice(6)); +} diff --git a/src/server/legacy-lifecycle-regressions.test.ts b/src/server/legacy-lifecycle-regressions.test.ts new file mode 100644 index 0000000..7d9f307 --- /dev/null +++ b/src/server/legacy-lifecycle-regressions.test.ts @@ -0,0 +1,197 @@ +import { expect, spyOn, test } from "bun:test"; +import { createMcpServer } from "../server.js"; +import type { McpAuthPolicy } from "../auth/contracts.js"; +import type { McpLegacyRevision } from "./contracts.js"; + +const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grants", + scopes: [], +}; +const serverInfo = { name: "legacy-lifecycle-regressions", version: "1" }; +const revisions: McpLegacyRevision[] = [ + "2025-11-25", + "2025-06-18", + "2025-03-26", +]; +function initialize(bodyVersion: string, headerVersion?: string) { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...(headerVersion === undefined + ? {} + : { "MCP-Protocol-Version": headerVersion }), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: bodyVersion, + capabilities: {}, + clientInfo: { name: "regression", version: "1" }, + }, + }), + }); +} + +for (const version of revisions) + test(`${version} rejects invalid initialization headers before authorization or allocation`, async () => { + let authorizations = 0; + const auth: McpAuthPolicy = { + authenticate: () => identity, + authorize: () => { + authorizations++; + return true; + }, + }; + const endpoint = createMcpServer({ + serverInfo, + auth, + legacy: { versions: [version], maxSessions: 1 }, + }); + const timer = spyOn(globalThis, "setTimeout"); + try { + for (const header of [ + "", + "malformed", + "2099-01-01", + "2024-11-05", + ...revisions.filter((candidate) => candidate !== version), + ]) { + const response = await endpoint.fetch(initialize(version, header)); + expect(response.status).toBe(400); + expect(response.headers.has("Mcp-Session-Id")).toBe(false); + } + expect(authorizations).toBe(0); + expect(timer).not.toHaveBeenCalled(); + // Unsupported body revisions still negotiate when the transport header is absent. + const negotiated = await endpoint.fetch(initialize("2099-01-01")); + expect(negotiated.status).toBe(200); + expect(await negotiated.json()).toMatchObject({ + result: { protocolVersion: version }, + }); + expect(authorizations).toBe(1); + const session = negotiated.headers.get("Mcp-Session-Id")!; + expect( + ( + await endpoint.fetch( + new Request("http://localhost/mcp", { + method: "DELETE", + headers: { + "Mcp-Session-Id": session, + "MCP-Protocol-Version": version, + }, + }), + ) + ).status, + ).toBe(204); + expect((await endpoint.fetch(initialize(version, version))).status).toBe( + 200, + ); + } finally { + timer.mockRestore(); + await endpoint.close(); + } + }); + +for (const phase of ["authenticate", "authorize"] as const) + test(`close during initialize ${phase} cannot resurrect sessions or timers`, async () => { + let entered!: () => void; + let resume!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const pending = new Promise((resolve) => { + resume = resolve; + }); + let authorizations = 0; + const auth: McpAuthPolicy = { + async authenticate() { + if (phase === "authenticate") { + entered(); + await pending; + } + return identity; + }, + async authorize() { + authorizations++; + if (phase === "authorize") { + entered(); + await pending; + } + return true; + }, + }; + const endpoint = createMcpServer({ + serverInfo, + auth, + legacy: { maxSessions: 1 }, + }); + const timer = spyOn(globalThis, "setTimeout"); + try { + const response = endpoint.fetch(initialize("2025-11-25")); + await started; + await endpoint.close(); + await endpoint.close(); + resume(); + const closed = await response; + expect(closed.status).toBe(503); + expect(closed.headers.has("Mcp-Session-Id")).toBe(false); + expect(timer).not.toHaveBeenCalled(); + expect(authorizations).toBe(phase === "authenticate" ? 0 : 1); + expect((await endpoint.fetch(initialize("2025-11-25"))).status).toBe(503); + await endpoint.close(); + expect(timer).not.toHaveBeenCalled(); + } finally { + resume(); + timer.mockRestore(); + await endpoint.close(); + } + }); + +test("close during DELETE authentication remains terminal after verifier resumes", async () => { + let entered!: () => void; + let resume!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const pending = new Promise((resolve) => { + resume = resolve; + }); + const auth: McpAuthPolicy = { + async authenticate(request) { + if (request.method === "DELETE") { + entered(); + await pending; + } + return identity; + }, + authorize: () => true, + }; + const endpoint = createMcpServer({ serverInfo, auth, legacy: {} }); + try { + const initialized = await endpoint.fetch(initialize("2025-11-25")); + const session = initialized.headers.get("Mcp-Session-Id")!; + const response = endpoint.fetch( + new Request("http://localhost/mcp", { + method: "DELETE", + headers: { + "Mcp-Session-Id": session, + "MCP-Protocol-Version": "2025-11-25", + }, + }), + ); + await started; + await endpoint.close(); + resume(); + expect((await response).status).toBe(503); + await endpoint.close(); + } finally { + resume(); + await endpoint.close(); + } +}); diff --git a/src/server/legacy.test.ts b/src/server/legacy.test.ts new file mode 100644 index 0000000..e3e229d --- /dev/null +++ b/src/server/legacy.test.ts @@ -0,0 +1,909 @@ +import { expect, test } from "bun:test"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createInterchangeProviders } from "../interchange/server.js"; +import { createMcpServer, createToolProviders } from "../server.js"; +import type { + McpLegacyRevision, + McpProviders, + McpServer, +} from "./contracts.js"; +import type { McpAuthPolicy } from "../auth/contracts.js"; +import { rpc, result } from "./fixtures.js"; + +const serverInfo = { name: "legacy-fixture", version: "1" }; +const revisions: McpLegacyRevision[] = [ + "2025-11-25", + "2025-06-18", + "2025-03-26", +]; +const tools = () => + createToolProviders( + [ + { + definition: { + name: "echo", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + additionalProperties: false, + }, + }, + resource: "tool:echo", + action: "call", + execute: (args, context) => { + context.reportProgress(1, 1); + return { content: [{ type: "text", text: args.text }] }; + }, + }, + ], + "public", + ); +function post( + body: unknown, + session?: string, + version?: string, + extra: Record = {}, +) { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...(session ? { "Mcp-Session-Id": session } : {}), + ...(version ? { "MCP-Protocol-Version": version } : {}), + ...extra, + }, + body: JSON.stringify(body), + }); +} +const message = ( + id: number, + method = "tools/list", + params: Record = {}, +) => ({ jsonrpc: "2.0", id, method, params }); +async function initialize( + server: McpServer, + version = "2025-11-25", + headers: Record = {}, +) { + const response = await server.fetch( + post( + message(0, "initialize", { + protocolVersion: version, + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }), + undefined, + undefined, + headers, + ), + ); + expect(response.status).toBe(200); + const session = response.headers.get("Mcp-Session-Id")!; + expect(session).toMatch(/^[\x21-\x7e]+$/); + return session; +} +const initialized = ( + session: string, + version = "2025-11-25", + headers: Record = {}, +) => + post( + { jsonrpc: "2.0", method: "notifications/initialized" }, + session, + version, + headers, + ); +async function events(response: Response): Promise[]> { + if (response.headers.get("content-type")?.includes("application/json")) + return [(await response.json()) as Record]; + return (await response.text()) + .trim() + .split("\n\n") + .map((event) => JSON.parse(event.slice(6)) as Record); +} + +for (const version of revisions) + test(`SDK client interoperates with gated ${version} over real HTTP`, async () => { + const providers: McpProviders = { + ...tools(), + "prompts/list": { + resource: "prompts", + action: "read", + handle: () => ({ prompts: [{ name: "hello" }] }), + }, + "prompts/get": { + resource: "prompts", + action: "read", + handle: () => ({ + messages: [ + { role: "user", content: { type: "text", text: "Hello" } }, + ], + }), + }, + "resources/list": { + resource: "resources", + action: "read", + handle: () => ({ resources: [{ name: "file", uri: "test:///file" }] }), + }, + "resources/templates/list": { + resource: "resources", + action: "read", + handle: () => ({ resourceTemplates: [] }), + }, + "resources/read": { + resource: "resources", + action: "read", + handle: () => ({ contents: [{ uri: "test:///file", text: "data" }] }), + }, + "completion/complete": { + resource: "completion", + action: "read", + handle: () => ({ completion: { values: ["hello"] } }), + }, + }; + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: { versions: [version] }, + providers, + }); + const listener = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: endpoint.fetch, + }); + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${listener.port}/mcp`), + ); + const client = new Client({ name: "official-sdk", version: "1" }); + try { + // SDK 1.30.1 exposes sessionId: string|undefined, incompatible with its own + // optional Transport property under exactOptionalPropertyTypes. Forward its + // real transport without casting away that discrepancy. + const bridge: Transport = { + async start() { + transport.onmessage = (message) => bridge.onmessage?.(message); + transport.onerror = (error) => bridge.onerror?.(error); + transport.onclose = () => bridge.onclose?.(); + await transport.start(); + }, + send: (message, options) => transport.send(message, options), + close: () => transport.close(), + setProtocolVersion: (version) => transport.setProtocolVersion(version), + }; + await client.connect(bridge); + expect(transport.protocolVersion).toBe(version); + expect(client.getServerCapabilities()).not.toHaveProperty("extensions"); + expect((await client.listTools()).tools[0]?.name).toBe("echo"); + const progress: number[] = []; + expect( + await client.callTool( + { name: "echo", arguments: { text: "SDK call" } }, + undefined, + { + onprogress: (value) => { + progress.push(value.progress); + }, + }, + ), + ).toMatchObject({ content: [{ text: "SDK call" }] }); + expect(progress).toEqual([1]); + expect((await client.listPrompts()).prompts[0]?.name).toBe("hello"); + expect(await client.getPrompt({ name: "hello" })).toHaveProperty( + "messages", + ); + expect((await client.listResources()).resources[0]?.uri).toBe( + "test:///file", + ); + expect(await client.readResource({ uri: "test:///file" })).toMatchObject({ + contents: [{ text: "data" }], + }); + expect(await client.listResourceTemplates()).toMatchObject({ + resourceTemplates: [], + }); + expect( + await client.complete({ + ref: { type: "ref/prompt", name: "hello" }, + argument: { name: "arg", value: "he" }, + }), + ).toMatchObject({ completion: { values: ["hello"] } }); + const session = transport.sessionId!; + await transport.terminateSession(); + expect( + (await endpoint.fetch(post(message(100), session, version))).status, + ).toBe(404); + } finally { + await client.close(); + await endpoint.close(); + await listener.stop(true); + } + }); + +test("legacy mode is explicit and modern requests remain stateless", async () => { + const modern = createMcpServer({ + serverInfo, + auth: "public", + providers: tools(), + }); + const dual = createMcpServer({ + serverInfo, + auth: "public", + providers: tools(), + legacy: { versions: ["2025-06-18"] }, + }); + try { + expect( + ( + await modern.fetch( + post( + message(0, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }), + ), + ) + ).status, + ).toBe(400); + const response = await dual.fetch(rpc("server/discover")); + expect(response.headers.has("Mcp-Session-Id")).toBe(false); + expect(await result(response)).toHaveProperty("result.supportedVersions", [ + "2026-07-28", + "2025-06-18", + ]); + expect( + await result( + await dual.fetch( + rpc( + "tools/call", + { name: "echo", arguments: { text: "modern" } }, + { "Mcp-Session-Id": "ignored-on-modern" }, + ), + ), + ), + ).toMatchObject({ result: { resultType: "complete" } }); + } finally { + await modern.close(); + await dual.close(); + } +}); + +test("legacy handshake, headers, missing/expired sessions, capacity and GET405", async () => { + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + providers: tools(), + legacy: { maxSessions: 1, sessionTtlMs: 100 }, + }); + try { + const session = await initialize(endpoint); + expect( + await result( + await endpoint.fetch(post(message(1), session, "2025-11-25")), + ), + ).toHaveProperty("error"); + expect((await endpoint.fetch(initialized(session))).status).toBe(202); + expect( + (await endpoint.fetch(post(message(2), undefined, "2025-11-25"))).status, + ).toBe(400); + expect( + (await endpoint.fetch(post(message(2), session, "2025-06-18"))).status, + ).toBe(400); + expect((await endpoint.fetch(post(message(2), session))).status).toBe(400); + expect( + ( + await endpoint.fetch( + new Request("http://localhost/mcp", { + headers: { + "Mcp-Session-Id": session, + "MCP-Protocol-Version": "2025-11-25", + Accept: "text/event-stream", + }, + }), + ) + ).status, + ).toBe(405); + expect( + ( + await endpoint.fetch( + post( + message(0, "initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }), + ), + ) + ).status, + ).toBe(503); + await Bun.sleep(120); + expect( + (await endpoint.fetch(post(message(3), session, "2025-11-25"))).status, + ).toBe(404); + await initialize(endpoint); + } finally { + await endpoint.close(); + } +}); + +test("sessions are bound to authenticated tenant/principal/context and every call rechecks authorization", async () => { + let calls = 0; + let granted = true; + const auth: McpAuthPolicy = { + authenticate: (request) => + request.headers.get("authorization") === "missing" + ? null + : { + tenantId: request.headers.get("tenant") ?? "tenant", + principalId: request.headers.get("principal") ?? "principal", + authorizationContextId: + request.headers.get("partition") ?? "grants", + scopes: [], + }, + authorize: () => granted, + }; + const endpoint = createMcpServer({ + serverInfo, + auth, + legacy: {}, + providers: { + "tools/list": { + resource: "tools", + action: "read", + handle: () => { + calls++; + return { tools: [] }; + }, + }, + }, + }); + try { + const session = await initialize(endpoint); + await endpoint.fetch(initialized(session)); + for (const headers of [ + { tenant: "other" }, + { principal: "other" }, + { partition: "other" }, + ]) + expect( + (await endpoint.fetch(post(message(1), session, "2025-11-25", headers))) + .status, + ).toBe(404); + expect( + ( + await endpoint.fetch( + post(message(1), session, "2025-11-25", { authorization: "missing" }), + ) + ).status, + ).toBe(401); + expect( + ( + await endpoint.fetch( + post(message(1), session, "2025-11-25", { + Origin: "https://evil.example", + }), + ) + ).status, + ).toBe(403); + granted = false; + expect( + await result( + await endpoint.fetch(post(message(1), session, "2025-11-25")), + ), + ).toHaveProperty("error"); + expect(calls).toBe(0); + } finally { + await endpoint.close(); + } +}); + +test("March batches mix requests and notifications, later revisions and initialize batches are rejected", async () => { + for (const version of revisions) { + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: { versions: [version] }, + providers: tools(), + }); + try { + const session = await initialize(endpoint, version); + await endpoint.fetch(initialized(session, version)); + const response = await endpoint.fetch( + post( + [ + message(1), + { + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: "unknown" }, + }, + message(2, "tools/call", { + name: "echo", + arguments: { text: "batch" }, + }), + ], + session, + version, + ), + ); + if (version === "2025-03-26") { + const replies = await events(response); + expect(replies.map((reply) => reply.id).sort()).toEqual([1, 2]); + expect(replies.every((reply) => reply.result !== undefined)).toBe(true); + expect( + ( + await endpoint.fetch( + post( + [ + { + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: "unknown" }, + }, + ], + session, + version, + ), + ) + ).status, + ).toBe(202); + } else expect(response.status).toBe(400); + expect( + ( + await endpoint.fetch( + post( + [ + message(8, "initialize", { + protocolVersion: version, + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }), + ], + session, + version, + ), + ) + ).status, + ).toBe(400); + } finally { + await endpoint.close(); + } + } +}); + +test("legacy schema boundaries reject invalid arguments, modern methods and polymorphic results", async () => { + let calls = 0; + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: {}, + providers: { + ...createToolProviders( + [ + { + definition: { + name: "strict", + inputSchema: { + type: "object", + required: ["value"], + properties: { value: { type: "number" } }, + }, + }, + resource: "tool:strict", + action: "call", + execute: (args) => { + calls++; + return args.value === 2 + ? { + resultType: "task", + taskId: "not-on-legacy", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:00Z", + ttlMs: 1000, + } + : { + resultType: "input_required", + requestState: "never-on-legacy", + }; + }, + }, + ], + "public", + ), + }, + }); + try { + const session = await initialize(endpoint); + await endpoint.fetch(initialized(session)); + for (const [id, method, params] of [ + [1, "tools/call", { name: "strict", arguments: { value: "wrong" } }], + [ + 2, + "tools/call", + { name: "strict", arguments: { value: 1 }, inputResponses: {} }, + ], + [3, "tasks/get", { taskId: "x" }], + [4, "subscriptions/listen", { notifications: {} }], + ] as const) + expect( + await result( + await endpoint.fetch( + post(message(id, method, params), session, "2025-11-25"), + ), + ), + ).toHaveProperty("error"); + expect(calls).toBe(0); + expect( + await result( + await endpoint.fetch( + post( + message(5, "tools/call", { + name: "strict", + arguments: { value: 1 }, + }), + session, + "2025-11-25", + ), + ), + ), + ).toHaveProperty("error"); + expect(calls).toBe(1); + expect( + await result( + await endpoint.fetch( + post( + message(6, "tools/call", { + name: "strict", + arguments: { value: 2 }, + }), + session, + "2025-11-25", + ), + ), + ), + ).toHaveProperty("error"); + } finally { + await endpoint.close(); + } +}); + +test("legacy disconnect is not cancellation; explicit cancellation is session-scoped", async () => { + const signals: AbortSignal[] = []; + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: {}, + providers: { + "tools/call": { + resource: "work", + action: "call", + async handle(_params, context) { + signals.push(context.signal); + context.reportProgress(1); + await new Promise((resolve) => + context.signal.addEventListener("abort", () => resolve(), { + once: true, + }), + ); + return { content: [] }; + }, + }, + }, + }); + try { + const a = await initialize(endpoint), + b = await initialize(endpoint); + await endpoint.fetch(initialized(a)); + await endpoint.fetch(initialized(b)); + const call = message(1, "tools/call", { + name: "work", + _meta: { progressToken: "progress" }, + }); + const ra = ( + await endpoint.fetch(post(call, a, "2025-11-25")) + ).body!.getReader(); + const rb = ( + await endpoint.fetch(post(call, b, "2025-11-25")) + ).body!.getReader(); + await ra.read(); + await rb.read(); + await ra.cancel(); + expect(signals[0]?.aborted).toBe(false); + expect( + ( + await endpoint.fetch( + post( + { + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 1 }, + }, + a, + "2025-11-25", + ), + ) + ).status, + ).toBe(202); + expect(signals[0]?.aborted).toBe(true); + expect(signals[1]?.aborted).toBe(false); + await endpoint.fetch( + new Request("http://localhost/mcp", { + method: "DELETE", + headers: { "Mcp-Session-Id": b, "MCP-Protocol-Version": "2025-11-25" }, + }), + ); + expect(signals[1]?.aborted).toBe(true); + await rb.cancel(); + } finally { + await endpoint.close(); + } +}); + +test("March omits protocol header legally; IDs cannot be reused and malformed batch members get individual errors", async () => { + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: { versions: ["2025-03-26"] }, + providers: tools(), + }); + try { + const session = await initialize(endpoint, "2025-03-26"); + expect( + ( + await endpoint.fetch( + post( + { jsonrpc: "2.0", method: "notifications/initialized" }, + session, + ), + ) + ).status, + ).toBe(202); + const response = await endpoint.fetch( + post([message(1), 42, message(2, "ping")], session), + ); + const replies = await events(response); + expect(replies).toHaveLength(3); + expect(replies.find((reply) => reply.id === null)).toHaveProperty( + "error.code", + -32600, + ); + expect(replies.find((reply) => reply.id === 1)).toHaveProperty( + "result.tools", + ); + expect(replies.find((reply) => reply.id === 2)).toHaveProperty("result"); + expect( + await result(await endpoint.fetch(post(message(1), session))), + ).toHaveProperty("error.code", -32600); + expect((await endpoint.fetch(post([], session))).status).toBe(400); + } finally { + await endpoint.close(); + } +}); + +test("cancellation during asynchronous authorization prevents provider dispatch", async () => { + let release!: () => void; + let checking!: () => void; + const started = new Promise((resolve) => { + checking = resolve; + }); + const wait = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const auth: McpAuthPolicy = { + authenticate: () => ({ + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grants", + scopes: [], + }), + authorize: async (_identity, operation) => { + if (operation.action === "call") { + checking(); + await wait; + } + return true; + }, + }; + const endpoint = createMcpServer({ + serverInfo, + auth, + legacy: {}, + providers: { + "tools/call": { + resource: "work", + action: "call", + handle: () => { + calls++; + return { content: [] }; + }, + }, + }, + }); + try { + const session = await initialize(endpoint); + await endpoint.fetch(initialized(session)); + const response = await endpoint.fetch( + post(message(1, "tools/call", { name: "work" }), session, "2025-11-25"), + ); + await started; + await endpoint.fetch( + post( + { + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 1 }, + }, + session, + "2025-11-25", + ), + ); + const settled = await Promise.race([ + result(response).then(() => "cancelled"), + Bun.sleep(50).then(() => "still-waiting"), + ]); + release(); + expect(settled).toBe("cancelled"); + expect(calls).toBe(0); + } finally { + release(); + await endpoint.close(); + } +}); + +test("legacy exports retain the Interchange approval gate", async () => { + let calls = 0; + const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grants", + scopes: [], + }; + const auth: McpAuthPolicy = { + authenticate: () => identity, + authorize: () => true, + }; + const definition = { + name: "selected", + description: "Approval required", + inputSchema: { type: "object" }, + }; + const endpoint = createMcpServer({ + serverInfo, + auth, + legacy: {}, + providers: createInterchangeProviders({ + auth, + exposures: [ + { + kind: "tool", + definition, + tenantId: "tenant", + resource: "tool:selected", + action: "call", + approve: async () => false, + toolName: "selected", + bundle: { + definitions: [definition], + run: async (call) => { + calls++; + return { callId: call.id, content: "must not execute" }; + }, + }, + }, + ], + }), + }); + try { + const session = await initialize(endpoint); + await endpoint.fetch(initialized(session)); + expect( + await result( + await endpoint.fetch( + post( + message(1, "tools/call", { name: "selected", arguments: {} }), + session, + "2025-11-25", + ), + ), + ), + ).toHaveProperty("error"); + expect(calls).toBe(0); + } finally { + await endpoint.close(); + } +}); + +test("legacy malformed and unsolicited response envelopes are HTTP errors", async () => { + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: {}, + providers: tools(), + }); + try { + const session = await initialize(endpoint); + await endpoint.fetch(initialized(session)); + for (const body of [ + 42, + { jsonrpc: "2.0", id: 1, result: {} }, + { jsonrpc: "2.0", id: null, method: "tools/list" }, + { jsonrpc: "2.0", method: "notifications/unsupported" }, + ]) { + expect( + (await endpoint.fetch(post(body, session, "2025-11-25"))).status, + ).toBe(400); + } + } finally { + await endpoint.close(); + } +}); + +test("March projects newer tool output into supported content without dropping structured data", async () => { + const endpoint = createMcpServer({ + serverInfo, + auth: "public", + legacy: { versions: ["2025-03-26"] }, + providers: createToolProviders( + [ + { + definition: { + name: "newer", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { answer: { type: "number" } }, + required: ["answer"], + }, + }, + resource: "tool:newer", + action: "call", + execute: () => ({ + content: [ + { + type: "resource_link", + name: "reference", + uri: "test:///reference", + }, + ], + structuredContent: { answer: 42 }, + }), + }, + ], + "public", + ), + }); + try { + const session = await initialize(endpoint, "2025-03-26"); + await endpoint.fetch(initialized(session, "2025-03-26")); + const catalog = await result( + await endpoint.fetch(post(message(1), session)), + ); + expect(catalog).not.toHaveProperty("result.tools.0.outputSchema"); + const answer = await result( + await endpoint.fetch( + post( + message(2, "tools/call", { name: "newer", arguments: {} }), + session, + ), + ), + ); + expect(answer).not.toHaveProperty("result.resultType"); + expect(answer).not.toHaveProperty("result.structuredContent"); + expect(answer).toMatchObject({ + result: { + content: [ + { type: "text", text: "reference: test:///reference" }, + { type: "text", text: '{"answer":42}' }, + ], + }, + }); + } finally { + await endpoint.close(); + } +}); diff --git a/src/server/legacy.ts b/src/server/legacy.ts new file mode 100644 index 0000000..f56d430 --- /dev/null +++ b/src/server/legacy.ts @@ -0,0 +1,571 @@ +import { validateMcpIdentity, type McpIdentity } from "../auth/contracts.js"; +import { + MCP_PROTOCOL_VERSION, + McpError, + validateLegacyInitialize, + validateJsonRpcRequest, + validateRequest, + validateRequestHeaders, + validateResult, + validateNotification, + record, +} from "../protocol/index.js"; +import type { + JsonRpcRequest, + JsonRpcNotification, + McpValidationContext, +} from "../protocol/index.js"; +import type { + McpLegacyRevision, + McpProviders, + McpRequestContext, + McpServerOptions, +} from "./contracts.js"; +import { errorPayload } from "./errors.js"; + +const REVISIONS: readonly McpLegacyRevision[] = [ + "2025-11-25", + "2025-06-18", + "2025-03-26", +]; +const METHODS = new Set([ + "tools/list", + "tools/call", + "prompts/list", + "prompts/get", + "resources/list", + "resources/templates/list", + "resources/read", + "completion/complete", +]); +type Session = { + identity: McpIdentity | null; + version: McpLegacyRevision; + capabilities: Record; + initialized: boolean; + closed: boolean; + used: Set; + active: Map; + timer: ReturnType; + expires: number; +}; +const json = ( + value: unknown, + status = 200, + headers: Record = {}, +) => + Response.json(value, { + status, + headers: { "Cache-Control": "no-store", ...headers }, + }); +const failed = (error: unknown, id?: string | number) => ({ + jsonrpc: "2.0", + id: id ?? null, + error: errorPayload(error, "Legacy request failed"), +}); +const partition = (identity: McpIdentity | null) => + identity === null + ? "public" + : JSON.stringify([ + identity.tenantId, + identity.principalId, + identity.authorizationContextId, + ]); + +/** Isolated legacy transport. No HTTP+SSE endpoint, GET stream, resumption, MRTR or extensions. */ +export function createLegacyServer( + options: McpServerOptions, + providers: McpProviders, +) { + const configured = options.legacy?.versions ?? REVISIONS; + if ( + !configured.length || + configured.some((version) => !REVISIONS.includes(version)) + ) + throw new Error("Unsupported legacy revision"); + const versions = REVISIONS.filter((version) => configured.includes(version)); + const ttl = options.legacy?.sessionTtlMs ?? 30 * 60_000; + const maxSessions = options.legacy?.maxSessions ?? 1024; + if ( + !Number.isSafeInteger(ttl) || + ttl <= 0 || + ttl > 2_147_483_647 || + !Number.isSafeInteger(maxSessions) || + maxSessions <= 0 + ) + throw new Error("Invalid legacy session limits"); + let closed = false; + function assertOpen() { + if (closed) throw new McpError("Legacy server is closed", 503, -32603); + } + const sessions = new Map(); + const capabilities: Record = {}; + for (const method of METHODS) + if (providers[method]) { + const family = method.split("/")[0]!; + capabilities[family === "completion" ? "completions" : family] = {}; + } + function remove(id: string, session: Session) { + session.closed = true; + clearTimeout(session.timer); + sessions.delete(id); + for (const controller of session.active.values()) controller.abort(); + session.active.clear(); + } + function lookup( + request: Request, + identity: McpIdentity | null, + ): [string, Session] { + const id = request.headers.get("Mcp-Session-Id"); + if (!id) throw new McpError("Session ID required", 400, -32600); + const session = sessions.get(id); + if (session && session.expires <= Date.now()) remove(id, session); + if ( + !session || + !sessions.has(id) || + partition(session.identity) !== partition(identity) + ) + throw new McpError("Session not found", 404, -32600); + const version = request.headers.get("MCP-Protocol-Version"); + if ( + (version !== null && version !== session.version) || + (version === null && session.version !== "2025-03-26") + ) + throw new McpError( + "Protocol version does not match session", + 400, + -32600, + ); + return [id, session]; + } + async function authorized( + identity: McpIdentity | null, + resource: string, + action: string, + ) { + return ( + options.auth === "public" || + (identity !== null && + (await options.auth.authorize(identity, { resource, action }))) + ); + } + function validation( + session: Session, + method: string, + params: Record, + ): McpValidationContext { + return { + era: "legacy", + protocolVersion: session.version, + extensions: {}, + clientCapabilities: session.capabilities, + serverCapabilities: capabilities, + ...providers[method]?.schemas?.(params), + }; + } + function notification(message: JsonRpcNotification, session: Session) { + const params = message.params ?? {}; + if ( + message.method !== "notifications/initialized" && + message.method !== "notifications/cancelled" + ) + throw new McpError("Unsupported notification", 400, -32601); + validateNotification(message.method, params, { + era: "legacy", + extensions: {}, + }); + if (message.method === "notifications/initialized") + session.initialized = true; + else session.active.get(params.requestId as string | number)?.abort(); + } + async function run( + message: JsonRpcRequest, + request: Request, + session: Session, + identity: McpIdentity | null, + send: (value: unknown) => void, + ) { + const id = message.id; + let controller: AbortController | undefined; + let abortListener: (() => void) | undefined; + try { + if (session.used.has(id)) + throw new McpError("Request ID already used in session", 400, -32600); + if (session.used.size >= 10_000) + throw new McpError( + "Session request limit reached; initialize a new session", + 429, + -32600, + ); + session.used.add(id); + if (message.method === "ping") return { jsonrpc: "2.0", id, result: {} }; + if (!session.initialized) + throw new McpError("Session not initialized", 400, -32600); + if (!METHODS.has(message.method) || !providers[message.method]) + throw new McpError("Method not available in legacy mode", 404, -32601); + const provider = providers[message.method]!; + const params = message.params ?? {}; + const context = validation(session, message.method, params); + validateRequest(message.method, params, context); + validateRequestHeaders(request.headers, message.method, params, context); + controller = new AbortController(); + session.active.set(id, controller); + if (session.expires <= Date.now() || session.closed) controller.abort(); + const signal = controller.signal; + signal.throwIfAborted(); + const cancelled = new Promise((_resolve, reject) => { + abortListener = () => + reject(new McpError("Request cancelled", undefined, -32603)); + signal.addEventListener("abort", abortListener, { once: true }); + }); + if ( + !(await Promise.race([ + authorized(identity, provider.resource, provider.action), + cancelled, + ])) + ) + throw new McpError("Operation forbidden", 403, -32602); + let lastProgress = -Infinity; + const providerContext: McpRequestContext = { + identity, + requestId: id, + protocolVersion: session.version, + clientCapabilities: session.capabilities, + signal, + notify() { + throw new McpError( + "Legacy change notifications are not supported", + 400, + -32601, + ); + }, + reportProgress(progress, total, message) { + const token = + params._meta === undefined + ? undefined + : record(params._meta).progressToken; + if (token === undefined || signal.aborted) return; + if (progress <= lastProgress) + throw new McpError("Progress must increase", 500, -32603); + const notification = { + progressToken: token, + progress, + ...(total === undefined ? {} : { total }), + ...(message === undefined ? {} : { message }), + }; + validateNotification("notifications/progress", notification, context); + lastProgress = progress; + send({ + jsonrpc: "2.0", + method: "notifications/progress", + params: notification, + }); + }, + }; + signal.throwIfAborted(); + const result = await Promise.race([ + Promise.resolve().then(() => { + signal.throwIfAborted(); + return provider.handle(params, providerContext); + }), + cancelled, + ]); + const validated = validateResult(message.method, result, context); + // Ordinary results only; the shared validator rejects task/MRTR before projection. + const { + resultType: _type, + ttlMs: _ttl, + cacheScope: _cache, + ...ordinary + } = validated; + if (session.version === "2025-03-26") { + // Structured tool output and resource links arrived in 2025-06-18. + // Preserve their information in text for March clients rather than emit + // a newer content discriminator or silently discard structured data. + const content = (value: unknown) => { + const block = record(value); + return block.type === "resource_link" + ? { + type: "text", + text: `${String(block.name)}: ${String(block.uri)}`, + } + : block; + }; + if (Array.isArray(ordinary.content)) { + const compatibleContent = ordinary.content.map(content); + if (ordinary.structuredContent !== undefined) + compatibleContent.push({ + type: "text", + text: JSON.stringify(ordinary.structuredContent), + }); + ordinary.content = compatibleContent; + } + if (Array.isArray(ordinary.messages)) + ordinary.messages = ordinary.messages.map((value) => { + const message = record(value); + return { ...message, content: content(message.content) }; + }); + delete ordinary.structuredContent; + if (Array.isArray(ordinary.tools)) + ordinary.tools = ordinary.tools.map((tool) => { + const { outputSchema: _output, ...definition } = record(tool); + return definition; + }); + } + return { jsonrpc: "2.0", id, result: ordinary }; + } catch (error) { + return failed(error, id); + } finally { + if (controller && abortListener) + controller.signal.removeEventListener("abort", abortListener); + if (controller) session.active.delete(id); + } + } + return { + versions, + async lifecycle(request: Request): Promise { + try { + assertOpen(); + const rawIdentity = + options.auth === "public" + ? null + : await options.auth.authenticate(request); + assertOpen(); + if (options.auth !== "public" && !rawIdentity) + return ( + options.challenge?.(401) ?? new Response(null, { status: 401 }) + ); + const identity = + rawIdentity === null ? null : validateMcpIdentity(rawIdentity); + const [id, session] = lookup(request, identity); + if (request.method === "DELETE") { + remove(id, session); + return new Response(null, { status: 204 }); + } + return new Response(null, { + status: 405, + headers: { Allow: "POST, DELETE" }, + }); + } catch (error) { + return json( + failed(error), + error instanceof McpError ? (error.status ?? 400) : 500, + ); + } + }, + async handle( + request: Request, + body: unknown, + identity: McpIdentity | null, + ): Promise { + const entries = Array.isArray(body) ? body : [body]; + const modern = + request.headers.get("MCP-Protocol-Version") === MCP_PROTOCOL_VERSION || + entries.some((entry) => { + if (entry === null || typeof entry !== "object") return false; + const params = (entry as Record).params; + if (params === null || typeof params !== "object") return false; + const meta = (params as Record)._meta; + return ( + meta !== null && + typeof meta === "object" && + (meta as Record)[ + "io.modelcontextprotocol/protocolVersion" + ] === MCP_PROTOCOL_VERSION + ); + }); + if (modern) return undefined; + const first = entries[0]; + const initializing = + first !== null && + typeof first === "object" && + (first as Record).method === "initialize"; + if ( + !initializing && + !request.headers.has("Mcp-Session-Id") && + request.headers.has("Mcp-Method") + ) + return undefined; + let id: string | number | undefined; + try { + assertOpen(); + if (initializing && !Array.isArray(body)) { + const message = validateJsonRpcRequest(body); + id = message.id; + if (request.headers.has("Mcp-Session-Id")) + throw new McpError( + "Initialize must start a new session", + 400, + -32600, + ); + const headerVersion = request.headers.get("MCP-Protocol-Version"); + if ( + headerVersion !== null && + !versions.some((version) => version === headerVersion) + ) + throw new McpError( + "Unsupported legacy protocol header", + 400, + -32600, + ); + const params = validateLegacyInitialize(message.params); + const permitted = await authorized( + identity, + "mcp:server", + "initialize", + ); + assertOpen(); + if (!permitted) + return ( + options.challenge?.(403) ?? new Response(null, { status: 403 }) + ); + for (const [id, session] of sessions) + if (session.expires <= Date.now()) remove(id, session); + if (sessions.size >= maxSessions) + throw new McpError("Legacy session capacity reached", 503, -32603); + const version = + versions.find((version) => version === params.protocolVersion) ?? + versions[0]!; + const sessionId = crypto.randomUUID(); + const session: Session = { + identity, + version, + capabilities: params.capabilities, + initialized: false, + closed: false, + used: new Set([id]), + active: new Map(), + expires: Date.now() + ttl, + timer: setTimeout(() => { + const current = sessions.get(sessionId); + if (current) remove(sessionId, current); + }, ttl), + }; + session.timer.unref(); + sessions.set(sessionId, session); + return json( + { + jsonrpc: "2.0", + id, + result: { + protocolVersion: version, + capabilities, + serverInfo: options.serverInfo, + }, + }, + 200, + { "Mcp-Session-Id": sessionId }, + ); + } + const [, session] = lookup(request, identity); + if ( + Array.isArray(body) && + (session.version !== "2025-03-26" || body.length === 0) + ) + throw new McpError( + "Batch unsupported for this revision", + 400, + -32600, + ); + if ( + entries.some( + (entry) => + entry !== null && + typeof entry === "object" && + (entry as Record).method === "initialize", + ) + ) + throw new McpError( + "Initialize cannot be batched or repeated", + 400, + -32600, + ); + // This shim never issues server-to-client requests, so unsolicited responses + // cannot be accepted (including the March response-batch shape). + if ( + entries.some( + (entry) => + entry !== null && + typeof entry === "object" && + (Object.hasOwn(entry, "result") || Object.hasOwn(entry, "error")), + ) + ) + throw new McpError("Unsolicited JSON-RPC response", 400, -32600); + const messages = entries.map((entry) => { + try { + return { message: validateJsonRpcRequest(entry, true) }; + } catch (error) { + return { error: failed(error) }; + } + }); + if (!Array.isArray(body) && messages[0]?.error) + return json(messages[0].error, 400); + if ( + !messages.some( + (item) => item.error || (item.message && "id" in item.message), + ) + ) { + for (const item of messages) + if (item.message) notification(item.message, session); + return new Response(null, { status: 202 }); + } + let disconnected = false; + const stream = new ReadableStream({ + start(sink) { + const send = (value: unknown) => { + if (!disconnected) + sink.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify(value)}\n\n`, + ), + ); + }; + void Promise.all( + messages.map(async (item) => { + if (item.error) { + send(item.error); + return; + } + if (!item.message) return; + if (!("id" in item.message)) { + try { + notification(item.message, session); + } catch { + /* Notifications never receive JSON-RPC responses. */ + } + return; + } + send(await run(item.message, request, session, identity, send)); + }), + ).finally(() => { + if (!disconnected) { + disconnected = true; + sink.close(); + } + }); + }, + // In legacy HTTP a lost connection is NOT a cancellation notification. + cancel() { + disconnected = true; + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + }); + } catch (error) { + return json( + failed(error, id), + error instanceof McpError ? (error.status ?? 400) : 500, + ); + } + }, + close() { + closed = true; + for (const [id, session] of sessions) remove(id, session); + }, + }; +} diff --git a/src/server/providers.test.ts b/src/server/providers.test.ts new file mode 100644 index 0000000..c4cfd25 --- /dev/null +++ b/src/server/providers.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { createMcpServer } from "../server.js"; +import { rpc, result } from "./fixtures.js"; +import type { McpProvider } from "./contracts.js"; + +const provider = (handle: McpProvider["handle"]): McpProvider => ({ + resource: "fixture", + action: "read", + handle, +}); +const info = { name: "providers", version: "1" }; + +test("prompts, resources, templates and completion execute through shared boundaries", async () => { + const server = createMcpServer({ + serverInfo: info, + auth: "public", + providers: { + "prompts/list": provider(() => ({ prompts: [{ name: "greet" }] })), + "prompts/get": provider(() => ({ + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + })), + "resources/list": provider(() => ({ + resources: [{ name: "file", uri: "test:///file" }], + })), + "resources/templates/list": provider(() => ({ + resourceTemplates: [{ name: "files", uriTemplate: "test:///{file}" }], + })), + "resources/read": provider((params) => ({ + contents: [{ uri: params.uri, text: "contents" }], + })), + "completion/complete": provider(() => ({ + completion: { values: ["hello"] }, + })), + }, + }); + for (const [method, params, field] of [ + ["prompts/list", {}, "prompts"], + ["prompts/get", { name: "greet" }, "messages"], + ["resources/list", {}, "resources"], + ["resources/templates/list", {}, "resourceTemplates"], + ["resources/read", { uri: "test:///file" }, "contents"], + [ + "completion/complete", + { + ref: { type: "ref/prompt", name: "greet" }, + argument: { name: "greeting", value: "he" }, + }, + "completion", + ], + ] as const) + expect( + await result(await server.fetch(rpc(method, params))), + ).toHaveProperty(`result.${field}`); + await server.close(); +}); + +test("MRTR requires client capability and passes continuation to selected provider", async () => { + const server = createMcpServer({ + serverInfo: info, + auth: "public", + providers: { + "tools/call": provider((params) => + params.inputResponses + ? { content: [{ type: "text", text: "resumed" }] } + : { + resultType: "input_required", + requestState: "host-bound-state", + inputRequests: { + question: { + method: "elicitation/create", + params: { + mode: "form", + message: "Choose", + requestedSchema: { + type: "object", + properties: { answer: { type: "string" } }, + }, + }, + }, + }, + }, + ), + }, + }); + expect( + await result(await server.fetch(rpc("tools/call", { name: "ask" }))), + ).toHaveProperty("error"); + const _meta = { + "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, + }; + expect( + await result(await server.fetch(rpc("tools/call", { name: "ask", _meta }))), + ).toMatchObject({ + result: { resultType: "input_required", requestState: "host-bound-state" }, + }); + expect( + await result( + await server.fetch( + rpc("tools/call", { + name: "ask", + _meta, + requestState: "host-bound-state", + inputResponses: { + question: { action: "accept", content: { answer: "yes" } }, + }, + }), + ), + ), + ).toMatchObject({ result: { content: [{ text: "resumed" }] } }); + await server.close(); +}); + +test("subscription provider cannot publish notifications outside requested filter", async () => { + const server = createMcpServer({ + serverInfo: info, + auth: "public", + providers: { + "subscriptions/listen": { + acceptSubscription: () => ({ toolsListChanged: true }), + ...provider((_params, context) => { + context.notify("notifications/tools/list_changed", {}); + context.notify("notifications/resources/updated", { + uri: "test:///secret", + }); + return {}; + }), + }, + }, + }); + const response = await server.fetch( + rpc("subscriptions/listen", { notifications: { toolsListChanged: true } }), + ); + const text = await response.text(); + expect(text).toContain("notifications/tools/list_changed"); + expect(text).not.toContain("test:///secret"); + await server.close(); +}); diff --git a/src/server/review-regressions.test.ts b/src/server/review-regressions.test.ts new file mode 100644 index 0000000..c748efb --- /dev/null +++ b/src/server/review-regressions.test.ts @@ -0,0 +1,332 @@ +import { expect, test } from "bun:test"; +import { createMcpServer, createToolProviders } from "../server.js"; +import { McpError } from "../protocol/index.js"; +import type { McpProvider, McpProviders } from "./contracts.js"; +import { rpc, result } from "./fixtures.js"; + +const subscriptionId = "io.modelcontextprotocol/subscriptionId"; +const serverInfo = { name: "wire-regressions", version: "1" }; +const provider = (handle: McpProvider["handle"]): McpProvider => ({ + resource: "fixture", + action: "read", + handle, +}); +async function frames(response: Response): Promise[]> { + if (response.headers.get("content-type")?.includes("application/json")) + return [(await response.json()) as Record]; + return (await response.text()) + .trim() + .split("\n\n") + .map((event) => JSON.parse(event.slice(6)) as Record); +} + +// MCP 2026 basic: consumer absent=complete compatibility is not an emission waiver. +test("all built-in and authored ordinary results emit complete discriminator", async () => { + const tools = createToolProviders( + [ + { + definition: { name: "echo", inputSchema: { type: "object" } }, + resource: "tool:echo", + action: "call", + execute: () => ({ content: [] }), + }, + ], + "public", + ); + const providers: McpProviders = { + ...tools, + "prompts/list": provider(() => ({ prompts: [] })), + "prompts/get": provider(() => ({ messages: [] })), + "resources/list": provider(() => ({ resources: [] })), + "resources/templates/list": provider(() => ({ resourceTemplates: [] })), + "resources/read": provider(() => ({ contents: [] })), + "completion/complete": provider(() => ({ completion: { values: [] } })), + }; + const server = createMcpServer({ serverInfo, auth: "public", providers }); + try { + for (const [method, params] of [ + ["server/discover", {}], + ["tools/list", {}], + ["tools/call", { name: "echo" }], + ["prompts/list", {}], + ["prompts/get", { name: "prompt" }], + ["resources/list", {}], + ["resources/templates/list", {}], + ["resources/read", { uri: "test:///file" }], + [ + "completion/complete", + { + ref: { type: "ref/prompt", name: "prompt" }, + argument: { name: "arg", value: "" }, + }, + ], + ] as const) + expect( + await result(await server.fetch(rpc(method, params))), + ).toHaveProperty("result.resultType", "complete"); + } finally { + await server.close(); + } +}); + +// Subscriptions: acknowledgement first, accepted subset only, originating ID on every frame. +test("subscription frames carry originating ID and retain provider metadata", async () => { + const server = createMcpServer({ + serverInfo, + auth: "public", + providers: { + "subscriptions/listen": { + ...provider((_params, context) => { + context.notify("notifications/tools/list_changed", { + _meta: { retained: true, [subscriptionId]: "spoof" }, + }); + return { _meta: { retained: true, [subscriptionId]: "spoof" } }; + }), + acceptSubscription: () => ({ toolsListChanged: true }), + }, + }, + }); + try { + const request = rpc("subscriptions/listen", { + notifications: { toolsListChanged: true }, + }); + const body = (await request.json()) as Record; + const response = await server.fetch( + new Request(request.url, { + method: "POST", + headers: request.headers, + body: JSON.stringify({ ...body, id: "stream-7" }), + }), + ); + const messages = await frames(response); + expect(messages).toHaveLength(3); + expect(messages[0]).toHaveProperty( + "method", + "notifications/subscriptions/acknowledged", + ); + for (const message of messages) { + const payload = (message.params ?? message.result) as Record< + string, + unknown + >; + expect(payload._meta).toMatchObject({ [subscriptionId]: "stream-7" }); + } + expect(messages[1]).toHaveProperty("params._meta.retained", true); + expect(messages[2]).toHaveProperty("result._meta.retained", true); + expect(messages[2]).toHaveProperty("result.resultType", "complete"); + } finally { + await server.close(); + } +}); + +test("acknowledgement waits for provider acceptance and publishes only accepted filters", async () => { + const ordering: string[] = []; + const server = createMcpServer({ + serverInfo, + auth: "public", + providers: { + "subscriptions/listen": { + ...provider((_params, context) => { + ordering.push("handle"); + context.notify("notifications/tools/list_changed", {}); + context.notify("notifications/resources/updated", { + uri: "test:///unsupported", + }); + return {}; + }), + async acceptSubscription() { + await Promise.resolve(); + ordering.push("accepted"); + return { toolsListChanged: true }; + }, + }, + }, + }); + try { + const messages = await frames( + await server.fetch( + rpc("subscriptions/listen", { + notifications: { + toolsListChanged: true, + resourceSubscriptions: ["test:///unsupported"], + }, + }), + ), + ); + expect(ordering).toEqual(["accepted", "handle"]); + expect(messages[0]).toHaveProperty("params.notifications", { + toolsListChanged: true, + }); + expect( + messages.some( + (message) => message.method === "notifications/resources/updated", + ), + ).toBe(false); + } finally { + await server.close(); + } +}); + +test("rejected or widened subscriptions never receive acknowledgement or execute handler", async () => { + for (const acceptSubscription of [ + () => { + throw new McpError("Unsupported subscription", 400, -32602); + }, + () => ({ resourceSubscriptions: ["test:///not-requested"] }), + ]) { + let executed = false; + const server = createMcpServer({ + serverInfo, + auth: "public", + providers: { + "subscriptions/listen": { + ...provider(() => { + executed = true; + return {}; + }), + acceptSubscription, + }, + }, + }); + try { + const messages = await frames( + await server.fetch( + rpc("subscriptions/listen", { + notifications: { toolsListChanged: true }, + }), + ), + ); + expect(messages).toHaveLength(1); + expect(messages[0]).toHaveProperty("error"); + expect(executed).toBe(false); + } finally { + await server.close(); + } + } +}); + +test("subscription progress is suppressed even with a client progress token", async () => { + const server = createMcpServer({ + serverInfo, + auth: "public", + providers: { + "subscriptions/listen": { + ...provider((_params, context) => { + context.reportProgress(1, 2); + return {}; + }), + acceptSubscription: () => ({}), + }, + }, + }); + try { + const messages = await frames( + await server.fetch( + rpc("subscriptions/listen", { + notifications: {}, + _meta: { progressToken: "token" }, + }), + ), + ); + expect( + messages.some((message) => message.method === "notifications/progress"), + ).toBe(false); + expect(messages).toHaveLength(2); + } finally { + await server.close(); + } +}); + +test("SSE and outer errors preserve safe structured McpError data identically", async () => { + const data = { + capabilities: { elicitation: { form: {} } }, + retry: { permitted: true }, + }; + const failure = new McpError("Client capability required", 400, -32021, data); + const server = createMcpServer({ + serverInfo, + auth: "public", + providers: { + "tools/call": provider(() => { + throw failure; + }), + "tools/list": { + ...provider(() => ({ tools: [] })), + schemas: () => { + throw failure; + }, + }, + }, + }); + try { + const streamed = await result( + await server.fetch(rpc("tools/call", { name: "fail" })), + ); + const outer = await result(await server.fetch(rpc("tools/list"))); + expect(streamed.error).toEqual(outer.error); + expect(streamed).toHaveProperty("error.data", data); + } finally { + await server.close(); + } +}); + +test("malformed requests omit unreadable IDs and retain readable IDs", async () => { + const server = createMcpServer({ serverInfo, auth: "public" }); + const template = rpc("server/discover"); + try { + for (const body of [ + "{", + JSON.stringify({ jsonrpc: "2.0", id: null, method: "server/discover" }), + JSON.stringify([{ id: 1 }]), + ]) { + const response = await server.fetch( + new Request(template.url, { + method: "POST", + headers: template.headers, + body, + }), + ); + expect(response.status).toBe(400); + expect(await result(response)).not.toHaveProperty("id"); + } + for (const id of ["readable-id", 0]) { + const response = await server.fetch( + new Request(template.url, { + method: "POST", + headers: template.headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "server/discover", + params: [], + }), + }), + ); + expect(await result(response)).toHaveProperty("id", id); + } + } finally { + await server.close(); + } +}); + +test("runtime provider null and unknown discriminators never cross the success boundary", async () => { + for (const resultType of [null, "unknown"]) { + const server = createMcpServer({ + serverInfo, + auth: "public", + providers: { + // @ts-expect-error Exercise malformed output from an untyped runtime provider. + "tools/call": provider(() => ({ resultType, content: [] })), + }, + }); + try { + const response = await result( + await server.fetch(rpc("tools/call", { name: "runtime" })), + ); + expect(response).toHaveProperty("error"); + expect(response).not.toHaveProperty("result"); + } finally { + await server.close(); + } + } +}); diff --git a/src/server/server.test.ts b/src/server/server.test.ts new file mode 100644 index 0000000..d6e2b46 --- /dev/null +++ b/src/server/server.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test } from "bun:test"; +import { createMcpServer, createToolProviders } from "../server.js"; +import { mcpCallTool, mcpConnect, mcpListTools } from "../client.js"; +import type { McpAuthPolicy } from "../auth/contracts.js"; +import type { FetchLike } from "../protocol/types.js"; + +const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grant-v1", + scopes: [], +}; +const auth: McpAuthPolicy = { + authenticate: () => identity, + authorize: () => true, +}; +const info = { name: "fixture", version: "1" }; +import { rpc, result } from "./fixtures.js"; + +describe("stateless MCP server", () => { + test("real client discovers, lists and calls with no session", async () => { + const server = createMcpServer({ + serverInfo: info, + auth: "public", + providers: createToolProviders( + [ + { + definition: { + name: "echo", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }, + }, + resource: "tool:echo", + action: "call", + execute: (args) => ({ + content: [{ type: "text", text: args.value }], + }), + }, + ], + "public", + ), + }); + const fetch: FetchLike = (input, init) => + server.fetch( + input instanceof Request + ? new Request(input, init) + : new Request(String(input), init), + ); + const session = await mcpConnect("http://localhost/mcp", { fetch }); + expect( + (await mcpListTools("http://localhost/mcp", { fetch, session }))[0]?.name, + ).toBe("echo"); + expect( + await mcpCallTool( + "http://localhost/mcp", + "echo", + { value: "hello" }, + { fetch, session }, + ), + ).toMatchObject({ content: [{ type: "text", text: "hello" }] }); + expect( + (await server.fetch(rpc("server/discover"))).headers.get( + "Mcp-Session-Id", + ), + ).toBeNull(); + await server.close(); + }); + + test("rejects Origin, unauthenticated identity, header mismatch and malformed JSON", async () => { + const server = createMcpServer({ serverInfo: info, auth }); + expect( + ( + await server.fetch( + rpc("server/discover", {}, { Origin: "https://evil.example" }), + ) + ).status, + ).toBe(403); + expect( + ( + await server.fetch( + rpc("server/discover", {}, { "Mcp-Method": "tools/list" }), + ) + ).status, + ).toBe(400); + const denied = createMcpServer({ + serverInfo: info, + auth: { ...auth, authenticate: () => null }, + }); + expect((await denied.fetch(rpc("server/discover"))).status).toBe(401); + const malformed = rpc("server/discover"); + expect( + ( + await server.fetch( + new Request(malformed.url, { + method: "POST", + headers: malformed.headers, + body: "[", + }), + ) + ).status, + ).toBe(400); + expect( + (await server.fetch(new Request("http://localhost/mcp"))).status, + ).toBe(405); + await server.close(); + await denied.close(); + }); + + test("invalid arguments and denied grants never execute providers", async () => { + let calls = 0; + let granted = true; + const policy = { ...auth, authorize: () => granted }; + const server = createMcpServer({ + serverInfo: info, + auth: policy, + providers: createToolProviders( + [ + { + definition: { + name: "integer", + inputSchema: { + type: "object", + properties: { count: { type: "integer" } }, + required: ["count"], + }, + }, + resource: "tool:integer", + action: "call", + execute: () => { + calls++; + return { content: [] }; + }, + }, + ], + policy, + ), + }); + expect( + await result( + await server.fetch( + rpc("tools/call", { name: "integer", arguments: { count: "wrong" } }), + ), + ), + ).toHaveProperty("error"); + granted = false; + expect( + ( + await server.fetch( + rpc("tools/call", { name: "integer", arguments: { count: 1 } }), + ) + ).status, + ).toBe(403); + expect(calls).toBe(0); + await server.close(); + }); + + test("streams scoped progress and aborts provider when response is cancelled", async () => { + let cancelled = false; + const server = createMcpServer({ + serverInfo: info, + auth: "public", + providers: { + "tools/call": { + resource: "tool:work", + action: "call", + async handle(_params, context) { + context.reportProgress(1, 2, "started"); + await new Promise((resolve) => + context.signal.addEventListener( + "abort", + () => { + cancelled = true; + resolve(); + }, + { once: true }, + ), + ); + return { content: [] }; + }, + }, + }, + }); + const response = await server.fetch( + rpc("tools/call", { + name: "work", + _meta: { progressToken: "progress-1" }, + }), + ); + const reader = response.body!.getReader(); + const event = new TextDecoder().decode((await reader.read()).value); + expect(event).toContain('"method":"notifications/progress"'); + expect(event).toContain('"progressToken":"progress-1"'); + await reader.cancel(); + expect(cancelled).toBe(true); + await server.close(); + }); + + test("rejects unknown providers and disabled extensions", () => { + const provider = { resource: "x", action: "read", handle: () => ({}) }; + expect(() => + createMcpServer({ + serverInfo: info, + auth: "public", + providers: { "fake/method": provider }, + }), + ).toThrow(); + expect(() => + createMcpServer({ + serverInfo: info, + auth: "public", + providers: { "tasks/get": provider }, + }), + ).toThrow(); + }); +}); + +test("unknown methods use HTTP 404 and unsupported versions list supported revisions", async () => { + const server = createMcpServer({ serverInfo: info, auth: "public" }); + expect((await server.fetch(rpc("unknown/method"))).status).toBe(404); + const request = rpc( + "server/discover", + { _meta: { "io.modelcontextprotocol/protocolVersion": "2025-11-25" } }, + { "MCP-Protocol-Version": "2025-11-25" }, + ); + const response = await server.fetch(request); + expect(response.status).toBe(400); + expect(await result(response)).toMatchObject({ + error: { code: -32022, data: { supported: ["2026-07-28"] } }, + }); + await server.close(); +}); + +test("fetch handler serves real stateless loopback HTTP requests", async () => { + const endpoint = createMcpServer({ + serverInfo: info, + auth: "public", + providers: createToolProviders( + [ + { + definition: { name: "network", inputSchema: { type: "object" } }, + resource: "tool:network", + action: "call", + execute: () => ({ content: [{ type: "text", text: "over HTTP" }] }), + }, + ], + "public", + ), + }); + const listener = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: endpoint.fetch, + }); + try { + const url = `http://127.0.0.1:${listener.port}/mcp`; + const session = await mcpConnect(url); + expect(await mcpCallTool(url, "network", {}, { session })).toMatchObject({ + content: [{ text: "over HTTP" }], + }); + } finally { + await endpoint.close(); + await listener.stop(true); + } +}); + +test("extension capability advertisements require corresponding providers", () => { + expect(() => + createMcpServer({ + serverInfo: info, + auth: "public", + extensions: { "io.modelcontextprotocol/tasks": {} }, + }), + ).toThrow(); + expect(() => + createMcpServer({ + serverInfo: info, + auth: "public", + extensions: { "io.modelcontextprotocol/skills": {} }, + }), + ).toThrow(); +}); diff --git a/src/server/tools.ts b/src/server/tools.ts new file mode 100644 index 0000000..26cfa0d --- /dev/null +++ b/src/server/tools.ts @@ -0,0 +1,101 @@ +import type { McpAuthPolicy } from "../auth/contracts.js"; +import { + McpError, + validateSchema, + validateInstance, +} from "../protocol/index.js"; +import type { + McpOperationOutcome, + McpProviders, + McpRequestContext, +} from "./contracts.js"; + +export type McpToolBinding = { + definition: { + name: string; + description?: string; + inputSchema: Record; + outputSchema?: Record; + [key: string]: unknown; + }; + resource: string; + action: string; + visible?: (context: McpRequestContext) => boolean; + execute: ( + args: Record, + context: McpRequestContext, + params: Record, + ) => McpOperationOutcome | Promise; +}; + +/** Finite catalog; authorization is rechecked even after successful discovery. */ +export function createToolProviders( + tools: readonly McpToolBinding[], + auth: McpAuthPolicy | "public", +): McpProviders { + const byName = new Map(); + for (const tool of tools) { + if (byName.has(tool.definition.name)) + throw new Error(`Duplicate tool: ${tool.definition.name}`); + validateSchema(tool.definition.inputSchema); + if (tool.definition.outputSchema) + validateSchema(tool.definition.outputSchema); + byName.set(tool.definition.name, tool); + } + const allowed = async (tool: McpToolBinding, context: McpRequestContext) => + (tool.visible?.(context) ?? true) && + (auth === "public" || + (context.identity !== null && + (await auth.authorize(context.identity, { + resource: tool.resource, + action: tool.action, + })))); + return { + "tools/list": { + resource: "mcp:tools", + action: "list", + async handle(params, context) { + if (params.cursor !== undefined) + throw new McpError("Unknown cursor", 400, -32602); + const visible = []; + for (const tool of byName.values()) + if (await allowed(tool, context)) visible.push(tool.definition); + return { tools: visible }; + }, + }, + "tools/call": { + resource: "mcp:tools", + action: "call", + schemas(params) { + const definition = byName.get(String(params.name))?.definition; + return definition + ? { + inputSchema: definition.inputSchema, + ...(definition.outputSchema + ? { outputSchema: definition.outputSchema } + : {}), + } + : {}; + }, + async handle(params, context) { + const tool = byName.get(String(params.name)); + if (!tool || !(await allowed(tool, context))) + throw new McpError("Tool unavailable", 403, -32602); + const args = (params.arguments ?? {}) as Record; + validateInstance(tool.definition.inputSchema, args); + const result = await tool.execute(args, context, params); + if ( + (result.resultType === undefined || + result.resultType === "complete") && + result.isError !== true && + tool.definition.outputSchema + ) + validateInstance( + tool.definition.outputSchema, + result.structuredContent, + ); + return result; + }, + }, + }; +} From a6b15edca33cc628f0db24a461f829f1b845699d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:38 -0700 Subject: [PATCH 15/28] feat(interchange): bridge selected bindings and host lifecycles Share live and stored-catalog result projection, preserve mediated tools and ask floors, and connect explicit host continuation, task, skill and Apps services. Expose only selected authorized operations with adapter tests. --- src/interchange/client.ts | 32 ++ src/interchange/client/apps.test.ts | 103 ++++++ src/interchange/client/capabilities.ts | 76 +++++ src/interchange/client/catalog.test.ts | 61 ++++ src/interchange/client/catalog.ts | 68 ++++ src/interchange/client/continuation.test.ts | 259 ++++++++++++++ src/interchange/client/contracts.ts | 70 ++++ src/interchange/client/integration.test.ts | 361 ++++++++++++++++++++ src/interchange/client/projection.test.ts | 117 +++++++ src/interchange/client/projection.ts | 120 +++++++ src/interchange/client/skills-large.test.ts | 229 +++++++++++++ src/interchange/client/skills.test.ts | 209 ++++++++++++ src/interchange/client/skills.ts | 119 +++++++ src/interchange/server.ts | 233 +++++++++++++ src/interchange/server/accepted.test.ts | 161 +++++++++ src/interchange/server/contracts.ts | 97 ++++++ src/interchange/server/server.test.ts | 275 +++++++++++++++ src/naming.ts | 10 + src/sidecar-bundle.test.ts | 9 +- src/sidecar-bundle.ts | 110 +++--- src/tool.test.ts | 4 +- src/tool.ts | 97 +++--- 22 files changed, 2700 insertions(+), 120 deletions(-) create mode 100644 src/interchange/client.ts create mode 100644 src/interchange/client/apps.test.ts create mode 100644 src/interchange/client/capabilities.ts create mode 100644 src/interchange/client/catalog.test.ts create mode 100644 src/interchange/client/catalog.ts create mode 100644 src/interchange/client/continuation.test.ts create mode 100644 src/interchange/client/contracts.ts create mode 100644 src/interchange/client/integration.test.ts create mode 100644 src/interchange/client/projection.test.ts create mode 100644 src/interchange/client/projection.ts create mode 100644 src/interchange/client/skills-large.test.ts create mode 100644 src/interchange/client/skills.test.ts create mode 100644 src/interchange/client/skills.ts create mode 100644 src/interchange/server.ts create mode 100644 src/interchange/server/accepted.test.ts create mode 100644 src/interchange/server/contracts.ts create mode 100644 src/interchange/server/server.test.ts diff --git a/src/interchange/client.ts b/src/interchange/client.ts new file mode 100644 index 0000000..81fe10c --- /dev/null +++ b/src/interchange/client.ts @@ -0,0 +1,32 @@ +/** Remote MCP consumption through actual Interchange tool and host contracts. */ +export { + mcpTools, + type McpServerConfig, + type McpToolsEnv, + type McpToolsOptions, +} from "../tool.js"; +export { + mcpServers, + SIDECAR_BUNDLE_ID, + shapeMcpContent, + type McpBoundServer, + type McpServersConfig, + type McpServersEnv, +} from "../sidecar-bundle.js"; +export type { + McpHostContext, + McpHostServices, + McpTaskControls, +} from "./client/contracts.js"; +export { + createCatalogGuard, + type McpCatalogSnapshot, +} from "./client/catalog.js"; +export { createMcpSkillConsumer } from "./client/skills.js"; + +export { + createSkillLoader, + parseSkillFrontmatter, + SKILL_LIMITS, +} from "../extensions/skills/index.js"; +export type * from "../extensions/skills/contracts.js"; diff --git a/src/interchange/client/apps.test.ts b/src/interchange/client/apps.test.ts new file mode 100644 index 0000000..e7ed917 --- /dev/null +++ b/src/interchange/client/apps.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { createAppHostBridge } from "../../extensions/apps.js"; +import { mcpTools } from "../../tool.js"; +import { mcpServers } from "../../sidecar-bundle.js"; +import { assertHostExtensions, hostClientOptions } from "./capabilities.js"; +import type { McpHostServices } from "./contracts.js"; + +const ui = ["io.modelcontextprotocol/ui"]; +// Test-only host. Production hosts must implement these callbacks and support +// obligations; the library does not provide a renderer. +const bridge = createAppHostBridge({ + async mount() { + return { async close() {} }; + }, + async authorizeTool() {}, + async callTool() { + return {}; + }, +}); +const support = { + sandboxRenderer: true, + cspEnforcement: true, + authenticatedBridge: true, +}; +const presentResult = async () => {}; + +for (const [name, apps] of [ + ["presentation alone", undefined], + [ + "missing sandbox", + { bridge, support: { ...support, sandboxRenderer: false } }, + ], + ["missing CSP", { bridge, support: { ...support, cspEnforcement: false } }], + [ + "unauthenticated bridge", + { bridge, support: { ...support, authenticatedBridge: false } }, + ], + ["missing bridge", { support }], + ["missing mount callback", { support, bridge: { call: bridge.call } }], + ["missing call callback", { support, bridge: { mount: bridge.mount } }], + ["unknown support", { bridge }], +] as const) { + test(`Apps opt-in rejects ${name} before either factory can advertise`, async () => { + // Exercise malformed runtime config as well as correctly typed false claims. + const host = { + presentResult, + ...(apps === undefined ? {} : { apps }), + } as unknown as McpHostServices; + expect(() => mcpServers({ servers: [], extensions: ui, host })).toThrow( + "Apps", + ); + await expect( + mcpTools({ servers: [], extensions: ui, host }), + ).rejects.toThrow("Apps"); + expect(() => hostClientOptions(host, ui)).toThrow("Apps"); + }); +} + +test("explicit host Apps support and bridge permit UI with form capability", () => { + const host: McpHostServices = { + apps: { bridge, support }, + presentResult, + elicitation: { form: async () => ({ action: "decline" }) }, + }; + expect(() => assertHostExtensions(ui, host)).not.toThrow(); + expect(hostClientOptions(host, ui)).toEqual({ + clientCapabilities: { + elicitation: { form: {} }, + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + }, + }, + }, + }); +}); + +test("metadata-only presentation without UI opt-in never advertises Apps", () => { + expect(hostClientOptions({ presentResult })).toEqual({}); + expect(() => + mcpServers({ servers: [], host: { presentResult } }), + ).not.toThrow(); +}); + +test("Apps without elicitation still requires validated support and advertises only UI", () => { + expect( + hostClientOptions({ apps: { bridge, support }, presentResult }, ui), + ).toEqual({ + clientCapabilities: { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + }, + }, + }, + }); + expect(() => assertHostExtensions(ui, { apps: { bridge, support } })).toThrow( + "Apps", + ); + expect( + hostClientOptions({ apps: { bridge, support }, presentResult }), + ).toEqual({}); +}); diff --git a/src/interchange/client/capabilities.ts b/src/interchange/client/capabilities.ts new file mode 100644 index 0000000..24fbc38 --- /dev/null +++ b/src/interchange/client/capabilities.ts @@ -0,0 +1,76 @@ +import { type } from "arktype"; +import { getAppHostRequirements } from "../../extensions/apps.js"; +import { supportedHostExtensions } from "../../extensions/maturity.js"; +import { createElicitationRuntime } from "../../extensions/elicitation.js"; +import type { McpClientOptions } from "../../client.js"; +import { + MCP_EXT_TASKS, + MCP_EXT_UI, + MCP_EXT_SKILLS, +} from "../../protocol/index.js"; +import type { McpHostServices } from "./contracts.js"; + +// Host configuration boundary, not an MCP wire schema. Support is explicitly +// declared by the embedding host; we do not implement or certify its renderer. +const AppsHostSchema = type({ + bridge: { mount: "Function", call: "Function" }, + support: { + sandboxRenderer: "boolean", + cspEnforcement: "boolean", + authenticatedBridge: "boolean", + }, +}); + +export function assertHostExtensions( + extensions: readonly string[] = [], + host: McpHostServices = {}, +): void { + if (extensions.includes(MCP_EXT_TASKS) && host.adoptTask === undefined) + throw new Error("MCP tasks require a host adoption and delivery service"); + if (extensions.includes(MCP_EXT_UI)) { + const apps = AppsHostSchema(host.apps); + if ( + apps instanceof type.errors || + !supportedHostExtensions({ apps: apps.support }).includes(MCP_EXT_UI) || + typeof host.presentResult !== "function" + ) { + throw new Error( + "MCP Apps require an explicit host bridge, sandbox renderer, CSP enforcement, authenticated bridge, and presentation service", + ); + } + } + if (extensions.includes(MCP_EXT_SKILLS)) + throw new Error( + "Use createMcpSkillConsumer for skills; tool factories do not activate skills", + ); +} + +export function hostClientOptions( + host: McpHostServices | undefined, + extensions: readonly string[] = [], +): Pick { + assertHostExtensions(extensions, host); + const elicitation = + host?.elicitation === undefined + ? {} + : createElicitationRuntime(host.elicitation).capabilities; + const hasElicitation = Object.keys(elicitation).length > 0; + if (!hasElicitation && !extensions.includes(MCP_EXT_UI)) return {}; + return { + clientCapabilities: { + ...(hasElicitation ? { elicitation } : {}), + ...(extensions.length === 0 + ? {} + : { + extensions: Object.fromEntries( + extensions.map((id) => [ + id, + id === MCP_EXT_UI + ? { mimeTypes: [getAppHostRequirements().mimeType] } + : {}, + ]), + ), + }), + }, + }; +} diff --git a/src/interchange/client/catalog.test.ts b/src/interchange/client/catalog.test.ts new file mode 100644 index 0000000..dc133f9 --- /dev/null +++ b/src/interchange/client/catalog.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { createCatalogGuard } from "./catalog.js"; +import type { McpTool } from "../../client.js"; + +const context = { + server: "s", + url: "https://example.test/mcp", + tool: "s.echo", + callId: "c", + signal: new AbortController().signal, +}; +const tool: McpTool = { name: "echo", inputSchema: { type: "object" } }; + +test("freshness refreshes only after expiry and changed schemas require rebuild", async () => { + let now = 0; + let refreshes = 0; + const guard = createCatalogGuard({ + now: () => now, + refresh: async () => { + refreshes++; + return { + tools: [ + refreshes === 1 + ? tool + : { ...tool, inputSchema: { type: "object", required: ["new"] } }, + ], + expiresAt: now + 10, + }; + }, + }); + await guard(context, tool); + await guard(context, tool); + expect(refreshes).toBe(1); + now = 10; + await expect(guard(context, tool)).rejects.toThrow("rebuild"); + expect(refreshes).toBe(2); +}); + +test("refresh cannot silently change approval policy, visibility or remove a tool", async () => { + for (const tools of [ + [], + [{ ...tool, annotations: { destructiveHint: true } }], + [{ ...tool, _meta: { ui: { visibility: ["app"] } } }], + ]) { + const guard = createCatalogGuard({ + refresh: async () => ({ tools, expiresAt: Date.now() + 10000 }), + }); + await expect(guard(context, tool)).rejects.toThrow("rebuild"); + } +}); + +test("expired refresh fails closed and can be retried", async () => { + let expiresAt = 0; + const guard = createCatalogGuard({ + now: () => 1, + refresh: async () => ({ tools: [tool], expiresAt }), + }); + await expect(guard(context, tool)).rejects.toThrow("expired"); + expiresAt = 2; + await guard(context, tool); +}); diff --git a/src/interchange/client/catalog.ts b/src/interchange/client/catalog.ts new file mode 100644 index 0000000..a5cdbef --- /dev/null +++ b/src/interchange/client/catalog.ts @@ -0,0 +1,68 @@ +import { type } from "arktype"; +import { McpToolSchema, type McpTool } from "../../client.js"; +import type { McpHostContext, McpHostServices } from "./contracts.js"; + +/** Expiry is host-computed from discovery time and server TTL. Private catalogs + * must remain scoped to one host authorization context, not a global cache. */ +export interface McpCatalogSnapshot { + readonly tools: readonly McpTool[]; + readonly expiresAt: number; +} + +/** Install as host.validateCatalog. Refresh never mutates an active tool bundle: + * any changed entry requires a rebuilt factory and fresh approval derivation. */ +export function createCatalogGuard(options: { + refresh: (context: McpHostContext) => Promise; + now?: () => number; +}): NonNullable { + const catalogs = new Map>(); + const now = options.now ?? Date.now; + return async (context, tool) => { + context.signal.throwIfAborted(); + const key = JSON.stringify([context.server, context.url]); + let snapshot = await catalogs.get(key); + if (snapshot === undefined || snapshot.expiresAt <= now()) { + const pending = (async () => { + const refreshed = await options.refresh(context); + if ( + !Number.isFinite(refreshed.expiresAt) || + refreshed.expiresAt <= now() + ) { + throw new Error("MCP catalog refresh returned an expired snapshot"); + } + const parsed = McpToolSchema.array()(refreshed.tools); + if (parsed instanceof type.errors) { + throw new Error(`Invalid refreshed MCP catalog: ${parsed.summary}`); + } + return structuredClone(refreshed); + })(); + catalogs.set(key, pending); + try { + snapshot = await pending; + } catch (cause) { + if (catalogs.get(key) === pending) catalogs.delete(key); + throw cause; + } + } + context.signal.throwIfAborted(); + const matches = snapshot.tools.filter( + (candidate) => candidate.name === tool.name, + ); + if (matches.length !== 1 || canonical(matches[0]) !== canonical(tool)) { + throw new Error( + "MCP catalog changed; rebuild the tool factory and re-evaluate approvals before calling", + ); + } + }; +} + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/src/interchange/client/continuation.test.ts b/src/interchange/client/continuation.test.ts new file mode 100644 index 0000000..de03156 --- /dev/null +++ b/src/interchange/client/continuation.test.ts @@ -0,0 +1,259 @@ +import { createAppHostBridge } from "../../extensions/apps.js"; +import { expect, test } from "bun:test"; +import { fromToolRunner } from "@intx/agent"; +import { type } from "arktype"; +import { mcpTools, type McpToolsEnv } from "../../tool.js"; +import { mcpServers, type McpServersEnv } from "../../sidecar-bundle.js"; +import type { McpHostServices } from "./contracts.js"; +import type { McpTool } from "../../client.js"; + +const RequestBody = type({ + id: "number", + method: "string", + "params?": "Record", +}); +const base = { + sources: [], + defaultSource: "test", + storage: {}, + workdir: "/tmp", + audit: {}, + authorize: async () => ({ effect: "allow", matchingGrants: [] }), + directors: {}, +}; +const tool: McpTool = { + name: "count", + inputSchema: { type: "object", properties: {} }, + outputSchema: { + type: "object", + properties: { count: { type: "integer" } }, + required: ["count"], + }, +}; + +async function bundleFor( + mode: "live" | "stored", + url: string, + host: McpHostServices, + extensions?: string[], +) { + if (mode === "live") { + const factory = await mcpTools({ + servers: [{ name: "s", url }], + host, + ...(extensions === undefined ? {} : { extensions }), + }); + return factory(base as unknown as McpToolsEnv); + } + return mcpServers({ + servers: [{ handle: "s", url, tools: [tool] }], + host, + ...(extensions === undefined ? {} : { extensions }), + })({ + ...base, + capabilities: { + resolve: () => ({ + resolve: async () => ({ + kind: "http", + fetch: (input: string | URL | Request, init?: RequestInit) => + fetch(new URL(String(input), url), init), + dispose() {}, + }), + }), + }, + } as unknown as McpServersEnv); +} + +for (const mode of ["live", "stored"] as const) { + for (const rounds of [0, 1, 2]) { + for (const valid of [false, true]) { + test(`${mode}: ${rounds} continuation rounds ${valid ? "accept valid" : "reject invalid"} catalog output`, async () => { + let calls = 0; + let answers = 0; + let presentations = 0; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const body = RequestBody.assert(await request.json()); + let result: unknown; + if (body.method === "server/discover") + result = { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + }; + else if (body.method === "tools/list") result = { tools: [tool] }; + else { + calls++; + result = + calls <= rounds + ? { + resultType: "input_required", + inputRequests: {}, + requestState: `round-${calls}`, + } + : { + content: [], + structuredContent: { count: valid ? 1 : "invalid" }, + }; + } + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }, + }); + try { + const bundle = await bundleFor( + mode, + new URL("mcp", server.url).href, + { + requestInput: async () => { + answers++; + return {}; + }, + presentResult: async () => { + presentations++; + }, + }, + ); + const agentTool = fromToolRunner(bundle)[0]; + if (agentTool?.kind !== "full") + throw new Error("Expected Interchange full tool handler"); + const result = await agentTool.handler( + { id: "call", name: "s.count", arguments: {} }, + new AbortController().signal, + ); + expect(calls).toBe(rounds + 1); + expect(answers).toBe(rounds); + expect(result.isError).toBe(!valid); + expect(presentations).toBe(valid ? 1 : 0); + if (valid) + expect(result.detail).toMatchObject({ + mcp: { structuredContent: { count: 1 } }, + }); + else expect(result.detail).toBeUndefined(); + await bundle.dispose?.(); + } finally { + server.stop(true); + } + }); + } + } + + test(`${mode}: UI plus form capabilities survive two genuine elicitation rounds`, async () => { + const requests: Record[] = []; + const messages: string[] = []; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const body = RequestBody.assert(await request.json()); + let result: unknown; + if (body.method === "server/discover") + result = { + supportedVersions: ["2026-07-28"], + capabilities: { + tools: {}, + extensions: { "io.modelcontextprotocol/ui": {} }, + }, + }; + else if (body.method === "tools/list") result = { tools: [tool] }; + else { + requests.push(body.params ?? {}); + const round = requests.length; + result = + round <= 2 + ? { + resultType: "input_required", + requestState: `state-${round}`, + inputRequests: { + [`question-${round}`]: { + method: "elicitation/create", + params: { + mode: "form", + message: `Question ${round}`, + requestedSchema: { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + }, + }, + }, + }, + } + : { + content: [{ type: "text", text: "Finished" }], + structuredContent: { count: 2 }, + }; + } + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }, + }); + try { + const bundle = await bundleFor( + mode, + new URL("mcp", server.url).href, + { + elicitation: { + form: async (request) => { + messages.push(request.message); + return { action: "accept", content: { answer: request.message } }; + }, + }, + // Explicit test host only; no renderer is supplied by the adapter. + apps: { + support: { + sandboxRenderer: true, + cspEnforcement: true, + authenticatedBridge: true, + }, + bridge: createAppHostBridge({ + async mount() { + return { async close() {} }; + }, + async authorizeTool() {}, + async callTool() { + return {}; + }, + }), + }, + presentResult: async () => {}, + }, + ["io.modelcontextprotocol/ui"], + ); + const result = await bundle.run( + { id: "call", name: "s.count", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(result.content).toBe("Finished"); + expect(messages).toEqual(["Question 1", "Question 2"]); + expect(requests).toHaveLength(3); + for (const request of requests) { + expect(request._meta).toMatchObject({ + "io.modelcontextprotocol/clientCapabilities": { + elicitation: { form: {} }, + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + }, + }, + }, + }); + } + expect(requests[1]).toMatchObject({ + requestState: "state-1", + inputResponses: { + "question-1": { action: "accept", content: { answer: "Question 1" } }, + }, + }); + expect(requests[2]).toMatchObject({ + requestState: "state-2", + inputResponses: { + "question-2": { action: "accept", content: { answer: "Question 2" } }, + }, + }); + await bundle.dispose?.(); + } finally { + server.stop(true); + } + }); +} diff --git a/src/interchange/client/contracts.ts b/src/interchange/client/contracts.ts new file mode 100644 index 0000000..13e192d --- /dev/null +++ b/src/interchange/client/contracts.ts @@ -0,0 +1,70 @@ +import type { createAppHostBridge } from "../../extensions/apps.js"; +import type { supportedHostExtensions } from "../../extensions/maturity.js"; +import type { McpElicitationHost } from "../../extensions/elicitation.js"; +import type { + McpInputRequired, + McpTaskHandle, + McpTool, + McpToolResult, + mcpGetTask, + mcpUpdateTask, + mcpCancelTask, +} from "../../client.js"; + +/** Host-bound identity, never credentials or model-supplied routing. */ +export interface McpHostContext { + readonly server: string; + readonly url: string; + readonly tool: string; + readonly callId: string; + readonly signal: AbortSignal; +} + +export interface McpTaskControls { + get: () => ReturnType; + /** The host must collect and authorize responses to outstanding inputs. */ + update: ( + responses: Parameters[2], + ) => ReturnType; + /** Cooperative intent, not proof that remote work stopped. */ + cancel: () => ReturnType; +} + +/** Optional services are absent capabilities, never implicit approval. */ +export interface McpHostServices { + /** Explicit host-provided Apps runtime, required only for UI opt-in. The host + * must use this bridge for UI mounting/tool calls and fulfill the support + * obligations; structural validation cannot certify a deployed renderer. */ + apps?: { + readonly bridge: ReturnType; + readonly support: NonNullable< + Parameters[0]["apps"] + >; + }; + /** Concrete form/URL services; only these advertise elicitation support. */ + elicitation?: McpElicitationHost; + /** Present elicitation to a user and authorize each answer. Undefined declines. + * This does not substitute for the Interchange tool invocation approval gate. */ + requestInput?: ( + context: McpHostContext, + pending: McpInputRequired, + ) => Promise | undefined>; + /** Persist correlation and arrange eventual Agent.deliver before returning. + * Controls are ephemeral; durable recovery must reacquire a mediated client. */ + adoptTask?: ( + context: McpHostContext, + task: McpTaskHandle, + controls: McpTaskControls, + ) => Promise<{ correlationId: string }>; + /** Host-only rich content/UI channel; metadata is never model text. + * This callback alone does not imply Apps rendering support. When opting into + * UI, wire presentation to the explicitly supplied apps bridge. */ + presentResult?: ( + context: McpHostContext, + result: McpToolResult, + ) => Promise; + /** Check freshness in the host's identity partition before execution. Reject + * changed schemas/visibility/approval hints and rebuild the synchronous factory; + * never mutate definitions or grants underneath a running agent. */ + validateCatalog?: (context: McpHostContext, tool: McpTool) => Promise; +} diff --git a/src/interchange/client/integration.test.ts b/src/interchange/client/integration.test.ts new file mode 100644 index 0000000..b94d8c4 --- /dev/null +++ b/src/interchange/client/integration.test.ts @@ -0,0 +1,361 @@ +import { expect, test } from "bun:test"; +import { fromToolRunner } from "@intx/agent"; +import { ToolResult } from "@intx/types/runtime"; +import { type } from "arktype"; +import { mcpTools, type McpToolsEnv } from "../../tool.js"; +import { mcpServers, type McpServersEnv } from "../../sidecar-bundle.js"; +import type { McpHostServices } from "./contracts.js"; + +const RequestBody = type({ + id: "number", + method: "string", + "params?": "Record", +}); +const tools = [{ name: "echo", inputSchema: { type: "object" } }]; +const base = { + sources: [], + defaultSource: "x", + storage: {}, + workdir: "/tmp", + audit: {}, + authorize: async () => ({ effect: "allow", matchingGrants: [] }), + directors: {}, +}; + +for (const mode of ["live", "stored"] as const) { + test(`${mode}: explicit host continuation preserves rich result and original arguments`, async () => { + const calls: Record[] = []; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const body = RequestBody.assert(await request.json()); + let result: unknown; + if (body.method === "server/discover") + result = { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + }; + else if (body.method === "tools/list") result = { tools }; + else { + calls.push(body.params ?? {}); + result = + calls.length === 1 + ? { + resultType: "input_required", + requestState: "opaque", + inputRequests: {}, + } + : { + content: [{ type: "text", text: "done" }], + structuredContent: { value: 7 }, + _meta: { private: "ui-only" }, + }; + } + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }, + }); + try { + const url = new URL("mcp", server.url).href; + let presentations = 0; + const host: McpHostServices = { + requestInput: async (context, pending) => { + expect(context.tool).toBe("srv.echo"); + expect(pending.requestState).toBe("opaque"); + return {}; + }, + presentResult: async (_context, result) => { + expect(result._meta).toEqual({ private: "ui-only" }); + presentations++; + }, + }; + const bundle = + mode === "live" + ? (await mcpTools({ servers: [{ name: "srv", url }], host }))( + base as unknown as McpToolsEnv, + ) + : mcpServers({ servers: [{ handle: "srv", url, tools }], host })({ + ...base, + capabilities: { + resolve: () => ({ + resolve: async () => ({ + kind: "http", + fetch: ( + input: string | URL | Request, + init?: RequestInit, + ) => fetch(new URL(String(input), server.url), init), + dispose() {}, + }), + }), + }, + } as unknown as McpServersEnv); + const agentTool = fromToolRunner(bundle)[0]; + if (agentTool?.kind !== "full") + throw new Error("Expected actual AgentTool full handler"); + const result = await agentTool.handler( + { id: "c", name: "srv.echo", arguments: { original: true } }, + new AbortController().signal, + ); + expect(ToolResult.allows(result)).toBe(true); + expect(result.isError).toBe(false); + expect(result.content).toBe("done"); + expect(result.detail).toMatchObject({ + mcp: { structuredContent: { value: 7 }, _meta: { private: "ui-only" } }, + }); + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(JSON.stringify(call._meta)).not.toContain( + "io.modelcontextprotocol/ui", + ); + } + expect(calls[1]).toMatchObject({ + name: "echo", + arguments: { original: true }, + requestState: "opaque", + inputResponses: {}, + }); + expect(presentations).toBe(1); + await bundle.dispose?.(); + } finally { + server.stop(true); + } + }); +} + +test("stored construction stays synchronous and freshness rejection precedes credential resolution", async () => { + let resolved = false; + const factory = mcpServers({ + servers: [{ handle: "s", url: "https://example.test/mcp", tools }], + host: { + validateCatalog: async () => { + throw new Error("catalog needs reapproval"); + }, + }, + }); + const bundle = factory({ + ...base, + capabilities: { + resolve: () => { + resolved = true; + throw new Error("must not resolve"); + }, + }, + } as unknown as McpServersEnv); + expect(bundle).not.toBeInstanceOf(Promise); + const result = await bundle.run( + { id: "c", name: "s.echo", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(result.content).toBe("catalog needs reapproval"); + expect(resolved).toBe(false); +}); + +test("factories refuse to advertise host capabilities they cannot implement", async () => { + for (const id of ["tasks", "ui", "skills"]) { + const extensions = [`io.modelcontextprotocol/${id}`]; + expect(() => mcpServers({ servers: [], extensions })).toThrow(); + await expect(mcpTools({ servers: [], extensions })).rejects.toThrow(); + } +}); + +test("stored catalog output schema is enforced, not merely preserved", async () => { + const factory = mcpServers({ + servers: [ + { + handle: "s", + url: "https://example.test/mcp", + tools: [ + { + name: "echo", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { count: { type: "integer" } }, + required: ["count"], + }, + }, + ], + }, + ], + }); + const bundle = factory({ + ...base, + capabilities: { + resolve: () => ({ + resolve: async () => ({ + kind: "http", + dispose() {}, + fetch: async (_input: unknown, init?: RequestInit) => { + const body = RequestBody.assert(JSON.parse(String(init?.body))); + const result = + body.method === "server/discover" + ? { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + } + : { + content: [], + structuredContent: { count: "not-an-integer" }, + }; + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }, + }), + }), + }, + } as unknown as McpServersEnv); + const result = await bundle.run( + { id: "c", name: "s.echo", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); +}); + +for (const mode of ["live", "stored"] as const) { + test(`${mode}: app-only tools are neither exposed nor dispatchable by the model`, async () => { + let remoteCalls = 0; + const catalog = [ + { + name: "visible", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: ["model", "app"] } }, + }, + { + name: "hidden", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: ["app"] } }, + }, + ]; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const body = RequestBody.assert(await request.json()); + const result = + body.method === "server/discover" + ? { supportedVersions: ["2026-07-28"], capabilities: { tools: {} } } + : body.method === "tools/list" + ? { tools: catalog } + : (remoteCalls++, + { content: [{ type: "text", text: "must not run" }] }); + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }, + }); + try { + const url = new URL("mcp", server.url).href; + const factory = + mode === "live" + ? await mcpTools({ + servers: [{ name: "s", url }], + host: { presentResult: async () => {} }, + }) + : mcpServers({ + servers: [{ handle: "s", url, tools: catalog }], + host: { presentResult: async () => {} }, + }); + expect(factory.definitions.map((tool) => tool.name)).toEqual([ + "s.visible", + ]); + const bundle = factory({ + ...base, + capabilities: { + resolve: () => { + throw new Error("hidden tool must not resolve credentials"); + }, + }, + } as unknown as McpServersEnv); + expect(bundle.definitions.map((tool) => tool.name)).toEqual([ + "s.visible", + ]); + const result = await bundle.run( + { id: "c", name: "s.hidden", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(result.content).toContain("unknown MCP tool"); + expect(remoteCalls).toBe(0); + } finally { + server.stop(true); + } + }); +} + +test("configured form elicitation advertises only form and sends an explicit decline", async () => { + let answered = false; + const factory = mcpServers({ + servers: [{ handle: "s", url: "https://example.test/mcp", tools }], + host: { elicitation: { form: async () => ({ action: "decline" }) } }, + }); + const bundle = factory({ + ...base, + capabilities: { + resolve: () => ({ + resolve: async () => ({ + kind: "http", + dispose() {}, + fetch: async (_input: unknown, init?: RequestInit) => { + const body = RequestBody.assert(JSON.parse(String(init?.body))); + expect(body.params?._meta).toMatchObject({ + "io.modelcontextprotocol/clientCapabilities": { + elicitation: { form: {} }, + }, + }); + let result: unknown; + if (body.method === "server/discover") + result = { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + }; + else if (body.params?.inputResponses === undefined) + result = { + resultType: "input_required", + inputRequests: { + consent: { + method: "elicitation/create", + params: { + message: "Consent?", + requestedSchema: { type: "object", properties: {} }, + }, + }, + }, + }; + else { + expect(body.params.inputResponses).toEqual({ + consent: { action: "decline" }, + }); + answered = true; + result = { content: [{ type: "text", text: "User declined." }] }; + } + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }, + }), + }), + }, + } as unknown as McpServersEnv); + const result = await bundle.run( + { id: "c", name: "s.echo", arguments: {} }, + new AbortController().signal, + ); + expect(result.content).toBe("User declined."); + expect(answered).toBe(true); +}); + +test("malformed UI visibility cannot silently expose a stored tool", () => { + expect(() => + mcpServers({ + servers: [ + { + handle: "s", + url: "https://example.test/mcp", + tools: [ + { + name: "hidden", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: "app" } }, + }, + ], + }, + ], + }), + ).toThrow("visibility"); +}); diff --git a/src/interchange/client/projection.test.ts b/src/interchange/client/projection.test.ts new file mode 100644 index 0000000..6f0838b --- /dev/null +++ b/src/interchange/client/projection.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { ToolResult } from "@intx/types/runtime"; +import { projectToolResult } from "./projection.js"; +import type { McpHostContext } from "./contracts.js"; +import type { McpInputRequired, McpTaskHandle } from "../../client.js"; + +const context: McpHostContext = { + server: "srv", + url: "https://example.test/mcp", + tool: "srv.echo", + callId: "c", + signal: new AbortController().signal, +}; +const pending: McpInputRequired = { + resultType: "input_required", + method: "tools/call", + params: { name: "echo", arguments: {} }, + requestState: "opaque", + inputRequests: {}, +}; +const task: McpTaskHandle = { + resultType: "task", + taskId: "t", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:00Z", + ttlMs: null, +}; + +test("rich result retains structured content and private metadata in detail", async () => { + const result = { + content: [{ type: "text", text: "hello" }], + structuredContent: { answer: 42 }, + _meta: { secretUiState: "private" }, + }; + const projected = await projectToolResult(context, result, {}); + expect(ToolResult.allows(projected)).toBe(true); + expect(projected.content).toBe("hello"); + expect(projected.detail).toEqual({ mcp: result }); + expect(JSON.stringify(projected.content)).not.toContain("private"); +}); + +test("absent lifecycle host reports unsupported without a fake pending marker", async () => { + for (const result of [pending, task]) { + const projected = await projectToolResult(context, result, {}); + expect(projected.isError).toBe(true); + expect(projected.pendingMarker).toBeUndefined(); + expect(projected.detail).toEqual({ mcp: result, outcome: "unsupported" }); + expect(projected.content).not.toContain("opaque"); + } +}); + +test("declined input never invokes the remote continuation", async () => { + const projected = await projectToolResult( + context, + pending, + {}, + { requestInput: async () => undefined }, + ); + expect(projected.isError).toBe(true); + expect(projected.detail).toEqual({ mcp: pending, outcome: "declined" }); +}); + +test("only host-adopted tasks produce a real Interchange pending marker", async () => { + const projected = await projectToolResult( + context, + task, + {}, + { + adoptTask: async (_context, received) => { + expect(received).toEqual(task); + return { correlationId: "durable-host-correlation" }; + }, + }, + ); + expect(ToolResult.allows(projected)).toBe(true); + expect(projected.pendingMarker).toEqual({ + status: "pending", + correlationId: "durable-host-correlation", + }); + expect(projected.content).not.toContain("completed"); +}); + +test("adopted task controls preserve the mediated transport and cooperative cancellation", async () => { + const methods: string[] = []; + const result = await projectToolResult( + context, + task, + { + extensions: ["io.modelcontextprotocol/tasks"], + fetch: async (_input, init) => { + const method = new Headers(init?.headers).get("mcp-method"); + methods.push(method ?? ""); + expect(new Headers(init?.headers).get("mcp-name")).toBe("t"); + const body: unknown = JSON.parse(String(init?.body)); + if (typeof body !== "object" || body === null || !("id" in body)) + throw new Error("Missing request ID"); + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: + method === "tasks/get" ? { ...task, resultType: "complete" } : {}, + }); + }, + }, + { + adoptTask: async (_context, _task, controls) => { + expect((await controls.get()).status).toBe("working"); + await controls.update({}); + await controls.cancel(); + return { correlationId: "persisted" }; + }, + }, + ); + expect(methods).toEqual(["tasks/get", "tasks/update", "tasks/cancel"]); + expect(result.pendingMarker?.correlationId).toBe("persisted"); +}); diff --git a/src/interchange/client/projection.ts b/src/interchange/client/projection.ts new file mode 100644 index 0000000..6782103 --- /dev/null +++ b/src/interchange/client/projection.ts @@ -0,0 +1,120 @@ +import { createElicitationRuntime } from "../../extensions/elicitation.js"; +import { + validateInputRequests, + type InputRequests, +} from "../../protocol/index.js"; +import type { ToolResult } from "@intx/types/runtime"; +import { type } from "arktype"; +import { + mcpContinue, + mcpGetTask, + mcpUpdateTask, + mcpCancelTask, + type McpClientOptions, + type McpToolCallResult, +} from "../../client.js"; +import type { McpHostContext, McpHostServices } from "./contracts.js"; + +const TextBlocks = type({ type: "'text'", text: "string" }).array(); + +export function shapeMcpContent(content: unknown): string { + if (typeof content === "string") return content; + const blocks = TextBlocks(content); + if (!(blocks instanceof type.errors)) + return blocks.map((block) => block.text).join("\n"); + return JSON.stringify(content) ?? ""; +} + +/** One lifecycle and rich-result projection for live and stored catalogs. */ +export async function projectToolResult( + context: McpHostContext, + initial: McpToolCallResult, + client: McpClientOptions, + host: McpHostServices = {}, +): Promise { + let result = initial; + // Bound interactive loops independently of individual HTTP request timeouts. + for (let round = 0; round < 32; round++) { + context.signal.throwIfAborted(); + if (result.resultType === "input_required") { + if (host.requestInput === undefined && host.elicitation === undefined) + return unavailable("unsupported", result); + if (result.method !== "tools/call") + throw new Error("MCP tool continuation changed method"); + let responses: Record | undefined; + if (host.requestInput !== undefined) + responses = await host.requestInput(context, result); + else { + const requests = result.inputRequests; + assertInputRequests(requests); + responses = await createElicitationRuntime( + host.elicitation ?? {}, + ).respond(requests, context.signal); + } + context.signal.throwIfAborted(); + if (responses === undefined) return unavailable("declined", result); + const next = await mcpContinue(context.url, result, responses, { + ...client, + signal: context.signal, + }); + if ( + next.resultType === "input_required" || + next.resultType === "task" || + "content" in next + ) { + result = next; + continue; + } + throw new Error("MCP tool continuation returned a non-tool result"); + } + if (result.resultType === "task") { + if (host.adoptTask === undefined) + return unavailable("unsupported", result); + const taskId = result.taskId; + // Do not retain the original invocation signal for the task's lifetime. + const { signal: _signal, ...taskClient } = client; + const adopted = await host.adoptTask(context, result, { + get: () => mcpGetTask(context.url, taskId, taskClient), + update: (responses) => + mcpUpdateTask(context.url, taskId, responses, taskClient), + cancel: () => mcpCancelTask(context.url, taskId, taskClient), + }); + if (adopted.correlationId.trim().length === 0) + throw new Error("MCP task host returned an empty correlation ID"); + return { + callId: context.callId, + content: "Remote MCP task adopted by the host; awaiting its outcome.", + detail: { mcp: result, outcome: "pending" }, + pendingMarker: { + status: "pending", + correlationId: adopted.correlationId, + }, + }; + } + await host.presentResult?.(context, result); + return { + callId: context.callId, + isError: result.isError === true, + content: shapeMcpContent(result.content), + detail: { mcp: result }, + }; + } + throw new Error("MCP continuation exceeded 32 input rounds"); + + function unavailable( + outcome: "unsupported" | "declined", + pending: McpToolCallResult, + ): ToolResult { + return { + callId: context.callId, + isError: true, + content: `MCP ${pending.resultType}: host handling ${outcome}.`, + detail: { mcp: pending, outcome }, + }; + } +} + +/** Narrow using the sole shared wire validator, not a second schema. */ +function assertInputRequests(value: unknown): asserts value is InputRequests { + validateInputRequests(value); +} diff --git a/src/interchange/client/skills-large.test.ts b/src/interchange/client/skills-large.test.ts new file mode 100644 index 0000000..75ebb4a --- /dev/null +++ b/src/interchange/client/skills-large.test.ts @@ -0,0 +1,229 @@ +import { expect, test } from "bun:test"; +import { createMcpServer, createSkillProviders } from "../../server.js"; +import { mcpReadResource } from "../../client.js"; +import { MCP_EXT_SKILLS } from "../../protocol/index.js"; +import { type } from "arktype"; +import { createSkillPublisher } from "../../extensions/skills/index.js"; +import { createMcpSkillConsumer } from "./skills.js"; + +const uri = "skill://large/SKILL.md"; +const RpcRequest = type({ id: "number", method: "string" }); +const origin = { + serverId: "large-server", + tenantId: "tenant", + principalId: "agent", + authorizationContextId: "grant", +}; + +// Deliberately sequential: exercise the actual manifest -> client -> decoder -> +// digest verification boundary without multiplying peak large-buffer usage. +for (const size of [4 * 1024 * 1024, 16 * 1024 * 1024]) { + for (const encoding of ["text", "blob"] as const) { + for (const sse of [false, true]) { + test(`skill ${size} bytes ${encoding} verifies (SSE=${sse})`, async () => { + const header = "---\nname: large\ndescription: Large skill\n---\n"; + const text = header + "x".repeat(size - header.length); + const bytes = new TextEncoder().encode(text); + const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, + ]); + const methods: string[] = []; + const consumer = createMcpSkillConsumer({ + origin, + url: "/mcp", + client: { + fetch: async (_input, init) => { + const request = RpcRequest.assert(JSON.parse(String(init?.body))); + methods.push(request.method); + const result = + request.method === "server/discover" + ? { + supportedVersions: ["2026-07-28"], + capabilities: { + resources: {}, + extensions: { "io.modelcontextprotocol/skills": {} }, + }, + } + : request.method === "skills/get" + ? publisher.get(uri) + : { + contents: [ + { + uri, + [encoding]: + encoding === "text" + ? text + : Buffer.from(bytes).toString("base64"), + }, + ], + }; + const json = JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result, + }); + const streaming = sse && request.method === "resources/read"; + const wire = new TextEncoder().encode( + streaming ? `data: ${json}\n\n` : json, + ); + let offset = 0; + return new Response( + new ReadableStream({ + pull(controller) { + if (offset === wire.length) { + controller.close(); + return; + } + const end = Math.min(offset + 64 * 1024, wire.length); + controller.enqueue(wire.subarray(offset, end)); + offset = end; + }, + }), + { + headers: { + "content-type": streaming + ? "text/event-stream" + : "application/json", + }, + }, + ); + }, + }, + }); + const held = await consumer.get(uri); + const verified = await consumer.verify(held); + expect(verified.bytes.byteLength).toBe(size); + expect( + Buffer.compare(Buffer.from(verified.bytes), Buffer.from(bytes)), + ).toBe(0); + expect(verified.trust).toBe("untrusted-mcp"); + expect(methods).toEqual([ + "server/discover", + "skills/get", + "resources/read", + ]); + }, 30_000); + } + } +} + +for (const size of [4 * 1024 * 1024, 16 * 1024 * 1024]) { + for (const encoding of ["text", "blob"] as const) { + test(`real Skills publisher/server/consumer ${size} ${encoding}`, async () => { + const header = "---\nname: large\ndescription: Large skill\n---\n"; + const text = header + "x".repeat(size - header.length); + const bytes = new TextEncoder().encode(text); + const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, + ]); + const providers = { ...createSkillProviders(publisher, "skills:large") }; + if (encoding === "text") + providers["resources/read"] = { + resource: "skills:large", + action: "read", + handle: () => ({ contents: [{ uri, text }] }), + }; + const server = createMcpServer({ + auth: "public", + serverInfo: { name: "large", version: "1" }, + extensions: { [MCP_EXT_SKILLS]: { directoryRead: true } }, + providers, + }); + try { + const consumer = createMcpSkillConsumer({ + origin, + url: "https://large.test/mcp", + client: { + fetch: async (input, init) => + server.fetch( + input instanceof Request + ? new Request(input, init) + : new Request(String(input), init), + ), + }, + }); + const verified = await consumer.verify(await consumer.get(uri)); + expect(verified.bytes.byteLength).toBe(size); + expect( + Buffer.compare(Buffer.from(verified.bytes), Buffer.from(bytes)), + ).toBe(0); + expect(verified.trust).toBe("untrusted-mcp"); + } finally { + await server.close(); + } + }, 30_000); + } +} + +test("server emission retains default limits and rejects oversized Skills payloads/metadata", async () => { + for (const kind of ["default", "decoded", "metadata"] as const) { + const publisher = await createSkillPublisher([ + { + uri, + files: new Map([ + [ + uri, + new TextEncoder().encode( + "---\nname: large\ndescription: Large skill\n---\n", + ), + ], + ]), + }, + ]); + const read = { + resource: "skills:large", + action: "read", + handle: () => ({ + contents: [ + { + uri, + text: "x".repeat( + kind === "default" + ? 4 * 1024 * 1024 + : kind === "decoded" + ? 16 * 1024 * 1024 + 1 + : 0, + ), + }, + ], + ...(kind === "metadata" + ? { _meta: { huge: "x".repeat(64 * 1024) } } + : {}), + }), + }; + const server = createMcpServer({ + auth: "public", + serverInfo: { name: "large", version: "1" }, + ...(kind === "default" + ? {} + : { extensions: { [MCP_EXT_SKILLS]: { directoryRead: true } } }), + providers: { + ...(kind === "default" + ? {} + : createSkillProviders(publisher, "skills:large")), + "resources/read": read, + }, + }); + try { + await expect( + mcpReadResource("https://large.test/mcp", uri, { + maxDecodedBytes: 16 * 1024 * 1024, + fetch: async (input, init) => + server.fetch( + input instanceof Request + ? new Request(input, init) + : new Request(String(input), init), + ), + }), + ).rejects.toThrow( + kind === "default" + ? "JSON byte limit" + : kind === "decoded" + ? "decoded byte limit" + : "metadata/envelope", + ); + } finally { + await server.close(); + } + } +}); diff --git a/src/interchange/client/skills.test.ts b/src/interchange/client/skills.test.ts new file mode 100644 index 0000000..fa41858 --- /dev/null +++ b/src/interchange/client/skills.test.ts @@ -0,0 +1,209 @@ +import { expect, test } from "bun:test"; +import { type } from "arktype"; +import { createSkillPublisher } from "../../extensions/skills/index.js"; +import { createMcpSkillConsumer } from "./skills.js"; + +const uri = "https://example.test/skills/example/SKILL.md"; +const bytes = new TextEncoder().encode( + "---\nname: example\ndescription: Example skill\n---\nUntrusted skill instructions: 漢字😀.\n", +); +const origin = { + serverId: "server", + tenantId: "tenant", + principalId: "agent", + authorizationContextId: "grant-version-1", +}; +const Request = type({ + id: "number", + method: "string", + params: { "uri?": "string" }, +}); + +async function fixture(resourceResult?: Record) { + const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, + ]); + const methods: string[] = []; + return { + methods, + client: { + fetch: async ( + _input: string | URL | globalThis.Request, + init?: RequestInit, + ) => { + const request = Request.assert(JSON.parse(String(init?.body))); + methods.push(request.method); + const result = + request.method === "server/discover" + ? { + supportedVersions: ["2026-07-28"], + capabilities: { + resources: {}, + extensions: { "io.modelcontextprotocol/skills": {} }, + }, + } + : request.method === "skills/get" + ? publisher.get(request.params.uri ?? "") + : (resourceResult ?? publisher.read(request.params.uri ?? "")); + return Response.json({ jsonrpc: "2.0", id: request.id, result }); + }, + }, + }; +} + +test("verified skill bytes are not activation when host policy is absent", async () => { + const { client, methods } = await fixture(); + const consumer = createMcpSkillConsumer({ origin, url: "/mcp", client }); + const held = await consumer.get(uri); + const verified = await consumer.verify(held); + expect(verified.bytes).toEqual(bytes); + expect(verified.trust).toBe("untrusted-mcp"); + expect(held.origin).toEqual(origin); + await expect(consumer.activate(held)).rejects.toThrow( + "host policy unavailable", + ); + expect(methods).toEqual(["server/discover", "skills/get", "resources/read"]); +}); + +test("host activation installs execution gate before context and removes context before release", async () => { + const { client } = await fixture(); + const events: string[] = []; + const consumer = createMcpSkillConsumer({ + origin, + url: "/mcp", + client, + host: { + approve: async (held) => { + events.push("approve"); + return { + identity: held.identity, + fingerprint: held.fingerprint, + approved: true, + execution: false, + }; + }, + openActingWindow: async () => { + events.push("gate"); + return { + admit: async () => { + events.push("admit"); + }, + removeContext: async () => { + events.push("remove"); + }, + close: async () => { + events.push("close"); + }, + }; + }, + }, + }); + const lease = await consumer.activate(await consumer.get(uri)); + expect(events).toEqual(["approve", "gate", "admit"]); + expect(() => lease.assertExecutionApproved()).toThrow("execution approval"); + await lease.release(); + expect(events).toEqual(["approve", "gate", "admit", "remove", "close"]); + await expect(lease.read(uri)).rejects.toThrow("closed"); +}); + +test("mismatched skill approval cannot admit context", async () => { + const { client } = await fixture(); + let admitted = false; + const consumer = createMcpSkillConsumer({ + origin, + url: "/mcp", + client, + host: { + approve: async (held) => ({ + identity: "another-principal", + fingerprint: held.fingerprint, + approved: true, + execution: true, + }), + openActingWindow: async () => { + admitted = true; + throw new Error("must not enter"); + }, + }, + }); + await expect(consumer.activate(await consumer.get(uri))).rejects.toThrow( + "denied or mismatched", + ); + expect(admitted).toBe(false); +}); + +for (const encoding of ["text", "blob"] as const) { + test(`skill ${encoding} manifest budget cancels before response EOF`, async () => { + const { client } = await fixture(); + const mediatedFetch = client.fetch; + let consumed = 0; + let eof = false; + let cancelled = false; + let wireLength = 0; + client.fetch = async (input, init) => { + const request = Request.assert(JSON.parse(String(init?.body))); + if (request.method !== "resources/read") + return mediatedFetch(input, init); + const payload = "x".repeat(200 * 1024); + const wire = new TextEncoder().encode( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { + contents: [ + { + uri, + [encoding]: encoding === "text" ? payload : btoa(payload), + }, + ], + }, + }), + ); + wireLength = wire.length; + return new Response( + new ReadableStream({ + pull(controller) { + if (consumed === wire.length) { + eof = true; + controller.close(); + return; + } + const end = Math.min(consumed + 1024, wire.length); + controller.enqueue(wire.slice(consumed, end)); + consumed = end; + }, + cancel() { + cancelled = true; + }, + }), + { headers: { "content-type": "application/json" } }, + ); + }; + const consumer = createMcpSkillConsumer({ origin, url: "/mcp", client }); + await expect(consumer.verify(await consumer.get(uri))).rejects.toThrow( + /limit|size|budget/, + ); + expect(cancelled).toBe(true); + expect(eof).toBe(false); + expect(consumed).toBeLessThan(wireLength / 4); + }); +} + +test("skill blob decoding accepts the exact UTF-8 manifest size", async () => { + const { client } = await fixture({ + contents: [{ uri, blob: Buffer.from(bytes).toString("base64") }], + }); + const consumer = createMcpSkillConsumer({ origin, url: "/mcp", client }); + expect((await consumer.verify(await consumer.get(uri))).bytes).toEqual(bytes); +}); +for (const resource of [ + { uri, blob: "a===" }, + { uri: "test://wrong", text: new TextDecoder().decode(bytes) }, + { uri, text: "short" }, +]) { + test(`skill final validation rejects ${JSON.stringify(resource)}`, async () => { + const { client } = await fixture({ contents: [resource] }); + const consumer = createMcpSkillConsumer({ origin, url: "/mcp", client }); + await expect(consumer.verify(await consumer.get(uri))).rejects.toThrow(); + }); +} diff --git a/src/interchange/client/skills.ts b/src/interchange/client/skills.ts new file mode 100644 index 0000000..8cf8d33 --- /dev/null +++ b/src/interchange/client/skills.ts @@ -0,0 +1,119 @@ +import { type } from "arktype"; +import { + mcpGetSkill, + mcpConnect, + mcpReadResource, + type McpClientOptions, +} from "../../client.js"; +import { validateSkill, MCP_EXT_SKILLS } from "../../protocol/index.js"; +import { + createSkillLoader, + type McpSkillOrigin, + type McpSkillHostPolicy, +} from "../../extensions/skills/index.js"; + +/** This host adapter accepts only one matching text/blob resource. The shared + * client validates the wire; this narrower shape is the byte-decoder boundary. */ +const TextResource = type({ uri: "string", text: "string" }); +const BlobResource = type({ uri: "string", blob: "string" }); + +/** Host-bound skill consumption. Supply a mediated client, never a raw token. + * Verification alone does not admit model context or authorize execution. */ +export function createMcpSkillConsumer(options: { + origin: McpSkillOrigin; + url: string; + client: McpClientOptions; + host?: McpSkillHostPolicy; +}) { + const origin = Object.freeze(structuredClone(options.origin)); + const client = { + ...options.client, + extensions: [ + ...new Set([...(options.client.extensions ?? []), MCP_EXT_SKILLS]), + ], + }; + let ready: Promise | undefined; + function connect(): Promise { + ready ??= (async () => ({ + ...client, + session: client.session ?? (await mcpConnect(options.url, client)), + }))().catch((cause: unknown) => { + ready = undefined; + throw cause; + }); + return ready; + } + const loader = createSkillLoader( + { + async get(_origin, uri) { + const result = await mcpGetSkill(options.url, uri, await connect()); + return validateSkill(result.skill); + }, + async read(_origin, uri, maxBytes) { + const result = await mcpReadResource(options.url, uri, { + ...(await connect()), + maxDecodedBytes: maxBytes, + }); + if (result.resultType !== "complete") + throw new Error( + "Skill resource requires unsupported host interaction", + ); + if (result.contents.length !== 1) + throw new Error( + "Skill resource must contain exactly one matching item", + ); + const item = result.contents[0]; + const text = TextResource(item); + const blob = BlobResource(item); + let bytes: Uint8Array; + if ( + !(text instanceof type.errors) && + blob instanceof type.errors && + text.uri === uri + ) { + if (Buffer.byteLength(text.text, "utf8") > maxBytes) + throw new Error("Skill resource exceeds manifest size"); + bytes = new TextEncoder().encode(text.text); + } else if ( + !(blob instanceof type.errors) && + text instanceof type.errors && + blob.uri === uri + ) { + // Avoid a repeated-group regex: engines can exhaust their matching + // budget on valid multi-megabyte blobs. Scan alphabet and padding separately. + const padding = blob.blob.indexOf("="); + if ( + blob.blob.length % 4 !== 0 || + /[^A-Za-z0-9+/=]/u.test(blob.blob) || + (padding !== -1 && + (padding < blob.blob.length - 2 || + !/^={1,2}$/u.test(blob.blob.slice(padding)))) + ) + throw new Error("Invalid skill resource base64"); + const decodedLength = + (blob.blob.length / 4) * 3 - + (blob.blob.endsWith("==") ? 2 : blob.blob.endsWith("=") ? 1 : 0); + if (decodedLength > maxBytes) + throw new Error("Skill resource exceeds manifest size"); + bytes = Uint8Array.from(atob(blob.blob), (character) => + character.charCodeAt(0), + ); + } else + throw new Error( + "Skill resource must contain matching text or blob bytes", + ); + if (bytes.byteLength > maxBytes) + throw new Error("Skill resource exceeds manifest size"); + return bytes; + }, + }, + options.host, + ); + return { + /** Refetching a changed entry invalidates its previous approval. */ + get: (uri: string) => loader.get(origin, uri), + verify: loader.verify, + activate: loader.activate, + clear: loader.clear, + }; +} diff --git a/src/interchange/server.ts b/src/interchange/server.ts new file mode 100644 index 0000000..abc4f3b --- /dev/null +++ b/src/interchange/server.ts @@ -0,0 +1,233 @@ +import { type } from "arktype"; +import { McpError, validateInstance } from "../protocol/index.js"; +import { createToolProviders } from "../server/tools.js"; +import type { + McpOperationOutcome, + McpProviders, + McpRequestContext, +} from "../server/contracts.js"; +import type { + InterchangeExposure, + InterchangeServerOptions, +} from "./server/contracts.js"; +export type * from "./server/contracts.js"; + +const Trigger = type({ + runId: "string > 0", + address: "string > 0", + messageId: "string > 0", +}); +const textResult = (text: string): McpOperationOutcome => ({ + content: [{ type: "text", text }], +}); +const jsonResult = (value: unknown): McpOperationOutcome => ({ + content: [{ type: "text", text: JSON.stringify(value) }], + ...(value !== null && typeof value === "object" && !Array.isArray(value) + ? { structuredContent: value } + : {}), +}); + +function boundUrl(origin: string, path: string): URL { + const base = new URL(origin); + if ( + !/^https?:$/.test(base.protocol) || + base.username || + base.password || + base.pathname !== "/" || + base.search || + base.hash + ) + throw new Error("Expected a fixed HTTP origin"); + if (!path.startsWith("/") || path.startsWith("//") || /[\\?#{}]/u.test(path)) + throw new Error("Expected a fully bound operation path"); + for (const segment of path.split("/")) { + const decoded = decodeURIComponent(segment); + if ( + decoded === "." || + decoded === ".." || + /[/\\%]/u.test(decoded) || + [...decoded].some((character) => character.charCodeAt(0) <= 32) + ) + throw new Error("Unsafe operation path"); + } + const url = new URL(path, base); + if (url.origin !== base.origin) throw new Error("Operation origin mismatch"); + return url; +} + +async function responseJson(response: Response): Promise { + if (!response.ok) + throw new McpError("Interchange operation failed", 502, -32603); + if (response.status === 204) return {}; + const body = await response.text(); + if (response.status === 202 && body.length === 0) { + const location = response.headers.get("location"); + // Acceptance is not completion/cancellation; keep the polling hint without following it. + return { + status: 202, + accepted: true, + ...(location === null ? {} : { location }), + }; + } + if ( + response.headers.get("content-type")?.split(";")[0]?.trim() !== + "application/json" + ) + throw new McpError( + "Unsupported Interchange response content type", + 502, + -32603, + ); + return JSON.parse(body) as unknown; +} + +/** Explicit allowlist only: no hub scanning, factory enumeration, or grant widening. */ +export function createInterchangeProviders( + options: InterchangeServerOptions, +): McpProviders { + const bindings = options.exposures.map((exposure) => { + if (!exposure.tenantId || typeof exposure.approve !== "function") + throw new Error("Tenant binding and approval service required"); + if (exposure.kind === "operation") { + boundUrl(exposure.origin, exposure.path); + if (exposure.method === "GET" && exposure.body) + throw new Error("GET request bodies are unsupported"); + } + if (exposure.kind === "workflow") + boundUrl(exposure.origin, workflowPath(exposure)); + if (exposure.kind === "tool") { + const selected = exposure.bundle.definitions.filter( + (definition) => definition.name === exposure.toolName, + ); + if (selected.length !== 1) + throw new Error( + "Tool selection must identify one installed definition", + ); + } + return { + definition: exposure.definition, + resource: exposure.resource, + action: exposure.action, + visible: (context: McpRequestContext) => + context.identity?.tenantId === exposure.tenantId, + async execute( + args: Record, + context: McpRequestContext, + params: Record, + ): Promise { + if ( + !context.identity || + context.identity.tenantId !== exposure.tenantId + ) + throw new McpError( + "Identity is not authorized for this binding", + 403, + -32602, + ); + if (!(await exposure.approve(args, context))) + throw new McpError("Invocation approval required", 403, -32602); + context.signal.throwIfAborted(); + if ( + params.inputResponses !== undefined || + params.requestState !== undefined + ) { + if (!exposure.continue) + throw new McpError("Continuation unavailable", 400, -32602); + return exposure.continue(params, context); + } + switch (exposure.kind) { + case "tool": { + const selected = exposure.bundle.definitions.find( + (definition) => definition.name === exposure.toolName, + ); + if (!selected) + throw new Error("Selected tool is no longer available"); + validateInstance(selected.inputSchema, args); + const result = await exposure.bundle.run( + { + id: String(context.requestId), + name: exposure.toolName, + arguments: args, + }, + context.signal, + ); + if (exposure.projectResult) + return exposure.projectResult(result, context); + if (result.pendingMarker) + throw new McpError( + "Pending tool requires a lifecycle projection", + 500, + -32603, + ); + // detail is deliberately host-only in Interchange; never leak it implicitly. + return { + ...(typeof result.content === "string" + ? textResult(result.content) + : jsonResult(result.content)), + ...(result.isError === undefined + ? {} + : { isError: result.isError }), + }; + } + case "agent": { + // Agent.send aborts the waiter, not the shared reactor. Never close a shared agent. + const result = await exposure.agent.send(exposure.message(args), { + signal: context.signal, + }); + return result.type === "reply" + ? textResult(result.reply) + : exposure.suspended(result, context); + } + case "operation": { + const body = exposure.body?.(args); + const response = await exposure.dispatch( + new Request(boundUrl(exposure.origin, exposure.path).href, { + method: exposure.method, + signal: context.signal, + redirect: "error", + ...(body === undefined + ? {} + : { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + }), + context.identity, + ); + return jsonResult(await responseJson(response)); + } + case "workflow": { + const intentId = await exposure.prepare(args, context); + if (!intentId) throw new Error("Durable dispatch intent required"); + context.signal.throwIfAborted(); + const response = await exposure.dispatch( + new Request( + boundUrl(exposure.origin, workflowPath(exposure)).href, + { + method: "POST", + signal: context.signal, + redirect: "error", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: exposure.message(args) }), + }, + ), + context.identity, + ); + const trigger = Trigger(await responseJson(response)); + if (trigger instanceof type.errors) + throw new Error("Invalid workflow trigger response"); + await exposure.recordTrigger(intentId, trigger, context); + return exposure.selectResult(trigger, context); + } + } + }, + }; + }); + return createToolProviders(bindings, options.auth); +} + +function workflowPath( + exposure: Extract, +): string { + return `/api/tenants/${encodeURIComponent(exposure.tenantId)}/workflows/${encodeURIComponent(exposure.deploymentId)}/mail`; +} diff --git a/src/interchange/server/accepted.test.ts b/src/interchange/server/accepted.test.ts new file mode 100644 index 0000000..20131c5 --- /dev/null +++ b/src/interchange/server/accepted.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { TenantEnv, TenantRow, PrincipalRow } from "@intx/hub-api"; +// Test-only import of the installed implementation; product code uses host dispatch. +import { + createRunRoutes, + type CreateRunRoutesDeps, +} from "../../../node_modules/@intx/hub-api/dist/routes/runs.js"; +import { createMcpServer } from "../../server.js"; +import { createInterchangeProviders } from "../server.js"; +import { rpc, result } from "../../server/fixtures.js"; +import type { McpAuthPolicy } from "../../auth/contracts.js"; + +/** Fail loudly if the installed route touches a service outside this test's injected surface. */ +function service(members: Partial = {}): T { + return new Proxy(members as T, { + get(target, key, receiver) { + if (!Reflect.has(target, key)) + throw new Error(`Unexpected test service access: ${String(key)}`); + return Reflect.get(target, key, receiver); + }, + }); +} +const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grants", + scopes: [], +}; +const auth: McpAuthPolicy = { + authenticate: () => identity, + authorize: () => true, +}; +const definition = { + name: "cancel", + inputSchema: { type: "object", additionalProperties: false }, +}; + +test("installed hub DELETE route acceptance preserves polling location without claiming cancellation", async () => { + let cancellations = 0; + const deps: CreateRunRoutesDeps = { + db: service(), + principalKeyStore: service(), + sessionService: service(), + sidecarRouter: service(), + eventCollectors: service(), + repoStore: null, + grantStore: service(), + conditionRegistry: {}, + approvalStore: service(), + requireGrant: (_resource, action) => async (_context, next) => { + expect(action).toBe("manage"); + await next(); + }, + workflowLifecycleService: service< + NonNullable + >({ + async requestCancellation(tenantId, runId, reason) { + expect([tenantId, runId, reason]).toEqual([ + "tenant", + "run", + "Cancellation requested by principal", + ]); + cancellations++; + return "pending"; + }, + }), + }; + const app = new Hono(); + app.use("*", async (context, next) => { + context.set("tenant", service({ id: identity.tenantId })); + context.set( + "principal", + service({ id: identity.principalId }), + ); + await next(); + }); + app.route("/api/tenants/tenant/workflows/runs", createRunRoutes(deps)); + const endpoint = createMcpServer({ + serverInfo: { name: "installed-route", version: "1" }, + auth, + providers: createInterchangeProviders({ + auth, + exposures: [ + { + kind: "operation", + definition, + tenantId: "tenant", + resource: "workflow-run:run", + action: "manage", + approve: async () => true, + method: "DELETE", + origin: "https://hub.example", + path: "/api/tenants/tenant/workflows/runs/run", + dispatch: async (request, boundIdentity) => { + expect(boundIdentity).toEqual(identity); + return app.fetch(request); + }, + }, + ], + }), + }); + try { + const response = await result( + await endpoint.fetch( + rpc("tools/call", { name: "cancel", arguments: {} }), + ), + ); + expect(cancellations).toBe(1); + expect(response).toMatchObject({ + result: { + resultType: "complete", + structuredContent: { + status: 202, + accepted: true, + location: "/api/tenants/tenant/workflows/runs/run/lifecycle", + }, + }, + }); + expect(JSON.stringify(response)).not.toContain('"cancelled"'); + } finally { + await endpoint.close(); + } +}); + +test("empty accepted signal response is success without terminal-state assertion", async () => { + const endpoint = createMcpServer({ + serverInfo: { name: "signal", version: "1" }, + auth, + providers: createInterchangeProviders({ + auth, + exposures: [ + { + kind: "operation", + definition, + tenantId: "tenant", + resource: "workflow-run:run", + action: "manage", + approve: async () => true, + method: "POST", + origin: "https://hub.example", + path: "/api/tenants/tenant/workflows/run/signals", + dispatch: async () => new Response(null, { status: 202 }), + }, + ], + }), + }); + try { + expect( + await result( + await endpoint.fetch( + rpc("tools/call", { name: "cancel", arguments: {} }), + ), + ), + ).toMatchObject({ + result: { structuredContent: { status: 202, accepted: true } }, + }); + } finally { + await endpoint.close(); + } +}); diff --git a/src/interchange/server/contracts.ts b/src/interchange/server/contracts.ts new file mode 100644 index 0000000..2c0da97 --- /dev/null +++ b/src/interchange/server/contracts.ts @@ -0,0 +1,97 @@ +import type { Agent, ToolBundle } from "@intx/agent"; +import type { ToolResult } from "@intx/types/runtime"; +import type { McpIdentity, McpAuthPolicy } from "../../auth/contracts.js"; +import type { + McpOperationOutcome, + McpRequestContext, +} from "../../server/contracts.js"; +import type { McpToolBinding } from "../../server/tools.js"; + +type BaseExposure = { + definition: McpToolBinding["definition"]; + tenantId: string; + resource: string; + action: string; + /** Host approval/grant floor. Mandatory even for a bare ToolBundle dispatcher. */ + approve: ( + args: Record, + context: McpRequestContext, + ) => Promise; + /** Host must bind continuations to identity, operation, arguments and one-time state. */ + continue?: ( + params: Record, + context: McpRequestContext, + ) => Promise; +}; + +export type InterchangeToolExposure = BaseExposure & { + kind: "tool"; + bundle: Pick; + toolName: string; + /** Explicit mapping for pending markers and full-fidelity detail; no implicit approval. */ + projectResult?: ( + result: ToolResult, + context: McpRequestContext, + ) => Promise; +}; +export type InterchangeAgentExposure = BaseExposure & { + kind: "agent"; + agent: Pick; + message: (args: Record) => string; + suspended: ( + result: Extract>, { type: "suspended" }>, + context: McpRequestContext, + ) => Promise; +}; + +/** Host-authenticated app pipeline; the adapter never forwards MCP bearer headers. */ +export type InterchangeDispatch = ( + request: Request, + identity: McpIdentity, +) => Promise; +export type InterchangeHttpExposure = BaseExposure & { + kind: "operation"; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Fully bound path. No model-controlled tenant, host, path or authorization headers. */ + path: string; + origin: string; + dispatch: InterchangeDispatch; + /** Authored projection, not automatic exposure of OpenAPI or an entire hub. */ + body?: (args: Record) => Record; +}; +export type WorkflowTrigger = { + runId: string; + address: string; + messageId: string; +}; +export type InterchangeWorkflowExposure = BaseExposure & { + kind: "workflow"; + deploymentId: string; + origin: string; + dispatch: InterchangeDispatch; + message: (args: Record) => string; + /** Must durably persist dispatch intent; ambiguous dispatch is never automatically replayed. */ + prepare: ( + args: Record, + context: McpRequestContext, + ) => Promise; + recordTrigger: ( + intentId: string, + trigger: WorkflowTrigger, + context: McpRequestContext, + ) => Promise; + /** Select only this message's result, or durably create a task bound to its correlation. */ + selectResult: ( + trigger: WorkflowTrigger, + context: McpRequestContext, + ) => Promise; +}; +export type InterchangeExposure = + | InterchangeToolExposure + | InterchangeAgentExposure + | InterchangeHttpExposure + | InterchangeWorkflowExposure; +export type InterchangeServerOptions = { + auth: McpAuthPolicy; + exposures: readonly InterchangeExposure[]; +}; diff --git a/src/interchange/server/server.test.ts b/src/interchange/server/server.test.ts new file mode 100644 index 0000000..02ab341 --- /dev/null +++ b/src/interchange/server/server.test.ts @@ -0,0 +1,275 @@ +import { expect, test } from "bun:test"; +import { stringTool } from "@intx/agent"; +import { Hono } from "hono"; +import { createInterchangeProviders } from "../server.js"; +import { createMcpServer } from "../../server.js"; +import { rpc, result } from "../../server/fixtures.js"; +import type { + InterchangeExposure, + InterchangeToolExposure, +} from "./contracts.js"; +import type { McpAuthPolicy } from "../../auth/contracts.js"; + +const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grants", + scopes: [], +}; +const auth: McpAuthPolicy = { + authenticate: () => identity, + authorize: () => true, +}; +const definition = { + name: "selected", + description: "Selected operation", + inputSchema: { + type: "object", + properties: { content: { type: "string" } }, + required: ["content"], + additionalProperties: false, + }, +}; +const base = { + definition, + tenantId: "tenant", + resource: "tool:selected", + action: "call", + approve: async () => true, +}; +function server(exposures: InterchangeExposure[], policy = auth) { + return createMcpServer({ + serverInfo: { name: "interchange", version: "1" }, + auth: policy, + providers: createInterchangeProviders({ auth: policy, exposures }), + }); +} + +test("installed AgentTool handler is exposed only by explicit selection and preserves ask gate", async () => { + let calls = 0; + const installed = stringTool({ + definition, + handler: async (args) => { + calls++; + return String(args.content); + }, + }); + if (installed.kind !== "string") throw new Error("Expected string tool"); + const exposure: InterchangeToolExposure = { + ...base, + kind: "tool", + toolName: "selected", + bundle: { + definitions: [definition, { ...definition, name: "private" }], + run: async (call, signal) => ({ + callId: call.id, + content: await installed.handler(call.arguments, signal), + }), + }, + }; + const endpoint = server([exposure]); + expect(await result(await endpoint.fetch(rpc("tools/list")))).toMatchObject({ + result: { tools: [{ name: "selected" }] }, + }); + expect( + await result( + await endpoint.fetch( + rpc("tools/call", { + name: "selected", + arguments: { content: "hello" }, + }), + ), + ), + ).toMatchObject({ result: { content: [{ text: "hello" }] } }); + const denied = server([{ ...exposure, approve: async () => false }]); + expect( + await result( + await denied.fetch( + rpc("tools/call", { name: "selected", arguments: { content: "no" } }), + ), + ), + ).toHaveProperty("error"); + expect(calls).toBe(1); + const otherTenant = server([exposure], { + ...auth, + authenticate: () => ({ ...identity, tenantId: "other" }), + }); + expect( + await result(await otherTenant.fetch(rpc("tools/list"))), + ).toMatchObject({ result: { tools: [] } }); + await endpoint.close(); + await denied.close(); + await otherTenant.close(); +}); + +test("authored operation invokes Hono pipeline with bound tenant and no MCP bearer forwarding", async () => { + const app = new Hono(); + let calls = 0; + app.post("/api/tenants/tenant/selected", async (context) => { + calls++; + expect(context.req.header("authorization")).toBeUndefined(); + return context.json({ accepted: await context.req.json() }); + }); + const endpoint = server([ + { + ...base, + kind: "operation", + method: "POST", + origin: "https://hub.example", + path: "/api/tenants/tenant/selected", + body: (args) => ({ content: args.content }), + dispatch: async (request, principal) => { + expect(principal).toEqual(identity); + return app.fetch(request); + }, + }, + ]); + expect( + await result( + await endpoint.fetch( + rpc( + "tools/call", + { name: "selected", arguments: { content: "hello" } }, + { Authorization: "Bearer mcp-only" }, + ), + ), + ), + ).toMatchObject({ + result: { structuredContent: { accepted: { content: "hello" } } }, + }); + expect( + await result( + await endpoint.fetch( + rpc("tools/call", { + name: "selected", + arguments: { content: "hello", tenantId: "evil" }, + }), + ), + ), + ).toHaveProperty("error"); + expect(calls).toBe(1); + await endpoint.close(); +}); + +test("workflow persists intent before actual mail route and selects only correlated result", async () => { + const events: string[] = []; + const endpoint = server([ + { + ...base, + kind: "workflow", + origin: "https://hub.example", + deploymentId: "deployment", + message: (args) => String(args.content), + prepare: async () => { + events.push("persist-intent"); + return "intent-1"; + }, + dispatch: async (request) => { + events.push("dispatch"); + expect(new URL(request.url).pathname).toBe( + "/api/tenants/tenant/workflows/deployment/mail", + ); + expect(await request.json()).toEqual({ content: "hello" }); + return Response.json( + { runId: "run-1", address: "run@example", messageId: "message-1" }, + { status: 202 }, + ); + }, + recordTrigger: async (intent, trigger) => { + expect(intent).toBe("intent-1"); + expect(trigger.messageId).toBe("message-1"); + events.push("persist-correlation"); + }, + selectResult: async (trigger) => { + expect(trigger.runId).toBe("run-1"); + events.push("select"); + return { content: [{ type: "text", text: "correlated answer" }] }; + }, + }, + ]); + expect( + await result( + await endpoint.fetch( + rpc("tools/call", { + name: "selected", + arguments: { content: "hello" }, + }), + ), + ), + ).toMatchObject({ result: { content: [{ text: "correlated answer" }] } }); + expect(events).toEqual([ + "persist-intent", + "dispatch", + "persist-correlation", + "select", + ]); + await endpoint.close(); +}); + +test("agent suspension is delegated without approval fabrication or shared agent close", async () => { + let suspended = false; + const endpoint = server([ + { + ...base, + kind: "agent", + message: (args) => String(args.content), + agent: { + send: async (_content, options) => { + expect(options?.signal).toBeInstanceOf(AbortSignal); + return { type: "suspended", correlationId: "approval-1" }; + }, + }, + suspended: async (pending) => { + suspended = true; + expect(pending.correlationId).toBe("approval-1"); + return { content: [{ type: "text", text: "Awaiting host approval" }] }; + }, + }, + ]); + await result( + await endpoint.fetch( + rpc("tools/call", { name: "selected", arguments: { content: "hello" } }), + ), + ); + expect(suspended).toBe(true); + await endpoint.close(); +}); + +test("traversal, unbound paths, cross-origin paths and unresolved schemas fail before publication", () => { + for (const path of [ + "//evil.example/x", + "/api/../admin", + "/api/%2e%2e/admin", + "/api/{tenantId}/x", + "/api/%252e%252e/admin", + ]) { + expect(() => + server([ + { + ...base, + kind: "operation", + method: "POST", + origin: "https://hub.example", + path, + dispatch: async () => Response.json({}), + }, + ]), + ).toThrow(); + } + expect(() => + server([ + { + ...base, + definition: { + ...definition, + inputSchema: { $ref: "https://evil.example/schema" }, + }, + kind: "operation", + method: "GET", + origin: "https://hub.example", + path: "/safe", + dispatch: async () => Response.json({}), + }, + ]), + ).toThrow(); +}); diff --git a/src/naming.ts b/src/naming.ts index ca8a2b8..7e7fd6d 100644 --- a/src/naming.ts +++ b/src/naming.ts @@ -2,6 +2,8 @@ // the deployed `mcpServers` sidecar bundle so both project a server's catalog // onto identical tool names and identical approval marks. +import { validateUiToolMeta } from "./protocol/ui.js"; +import { isToolVisibleToModel } from "./extensions/apps.js"; import type { McpTool } from "./client.js"; import { parseMcpEndpoint } from "./url.js"; @@ -76,3 +78,11 @@ export function isAskExempt( if (tool.annotations?.destructiveHint === true) return false; return allowWithoutAsk.includes(qualified); } + +/** App-only tools never enter model definitions or the model dispatcher. */ +export function isModelVisible(tool: McpTool): boolean { + const ui = tool._meta?.ui; + return isToolVisibleToModel( + ui === undefined ? undefined : validateUiToolMeta(ui), + ); +} diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts index ec232ba..4b634c5 100644 --- a/src/sidecar-bundle.test.ts +++ b/src/sidecar-bundle.test.ts @@ -23,10 +23,10 @@ afterEach(() => { }); const CATALOG: McpTool[] = [ - { name: "echo", description: "Echoes.", inputSchema: {} }, + { name: "echo", description: "Echoes.", inputSchema: { type: "object" } }, { name: "delete_thing", - inputSchema: {}, + inputSchema: { type: "object" }, annotations: { destructiveHint: true }, }, ]; @@ -315,7 +315,10 @@ describe("mcpServers round trip through a mediated handle", () => { }); describe("catalog names", () => { - const tool = (name: string): McpTool => ({ name, inputSchema: {} }); + const tool = (name: string): McpTool => ({ + name, + inputSchema: { type: "object" }, + }); test("a catalog listing a tool twice is refused", () => { expect(() => diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts index 3ef9333..95c1cbd 100644 --- a/src/sidecar-bundle.ts +++ b/src/sidecar-bundle.ts @@ -25,16 +25,24 @@ import { type McpClientOptions, McpToolSchema, type McpTool, - type McpToolCallResult, } from "./client.js"; import { assertCatalogNames, assertServers, isAskExempt, + isModelVisible, qualifiedName, toolDescription, } from "./naming.js"; +import { + assertHostExtensions, + hostClientOptions, +} from "./interchange/client/capabilities.js"; +import { projectToolResult } from "./interchange/client/projection.js"; +import type { McpHostServices } from "./interchange/client/contracts.js"; +export { shapeMcpContent } from "./interchange/client/projection.js"; + /** The consumer key a host matches a credential binding against. */ export const SIDECAR_BUNDLE_ID = "@corbits/mcp/sidecar-bundle"; @@ -76,6 +84,8 @@ export interface McpBoundServer { } export interface McpServersConfig { + /** Explicit host lifecycle and presentation services. */ + readonly host?: McpHostServices; readonly servers: readonly McpBoundServer[]; /** Per-request bound on connect and `tools/call`; defaults to 60s. */ readonly timeoutMs?: number; @@ -118,51 +128,6 @@ function assertConfig(config: McpServersConfig): void { } } -const TextBlock = type({ type: "'text'", text: "string" }).array(); - -/** - * MCP results are never interpreted: text blocks come back as their text so - * the model reads prose, and anything else is handed over verbatim as JSON. - */ -function shapedToolResult( - callId: string, - result: McpToolCallResult, -): { callId: string; isError: boolean; content: string } { - if (result.resultType === "input_required") { - return { - callId, - isError: true, - content: JSON.stringify({ - resultType: result.resultType, - method: result.method, - ...(result.inputRequests !== undefined - ? { inputRequests: result.inputRequests } - : {}), - ...(result.requestState !== undefined - ? { requestState: result.requestState } - : {}), - }), - }; - } - if (result.resultType === "task") { - return { callId, isError: false, content: JSON.stringify(result) }; - } - return { - callId, - isError: result.isError === true, - content: shapeMcpContent(result.content), - }; -} - -export function shapeMcpContent(content: unknown): string { - if (typeof content === "string") return content; - const blocks = TextBlock(content); - if (!(blocks instanceof type.errors)) { - return blocks.map((block) => block.text).join("\n"); - } - return JSON.stringify(content); -} - /** A mediated handle resolves a relative path against the origin it is pinned * to, so the bundle passes the server's path and never names a host. */ function requestPath(url: string): string { @@ -186,11 +151,12 @@ export function mcpServers( ): AnnotatedToolFactory { assertConfig(config); const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + assertHostExtensions(config.extensions, config.host); const entries = config.servers.flatMap((server) => - server.tools.map((tool) => ({ - server, - tool, + server.tools.filter(isModelVisible).map((tool) => ({ + server: { ...server }, + tool: structuredClone(tool), qualified: qualifiedName(server.handle, tool.name), })), ); @@ -238,20 +204,19 @@ export function mcpServers( } const mediated: MediatedFetch = (input, init) => credential.fetch(input, init); - const session = await mcpConnect(requestPath(runtime.server.url), { + const opts: McpClientOptions = { fetch: mediated, timeoutMs, - ...(config.extensions !== undefined - ? { extensions: config.extensions } - : {}), - }); - return { - fetch: mediated, - session, + ...hostClientOptions(config.host, config.extensions), ...(config.extensions !== undefined ? { extensions: config.extensions } : {}), }; + const session = await mcpConnect( + requestPath(runtime.server.url), + opts, + ); + return { ...opts, session }; })().catch((cause: unknown) => { runtime.ready = undefined; runtime.handle = undefined; @@ -274,23 +239,38 @@ export function mcpServers( }; } try { - const client = await connect(runtime); + const context = { + server: found.server.handle, + url: requestPath(found.server.url), + tool: found.qualified, + callId: call.id, + signal, + }; + await config.host?.validateCatalog?.(context, found.tool); + const client: McpClientOptions = { + ...(await connect(runtime)), + signal, + inputSchema: found.tool.inputSchema, + ...(found.tool.outputSchema === undefined + ? {} + : { outputSchema: found.tool.outputSchema }), + }; const result = await mcpCallTool( requestPath(found.server.url), found.tool.name, call.arguments, - { - ...client, - timeoutMs, - signal, - inputSchema: found.tool.inputSchema, - }, + client, ).catch((cause: unknown) => { // The session may have expired; the next call opens a new one. runtime.ready = undefined; throw cause; }); - return shapedToolResult(call.id, result); + return await projectToolResult( + context, + result, + client, + config.host, + ); } catch (cause) { // One server's failure is that server's tool error, never the // agent's: every other bound server keeps working. diff --git a/src/tool.test.ts b/src/tool.test.ts index 6efc104..704c879 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -18,10 +18,10 @@ afterEach(() => { }); describe("ask-mark derivation (CL-8392)", () => { - const safeTool: McpTool = { name: "echo", inputSchema: {} }; + const safeTool: McpTool = { name: "echo", inputSchema: { type: "object" } }; const destructiveTool: McpTool = { name: "delete_thing", - inputSchema: {}, + inputSchema: { type: "object" }, annotations: { destructiveHint: true }, }; diff --git a/src/tool.ts b/src/tool.ts index 90b005d..0b44d8c 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -19,16 +19,23 @@ import { mcpListTools, type McpClientOptions, type McpTool, - type McpToolCallResult, } from "./client.js"; import { assertCatalogNames, assertServers, isAskExempt, + isModelVisible, qualifiedName, toolDescription, } from "./naming.js"; +import { + assertHostExtensions, + hostClientOptions, +} from "./interchange/client/capabilities.js"; +import { projectToolResult } from "./interchange/client/projection.js"; +import type { McpHostServices } from "./interchange/client/contracts.js"; + export interface McpServerConfig { /** Namespace prefix for this server's tools: `.`. */ name: string; @@ -48,6 +55,8 @@ export interface McpToolsEnv extends BaseEnv { export interface McpToolsOptions { servers: McpServerConfig[]; + /** Explicit host lifecycle and presentation services. */ + host?: McpHostServices; /** * `.` names allowed to float below the `ask` floor onto * `allow` from a resolved `allow` grant. Every generated tool starts @@ -65,39 +74,6 @@ export interface McpToolsOptions { extensions?: readonly string[]; } -function toolResult( - callId: string, - result: McpToolCallResult, -): { callId: string; isError: boolean; content: string } { - if (result.resultType === "input_required") { - return { - callId, - isError: true, - content: JSON.stringify({ - resultType: result.resultType, - method: result.method, - ...(result.inputRequests !== undefined - ? { inputRequests: result.inputRequests } - : {}), - ...(result.requestState !== undefined - ? { requestState: result.requestState } - : {}), - }), - }; - } - if (result.resultType === "task") { - return { callId, isError: false, content: JSON.stringify(result) }; - } - return { - callId, - isError: result.isError === true, - content: - typeof result.content === "string" - ? result.content - : JSON.stringify(result.content), - }; -} - async function resolveFetch( server: McpServerConfig, credentials: CredentialCapability | undefined, @@ -132,6 +108,7 @@ export async function mcpTools( ): Promise> { const allowWithoutAsk = options.allowWithoutAsk ?? []; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + assertHostExtensions(options.extensions, options.host); assertServers(options.servers, "MCP server name"); const discovered: DiscoveredTool[] = []; @@ -140,6 +117,7 @@ export async function mcpTools( const clientOpts = { ...(clientFetch !== undefined ? { fetch: clientFetch } : {}), timeoutMs, + ...hostClientOptions(options.host, options.extensions), ...(options.extensions !== undefined ? { extensions: options.extensions } : {}), @@ -147,10 +125,10 @@ export async function mcpTools( const session = await mcpConnect(server.url, clientOpts); const tools = await mcpListTools(server.url, { ...clientOpts, session }); assertCatalogNames(server.name, tools); - for (const tool of tools) { + for (const tool of tools.filter(isModelVisible)) { discovered.push({ - server, - tool, + server: { ...server }, + tool: structuredClone(tool), qualified: qualifiedName(server.name, tool.name), }); } @@ -195,6 +173,7 @@ export async function mcpTools( const opts = { ...(clientFetch !== undefined ? { fetch: clientFetch } : {}), timeoutMs, + ...hostClientOptions(options.host, options.extensions), ...(options.extensions !== undefined ? { extensions: options.extensions } : {}), @@ -219,19 +198,37 @@ export async function mcpTools( }; } try { - const result = await connect(found.server) - .then((client) => - mcpCallTool(found.server.url, found.tool.name, call.arguments, { - ...client, - signal, - inputSchema: found.tool.inputSchema, - }), - ) - .catch((cause: unknown) => { - clientByServer.delete(found.server.name); - throw cause; - }); - return toolResult(call.id, result); + const context = { + server: found.server.name, + url: found.server.url, + tool: found.qualified, + callId: call.id, + signal, + }; + await options.host?.validateCatalog?.(context, found.tool); + const client: McpClientOptions = { + ...(await connect(found.server)), + signal, + inputSchema: found.tool.inputSchema, + ...(found.tool.outputSchema === undefined + ? {} + : { outputSchema: found.tool.outputSchema }), + }; + const result = await mcpCallTool( + found.server.url, + found.tool.name, + call.arguments, + client, + ).catch((cause: unknown) => { + clientByServer.delete(found.server.name); + throw cause; + }); + return await projectToolResult( + context, + result, + client, + options.host, + ); } catch (err) { return { callId: call.id, From 46fddd0ada8fcd1dc2ad5f4057cb415bd0689a66 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:38 -0700 Subject: [PATCH 16/28] build!: expose role entries and require explicit oauth peer installs Publish standalone client/server and Interchange entries plus the explicit file task store. Declare AJV and YAML runtimes and make host peers optional for neutral consumers. Root and guarded login consumers must now install OAuth-core explicitly; no version bump is included. --- bun.lock | 24 +++++++++++++++++++----- package.json | 43 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 4d4d3d8..3ad002d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,14 @@ "": { "name": "@corbits/mcp", "dependencies": { - "@corbits/oauth-core": "^0.3.0", "@intx/types": "^0.5.0", + "ajv": "^8.17.1", "arktype": "^2.2.3", + "yaml": "2.8.1", }, "devDependencies": { "@corbits/credential-http": "0.1.0", + "@corbits/oauth-core": "0.3.0", "@intx/agent": "0.5.0", "@intx/authz": "0.5.0", "@intx/db": "0.5.0", @@ -27,16 +29,20 @@ }, "peerDependencies": { "@corbits/credential-http": "^0.1.0", + "@corbits/oauth-core": "^0.3.0", "@intx/agent": "^0.5.0", "@intx/db": "^0.5.0", - "@intx/harness": "^0.5.0", + "@intx/harness": "^0.4.0 || ^0.5.0", "@intx/hub-api": "^0.5.0", "drizzle-orm": "^0.45.2", "hono": "^4.11.9", }, "optionalPeers": [ "@corbits/credential-http", + "@corbits/oauth-core", + "@intx/agent", "@intx/db", + "@intx/harness", "@intx/hub-api", "drizzle-orm", "hono", @@ -88,7 +94,7 @@ "@intx/hub-api": ["@intx/hub-api@0.5.0", "", { "dependencies": { "@hono/standard-validator": "^0.2.2", "@intx/agent": "0.5.0", "@intx/authz": "0.5.0", "@intx/crypto": "0.5.0", "@intx/db": "0.5.0", "@intx/hub-common": "0.5.0", "@intx/hub-sessions": "0.5.0", "@intx/log": "0.5.0", "@intx/mime": "0.5.0", "@intx/storage-isogit": "0.5.0", "@intx/types": "0.5.0", "@intx/workflow-deploy": "0.5.0", "arktype": "^2.1.29", "better-auth": "^1.4.18", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", "hono-openapi": "^1.2.0", "isomorphic-git": "^1.27.2", "ssri": "^12.0.0" } }, "sha512-ObyGu0YV9w8g93LA6+a0tjGiI4saM1fwUi7JGiVGRJlQ2imD+iaIE8dyxzUdkeQN/ZmrGIQCdHt1Dg4+c0dQNA=="], - "@intx/hub-common": ["@intx/hub-common@0.5.0", "", { "dependencies": { "@intx/types": "0.5.0" } }, "sha512-riLxc8a9ZkCaEDUxr3DWlrIaZ/WeLZ9HJmXU9MbcM9TEUfMR68F/EiuSGKwQRFDVH9WwRZB82WN6vuGWbAHksA=="], + "@intx/hub-common": ["@intx/hub-common@0.4.0", "", { "dependencies": { "@intx/types": "0.4.0" } }, "sha512-4dcRX3kamqduf1Yil85GVsjXKnAnu6uqSYrAsl4vGpuV6R2DCy7z/PHd+DtIqk6TJrRdW7DtfkNXwSEc3dn2VQ=="], "@intx/hub-sessions": ["@intx/hub-sessions@0.5.0", "", { "dependencies": { "@intx/agent": "0.5.0", "@intx/crypto": "0.5.0", "@intx/db": "0.5.0", "@intx/hub-common": "0.5.0", "@intx/log": "0.5.0", "@intx/mime": "0.5.0", "@intx/pack-transport": "0.5.0", "@intx/storage-isogit": "0.5.0", "@intx/tool-packaging": "0.5.0", "@intx/types": "0.5.0", "@intx/workflow": "0.5.0", "@intx/workflow-deploy": "0.5.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "isomorphic-git": "^1.27.2", "tar": "^7.5.1" } }, "sha512-CgcXHnGtIw/Rjdhnry2TOSVvKeUh2x+XXvLJ/hrw86hBocvROUOFOykJ3/63Uft0OHPGKXf1D5AFPyc71P2arQ=="], @@ -598,6 +604,8 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -606,10 +614,16 @@ "@corbits/oauth-core/@intx/hub-api": ["@intx/hub-api@0.4.0", "", { "dependencies": { "@hono/standard-validator": "^0.2.2", "@intx/agent": "0.4.0", "@intx/authz": "0.4.0", "@intx/crypto": "0.4.0", "@intx/db": "0.4.0", "@intx/hub-common": "0.4.0", "@intx/hub-sessions": "0.4.0", "@intx/log": "0.4.0", "@intx/mime": "0.4.0", "@intx/storage-isogit": "0.4.0", "@intx/types": "0.4.0", "@intx/workflow-deploy": "0.4.0", "arktype": "^2.1.29", "better-auth": "^1.4.18", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", "hono-openapi": "^1.2.0", "isomorphic-git": "^1.27.2", "ssri": "^12.0.0" } }, "sha512-3Txw7Im38BdRujYeRlxgrzCdvOG1v92556AjNa/6WXSwgXt9Mn2fB4RoQrA6oUHcm10Y94WZh3IjaQsXFl7Qvg=="], - "@corbits/oauth-core/@intx/hub-common": ["@intx/hub-common@0.4.0", "", { "dependencies": { "@intx/types": "0.4.0" } }, "sha512-4dcRX3kamqduf1Yil85GVsjXKnAnu6uqSYrAsl4vGpuV6R2DCy7z/PHd+DtIqk6TJrRdW7DtfkNXwSEc3dn2VQ=="], - "@corbits/oauth-core/@intx/types": ["@intx/types@0.4.0", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-nJ2fcmskMOZNMRfbHs5ueKpCANHNttoL+sZgIoFaVUF/QLpFWfVp0YV72CQAaa9bVv9j5kRSwtSOCCGSlwQ6Dw=="], + "@intx/db/@intx/hub-common": ["@intx/hub-common@0.5.0", "", { "dependencies": { "@intx/types": "0.5.0" } }, "sha512-riLxc8a9ZkCaEDUxr3DWlrIaZ/WeLZ9HJmXU9MbcM9TEUfMR68F/EiuSGKwQRFDVH9WwRZB82WN6vuGWbAHksA=="], + + "@intx/hub-api/@intx/hub-common": ["@intx/hub-common@0.5.0", "", { "dependencies": { "@intx/types": "0.5.0" } }, "sha512-riLxc8a9ZkCaEDUxr3DWlrIaZ/WeLZ9HJmXU9MbcM9TEUfMR68F/EiuSGKwQRFDVH9WwRZB82WN6vuGWbAHksA=="], + + "@intx/hub-common/@intx/types": ["@intx/types@0.4.0", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-nJ2fcmskMOZNMRfbHs5ueKpCANHNttoL+sZgIoFaVUF/QLpFWfVp0YV72CQAaa9bVv9j5kRSwtSOCCGSlwQ6Dw=="], + + "@intx/hub-sessions/@intx/hub-common": ["@intx/hub-common@0.5.0", "", { "dependencies": { "@intx/types": "0.5.0" } }, "sha512-riLxc8a9ZkCaEDUxr3DWlrIaZ/WeLZ9HJmXU9MbcM9TEUfMR68F/EiuSGKwQRFDVH9WwRZB82WN6vuGWbAHksA=="], + "@npmcli/agent/lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/package.json b/package.json index 0703648..081619c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "files": [ "dist", "README.md", - "LICENSE" + "LICENSE", + "docs", + "examples" ], "type": "module", "sideEffects": false, @@ -38,6 +40,26 @@ "./hub": { "types": "./dist/hub/index.d.ts", "default": "./dist/hub/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "default": "./dist/client.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + }, + "./interchange/client": { + "types": "./dist/interchange/client.d.ts", + "default": "./dist/interchange/client.js" + }, + "./interchange/server": { + "types": "./dist/interchange/server.d.ts", + "default": "./dist/interchange/server.js" + }, + "./server/task-store": { + "types": "./dist/extensions/tasks/file-store.d.ts", + "default": "./dist/extensions/tasks/file-store.js" } }, "publishConfig": { @@ -52,15 +74,18 @@ "format:check": "oxfmt --check", "test": "bun test src --pass-with-no-tests", "test:e2e": "bun test e2e", - "check": "bun run typecheck && bun run lint && bun run format:check && bun run test" + "check": "bun run typecheck && bun run lint && bun run format:check && bun run test", + "test:package": "bun test e2e/package.test.ts" }, "dependencies": { - "@corbits/oauth-core": "^0.3.0", "@intx/types": "^0.5.0", - "arktype": "^2.2.3" + "ajv": "^8.17.1", + "arktype": "^2.2.3", + "yaml": "2.8.1" }, "devDependencies": { "@corbits/credential-http": "0.1.0", + "@corbits/oauth-core": "0.3.0", "@intx/agent": "0.5.0", "@intx/authz": "0.5.0", "@intx/db": "0.5.0", @@ -77,6 +102,7 @@ }, "peerDependencies": { "@corbits/credential-http": "^0.1.0", + "@corbits/oauth-core": "^0.3.0", "@intx/agent": "^0.5.0", "@intx/db": "^0.5.0", "@intx/harness": "^0.4.0 || ^0.5.0", @@ -99,6 +125,15 @@ }, "hono": { "optional": true + }, + "@intx/agent": { + "optional": true + }, + "@intx/harness": { + "optional": true + }, + "@corbits/oauth-core": { + "optional": true } }, "engines": { From 66b30793c7ccd0b1a7e2be8e14fd8c3e3ed8464e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:38 -0700 Subject: [PATCH 17/28] test(package): exercise installed bridges and standalone examples Test packed neutral, host, auth and legacy entry points through ordinary npm installs and add runnable standalone examples. Cover public declarations, runtime isolation and bidirectional integration. --- e2e/fixtures/packed-auth.mjs | 42 +++++ e2e/fixtures/packed-host.mjs | 43 +++++ e2e/fixtures/packed-legacy.mjs | 68 ++++++++ e2e/fixtures/packed-standalone.mjs | 135 ++++++++++++++++ e2e/interchange.test.ts | 243 +++++++++++++++++++++++++++++ e2e/package.test.ts | 186 ++++++++++++++++++++++ examples/README.md | 28 ++++ examples/standalone-client.ts | 14 ++ examples/standalone-server.ts | 37 +++++ 9 files changed, 796 insertions(+) create mode 100644 e2e/fixtures/packed-auth.mjs create mode 100644 e2e/fixtures/packed-host.mjs create mode 100644 e2e/fixtures/packed-legacy.mjs create mode 100644 e2e/fixtures/packed-standalone.mjs create mode 100644 e2e/interchange.test.ts create mode 100644 e2e/package.test.ts create mode 100644 examples/README.md create mode 100644 examples/standalone-client.ts create mode 100644 examples/standalone-server.ts diff --git a/e2e/fixtures/packed-auth.mjs b/e2e/fixtures/packed-auth.mjs new file mode 100644 index 0000000..51e567e --- /dev/null +++ b/e2e/fixtures/packed-auth.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import * as root from "@corbits/mcp"; + +let persisted = false; +const issuer = "https://auth.example"; +const session = root.createMcpAuthorizationSession({ + entry: { + resourceUrl: "https://mcp.example/mcp", + authorizationServer: { + issuer, + authorizationEndpoint: `${issuer}/authorize`, + tokenEndpoint: `${issuer}/token`, + }, + }, + client: { kind: "pre-registered", clientId: "packed-client", issuer }, + config: { redirectUri: "http://127.0.0.1:8080/callback" }, + tenantId: "tenant", + principalId: "principal", + persist: async () => { + persisted = true; + }, + fetchImpl: async () => { + throw new Error("Cancelled session must not exchange tokens"); + }, +}); +const authorize = new URL(session.authorizeUrl); +assert.equal(authorize.searchParams.get("resource"), "https://mcp.example/mcp"); +assert.equal(authorize.searchParams.get("code_challenge_method"), "S256"); +assert.ok(authorize.searchParams.get("state")); +session.cancel(); +await assert.rejects( + session.complete( + new URLSearchParams({ + state: authorize.searchParams.get("state"), + code: "unused", + iss: issuer, + }), + ), + /consumed|expired/, +); +assert.equal(persisted, false); +console.log("packed auth APIs passed"); diff --git a/e2e/fixtures/packed-host.mjs b/e2e/fixtures/packed-host.mjs new file mode 100644 index 0000000..1b85482 --- /dev/null +++ b/e2e/fixtures/packed-host.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import * as root from "@corbits/mcp"; +import * as client from "@corbits/mcp/client"; +import * as consume from "@corbits/mcp/interchange/client"; +import * as expose from "@corbits/mcp/interchange/server"; +import * as sidecar from "@corbits/mcp/sidecar-bundle"; +import { createSkillPublisher } from "@corbits/mcp/server"; + +assert.equal(root.mcpConnect, client.mcpConnect); +assert.equal(root.mcpTools, consume.mcpTools); +assert.equal(typeof expose.createInterchangeProviders, "function"); +assert.equal(typeof sidecar.mcpServers, "function"); +const uri = "skill://demo/SKILL.md"; +const bytes = new TextEncoder().encode( + "---\nname: demo\ndescription: Packed example\n---\nUntrusted instructions.\n", +); +const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, +]); +const loader = consume.createSkillLoader({ + async get(_origin, requested) { + return publisher.get(requested).skill; + }, + async read(_origin, requested) { + return Buffer.from(publisher.read(requested).contents[0].blob, "base64"); + }, +}); +const held = await loader.get( + { + serverId: "server", + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "partition", + }, + uri, +); +const verified = await loader.verify(held); +assert.deepEqual(Buffer.from(verified.bytes), Buffer.from(bytes)); +assert.equal(verified.trust, "untrusted-mcp"); +await assert.rejects(loader.activate(held), /host|policy|activation/i); +loader.clear(); + +console.log("packed host APIs passed"); diff --git a/e2e/fixtures/packed-legacy.mjs b/e2e/fixtures/packed-legacy.mjs new file mode 100644 index 0000000..98d974e --- /dev/null +++ b/e2e/fixtures/packed-legacy.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { createMcpServer } from "@corbits/mcp/server"; + +for (const version of ["2025-11-25", "2025-06-18", "2025-03-26"]) { + const server = createMcpServer({ + serverInfo: { name: "packed-legacy", version: "1" }, + auth: "public", + legacy: { versions: [version], maxSessions: 1, sessionTtlMs: 60_000 }, + }); + const post = (body, session) => + server.fetch( + new Request("https://mcp.example/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...(session + ? { "Mcp-Session-Id": session, "MCP-Protocol-Version": version } + : {}), + }, + body: JSON.stringify(body), + }), + ); + const initialize = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: version, + capabilities: {}, + clientInfo: { name: "packed", version: "1" }, + }, + }; + try { + const response = await post(initialize); + assert.equal(response.status, 200); + const session = response.headers.get("Mcp-Session-Id"); + assert.ok(session); + assert.equal((await response.json()).result.protocolVersion, version); + const ready = await post( + { jsonrpc: "2.0", method: "notifications/initialized" }, + session, + ); + assert.equal(ready.status, 202); + assert.equal( + (await post(initialize)).status, + 503, + "configured session capacity is enforced", + ); + assert.equal( + ( + await server.fetch( + new Request("https://mcp.example/mcp", { + headers: { + "Mcp-Session-Id": session, + "MCP-Protocol-Version": version, + }, + }), + ) + ).status, + 405, + "legacy GET streams are unsupported", + ); + } finally { + await server.close(); + } +} +console.log("packed legacy APIs passed"); diff --git a/e2e/fixtures/packed-standalone.mjs b/e2e/fixtures/packed-standalone.mjs new file mode 100644 index 0000000..22d63c6 --- /dev/null +++ b/e2e/fixtures/packed-standalone.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createSkillPublisher, + createTaskRuntime, + validateTaskRecord, + createMcpResourceServerAuth, +} from "@corbits/mcp/server"; +import { createFileTaskStore } from "@corbits/mcp/server/task-store"; + +const uri = "skill://demo/SKILL.md"; +const bytes = new TextEncoder().encode( + "---\nname: demo\ndescription: Packed example\n---\nUntrusted instructions.\n", +); +const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, +]); +assert.equal(publisher.list().skills[0].uri, uri); +assert.equal(publisher.get(uri).skill.resources[0].size, bytes.length); +assert.deepEqual( + Buffer.from(publisher.read(uri).contents[0].blob, "base64"), + Buffer.from(bytes), +); + +const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "partition", +}; +const directory = await mkdtemp(join(tmpdir(), "packed-mcp-tasks-")); +let store; +let starts = 0; +let recovered = 0; +const executor = { + async start() { + starts++; + }, + async recover(record) { + recovered++; + return { + ...record.task, + status: "completed", + result: { content: [{ type: "text", text: "recovered" }] }, + }; + }, + async submitInput() { + throw new Error("No input expected"); + }, + async requestCancel() { + throw new Error("No cancellation expected"); + }, +}; +try { + store = await createFileTaskStore({ + directory, + validateRecord: validateTaskRecord, + }); + const runtime = createTaskRuntime({ + store, + executor, + authorizeInput: async () => { + throw new Error("No input expected"); + }, + }); + const task = await runtime.create(identity, { name: "echo", arguments: {} }); + assert.equal(task.resultType, "task"); + assert.equal(starts, 1); + assert.equal((await runtime.get(identity, task.taskId)).status, "working"); + await store.close(); + store = await createFileTaskStore({ + directory, + validateRecord: validateTaskRecord, + }); + const restarted = createTaskRuntime({ + store, + executor, + authorizeInput: async () => {}, + }); + await restarted.recover(); + assert.equal(recovered, 1); + assert.equal(starts, 1, "recovery must not replay start"); + const result = await restarted.get(identity, task.taskId); + assert.equal(result.status, "completed"); + assert.equal(result.result.content[0].text, "recovered"); + await assert.rejects( + restarted.get({ ...identity, principalId: "other" }, task.taskId), + ); +} finally { + await store?.close(); + await rm(directory, { recursive: true, force: true }); +} + +// Injected verifier is a host contract, not a claim of cryptographic verification. +const auth = createMcpResourceServerAuth({ + resource: "https://mcp.example/mcp", + resourceMetadataUrl: + "https://mcp.example/.well-known/oauth-protected-resource/mcp", + authorizationServers: ["https://auth.example"], + async verifyAccessToken(token) { + assert.equal(token, "fixture-token"); + return { + issuer: "https://auth.example", + audience: ["https://mcp.example/mcp"], + expiresAt: Date.now() + 60_000, + identity: { ...identity, scopes: ["read"] }, + }; + }, + authorize: () => true, +}); +assert.equal( + await auth.policy.authenticate(new Request("https://mcp.example/mcp")), + null, +); +assert.equal( + ( + await auth.policy.authenticate( + new Request("https://mcp.example/mcp", { + headers: { authorization: "Bearer fixture-token" }, + }), + ) + ).principalId, + "principal", +); +assert.equal(auth.challenge(401).status, 401); +assert.match( + auth.challenge(401).headers.get("www-authenticate"), + /resource_metadata=/, +); +assert.equal( + (await auth.metadataResponse().json()).resource, + "https://mcp.example/mcp", +); +console.log("packed standalone lifecycle passed"); diff --git a/e2e/interchange.test.ts b/e2e/interchange.test.ts new file mode 100644 index 0000000..05f9759 --- /dev/null +++ b/e2e/interchange.test.ts @@ -0,0 +1,243 @@ +import { expect, test } from "bun:test"; +import { + createDefaultDirectorRegistry, + createToolRunner, + defineTool, + tool, + type BaseEnv, +} from "@intx/agent"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { + createCredentialCapability, + createCredentialProviderRegistry, + createHttpCredentialProvider, +} from "@intx/harness"; +import { mcpCallTool, mcpConnect, mcpListTools } from "../src/client.js"; +import { createMcpServer } from "../src/server.js"; +import { createInterchangeProviders } from "../src/interchange/server.js"; +import { mcpTools } from "../src/tool.js"; +import type { McpAuthPolicy } from "../src/auth/contracts.js"; + +// No inference is performed: use real ToolBundle/runner and credential mediation. +// Access to unrelated agent storage fails rather than pretending to implement it. +function toolEnv(): BaseEnv { + return { + sources: [], + defaultSource: "unused", + workdir: "/unused", + get storage(): never { + throw new Error("This tool-only test must not access inference storage"); + }, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + }; +} + +function fixture() { + let executions = 0; + let permitted = true; + let approved = true; + const inputSchema = { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + additionalProperties: false, + }; + const factory = defineTool({ + id: "@example/roundtrip/tools", + definitions: [{ name: "internal.echo" }, { name: "internal.secret" }], + factory: () => + createToolRunner([ + tool({ + definition: { + name: "internal.echo", + description: "Echo text", + inputSchema, + }, + handler: async (call) => { + executions++; + return { + callId: call.id, + content: String(call.arguments.text), + detail: { privateNote: "host-only" }, + }; + }, + }), + tool({ + definition: { + name: "internal.secret", + description: "Not exposed", + inputSchema, + }, + handler: async () => { + throw new Error("Unselected tool executed"); + }, + }), + ]), + }); + const auth: McpAuthPolicy = { + authenticate: (request) => + request.headers.get("authorization") === "Bearer integration-secret" + ? { + tenantId: "tenant-a", + principalId: "principal-a", + scopes: ["tools"], + authorizationContextId: "partition-a", + } + : null, + authorize: (_identity, operation) => + operation.resource === "mcp:tools" || permitted, + }; + const server = createMcpServer({ + serverInfo: { name: "interchange-roundtrip", version: "1" }, + auth, + providers: createInterchangeProviders({ + auth, + exposures: [ + { + kind: "tool", + definition: { + name: "echo", + description: "Selected echo", + inputSchema, + }, + tenantId: "tenant-a", + resource: "tool:internal.echo", + action: "invoke", + approve: async () => approved, + bundle: factory(toolEnv()), + toolName: "internal.echo", + }, + ], + }), + }); + const http = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: server.fetch, + }); + const url = new URL("/mcp", http.url).href; + const credentials = createCredentialCapability({ + consumer: "@corbits/mcp/servers", + providers: createCredentialProviderRegistry([ + createHttpCredentialProvider(), + ]), + bindings: new Map([ + [ + "mcp-server", + { + credentialId: "credential-a", + providerKey: "http", + origin: http.url.origin, + readCurrentMaterial: () => ({ secret: "integration-secret" }), + }, + ], + ]), + grants: [ + { + id: "credential-grant", + resource: "credential:credential-a", + action: "use", + effect: "allow", + origin: "system", + conditions: { tool: "@corbits/mcp/servers" }, + roleId: null, + principalId: null, + expiresAt: null, + }, + ], + }); + return { + url, + credentials, + executions: () => executions, + revoke: () => { + permitted = false; + }, + denyApproval: () => { + approved = false; + }, + async close() { + http.stop(true); + await server.close(); + }, + }; +} + +test("selected real Interchange tool is callable over modern MCP, not the whole bundle", async () => { + const f = fixture(); + try { + const handle = await f.credentials.resolve("mcp-server"); + const options = { fetch: handle.fetch.bind(handle) }; + const session = await mcpConnect(f.url, options); + expect(session.protocolVersion).toBe("2026-07-28"); + expect(session.sessionId).toBeUndefined(); + const tools = await mcpListTools(f.url, { ...options, session }); + expect(tools.map((tool) => tool.name)).toEqual(["echo"]); + const result = await mcpCallTool( + f.url, + "echo", + { text: "hello" }, + { ...options, session }, + ); + expect(result).toMatchObject({ + content: [{ type: "text", text: "hello" }], + }); + expect(JSON.stringify(result)).not.toContain("host-only"); + expect(f.executions()).toBe(1); + await expect( + mcpCallTool(f.url, "echo", { text: 42 }, options), + ).rejects.toThrow(); + await expect( + mcpCallTool(f.url, "internal.secret", { text: "no" }, options), + ).rejects.toThrow(); + expect(f.executions()).toBe(1); + f.revoke(); + expect(await mcpListTools(f.url, options)).toEqual([]); + await expect( + mcpCallTool(f.url, "echo", { text: "denied" }, options), + ).rejects.toThrow(); + expect(f.executions()).toBe(1); + await expect(handle.fetch("https://other.example/mcp")).rejects.toThrow(); + } finally { + await f.close(); + } +}); + +test("MCP consumption round-trips through actual Interchange factories and rechecks approval", async () => { + const f = fixture(); + try { + const consume = await mcpTools( + { + servers: [ + { name: "remote", url: f.url, credentialHandle: "mcp-server" }, + ], + }, + { credentials: f.credentials }, + ); + expect(consume.definitions).toEqual([ + { name: "remote.echo", approval: "ask" }, + ]); + const bundle = consume( + Object.assign(toolEnv(), { credentials: f.credentials }), + ); + const signal = new AbortController().signal; + const call = { + id: "roundtrip", + name: "remote.echo", + arguments: { text: "through both directions" }, + }; + const result = await bundle.run(call, signal); + expect(result.isError).not.toBe(true); + expect(result.content).toBe("through both directions"); + expect(JSON.stringify(result)).not.toContain("integration-secret"); + expect(f.executions()).toBe(1); + f.denyApproval(); + expect((await bundle.run(call, signal)).isError).toBe(true); + expect(f.executions()).toBe(1); + await bundle.dispose?.(); + } finally { + await f.close(); + } +}); diff --git a/e2e/package.test.ts b/e2e/package.test.ts new file mode 100644 index 0000000..6619605 --- /dev/null +++ b/e2e/package.test.ts @@ -0,0 +1,186 @@ +import packageManifest from "../package.json"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +let sandbox: string; +let hostSandbox: string; + +async function command(argv: string[], cwd: string): Promise { + const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) + throw new Error(`${argv.join(" ")} exited ${code}\n${stdout}\n${stderr}`); + return stdout; +} + +beforeAll(async () => { + sandbox = await mkdtemp(join(tmpdir(), "corbits-mcp-package-")); + await command(["bun", "run", "build"], root); + await command( + ["bun", "pm", "pack", "--ignore-scripts", "--destination", sandbox], + root, + ); + await writeFile( + join(sandbox, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ); + await command( + [ + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + join(sandbox, `corbits-mcp-${packageManifest.version}.tgz`), + ], + sandbox, + ); + hostSandbox = await mkdtemp(join(tmpdir(), "corbits-mcp-host-package-")); + await writeFile( + join(hostSandbox, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ); + const peers = Object.entries(packageManifest.devDependencies) + .filter(([name]) => name === "@intx/agent" || name === "@intx/harness") + .map(([name, version]) => `${name}@${version}`); + await command( + [ + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + join(sandbox, `corbits-mcp-${packageManifest.version}.tgz`), + ...peers, + "@corbits/oauth-core@0.3.0", + ], + hostSandbox, + ); +}, 180_000); + +afterAll(async () => { + if (sandbox) await rm(sandbox, { recursive: true, force: true }); + if (hostSandbox) await rm(hostSandbox, { recursive: true, force: true }); +}, 30_000); + +test("packed exports retain the four roles and historical facades", async () => { + const manifest = JSON.parse( + await readFile( + join(sandbox, "node_modules/@corbits/mcp/package.json"), + "utf8", + ), + ); + expect(manifest.dependencies["@corbits/oauth-core"]).toBeUndefined(); + expect(manifest.peerDependencies["@corbits/oauth-core"]).toBe("^0.3.0"); + expect(manifest.peerDependenciesMeta["@corbits/oauth-core"]).toEqual({ + optional: true, + }); + for (const entry of [ + ".", + "./client", + "./server", + "./server/task-store", + "./interchange/client", + "./interchange/server", + "./hub", + "./sidecar-bundle", + ]) { + const target = manifest.exports[entry]; + expect(target).toBeDefined(); + expect( + ( + await readFile( + join(sandbox, "node_modules/@corbits/mcp", target.types), + "utf8", + ) + ).length, + ).toBeGreaterThan(0); + expect( + ( + await readFile( + join(sandbox, "node_modules/@corbits/mcp", target.default), + "utf8", + ) + ).length, + ).toBeGreaterThan(0); + } +}); + +for (const runtime of ["bun", "node"]) { + test(`packed standalone entries load without host peers under ${runtime}`, async () => { + const script = ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + import { mcpConnect, mcpCallTool } from "@corbits/mcp/client"; + import { createMcpServer } from "@corbits/mcp/server"; + const require = createRequire(import.meta.url); + for (const dependency of ["@corbits/oauth-core", "@intx/agent", "@intx/harness", "@intx/db", "@intx/hub-api", "@intx/hub-common", "hono", "@modelcontextprotocol/sdk"]) { + assert.throws(() => require.resolve(dependency), { code: "MODULE_NOT_FOUND" }, "Unexpected installed host dependency: " + dependency); + } + assert.equal(typeof mcpConnect, "function"); + assert.equal(typeof mcpCallTool, "function"); + assert.equal(typeof createMcpServer, "function"); + if (process.release.name === "node" && !process.versions.bun) assert.ok(Number(process.versions.node.split(".")[0]) >= 24); + console.log("isolated imports passed"); + `; + await writeFile(join(sandbox, "smoke.mjs"), script); + expect(await command([runtime, "smoke.mjs"], sandbox)).toContain( + "isolated imports passed", + ); + }, 30_000); +} + +for (const runtime of ["bun", "node"]) { + for (const role of ["standalone", "host", "auth", "legacy"] as const) { + test(`packed ${role} factories execute through public APIs under ${runtime}`, async () => { + const directory = + role === "standalone" || role === "legacy" ? sandbox : hostSandbox; + const script = await readFile( + new URL(`./fixtures/packed-${role}.mjs`, import.meta.url), + "utf8", + ); + await writeFile(join(directory, `${role}.mjs`), script); + expect(await command([runtime, `${role}.mjs`], directory)).toContain( + role === "standalone" + ? "packed standalone lifecycle passed" + : `packed ${role} APIs passed`, + ); + }, 30_000); + } +} + +for (const role of ["client", "server"]) { + test(`packed neutral ${role} graph excludes filesystem and host runtimes`, async () => { + await writeFile( + join(sandbox, "client-entry.mjs"), + `import * as entry from "@corbits/mcp/${role}"; console.log(Object.keys(entry));`, + ); + await writeFile( + join(sandbox, "client-boundary.mjs"), + ` + import assert from "node:assert/strict"; + const result = await Bun.build({ + entrypoints: ["./client-entry.mjs"], target: "bun", write: false, + plugins: [{ name: "neutral-${role}-boundary", setup(build) { + build.onResolve({ filter: /^(?:node:)?(?:fs|path)$|^(?:node:)?fs\\/|^@intx\\/|^@corbits\\/(?:oauth-core|credential-http)|^hono(?:\\/|$)|file-store/ }, args => { + throw new Error("Forbidden neutral-${role} dependency: " + args.path); + }); + }}], + }); + assert.equal(result.success, true, result.logs.map(String).join("\\n")); + console.log("neutral client graph passed"); + `, + ); + expect(await command(["bun", "client-boundary.mjs"], sandbox)).toContain( + "neutral client graph passed", + ); + }); +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..2856b98 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,28 @@ +# Examples + +These examples use public entry points after building/installing the package. + +- `standalone-client.ts`: discover and call an explicitly named remote tool. +- `standalone-server.ts`: intentionally public loopback echo server. Do not expose + sensitive operations with `auth: "public"`; install a validated auth policy and + explicit allowed origins when deploying an authenticated service. +- `../e2e/interchange.test.ts`: executable bidirectional example using installed + Interchange `defineTool`, `createToolRunner`, HTTP credential provider and + credential capability. It exposes one selected tool (not the entire bundle), + calls it through standalone MCP, consumes it back into a real tool factory, + and asserts schema/authorization/approval failures do not execute it. It does + not simulate an inference reactor or certify workflow/task lifecycle support. + +```sh +bun run build +bun examples/standalone-server.ts +# In another terminal: +bun examples/standalone-client.ts http://127.0.0.1:3000/mcp echo +``` + +For an authenticated host, compose `createInterchangeProviders({ auth, exposures })` +into `createMcpServer({ serverInfo, auth, providers })`. Each exposure supplies a +host-bound tenant, operation resource/action, explicit approval callback and a +selected tool/agent/HTTP/workflow binding. Never derive identity or fixed tenant +paths from model arguments. See the [migration guide](../docs/migration.md) for +required host services and cancellation/durability limits. diff --git a/examples/standalone-client.ts b/examples/standalone-client.ts new file mode 100644 index 0000000..e2ee7c8 --- /dev/null +++ b/examples/standalone-client.ts @@ -0,0 +1,14 @@ +import { mcpCallTool, mcpConnect, mcpListTools } from "@corbits/mcp/client"; + +const url = process.argv[2]; +const name = process.argv[3]; +if (!url || !name) throw new Error("Usage: standalone-client.ts "); +const session = await mcpConnect(url); +console.log(await mcpListTools(url, { session })); +const result = await mcpCallTool(url, name, { text: "hello" }, { session }); +if (result.resultType === "input_required" || result.resultType === "task") { + throw new Error( + "This minimal example has no elicitation or task host services", + ); +} +console.log(result.content); diff --git a/examples/standalone-server.ts b/examples/standalone-server.ts new file mode 100644 index 0000000..6fbabda --- /dev/null +++ b/examples/standalone-server.ts @@ -0,0 +1,37 @@ +import { createMcpServer, createToolProviders } from "@corbits/mcp/server"; + +const server = createMcpServer({ + serverInfo: { name: "public-loopback-echo", version: "1" }, + auth: "public", + providers: createToolProviders( + [ + { + definition: { + name: "echo", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + additionalProperties: false, + }, + }, + resource: "example:echo", + action: "invoke", + execute: (args) => ({ + content: [{ type: "text", text: String(args.text) }], + }), + }, + ], + "public", + ), +}); +const http = Bun.serve({ + hostname: "127.0.0.1", + port: 3000, + fetch: server.fetch, +}); +console.log(new URL("/mcp", http.url).href); +process.once("SIGINT", () => { + http.stop(true); + void server.close(); +}); From dacf1b15f3ad7e3643dd10ccc9f7b4eb47c7b150 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 14:49:39 -0700 Subject: [PATCH 18/28] docs: document bridge contracts and qualified conformance evidence Retain the architecture scope and document migration, role APIs, legacy deployment and final serial verification. Preserve companion dependency defects and host obligations without claiming exhaustive certification. --- AGENTS.md | 38 +++++++++-- README.md | 63 ++++++++++++++--- SOLUTION_SCOPE.md | 117 ++++++++++++++++++++++++++++++++ docs/conformance.md | 135 +++++++++++++++++++++++++++++++++++++ docs/integration-status.md | 98 +++++++++++++++++++++++++++ docs/legacy-server.md | 58 ++++++++++++++++ docs/migration.md | 127 ++++++++++++++++++++++++++++++++++ docs/public-api.md | 106 +++++++++++++++++++++++++++++ 8 files changed, 724 insertions(+), 18 deletions(-) create mode 100644 SOLUTION_SCOPE.md create mode 100644 docs/conformance.md create mode 100644 docs/integration-status.md create mode 100644 docs/legacy-server.md create mode 100644 docs/migration.md create mode 100644 docs/public-api.md diff --git a/AGENTS.md b/AGENTS.md index 15aa740..d7bb882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,10 @@ never hold a server's bearer. route this package mounts. The browser never holds an MCP token, so this is the only way an OAuth-protected server's `tools/list` can be read. 2. **The catalog is stored** with the rest of the agent's deploy config. -3. **`mcpTools` turns it into tools.** It is synchronous and does no - network work at construction: the names and the `ask` marks come from the - stored catalog. +3. **`mcpServers` turns the stored catalog into tools.** Construction is + synchronous and does no network work: names and `ask` marks come from the + stored catalog. By contrast, `mcpTools` is asynchronous and discovers the + remote catalog live before building tools. 4. **At run time the agent only ever sees a mediated handle.** Each server's credential resolves to an `http` handle (a fetch pinned to the credential's origin that injects the bearer per request), so the token never reaches @@ -47,16 +48,15 @@ no session speaks this stateless path directly. `input_required` comes back as a typed result and `mcpContinue` retries the original method with `inputResponses`. `subscriptions/listen` is the long-lived stream. `notifications/progress` stays on the response stream of the call that asked -for it. Aborting an in-flight call posts `notifications/cancelled`. +for it. Modern HTTP cancellation closes the response stream; it does not post `notifications/cancelled`. A legacy server still gets `initialize`, `notifications/initialized`, and `Mcp-Session-Id`. In the sidecar bundle every request goes through a fetch the mediated credential handle pins to the server's origin. The SDK's streamable-HTTP client transport is built for long-lived sessions -(resumable streams, server-initiated requests) and installs `zod`, `ajv`, -`express` and more, none of which this package uses. Resumable streams -(`Last-Event-ID`), the removed HTTP+SSE transport, `ping`, and the deprecated +(resumable streams, server-initiated requests) and installs server-framework dependencies this client does not need. Shared wire/schema validation now uses a directly declared AJV dependency, not the SDK runtime. Resumable streams +(`Last-Event-ID`), the removed HTTP+SSE transport, modern `ping`, and the deprecated roots, sampling, and logging methods are not implemented. `e2e/` runs discovery and tool calls against the SDK's stateless and stateful 2025 transports and against a small 2026 fixture; the SDK is a devDependency only. @@ -74,3 +74,27 @@ bun install && bun run check && bun run test:e2e - Read `README.md` before changing public behavior. - Keep changes focused; update docs and tests alongside code changes. - Run `bun run check` before declaring work complete. + +## Bidirectional package boundaries + +- `./client` and `./server` are standalone entries. Never import agent, harness, hub, DB or Hono runtime code into their dependency graph. +- `./interchange/client` consumes remote MCP; `./interchange/server` exposes only explicitly selected host bindings. Preserve the root, sidecar-bundle and hub facades. +- Shared wire validation belongs in `src/protocol`; extension runtimes accept transport/provider interfaces instead of importing client facades. +- Runtime JSON Schema validation uses declared AJV; skill YAML parsing uses declared YAML. The MCP SDK remains development-only and only tests legacy transport interoperability. +- `docs/conformance.md` tracks role/revision/capability-qualified evidence; never infer complete conformance from aggregate test counts. +- Package gates: `bun run check`, `bun run test:e2e`, `bun run build`, `bun run test:package`. The packed smoke suite requires npm, registry access and Node 24+. + +## Server compatibility and capability gates + +- `createMcpServer` is modern/stateless by default. Explicit `legacy` options + enable the three 2025 Streamable HTTP revisions; see docs/legacy-server.md for + session bounds, affinity/restart behavior, initialization authorization and + March-only batches. Do not describe this implemented shim as missing. +- Apps metadata is not host support. Advertisement requires explicit host bridge, + sandbox/CSP/authenticated-bridge support and presentation; actual rendering is + host-owned. Keep metadata-only hosts unadvertised. +- OAuth-core is an optional peer required by root/guarded login, not by neutral + client/server. Keep stock dependency defects CL11479/CL11480 distinct from + local guarded behavior and from application deployment obligations. +- Shared-worktree build/pack gates must run serially; record exact runtime + versions and do not infer final Node26 evidence from earlier Node24 results. diff --git a/README.md b/README.md index 890767d..db4ee1a 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,61 @@ A Corbits tool pack for the Interchange sidecar that turns each tool on a remote `mcpConnect` probes `server/discover`. A 2026-07-28 server is stateless: later calls send `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`, and carry `io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities` in `_meta`. They do not send `initialize`, `notifications/initialized`, or `Mcp-Session-Id`. A call made with no session speaks this stateless path directly; a session handed over from `mcpConnect` is stateless too. If `server/discover` is missing, times out, or the server only speaks a 2025 (or earlier) version, the client keeps the `initialize` plus `Mcp-Session-Id` path. -Core methods, paginated where the spec uses a cursor: `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/templates/list`, `completion/complete`. A missing `resultType` is `complete`. `input_required` is returned to the caller (method, `inputRequests`, `requestState`) and retried with `mcpContinue`. `subscriptions/listen` is a long-lived POST; `notifications/progress` stays on the call that asked for it; aborting an in-flight call sends `notifications/cancelled`. +Core methods, paginated where the spec uses a cursor: `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/templates/list`, `completion/complete`. A missing `resultType` is `complete`. `input_required` is returned to the caller (method, `inputRequests`, `requestState`) and retried with `mcpContinue`. `subscriptions/listen` is a long-lived POST; `notifications/progress` stays on the call that asked for it; modern HTTP cancellation closes the response stream (it does not send `notifications/cancelled`). Legacy transport cancellation is separately negotiated. -Extensions are off unless named in `extensions`: `io.modelcontextprotocol/tasks` (`tasks/get`, `tasks/update`, `tasks/cancel`, and a `task` result), `io.modelcontextprotocol/ui` (capability advertisement and passthrough of tool-result UI metadata; no widget renderer), and `io.modelcontextprotocol/skills` (`skills/list`, `skills/get`, with `resources/directory/read` only when the server declares `directoryRead`). Roots, sampling, logging, `ping`, HTTP+SSE, and SSE resumption are not implemented. Prompts, resources, and subscriptions are not projected into agent tools. Tools remain the only grantable unit. Catalog entries keep `outputSchema`, `annotations.title`, the four boolean hints, `ttlMs`, and `cacheScope`. JSON Schema is passed through, including unknown 2020-12 keywords. +Extensions are off unless named in `extensions`: `io.modelcontextprotocol/tasks` (`tasks/get`, `tasks/update`, `tasks/cancel`, and a `task` result), `io.modelcontextprotocol/ui` (capability advertisement and passthrough of tool-result UI metadata; no widget renderer), and `io.modelcontextprotocol/skills` (`skills/list`, `skills/get`, with `resources/directory/read` only when the server declares `directoryRead`). Roots, sampling, logging, modern `ping`, HTTP+SSE, and SSE resumption are not implemented; the opt-in legacy shim supports revision-scoped `ping`. Prompts, resources, and subscriptions are not projected into agent tools. Tools remain the only grantable unit. Catalog entries keep `outputSchema`, `annotations.title`, the four boolean hints, `ttlMs`, and `cacheScope`. JSON Schema is passed through, including unknown 2020-12 keywords. + +## Public entries + +| Entry | Role | Host dependencies | +| --------------------------------- | ------------------------------------------------------------ | ------------------------------------------ | +| `@corbits/mcp/client` | Standalone MCP client | No Interchange/hub runtime required | +| `@corbits/mcp/server` | Fetch-compatible MCP server | No mandatory Interchange or OAuth provider | +| `@corbits/mcp/interchange/client` | Remote MCP into selected Interchange tools and host services | Agent/harness peers | +| `@corbits/mcp/interchange/server` | Explicit selected Interchange bindings exposed as MCP | Host-bound identity and grants | + +The root, `./sidecar-bundle`, and `./hub` remain compatibility facades. Import +standalone roles directly to avoid the root facade's historical host dependencies. +See [migration and deployment](docs/migration.md), the +[revision/maturity and requirement matrix](docs/conformance.md), and +[examples](examples/README.md), and the [public API handoff](docs/public-api.md). These are scoped APIs, not a claim of full MCP +conformance or browser Apps-host support. + +## Server compatibility and Apps hosting + +`createMcpServer` is stateless by default. Its explicit `legacy` option enables +2025-11-25, 2025-06-18 and 2025-03-26 Streamable HTTP sessions. Defaults are 1,024 +in-memory sessions with a 30-minute absolute TTL; hosts need session affinity, +and restart loses sessions. Sessions permit 10,000 unique request IDs. Private +initialization requires `mcp:server` / `initialize` authorization. Only March 2025 +accepts supported batches; GET returns 405, with no HTTP+SSE or resumption. See +[legacy deployment and limits](docs/legacy-server.md). + +Apps capability advertisement requires explicit Apps host support and bridge, +sandbox/CSP/authenticated-bridge commitments, and presentation. Metadata-only +handling never advertises Apps. This package does not supply a browser renderer +or certify the host's sandbox. See [scope and evidence](docs/conformance.md). ## Install ```bash -bun add @corbits/mcp @intx/agent @intx/harness +bun add @corbits/mcp +# For Interchange consumption: +bun add @intx/agent @intx/harness +# Historical root / guarded OAuth login also requires OAuth-core: +bun add @corbits/oauth-core@0.3.0 ``` Runs on Node.js 24+ and Bun 1.2+. +Standalone client/server entries do not require OAuth-core or Interchange host +peers. OAuth-core is now an **optional peer**, not a mandatory dependency: this +is a breaking installation change for the historical root and OAuth login users, +who must explicitly install it as shown above. Installing OAuth-core still brings +its own DB/hub/Hono dependencies; that host installation is separate from the +neutral package. See [public API dependency notes](docs/public-api.md) for the +credential-http/harness/types peer conflict affecting the full hub setup. + For the `@corbits/mcp/hub` routes, also install: ```bash @@ -33,7 +76,7 @@ bun add @corbits/credential-http @intx/authz @intx/crypto @intx/db @intx/hub-api Lists the tools on a public MCP server: ```ts -import { mcpConnect, mcpListTools } from "@corbits/mcp"; +import { mcpConnect, mcpListTools } from "@corbits/mcp/client"; const url = "https://mcp.deepwiki.com/mcp"; const session = await mcpConnect(url); @@ -42,7 +85,7 @@ for (const tool of await mcpListTools(url, { session })) { } ``` -Every client call takes `{ fetch, timeoutMs, signal, session }` as its last argument, for example a fetch that adds an `Authorization` header. `timeoutMs` covers the request and reading its body, and `signal` cancels both. `mcpTools` and `mcpServers` take a `timeoutMs` option, 60 seconds by default, and pass each tool call's abort signal through, so a stalling server fails that call instead of hanging it. A response body or event-stream frame over 4 MiB is refused and the stream cancelled. +Every client call takes `{ fetch, timeoutMs, signal, session }` as its last argument, for example a fetch that adds an `Authorization` header. `timeoutMs` covers the request and reading its body, and `signal` cancels both. `mcpTools` and `mcpServers` take a `timeoutMs` option, 60 seconds by default, and pass each tool call's abort signal through, so a stalling server fails that call instead of hanging it. By default a response body or event-stream frame over 4 MiB is refused and the stream cancelled. Explicit `resources/read` requests with `maxDecodedBytes` (including Skills consumer reads) support up to 16 MiB aggregate decoded text/blob bytes, with a finite JSON/base64 encoding allowance and a separate 64 KiB metadata/envelope cap. Other methods retain the 4 MiB default. Servers explicitly configured with the Skills extension apply the same bounded resource-emission policy; ordinary resource providers retain the default validation limit. ## Where it fits @@ -75,7 +118,7 @@ Every client call takes `{ fetch, timeoutMs, signal, session }` as its last argu | `mountMcpDiscovery(app, opts)` | `@corbits/mcp/hub` | Mount `POST /mcp/discover`. | | `discoverMcpServer(args)`, `readCredential(opts)` | `@corbits/mcp/hub` | The discovery and credential reads the route uses. | -`McpToolSchema` validates a catalog entry (`McpTool`). Transport and protocol failures throw `McpError`. OAuth discovery failures throw `OAuthDiscoveryError` from `@corbits/oauth-core`. +`McpToolSchema` validates a catalog entry (`McpTool`). Transport and protocol failures throw `McpError`. OAuth-facing discovery failures throw `OAuthDiscoveryError` from `@corbits/oauth-core`. Neutral resource-auth URL-policy errors preserve the diagnostic name/message but are not instances of that optional peer class; see [public API](docs/public-api.md). ### Grants @@ -168,7 +211,7 @@ export const tools = mcpServers({ }); ``` -Each catalog entry becomes a tool named `.`, with the remote `inputSchema` passed through and the call proxied to `tools/call`. Text content comes back as text and anything else as JSON; `isError` passes through. `input_required` comes back as an error result whose JSON carries the method, `inputRequests`, and `requestState`. A handle that does not resolve, or a server that fails the handshake, fails only that server's calls. +Each catalog entry becomes a tool named `.`, with the remote `inputSchema` passed through and the call proxied to `tools/call`. Text content comes back as text; richer content and private metadata use explicit detail/host channels. `isError` passes through. `input_required` and task results require explicit host lifecycle services; absent services report unsupported rather than implicitly approving input or fabricating pending execution. A handle that does not resolve, or a server that fails the handshake, fails only that server's calls. `handle` is the credential handle the host binds this server's token to. The bundle resolves it from the runtime `credentials` capability and expects an `http` credential, such as the one `@corbits/credential-http`'s MCP provider returns. The package manifest declares the `mcp-server` credential for this. @@ -221,19 +264,17 @@ The focused regressions in `src/protocol-2026.test.ts` check these requirements: These tests and the SDK transport e2e suite are targeted compatibility evidence, not comprehensive MCP certification. The transport and extension limits listed above still apply, including no deprecated HTTP+SSE transport or SSE resumption. -This scoped change does not audit or change authorization-error precedence or -OAuth URL handling. ## Upgrading from 0.1 - Existing server configs, stored catalogs and the `mcp-server` credential keep working unchanged. -- `@intx/harness` is a required peer. Import `FetchLike` from it; `@corbits/mcp` no longer exports that type. +- For historical Interchange integrations, install `@intx/agent` and `@intx/harness`. They are optional package peers so standalone imports do not install a host runtime. - `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `@corbits/credential-http` are optional peers. Install them if you use `@corbits/mcp/hub`. `@corbits/credential-http` replaces `@corbits/credential-mcp`. - Discovery with a credential pins to the credential's provider `apiBaseUrl` origin, not to the requested URL's origin. A credential with a secret whose provider has no `apiBaseUrl` is refused, and a URL on another origin needs `extraOrigins`. No origin is allowed by default. - `readCredentialSecret` is now `readCredential`, which returns `{ secret, origin? }`. `discoverMcpServer` takes `credential: { secret, origin? }` instead of `secret`. - `mcpInitialize` returns an `McpSession` (`protocolVersion`, `sessionId?`, `serverInfo?`) and sends `notifications/initialized`. Pass it as `{ session }` to `mcpListTools` and `mcpCallTool`; a stateful server needs it. - The discovery route refuses plain-http loopback URLs unless `allowLoopback` is set. Handles and server names containing `.`, and catalogs repeating a tool name, are refused. `mcpTools` refuses an empty or repeated server name, and a non-https URL, before any request. -- `@intx/agent` and `@intx/harness` peers are `^0.4.0`. +- The integration baseline is `@intx/agent` 0.5.0 and `@intx/harness` 0.5.0; see package peer ranges. - Discovery rejects authorization-server metadata whose `issuer` differs from the one the protected resource names. ## License diff --git a/SOLUTION_SCOPE.md b/SOLUTION_SCOPE.md new file mode 100644 index 0000000..0bb6777 --- /dev/null +++ b/SOLUTION_SCOPE.md @@ -0,0 +1,117 @@ +# PR28 solution scope: bidirectional MCP ↔ Interchange + +Status: implementation authorized; architecture handoff, 2026-10-25. Parent: **CL-11472**. This document is a design, not a claim that the implementation exists. + +## 1. Baseline and authority + +- PR worktree: the repository checkout for the branch below. +- Verified branch: `cl-11364-speak-mcp-2026-07-28-from-the-corbits-mcp-client`; baseline `8217114427dc90e17107c8e0469c5a9995b8bc38`; initially clean. +- Read repository AGENTS, README, CONTRIBUTING, package manifest, client/projection/auth code, and installed dependency declarations. Existing package is 0.3.1 with `@intx/*` 0.5.0, oauth-core 0.3.0, SDK 1.30.1 as a development dependency. +- Local Interchange remote is `git@github.com:faremeter/interchange.git`, committed revision `16d8dc3ee5e0b349614fbf9cecb4d2226f50db57`. It has unrelated dirty/untracked work: do not modify it or rely on those additions. The upstream GitHub agent index at this revision was also inspected. Installed 0.5.0 declarations and route implementations are the executable compatibility baseline, not an assumption that local main equals a release. + +### Normative sources + +Use **versioned** core pages below and their linked schema, not `/draft`, marketing summaries, or SDK generation names. Capture these in `docs/conformance.md` with clause → test → role → applicability. Reviewed on 2026-10-25: + +- Core: ; `basic`, `basic/versioning`, `basic/transports/streamable-http`, `basic/patterns/{mrtr,subscriptions,progress,cancellation}`, `client/elicitation`, `server/{discover,tools,prompts,resources}`, `server/utilities/{completion,caching}`, and `deprecated` under that version. +- Schema: . Pin the fetched source revision and license if copying schema material. +- Auth: `basic/authorization`, `basic/authorization/authorization-server-discovery`, and linked registration/security requirements under the same core revision. +- Tasks: . README explicitly classifies `2026-07-28` schemas as **Stable**; draft is Development. +- Skills: ; official extension, SEP-2640 Final; base revision 2026-07-28 or later. `skills.md` is an indirection, not the substantive document. Skill format delegates to . +- Apps: . Implement this released specification; distinguish its independent UI protocol revision from MCP core revision. +- Extension negotiation: . Extension capabilities are opt-in, not synonymous with experimental maturity. +- Host evidence: , especially “MCP client runtimes”, notifications, elicitation, and tasks. Current docs describe 2026-07-28 support in the SDK-2-based runtime and task public beta on CLI 2.1.304+, with feature-flag/provider/surface restrictions. The community extension matrix has blank Claude cells and cannot prove universal support. Do not turn Claude runtime “v2” into protocol names or assume Desktop/web supports every CLI feature. + +## 2. Deliverables and dependency direction + +Four supported entries: + +| Entry | Responsibility | Runtime dependency boundary | +| --------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `@corbits/mcp/client` | Standalone MCP client; existing client functions, transport, typed continuations and extension operations | No agent, hub, DB, Hono or credential-store runtime import | +| `@corbits/mcp/server` | Fetch-compatible MCP server, registry, HTTP binding, server-side extension providers and security hooks | No mandatory Interchange or OAuth-provider implementation | +| `@corbits/mcp/interchange/client` | Remote MCP → Interchange tool factories and host services for prompts/resources, MRTR/tasks/skills | Uses actual agent/harness contracts and mediated handles | +| `@corbits/mcp/interchange/server` | Selected Interchange operations/tools/agents/workflows → MCP provider bindings | Explicit selection and identity mapping, never automatic exposure of the whole hub | + +Dependency graph: `protocol` ← `client` / `server` / `extensions`; `interchange/*` composes those. Extension runtimes receive transport/registry interfaces; they do not import client facades and create cycles. Both roles consume the same wire validators, dialect validator, result applicability table, header codec and error taxonomy. Provider-specific validation supplements but never forks protocol validation. + +Existing root, `./sidecar-bundle`, and `./hub` exports remain compatible façades. Existing root names, signatures, qualified names, credential declaration identity, bundle ID, stored catalogs, synchronous bundle construction and destructive ask floor remain intact. Root can keep its historical agent dependencies; the new standalone subpaths must not inherit them. Keep old extension functions as aliases if their canonical implementation moves. Security corrections can reject previously accepted malformed inputs: enumerate these in migration notes rather than silently preserving unsafe behavior. Do not rename MCP methods or extension IDs to `v2`. + +No separate skills entry is necessary: wire APIs belong to client/server and verified host integration belongs to interchange/client. Keep filesystem-specific implementations behind explicit server imports/factories; do not pull filesystem code into the client entry. + +## 3. Frozen module ownership (six existing Linear buckets) + +| Bucket | Exclusive implementation paths | Handoff | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **CL-11473 shared/client** | `src/protocol/**`, `src/client/**`, `src/client.ts`, `src/client.test.ts`, `src/protocol-2026.test.ts` | Sole owner of all wire types/validators, including extension wire types; publish contracts first, then migrate client | +| **CL-11474 server/export** | `src/server.ts`, `src/server/**`, `src/interchange/server.ts`, `src/interchange/server/**` | Standalone server and explicit upstream operation/tool/agent/workflow exports | +| **CL-11475 Interchange consumption** | `src/interchange/client.ts`, `src/interchange/client/**`, `src/tool.ts`, `src/tool.test.ts`, `src/sidecar-bundle.ts`, `src/sidecar-bundle.test.ts`, `src/naming.ts` | One common result/lifecycle projection used by live and stored-catalog factories | +| **CL-11476 skills/extensions/maturity** | `src/extensions/**` | Tasks executor/store interfaces + reference adapter; skills publishers/verifiers/host leases; Apps visibility/metadata helpers. Wire changes requested from CL-11473, not duplicated | +| **CL-11477 authorization** | `src/auth/**`, `src/oauth-discovery.ts`, `src/oauth-discovery.test.ts`, `src/url.ts`, `src/hub/**` | HTTP resource-server auth, outbound discovery/login integration and mediation boundaries | +| **CL-11478 conformance/docs** | `e2e/**`, `docs/**`, `examples/**`, `src/index.ts`, `package.json`, `bun.lock`, `tsconfig*.json`, `README.md`, `AGENTS.md` | Sole package/export/dependency integrator; cross-role tests, packed imports and documentation | + +Planner owns this PRD/scope/build-plan handoff only. Tests beside new modules belong to their module owner. No lane edits another lane's files; request an exported seam. Integration owner updates package metadata once requirements from lanes are known. CL-11473's initial contract commit is a prerequisite for implementation merging, not a reason to delay parallel fixture/provider preparation. + +## 4. Shared contracts to freeze before fanout + +These are **proposed local APIs**, not existing `@intx` exports. Names below are the implementation contract; source files own them once implemented. Type details must be derived from the pinned wire schema, not copied independently into both roles. + +1. `src/protocol/types.ts`, `schemas.ts`, `methods.ts`, `headers.ts`, `errors.ts`, `json-schema.ts`, `extensions.ts`, `index.ts`. + - One `McpMethodMap` associates each method with params, ordinary result, allowed polymorphic results, required capability, cacheability and HTTP name source. + - MRTR allowed only on `tools/call`, `prompts/get`, `resources/read`; task results allowed only on `tools/call` for the pinned tasks revision. All others accept complete results only. Unknown resultType is invalid everywhere, including subscription termination. + - Name sources: tool/prompt name, resource URI, and **taskId for all three tasks RPCs**. Extension methods use their actual specification rules; do not invent name headers by analogy. + - Shared `validateRequest`, `validateResult`, `validateNotification`, `validateSchema`, `validateInstance`, `encodeHeaderValue`, `validateRequestHeaders`. Validation context includes protocol era and opted-in extension settings. Keep unknown metadata/schema vocabulary where allowed; never erase metadata simply to satisfy a validator. + - `FetchLike` is a structural fetch signature in the protocol/client layer, not an Interchange runtime dependency. Shared `McpError` retains existing fields/API and distinguishes local failure from peer JSON-RPC errors. + - Add bounded AJV 2020-12 validation (and explicitly registered supported older dialects if needed); schema object validity and data validity are separate. No implicit network `$ref` resolution. Never use an unknown-keyword strictness setting that rejects valid 2020-12 extension annotations. Add depth/subschema/data limits before compilation. +2. `src/client/transport.ts`: internal `McpRequestTransport.request(method, params, options)` and `.listen(filter, observer, options)`; options include abort, timeout, progress, protocol era, extension settings, schema context and injectable fetch. Existing public functions delegate to this. Extension runtime functions accept this interface; façades preserve existing signatures. +3. `src/server/contracts.ts`: `McpServerOptions`, `McpRequestContext`, `McpProviders`, `McpOperationOutcome`; `createMcpServer(options)` returns `{ fetch(request): Promise, close(): Promise }`. + - Context has authenticated host identity, request ID, protocol version, client capabilities, signal, and scoped `reportProgress`; identity does not come from caller-controlled params or clientInfo. + - Providers cover tools, prompts, resources/templates, completion, and subscriptions. Outcome is a discriminated complete/input-required/task union validated centrally. Extension providers plug into the same registry. + - New modern HTTP server handles no implicit session state. A separately gated legacy HTTP shim handles `initialize`/initialized and supported old versions for compatibility, with capabilities restricted to behavior actually implemented. Never put modern tasks/MRTR on a legacy wire. +4. `src/auth/contracts.ts`: `McpAuthPolicy.authenticate(request)` returns validated `{ tenantId, principalId, scopes, authorizationContextId }`; `authorize(identity, operation)` checks selected resource/action. Policy can support intentional public mode, but sensitive adapters require authentication. `authorizationContextId` partitions caches; it is host-assigned and never a raw bearer. +5. `src/extensions/tasks/contracts.ts`: `McpTaskStore` with durable create/get/atomic compare-and-swap update and expiry, plus `McpTaskExecutor` with start/recover/submitInput/requestCancel. Records bind tenant, principal, original method/params, revision, outstanding/answered input keys, backend correlation, status, timestamps, TTL, result/error, and cancellation intent. Store durability precedes returning a handle. Never serialize callbacks or rely on a surviving Promise. +6. `src/interchange/server/contracts.ts`: `InterchangeExposure` explicit bindings for named tools, selected HTTP operations, agents, and workflows. Each binding supplies MCP definition, authorization resource/action, execute adapter, and applicable continuation/task hooks. HTTP selectors use `(method,path)` or a unique declared operationId, never guessed IDs. Identity/tenant/path constants are host-bound, not model-overridable. +7. `src/interchange/client/contracts.ts`: `McpHostServices` supplies user-facing elicitation, task persistence/status, resource/prompt presentation, verified-skill activation and policy callbacks. Missing service means capability is absent or a typed unsupported result, never implicit approval. Host callbacks do not change the wire vocabulary. +8. `src/extensions/skills/contracts.ts`: origin-scoped skill entry/held-manifest, approval receipt, verified bytes and acting-window lease. `McpSkillHostPolicy` must bind provenance and approval to model-context admission and all code-execution tools during the lease. A resource read is not skill activation. Pure verification can return verified bytes without claiming host activation. + +## 5. Real Interchange bindings + +Verified against `packages/agent/src/{index,tool,definition,agent}.ts`, `packages/harness/src/credential-capability.ts`, `packages/hub-api/src/{app,routes/runs,routes/workflows}.ts`, `packages/hub-client/src/workflows.ts`, and installed declarations/JS: + +| Upstream surface | Correct bridge use / limitation | +| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineTool`, `AnnotatedToolFactory`, synchronous `factory(env): ToolBundle`; `ToolBundle.definitions`, `.run(call,signal)`, optional `.dispose()` | Retain stored-catalog synchronous construction. Factory is evaluated only with authorized host env. Bare ToolBundle.run is a dispatcher, not an authorization boundary: MCP exporter explicitly checks grants first. Never instantiate every registered factory to discover arbitrary tools | +| `AgentTool` and `ToolHandler(call, signal): Promise`; `ToolResult` is an Interchange result, not the MCP wire union | Shared adapter must preserve structured/binary content in supported detail/host channels and truthful text fallback. Do not stringify input_required/task into an error and call it lifecycle support | +| `Agent.send(content, {signal})`, `.stream()`, `.deliver()`, `.close()` | send returns reply or `suspended` with correlationId/optional approvalSnapshot. Signal drops queued work or rejects an active waiter; **the active reactor cycle keeps running**. Never close a shared agent as per-call cancellation. Use dedicated workflow run cancellation where termination is required; describe cooperative intent truthfully | +| `BaseEnv.authorize(resource,action,context)` and plugins | Host can compose a skill acting-window authorization gate with existing grants. No inspected export provides a turnkey remote-skill activation API. Host must wire context admission, user approval, execution-tool classification and provenance | +| CredentialCapability.resolve(handle), origin-pinned `http` handle, rotating material source | Agent sees the mediated handle only. Maintain per-tool credential consumer identity, grant checks and origin binding. Never return raw secrets through model context, tool results, tasks, telemetry, or skill caches | +| Hub `/openapi.json`; Hono app through exported `createApp`/`mountHubRoutes` | Use explicit operation allowlists against provided OpenAPI or authored bindings. Many route definitions use summary/tags without operationId; method+path selection must work. Invoke the host-authenticated route/application pipeline, not private route functions or fabricated registration helpers | +| `/api/tenants/:tenantId/workflows/:runId/mail` (POST), `.../:runId/signals` (POST), `.../:runId/runs` and `.../:runId/runs/:eventRunId/events` (GET) | Actual hub-client helpers: triggerWorkflowRun, deliverWorkflowSignal, listWorkflowRuns, readWorkflowRunEvents. Hub-client is not currently installed: avoid adding it merely to make these HTTP calls; use the same verified routes via injected fetch, or deliberately add/test it through the integrator | +| `/api/tenants/:tenantId/workflows/runs/:runId` (GET/DELETE), `.../events` (GET), `.../mail` (POST), `.../lifecycle` (GET) | DELETE requests durable cooperative cancellation; may return 202/409/503. Events are full seq-ordered snapshots: deduplicate by seq, do not assume SSE. Trigger returns runId/address/messageId, not a completed agent answer | +| Workflow signals require runId/signalName/signalId and optional correlationId; approvals have dedicated resolution semantics | `tasks/update` only forwards to configured outstanding input bindings, with stable IDs. Do not translate any accepted form directly into broad approval or arbitrary signal delivery. Re-check approver authority through host approval route/service | + +Workflow invocation uses an explicitly configured deployment/run and result selector. For isolation, prefer a dedicated run per invocation; if sharing a run, the host must provide a proven message/correlation-level result selector. Never report all run events as the result of one invocation. Durable task records store run/message/signal correlation. Persist dispatch intent before executing; reconcile ambiguous crashes, never automatically replay a non-idempotent mail trigger and promise exactly-once execution. + +Route projection is explicit and finite: reject ambiguous IDs, traversal, unsupported body/content-type/stream shapes, undeclared security overrides, and unresolved schema references before exposure. Read-only routes may optionally become resources; mutation/invocation routes become tools. Prompts/resources/skills do not silently become new grantable tools. Catalog visibility and execution authorization use the same bound identity; execute always rechecks authorization. + +## 6. Maturity and compatibility policy + +Registry records `{ id, coreRevision, extensionRevision, sourceCommit, maturity, enabledByDefault:false }`. Known baseline: tasks stable 2026-07-28; skills stable source snapshot above; Apps released 2026-01-26. Package version/SDK generation is not extension maturity. + +- Stable upstream capabilities get ordinary local API names. An actual draft/experimental capability, if deliberately implemented, gets `unstable_` local exports/config plus explicit source pin and migration policy. The wire still uses the upstream identifier and settings exactly. +- No invented `/v2` methods, `-v2` extension IDs, protocol-version strings or task statuses. Existing unsupported/unknown extension settings fail clearly or remain unadvertised. +- Legacy client negotiation remains supported for the versions already documented by this package. New server legacy shim initially targets 2025-11-25, 2025-06-18 and 2025-03-26 Streamable HTTP only as tested. Legacy extension generations are not silently converted into modern ones. No claim that SDK 1.30.1 verifies modern 2026 behavior. +- Deprecated roots/sampling/logging are distinguished from core-required behavior: do not newly advertise them. MRTR parser recognizes standard requests and rejects unsupported capabilities safely. Keep existing DCR as deprecated compatibility fallback; prefer pre-registration/CIMD. Form/URL elicitation is in scope through explicit host hooks. + +## 7. External dependencies and deployment gates + +1. Add `ajv` 8.x, with optional `ajv-formats` if required by the chosen validation policy, and a bounded YAML parser (e.g. `yaml` 2.x) through CL-11478. Pin installed versions in lockfile and test Node 24 and Bun. Do not add MCP SDK to runtime solely for schema validation. Preserve licensing/provenance of copied upstream definitions. +2. oauth-core **already** exposes `OAuthClientConfig.resource`, and token/refresh helpers consume it. Fix `mcpClientConfig` locally to set it; extraAuthorizeParams.resource alone is insufficient. oauth-core callback-server returns only a code; its stock provider helper cannot validate callback iss. Existing `startOAuthLogin` accepts an injected `startCallbackServer`, so implement an issuer-validating local callback adapter and reuse OAuth exchange/state/PKCE machinery. A convenience hook upstream is optional, not a blocker for this PR. +3. No external Interchange repository edit is required for selected routes, tools, basic agents/workflows, mediated credentials, or verified skill host integration through explicit callbacks. Deploying the bridge still requires the host to mount it, bind identity/grants, configure lifecycle/dispatch services, and wire UI/context/approval hooks. Missing services must be reported unavailable, not fabricated. +4. Strong per-cycle cancellation of an existing shared in-process Agent is unavailable in 0.5.0. An upstream companion change is necessary **only if that stronger guarantee is demanded**. MCP cooperative task cancellation does not require it; expose the limitation and use dedicated workflow runs when stopping actual work matters. +5. Production durable task-store choice was asked of the director and remains unspecified. Default plan: injected atomic durable store contract plus an opt-in, restart-tested single-host file-backed reference store. Use an exclusive writer lock, atomic durable writes and explicit recovery; do not claim multi-host support for it. The host supplies a distributed adapter for multi-replica deployment. Test-only memory storage must never satisfy the production task-advertisement gate. +6. A durable store is insufficient without a recoverable executor. On restart reconcile backend operations and persist a truthful terminal failure when recovery is impossible; never leave a fake runnable task or replay non-idempotent effects blindly. Companion host persistence wiring is a deployment dependency, not an external edit folded into PR28. + +## 8. Non-goals and deliberately deferred scope + +This PR does not implement an authorization server, a browser/iframe Apps renderer, a general workflow engine, inference/sampling service, arbitrary OpenAPI compiler, blanket exposure of all hub admin routes, unapproved skill script execution, a distributed task database, or automatic grant widening. It does not add stdio/subprocess management, removed HTTP+SSE transport, SSE redelivery/resumption, deprecated roots/sampling/logging features, proprietary Claude channels, or old tasks-generation compatibility. Modern Streamable HTTP is the supported transport for both roles; transport-independent contracts keep a future stdio binding possible without implying it ships now. No external-repository edits, commits, pushes or issue creation are part of this planning deliverable. Full Apps-host, host-runtime, and product-wide “100% MCP compliance” claims are out of scope; compliance claims must be role-, revision-, transport-, capability- and test-qualified. diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..a91a261 --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,135 @@ +# Conformance evidence and release gates + +Snapshot: 2026-10-25. This matrix records implemented scope, assertion locators +and qualified evidence, not a blanket MCP compliance verdict. The integration +gate passed final QA with 423 tests and 2,116 assertions; qualified build, e2e +and package evidence is recorded separately in integration-status.md. Counts do not prove every +normative clause or deployment. No stale missing-export or mandatory-OAuth +blocker applies to the current package. + +## Revision and maturity matrix + +| Surface | Revision/source | Maturity | Applicability | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------- | +| MCP core | [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) | Released | Stateless Streamable HTTP client/server | +| Legacy MCP client | 2025-11-25, 2025-06-18, 2025-03-26, historical 2024-11-05 negotiation | Compatibility | Initialize/session path; no claim of removed HTTP+SSE transport support | +| Legacy MCP server | 2025-11-25, 2025-06-18, 2025-03-26 | Gated compatibility | Opt-in implemented; session affinity required; final scoped independent legacy approval | +| Tasks | [2026-07-28, b3d28aceaa6d49f5f92258e3d06b61ad6f111e84](https://github.com/modelcontextprotocol/ext-tasks/blob/b3d28aceaa6d49f5f92258e3d06b61ad6f111e84/specification/2026-07-28/tasks.md) | Stable upstream | Opt-in; tools/call only; durable host services required | +| Skills | [stable, c1aa87e4c381c82b99aa37471d0eae4ab50f9985](https://github.com/modelcontextprotocol/ext-skills/blob/c1aa87e4c381c82b99aa37471d0eae4ab50f9985/specification/stable/skills.mdx) | Stable upstream | Core 2026-07-28+; verification is not activation | +| Apps | [2026-01-26, 266a9e3b70dbd4a4f0546dda95da2a2c1a8be4b7](https://github.com/modelcontextprotocol/ext-apps/blob/266a9e3b70dbd4a4f0546dda95da2a2c1a8be4b7/specification/2026-01-26/apps.mdx) | Stable upstream | Explicit host bridge/support/presentation gate; renderer remains host-owned | +| Interchange | Installed @intx packages 0.5.0 | Executable integration baseline | Actual ToolBundle, credential and Agent contracts, not fabricated upstream APIs | +| MCP SDK | 1.30.1, development only | Legacy interoperability oracle | Does not certify 2026 behavior | + +All three extensions are disabled by default. Stable upstream does not mean every +host supports them. No `/v2` MCP methods, extension identifiers or protocol names +are introduced. Actual experimental additions require an `unstable_` local API, +source pin and migration policy. The Apps UI protocol revision is independent of +the core MCP revision. + +[Claude Code documentation](https://code.claude.com/docs/en/mcp) describes a +2026 runtime and task public beta on CLI 2.1.304+ with runtime, provider and surface +restrictions. This is not evidence for universal Claude Desktop/web/CLI support. +Blank community-matrix cells are not positive support evidence. + +## Requirement matrix + +C = standalone client; S = server; IC/IS = Interchange consumption/exposure. +The listed suites provide scoped evidence. Core F1–F11, auth neutral isolation, +Apps gating and large Skills bounds have reported independent approvals. +Legacy has final scoped independent approval; final reconciled QA passed. +Clauses without a dedicated exhaustive +mapping remain **unverified for full clause coverage**, not “missing tests” or +“missing implementation.” + +| ID | Normative clause / requirement | Role / applicability | Current evidence locator | Qualification | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| C01 | [Basic](https://modelcontextprotocol.io/specification/2026-07-28/basic): JSON-RPC envelopes, required per-request _meta | C/S, modern | src/protocol/protocol.test.ts; src/client/contracts.test.ts; src/server/server.test.ts | Scoped suite evidence; full clause coverage not certified | +| C02 | [Versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning): supported revision and no invented version | C/S | src/protocol-2026.test.ts; src/server/server.test.ts; src/server/legacy.test.ts | Scoped suite evidence; full clause coverage not certified | +| C03 | [HTTP sending](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages): POST per message, Accept JSON + SSE, one request/notification | C/S | src/client.test.ts; src/server/server.test.ts; src/server/review-regressions.test.ts | Scoped suite evidence; full clause coverage not certified | +| C04 | [HTTP security](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#security--endpoint): invalid Origin rejected with 403 | S | src/server/server.test.ts (Origin/auth/header/malformed requests) | Scoped suite evidence; full clause coverage not certified | +| C05 | [HTTP metadata](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#request-metadata): method/name/version headers match body, encoding and custom primitive headers | C/S | src/protocol/headers-codec.test.ts; src/client/review-regressions.test.ts | Scoped suite evidence; full clause coverage not certified | +| C06 | [HTTP receiving](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages): JSON/SSE, notifications scoped to request, no independent server request | C/S | src/client/contracts.test.ts; src/client/review-regressions.test.ts; src/server/review-regressions.test.ts | Scoped suite evidence; full clause coverage not certified | +| C07 | [HTTP backward compatibility](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility): discovery fallback but not auth/rate-limit/server failures | C, legacy | src/protocol-2026.test.ts; src/client/review-regressions.test.ts F8; e2e/sdk-transport.test.ts | Scoped suite evidence; full clause coverage not certified | +| C08 | [Discovery](https://modelcontextprotocol.io/specification/2026-07-28/server/discover): truthful version/capability advertisement, no modern sessions | C/S | src/server/server.test.ts (real client and loopback); e2e/protocol-2026.test.ts | Scoped suite evidence; full clause coverage not certified | +| C09 | [Tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools): schema-valid input/output, content, errors, unique catalog names | C/S/IC/IS | src/protocol/protocol.test.ts; src/server/providers.test.ts; src/client.test.ts | Scoped suite evidence; full clause coverage not certified | +| C10 | [Prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts), [resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources), [completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion): feature-specific shapes, cursors and capabilities | C/S | src/protocol-2026.test.ts; src/protocol/protocol.test.ts; src/server/providers.test.ts | Scoped suite evidence; full clause coverage not certified | +| C11 | [Caching](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching): cache fields; authenticated cache isolation | C/S/IC/IS when caching | e2e/protocol-2026.test.ts; src/interchange/client/catalog.test.ts (host cache policy remains deployment-specific) | Scoped suite evidence; full clause coverage not certified | +| C12 | [MRTR supported requests](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#supported-requests): only tools/call, prompts/get, resources/read permit input_required | C/S | src/protocol/protocol.test.ts; src/client/review-regressions.test.ts F2/F3 | Scoped suite evidence; full clause coverage not certified | +| C13 | [MRTR client workflow](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow): opaque latest requestState, original params, fresh ID | C/IC | src/protocol-2026.test.ts; src/interchange/client/continuation.test.ts | Scoped suite evidence; full clause coverage not certified | +| C14 | [Elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation): explicit form/URL support, validated responses, no implicit consent | C/S/IC/IS when opted in | src/client/review-regressions.test.ts F9; src/extensions/host.test.ts; src/interchange/client/projection.test.ts | Scoped suite evidence; full clause coverage not certified | +| C15 | [Progress](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress): request-local progress only | C/S | src/client/review-regressions.test.ts F11; src/server/server.test.ts | Scoped suite evidence; full clause coverage not certified | +| C16 | [Cancellation](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation): modern HTTP stream closure signals cancellation, no cancelled notification | C/S | src/client/review-regressions.test.ts F5; src/server/server.test.ts; src/server/legacy.test.ts | Scoped suite evidence; full clause coverage not certified | +| C17 | [Subscriptions](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions): negotiated filters, only allowed change notifications, terminal complete result | C/S when provided | src/client/review-regressions.test.ts F7/F11; src/server/review-regressions.test.ts | Scoped suite evidence; full clause coverage not certified | +| C18 | [Authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization): protected-resource auth, challenge and operation authority | S/IS protected endpoints | src/auth/security.test.ts; src/auth/flows.test.ts; src/server/server.test.ts; e2e/interchange.test.ts | Guarded/neutral auth approved; stock dependency limits remain | +| C19 | [AS discovery](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery): issuer exactness, resource binding, PKCE, registration policy | C/hub OAuth | src/oauth-discovery.test.ts; src/auth/security.test.ts; src/auth/review-regressions.test.ts | Guarded/neutral auth approved; stock dependency limits remain | +| C20 | [Deprecated features](https://modelcontextprotocol.io/specification/2026-07-28/deprecated) | All | Explicit modern non-goals; legacy ping/cancellation are revision-scoped | Scoped exclusions; see legacy-server.md | +| T01 | Tasks Capability Negotiation / Supported Methods: opt-in each request, tools/call only, missing capability error | C/S/IC/IS tasks | src/server/extensions.test.ts; src/protocol/protocol.test.ts | Scoped suite evidence; full clause coverage not certified | +| T02 | Tasks Task Status: working/input_required/completed/failed/cancelled; completed tool error differs from JSON-RPC failed | C/S tasks | src/extensions/tasks/tasks.test.ts; src/client/review-regressions.test.ts F3 | Scoped suite evidence; full clause coverage not certified | +| T03 | Tasks get/update/cancel: taskId header and outstanding input keys | C/S tasks | src/extensions/tasks/tasks.test.ts; src/protocol/headers-codec.test.ts | Scoped suite evidence; full clause coverage not certified | +| T04 | Tasks durability + scope §§4,7: persist before handle; tenant/principal isolation, TTL, CAS and recoverable executor | S/IS tasks | src/extensions/tasks/tasks.test.ts; e2e/fixtures/packed-standalone.mjs | Scoped suite evidence; full clause coverage not certified | +| T05 | Scope §5: workflow correlation and truthful cooperative cancellation | IS tasks/workflows | src/interchange/server/accepted.test.ts; src/interchange/server/server.test.ts | Scoped suite evidence; full clause coverage not certified | +| K01 | Skills Capability Negotiation: resources capability, list/get; directoryRead only if declared | C/S skills | src/server/extensions.test.ts; src/interchange/client/skills.test.ts | Scoped suite evidence; full clause coverage not certified | +| K02 | Skills Skill Format / Resource Mapping: root SKILL.md, YAML name/description, name/path match, no scheme-only identity | S/IC skills | src/extensions/skills/skills.test.ts | Scoped suite evidence; full clause coverage not certified | +| K03 | Skills manifest integrity/security + scope §4: all file hashes, byte limits, origin and held-manifest binding | C/S/IC skills | src/extensions/skills/skills.test.ts; src/interchange/client/skills-large.test.ts; src/protocol/resource-bounds.test.ts | Independent authenticated-loopback/bounds approval, including 16 MiB | +| K04 | Scope §4 host activation: approval/provenance bound to context admission and all execution tools for acting window | IC skills | src/extensions/skills/verification-race.test.ts; src/extensions/skills/skills.test.ts | Scoped suite evidence; full clause coverage not certified | +| A01 | Apps Capability Negotiation / Tool-UI Linkage: opt-in, metadata preserved, app-only tools not model-visible | C/S/IC/IS UI metadata | src/interchange/client/apps.test.ts; src/extensions/host.test.ts | Scoped independent approval (74 tests + 6 probes); host renderer not certified | +| A02 | Apps UI Resource Format: ui:// and text/html;profile=mcp-app; host sandbox/CSP | S metadata; browser host excluded | src/extensions/host.test.ts; src/interchange/client/apps.test.ts (actual renderer is host-owned) | Scoped independent approval (74 tests + 6 probes); host renderer not certified | +| I01 | Scope §§2,5: real ToolBundle dispatch, explicit selected exposure, execution rechecks grants | IC/IS | e2e/interchange.test.ts; src/interchange/server/server.test.ts | Scoped suite evidence; full clause coverage not certified | +| I02 | Scope §§2,5: synchronous stored catalogs, credential consumer identity and origin pinning, destructive ask floor | IC | src/sidecar-bundle.test.ts; src/tool.test.ts; src/hub/discover.test.ts | Scoped suite evidence; full clause coverage not certified | +| I03 | Scope §5: preserve structured/binary/private detail and truthful lifecycle projection | IC | src/interchange/client/projection.test.ts; src/interchange/client/integration.test.ts | Scoped suite evidence; full clause coverage not certified | +| P01 | Scope §2: four entries, historical facades, declarations and runtime dependency isolation | Package | e2e/package.test.ts; installed tarballs on Bun 1.4.2 / Node 24.22.1 | Installed boundary passes; latest counts in integration-status.md | + +## Implemented compatibility and review scope + +The server implements opt-in 2025-11-25/06-18/03-26 Streamable HTTP sessions; +modern remains stateless. See [legacy deployment and limits](legacy-server.md). +Legacy now has final scoped approval: 50 tests / 319 assertions plus 5 +independent tests / 63 assertions, as reported by the director. Both header and +shutdown repairs are closed. This is separate from the approved core F1–F11 +repairs and does not substitute for final reconciled QA. No removed HTTP+SSE or resumption support is claimed. + +Apps advertisement requires explicit host bridge support, sandbox renderer, +CSP enforcement, authenticated bridge and presentation. Metadata-only handling +never advertises Apps. The host still supplies the actual renderer and enforces +its security properties; the library's gate is not a deployed-browser audit. +Skills verification supports bounded 16 MiB decoded resources; independent +review exercised actual authenticated loopback and all bounds. Verification is +not approval to admit context or execute code. + +The verified Skills adapter declines dynamic manifests, as permitted by the +pinned specification. It requires complete resource reads with exactly one +matching text/blob item; resource-read MRTR is unsupported by that adapter. +These are explicit supported-shape limits, not a reopening of the approved +resource bounds or a claim that dynamic consumption is mandatory. + +## Remaining actual requirements + +- **CL11479 — external OAuth-core defects:** stock 0.3.0 token/profile/storage + requires refresh tokens; stock token POST follows redirects and exposes raw + response errors; authorize extras can overwrite state/PKCE. Guarded MCP + sessions avoid those paths but do not fix stock OAuth-core. Stock hub DCR-only + selection and present-but-unadvertised issuer handling are additional documented + integration limits, not silently added issue acceptance criteria. Resource + exchange/refresh propagation already exists upstream; the local config omission + was fixed. See [guarded auth and companion limits](public-api.md). +- **CL11480 — external credential-http defects:** old harness/types peer ranges + block the pinned full hub install; Request+init semantics and insecure configured + destination schemes also need upstream correction. Neutral installs pass and + must not be conflated with this optional host failure. +- **Host obligations:** trusted identity/grants, cryptographic verification or + introspection, credential storage, callback/registration setup, explicit selected + bindings, durable recoverable executors, session affinity, skill approval and + context lifecycle, and Apps renderer/bridge security. These are injected + application responsibilities, not invented missing library features. +- **Unverified deployments/clauses:** production DB transactions, live OAuth + services and callback deployment, distributed lifecycle durability, browser + sandbox enforcement, and exhaustive revision-by-clause coverage require their + own evidence. No 100% conformance or production certification is asserted. + +## Gates and provenance + +`bun run check`, `bun run test:e2e`, and the serial installed-package gate provide +complementary evidence; exact commands, versions, counts and exit codes are in +[integration-status.md](integration-status.md). Independent Apps (74 tests plus +6 probes), authenticated large-Skills, core and auth approvals are reported by +the director; they are not relabeled as fresh executions by this docs lane. diff --git a/docs/integration-status.md b/docs/integration-status.md new file mode 100644 index 0000000..66f90df --- /dev/null +++ b/docs/integration-status.md @@ -0,0 +1,98 @@ +# CL-11478 final documentation and package snapshot + +Snapshot: 2026-10-25, PR28 commit preparation. All implementation reviews, +including final scoped legacy review, are approved; final QA and commit-preparation verification report 423 passing +unit tests and 2,116 assertions. This is not a 100% conformance claim. + +## Current package and compatibility + +- Public role factories/contracts are exported and exercised through installed + tarballs. File-store is explicit `@corbits/mcp/server/task-store` only. +- OAuth-core is an optional peer `^0.3.0` and pinned development dependency 0.3.0. + Root/login consumers install it explicitly. Neutral client/server installations + and module graphs exclude optional host stacks. Host/auth fixtures explicitly + install OAuth-core 0.3.0 and agent/harness 0.5.0. +- Opt-in server legacy compatibility is implemented for 2025-11-25/06-18/03-26. + [Legacy deployment](legacy-server.md) documents defaults (1,024 sessions, + absolute 30-minute TTL), in-memory affinity/restart loss, 10,000 request IDs, + private initialization authority, March batches and GET405/no resumption. + Modern remains stateless by default. +- Apps advertisement requires explicit host bridge/support, sandbox/CSP and + authenticated-bridge commitments, plus presentation. Metadata-only handling + does not advertise Apps. Actual renderer enforcement remains host-owned. +- Large Skills resources support bounded 16 MiB decoded reads with finite wire + allowance and independent metadata bounds; authenticated loopback and bounds + were independently approved, not inferred from artifact smoke counts. + +## Exact executable evidence + +| Command | Exit | Result | +| ---------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm exec --yes --package=node@24 -- sh -c 'node --version; bun run test:package'` | 0 | **Node 24.22.1**, Bun 1.4.2; **13 pass / 0 fail**, 39 top-level assertions; one serial build/pack/install gate after legacy fixture addition | +| `npm exec --yes --package=node@24 -- sh -c 'node --version; bun run test:e2e'` | 0 | **Node 24.22.1**, Bun 1.4.2; **22 pass / 0 fail**, 80 assertions | +| `bun run build` | 0 | Commit-preparation declaration/package build | +| `bun run check` | 0 | Final QA and commit preparation: typecheck/lint/format and **423 pass / 0 fail**, **2,116 assertions** | + +Ordinary npm installation is used without `--omit=peer`, `--force` or +`--legacy-peer-deps`. The 13 current tests cover public export/declarations, +peer-free imports, publisher/loader/task recovery/resource-auth/guarded OAuth +behavior, legacy initialization/capacity/GET405 for three revisions, and both +neutral graphs. They do not substitute for full revision review. Final migration +and legacy artifact evidence is **Bun + Node24**; no final Node26 claim is made. + +Final QA supersedes the earlier reconciliation formatting failure. Commit +preparation reran all four gates above serially, including fresh artifact gates +after the test-only cancellation correction. All exited 0; source checks ran +with Bun 1.4.2 (shell Node 26.10.0), and artifact execution used Node 24.22.1. +No product behavior changed during commit preparation. + +Director-reported independent approvals cover core F1–F11, authorization, +extensions, Interchange adapters, 16 MiB Skills bounds, and modern/legacy server +behavior. Final scoped legacy evidence includes 50 tests / 319 assertions plus +5 independent tests / 63 assertions, with header and shutdown repairs closed. +These approvals remain scoped, not exhaustive revision certification. + +## External follow-ups versus host obligations + +**CL11479 (OAuth-core):** stock token-only grant conversion/storage requires a +refresh token; token POST redirect/error handling and extra state/PKCE override +behavior need correction. Guarded MCP login avoids relevant unsafe paths but +stock helpers remain unchanged. Stock hub DCR-only selection and present-but- +unadvertised issuer handling are additional integration limits, distinct from +exact issue acceptance. Resource exchange/refresh propagation exists upstream; +this package's local config omission was fixed. + +**CL11480 (credential-http):** full pinned hub installation fails npm ERESOLVE: +0.1.0 requires harness/types ^0.4.0 against the 0.5.0 baseline. A companion +release validated with and declaring support for both 0.5.0 peers is needed; +no fixed release number is established. Request+init overrides and insecure +configured schemes also require upstream correction. These optional-host +failures do not block the passing neutral installation. + +Previously reproduced external install (exit 1): + +```sh +npm install --prefix "$peer_dir" --ignore-scripts --no-audit --no-fund @corbits/credential-http@0.1.0 @intx/agent@0.5.0 @intx/harness@0.5.0 @intx/db@0.5.0 @intx/hub-api@0.5.0 @intx/types@0.5.0 drizzle-orm@0.45.3 hono@4.13.9 +``` + +**Host obligations:** trusted identity/grants, real token verifier/introspection, +encrypted persistence, callback/registration/consent setup, egress/DNS policy, +selected bindings, recoverable executors/storage ownership, legacy affinity, +skill approval/context lifecycle, and actual Apps renderer/bridge security. +These are injected deployment contracts, not missing library implementations. + +**Unverified scope:** live production OAuth/DB/listener deployments, distributed +lifecycle durability, actual browser sandbox enforcement and exhaustive +revision-by-clause certification. The [requirement matrix](conformance.md) +retains these qualifications rather than labeling resolved code/tests missing. + +## Final narrow documentation corrections + +Stored-catalog synchronous construction is correctly attributed to `mcpServers`; +`mcpTools` performs asynchronous live discovery. The stale README OAuth URL +scope disclaimer was removed. Packaged public API/conformance docs explicitly +state verified Skills supported shapes: dynamic manifests declined (permitted +by the pinned specification), exactly one matching text/blob item in a complete +resource read, and no resource MRTR in that adapter. No implementation scope was +expanded. Commit preparation preserves the reviewed product behavior and runs +the repository gates serially. diff --git a/docs/legacy-server.md b/docs/legacy-server.md new file mode 100644 index 0000000..4828b86 --- /dev/null +++ b/docs/legacy-server.md @@ -0,0 +1,58 @@ +# Opt-in legacy Streamable HTTP server + +`createMcpServer` is modern/stateless by default. To enable identity-bound 2025 +sessions, configure the public `legacy` option: + +```ts +import { createMcpServer } from "@corbits/mcp/server"; + +const server = createMcpServer({ + serverInfo: { name: "example", version: "1" }, + auth: "public", // For private servers, supply an authenticating/authorizing policy. + legacy: { + versions: ["2025-11-25", "2025-06-18", "2025-03-26"], + maxSessions: 1024, + sessionTtlMs: 30 * 60_000, + }, +}); +``` + +Omitting individual legacy settings uses those defaults. Omitting `legacy` +entirely does not create legacy sessions. Modern requests stay stateless even +when legacy support is enabled. Public types `McpLegacyOptions` and +`McpLegacyRevision` are available through the server entry. + +## Deployment and compatibility limits + +- Sessions live in memory in one server instance. Route all requests for a + session to that instance (affinity); restart loses sessions and clients must + initialize again. This is not a distributed session store. +- The default absolute lifetime is 30 minutes, not extended by traffic. Capacity + defaults to 1,024 sessions; exhaustion refuses a new initialization. +- A session permits at most 10,000 unique request IDs (including initialization). + IDs cannot be reused; initialize a new session when the limit is reached. +- Private initialization requires authorization for resource `mcp:server`, action + `initialize`. Sessions bind tenant/principal/authorization context, and calls + recheck authorization. Session possession is not a grant. +- Only 2025-03-26 accepts supported JSON-RPC request/notification batches. + Initialization cannot be batched; later revisions reject batches. +- GET returns 405. Removed HTTP+SSE, independent GET streams and resumption are + not implemented. Legacy cancellation uses the session-scoped explicit + notification, not modern stream-close cancellation semantics. +- No translation of modern tasks/skills/MRTR into older extension generations is + implied. The legacy shim exposes only supported legacy methods/results. + +## Evidence scope + +`src/server/legacy.test.ts` covers actual SDK clients over loopback HTTP for all +three revisions, session lifecycle/bounds, identity/authorization, March batches, +malformed envelopes, result projection and cancellation. Legacy review now has +final scoped approval, reported by the director: 50 tests / 319 assertions plus +5 independent tests / 63 assertions. Both header and shutdown repairs are closed. +Final reconciled QA remains pending; this approval is not universal revision +certification. + +`e2e/fixtures/packed-legacy.mjs` checks installed public factory options for all +three revisions, initialization/session headers, configured capacity and GET405 +under Bun and Node24. It is a public-artifact smoke, not a substitute for the +full server or independent revision review. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..125fb7f --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,127 @@ +# Bidirectional API migration + +## Choose the narrow entry + +```ts +// Before: the historical root also imports the Interchange tool facade. +import { mcpConnect } from "@corbits/mcp"; +// After, for standalone usage: +import { mcpConnect } from "@corbits/mcp/client"; +``` + +The root, `@corbits/mcp/sidecar-bundle`, and `@corbits/mcp/hub` remain supported. +Do not change stored tool names, credential consumers or bundle IDs to migrate. +`@corbits/mcp/interchange/client` owns remote-MCP consumption; +`@corbits/mcp/interchange/server` owns explicit host exposure. +The standalone server entry exports `createMcpServer` and `createToolProviders`. + +Agent/harness peers are optional at package-install time, not optional when +executing their adapters. Install `@intx/agent` and `@intx/harness` for the +historical root/Interchange consumption. Hub deployments additionally install +the hub's documented credential, DB, Hono and authorization dependencies. +The smoke test installs the packed package without peers to check standalone +imports; this is runtime import isolation, not a promise of zero dependencies. +AJV 8.20.0 and YAML 2.8.1 are lockfile-selected runtime dependencies. JSON Schema +validation must not resolve network references implicitly. + +## Preserve execution and authorization semantics + +- Stored-catalog `mcpServers(...)` construction stays synchronous and performs no + network discovery at factory construction. Live `mcpTools(...)` stays async. +- Qualified names remain `.`. The `mcp-server` credential and + `@corbits/mcp/servers` consumer identity are unchanged. Do not widen grants. +- All tools start ask-marked; explicit safe-tool allowlists cannot lower a + destructive tool's ask floor. Calling a bare ToolBundle dispatcher does not + itself perform host authorization. +- Expose a finite list with `createInterchangeProviders`. Bind tenant, resource, + action and approval in the host. Catalog visibility is not execution approval; + authorization is checked again on every invocation. +- Credential handles are mediated, origin-pinned fetches. Raw bearer values must + never enter agent tool arguments/results, skills, task records or model context. +- Private Interchange `detail` is not automatically exported over MCP. An explicit + result projection is required when a host deliberately exposes richer data. + +## Lifecycle results are not ordinary tool output + +Narrow `resultType` before reading complete-result fields. `input_required` may +repeat: fulfill only supported requests through the host UI/policy, then call +`mcpContinue` with the latest pending result. Its opaque state may be empty or +absent; do not reuse an older state's value. Decline/cancel is not approval. +Tasks are negotiated per request and only augment `tools/call` in the pinned +revision. Missing lifecycle services mean unavailable/unsupported, not success, +a fabricated pending marker, or a new grantable tool. + +For durable tasks, supply atomic persistence and a recoverable executor. An +in-memory store or surviving Promise is not production durability. A reference +single-host file store does not imply distributed/multi-writer support. Persist +dispatch intent before non-idempotent workflow mail and reconcile ambiguous +crashes rather than automatically replaying them. Bind result selection to the +specific run/message/correlation, not all events of a shared workflow. + +`Agent.send` cancellation in Interchange 0.5.0 cancels the waiter, not the active +reactor cycle. Never close a shared agent for a single MCP call. Dedicated +workflow runs can request durable cooperative cancellation, which still is not +an immediate termination guarantee. + +## Security tightening + +Malformed wire envelopes, unsupported polymorphic result types, wrong header +mirrors, invalid schema/data, unauthorized selections and unnegotiated extension +operations are errors rather than permissive passthrough. Modern HTTP cancellation +closes the response stream; it must not send the stdio cancellation notification. +OAuth issuer equality is exact (trailing-slash aliases are not equivalent); +resource indicators apply to authorization, code exchange and refresh. Reject +credential-bearing/fragment endpoints and traversal before URL normalization. +These corrections intentionally reject unsafe inputs previously accepted. + +## Extension deployment checklist + +- Tasks: opt-in capability, durable store, recoverable backend, identity isolation, + TTL cleanup, explicit outstanding-input and cancellation authority. +- Skills: verified origin-scoped manifest and bytes, bounded YAML/parser/filesystem + access, explicit approval and provenance. Verification/read is not activation: + the host must gate context admission and all code-execution tools during the + approved acting window, including revocation/expiry. +- Apps: advertisement requires explicit host support and bridge, sandbox/CSP/ + authenticated-bridge commitments, and presentation. Metadata-only handling + never advertises Apps. The host supplies and enforces the actual renderer, + origins and UI lifecycle; the package gate does not implement that renderer. +- HTTP operations: authored finite method/path bindings, host-authenticated app + dispatch, fixed tenant/path/origin and explicit result mapping. No automatic + exposure of every OpenAPI operation or hub administration route. + +See [conformance evidence](conformance.md) for revision pins and pending coverage. +No release should claim full conformance while those requirements lack test evidence. + +## Public extension factories and guarded authorization + +The explicit server-only `@corbits/mcp/server/task-store` subpath exposes +`createFileTaskStore`; it is not imported by the neutral client. Guarded OAuth +host APIs are now exported at the historical root (`createMcpAuthorizationSession`, +`prepareMcpClient`, `refreshMcpOAuthTokens`, callback and auth helpers). +Root import consumers still need their historical Interchange dependencies. + +Publisher/task-runtime/server-auth and host skill-loader factories are now exported +through their role entries and exercised from installed tarballs. OAuth-core is now an optional peer: historical root and guarded login consumers +must explicitly install `@corbits/oauth-core@^0.3.0` alongside their agent/harness +peers. Neutral client/server consumers need none of those optional host packages. +Packed auth fixtures explicitly install OAuth-core 0.3.0. See +[public API](public-api.md) for the neutral URL-error class distinction and the +companion credential-http harness/types 0.5.0 support requirement. + +## Legacy server rollout + +Legacy compatibility is implemented behind `createMcpServer({ legacy: ... })`, +not enabled by default. See [legacy-server.md](legacy-server.md) for the exact +three revisions, session affinity/restart loss, default 1,024-session/30-minute +bounds, 10,000 request IDs, initialization authorization, March-only batches and +GET405/no-resumption limits. Modern requests remain stateless. + +## External companion follow-ups + +CL11479 tracks stock OAuth-core token-only/redirect/error/protected-parameter +defects, not a generic deployment task. CL11480 tracks credential-http peer +compatibility plus Request+init and configured-scheme policy defects. Guarded +local login and neutral package isolation do not repair those external stock +paths. See [public API limits](public-api.md); do not force dependency resolution +or infer production-host approval from installed fixture results. diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..26fd6a4 --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,106 @@ +# Public extension and authorization API + +Status: integration followup, 2026-10-25. Factory exports are implemented and +installed behavior is tested; OAuth-core is an optional host peer. + +| Entry | Public API | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@corbits/mcp/client` | Standalone client functions and wire types | +| `@corbits/mcp/server` | Server/provider factories, `createSkillPublisher`, skill contracts, `createTaskRuntime`, `validateTaskRecord`, task contracts, `createMcpResourceServerAuth`, auth policy/identity/options types | +| `@corbits/mcp/server/task-store` | `createFileTaskStore` (explicit filesystem entry only) | +| `@corbits/mcp/interchange/client` | Existing tool/server/host APIs plus `createSkillLoader`, `parseSkillFrontmatter`, `SKILL_LIMITS`, skill contracts | +| `@corbits/mcp/interchange/server` | Explicit selected host exposure contracts and providers | +| `@corbits/mcp` | Historical facade and guarded authorization APIs | + +Facade changes add reexports only. No filesystem store is exported through a +neutral barrel. The file store requires a private host-owned directory and one +writer; stale-lock recovery requires proof the previous writer is dead. + +## Dependency boundary and setup + +OAuth-core is an optional peer (`^0.3.0`) and pinned development dependency +(0.3.0). Neutral client/server entries do not require it. Root and guarded OAuth +login users must explicitly install OAuth-core plus the root's agent/harness +peers; this is a breaking change from automatic OAuth installation. Host/auth +packed fixtures explicitly install these dependencies. OAuth-core itself still +transitively installs DB/hub/Hono; that cost belongs to the opt-in host setup. + +Resource-server URL validation now uses the neutral auth URL-policy seam. Its +errors retain the diagnostic `OAuthDiscoveryError` name/message/detail but are +not instances of OAuth-core's error class. OAuth-facing helpers translate to the +actual OAuth-core `OAuthDiscoveryError` class. Neutral consumers must not import +the optional OAuth implementation merely to perform an `instanceof` check. + +```sh +npm install @corbits/mcp @corbits/oauth-core@0.3.0 @intx/agent@0.5.0 @intx/harness@0.5.0 +``` + +The full hub peer set does not resolve: credential-http 0.1.0 declares harness +**and types** `^0.4.0`, incompatible with the pinned 0.5.0 baseline. A companion +credential-http release validated with and declaring support for both +`@intx/harness@0.5.0` and `@intx/types@0.5.0` is needed. No known release number +is asserted; no force/legacy-peer-deps or external patch was used. + +## Installed evidence + +The final optional-peer tarball fixtures pass on Bun 1.4.2 and Node 24.22.1: +publisher bytes/metadata, task close/reopen/recovery without replay and principal +isolation, injected resource-auth verifier/challenge/metadata, skill verification +and refusal to activate without policy, historical facade identity, and guarded +OAuth PKCE/resource/cancellation. These are scoped fixture assertions, not live +OAuth interoperability, cryptographic-verifier certification or full conformance. + +Package tests use ordinary npm installs (no peer omission), reject absent public +exports, assert unwanted dependencies are absent, and check both neutral module +graphs. These checks validate the dependency boundary; see [integration status](integration-status.md) for command evidence. + +## Legacy public options and Apps gate + +The server entry exposes `McpLegacyOptions` / `McpLegacyRevision`; the installed +legacy smoke exercises `createMcpServer({ legacy: ... })` for all three 2025 +revisions. See [legacy-server.md](legacy-server.md) for bounds and deployment +requirements. Modern mode stays session-free by default. + +Interchange Apps advertisement requires explicit `apps` bridge/support and +presentation, including sandbox renderer, CSP enforcement and authenticated +bridge support. Metadata-only presentation does not advertise Apps. The host +must implement the renderer and enforce these commitments. + +## Guarded path versus external companion defects + +**CL11479:** OAuth-core 0.3.0 stock BaseTokens/conversion/storage requires refresh +material even for valid access-token-only grants. Stock token POST follows +redirects and exposes raw response error bodies, and authorization extras can +overwrite protected state/PKCE. The guarded MCP login/session path supports +access-only tokens through honest host persistence, refuses redirects, sanitizes +errors and rejects protected overrides; it does not fix stock helpers. + +Additional documented stock hub limits are DCR-only registration selection and +present-but-unadvertised `iss` not being checked on the stock callback path. +These integration limits are distinct from the exact CL11479 issue acceptance. +Use guarded preparation/session/callback seams rather than assuming the stock +provider/hub path has equivalent checks. OAuth-core already propagates resource +on code exchange and refresh; the local config-builder omission was fixed, not +an absent upstream feature. + +**CL11480:** Besides the harness/types peer conflict above, credential-http 0.1.0 +ignores Request+init overrides in its Request branch and permits insecure +configured credential destination schemes. Local hub endpoint/redirect checks +do not fix standalone external provider handles. Upstream correction and host +transport policy remain required; no external package was patched here. + +The source audit is recorded in `src/auth/INTEGRATION.md`; this public summary +keeps the relevant limits available in the packed docs. Production encrypted +storage/authority, live callback listeners, consent, actual cryptographic token +verification, egress/DNS policy and distributed durability remain host obligations. + +## Verified Skills supported shapes + +The verified Skills loader declines dynamic manifests. This refusal is permitted +by the pinned Skills specification; dynamic consumption is not a newly missing +mandatory capability or an expansion of this adapter's implementation scope. +The adapter requires a complete resource read containing exactly one text or +blob item whose URI matches the requested resource. Resource-read MRTR +(`input_required`) is unsupported by this verified adapter; generic client MRTR +support does not imply that the Skills adapter can fulfill it. These supported- +shape limits are separate from the approved decoded-size and integrity bounds. From ffb3b76c093fc5c3ac272bdf63f9274756f01e54 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:00:48 -0700 Subject: [PATCH 19/28] fix(tasks): reject input when the task is not waiting submitInput no longer CAS-writes a task that is not input_required or not waiting for the submitted keys, including after a concurrent transition. --- src/extensions/tasks/index.ts | 5 +- src/extensions/tasks/tasks.test.ts | 94 ++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/extensions/tasks/index.ts b/src/extensions/tasks/index.ts index 96c9855..113e3f8 100644 --- a/src/extensions/tasks/index.ts +++ b/src/extensions/tasks/index.ts @@ -158,7 +158,8 @@ export function createTaskRuntime(options: { let accepted: InputResponses = {}; const next = await update(identity, taskId, (record) => { accepted = {}; - if (record.task.status !== "input_required") return record; + if (record.task.status !== "input_required") + fail("Task is not waiting for input"); for (const [key, response] of Object.entries(responses)) if ( Object.hasOwn(record.task.inputRequests, key) && @@ -174,6 +175,8 @@ export function createTaskRuntime(options: { }; delete record.task.inputRequests[key]; } + if (!Object.keys(accepted).length) + fail("Task is not waiting for input"); if (!Object.keys(record.task.inputRequests).length) { const { inputRequests: _requests, ...base } = record.task; record.task = { ...base, status: "working" }; diff --git a/src/extensions/tasks/tasks.test.ts b/src/extensions/tasks/tasks.test.ts index e084f53..d67d3ae 100644 --- a/src/extensions/tasks/tasks.test.ts +++ b/src/extensions/tasks/tasks.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createFileTaskStore } from "./file-store.js"; import { createTaskRuntime, pollTask, validateTaskRecord } from "./index.js"; -import type { McpTaskExecutor, McpTaskIdentity } from "./contracts.js"; +import type { + McpTaskExecutor, + McpTaskIdentity, + McpTaskStore, +} from "./contracts.js"; import type { DetailedTask, InputRequests } from "../../protocol/index.js"; const identity: McpTaskIdentity = { tenantId: "t", @@ -136,10 +140,16 @@ test("input authorization, partial answers, lifetime key uniqueness, cooperative expect((await runtime.get(identity, initial.taskId)).status).toBe( "input_required", ); - await runtime.submitInput(identity, initial.taskId, { - approval: { action: "accept", content: { yes: true } }, - }); + const answeredRevision = (await f.store.get(initial.taskId))!.revision; + await expect( + runtime.submitInput(identity, initial.taskId, { + approval: { action: "accept", content: { yes: true } }, + }), + ).rejects.toThrow("not waiting"); expect(submissions).toBe(1); + expect((await f.store.get(initial.taskId))!.revision).toBe( + answeredRevision, + ); await expect( runtime.transition(identity, initial.taskId, { ...initial, @@ -199,6 +209,82 @@ test("expired tasks are inaccessible and removed; ambiguous dispatch is durable await f.cleanup(); } }); +test("submitInput fails without a revision bump unless the task is waiting for that input", async () => { + const f = await fixture(); + let submissions = 0; + const countInput: McpTaskExecutor = { + ...executor, + async submitInput() { + submissions++; + }, + }; + try { + const runtime = createTaskRuntime({ + store: f.store, + executor: countInput, + async authorizeInput() {}, + }); + const created = await runtime.create(identity, { name: "work" }); + const before = (await f.store.get(created.taskId))!; + await expect( + runtime.submitInput(identity, created.taskId, { + approval: { action: "accept", content: { yes: true } }, + }), + ).rejects.toThrow("not waiting"); + const after = (await f.store.get(created.taskId))!; + expect(after.revision).toBe(before.revision); + expect(after.answered).toEqual({}); + expect(after.task.status).toBe("working"); + expect(submissions).toBe(0); + await runtime.transition(identity, created.taskId, { + ...created, + status: "input_required", + inputRequests: requests, + }); + const waiting = (await f.store.get(created.taskId))!; + let raced = false; + const store: McpTaskStore = { + durable: true, + create: (record) => f.store.create(record), + get: (taskId) => f.store.get(taskId), + list: () => f.store.list(), + expire: (now) => f.store.expire(now), + async compareAndSwap(taskId, revision, next) { + if (!raced) { + raced = true; + const current = await f.store.get(taskId); + if (!current) return false; + const { inputRequests: _pending, ...task } = current.task; + await f.store.compareAndSwap(taskId, current.revision, { + ...current, + revision: current.revision + 1, + task: { ...task, status: "working" }, + }); + return false; + } + return f.store.compareAndSwap(taskId, revision, next); + }, + }; + const racing = createTaskRuntime({ + store, + executor: countInput, + async authorizeInput() {}, + }); + await expect( + racing.submitInput(identity, created.taskId, { + approval: { action: "accept", content: { yes: true } }, + }), + ).rejects.toThrow("not waiting"); + const racedRecord = (await f.store.get(created.taskId))!; + expect(raced).toBe(true); + expect(racedRecord.revision).toBe(waiting.revision + 1); + expect(racedRecord.answered).toEqual({}); + expect(racedRecord.task.status).toBe("working"); + expect(submissions).toBe(0); + } finally { + await f.cleanup(); + } +}); test("polling persists identity, honors interval, deduplicates input and returns final result", async () => { const initial: DetailedTask = { taskId: "id", From 2c7da45f59f3e3d75eefb433146f2fc72f61e52a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:03:18 -0700 Subject: [PATCH 20/28] fix(skills): keep distinct directory children and snapshot holds --- src/extensions/skills/index.ts | 51 +++++---- src/extensions/skills/skills.test.ts | 21 ++++ .../skills/verification-race.test.ts | 108 +++++++++++++++++- 3 files changed, 159 insertions(+), 21 deletions(-) diff --git a/src/extensions/skills/index.ts b/src/extensions/skills/index.ts index 345d50a..35dd1c4 100644 --- a/src/extensions/skills/index.ts +++ b/src/extensions/skills/index.ts @@ -31,6 +31,14 @@ const canonical = (value: unknown): string => { .join(",")}}`; return JSON.stringify(value) ?? fail("Non-JSON frontmatter"); }; +function skillBody(entry: Skill): string { + const resources = entry.resources; + if (resources === "dynamic") return fail("Dynamic skills unsupported"); + return canonical({ + frontmatter: entry.frontmatter, + resources: [...resources].sort((a, b) => a.uri.localeCompare(b.uri)), + }); +} async function digest(bytes: Uint8Array): Promise { const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)); return `sha256:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("")}`; @@ -165,32 +173,34 @@ export function createSkillLoader( ): Promise { if (Object.values(origin).some((v) => !v)) fail("Host origin identity required"); - checkEntry(entry); + const snapshot = structuredClone(entry); + const originSnapshot = structuredClone(origin); + checkEntry(snapshot); const identity = canonical([ - origin.serverId, - origin.tenantId, - origin.principalId, - origin.authorizationContextId, - entry.uri, + originSnapshot.serverId, + originSnapshot.tenantId, + originSnapshot.principalId, + originSnapshot.authorizationContextId, + snapshot.uri, ]); - const resources = entry.resources; + const resources = snapshot.resources; if (resources === "dynamic") return fail("Dynamic skills unsupported"); - const fingerprint = await digest( - encoder.encode( - canonical({ - frontmatter: entry.frontmatter, - resources: [...resources].sort((a, b) => a.uri.localeCompare(b.uri)), - }), - ), - ); - if (current.get(identity)?.fingerprint !== fingerprint) { + const canonicalBody = skillBody(snapshot); + const fingerprint = await digest(encoder.encode(canonicalBody)); + let diverged = true; + try { + diverged = skillBody(entry) !== canonicalBody; + } catch { + diverged = true; + } + if (diverged || current.get(identity)?.fingerprint !== fingerprint) { approvals.delete(identity); current.set(identity, { fingerprint, generation: {} }); } const generation = current.get(identity)!.generation; const held: HeldSkill = freeze({ - origin: structuredClone(origin), - entry: structuredClone(entry), + origin: originSnapshot, + entry: snapshot, identity, fingerprint, }); @@ -416,10 +426,11 @@ export async function createSkillPublisher( if (file.startsWith(`${uri}/`)) { const relative = file.slice(uri.length + 1); const name = relative.split("/")[0]!; - children.set(name, { + const directory = relative.includes("/"); + children.set(`${directory ? "d" : "f"}:${name}`, { uri: `${uri}/${name}`, name, - ...(relative.includes("/") ? { mimeType: "inode/directory" } : {}), + ...(directory ? { mimeType: "inode/directory" } : {}), }); } if (!children.size) return fail("Unknown directory"); diff --git a/src/extensions/skills/skills.test.ts b/src/extensions/skills/skills.test.ts index 241e9fc..9ce6ea0 100644 --- a/src/extensions/skills/skills.test.ts +++ b/src/extensions/skills/skills.test.ts @@ -85,6 +85,27 @@ describe("pinned Skills integrity and approval", () => { Buffer.from(main).toString("base64"), ); }); + test("directoryRead returns a file and a same-prefix directory", async () => { + const f = await fixture( + new Map([ + ["skill://demo/scripts", encode("not a directory")], + ["skill://demo/scripts/run.sh", encode("echo safe")], + ]), + ); + const scripts = f.publisher + .directoryRead("skill://demo") + .resources.filter((resource) => resource.name === "scripts"); + expect(scripts).toHaveLength(2); + expect(scripts).toContainEqual({ + uri: "skill://demo/scripts", + name: "scripts", + }); + expect(scripts).toContainEqual({ + uri: "skill://demo/scripts", + name: "scripts", + mimeType: "inode/directory", + }); + }); test("lazy retrieval, isolated cache, immutable entry and bytes", async () => { const f = await fixture(); const loader = createSkillLoader(f.source); diff --git a/src/extensions/skills/verification-race.test.ts b/src/extensions/skills/verification-race.test.ts index 025a562..a1a0211 100644 --- a/src/extensions/skills/verification-race.test.ts +++ b/src/extensions/skills/verification-race.test.ts @@ -1,6 +1,10 @@ import { expect, spyOn, test } from "bun:test"; import { createSkillLoader, createSkillPublisher } from "./index.js"; -import type { McpSkillHostPolicy } from "./contracts.js"; +import type { + McpSkillHostPolicy, + McpSkillOrigin, + SkillSource, +} from "./contracts.js"; for (const stale of ["frontmatter mismatch", "success"] as const) { test(`stale verification ${stale} cannot revoke or serve after generation replacement`, async () => { @@ -93,3 +97,105 @@ for (const stale of ["frontmatter mismatch", "success"] as const) { } }); } + +test("mutating a skill during hold does not reuse the prior execution approval", async () => { + const uri = "skill://demo/SKILL.md"; + const bytes = new TextEncoder().encode( + "---\nname: demo\ndescription: Correct\n---\nInstructions", + ); + const mutated = new TextEncoder().encode( + "---\nname: demo\ndescription: Mutated\n---\nInstructions", + ); + const publisher = await createSkillPublisher([ + { uri, files: new Map([[uri, bytes]]) }, + ]); + const origin: McpSkillOrigin = { + serverId: "server", + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "auth", + }; + let approvals = 0; + let payload = bytes; + const host: McpSkillHostPolicy = { + async approve(held) { + approvals++; + return { + identity: held.identity, + fingerprint: held.fingerprint, + approved: true, + execution: true, + }; + }, + async openActingWindow() { + return { async admit() {}, async removeContext() {}, async close() {} }; + }, + }; + const source: SkillSource = { + async get() { + return publisher.get(uri).skill; + }, + async read() { + return payload.slice(); + }, + }; + const loader = createSkillLoader(source, host); + const entry = publisher.get(uri).skill; + const lease = await loader.activate(await loader.hold(origin, entry)); + lease.assertExecutionApproved(); + const digestBytes = await crypto.subtle.digest("SHA-256", mutated); + const mutatedDigest = `sha256:${Array.from(new Uint8Array(digestBytes), (b) => b.toString(16).padStart(2, "0")).join("")}`; + let reached!: () => void; + let release!: () => void; + const paused = new Promise((resolve) => { + reached = resolve; + }); + const resume = new Promise((resolve) => { + release = resolve; + }); + const realDigest = crypto.subtle.digest.bind(crypto.subtle); + let arm = true; + const spy = spyOn(crypto.subtle, "digest").mockImplementation( + async (algorithm, data) => { + const result = await realDigest(algorithm, data); + if (arm) { + arm = false; + reached(); + await resume; + } + return result; + }, + ); + const pending = loader.hold(origin, entry).then( + (value) => ({ value, error: undefined }), + (error: unknown) => ({ value: undefined, error }), + ); + try { + await paused; + if (entry.resources === "dynamic") throw new Error("fixture"); + const resource = entry.resources.find((file) => file.uri === uri); + if (!resource) throw new Error("fixture"); + entry.frontmatter.description = "Mutated"; + resource.digest = mutatedDigest; + resource.size = mutated.byteLength; + payload = mutated; + release(); + const held = await pending; + expect(held.error).toBeUndefined(); + if (!held.value || held.value.entry.resources === "dynamic") + throw new Error("fixture"); + expect(held.value.entry.frontmatter.description).toBe("Correct"); + expect( + held.value.entry.resources.find((file) => file.uri === uri)?.digest, + ).not.toBe(mutatedDigest); + expect(() => lease.assertExecutionApproved()).toThrow("revoked"); + const replacement = await loader.activate(held.value); + replacement.assertExecutionApproved(); + expect((await replacement.read(uri)).bytes).toEqual(bytes); + expect(approvals).toBe(2); + } finally { + release(); + await pending; + spy.mockRestore(); + } +}); From 5ccd8c9819c3323df28a4442083b5c360cb70a82 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:05:06 -0700 Subject: [PATCH 21/28] fix(auth): do not persist a cancelled token exchange Consume the authorization session only after the grant is stored, and skip persistence when cancel lands during the token request. --- src/auth/login.ts | 60 +++++++++++++++------- src/auth/session-regressions.test.ts | 74 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 src/auth/session-regressions.test.ts diff --git a/src/auth/login.ts b/src/auth/login.ts index e9b4232..61e1670 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -152,6 +152,8 @@ export function createMcpAuthorizationSession(opts: { clientId: client.clientId, }); let consumed = false; + let locked = false; + let exchanged: McpOAuthTokens | undefined; const authorizeUrl = new URL(buildAuthorizeUrl(config, pkce, state)); if (config.scopes.length === 0) authorizeUrl.searchParams.delete("scope"); return { @@ -161,7 +163,7 @@ export function createMcpAuthorizationSession(opts: { consumed = true; }, async complete(params: URLSearchParams): Promise { - if (consumed || now() >= expiresAt) + if (consumed || locked || now() >= expiresAt) throw new OAuthDiscoveryError( "Authorization session expired or already consumed", ); @@ -179,24 +181,48 @@ export function createMcpAuthorizationSession(opts: { : {}), }; assertAuthorizationCallbackIssuer(params, issuerOptions); - // An authentic terminal denial must consume the same one-use session as a code. - consumed = true; - const { code } = readAuthorizationCallback(params, issuerOptions); - let tokens: McpOAuthTokens; + // An authentic terminal denial consumes the one-use session. A code does + // not: cancellation during exchange must skip persistence, and a persist + // failure must leave the exchanged grant retryable. + locked = true; try { - tokens = tokensFromResponse( - await exchangeCode( - config, - code, - pkce.verifier, - secureOAuthFetch(opts.fetchImpl), - ), - now(), - ); - } catch { - throw new OAuthDiscoveryError("OAuth code exchange failed"); + if (exchanged === undefined) { + let code: string; + try { + ({ code } = readAuthorizationCallback(params, issuerOptions)); + } catch (error) { + consumed = true; + throw error; + } + let tokens: McpOAuthTokens; + try { + tokens = tokensFromResponse( + await exchangeCode( + config, + code, + pkce.verifier, + secureOAuthFetch(opts.fetchImpl), + ), + now(), + ); + } catch { + throw new OAuthDiscoveryError("OAuth code exchange failed"); + } + if (consumed || now() >= expiresAt) + throw new OAuthDiscoveryError( + "Authorization session expired or already consumed", + ); + exchanged = tokens; + } + if (consumed || now() >= expiresAt) + throw new OAuthDiscoveryError( + "Authorization session expired or already consumed", + ); + await opts.persist(binding, exchanged); + consumed = true; + } finally { + if (!consumed) locked = false; } - await opts.persist(binding, tokens); }, }; } diff --git a/src/auth/session-regressions.test.ts b/src/auth/session-regressions.test.ts new file mode 100644 index 0000000..715ede1 --- /dev/null +++ b/src/auth/session-regressions.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { createMcpAuthorizationSession } from "./login.js"; + +const redirectUri = "http://127.0.0.1:8080/callback"; + +test("cancel during token exchange does not persist", async () => { + const issuer = "https://auth.example"; + let persisted = 0; + let session: ReturnType; + session = createMcpAuthorizationSession({ + entry: { + resourceUrl: "https://mcp.example/mcp", + authorizationServer: { + issuer, + authorizationEndpoint: `${issuer}/authorize`, + tokenEndpoint: `${issuer}/token`, + authorizationResponseIssParameterSupported: true, + }, + }, + client: { kind: "pre-registered", clientId: "client", issuer }, + config: { redirectUri }, + tenantId: "tenant", + principalId: "principal", + persist: async () => { + persisted++; + }, + fetchImpl: async () => { + session.cancel(); + return Response.json({ access_token: "access", token_type: "Bearer" }); + }, + }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + const params = new URLSearchParams({ state, iss: issuer, code: "code" }); + await expect(session.complete(params)).rejects.toThrow(/consumed/); + expect(persisted).toBe(0); + await expect(session.complete(params)).rejects.toThrow(/consumed/); + expect(persisted).toBe(0); +}); + +test("persist failure leaves the exchanged grant retryable", async () => { + const issuer = "https://auth.example"; + let exchanges = 0; + let attempts = 0; + const session = createMcpAuthorizationSession({ + entry: { + resourceUrl: "https://mcp.example/mcp", + authorizationServer: { + issuer, + authorizationEndpoint: `${issuer}/authorize`, + tokenEndpoint: `${issuer}/token`, + authorizationResponseIssParameterSupported: true, + }, + }, + client: { kind: "pre-registered", clientId: "client", issuer }, + config: { redirectUri }, + tenantId: "tenant", + principalId: "principal", + persist: async (_binding, tokens) => { + attempts++; + if (attempts === 1) throw new Error("disk full"); + expect(tokens.access).toBe("access"); + }, + fetchImpl: async () => { + exchanges++; + return Response.json({ access_token: "access", token_type: "Bearer" }); + }, + }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + const params = new URLSearchParams({ state, iss: issuer, code: "code" }); + await expect(session.complete(params)).rejects.toThrow(/disk full/); + await session.complete(params); + expect(exchanges).toBe(1); + expect(attempts).toBe(2); +}); From 3c30ff2c006ba1ac53aa160b9d0388418278601c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:05:27 -0700 Subject: [PATCH 22/28] fix(auth): require iss for cross-origin token exchange Refuse to send the code and verifier when an authorization or token endpoint origin differs from the issuer unless iss matches, and do not add offline_access from that cross-origin metadata. --- src/auth/login.ts | 5 ++ src/auth/session-regressions.test.ts | 100 +++++++++++++++++++++++++++ src/oauth-discovery.test.ts | 44 ++++++++++++ src/oauth-discovery.ts | 59 +++++++++++++++- 4 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index 61e1670..fd52119 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -9,6 +9,7 @@ import { import { type } from "arktype"; import { assertAuthorizationCallbackIssuer, + assertCrossOriginAuthorizationIssuer, mcpClientConfig, preferredMcpClientRegistration, readAuthorizationCallback, @@ -181,6 +182,10 @@ export function createMcpAuthorizationSession(opts: { : {}), }; assertAuthorizationCallbackIssuer(params, issuerOptions); + assertCrossOriginAuthorizationIssuer( + params, + opts.entry.authorizationServer, + ); // An authentic terminal denial consumes the one-use session. A code does // not: cancellation during exchange must skip persistence, and a persist // failure must leave the exchanged grant retryable. diff --git a/src/auth/session-regressions.test.ts b/src/auth/session-regressions.test.ts index 715ede1..b58f1e9 100644 --- a/src/auth/session-regressions.test.ts +++ b/src/auth/session-regressions.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { discoverMcpLoginEntry } from "../oauth-discovery.js"; import { createMcpAuthorizationSession } from "./login.js"; const redirectUri = "http://127.0.0.1:8080/callback"; @@ -72,3 +73,102 @@ test("persist failure leaves the exchanged grant retryable", async () => { expect(exchanges).toBe(1); expect(attempts).toBe(2); }); + +test("same-origin issuer still exchanges when iss is absent", async () => { + const issuer = "https://auth.example"; + let persisted = 0; + const session = createMcpAuthorizationSession({ + entry: { + resourceUrl: "https://mcp.example/mcp", + authorizationServer: { + issuer, + authorizationEndpoint: `${issuer}/authorize`, + tokenEndpoint: `${issuer}/token`, + }, + }, + client: { kind: "pre-registered", clientId: "client", issuer }, + config: { redirectUri }, + tenantId: "tenant", + principalId: "principal", + persist: async () => { + persisted++; + }, + fetchImpl: async () => + Response.json({ access_token: "access", token_type: "Bearer" }), + }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + await session.complete(new URLSearchParams({ state, code: "code" })); + expect(persisted).toBe(1); +}); + +test("cross-origin token endpoint does not receive the code without a matching iss", async () => { + const resource = "https://mcp.evil.example/mcp"; + const attacker = "https://attacker.example"; + const honest = "https://idp.honest.example"; + const entry = await discoverMcpLoginEntry({ + resourceUrl: resource, + fetchImpl: async (input) => { + const url = String(input); + if ( + url === + "https://mcp.evil.example/.well-known/oauth-protected-resource/mcp" + ) + return Response.json({ + resource, + authorization_servers: [attacker], + scopes_supported: ["read"], + }); + if (url === `${attacker}/.well-known/oauth-authorization-server`) + return Response.json({ + issuer: attacker, + authorization_endpoint: `${honest}/authorize`, + token_endpoint: `${attacker}/token`, + code_challenge_methods_supported: ["S256"], + scopes_supported: ["read", "offline_access"], + }); + return new Response(null, { status: 404 }); + }, + }); + const posts: { url: string; body: string }[] = []; + let persisted = 0; + const session = createMcpAuthorizationSession({ + entry, + client: { + kind: "cimd", + clientId: "https://client.example/client.json", + }, + config: { redirectUri }, + tenantId: "tenant", + principalId: "principal", + persist: async () => { + persisted++; + }, + fetchImpl: async (input, init) => { + posts.push({ url: String(input), body: String(init?.body ?? "") }); + return Response.json({ access_token: "access", token_type: "Bearer" }); + }, + }); + const authorize = new URL(session.authorizeUrl); + expect(authorize.origin).toBe(honest); + expect(authorize.searchParams.get("scope")).toBe("read"); + const state = authorize.searchParams.get("state")!; + await expect( + session.complete(new URLSearchParams({ state, code: "auth-code" })), + ).rejects.toThrow(/iss/); + await expect( + session.complete( + new URLSearchParams({ state, code: "auth-code", iss: honest }), + ), + ).rejects.toThrow(/iss/); + expect(posts).toHaveLength(0); + expect(persisted).toBe(0); + await session.complete( + new URLSearchParams({ state, code: "auth-code", iss: attacker }), + ); + expect(persisted).toBe(1); + expect(posts).toHaveLength(1); + const body = new URLSearchParams(posts[0]!.body); + expect(posts[0]!.url).toBe(`${attacker}/token`); + expect(body.get("code")).toBe("auth-code"); + expect(body.get("code_verifier")?.length).toBeGreaterThan(0); +}); diff --git a/src/oauth-discovery.test.ts b/src/oauth-discovery.test.ts index 08aca9f..22688f6 100644 --- a/src/oauth-discovery.test.ts +++ b/src/oauth-discovery.test.ts @@ -706,6 +706,50 @@ describe("authorization response iss", () => { }); }); +describe("cross-origin authorization metadata", () => { + test("does not add offline_access when the resource only advertised read", async () => { + const resource = "https://mcp.evil.example/mcp"; + const attacker = "https://attacker.example"; + const { fetchImpl } = fakeFetch({ + "https://mcp.evil.example/.well-known/oauth-protected-resource/mcp": { + status: 200, + body: { + resource, + authorization_servers: [attacker], + scopes_supported: ["read"], + }, + }, + "https://attacker.example/.well-known/oauth-authorization-server": { + status: 200, + body: { + issuer: attacker, + authorization_endpoint: "https://idp.honest.example/authorize", + token_endpoint: `${attacker}/token`, + code_challenge_methods_supported: ["S256"], + scopes_supported: ["read", "offline_access"], + }, + }, + }); + const entry = await discoverMcpLoginEntry({ + resourceUrl: resource, + fetchImpl, + }); + expect(entry.authorizationServer.issuer).toBe(attacker); + expect(entry.authorizationServer.authorizationEndpoint).toBe( + "https://idp.honest.example/authorize", + ); + expect(entry.authorizationServer.tokenEndpoint).toBe(`${attacker}/token`); + expect(entry.resourceScopesSupported).toEqual(["read"]); + expect(selectMcpScopes({ entry })).toEqual(["read"]); + expect( + mcpClientConfig(entry, { + clientId: "https://client.example/client.json", + redirectUri: "http://127.0.0.1:18080/callback", + }).scopes, + ).toEqual(["read"]); + }); +}); + describe("selectMcpScopes", () => { test("prefers the WWW-Authenticate challenge scope over the resource's scopes_supported", () => { // Load-bearing: MCP spec "Scope Selection Strategy" priority order — diff --git a/src/oauth-discovery.ts b/src/oauth-discovery.ts index 61fed9f..31ab339 100644 --- a/src/oauth-discovery.ts +++ b/src/oauth-discovery.ts @@ -680,6 +680,51 @@ export function assertAuthorizationCallbackIssuer( assertAuthorizationIssuer(issuerOpts); } +function urlOrigin(raw: string): string | undefined { + try { + return new URL(raw).origin; + } catch { + return undefined; + } +} + +function sameOrigin(endpoint: string, issuer: string): boolean { + const endpointOrigin = urlOrigin(endpoint); + const issuerOrigin = urlOrigin(issuer); + return ( + endpointOrigin !== undefined && + issuerOrigin !== undefined && + endpointOrigin === issuerOrigin + ); +} + +/** + * Mix-up defense for metadata that names an authorization or token endpoint + * on a different origin than the issuer. RFC 9207 allows a missing `iss` when + * the server does not advertise `authorization_response_iss_parameter_supported`, + * but that would send the code and PKCE verifier to whichever token endpoint + * the metadata named. Cross-origin endpoints require a present `iss` equal to + * the recorded issuer. Same-origin issuers keep the ordinary missing-`iss` rule. + */ +export function assertCrossOriginAuthorizationIssuer( + params: URLSearchParams | Record, + server: Pick< + McpAuthorizationServer, + "issuer" | "authorizationEndpoint" | "tokenEndpoint" + >, +): void { + if ( + sameOrigin(server.authorizationEndpoint, server.issuer) && + sameOrigin(server.tokenEndpoint, server.issuer) + ) + return; + const iss = callbackField(params, "iss"); + if (iss === undefined || iss !== server.issuer) + throw new OAuthDiscoveryError( + "cross-origin authorization endpoints require a matching iss", + ); +} + /** * Read an authorization callback. `iss` is checked before `error` or `code` * is acted on, and this function does not redeem the code. @@ -711,7 +756,9 @@ export function readAuthorizationCallback( // spec's "Refresh Tokens" section: `wantRefresh` defaults to true (this // package always registers for refresh_token, see grantTypesToRequest), so // offline_access is added whenever the authorization server's own -// `scopes_supported` lists it; pass `wantRefresh: false` to opt out. +// `scopes_supported` lists it and its authorization endpoint shares the +// issuer origin. A cross-origin authorization endpoint is not trusted to +// widen the resource's scopes. Pass `wantRefresh: false` to opt out. export function selectMcpScopes(opts: SelectMcpScopesOptions): string[] { const priorityScopes = selectPriorityScopes(opts); return addOfflineAccessScope(priorityScopes, opts); @@ -733,6 +780,16 @@ function addOfflineAccessScope( opts: SelectMcpScopesOptions, ): string[] { if (opts.wantRefresh === false) return scopes; + // Scope widening follows the authorization server that will actually receive + // the authorize request. Metadata from another origin must not add + // offline_access on top of the resource's advertised scopes. + if ( + !sameOrigin( + opts.entry.authorizationServer.authorizationEndpoint, + opts.entry.authorizationServer.issuer, + ) + ) + return scopes; const authServerScopes = opts.entry.authorizationServer.scopesSupported; if (authServerScopes === undefined) return scopes; if (!authServerScopes.includes("offline_access")) return scopes; From e8679d95eea9883cb575701feba9d6534e3cbf02 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:09:13 -0700 Subject: [PATCH 23/28] fix(tasks): narrow inputRequests to the waiting state --- src/extensions/tasks/index.ts | 19 ++++++++++++------- src/extensions/tasks/tasks.test.ts | 4 +++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/extensions/tasks/index.ts b/src/extensions/tasks/index.ts index 113e3f8..b5405ce 100644 --- a/src/extensions/tasks/index.ts +++ b/src/extensions/tasks/index.ts @@ -24,6 +24,12 @@ const sameIdentity = (a: McpTaskIdentity, b: McpTaskIdentity) => const fail = (message: string): never => { throw Object.assign(new Error(message), { code: -32602 }); }; +const waitingTask = ( + task: DetailedTask, +): Extract => { + if (task.status === "input_required") return task; + return fail("Task is not waiting for input"); +}; export function createTaskRuntime(options: { store: McpTaskStore; @@ -158,14 +164,13 @@ export function createTaskRuntime(options: { let accepted: InputResponses = {}; const next = await update(identity, taskId, (record) => { accepted = {}; - if (record.task.status !== "input_required") - fail("Task is not waiting for input"); + const task = waitingTask(record.task); for (const [key, response] of Object.entries(responses)) if ( - Object.hasOwn(record.task.inputRequests, key) && + Object.hasOwn(task.inputRequests, key) && !Object.hasOwn(record.answered, key) ) { - const request = record.task.inputRequests[key]; + const request = task.inputRequests[key]; if (request?.method === "elicitation/create") validateElicitationResponse(request.params, response); accepted = { ...accepted, [key]: structuredClone(response) }; @@ -173,12 +178,12 @@ export function createTaskRuntime(options: { ...record.answered, [key]: structuredClone(response), }; - delete record.task.inputRequests[key]; + delete task.inputRequests[key]; } if (!Object.keys(accepted).length) fail("Task is not waiting for input"); - if (!Object.keys(record.task.inputRequests).length) { - const { inputRequests: _requests, ...base } = record.task; + if (!Object.keys(task.inputRequests).length) { + const { inputRequests: _requests, status: _status, ...base } = task; record.task = { ...base, status: "working" }; } return record; diff --git a/src/extensions/tasks/tasks.test.ts b/src/extensions/tasks/tasks.test.ts index d67d3ae..18b7ff9 100644 --- a/src/extensions/tasks/tasks.test.ts +++ b/src/extensions/tasks/tasks.test.ts @@ -254,7 +254,9 @@ test("submitInput fails without a revision bump unless the task is waiting for t raced = true; const current = await f.store.get(taskId); if (!current) return false; - const { inputRequests: _pending, ...task } = current.task; + const pending = current.task; + if (pending.status !== "input_required") return false; + const { inputRequests: _pending, status: _status, ...task } = pending; await f.store.compareAndSwap(taskId, current.revision, { ...current, revision: current.revision + 1, From c26068d4a29ecfe28a17dc2109f2ec882b523482 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:10:30 -0700 Subject: [PATCH 24/28] fix(server): authorize discover and the exposure pair Denied principals no longer receive server/discover. Tool catalog and invocation use the exposure resource and action, not mcp:tools. --- docs/migration.md | 17 ++++- src/interchange/server/server.test.ts | 77 +++++++++++++++++++ src/server.ts | 68 ++++++++++++++--- src/server/contracts.ts | 16 +++- src/server/legacy.ts | 7 +- src/server/server.test.ts | 106 ++++++++++++++++++++++++++ src/server/tools.ts | 58 +++++++++++--- 7 files changed, 321 insertions(+), 28 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 125fb7f..4b18de1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -33,9 +33,20 @@ validation must not resolve network references implicitly. - All tools start ask-marked; explicit safe-tool allowlists cannot lower a destructive tool's ask floor. Calling a bare ToolBundle dispatcher does not itself perform host authorization. -- Expose a finite list with `createInterchangeProviders`. Bind tenant, resource, - action and approval in the host. Catalog visibility is not execution approval; - authorization is checked again on every invocation. +- Expose a finite list with `createInterchangeProviders`. The only tool + authorization pair is that exposure's `resource` and `action` — the same + pair for catalog visibility and for invocation. Do not grant a separate + `mcp:tools` `list` or `call` action. The server gate authorizes the selected + exposure pair, and `auth: "public"` on `createToolProviders` does not skip a + private `createMcpServer` policy. Bind tenant and approval in the host as + well. Catalog visibility is not execution approval; the exposure pair is + checked again on every invocation, and `approve` runs only after it is allowed. +- Private `server/discover` returns capabilities and server info only when + `authorize` allows at least one registered pair (each exposure's resource and + action, or any other provider's resource and action). A principal allowed for + none receives 403 and no discover body. A private server with no providers + authorizes resource `mcp:server`, action `discover`. Public servers + (`auth: "public"`) do not perform that check. - Credential handles are mediated, origin-pinned fetches. Raw bearer values must never enter agent tool arguments/results, skills, task records or model context. - Private Interchange `detail` is not automatically exported over MCP. An explicit diff --git a/src/interchange/server/server.test.ts b/src/interchange/server/server.test.ts index 02ab341..9d56fd6 100644 --- a/src/interchange/server/server.test.ts +++ b/src/interchange/server/server.test.ts @@ -273,3 +273,80 @@ test("traversal, unbound paths, cross-origin paths and unresolved schemas fail b ]), ).toThrow(); }); + +test("exposure resource and action are the only tool authorization pair", async () => { + const seen: string[] = []; + let executions = 0; + let approvals = 0; + let permitted = true; + const policy: McpAuthPolicy = { + authenticate: () => identity, + authorize: (_principal, operation) => { + seen.push(`${operation.resource} ${operation.action}`); + return ( + permitted && + operation.resource === base.resource && + operation.action === base.action + ); + }, + }; + const endpoint = server( + [ + { + ...base, + kind: "tool", + toolName: "selected", + approve: async () => { + approvals++; + return true; + }, + bundle: { + definitions: [definition], + run: async (call) => { + executions++; + return { callId: call.id, content: "ran" }; + }, + }, + }, + ], + policy, + ); + const discovered = await endpoint.fetch(rpc("server/discover")); + expect(discovered.status).toBe(200); + expect(await result(discovered)).toMatchObject({ + result: { + _meta: { "io.modelcontextprotocol/serverInfo": { name: "interchange" } }, + }, + }); + expect(await result(await endpoint.fetch(rpc("tools/list")))).toMatchObject({ + result: { tools: [{ name: "selected" }] }, + }); + expect( + await result( + await endpoint.fetch( + rpc("tools/call", { name: "selected", arguments: { content: "x" } }), + ), + ), + ).toMatchObject({ result: { content: [{ text: "ran" }] } }); + expect(approvals).toBe(1); + expect(executions).toBe(1); + expect(seen.every((entry) => entry === "tool:selected call")).toBe(true); + permitted = false; + const denied = await endpoint.fetch(rpc("server/discover")); + expect(denied.status).toBe(403); + expect(await denied.text()).not.toContain("interchange"); + expect(await result(await endpoint.fetch(rpc("tools/list")))).toMatchObject({ + result: { tools: [] }, + }); + expect( + ( + await endpoint.fetch( + rpc("tools/call", { name: "selected", arguments: { content: "x" } }), + ) + ).status, + ).toBe(403); + expect(approvals).toBe(1); + expect(executions).toBe(1); + expect(seen.some((entry) => entry.startsWith("mcp:tools"))).toBe(false); + await endpoint.close(); +}); diff --git a/src/server.ts b/src/server.ts index 3068dd6..037b6b2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,8 @@ import type { McpServerOptions, McpServer, McpRequestContext, + McpProvider, + McpProviders, } from "./server/contracts.js"; export type * from "./server/contracts.js"; export { @@ -40,6 +42,37 @@ const record = (value: unknown): Record => ? (value as Record) : {}; +function methodAuthorization( + provider: McpProvider, + params: Record, +): { resource: string; action: string } | null { + return provider.authorization + ? provider.authorization(params) + : { resource: provider.resource, action: provider.action }; +} + +/** Pairs a private discover check may satisfy. No providers means mcp:server/discover. */ +function discoverAuthorizations( + providers: McpProviders, +): { resource: string; action: string }[] { + const pairs: { resource: string; action: string }[] = []; + const seen = new Set(); + for (const provider of Object.values(providers)) { + const listed = provider.authorizationPairs ?? [ + { resource: provider.resource, action: provider.action }, + ]; + for (const pair of listed) { + const key = `${pair.resource}\0${pair.action}`; + if (seen.has(key)) continue; + seen.add(key); + pairs.push(pair); + } + } + if (pairs.length === 0) + pairs.push({ resource: "mcp:server", action: "discover" }); + return pairs; +} + /** Stateless modern endpoint with an independently gated legacy session transport. */ export function createMcpServer(options: McpServerOptions): McpServer { const providers = Object.freeze({ ...options.providers }); @@ -186,6 +219,20 @@ export function createMcpServer(options: McpServerOptions): McpServer { ); validateRequest(envelope.method, params, validation); if (envelope.method === "server/discover") { + if (options.auth !== "public") { + let permitted = false; + for (const pair of discoverAuthorizations(providers)) { + if (identity && (await options.auth.authorize(identity, pair))) { + permitted = true; + break; + } + } + if (closed) return new Response(null, { status: 503 }); + if (!permitted) + return ( + options.challenge?.(403) ?? new Response(null, { status: 403 }) + ); + } const result = validateResult( envelope.method, { @@ -208,17 +255,15 @@ export function createMcpServer(options: McpServerOptions): McpServer { } const provider = providers[envelope.method]; if (!provider) throw new McpError("Method not found", 404, -32601); - if ( - options.auth !== "public" && - identity && - !(await options.auth.authorize(identity, { - resource: provider.resource, - action: provider.action, - })) - ) - return ( - options.challenge?.(403) ?? new Response(null, { status: 403 }) - ); + const pair = methodAuthorization(provider, params); + if (pair && options.auth !== "public" && identity) { + const permitted = await options.auth.authorize(identity, pair); + if (closed) return new Response(null, { status: 503 }); + if (!permitted) + return ( + options.challenge?.(403) ?? new Response(null, { status: 403 }) + ); + } const controller = new AbortController(); active.add(controller); const abort = () => controller.abort(request.signal.reason); @@ -275,6 +320,7 @@ export function createMcpServer(options: McpServerOptions): McpServer { if (controller.signal.aborted) finish(); const context: McpRequestContext = { identity, + auth: options.auth, requestId, protocolVersion: MCP_PROTOCOL_VERSION, clientCapabilities: validation.clientCapabilities ?? {}, diff --git a/src/server/contracts.ts b/src/server/contracts.ts index b698db1..3a32441 100644 --- a/src/server/contracts.ts +++ b/src/server/contracts.ts @@ -1,4 +1,8 @@ -import type { McpAuthPolicy, McpIdentity } from "../auth/contracts.js"; +import type { + McpAuthOperation, + McpAuthPolicy, + McpIdentity, +} from "../auth/contracts.js"; import type { McpExtensionSettings, McpValidationContext, @@ -11,6 +15,8 @@ export type McpOperationOutcome = McpMethodMap["tools/call"]["result"]; export type McpRequestContext = { identity: McpIdentity | null; + /** Server policy. A public tool catalog must not ignore this on a private server. */ + auth: McpAuthPolicy | "public"; requestId: string | number; protocolVersion: string; clientCapabilities: Record; @@ -23,6 +29,14 @@ export type McpProvider = { /** Authorization applies to both catalog and execution methods. */ resource: string; action: string; + /** + * Method gate. Undefined uses `resource` and `action`. + * Null means this method has no single pair; the provider authorizes each + * binding with that binding's own resource and action. + */ + authorization?: (params: Record) => McpAuthOperation | null; + /** Private discover succeeds when any of these pairs is allowed. Defaults to resource/action. */ + authorizationPairs?: readonly McpAuthOperation[]; schemas?: ( params: Record, ) => Pick; diff --git a/src/server/legacy.ts b/src/server/legacy.ts index f56d430..b4a18c7 100644 --- a/src/server/legacy.ts +++ b/src/server/legacy.ts @@ -205,6 +205,9 @@ export function createLegacyServer( throw new McpError("Method not available in legacy mode", 404, -32601); const provider = providers[message.method]!; const params = message.params ?? {}; + const pair = provider.authorization + ? provider.authorization(params) + : { resource: provider.resource, action: provider.action }; const context = validation(session, message.method, params); validateRequest(message.method, params, context); validateRequestHeaders(request.headers, message.method, params, context); @@ -219,8 +222,9 @@ export function createLegacyServer( signal.addEventListener("abort", abortListener, { once: true }); }); if ( + pair && !(await Promise.race([ - authorized(identity, provider.resource, provider.action), + authorized(identity, pair.resource, pair.action), cancelled, ])) ) @@ -228,6 +232,7 @@ export function createLegacyServer( let lastProgress = -Infinity; const providerContext: McpRequestContext = { identity, + auth: options.auth, requestId: id, protocolVersion: session.version, clientCapabilities: session.capabilities, diff --git a/src/server/server.test.ts b/src/server/server.test.ts index d6e2b46..72ac719 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -283,3 +283,109 @@ test("extension capability advertisements require corresponding providers", () = }), ).toThrow(); }); + +test("denied authorize does not return server/discover", async () => { + const seen: { resource: string; action: string }[] = []; + const policy: McpAuthPolicy = { + authenticate: () => identity, + authorize: (_principal, operation) => { + seen.push(operation); + return false; + }, + }; + const server = createMcpServer({ + serverInfo: info, + auth: policy, + providers: createToolProviders( + [ + { + definition: { name: "echo", inputSchema: { type: "object" } }, + resource: "tool:echo", + action: "invoke", + execute: () => ({ content: [] }), + }, + ], + "public", + ), + }); + const response = await server.fetch(rpc("server/discover")); + expect(response.status).toBe(403); + expect(await response.text()).not.toContain(info.name); + expect(seen).toEqual([{ resource: "tool:echo", action: "invoke" }]); + const empty = createMcpServer({ serverInfo: info, auth: policy }); + seen.length = 0; + const bare = await empty.fetch(rpc("server/discover")); + expect(bare.status).toBe(403); + expect(await bare.text()).not.toContain(info.name); + expect(seen).toEqual([{ resource: "mcp:server", action: "discover" }]); + await server.close(); + await empty.close(); +}); + +test("private server authorizes each tool binding when the provider auth is public", async () => { + let executions = 0; + const seen: string[] = []; + let allow = true; + const server = createMcpServer({ + serverInfo: info, + auth: { + authenticate: () => identity, + authorize: (_principal, operation) => { + seen.push(`${operation.resource} ${operation.action}`); + return ( + allow && + operation.resource === "tool:echo" && + operation.action === "invoke" + ); + }, + }, + providers: createToolProviders( + [ + { + definition: { name: "echo", inputSchema: { type: "object" } }, + resource: "tool:echo", + action: "invoke", + execute: () => { + executions++; + return { content: [{ type: "text", text: "ok" }] }; + }, + }, + { + definition: { name: "other", inputSchema: { type: "object" } }, + resource: "tool:other", + action: "invoke", + execute: () => { + executions++; + return { content: [] }; + }, + }, + ], + "public", + ), + }); + expect((await server.fetch(rpc("server/discover"))).status).toBe(200); + expect(await result(await server.fetch(rpc("tools/list")))).toMatchObject({ + result: { tools: [{ name: "echo" }] }, + }); + expect( + await result( + await server.fetch(rpc("tools/call", { name: "echo", arguments: {} })), + ), + ).toMatchObject({ result: { content: [{ text: "ok" }] } }); + expect(executions).toBe(1); + expect( + seen.every( + (entry) => entry === "tool:echo invoke" || entry === "tool:other invoke", + ), + ).toBe(true); + allow = false; + expect(await result(await server.fetch(rpc("tools/list")))).toMatchObject({ + result: { tools: [] }, + }); + expect( + (await server.fetch(rpc("tools/call", { name: "echo", arguments: {} }))) + .status, + ).toBe(403); + expect(executions).toBe(1); + await server.close(); +}); diff --git a/src/server/tools.ts b/src/server/tools.ts index 26cfa0d..724cad7 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -28,6 +28,20 @@ export type McpToolBinding = { ) => McpOperationOutcome | Promise; }; +function activePolicy( + providerAuth: McpAuthPolicy | "public", + serverAuth: McpAuthPolicy | "public", +): "public" | Pick { + if (providerAuth === "public") return serverAuth; + if (serverAuth === "public" || serverAuth === providerAuth) + return providerAuth; + return { + authorize: async (identity, operation) => + (await providerAuth.authorize(identity, operation)) && + (await serverAuth.authorize(identity, operation)), + }; +} + /** Finite catalog; authorization is rechecked even after successful discovery. */ export function createToolProviders( tools: readonly McpToolBinding[], @@ -42,18 +56,33 @@ export function createToolProviders( validateSchema(tool.definition.outputSchema); byName.set(tool.definition.name, tool); } - const allowed = async (tool: McpToolBinding, context: McpRequestContext) => - (tool.visible?.(context) ?? true) && - (auth === "public" || - (context.identity !== null && - (await auth.authorize(context.identity, { - resource: tool.resource, - action: tool.action, - })))); + const bindings = [...byName.values()]; + const authorizationPairs = bindings.map((tool) => ({ + resource: tool.resource, + action: tool.action, + })); + const fallback = authorizationPairs[0] ?? { + resource: "mcp:server", + action: "discover", + }; + const allowed = async (tool: McpToolBinding, context: McpRequestContext) => { + const policy = activePolicy(auth, context.auth); + return ( + (tool.visible?.(context) ?? true) && + (policy === "public" || + (context.identity !== null && + (await policy.authorize(context.identity, { + resource: tool.resource, + action: tool.action, + })))) + ); + }; return { "tools/list": { - resource: "mcp:tools", - action: "list", + resource: fallback.resource, + action: fallback.action, + authorizationPairs, + authorization: () => null, async handle(params, context) { if (params.cursor !== undefined) throw new McpError("Unknown cursor", 400, -32602); @@ -64,8 +93,13 @@ export function createToolProviders( }, }, "tools/call": { - resource: "mcp:tools", - action: "call", + resource: fallback.resource, + action: fallback.action, + authorizationPairs, + authorization(params) { + const tool = byName.get(String(params.name)); + return tool ? { resource: tool.resource, action: tool.action } : null; + }, schemas(params) { const definition = byName.get(String(params.name))?.definition; return definition From 84e0b4bf625e8223c6fe0ef4e51b7d3917ac3d4e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:37:18 -0700 Subject: [PATCH 25/28] fix(server): deny unknown tool calls like rejected bindings A missing tools/call name skipped the gate, so it did not match a denied binding. Private modern and legacy calls now share that denial, while tools/list stays a filtered catalog. --- docs/migration.md | 4 + src/server.ts | 7 +- src/server/contracts.ts | 5 +- src/server/legacy.ts | 12 +- src/server/review-regressions.test.ts | 183 ++++++++++++++++++++++++++ src/server/tools.ts | 3 +- 6 files changed, 204 insertions(+), 10 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 4b18de1..f748a8a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -41,6 +41,10 @@ validation must not resolve network references implicitly. private `createMcpServer` policy. Bind tenant and approval in the host as well. Catalog visibility is not execution approval; the exposure pair is checked again on every invocation, and `approve` runs only after it is allowed. + A `tools/call` whose name is not in the catalog gets the same denial as a + binding `authorize` rejects, so the call cannot probe membership. `tools/list` + stays a filtered catalog — empty when nothing is visible — and is not a 403 + merely because the caller can see no tools. - Private `server/discover` returns capabilities and server info only when `authorize` allows at least one registered pair (each exposure's resource and action, or any other provider's resource and action). A principal allowed for diff --git a/src/server.ts b/src/server.ts index 037b6b2..fdeaadd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,7 +45,7 @@ const record = (value: unknown): Record => function methodAuthorization( provider: McpProvider, params: Record, -): { resource: string; action: string } | null { +): { resource: string; action: string } | null | false { return provider.authorization ? provider.authorization(params) : { resource: provider.resource, action: provider.action }; @@ -256,8 +256,9 @@ export function createMcpServer(options: McpServerOptions): McpServer { const provider = providers[envelope.method]; if (!provider) throw new McpError("Method not found", 404, -32601); const pair = methodAuthorization(provider, params); - if (pair && options.auth !== "public" && identity) { - const permitted = await options.auth.authorize(identity, pair); + if (pair !== null && options.auth !== "public" && identity) { + const permitted = + pair !== false && (await options.auth.authorize(identity, pair)); if (closed) return new Response(null, { status: 503 }); if (!permitted) return ( diff --git a/src/server/contracts.ts b/src/server/contracts.ts index 3a32441..41584cf 100644 --- a/src/server/contracts.ts +++ b/src/server/contracts.ts @@ -33,8 +33,11 @@ export type McpProvider = { * Method gate. Undefined uses `resource` and `action`. * Null means this method has no single pair; the provider authorizes each * binding with that binding's own resource and action. + * False denies immediately, the same response as `authorize` returning false. */ - authorization?: (params: Record) => McpAuthOperation | null; + authorization?: ( + params: Record, + ) => McpAuthOperation | null | false; /** Private discover succeeds when any of these pairs is allowed. Defaults to resource/action. */ authorizationPairs?: readonly McpAuthOperation[]; schemas?: ( diff --git a/src/server/legacy.ts b/src/server/legacy.ts index b4a18c7..d3b2a99 100644 --- a/src/server/legacy.ts +++ b/src/server/legacy.ts @@ -222,11 +222,13 @@ export function createLegacyServer( signal.addEventListener("abort", abortListener, { once: true }); }); if ( - pair && - !(await Promise.race([ - authorized(identity, pair.resource, pair.action), - cancelled, - ])) + (pair === false && options.auth !== "public") || + (pair !== null && + pair !== false && + !(await Promise.race([ + authorized(identity, pair.resource, pair.action), + cancelled, + ]))) ) throw new McpError("Operation forbidden", 403, -32602); let lastProgress = -Infinity; diff --git a/src/server/review-regressions.test.ts b/src/server/review-regressions.test.ts index c748efb..5067275 100644 --- a/src/server/review-regressions.test.ts +++ b/src/server/review-regressions.test.ts @@ -330,3 +330,186 @@ test("runtime provider null and unknown discriminators never cross the success b } } }); + +test("unknown tools/call matches a denied binding without probing the catalog", async () => { + const identity = { + tenantId: "tenant", + principalId: "principal", + authorizationContextId: "grant-v1", + scopes: [] as string[], + }; + let executions = 0; + let allowVisible = true; + const seen: string[] = []; + const providers = () => + createToolProviders( + (["visible", "secret"] as const).map((name) => ({ + definition: { name, inputSchema: { type: "object" } }, + resource: `tool:${name}`, + action: "call", + execute: () => { + executions++; + return { content: [] }; + }, + })), + "public", + ); + const auth = { + authenticate: () => identity, + authorize: ( + _principal: typeof identity, + operation: { resource: string; action: string }, + ) => { + seen.push(`${operation.resource} ${operation.action}`); + return ( + (operation.resource === "mcp:server" && + operation.action === "initialize") || + (allowVisible && + operation.resource === "tool:visible" && + operation.action === "call") + ); + }, + }; + const denial = (body: string) => + body.trim() + ? body + .trim() + .split("\n\n") + .map((frame) => { + const parsed = JSON.parse( + frame.startsWith("data: ") ? frame.slice(6) : frame, + ) as { error?: { message?: string } }; + return parsed.error?.message ?? ""; + }) + .join("\n") + : ""; + const modern = createMcpServer({ + serverInfo, + auth, + providers: providers(), + }); + try { + const denied = await modern.fetch( + rpc("tools/call", { name: "secret", arguments: {} }), + ); + const unknown = await modern.fetch( + rpc("tools/call", { name: "no-such-tool", arguments: {} }), + ); + expect(denied.status).toBe(403); + expect(unknown.status).toBe(denied.status); + expect(await unknown.text()).toBe(await denied.text()); + expect(executions).toBe(0); + const listed = await modern.fetch(rpc("tools/list")); + expect(listed.status).toBe(200); + expect(await result(listed)).toMatchObject({ + result: { tools: [{ name: "visible" }] }, + }); + expect( + await result( + await modern.fetch( + rpc("tools/call", { name: "visible", arguments: {} }), + ), + ), + ).toHaveProperty("result"); + expect(executions).toBe(1); + allowVisible = false; + const empty = await modern.fetch(rpc("tools/list")); + expect(empty.status).toBe(200); + expect(await result(empty)).toMatchObject({ result: { tools: [] } }); + const hidden = await modern.fetch( + rpc("tools/call", { name: "secret", arguments: {} }), + ); + const missing = await modern.fetch( + rpc("tools/call", { name: "no-such-tool", arguments: {} }), + ); + expect(hidden.status).toBe(403); + expect(missing.status).toBe(hidden.status); + expect(await missing.text()).toBe(await hidden.text()); + expect(executions).toBe(1); + } finally { + await modern.close(); + } + + executions = 0; + allowVisible = true; + const legacy = createMcpServer({ + serverInfo, + auth, + legacy: { versions: ["2025-11-25"] }, + providers: providers(), + }); + const post = (body: unknown, session?: string) => + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...(session + ? { "Mcp-Session-Id": session, "MCP-Protocol-Version": "2025-11-25" } + : {}), + }, + body: JSON.stringify(body), + }); + const message = ( + id: number, + method: string, + params: Record = {}, + ) => ({ jsonrpc: "2.0", id, method, params }); + try { + const opened = await legacy.fetch( + post( + message(0, "initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }), + ), + ); + expect(opened.status).toBe(200); + const session = opened.headers.get("Mcp-Session-Id"); + if (!session) throw new Error("missing legacy session"); + expect( + ( + await legacy.fetch( + post( + { jsonrpc: "2.0", method: "notifications/initialized" }, + session, + ), + ) + ).status, + ).toBe(202); + const hidden = await legacy.fetch( + post( + message(1, "tools/call", { name: "secret", arguments: {} }), + session, + ), + ); + const missing = await legacy.fetch( + post( + message(2, "tools/call", { name: "no-such-tool", arguments: {} }), + session, + ), + ); + const hiddenText = await hidden.text(); + const missingText = await missing.text(); + expect(hidden.status).toBe(missing.status); + expect(denial(hiddenText)).toBe("Operation forbidden"); + expect(denial(missingText)).toBe(denial(hiddenText)); + expect(hiddenText).not.toContain("Tool unavailable"); + expect(missingText).not.toContain("Tool unavailable"); + expect(executions).toBe(0); + const listed = await legacy.fetch(post(message(3, "tools/list"), session)); + expect(listed.status).toBe(200); + expect(await result(listed)).toMatchObject({ + result: { tools: [{ name: "visible" }] }, + }); + allowVisible = false; + const empty = await legacy.fetch(post(message(4, "tools/list"), session)); + expect(empty.status).toBe(200); + expect(await result(empty)).toMatchObject({ result: { tools: [] } }); + expect(executions).toBe(0); + } finally { + await legacy.close(); + } + expect(seen.some((entry) => entry.startsWith("mcp:tools"))).toBe(false); +}); diff --git a/src/server/tools.ts b/src/server/tools.ts index 724cad7..dc61178 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -98,7 +98,8 @@ export function createToolProviders( authorizationPairs, authorization(params) { const tool = byName.get(String(params.name)); - return tool ? { resource: tool.resource, action: tool.action } : null; + // Null would skip the gate and reveal whether the name is in the catalog. + return tool ? { resource: tool.resource, action: tool.action } : false; }, schemas(params) { const definition = byName.get(String(params.name))?.definition; From dab6c9e8da3495385f053df246dca557d0fa18e7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:42:55 -0700 Subject: [PATCH 26/28] fix(server): deny tool calls before schema checks A rejected tools/call validated the catalog schema before the gate, so a missing required field differed from an unknown name. Denial now precedes schema and header checks. --- docs/migration.md | 3 +- src/server.ts | 27 ++++--- src/server/legacy.ts | 6 +- src/server/review-regressions.test.ts | 112 +++++++++++++++++--------- 4 files changed, 96 insertions(+), 52 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index f748a8a..bab79ae 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -42,7 +42,8 @@ validation must not resolve network references implicitly. well. Catalog visibility is not execution approval; the exposure pair is checked again on every invocation, and `approve` runs only after it is allowed. A `tools/call` whose name is not in the catalog gets the same denial as a - binding `authorize` rejects, so the call cannot probe membership. `tools/list` + binding `authorize` rejects, before schema or header checks, so the call + cannot probe membership. `tools/list` stays a filtered catalog — empty when nothing is visible — and is not a 403 merely because the caller can see no tools. - Private `server/discover` returns capabilities and server info only when diff --git a/src/server.ts b/src/server.ts index fdeaadd..ef80e7b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -190,6 +190,23 @@ export function createMcpServer(options: McpServerOptions): McpServer { id = envelope.id; const params = record(envelope.params); const meta = record(params._meta); + if (envelope.method !== "server/discover") { + const gated = providers[envelope.method]; + if (gated) { + const pair = methodAuthorization(gated, params); + if (pair !== null && options.auth !== "public" && identity) { + const permitted = + pair !== false && + (await options.auth.authorize(identity, pair)); + if (closed) return new Response(null, { status: 503 }); + if (!permitted) + return ( + options.challenge?.(403) ?? + new Response(null, { status: 403 }) + ); + } + } + } const validation: McpValidationContext = { era: "modern", protocolVersion: MCP_PROTOCOL_VERSION, @@ -255,16 +272,6 @@ export function createMcpServer(options: McpServerOptions): McpServer { } const provider = providers[envelope.method]; if (!provider) throw new McpError("Method not found", 404, -32601); - const pair = methodAuthorization(provider, params); - if (pair !== null && options.auth !== "public" && identity) { - const permitted = - pair !== false && (await options.auth.authorize(identity, pair)); - if (closed) return new Response(null, { status: 503 }); - if (!permitted) - return ( - options.challenge?.(403) ?? new Response(null, { status: 403 }) - ); - } const controller = new AbortController(); active.add(controller); const abort = () => controller.abort(request.signal.reason); diff --git a/src/server/legacy.ts b/src/server/legacy.ts index d3b2a99..18f3d7e 100644 --- a/src/server/legacy.ts +++ b/src/server/legacy.ts @@ -208,9 +208,6 @@ export function createLegacyServer( const pair = provider.authorization ? provider.authorization(params) : { resource: provider.resource, action: provider.action }; - const context = validation(session, message.method, params); - validateRequest(message.method, params, context); - validateRequestHeaders(request.headers, message.method, params, context); controller = new AbortController(); session.active.set(id, controller); if (session.expires <= Date.now() || session.closed) controller.abort(); @@ -231,6 +228,9 @@ export function createLegacyServer( ]))) ) throw new McpError("Operation forbidden", 403, -32602); + const context = validation(session, message.method, params); + validateRequest(message.method, params, context); + validateRequestHeaders(request.headers, message.method, params, context); let lastProgress = -Infinity; const providerContext: McpRequestContext = { identity, diff --git a/src/server/review-regressions.test.ts b/src/server/review-regressions.test.ts index 5067275..793c6cf 100644 --- a/src/server/review-regressions.test.ts +++ b/src/server/review-regressions.test.ts @@ -343,15 +343,33 @@ test("unknown tools/call matches a denied binding without probing the catalog", const seen: string[] = []; const providers = () => createToolProviders( - (["visible", "secret"] as const).map((name) => ({ - definition: { name, inputSchema: { type: "object" } }, - resource: `tool:${name}`, - action: "call", - execute: () => { - executions++; - return { content: [] }; + [ + { + definition: { name: "visible", inputSchema: { type: "object" } }, + resource: "tool:visible", + action: "call", + execute: () => { + executions++; + return { content: [] }; + }, + }, + { + definition: { + name: "secret", + inputSchema: { + type: "object", + properties: { token: { type: "string" } }, + required: ["token"], + }, + }, + resource: "tool:secret", + action: "call", + execute: () => { + executions++; + return { content: [] }; + }, }, - })), + ], "public", ); const auth = { @@ -389,15 +407,21 @@ test("unknown tools/call matches a denied binding without probing the catalog", providers: providers(), }); try { - const denied = await modern.fetch( - rpc("tools/call", { name: "secret", arguments: {} }), - ); - const unknown = await modern.fetch( - rpc("tools/call", { name: "no-such-tool", arguments: {} }), - ); - expect(denied.status).toBe(403); - expect(unknown.status).toBe(denied.status); - expect(await unknown.text()).toBe(await denied.text()); + for (const args of [{}, { token: "present" }]) { + const denied = await modern.fetch( + rpc("tools/call", { name: "secret", arguments: args }), + ); + const unknown = await modern.fetch( + rpc("tools/call", { name: "no-such-tool", arguments: args }), + ); + const deniedBody = await denied.text(); + const unknownBody = await unknown.text(); + expect(denied.status).toBe(403); + expect(unknown.status).toBe(denied.status); + expect(unknownBody).toBe(deniedBody); + expect(deniedBody).not.toContain("JSON Schema"); + expect(deniedBody).not.toContain("token"); + } expect(executions).toBe(0); const listed = await modern.fetch(rpc("tools/list")); expect(listed.status).toBe(200); @@ -478,33 +502,45 @@ test("unknown tools/call matches a denied binding without probing the catalog", ) ).status, ).toBe(202); - const hidden = await legacy.fetch( - post( - message(1, "tools/call", { name: "secret", arguments: {} }), - session, - ), - ); - const missing = await legacy.fetch( - post( - message(2, "tools/call", { name: "no-such-tool", arguments: {} }), - session, - ), - ); - const hiddenText = await hidden.text(); - const missingText = await missing.text(); - expect(hidden.status).toBe(missing.status); - expect(denial(hiddenText)).toBe("Operation forbidden"); - expect(denial(missingText)).toBe(denial(hiddenText)); - expect(hiddenText).not.toContain("Tool unavailable"); - expect(missingText).not.toContain("Tool unavailable"); + let nextId = 1; + for (const args of [{}, { token: "present" }]) { + const hidden = await legacy.fetch( + post( + message(nextId++, "tools/call", { name: "secret", arguments: args }), + session, + ), + ); + const missing = await legacy.fetch( + post( + message(nextId++, "tools/call", { + name: "no-such-tool", + arguments: args, + }), + session, + ), + ); + const hiddenText = await hidden.text(); + const missingText = await missing.text(); + expect(hidden.status).toBe(missing.status); + expect(denial(hiddenText)).toBe("Operation forbidden"); + expect(denial(missingText)).toBe(denial(hiddenText)); + expect(hiddenText).not.toContain("Tool unavailable"); + expect(missingText).not.toContain("Tool unavailable"); + expect(hiddenText).not.toContain("JSON Schema"); + expect(missingText).not.toContain("JSON Schema"); + } expect(executions).toBe(0); - const listed = await legacy.fetch(post(message(3, "tools/list"), session)); + const listed = await legacy.fetch( + post(message(nextId++, "tools/list"), session), + ); expect(listed.status).toBe(200); expect(await result(listed)).toMatchObject({ result: { tools: [{ name: "visible" }] }, }); allowVisible = false; - const empty = await legacy.fetch(post(message(4, "tools/list"), session)); + const empty = await legacy.fetch( + post(message(nextId++, "tools/list"), session), + ); expect(empty.status).toBe(200); expect(await result(empty)).toMatchObject({ result: { tools: [] } }); expect(executions).toBe(0); From ae759b84cfb17fdbe6e9ae8384be7b3fc706f4fc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 16:19:10 -0700 Subject: [PATCH 27/28] refactor: simplify the six review-fix commits Drop the one-use origin helper and method gate, the dead dynamic-skill check, the repeated exchange expiry check, and a restated scope comment. Authorization and protocol behavior is unchanged. --- src/auth/login.ts | 3 +-- src/extensions/skills/index.ts | 2 -- src/oauth-discovery.ts | 19 +++---------------- src/server/tools.ts | 3 +-- 4 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index fd52119..c5e6497 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -218,8 +218,7 @@ export function createMcpAuthorizationSession(opts: { "Authorization session expired or already consumed", ); exchanged = tokens; - } - if (consumed || now() >= expiresAt) + } else if (consumed || now() >= expiresAt) throw new OAuthDiscoveryError( "Authorization session expired or already consumed", ); diff --git a/src/extensions/skills/index.ts b/src/extensions/skills/index.ts index 35dd1c4..e089964 100644 --- a/src/extensions/skills/index.ts +++ b/src/extensions/skills/index.ts @@ -183,8 +183,6 @@ export function createSkillLoader( originSnapshot.authorizationContextId, snapshot.uri, ]); - const resources = snapshot.resources; - if (resources === "dynamic") return fail("Dynamic skills unsupported"); const canonicalBody = skillBody(snapshot); const fingerprint = await digest(encoder.encode(canonicalBody)); let diverged = true; diff --git a/src/oauth-discovery.ts b/src/oauth-discovery.ts index 31ab339..589854c 100644 --- a/src/oauth-discovery.ts +++ b/src/oauth-discovery.ts @@ -680,24 +680,14 @@ export function assertAuthorizationCallbackIssuer( assertAuthorizationIssuer(issuerOpts); } -function urlOrigin(raw: string): string | undefined { +function sameOrigin(endpoint: string, issuer: string): boolean { try { - return new URL(raw).origin; + return new URL(endpoint).origin === new URL(issuer).origin; } catch { - return undefined; + return false; } } -function sameOrigin(endpoint: string, issuer: string): boolean { - const endpointOrigin = urlOrigin(endpoint); - const issuerOrigin = urlOrigin(issuer); - return ( - endpointOrigin !== undefined && - issuerOrigin !== undefined && - endpointOrigin === issuerOrigin - ); -} - /** * Mix-up defense for metadata that names an authorization or token endpoint * on a different origin than the issuer. RFC 9207 allows a missing `iss` when @@ -780,9 +770,6 @@ function addOfflineAccessScope( opts: SelectMcpScopesOptions, ): string[] { if (opts.wantRefresh === false) return scopes; - // Scope widening follows the authorization server that will actually receive - // the authorize request. Metadata from another origin must not add - // offline_access on top of the resource's advertised scopes. if ( !sameOrigin( opts.entry.authorizationServer.authorizationEndpoint, diff --git a/src/server/tools.ts b/src/server/tools.ts index dc61178..1d50adb 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -56,8 +56,7 @@ export function createToolProviders( validateSchema(tool.definition.outputSchema); byName.set(tool.definition.name, tool); } - const bindings = [...byName.values()]; - const authorizationPairs = bindings.map((tool) => ({ + const authorizationPairs = [...byName.values()].map((tool) => ({ resource: tool.resource, action: tool.action, })); From bd6dbed871d583a81d7a54921d11eb0b302cafdf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 17:03:03 -0700 Subject: [PATCH 28/28] ci: smoke the neutral client and the peer-backed root entry A bare install cannot load the root entry because agent and oauth-core are optional peers. Check the standalone client without them, and the historical root exports with those peers installed. --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45a0f01..a71ffda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,11 @@ jobs: run: | set -euo pipefail TARBALL="$PWD/$(npm pack --silent)" - mkdir -p "$RUNNER_TEMP/c" && cd "$RUNNER_TEMP/c" + mkdir -p "$RUNNER_TEMP/neutral" && cd "$RUNNER_TEMP/neutral" npm init -y >/dev/null && npm pkg set type=module >/dev/null npm install "$TARBALL" + node -e 'import("@corbits/mcp/client").then((m) => { for (const n of ["mcpInitialize", "mcpListTools", "mcpCallTool"]) if (typeof m[n] !== "function") throw new Error("missing export: " + n); })' + mkdir -p "$RUNNER_TEMP/host" && cd "$RUNNER_TEMP/host" + npm init -y >/dev/null && npm pkg set type=module >/dev/null + npm install "$TARBALL" @intx/agent@0.5.0 @corbits/oauth-core@0.3.2 node -e 'import("@corbits/mcp").then((m) => { for (const n of ["mcpTools", "mcpInitialize", "mcpListTools", "mcpCallTool"]) if (typeof m[n] !== "function") throw new Error("missing export: " + n); })'