diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 50e36823..2e0afb07 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,40 @@ 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. ### Fixed — test servers bound a port they never dialled (CI flake) - **Intermittent 401/404 in the middleware test suite.** A test would fail with diff --git a/middleware/src/mcp/README.md b/middleware/src/mcp/README.md index 8f6a9c50..8696e48b 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 00000000..8c926164 --- /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 00000000..e17ca8c0 Binary files /dev/null and b/middleware/src/mcp/publicMcpRequestState.ts differ diff --git a/middleware/src/mcp/publicMcpServer.ts b/middleware/src/mcp/publicMcpServer.ts index 12d2d966..c3b67d49 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 186b9dd4..96133bd8 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 00000000..2fb00695 --- /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/, + ); + }); +});