diff --git a/middleware/src/mcp/README.md b/middleware/src/mcp/README.md index c86680d3..cbb3b38b 100644 --- a/middleware/src/mcp/README.md +++ b/middleware/src/mcp/README.md @@ -132,6 +132,60 @@ If masking is unavailable or fails, the call is **refused** rather than answered with unmasked data. You will see an error, not a result. That is intentional and not retryable in a tight loop — report it to the operator. +## When a tool needs more input (MRTR) + +A tool may answer that it cannot finish without one more value from a human — a +confirmation, a missing parameter, a disambiguation. Instead of failing with +prose you cannot act on, the call comes back as +[MRTR](https://modelcontextprotocol.io/specification/2026-07-28/changelog) +`input_required`: + +```json +{ + "resultType": "input_required", + "message": "PIN required for this room.", + "inputRequests": [ + { "name": "pin", "label": "PIN", "secret": true, "required": true } + ], + "content": [{ "type": "text", "text": "PIN required for this room." }] +} +``` + +`content` is populated as well, so a client that predates MRTR still shows the +human what is being asked for rather than an empty result. + +**This is not an error.** `isError` is absent, because the call succeeded — the +tool asked a question. + +To continue, **retry the original request** with the collected values under +`inputResponses`: + +```json +{ + "jsonrpc": "2.0", "method": "tools/call", "id": 2, + "params": { + "name": "book_room", + "arguments": { "roomId": "r1", "inputResponses": { "pin": "4711" } } + } +} +``` + +Things worth knowing before you build on this: + +- **Nothing is parked server-side.** The endpoint is stateless (see above), so + the retry must carry the original arguments too — they are not remembered for + you. Any instance behind the load balancer can serve the retry. +- **One round trip, not a loop.** A tool that asks for input *again* on a request + that already carried `inputResponses` is refused with an ordinary tool error. + You will never be bounced indefinitely. +- **Render `message` and the field labels as untrusted text.** They are authored + by the tool. Field counts and lengths are clamped server-side (at most 8 + 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. +- A tool that emits an unusable request errors with the reason named, rather than + handing you a half-rendered form. + ## Limits | Limit | Value | diff --git a/middleware/src/mcp/publicMcpInputRequired.ts b/middleware/src/mcp/publicMcpInputRequired.ts new file mode 100644 index 00000000..136b070c --- /dev/null +++ b/middleware/src/mcp/publicMcpInputRequired.ts @@ -0,0 +1,231 @@ +/** + * MRTR as a SERVER — `resultType: "input_required"` on the public MCP endpoint + * (issue #544, server half). + * + * The client half shipped in PR #550: when a remote MCP server answers a + * `tools/call` with `resultType: "input_required"`, `McpManager` parks the call + * and omadia renders an input card. Nothing in omadia's OWN MCP server path ever + * produced that shape, so the endpoint could only ever answer with a result or + * an error. A tool that needed one more value from the human had exactly two + * options, both bad: fail with a message no machine can act on, or guess. + * + * This module is the missing direction. A dispatched tool signals "I need these + * fields" in-band, and the endpoint renders it as MRTR so an ordinary MCP client + * (Claude Desktop, an agent framework) can collect the values and retry. + * + * ## Why in-band, and not a new dispatch return type + * + * `ToolDispatchResult` is `{ content: string; isError?: boolean }` and is shared + * by every dispatch surface — chat, routines, sub-agents, this endpoint. Adding + * a third variant would force every one of those call sites to grow a branch for + * a case only this endpoint can render. So the signal rides the result string as + * a JSON sentinel, exactly the convention `_pendingUserChoice` already uses for + * plugin-emitted choice cards (see `parseToolEmittedChoice`). A surface that + * does not understand it shows the tool's own `message` and is no worse off than + * before. + * + * ## Why the retry needs no server-side state + * + * MRTR has the CLIENT retry the original request with `inputResponses` added, so + * the arguments come back from the caller. That is what lets this work on a + * deliberately stateless endpoint (see `README.md`): omadia parks nothing, holds + * no correlation id, and any instance behind the load balancer can serve the + * retry. The retry is an ordinary `tools/call` whose arguments happen to carry + * one more key — `inputResponses`, the SAME key `REPLAY_ARG_KEY` uses on the + * client half, so the two directions speak one vocabulary. + * + * Consequence worth stating: a tool that asks for input must be able to finish + * from `{...originalArgs, inputResponses}` alone. Anything it cached in memory + * during the first call is gone. That is a real constraint, and it is the same + * one the client half documents for stdio servers. + * + * ## Trust boundary + * + * The `message` and the field `label`/`description` values are authored by the + * TOOL, and reach a human through the caller's UI. They are clamped by + * {@link parseMcpInputRequests} (max 8 fields, names ≤64, labels ≤120), which is + * the same validation the client half applies to a remote server's request — + * deliberately, so neither direction is the lenient one. + */ + +import { + MCP_RESULT_TYPE_INPUT_REQUIRED, + REPLAY_ARG_KEY, + parseMcpInputRequests, + type McpInputField, + type McpInputParseFailure, +} from '@omadia/orchestrator'; + +/** + * The in-band key a dispatched tool sets to ask for more input. + * + * Sibling of `_pendingUserChoice`. Distinct on purpose: a choice is 2–4 buttons + * the orchestrator renders in a chat channel, an input request is free-text + * fields an external MCP client renders. Reusing one key for both would make the + * endpoint guess which shape it was handed. + */ +export const PENDING_INPUT_REQUEST_KEY = '_pendingInputRequest'; + +/** Longest tool-authored prompt echoed to the caller. Matches the client half's + * `PROMPT_MAX`, so a request omadia SENDS and one it RECEIVES clamp alike. */ +const MESSAGE_MAX = 500; + +/** A tool's request for mid-call input, after validation. */ +export interface ToolEmittedInputRequest { + /** Tool-authored prose shown above the fields. Absent when it sent none. */ + readonly message?: string; + readonly inputRequests: readonly McpInputField[]; +} + +/** + * Why a sentinel-looking result was NOT rendered as `input_required`. + * + * Surfaced rather than swallowed: a tool that emits a malformed request has a + * bug, and silently shipping its raw JSON to the caller as a "result" is how + * that bug stays invisible. `unusable` carries the specific + * {@link McpInputParseFailure} so the audit line names it. + */ +export type InputRequestRejection = + | { readonly kind: 'absent' } + | { readonly kind: 'unusable'; readonly reason: McpInputParseFailure }; + +export type ParseInputRequestOutcome = + | { readonly ok: true; readonly request: ToolEmittedInputRequest } + | { readonly ok: false; readonly rejection: InputRequestRejection }; + +function clamp(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed.slice(0, max) : undefined; +} + +/** + * Parse a dispatch result string for a {@link PENDING_INPUT_REQUEST_KEY} + * sentinel. + * + * Returns `absent` for every ordinary result — including any string that is not + * JSON at all, which is the overwhelming majority — so an ordinary tool result + * stays an ordinary tool result. + */ +export function parseToolEmittedInputRequest( + content: string, +): ParseInputRequestOutcome { + // Cheap reject before paying for JSON.parse: the sentinel is a JSON object + // and every dispatch result flows through here. + if (!content.includes(PENDING_INPUT_REQUEST_KEY)) { + return { ok: false, rejection: { kind: 'absent' } }; + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return { ok: false, rejection: { kind: 'absent' } }; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, rejection: { kind: 'absent' } }; + } + const raw = (parsed as Record)[PENDING_INPUT_REQUEST_KEY]; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, rejection: { kind: 'absent' } }; + } + const shape = raw as { message?: unknown; inputRequests?: unknown }; + // Same validator the client half runs on a REMOTE server's request. One + // vocabulary, one set of clamps, in both directions. + const fields = parseMcpInputRequests(shape.inputRequests); + if (!fields.ok) { + return { ok: false, rejection: { kind: 'unusable', reason: fields.reason } }; + } + const message = clamp(shape.message, MESSAGE_MAX); + return { + ok: true, + request: { + ...(message !== undefined ? { message } : {}), + inputRequests: fields.fields, + }, + }; +} + +/** + * True when this `tools/call` is the RETRY leg — the caller collected the values + * and sent them back. + * + * Only used to keep a tool from bouncing the caller forever: a retry that comes + * back asking for input AGAIN is refused (see + * {@link inputRequestBounceError}), mirroring `MCP_INPUT_MAX_REPLAY_DEPTH` on + * the client half. The responses themselves are passed to the tool untouched — + * this endpoint does not read them, and must not: they may be secrets the human + * typed for the tool. + */ +export function carriesInputResponses(args: unknown): boolean { + if (args === null || typeof args !== 'object' || Array.isArray(args)) { + return false; + } + const responses = (args as Record)[REPLAY_ARG_KEY]; + return ( + responses !== null && + typeof responses === 'object' && + !Array.isArray(responses) && + Object.keys(responses as Record).length > 0 + ); +} + +/** The bounce cap tripped. An ordinary tool error, not a second request. */ +export function inputRequestBounceError(toolName: string): string { + return ( + `Error: tool "${toolName}" asked for user input again after it had already ` + + 'been answered once. Refused to avoid an endless input loop — report this ' + + 'to the tool author instead of retrying.' + ); +} + +/** A tool emitted a request nobody can render. An ordinary tool error. */ +export function inputRequestMalformedError( + toolName: string, + reason: McpInputParseFailure, +): string { + return ( + `Error: tool "${toolName}" asked for user input with unusable ` + + `inputRequests (${reason}). Treat this as a failed tool call.` + ); +} + +/** + * The MRTR JSON-RPC result body. + * + * `content` is populated as well as `resultType`, on purpose: a client that + * predates MRTR ignores the unknown keys and still shows the human what is being + * asked for, instead of rendering an empty result and looking broken. + */ +export interface McpInputRequiredResult { + readonly content: ReadonlyArray<{ readonly type: 'text'; readonly text: string }>; + readonly resultType: typeof MCP_RESULT_TYPE_INPUT_REQUIRED; + readonly inputRequests: readonly McpInputField[]; + readonly message?: string; +} + +/** Fallback prose when the tool named no message — the field names alone are + * not a sentence, and this text reaches a human. */ +function defaultMessage(request: ToolEmittedInputRequest): string { + const names = request.inputRequests.map((field) => field.label ?? field.name); + return `Additional input required: ${names.join(', ')}.`; +} + +/** + * Render a validated request as the MRTR result body. + * + * Never carries `isError`. An `input_required` answer is not a failure — the + * client half's `isInputRequiredResult` explicitly excludes `isError` results, + * so setting it here would make omadia's own endpoint unreadable by omadia's own + * client. + */ +export function renderInputRequiredResult( + request: ToolEmittedInputRequest, +): McpInputRequiredResult { + const message = request.message ?? defaultMessage(request); + return { + content: [{ type: 'text', text: message }], + resultType: MCP_RESULT_TYPE_INPUT_REQUIRED, + inputRequests: request.inputRequests, + message, + }; +} diff --git a/middleware/src/mcp/publicMcpServer.ts b/middleware/src/mcp/publicMcpServer.ts index 7f83c8c3..12d2d966 100644 --- a/middleware/src/mcp/publicMcpServer.ts +++ b/middleware/src/mcp/publicMcpServer.ts @@ -68,6 +68,14 @@ import type { } from '@omadia/orchestrator'; import { rawBodyBytes } from '../http/rawBodySize.js'; +import { + carriesInputResponses, + inputRequestBounceError, + inputRequestMalformedError, + parseToolEmittedInputRequest, + renderInputRequiredResult, + type McpInputRequiredResult, +} from './publicMcpInputRequired.js'; import type { PublicMcpKeyBinding, PublicMcpKeyBindingStore } from './publicMcpKeyBindings.js'; import { createFailClosedPrivacyGate, @@ -482,9 +490,25 @@ export class PublicMcpServer { ? meta.idempotencyKey : undefined; const result = await this.callToolFor(principal, name, args ?? {}, idempotencyKey); + // #544 (server half) — MRTR. A tool that cannot finish without one more + // value from the human says so in-band; render it as + // `resultType: "input_required"` so the caller can collect the values + // and retry, instead of shipping our internal sentinel JSON as if it + // were an answer. Failed calls are excluded: a failure has no pending + // continuation, and treating one as a prompt would turn every tool error + // into a question for the user. + const pendingInput = result.isError + ? undefined + : this.resolveInputRequired(name, args, result.content); + const body = + pendingInput !== undefined + ? pendingInput + : { + content: [{ type: 'text' as const, text: result.content }], + ...(result.isError ? { isError: true } : {}), + }; return { - content: [{ type: 'text' as const, text: result.content }], - ...(result.isError ? { isError: true } : {}), + ...body, // #647 — AI-Act Art. 50 provenance, per call. The envelope-level twin // of the router's response header, carried in the spec's designated // passthrough (`_meta`) so an existing client that does not read the @@ -599,6 +623,55 @@ export class PublicMcpServer { return callable; } + /** + * #544 (server half) — decide whether this successful dispatch is really a + * request for more input, and render it. + * + * Returns `undefined` for every ordinary result, which is the overwhelming + * majority: the caller then builds the normal body and nothing about this + * endpoint changes. Three outcomes when the sentinel IS present: + * + * - malformed request → an ordinary tool error naming the reason. A tool + * that emits an unrenderable request has a bug; shipping its raw sentinel + * JSON to the caller as a "result" is how that bug stays invisible. + * - already answered → an ordinary tool error. The caller already sent + * `inputResponses` once, so a second request means the tool would bounce + * the human indefinitely. Mirrors `MCP_INPUT_MAX_REPLAY_DEPTH` on the + * client half — one round trip, not a loop. + * - otherwise → the MRTR body. + * + * The two error outcomes deliberately produce `isError`, not a request: they + * ARE failed calls, and the client half's `isInputRequiredResult` refuses to + * read an `isError` result as a card — so mislabelling either one would make + * omadia's own endpoint unreadable by omadia's own client. + */ + private resolveInputRequired( + name: string, + args: unknown, + content: string, + ): + | McpInputRequiredResult + | { content: Array<{ type: 'text'; text: string }>; isError: true } + | undefined { + const parsed = parseToolEmittedInputRequest(content); + if (!parsed.ok) { + if (parsed.rejection.kind === 'absent') return undefined; + return { + content: [ + { type: 'text', text: inputRequestMalformedError(name, parsed.rejection.reason) }, + ], + isError: true, + }; + } + if (carriesInputResponses(args)) { + return { + content: [{ type: 'text', text: inputRequestBounceError(name) }], + isError: true, + }; + } + return renderInputRequiredResult(parsed.request); + } + private async callToolFor( principal: ApiKeyPrincipal, name: string, diff --git a/middleware/test/publicMcp/publicMcpInputRequired.test.ts b/middleware/test/publicMcp/publicMcpInputRequired.test.ts new file mode 100644 index 00000000..c32e7393 --- /dev/null +++ b/middleware/test/publicMcp/publicMcpInputRequired.test.ts @@ -0,0 +1,326 @@ +/** + * Issue #544 (server half) — MRTR `resultType: "input_required"` on the public + * MCP endpoint. + * + * The client half shipped in PR #550: omadia parks a call when a REMOTE server + * asks for mid-call input. Nothing in omadia's own MCP server path ever produced + * that shape, so a tool that needed one more value from the human could only + * fail with prose or guess. + * + * Two layers are covered, deliberately: + * + * 1. The pure module (`publicMcpInputRequired.ts`) — parsing, the bounce + * predicate, and the rendered body. Fast, no listener, no sandbox skip. + * 2. The real endpoint, end-to-end through `startHarness` — the SAME + * `mountPublicMcp` production calls, so a guarantee proven here is a + * guarantee about the mounted route rather than about a hand-built app. + * (See the harness doc comment for why that distinction has already bitten + * this repo once.) + * + * The round trip is asserted whole: ask → the caller retries with + * `inputResponses` → the tool receives them verbatim and finishes. A test that + * only proved the ASK would pass against an endpoint whose retry leg is broken, + * which is the half that makes the feature usable. + */ + +import { after, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { MCP_INVOKE_SCOPE, MCP_LIST_SCOPE } from '@omadia/api-key-auth'; +import { AI_PROVENANCE_META_KEY } from '@omadia/channel-sdk'; +import type { ToolDispatchResult } from '@omadia/orchestrator'; + +import { + PENDING_INPUT_REQUEST_KEY, + carriesInputResponses, + parseToolEmittedInputRequest, + renderInputRequiredResult, +} from '../../src/mcp/publicMcpInputRequired.js'; +import { + callToolRequest, + callResultText, + fakeDispatcher, + isSandboxListenDenied, + startHarness, + type Harness, + type HarnessOptions, +} from './harness.js'; + +const TOOL = 'book_room'; +const KEY_TOKEN = 'omadia_ak_test_token_bbbbbbbbbbbbbbbb'; +const KEY_ID = 'key-mrtr'; + +/** The in-band sentinel a tool emits to ask for more input. */ +function askFor( + fields: ReadonlyArray>, + message?: string, +): ToolDispatchResult { + return { + content: JSON.stringify({ + [PENDING_INPUT_REQUEST_KEY]: { + ...(message !== undefined ? { message } : {}), + inputRequests: fields, + }, + }), + }; +} + +// ── 1. the pure module ────────────────────────────────────────────────────── + +describe('#544 server half — parsing a tool-emitted input request', () => { + it('MUTATION CHECK: accepts a well-formed request and clamps through the shared validator', () => { + const outcome = parseToolEmittedInputRequest( + askFor([{ name: 'roomId', label: 'Room' }, { name: 'pin', secret: true }], 'Which room?') + .content, + ); + assert.ok(outcome.ok, 'a well-formed request was rejected'); + assert.equal(outcome.request.message, 'Which room?'); + assert.deepEqual( + outcome.request.inputRequests.map((f) => f.name), + ['roomId', 'pin'], + ); + }); + + it('MUTATION CHECK: an ordinary result is absent, not malformed', () => { + // The distinction matters: `absent` passes the result through untouched, + // `unusable` turns it into a tool ERROR. Confusing the two would convert + // every result that happens to be JSON into a failed call. + for (const content of [ + 'plain text', + '{"rows":[1,2,3]}', + '[]', + '', + 'null', + `{"${PENDING_INPUT_REQUEST_KEY}":"not an object"}`, + ]) { + const outcome = parseToolEmittedInputRequest(content); + assert.equal(outcome.ok, false, `unexpectedly parsed: ${content}`); + assert.equal( + outcome.ok === false && outcome.rejection.kind, + 'absent', + `should be absent, not a rejection: ${content}`, + ); + } + }); + + it('MUTATION CHECK: a malformed request is reported, never silently shipped', () => { + const tooMany = Array.from({ length: 9 }, (_, i) => ({ name: `f${String(i)}` })); + const outcome = parseToolEmittedInputRequest(askFor(tooMany).content); + assert.equal(outcome.ok, false); + assert.equal(outcome.ok === false && outcome.rejection.kind, 'unusable'); + + const empty = parseToolEmittedInputRequest(askFor([]).content); + assert.equal(empty.ok, false); + assert.equal(empty.ok === false && empty.rejection.kind, 'unusable'); + }); + + it('MUTATION CHECK: carriesInputResponses identifies the retry leg only', () => { + assert.equal(carriesInputResponses({ inputResponses: { pin: '1234' } }), true); + // An EMPTY object is not an answer — treating it as one would let a caller + // suppress the request without ever answering it. + assert.equal(carriesInputResponses({ inputResponses: {} }), false); + assert.equal(carriesInputResponses({ inputResponses: null }), false); + assert.equal(carriesInputResponses({ inputResponses: ['pin'] }), false); + assert.equal(carriesInputResponses({ roomId: 'r1' }), false); + assert.equal(carriesInputResponses(undefined), false); + }); + + it('MUTATION CHECK: the rendered body is readable by a pre-MRTR client and never isError', () => { + const rendered = renderInputRequiredResult({ + inputRequests: [{ name: 'pin', label: 'PIN' }], + }); + assert.equal(rendered.resultType, 'input_required'); + // A client that predates MRTR ignores `resultType` and shows `content`; an + // empty `content` would make the endpoint look broken to it. + assert.ok((rendered.content[0]?.text ?? '').length > 0, 'no human-readable content'); + assert.equal((rendered as { isError?: boolean }).isError, undefined); + }); +}); + +// ── 2. the real endpoint ──────────────────────────────────────────────────── + +describe('#544 server half — the mounted endpoint', () => { + // EVERY harness, not just the last one. Each test starts its own listener, so + // keeping a single slot leaks the earlier ones and the FILE hangs to the test + // timeout while every assertion inside it passed — a green run that reports as + // a red file, which is the least useful failure mode there is. + const started: Harness[] = []; + after(async () => { + for (const h of started) { + try { + await h.close(); + } catch { + /* teardown must not mask a test failure */ + } + } + }); + + 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 }]) }, + }; + } + + 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; + } + } + + function resultOf(payload: Record): { + resultType?: string; + inputRequests?: Array<{ name: string; secret?: boolean }>; + message?: string; + isError?: boolean; + _meta?: Record; + } { + return (payload['result'] ?? {}) as never; + } + + it('MUTATION CHECK: a tool asking for input answers with resultType input_required', async (t) => { + const h = await start( + options(async () => + askFor([{ name: 'pin', label: 'PIN', secret: true }], 'PIN required for this room.'), + ), + t, + ); + if (!h) return; + + const res = await h.rpc(callToolRequest(TOOL, { roomId: 'r1' }, 20), { token: KEY_TOKEN }); + assert.equal(res.status, 200, JSON.stringify(res.payload)); + const result = resultOf(res.payload); + + assert.equal(result.resultType, 'input_required', 'not rendered as MRTR'); + assert.deepEqual(result.inputRequests?.map((f) => f.name), ['pin']); + assert.equal(result.inputRequests?.[0]?.secret, true, 'the secret flag was dropped'); + assert.equal(result.message, 'PIN required for this room.'); + // An `input_required` answer is NOT a failure — the client half's + // `isInputRequiredResult` refuses to read an isError result as a card. + assert.equal(result.isError, undefined, 'input_required must not be flagged as an error'); + // The internal sentinel is an implementation detail; a caller must never see + // the raw JSON we parse. + assert.equal( + (callResultText(res.payload) ?? '').includes(PENDING_INPUT_REQUEST_KEY), + false, + 'the internal sentinel leaked to the caller', + ); + // #647 regression — provenance still rides the new body shape. + assert.ok(result._meta?.[AI_PROVENANCE_META_KEY], 'provenance _meta lost on the MRTR body'); + }); + + it('MUTATION CHECK: the retry delivers inputResponses to the tool verbatim and completes', async (t) => { + const seen: unknown[] = []; + const h = await start( + options(async (input) => { + seen.push(input); + const args = input as { inputResponses?: Record }; + if (!args.inputResponses) return askFor([{ name: 'pin', secret: true }], 'PIN?'); + return { content: `booked with pin ${args.inputResponses['pin'] ?? '?'}` }; + }), + t, + ); + if (!h) return; + + const ask = await h.rpc(callToolRequest(TOOL, { roomId: 'r1' }, 21), { token: KEY_TOKEN }); + assert.equal(resultOf(ask.payload).resultType, 'input_required'); + + // MRTR: the CALLER retries the original request with the collected values. + // Nothing was parked server-side, which is what keeps the endpoint stateless. + const retry = await h.rpc( + callToolRequest(TOOL, { roomId: 'r1', inputResponses: { pin: '4711' } }, 22), + { token: KEY_TOKEN }, + ); + assert.equal(retry.status, 200, JSON.stringify(retry.payload)); + assert.equal(resultOf(retry.payload).resultType, undefined, 'retry still asked for input'); + assert.equal(callResultText(retry.payload), 'booked with pin 4711'); + + const retryArgs = seen[1] as { roomId?: string; inputResponses?: Record }; + assert.equal(retryArgs.roomId, 'r1', 'the original arguments were not replayed'); + assert.deepEqual(retryArgs.inputResponses, { pin: '4711' }); + }); + + it('MUTATION CHECK: a tool that asks again after being answered is refused, not looped', async (t) => { + const h = await start( + options(async () => askFor([{ name: 'pin', secret: true }], 'PIN?')), + t, + ); + if (!h) return; + + const retry = await h.rpc( + callToolRequest(TOOL, { roomId: 'r1', inputResponses: { pin: '4711' } }, 23), + { token: KEY_TOKEN }, + ); + assert.equal(retry.status, 200, JSON.stringify(retry.payload)); + const result = resultOf(retry.payload); + assert.equal(result.resultType, undefined, 'a second request was rendered — this is the loop'); + assert.equal(result.isError, true, 'the bounce was not reported as a failed call'); + assert.match(callResultText(retry.payload) ?? '', /endless input loop/); + }); + + it('MUTATION CHECK: a malformed request becomes a tool error, and never leaks the sentinel', async (t) => { + const h = await start( + options(async () => askFor(Array.from({ length: 9 }, (_, i) => ({ name: `f${String(i)}` })))), + t, + ); + if (!h) return; + + const res = await h.rpc(callToolRequest(TOOL, {}, 24), { token: KEY_TOKEN }); + const result = resultOf(res.payload); + assert.equal(result.resultType, undefined); + assert.equal(result.isError, true, 'an unrenderable request was reported as success'); + const text = callResultText(res.payload) ?? ''; + assert.match(text, /too_many_fields/, 'the reason was not named'); + assert.equal( + text.includes(PENDING_INPUT_REQUEST_KEY), + false, + 'the raw sentinel leaked to the caller', + ); + }); + + it('MUTATION CHECK: a failed call carrying the sentinel is not turned into a question', async (t) => { + const h = await start( + options(async () => ({ ...askFor([{ name: 'pin' }], 'PIN?'), isError: true })), + t, + ); + if (!h) return; + + const res = await h.rpc(callToolRequest(TOOL, {}, 25), { token: KEY_TOKEN }); + const result = resultOf(res.payload); + assert.equal(result.resultType, undefined, 'a failure was rendered as an input request'); + assert.equal(result.isError, true); + }); + + it('an ordinary tool result is untouched', async (t) => { + const h = await start(options(async () => ({ content: 'dispatched:ok' })), t); + if (!h) return; + + const res = await h.rpc(callToolRequest(TOOL, {}, 26), { token: KEY_TOKEN }); + const result = resultOf(res.payload); + assert.equal(result.resultType, undefined); + assert.equal(result.isError, undefined); + assert.equal(callResultText(res.payload), 'dispatched:ok'); + assert.ok(result._meta?.[AI_PROVENANCE_META_KEY], 'provenance _meta lost on the ordinary body'); + }); +});