From f4e4793d58e66116b49512e0cfcd1a2b16ff5fdf Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Fri, 14 Aug 2026 11:59:32 +0200 Subject: [PATCH] feat(#700): serve MRTR to 2026-07-28 clients on the public MCP endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public endpoint ran the v1 SDK, which never answers `server/discover`, so every connection to it negotiated the legacy era no matter what the client supported. On that era a modern client strips `resultType`, which made omadia's MRTR invisible to it — and changing the emitted dialect could not have fixed that, because the era, not the body, was the blocker. Two SDK generations now sit behind the one path, routed by `isLegacyRequest`. That is a router rather than a port because neither generation can serve both eras here: the v1 line cannot negotiate 2026-07-28 at all, and the v2 line refuses to emit omadia's flat `inputRequests` array on either era ("each inputRequests entry must be an embedded elicitation/create, sampling/createMessage, or roots/list request"). Routing is the SDK's own documented composition for keeping an existing legacy deployment next to a strict modern endpoint. The documented 2025 contract is untouched, byte for byte — the existing endpoint suite passes unmodified. Modern callers get the revision's shape instead: one embedded `elicitation/create` request plus an opaque `requestState`, with the answers arriving as the spec's top-level `inputResponses`. `requestState` is HMAC-signed over a vault-persisted key, bound to the API key and the method, one-hour TTL. The spec makes integrity the server author's job and the SDK provides no default; a modified, expired or borrowed state is refused with the frozen `-32602`. The signed round counter also replaces the 2025 dialect's guessable bounce cap, which a caller could reset by stripping `inputResponses` from its arguments. Both `requestState.verify` and `inputRequired.legacyShim` belong on the SERVER options. Passing either to `createMcpHandler` compiles, runs, and silently does nothing — a tampered state is then accepted while the endpoint looks healthy. Measured, and the tamper test catches it. A tool cannot tell which era its caller spoke: it still asks with the `_pendingInputRequest` sentinel and still receives a flat `inputResponses` object. All translation stops at the endpoint. Known gap, recorded rather than papered over: the revision's elicitation schema admits only the `email`, `date`, `uri` and `date-time` string formats and has no masked-input concept, so a field a tool marked `secret` is named in the prose instead of flagged in the schema. Emitting a `password` format anyway would produce a request a conforming client rejects. Mutation checks (dist rebuilt each time): moving `requestState.verify` to the handler options turns the tamper and round-cap tests red; disabling the era routing turns 5 of 16 MRTR tests red. Closes #700 --- docs/CHANGELOG.md | 35 ++ middleware/src/mcp/README.md | 40 ++- middleware/src/mcp/publicMcpModernMrtr.ts | 148 ++++++++ middleware/src/mcp/publicMcpRequestState.ts | Bin 0 -> 7268 bytes middleware/src/mcp/publicMcpServer.ts | 320 +++++++++++++++++- middleware/src/mcp/wirePublicMcp.ts | 19 ++ .../publicMcp/publicMcpModernMrtr.test.ts | 308 +++++++++++++++++ 7 files changed, 861 insertions(+), 9 deletions(-) create mode 100644 middleware/src/mcp/publicMcpModernMrtr.ts create mode 100644 middleware/src/mcp/publicMcpRequestState.ts create mode 100644 middleware/test/publicMcp/publicMcpModernMrtr.test.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0acfd27df..68c075a69 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,41 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Added — the public MCP endpoint serves MRTR to 2026-07-28 clients (#700) + +- **Two SDK generations behind one path, routed by protocol era.** A request + that negotiates the 2025 `initialize` handshake is served by the v1 wiring + exactly as before; one carrying the 2026-07-28 envelope is served by + `@modelcontextprotocol/server@2`. Neither generation can serve the other's + era, which is why this is a router and not a port: the v1 line never answers + `server/discover` (so a modern client negotiating against it falls back to + legacy, where it strips `resultType` and MRTR becomes invisible to it), and + the v2 line refuses to emit omadia's flat `inputRequests` array at all, on + either era. Routing is the SDK's own documented composition for this. +- **The documented 2025 contract is untouched.** Same array dialect, same + `arguments.inputResponses` retry, byte for byte — the existing endpoint suite + passes unmodified. Modern callers instead get the revision's shape: one + embedded `elicitation/create` request and an opaque `requestState`. +- **`requestState` is integrity-protected, which the spec requires and the SDK + does not do for you.** HMAC-SHA256 via the SDK's own codec, over a + vault-persisted key (so any instance behind the load balancer can verify what + another minted), bound to the API key and the method, one-hour TTL. A + modified, expired or borrowed state is refused with the frozen `-32602`. + Without a vault the endpoint generates a per-process key and warns — still + signed and still verified, just not across instances. +- **The bounce cap stops being guessable.** On the 2025 dialect it is inferred + from `inputResponses` appearing in the arguments, so a caller that strips the + key gets a fresh card forever. On the modern dialect the round counter is + inside the signed state. +- **A tool cannot tell which era its caller spoke.** The translation lives + entirely in the endpoint: a tool still asks with the `_pendingInputRequest` + sentinel and still receives a flat `inputResponses` object. +- **Known gap, stated rather than papered over:** the revision's elicitation + schema has no masked-input concept (`email`, `date`, `uri`, `date-time` are + the only string formats), so a field the tool marked `secret` is named in the + prose instead of flagged in the schema. Emitting a `password` format anyway + would produce a request a conforming client rejects. + ### Added — one-click updates on Fly.io (#696, follow-up to #432) - **A Fly executor for the rolling self-update.** Fly Machines are Firecracker diff --git a/middleware/src/mcp/README.md b/middleware/src/mcp/README.md index 8f6a9c504..8696e48b0 100644 --- a/middleware/src/mcp/README.md +++ b/middleware/src/mcp/README.md @@ -183,13 +183,39 @@ Things worth knowing before you build on this: fields; names ≤64 and labels ≤120 characters). - **`secret: true` means render it masked.** It is advisory about display only — the value still crosses the wire to the tool. -- **This endpoint speaks the 2025-era dialect only** (`inputRequests` as a flat - array). The 2026-07-28 revision instead sends a MAP of whole - `elicitation/create` requests plus an opaque `requestState`. omadia's MCP - *client* understands both since #562 phase 3 — its `parseMcpInputRequests` - branches on the shape — but this server surface still emits only the array - form, so a strictly-2026-07-28 client cannot complete MRTR against omadia. - Tracked in #700; porting this surface is not part of #562. + +### Two dialects, chosen by your protocol era (#700) + +Everything above describes the **2025-era** dialect, and it is unchanged. If +your client negotiates the 2025 `initialize` handshake, that is what you get, +byte for byte. + +A client that negotiates **2026-07-28** (via `server/discover`) gets the +revision's own MRTR instead: + +| | 2025-era | 2026-07-28 | +|---|---|---| +| the ask | `inputRequests` as a flat array of fields | `inputRequests` as a map with one embedded `elicitation/create` request | +| the answers | `arguments.inputResponses`, flat | top-level `inputResponses` param, one `ElicitResult` per key | +| the loop guard | inferred from `inputResponses` being present | a signed `requestState` you echo back verbatim | + +You do not choose between them and there is no configuration switch. The +endpoint routes on the era of your request, because neither SDK generation can +serve both: the 2025 line never answers `server/discover`, and the 2026 line +refuses to emit the array form at all. + +Two things worth knowing on the modern dialect: + +- **Echo `requestState` unchanged.** It is HMAC-signed, bound to your API key + and the method, and valid for one hour. A modified, expired or borrowed one + is refused with `-32602 Invalid or expired requestState`. It is also what + counts your input rounds, so stripping it does not buy you another card. +- **`secret` cannot be expressed.** The revision's elicitation schema admits + only the `email`, `date`, `uri` and `date-time` string formats, with no + masked-input concept, so a field the tool marked secret is named in the + prose instead of flagged in the schema. Your form widget will not mask it. + +omadia's own MCP client speaks both since #562 phase 3. - A tool that emits an unusable request errors with the reason named, rather than handing you a half-rendered form. diff --git a/middleware/src/mcp/publicMcpModernMrtr.ts b/middleware/src/mcp/publicMcpModernMrtr.ts new file mode 100644 index 000000000..8c9261646 --- /dev/null +++ b/middleware/src/mcp/publicMcpModernMrtr.ts @@ -0,0 +1,148 @@ +/** + * MRTR in the 2026-07-28 dialect, for the public MCP endpoint (issue #700). + * + * ─── Why there are two dialects at all ────────────────────────────────────── + * + * omadia's MRTR (#544) predates the revision that standardised it. It puts a + * flat ARRAY of fields in `inputRequests` and has the caller retry with a flat + * `arguments.inputResponses` object. The 2026-07-28 revision instead sends a + * MAP of whole `elicitation/create` requests and takes the answers back as + * top-level `inputResponses` params, one `ElicitResult` per key. + * + * Both are live. `publicMcpServer` routes a 2025-era request to the v1 serving + * path, which keeps emitting the array form byte for byte, and a modern request + * to the v2 path, which uses this module. The choice is made by the protocol + * era of the connection, never by configuration — see the endpoint README. + * + * ─── What this module is NOT allowed to change ────────────────────────────── + * + * The TOOL's view. A dispatched tool asks for input by emitting the same + * `_pendingInputRequest` sentinel it always has, and receives the answers as + * the same flat `arguments.inputResponses` object it always has. Everything + * era-specific stops at this file. A tool that works on a 2025 caller works + * unchanged on a 2026-07-28 one, which is the whole point of putting the + * translation here rather than in the dispatch layer. + */ + +import { inputRequired } from '@modelcontextprotocol/server'; + +import type { McpInputField } from '@omadia/orchestrator'; + +import type { ToolEmittedInputRequest } from './publicMcpInputRequired.js'; + +/** + * The key the single embedded elicitation is filed under. + * + * One request, not one per field: omadia's card IS one form that a human fills + * in and submits once, and splitting it into N embedded elicitations would ask + * a conforming client to render N dialogs for what the tool asked as a single + * question. The key is stable so the retry leg can find the answers again. + */ +export const MODERN_INPUT_REQUEST_KEY = 'omadiaInputRequest'; + +/** The elicitation schema's own property map, and one entry in it. Derived + * from the builder rather than restated, so a change in the SDK's accepted + * shape is a compile error here instead of a runtime rejection at the seam. */ +type ElicitObjectSchema = Extract< + Parameters[0]['requestedSchema'], + { type: 'object' } +>; +type ElicitFieldSchema = ElicitObjectSchema['properties'][string]; + +/** + * JSON Schema for one field. + * + * NOTE — `secret` does not survive into the schema, and that is the spec's + * limitation rather than a shortcut here. The 2026-07-28 elicitation schema + * admits exactly four string formats (`email`, `date`, `uri`, `date-time`); + * there is no `password`, and no other masked-input concept anywhere in the + * revision. Emitting one anyway would produce a request a conforming client + * rejects, which trades a missing hint for a broken call. + * + * Dropping it silently was the other option and is worse: `secret` is a + * privacy hint about what a human is about to type on screen. It is therefore + * carried in the prose instead — see {@link secrecyNotice} — so a modern + * client's user is told, even though its form widget cannot mask the input. + */ +function fieldSchema(field: McpInputField): ElicitFieldSchema { + return { + type: 'string', + ...(field.label !== undefined ? { title: field.label } : {}), + ...(field.description !== undefined ? { description: field.description } : {}), + }; +} + +/** + * Server-authored sentence naming the fields the tool marked secret. + * + * Deliberately appended to the message rather than mixed into each field's + * tool-authored `description`: this text is omadia's, not the tool's, and + * keeping the two apart is what lets a reader tell who is speaking on a + * surface where the tool's strings are untrusted. + */ +function secrecyNotice(fields: readonly McpInputField[]): string | undefined { + const secret = fields.filter((field) => field.secret === true); + if (secret.length === 0) return undefined; + const names = secret.map((field) => field.label ?? field.name).join(', '); + return `Sensitive, and this protocol revision cannot ask your client to mask it: ${names}.`; +} + +/** + * Render a tool's input request as the spec's embedded elicitation map. + * + * The fields were already validated and clamped by `parseMcpInputRequests` + * (at most 8, names and labels bounded) before they got here, so this is a + * projection and not a second validation pass. Deliberately so: two validators + * for one vocabulary is how the two directions drift apart. + */ +export function toEmbeddedInputRequests( + request: ToolEmittedInputRequest, + message: string, +): Record> { + const properties: Record = {}; + const required: string[] = []; + for (const field of request.inputRequests) { + properties[field.name] = fieldSchema(field); + // Absent `required` means required on omadia's dialect (a server that + // bothered to block on a field is asking for it); the spec is explicit + // instead, so the implicit rule is made explicit here rather than lost. + if (field.required !== false) required.push(field.name); + } + const notice = secrecyNotice(request.inputRequests); + return { + [MODERN_INPUT_REQUEST_KEY]: inputRequired.elicit({ + message: notice === undefined ? message : `${message} ${notice}`, + requestedSchema: { type: 'object', properties, required }, + }), + }; +} + +/** + * Flatten the client's `inputResponses` back into the object a tool expects. + * + * Returns `undefined` when there is nothing usable — a missing key, or an + * elicitation the human declined or cancelled. That is NOT an error here: a + * declined card means the tool never got its answer, so the call proceeds as + * if the values were never supplied and the tool decides what to do. Turning a + * decline into a protocol error would make "the user pressed cancel" look like + * a broken client. + * + * The values are the human's, relayed untouched. This endpoint does not read + * them and must not: they may be the secrets the card asked for. + */ +export function flattenInputResponses( + responses: unknown, +): Record | undefined { + if (responses === null || typeof responses !== 'object' || Array.isArray(responses)) { + return undefined; + } + const entry = (responses as Record)[MODERN_INPUT_REQUEST_KEY]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; + const shape = entry as { action?: unknown; content?: unknown }; + if (shape.action !== 'accept') return undefined; + if (shape.content === null || typeof shape.content !== 'object' || Array.isArray(shape.content)) { + return undefined; + } + const content = shape.content as Record; + return Object.keys(content).length > 0 ? content : undefined; +} diff --git a/middleware/src/mcp/publicMcpRequestState.ts b/middleware/src/mcp/publicMcpRequestState.ts new file mode 100644 index 0000000000000000000000000000000000000000..e17ca8c029a35c75fa7a8209361f2591f8fbb9bf GIT binary patch literal 7268 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 U5)9hzSK*r|SbJvFo&_cU3sm+Jy#N3J literal 0 HcmV?d00001 diff --git a/middleware/src/mcp/publicMcpServer.ts b/middleware/src/mcp/publicMcpServer.ts index 12d2d9669..c3b67d495 100644 --- a/middleware/src/mcp/publicMcpServer.ts +++ b/middleware/src/mcp/publicMcpServer.ts @@ -45,7 +45,7 @@ * present. Any looser rule turns the list into an inventory of what to attack. */ -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import type { Request, RequestHandler, Response } from 'express'; import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; @@ -56,10 +56,23 @@ import { ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; +import { + McpServer as ModernMcpServer, + createMcpHandler, + inputRequired, + isLegacyRequest, + type RequestStateCodec, + type Tool as ModernTool, +} from '@modelcontextprotocol/server'; import type { ApiKeyPrincipal, RateLimiter } from '@omadia/api-key-auth'; import { hasScope, hasWriteScope, MCP_INVOKE_SCOPE, MCP_LIST_SCOPE } from '@omadia/api-key-auth'; -import { AI_PROVENANCE_META_KEY, ENVELOPE_PROVENANCE } from '@omadia/channel-sdk'; +import { + AI_PROVENANCE_HEADER, + AI_PROVENANCE_META_KEY, + ENVELOPE_PROVENANCE, +} from '@omadia/channel-sdk'; +import { REPLAY_ARG_KEY } from '@omadia/orchestrator'; import type { DispatchableToolSpec, PrivacyTurnHandle, @@ -77,11 +90,20 @@ import { type McpInputRequiredResult, } from './publicMcpInputRequired.js'; import type { PublicMcpKeyBinding, PublicMcpKeyBindingStore } from './publicMcpKeyBindings.js'; +import { + flattenInputResponses, + toEmbeddedInputRequests, +} from './publicMcpModernMrtr.js'; import { createFailClosedPrivacyGate, isPubliclyServableTool, type PublicMcpPrivacyGate, } from './publicMcpPrivacy.js'; +import { + PUBLIC_MCP_MAX_INPUT_ROUNDS, + createPublicMcpRequestStateCodec, + type PublicMcpRequestState, +} from './publicMcpRequestState.js'; /** Mirrors `LoopbackMcpServer`'s ceiling. See `enforceBodyCap` for why it is * re-checked here instead of being handed to `express.json`. */ @@ -206,6 +228,24 @@ export interface PublicMcpServerDeps { readonly serverVersion?: string; readonly toolTimeoutMs?: number; readonly maxConcurrentCalls?: number; + /** + * HMAC codec for the 2026-07-28 `requestState` (issue #700). + * + * SHOULD be supplied by the wiring from the vault-backed key, because the + * endpoint is stateless and horizontally scaled: the instance that verifies + * an echoed state is usually not the one that minted it, and a per-process + * key makes retries fail only when they land elsewhere — intermittently, + * under load, which is the worst way to discover a key problem. + * + * Omitted, this class generates a process-local key and says so once. That + * keeps single-process installs and tests working with the integrity check + * fully intact, rather than degrading to an unverified passthrough, which is + * the one option that would be silently insecure. + * + * A resolver rather than a value because the key comes from the vault, which + * is async, while the mount that supplies it is not. Called at most once. + */ + readonly resolveRequestStateCodec?: () => Promise>; } /** Deliberately identical for "no such tool", "not allowlisted for this key", @@ -217,9 +257,49 @@ function unavailableToolMessage(name: string): string { return `Tool \`${name}\` is not available to this API key.`; } +/** + * The handler context fields the 2026-07-28 leg reads (#700). + * + * Typed structurally and read defensively: both are absent on the ORIGINAL + * call and present only on a retry, so every access here is on a value the + * seam may legitimately not have populated. + */ +interface ModernCallContext { + readonly mcpReq?: { + readonly inputResponses?: unknown; + readonly requestState?: () => T | undefined; + }; +} + +/** The client's embedded answers on a retry, or `undefined` on a first call. */ +function readInputResponses(ctx: unknown): unknown { + return (ctx as ModernCallContext | undefined)?.mcpReq?.inputResponses; +} + +/** + * The VERIFIED `requestState` payload. + * + * Safe to trust: the seam ran `requestState.verify` before the handler, and a + * value that failed it never gets this far — the request was already answered + * with the frozen `-32602`. What arrives here is the codec's decoded payload, + * not the wire string. + */ +function readVerifiedState(ctx: unknown): PublicMcpRequestState | undefined { + const accessor = (ctx as ModernCallContext | undefined)?.mcpReq?.requestState; + if (typeof accessor !== 'function') return undefined; + const state = accessor(); + return state !== null && typeof state === 'object' && typeof state.round === 'number' + ? state + : undefined; +} + export class PublicMcpServer { private inFlight = 0; + /** Memoised so a generated fallback key stays stable for this process — + * minting per request would refuse every retry, including its own. */ + private resolvedCodec?: Promise>; + constructor(private readonly deps: PublicMcpServerDeps) {} private get toolTimeoutMs(): number { @@ -415,6 +495,31 @@ export class PublicMcpServer { return; } + // ── era routing (#700) ────────────────────────────────────────────────── + // Two SDK generations serve this one path, and the protocol era of the + // request decides which — never configuration, and never a per-deployment + // flag. The reason is that neither generation can serve both eras here: + // + // - The v1 line has no `server/discover`, so a 2026-07-28 client + // negotiating against it always falls back to the legacy era, where a + // modern client STRIPS `resultType` off the reply. MRTR is invisible to + // it no matter which dialect the body carries. + // - The v2 line refuses to emit omadia's 2025-era `inputRequests` array at + // all, on either era ("each inputRequests entry must be an embedded + // elicitation/create, sampling/createMessage, or roots/list request"), + // so serving legacy traffic from it would withdraw the dialect this + // endpoint's README documents and existing integrations are built on. + // + // Routing is the SDK's own documented composition for exactly this + // situation. `isLegacyRequest` is authoritative and must not be + // second-guessed: everything it calls non-legacy — including malformed + // envelopes and unsupported-revision claims — belongs to the modern path, + // which owns those error answers. + if (!(await isLegacyRequest(this.toWebRequest(req), req.body))) { + await this.handleModern(req, res, principal); + return; + } + let session: ReturnType | undefined; try { session = this.createRequestScopedServer(principal); @@ -526,6 +631,217 @@ export class PublicMcpServer { return { mcp, transport }; } + // ── the 2026-07-28 leg (#700) ───────────────────────────────────────────── + + /** + * The `requestState` codec, resolved once. + * + * A generated key is a real fallback, not a disabled check: states are still + * signed and still verified, they just stop being verifiable by a SIBLING + * instance. The warning names that, because the symptom (a retry failing only + * when it lands on another process) is otherwise very hard to read. + */ + private codec(): Promise> { + // The PROMISE is memoised, not its value: two concurrent first requests + // must not each resolve a key, or the second would mint under a key the + // first never used and every retry would land on a coin flip. + this.resolvedCodec ??= (async () => { + const resolve = this.deps.resolveRequestStateCodec; + if (resolve) return resolve(); + console.warn( + '[public-mcp] ⚠ MRTR requestState key GENERATED per process — a retry served by another instance will be refused. Wire `resolveRequestStateCodec` from the vault.', + ); + return createPublicMcpRequestStateCodec(randomBytes(32)); + })(); + return this.resolvedCodec; + } + + /** + * Rebuild the Express request as a web-standard `Request`. + * + * Built from the ALREADY-PARSED body rather than the socket: `express.json()` + * consumed the stream long before this runs, so a handler that tried to read + * `req` again would hang on a stream with nothing left in it. The parsed body + * is handed to the SDK separately (`parsedBody`) for the same reason — this + * copy exists so the request classifier and the transport see identical + * bytes. + */ + private toWebRequest(req: Request): globalThis.Request { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + const url = `http://${req.headers.host ?? 'public-mcp.invalid'}${req.originalUrl}`; + return new globalThis.Request(url, { + method: req.method, + headers, + ...(req.body !== undefined ? { body: JSON.stringify(req.body) } : {}), + }); + } + + /** + * Serve one 2026-07-28 request. + * + * The handler is built per request, like the legacy session above and for the + * same reason: the principal is baked into the factory's closure, so there is + * no window in which one caller's server instance can answer another + * caller's request. This surface is internet-facing and its whole + * authorization model is per-key; a shared instance would put that model one + * refactor away from a cross-key leak, and building a handler is cheaper than + * the server-plus-transport pair the legacy path already builds per request. + */ + private async handleModern( + req: Request, + res: Response, + principal: ApiKeyPrincipal, + ): Promise { + const handler = createMcpHandler(() => this.createModernServer(principal), { + // The legacy era never reaches here — `handleHttp` routed it to the v1 + // path already. Saying so explicitly means a routing regression fails + // loudly at this boundary instead of quietly serving 2025 traffic from + // the generation that cannot speak omadia's dialect. + legacy: 'reject', + }); + const response = await handler.fetch(this.toWebRequest(req), { + parsedBody: req.body, + // Pass-through only: authentication already happened in `requireApiKey`. + // The token is deliberately NOT forwarded — nothing downstream needs the + // secret, and the key id is what the `requestState` binding needs. + authInfo: { token: '', clientId: principal.keyId, scopes: [...principal.scopes] }, + }); + await this.writeWebResponse(res, response); + } + + /** Copy a web `Response` onto the Express response. */ + private async writeWebResponse(res: Response, response: globalThis.Response): Promise { + response.headers.forEach((value, name) => { + // The provenance header is already set by the router's middleware for + // every reply on this route; letting the SDK's copy overwrite it would + // make the AI-Act marking depend on which leg answered (#647). + if (name.toLowerCase() === AI_PROVENANCE_HEADER) return; + res.setHeader(name, value); + }); + res.status(response.status); + const body = response.body ? Buffer.from(await response.arrayBuffer()) : undefined; + if (body === undefined || body.length === 0) { + res.end(); + return; + } + res.end(body); + } + + /** + * The 2026-07-28 server instance for ONE request. + * + * Every authorization decision goes through the SAME `listToolsFor` / + * `callToolFor` the legacy leg uses. That is the point: the four gates + * (binding, allowlist, scope, privacy) are not reimplemented per era, so + * there is no second place for them to disagree. Only the MRTR dialect and + * the round accounting differ, and both live in the handler below. + */ + private async createModernServer(principal: ApiKeyPrincipal): Promise { + const codec = await this.codec(); + const mcp = new ModernMcpServer( + { + name: this.deps.serverName ?? 'omadia-public-mcp', + version: this.deps.serverVersion ?? '0.0.0', + }, + { + capabilities: { tools: {} }, + // BOTH of these belong on the SERVER options. Passing either to + // `createMcpHandler` compiles, runs, and does nothing — a tampered + // `requestState` is then accepted while the endpoint looks healthy. + // Measured; do not move them. + inputRequired: { legacyShim: false }, + requestState: { verify: codec.verify }, + }, + ); + + mcp.server.setRequestHandler('tools/list', async () => + this.sanitized('tools/list', async () => ({ + // Same projection the legacy leg serves. The cast asserts what + // TypeScript cannot prove about a caller-supplied JSON Schema: v2 + // models `inputSchema` as a concrete JSON value where this endpoint + // carries it as `unknown`. The runtime value is the identical object + // the v1 leg puts on the wire — nothing here reshapes it. + tools: (await this.listToolsFor(principal)) as unknown as ModernTool[], + })), + ); + + mcp.server.setRequestHandler('tools/call', async (request, ctx) => + this.sanitized('tools/call', async () => { + const params = request.params as unknown as { + name: string; + arguments?: Record; + _meta?: { idempotencyKey?: unknown }; + }; + const name = params.name; + const idempotencyKey = + typeof params._meta?.idempotencyKey === 'string' && params._meta.idempotencyKey.length > 0 + ? params._meta.idempotencyKey + : undefined; + + // The retry leg. The human's answers arrive as a spec `ElicitResult` + // and are handed to the tool as the SAME flat object the 2025-era + // dialect delivers, so a tool never learns which era its caller spoke. + const answered = flattenInputResponses(readInputResponses(ctx)); + const args: Record = { + ...(params.arguments ?? {}), + ...(answered !== undefined ? { [REPLAY_ARG_KEY]: answered } : {}), + }; + + const result = await this.callToolFor(principal, name, args, idempotencyKey); + const pending = result.isError + ? undefined + : parseToolEmittedInputRequest(result.content); + + if (pending === undefined || !pending.ok) { + if (pending !== undefined && pending.rejection.kind === 'unusable') { + return this.modernBody({ + content: [ + { type: 'text' as const, text: inputRequestMalformedError(name, pending.rejection.reason) }, + ], + isError: true, + }); + } + return this.modernBody({ + content: [{ type: 'text' as const, text: result.content }], + ...(result.isError ? { isError: true } : {}), + }); + } + + // The bounce cap, read off the SIGNED round rather than inferred from + // the arguments. On the 2025-era dialect a caller that strips + // `inputResponses` gets a fresh card forever; here it cannot, because + // the count it would have to forge is under the endpoint's own MAC. + const round = (readVerifiedState(ctx)?.round ?? 0) + 1; + if (round > PUBLIC_MCP_MAX_INPUT_ROUNDS) { + return this.modernBody({ + content: [{ type: 'text' as const, text: inputRequestBounceError(name) }], + isError: true, + }); + } + + const rendered = renderInputRequiredResult(pending.request); + return inputRequired({ + inputRequests: toEmbeddedInputRequests(pending.request, rendered.message ?? ''), + requestState: await codec.mint({ tool: name, round }, ctx), + }); + }), + ); + + return mcp; + } + + /** Attach the per-call AI-Act provenance twin, exactly as the legacy leg + * does (#647) — the marking must not depend on which era answered. */ + private modernBody>( + body: T, + ): T & { _meta: Record } { + return { ...body, _meta: { [AI_PROVENANCE_META_KEY]: ENVELOPE_PROVENANCE } }; + } + /** * The tools this key can actually CALL, name-sorted. * diff --git a/middleware/src/mcp/wirePublicMcp.ts b/middleware/src/mcp/wirePublicMcp.ts index 186b9dd4c..96133bd88 100644 --- a/middleware/src/mcp/wirePublicMcp.ts +++ b/middleware/src/mcp/wirePublicMcp.ts @@ -34,6 +34,10 @@ import { type PublicMcpKeyBindingStore, } from './publicMcpKeyBindings.js'; import { PUBLIC_MCP_PATH, PUBLIC_MCP_SERVER_NAME } from './publicMcpPath.js'; +import { + createPublicMcpRequestStateCodec, + resolvePublicMcpRequestStateKey, +} from './publicMcpRequestState.js'; import { createPublicMcpRouter } from './publicMcpRouter.js'; import type { PublicMcpAuditEntry, PublicMcpDispatcher } from './publicMcpServer.js'; @@ -346,6 +350,21 @@ export function mountPublicMcp(app: Express, requireAuth: RequestHandler, deps: privacy: deps.privacy ?? makePrivacyProvider(deps.getPrivacyService ?? (() => undefined)), serverName: PUBLIC_MCP_SERVER_NAME, + // #700 — the MRTR `requestState` key for 2026-07-28 callers. Vault-backed + // so every instance behind the load balancer verifies what any other one + // minted; without a vault the endpoint falls back to a per-process key + // and says so, which is right for a single-process install and loud + // enough to notice in a scaled one. + ...(deps.vault + ? { + resolveRequestStateCodec: async (): Promise< + ReturnType + > => + createPublicMcpRequestStateCodec( + await resolvePublicMcpRequestStateKey(requireVault(deps)), + ), + } + : {}), ...(deps.toolTimeoutMs !== undefined ? { toolTimeoutMs: deps.toolTimeoutMs } : {}), ...(deps.maxConcurrentCalls !== undefined ? { maxConcurrentCalls: deps.maxConcurrentCalls } diff --git a/middleware/test/publicMcp/publicMcpModernMrtr.test.ts b/middleware/test/publicMcp/publicMcpModernMrtr.test.ts new file mode 100644 index 000000000..2fb006953 --- /dev/null +++ b/middleware/test/publicMcp/publicMcpModernMrtr.test.ts @@ -0,0 +1,308 @@ +/** + * Issue #700 — MRTR on the public MCP endpoint, 2026-07-28 era. + * + * `publicMcpInputRequired.test.ts` is the 2025-era half: it drives the same + * endpoint with plain JSON-RPC and asserts omadia's flat `inputRequests` array + * dialect, unchanged. This file is the other half, and it drives a REAL + * `@modelcontextprotocol/client@2` against the SAME mounted route. + * + * Why both must exist, stated once: the endpoint now serves two SDK + * generations, chosen by the protocol era of the request. Neither can serve the + * other's era — the v1 line never answers `server/discover` (so a modern client + * negotiating against it falls back to legacy, where it strips `resultType` and + * MRTR becomes invisible), and the v2 line refuses to emit omadia's array + * dialect at all. A suite covering one era would report green while the other + * was entirely broken. + * + * What is asserted here beyond "it works": + * + * - The era really is `modern`, so this file cannot silently drift onto the + * legacy path and keep passing. + * - `requestState` is integrity-protected. A tampered one is REFUSED, which + * is the spec's server requirement and the one property that is invisible + * in a happy-path test. + * - The tool sees the identical flat `inputResponses` object it sees on the + * 2025-era dialect. Era-dependent divergence in what a TOOL observes is the + * failure mode this whole split exists to avoid. + */ + +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + Client, + StreamableHTTPClientTransport, + isInputRequiredResult, +} from '@modelcontextprotocol/client'; +import { MCP_INVOKE_SCOPE, MCP_LIST_SCOPE } from '@omadia/api-key-auth'; +import type { ToolDispatchResult } from '@omadia/orchestrator'; + +import { PENDING_INPUT_REQUEST_KEY } from '../../src/mcp/publicMcpInputRequired.js'; +import { MODERN_INPUT_REQUEST_KEY } from '../../src/mcp/publicMcpModernMrtr.js'; +import { + fakeDispatcher, + isSandboxListenDenied, + startHarness, + type Harness, + type HarnessOptions, +} from './harness.js'; + +const TOOL = 'book_room'; +const KEY_TOKEN = 'omadia_ak_test_token_cccccccccccccccc'; +const KEY_ID = 'key-modern-mrtr'; + +/** Every argument object the dispatcher was handed, in order. */ +const seen: unknown[] = []; + +/** A tool that asks for a PIN once, then completes using what came back. */ +async function bookRoom(input: unknown): Promise { + seen.push(input); + const args = (input ?? {}) as Record; + const responses = args['inputResponses'] as Record | undefined; + if (responses && Object.keys(responses).length > 0) { + return { content: `booked ${String(args['roomId'])} with pin=${String(responses['pin'])}` }; + } + return { + content: JSON.stringify({ + [PENDING_INPUT_REQUEST_KEY]: { + message: 'PIN required for this room.', + inputRequests: [{ name: 'pin', label: 'PIN', secret: true, required: true }], + }, + }), + }; +} + +/** A tool that asks every time — the bounce the round cap has to refuse. */ +async function alwaysAsks(input: unknown): Promise { + seen.push(input); + return { + content: JSON.stringify({ + [PENDING_INPUT_REQUEST_KEY]: { message: 'again', inputRequests: [{ name: 'pin' }] }, + }), + }; +} + +function options(handle: (input: unknown) => Promise): HarnessOptions { + return { + keys: [{ token: KEY_TOKEN, id: KEY_ID, scopes: [MCP_LIST_SCOPE, MCP_INVOKE_SCOPE] }], + bindingRows: [ + { + key_id: KEY_ID, + agent_id: 'ops', + read_tools: [TOOL], + write_tools: [], + write_rate_limit_per_minute: 5, + enabled: true, + }, + ], + dispatchers: { ops: fakeDispatcher([{ name: TOOL, handle }]) }, + }; +} + +const started: Harness[] = []; +const clients: Client[] = []; +after(async () => { + for (const c of clients.splice(0)) await c.close().catch(() => {}); + for (const h of started.splice(0)) await h.close(); +}); + +async function start( + opts: HarnessOptions, + t: { skip: (m: string) => void }, +): Promise { + try { + const h = await startHarness(opts); + started.push(h); + return h; + } catch (error) { + if (isSandboxListenDenied(error)) { + t.skip('sandbox blocks loopback listeners on 127.0.0.1'); + return undefined; + } + throw error; + } +} + +/** + * Build `tools/call` params for a retry leg. + * + * The cast is the point, not an accident: `@modelcontextprotocol/client@2` does + * not MODEL `inputResponses` / `requestState` on `callTool`'s params even + * though its transport serialises them onto the wire exactly as the spec + * requires (verified against the raw POST bodies). A real integration hits the + * same gap, so the test reproduces what that integration has to write rather + * than routing around it. + */ +function retryParams(params: Record): Parameters[0] { + return params as unknown as Parameters[0]; +} + +/** The `requestState` off an ask, read through `unknown` because the SDK's + * result type does not carry it either. */ +function stateOf(result: unknown): string { + const state = (result as { requestState?: unknown }).requestState; + assert.equal(typeof state, 'string', 'an ask must carry a requestState'); + return state as string; +} + +/** A negotiating client, authenticated exactly as a real integration would be. */ +async function connect(harness: Harness): Promise { + const client = new Client( + { name: 'modern-integration', version: '0.0.0' }, + { + versionNegotiation: { mode: 'auto' }, + // omadia parks and asks a human; it never fulfils in-process. + inputRequired: { autoFulfill: false }, + capabilities: { elicitation: {} }, + }, + ); + clients.push(client); + await client.connect( + new StreamableHTTPClientTransport(new URL(harness.url), { + requestInit: { headers: { Authorization: `Bearer ${KEY_TOKEN}` } }, + }), + ); + return client; +} + +describe('#700 — the public endpoint serves MRTR to a 2026-07-28 client', () => { + it('MUTATION CHECK: negotiates the modern era against this endpoint', async (t) => { + // Load-bearing: every assertion below would also pass on a legacy + // connection that happened to answer similarly, and the modern leg — + // the only one `requestState` and the embedded elicitation exist on — + // would stop being covered while this file kept reporting green. + const h = await start(options(bookRoom), t); + if (!h) return; + const client = await connect(h); + assert.equal(client.getProtocolEra(), 'modern'); + }); + + it('asks with a spec-shaped embedded elicitation and an opaque requestState', async (t) => { + const h = await start(options(bookRoom), t); + if (!h) return; + const client = await connect(h); + + const result = await client.callTool( + { name: TOOL, arguments: { roomId: 'r1' } }, + { allowInputRequired: true }, + ); + + assert.ok(isInputRequiredResult(result), JSON.stringify(result)); + const requests = (result as { inputRequests?: Record }).inputRequests ?? {}; + assert.deepEqual(Object.keys(requests), [MODERN_INPUT_REQUEST_KEY]); + const embedded = requests[MODERN_INPUT_REQUEST_KEY] as { + method: string; + params: { message: string; requestedSchema: Record }; + }; + assert.equal(embedded.method, 'elicitation/create'); + assert.match(embedded.params.message, /PIN required for this room\./); + // The spec has no masked-input concept, so the secrecy the tool declared is + // carried in prose rather than dropped. + assert.match(embedded.params.message, /Sensitive/); + assert.deepEqual( + Object.keys( + (embedded.params.requestedSchema as { properties: Record }).properties, + ), + ['pin'], + ); + assert.deepEqual( + (embedded.params.requestedSchema as { required: string[] }).required, + ['pin'], + ); + assert.equal( + typeof (result as { requestState?: unknown }).requestState, + 'string', + 'a modern ask must carry a requestState', + ); + }); + + it('completes the retry, and the TOOL sees the same flat object as on 2025-era', async (t) => { + const h = await start(options(bookRoom), t); + if (!h) return; + const client = await connect(h); + seen.length = 0; + + const ask = await client.callTool( + { name: TOOL, arguments: { roomId: 'r1' } }, + { allowInputRequired: true }, + ); + const done = await client.callTool( + retryParams({ + name: TOOL, + arguments: { roomId: 'r1' }, + inputResponses: { + [MODERN_INPUT_REQUEST_KEY]: { action: 'accept', content: { pin: '4711' } }, + }, + requestState: stateOf(ask), + }), + { allowInputRequired: true }, + ); + + assert.equal( + (done as { content?: { text?: string }[] }).content?.[0]?.text, + 'booked r1 with pin=4711', + ); + // The whole point of translating in the endpoint rather than the dispatch + // layer: a tool cannot tell which era its caller spoke. + assert.deepEqual(seen[1], { roomId: 'r1', inputResponses: { pin: '4711' } }); + }); + + it('MUTATION CHECK: refuses a tampered requestState', async (t) => { + // The spec's server requirement, and the one property a happy-path test + // cannot see: `requestState` round-trips through the client and is + // attacker-controlled on re-entry. + const h = await start(options(bookRoom), t); + if (!h) return; + const client = await connect(h); + + const ask = await client.callTool( + { name: TOOL, arguments: { roomId: 'r1' } }, + { allowInputRequired: true }, + ); + const state = stateOf(ask); + // Mutated in the MIDDLE: appending to a base64url tail can be absorbed by a + // lenient decoder and would prove nothing. + const tampered = `${state.slice(0, 8)}${state[8] === 'A' ? 'B' : 'A'}${state.slice(9)}`; + + await assert.rejects( + client.callTool( + retryParams({ + name: TOOL, + arguments: { roomId: 'r1' }, + inputResponses: { + [MODERN_INPUT_REQUEST_KEY]: { action: 'accept', content: { pin: '4711' } }, + }, + requestState: tampered, + }), + { allowInputRequired: true }, + ), + /Invalid or expired requestState/, + ); + }); + + it('MUTATION CHECK: the round cap is read off the signed state, not the arguments', async (t) => { + // On the 2025-era dialect the cap is inferred from `inputResponses` being + // present in the arguments, so a caller that strips the key gets a fresh + // card forever. Here the count is under the endpoint's own MAC, so the + // same trick cannot work: the retry below carries NO answers at all and is + // still recognised as the second round. + const h = await start(options(alwaysAsks), t); + if (!h) return; + const client = await connect(h); + + const ask = await client.callTool( + { name: TOOL, arguments: { roomId: 'r1' } }, + { allowInputRequired: true }, + ); + const second = await client.callTool( + retryParams({ name: TOOL, arguments: { roomId: 'r1' }, requestState: stateOf(ask) }), + { allowInputRequired: true }, + ); + + assert.equal((second as { isError?: boolean }).isError, true, JSON.stringify(second)); + assert.match( + (second as { content?: { text?: string }[] }).content?.[0]?.text ?? '', + /asked for user input again/, + ); + }); +});