From 122827ff73a6e50c1c3d968014b0f9ded025bb8f Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 18:56:53 +0200 Subject: [PATCH 01/11] feat: add private elicitation terminal contracts Adds the private terminal output infrastructure the elicitation runtime composes with: the advertised output union (business output plus distinct `declined` and `cancelled` variants), and the two result composers. Terminal copy is a parameter, never a template. Business meaning and the final product text stay with the Supabase cost policy. Recovery results deliberately carry no `structuredContent`: recovery is not one of the advertised output variants, so structured content there would put off-schema data on the wire. Plan boundary B5, landed first rather than fifth. The runtime's `ToolPolicy.outputSchema` hook needs the output union from its first line, so the terminal module is a dependency of the runtime commit rather than a follow-up to it. Every plan boundary keeps its own commit; only the order changed. --- .../src/elicitations/terminal.test.ts | 107 ++++++++++++++++ .../src/elicitations/terminal.ts | 114 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 packages/mcp-server-supabase/src/elicitations/terminal.test.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/terminal.ts diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts new file mode 100644 index 00000000..1f3f9ab3 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod/v4'; + +import { + recoveryResult, + terminalResult, + withTerminalOutput, +} from './terminal.js'; + +const businessSchema = z.object({ + id: z.string(), + status: z.literal('created'), +}); + +describe('elicitation output widening', () => { + test('keeps business output and adds the two terminal variants', () => { + const outputSchema = withTerminalOutput(businessSchema); + + expect( + outputSchema.parse({ id: 'project-1', status: 'created' }) + ).toStrictEqual({ id: 'project-1', status: 'created' }); + expect(outputSchema.parse({ status: 'declined' })).toStrictEqual({ + status: 'declined', + }); + expect(outputSchema.parse({ status: 'cancelled' })).toStrictEqual({ + status: 'cancelled', + }); + }); + + test('advertises an object root MCP structured output accepts', () => { + const jsonSchema = z.toJSONSchema(withTerminalOutput(businessSchema), { + target: 'draft-7', + }); + + expect(jsonSchema.type).toBe('object'); + expect(jsonSchema).not.toHaveProperty('anyOf'); + expect(jsonSchema).not.toHaveProperty('oneOf'); + }); + + test('rejects a terminal status the schema does not advertise', () => { + const outputSchema = withTerminalOutput(businessSchema); + + expect(() => outputSchema.parse({ status: 'expired' })).toThrow(); + }); + + test('keeps the variants distinct instead of accepting a blend', () => { + const outputSchema = withTerminalOutput(businessSchema); + + // A terminal outcome ran no business logic, so it carries no business + // field, and an accepted execution still owes every one of them. + expect(() => + outputSchema.parse({ id: 'project-1', status: 'declined' }) + ).toThrow(); + expect(() => outputSchema.parse({ status: 'created' })).toThrow(); + }); + + test('keeps the business schema unknown-key handling', () => { + const loose = z.object({ id: z.string() }).catchall(z.unknown()); + const outputSchema = withTerminalOutput(loose); + + // Stripping here would turn output the tool is allowed to emit into a + // validation failure on the call path. + expect(outputSchema.parse({ id: 'project-1', extra: 1 })).toStrictEqual({ + id: 'project-1', + extra: 1, + }); + }); +}); + +describe('terminal results', () => { + test('keep decline and cancel distinct and carry caller copy verbatim', () => { + const declined = terminalResult('declined', 'The user declined.'); + const cancelled = terminalResult('cancelled', 'The user cancelled.'); + + expect(declined).toStrictEqual({ + content: [{ type: 'text', text: 'The user declined.' }], + structuredContent: { status: 'declined' }, + }); + expect(cancelled).toStrictEqual({ + content: [{ type: 'text', text: 'The user cancelled.' }], + structuredContent: { status: 'cancelled' }, + }); + }); + + test('validate against the advertised schema', () => { + const outputSchema = withTerminalOutput(businessSchema); + + expect( + outputSchema.parse(terminalResult('declined', 'x').structuredContent) + ).toStrictEqual({ status: 'declined' }); + expect( + outputSchema.parse(terminalResult('cancelled', 'x').structuredContent) + ).toStrictEqual({ status: 'cancelled' }); + }); +}); + +describe('recovery results', () => { + test('carry actionable text and no off-schema structured content', () => { + const result = recoveryResult('This expired. Run the tool again.'); + + expect(result).toStrictEqual({ + isError: true, + content: [{ type: 'text', text: 'This expired. Run the tool again.' }], + }); + expect('structuredContent' in result).toBe(false); + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.ts b/packages/mcp-server-supabase/src/elicitations/terminal.ts new file mode 100644 index 00000000..c167177f --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/terminal.ts @@ -0,0 +1,114 @@ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { z } from 'zod/v4'; + +/** + * Non-error terminal outcomes an elicitation flow reaches without running the + * tool's business logic. + * + * `declined` and `cancelled` stay distinct wire values: a caller that refuses + * an action has answered, while a caller that abandoned the prompt has not. + * Collapsing them would erase that difference for every consumer downstream. + */ +export const elicitationTerminalStatusSchema = z.enum([ + 'declined', + 'cancelled', +]); + +export type ElicitationTerminalStatus = z.infer< + typeof elicitationTerminalStatusSchema +>; + +/** + * Widens a tool's business output schema with the terminal variants, so a + * guarded tool advertises every shape it can actually return. + * + * MCP restricts structured output to an object root, so the variants sit + * beneath one object root instead of in a root-level union: every business + * field becomes optional at the root, `status` also accepts the terminal + * values, and a refinement decides which whole variant a payload must + * satisfy. A root-level union serializes to `anyOf` with no root `type`, + * which fails the entire `tools/list` response. + * + * The business schema itself passes through unchanged: an accepted execution + * is validated by the tool's own schema, not by the widened copy, so an + * incomplete business payload stays a rejection. The widened root is derived + * from that same schema, so whatever it says about undeclared fields still + * holds and an accepted payload survives validation byte for byte. + */ +export function withTerminalOutput>( + schema: Schema +) { + const businessStatus = (schema.shape as Record).status; + + // Deriving the root from the business schema keeps its own unknown-key + // handling: a widened copy that stripped what the tool is allowed to emit + // would turn valid output into an error. + const root = schema.partial().extend({ + // A business schema that already carries `status` keeps its own values; + // the terminal ones are offered beside them rather than replacing them. + status: + businessStatus === undefined + ? elicitationTerminalStatusSchema.optional() + : z.union([businessStatus, elicitationTerminalStatusSchema]).optional(), + }); + + return root.superRefine((value, ctx) => { + const record = value as Record; + const terminal = elicitationTerminalStatusSchema.safeParse(record.status); + + if (terminal.success) { + // A terminal payload carries the status and nothing else: a business + // field beside it would claim work that never ran. + for (const key of Object.keys(record)) { + if (key !== 'status') { + ctx.addIssue({ + code: 'custom', + path: [key], + message: `Unexpected field on a "${terminal.data}" terminal result.`, + }); + } + } + return; + } + + const business = schema.safeParse(value); + + if (!business.success) { + for (const issue of business.error.issues) { + ctx.addIssue({ ...issue }); + } + } + }); +} + +/** + * Composes a non-error terminal result: the distinct status the output schema + * advertises, plus the caller's own text. + * + * The text is a parameter rather than a template. This module carries no + * product copy, so the same helper serves any policy built on the runtime. + */ +export function terminalResult( + status: ElicitationTerminalStatus, + message: string +): CallToolResult { + return { + content: [{ type: 'text', text: message }], + structuredContent: { status }, + }; +} + +/** + * Composes an in-band recovery result: an actionable error the caller can act + * on by running the tool again. + * + * It deliberately carries no `structuredContent`. Recovery is not one of the + * advertised output variants, so emitting structured content here would put + * off-schema data on the wire. + */ +export function recoveryResult(message: string): CallToolResult { + return { + isError: true, + content: [{ type: 'text', text: message }], + }; +} From 66b772960539b63ce2dd34e8fa33c974c8160511 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 18:59:18 +0200 Subject: [PATCH 02/11] feat: resolve private elicitation capability Adds the mode-less capability resolver. It reads the SDK-owned era and client capabilities normalized by the tool-policy foundation, and combines them with the serving-path and opt-out facts the entry point injects. Hosted URL parsing and route selection stay outside the package. A mode-less `elicitation: {}` and an explicit `elicitation.form` are both form capable on a supported modern serving path. URL-only and absent declarations are not. The legacy era has no multi-round-trip leg to deliver a form on, so classic hosted and deprecated stdio stay incapable however they declare themselves. The resolver takes only `era` and `clientCapabilities`, so a client name or version cannot reach a branch. There is no compatibility table to drift. Plan boundary B4, landed second rather than fourth: the runtime commit depends on this resolver rather than the other way around. --- .../src/elicitations/capability.test.ts | 98 +++++++++++++++++++ .../src/elicitations/capability.ts | 66 +++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 packages/mcp-server-supabase/src/elicitations/capability.test.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/capability.ts diff --git a/packages/mcp-server-supabase/src/elicitations/capability.test.ts b/packages/mcp-server-supabase/src/elicitations/capability.test.ts new file mode 100644 index 00000000..6524d990 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/capability.test.ts @@ -0,0 +1,98 @@ +import type { ToolRequestContext } from '@supabase/mcp-utils'; +import { describe, expect, test } from 'vitest'; + +import { resolveElicitationAvailability } from './capability.js'; + +type Context = Pick< + ToolRequestContext, + 'era' | 'clientInfo' | 'clientCapabilities' +>; + +const modern = ( + clientCapabilities: ToolRequestContext['clientCapabilities'] +): Context => ({ + era: 'modern', + clientInfo: { name: 'test-client', version: '1.2.3' }, + clientCapabilities, +}); + +const servingPath = { formDeliveryAvailable: true }; + +describe('form elicitation availability', () => { + test('accepts a mode-less declaration on a supported serving path', () => { + expect( + resolveElicitationAvailability(modern({ elicitation: {} }), servingPath) + ).toStrictEqual({ formElicitation: true, reason: 'available' }); + }); + + test('accepts an explicit form declaration on a supported serving path', () => { + expect( + resolveElicitationAvailability( + modern({ elicitation: { form: {} } }), + servingPath + ) + ).toStrictEqual({ formElicitation: true, reason: 'available' }); + }); + + test('treats URL-only and absent declarations as incapable', () => { + expect( + resolveElicitationAvailability( + modern({ elicitation: { url: {} } }), + servingPath + ) + ).toStrictEqual({ formElicitation: false, reason: 'capability' }); + expect( + resolveElicitationAvailability(modern({}), servingPath) + ).toStrictEqual({ formElicitation: false, reason: 'capability' }); + }); + + test('reports an unsupported serving path and an injected opt-out apart', () => { + expect( + resolveElicitationAvailability(modern({ elicitation: {} }), { + formDeliveryAvailable: false, + }) + ).toStrictEqual({ formElicitation: false, reason: 'serving_path' }); + expect( + resolveElicitationAvailability(modern({ elicitation: {} }), { + formDeliveryAvailable: true, + optOut: true, + }) + ).toStrictEqual({ formElicitation: false, reason: 'opt_out' }); + }); + + test('keeps the classic era incapable even when it declares form support', () => { + const classic: Context = { + era: 'legacy', + clientInfo: { name: 'test-client', version: '1.2.3' }, + clientCapabilities: { elicitation: { form: {} } }, + }; + + expect(resolveElicitationAvailability(classic, servingPath)).toStrictEqual({ + formElicitation: false, + reason: 'serving_path', + }); + }); + + test('gives client labels no authority over the outcome', () => { + const clientCapabilities = { elicitation: { url: {} } }; + const labelled: Context = { + era: 'modern', + clientInfo: { name: 'claude-ai', version: '1.0.0' }, + clientCapabilities, + }; + const otherLabel: Context = { + era: 'modern', + clientInfo: { name: 'some-other-client', version: '9.9.9' }, + clientCapabilities, + }; + + const known = resolveElicitationAvailability(labelled, servingPath); + const unknown = resolveElicitationAvailability(otherLabel, servingPath); + + expect(known).toStrictEqual(unknown); + expect(known).toStrictEqual({ + formElicitation: false, + reason: 'capability', + }); + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/capability.ts b/packages/mcp-server-supabase/src/elicitations/capability.ts new file mode 100644 index 00000000..458696ed --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/capability.ts @@ -0,0 +1,66 @@ +import type { ToolRequestContext } from '@supabase/mcp-utils'; + +/** + * Whether this request can carry a form elicitation, and the one stable reason + * behind the answer. + * + * The reasons stay distinguishable so a caller can tell an operator opt-out + * from a serving path that cannot deliver a form, and both from a client that + * never declared form support. + */ +export type ElicitationAvailability = { + formElicitation: boolean; + reason: 'available' | 'serving_path' | 'opt_out' | 'capability'; +}; + +/** + * Facts the entry point injects because capability metadata cannot derive + * them. Hosted URL parsing and route selection stay outside this package. + */ +export type ElicitationServingFacts = { + /** Whether the serving path in front of this server can deliver a form. */ + formDeliveryAvailable: boolean; + /** Connection-level form elicitation opt-out. */ + optOut?: boolean; +}; + +/** + * The SDK-owned facts the resolver reads. Client name and version are + * deliberately absent: no client label carries authority here, so there is no + * compatibility table to drift. + */ +type CapabilityContext = Pick; + +/** + * Resolves form elicitation support from SDK-owned request facts combined with + * the injected serving-path facts. + */ +export function resolveElicitationAvailability( + ctx: CapabilityContext, + facts: ElicitationServingFacts +): ElicitationAvailability { + // A legacy request has no multi-round-trip leg to deliver a form on, so + // classic hosted and deprecated stdio stay incapable however they declare + // themselves. + if (!facts.formDeliveryAvailable || ctx.era !== 'modern') { + return { formElicitation: false, reason: 'serving_path' }; + } + + if (facts.optOut === true) { + return { formElicitation: false, reason: 'opt_out' }; + } + + const elicitation = ctx.clientCapabilities?.elicitation; + // A mode-less `elicitation: {}` predates the mode split and means every + // mode. A declaration that names its modes must name `form`, which leaves a + // URL-only declaration incapable. + const declaresForm = + elicitation !== undefined && + (Object.keys(elicitation).length === 0 || 'form' in elicitation); + + if (!declaresForm) { + return { formElicitation: false, reason: 'capability' }; + } + + return { formElicitation: true, reason: 'available' }; +} From ac834f13886e23312d8ca5c786f15b0b5fb9890c Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 19:07:22 +0200 Subject: [PATCH 03/11] feat: add signed continuation state Adds the stateless continuation the elicitation runtime hands the client: `{ v, policyVersion, policy, tool, argsDigest, proposal, jti, iat, exp }` readable under an HMAC, bound to the authenticated actor and the originating MCP method, and capped at 120 seconds. Nothing is stored server side. Ported by behavior from the reviewed codec in #367 at c5660c9, without the replay store, the replay adapter, capacity handling, `consume`, and every other single-use path. Repeating live accepted state is the approved detection-only posture, so there is nothing to consume. Verification splits the two failure classes the invariants require. Malformed input, a failed MAC, a wrong actor, and a wrong MCP method throw, and the SDK request-state seam answers those as the frozen `-32602` before any handler runs. An elapsed lifetime resolves instead, carrying the fact that the value authenticated, so the runtime can answer with recovery text. That split is also why this package signs its own envelope rather than using the SDK's `createRequestStateCodec`: the SDK verifier throws on expiry, which would classify an authenticated expiry as `-32602`. `jti` never leaves the module. `mint` returns the derived Interaction ID and `verify` resolves with it, so no caller can read the raw value, log it, or build a single-use check on it. Plan boundary B2, landed third rather than second: the runtime commit builds on this module. --- .../src/elicitations/codec.ts | 261 ++++++++++++++++ .../src/elicitations/interaction-id.ts | 19 ++ .../src/elicitations/state.test.ts | 293 ++++++++++++++++++ .../src/elicitations/state.ts | 151 +++++++++ 4 files changed, 724 insertions(+) create mode 100644 packages/mcp-server-supabase/src/elicitations/codec.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/interaction-id.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/state.test.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/state.ts diff --git a/packages/mcp-server-supabase/src/elicitations/codec.ts b/packages/mcp-server-supabase/src/elicitations/codec.ts new file mode 100644 index 00000000..f6e9405d --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/codec.ts @@ -0,0 +1,261 @@ +import type { ServerContext } from '@modelcontextprotocol/server'; + +/** + * Signed, client-readable envelope for multi-round-trip request state. + * + * Wire shape, matching the SDK's own codec: + * + * "v1." b64url({"p":,"exp":,"b":}) "." b64url(mac) + * + * The body is integrity-protected, not encrypted: the client can decode and + * read the payload, so nothing secret goes in it. + * + * This package signs its own envelope rather than using the SDK's + * `createRequestStateCodec` for one reason: the SDK verifier throws on an + * elapsed expiry, and a throw at the request-state seam is a frozen JSON-RPC + * `-32602`. An authenticated expiry has to reach the tool as recoverable text + * instead, so expiry classification has to survive verification rather than + * end it. Integrity, binding, and malformed input still throw and still + * surface as the SDK-owned `-32602`. + */ + +const STATE_PREFIX = 'v1.'; +const BIND_LABEL = 'mcp.requestState.bind:'; +const SIGNING_KEY_LABEL = 'mcp-request-state:v1'; +const BIND_TAG_BYTES = 16; +const MINIMUM_KEY_BYTES = 32; + +const encoder = new TextEncoder(); + +export type StateSigner = { + sign(value: string): Promise; + verify(value: string, mac: BufferSource): Promise; +}; + +/** Any payload this codec can classify as live or elapsed. */ +export type ExpiringPayload = { exp: number }; + +export type VerifiedPayload = + | { kind: 'valid'; payload: T } + | { kind: 'expired'; payload: T }; + +export type SignedStateCodec = { + mint(payload: T, ctx: ServerContext): Promise; + /** + * Authenticates an echoed value, then classifies its expiry. + * + * Throws for malformed input, a bad MAC, and a binding mismatch, which the + * SDK request-state seam answers as `-32602`. Resolves for anything that + * proved authentic, including an elapsed lifetime. + */ + verify(wire: string, ctx: ServerContext): Promise>; +}; + +export function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCodePoint(byte); + } + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); +} + +function base64UrlToBytes(value: string): Uint8Array { + const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/')); + return Uint8Array.from(binary, (character) => character.codePointAt(0) ?? 0); +} + +function constantTimeEqual(left: string, right: string): boolean { + if (left.length !== right.length) { + return false; + } + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return difference === 0; +} + +/** + * Renders a value as JSON with object keys sorted and undefined members + * dropped, so two arguments that mean the same thing digest the same way + * whatever order they arrived in. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + const encoded = JSON.stringify(value); + if (encoded === undefined) { + throw new TypeError('Canonical arguments must be JSON-serializable'); + } + return encoded; + } + + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}`; +} + +/** Unkeyed digest of canonical arguments, carried inside the signed payload. */ +export async function canonicalArgumentsDigest( + value: unknown +): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + encoder.encode(canonicalJson(value)) + ); + return bytesToBase64Url(new Uint8Array(digest)); +} + +/** + * Derives a signing key from the operator secret, domain-separated by a fixed + * label so the raw secret never signs anything directly. + */ +export function createStateSigner(key: string | Uint8Array): StateSigner { + const rawKey = + typeof key === 'string' ? encoder.encode(key) : Uint8Array.from(key); + + if (rawKey.byteLength < MINIMUM_KEY_BYTES) { + throw new RangeError( + `State key must be at least ${MINIMUM_KEY_BYTES} bytes (got ${rawKey.byteLength})` + ); + } + + const signingKey = (async () => { + const derivationKey = await crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const derived = await crypto.subtle.sign( + 'HMAC', + derivationKey, + encoder.encode(SIGNING_KEY_LABEL) + ); + return crypto.subtle.importKey( + 'raw', + derived, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'] + ); + })(); + + return { + async sign(value) { + return new Uint8Array( + await crypto.subtle.sign( + 'HMAC', + await signingKey, + encoder.encode(value) + ) + ); + }, + async verify(value, mac) { + return crypto.subtle.verify( + 'HMAC', + await signingKey, + mac, + encoder.encode(value) + ); + }, + }; +} + +export function createSignedStateCodec(options: { + signer: StateSigner; + /** + * Values the state is bound to, such as the authenticated actor and the + * originating MCP method. Stored as a keyed tag, never as the raw string. + */ + bind: (ctx: ServerContext) => string; + clock: () => number; +}): SignedStateCodec { + const { signer, bind, clock } = options; + + async function bindTag(ctx: ServerContext): Promise { + const signature = await signer.sign(BIND_LABEL + bind(ctx)); + return bytesToBase64Url(signature.slice(0, BIND_TAG_BYTES)); + } + + return { + async mint(payload, ctx) { + const envelope = { + p: payload, + exp: payload.exp, + b: await bindTag(ctx), + }; + const body = bytesToBase64Url(encoder.encode(JSON.stringify(envelope))); + const mac = bytesToBase64Url(await signer.sign(STATE_PREFIX + body)); + return `${STATE_PREFIX}${body}.${mac}`; + }, + + async verify(wire, ctx) { + const separator = wire.lastIndexOf('.'); + if (!wire.startsWith(STATE_PREFIX) || separator <= STATE_PREFIX.length) { + throw new Error('malformed'); + } + + const body = wire.slice(STATE_PREFIX.length, separator); + let mac: Uint8Array; + try { + mac = base64UrlToBytes(wire.slice(separator + 1)); + } catch { + throw new Error('malformed'); + } + + if (!(await signer.verify(STATE_PREFIX + body, mac))) { + throw new Error('mac'); + } + + let envelope: { p?: unknown; exp?: unknown; b?: unknown }; + try { + envelope = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode( + base64UrlToBytes(body) + ) + ); + } catch { + throw new Error('malformed'); + } + + // The MAC covers the whole body, so the binding tag is authentic by the + // time it is compared. The compare stays constant time anyway. + if ( + typeof envelope.b !== 'string' || + !constantTimeEqual(envelope.b, await bindTag(ctx)) + ) { + throw new Error('bind'); + } + + if ( + typeof envelope.exp !== 'number' || + envelope.p === null || + typeof envelope.p !== 'object' + ) { + throw new Error('malformed'); + } + + const payload = envelope.p as T; + + // Authenticate first, classify second: an elapsed lifetime is a fact + // about a value this server signed, so the caller can recover from it. + if (envelope.exp < Math.floor(clock() / 1_000)) { + return { kind: 'expired', payload }; + } + + return { kind: 'valid', payload }; + }, + }; +} diff --git a/packages/mcp-server-supabase/src/elicitations/interaction-id.ts b/packages/mcp-server-supabase/src/elicitations/interaction-id.ts new file mode 100644 index 00000000..b34bcb06 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/interaction-id.ts @@ -0,0 +1,19 @@ +import { bytesToBase64Url, type StateSigner } from './codec.js'; + +const INTERACTION_LABEL = 'mcp-interaction:v1|'; + +/** + * Derives the Interaction ID that correlates every round, and every repeated + * attempt, of one logical interaction. + * + * The input is the signed state's `jti`. The derivation is a keyed one-way + * function, so a telemetry sink holding the Interaction ID cannot recover the + * `jti` or forge state that carries it. `jti` exists for this derivation and + * nothing else: it grants no single use and no production code reads it. + */ +export async function deriveInteractionId( + signer: StateSigner, + jti: string +): Promise { + return bytesToBase64Url(await signer.sign(INTERACTION_LABEL + jti)); +} diff --git a/packages/mcp-server-supabase/src/elicitations/state.test.ts b/packages/mcp-server-supabase/src/elicitations/state.test.ts new file mode 100644 index 00000000..d6a97fd2 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/state.test.ts @@ -0,0 +1,293 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { + createMcpHandler, + inputRequired, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { createMcpServer, tool, type ToolPolicy } from '@supabase/mcp-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { createContinuationState } from './state.js'; + +const MODERN_PROTOCOL_VERSION = '2026-07-28'; +const MCP_ENDPOINT = new URL('https://mcp.test'); +const STATE_KEY = 'continuation-state-key-that-is-long-enough'; +const ACTOR_ID = 'actor-1'; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } +}); + +const claims = { + policy: 'test-policy', + policyVersion: 1, + tool: 'guarded', + argsDigest: 'digest-1', + proposal: { detail: 'proposed' }, +}; + +/** + * The codec reads only the MCP method from the SDK context, so the rows that + * exercise binding supply exactly that. + */ +function context(method = 'tools/call'): ServerContext { + return { mcpReq: { method } } as unknown as ServerContext; +} + +function readPayload(wire: string): Record { + const body = wire.slice('v1.'.length, wire.lastIndexOf('.')); + const json = atob(body.replaceAll('-', '+').replaceAll('_', '/')); + return JSON.parse(json) as Record; +} + +function reseal(wire: string, envelope: unknown): string { + const body = btoa(JSON.stringify(envelope)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); + return `v1.${body}.${wire.slice(wire.lastIndexOf('.') + 1)}`; +} + +/** + * Edits the MAC at its first character. The tag is 32 bytes in 43 base64url + * characters, so the final character carries bits a decoder discards: editing + * there can leave the decoded tag identical and still verify. + */ +function breakMac(wire: string): string { + const separator = wire.lastIndexOf('.'); + const mac = wire.slice(separator + 1); + const flipped = mac.startsWith('A') ? 'B' : 'A'; + return `${wire.slice(0, separator + 1)}${flipped}${mac.slice(1)}`; +} + +describe('continuation lifetime', () => { + test('refuses a lifetime beyond the 120 second maximum', () => { + expect(() => + createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + lifetimeSeconds: 121, + }) + ).toThrow(RangeError); + expect(() => + createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + lifetimeSeconds: 0, + }) + ).toThrow(RangeError); + }); +}); + +describe('continuation state', () => { + test('mints a readable payload that carries no signing secret', async () => { + const state = createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + clock: () => 1_700_000_000_000, + }); + + const { requestState } = await state.mint(claims, context()); + const envelope = readPayload(requestState); + + expect(envelope.p).toStrictEqual({ + v: 1, + policyVersion: 1, + policy: 'test-policy', + tool: 'guarded', + argsDigest: 'digest-1', + proposal: { detail: 'proposed' }, + jti: expect.any(String), + iat: 1_700_000_000, + exp: 1_700_000_120, + }); + expect(requestState).not.toContain(STATE_KEY); + expect(JSON.stringify(envelope)).not.toContain(STATE_KEY); + expect(JSON.stringify(envelope)).not.toContain(ACTOR_ID); + }); + + test('round-trips its own state without exposing the correlation id', async () => { + const state = createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + }); + + const minted = await state.mint(claims, context()); + const verified = await state.verify(minted.requestState, context()); + + expect(verified.kind).toBe('valid'); + expect(verified.interactionId).toBe(minted.interactionId); + if (verified.kind !== 'valid') { + throw new Error('expected valid state'); + } + expect(verified.state).toMatchObject({ + v: 1, + policy: 'test-policy', + tool: 'guarded', + argsDigest: 'digest-1', + proposal: { detail: 'proposed' }, + }); + expect('jti' in verified.state).toBe(false); + expect(verified.interactionId).not.toContain( + String(readPayload(minted.requestState).jti) + ); + }); + + test('refuses malformed state, payload mutation, and a broken MAC', async () => { + const state = createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + }); + const { requestState } = await state.mint(claims, context()); + const envelope = readPayload(requestState) as { + p: Record; + }; + + await expect(state.verify('not-request-state', context())).rejects.toThrow( + 'malformed' + ); + await expect( + state.verify( + reseal(requestState, { + ...envelope, + p: { ...envelope.p, tool: 'other_tool' }, + }), + context() + ) + ).rejects.toThrow('mac'); + await expect( + state.verify(breakMac(requestState), context()) + ).rejects.toThrow('mac'); + }); + + test('refuses another actor and another MCP method', async () => { + const minter = createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + }); + const otherActor = createContinuationState({ + actorId: 'actor-2', + stateKey: STATE_KEY, + }); + const { requestState } = await minter.mint(claims, context()); + + await expect(otherActor.verify(requestState, context())).rejects.toThrow( + 'bind' + ); + await expect( + minter.verify(requestState, context('prompts/get')) + ).rejects.toThrow('bind'); + }); + + test('classifies an elapsed lifetime as expired and keeps the Interaction ID', async () => { + let now = 1_700_000_000_000; + const state = createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + clock: () => now, + }); + + const minted = await state.mint(claims, context()); + now += 121_000; + const verified = await state.verify(minted.requestState, context()); + + expect(verified).toStrictEqual({ + kind: 'expired', + interactionId: minted.interactionId, + }); + }); +}); + +describe('served request-state seam', () => { + test('answers refused state with the SDK-owned invalid params error', async () => { + const state = createContinuationState({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + }); + const mintOnFirstRound: ToolPolicy< + { value: string }, + undefined + >['resolve'] = async (_params, ctx) => { + const minted = await state.mint(claims, ctx.server); + return { + type: 'result', + result: inputRequired({ requestState: minted.requestState }), + telemetry: { + policyId: 'state-test-policy', + policyVersion: 1, + outcome: 'input_required', + }, + }; + }; + const resolve = vi.fn(mintOnFirstRound); + + const handler = createMcpHandler( + () => + createMcpServer({ + name: 'state-test-server', + version: '0.0.0', + requestState: { verify: state.verify }, + tools: { + guarded: tool({ + description: 'Guarded', + parameters: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + policy: { resolve }, + execute: async ({ value }) => ({ value }), + }), + }, + }), + { legacy: 'reject' } + ); + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: async (url, init) => { + // The body is the JSON-RPC request this same test just sent. + const body = (await new Request(url, init).json()) as { + params?: { requestState?: string }; + }; + // Edit the echoed value, the cheapest stand-in for any + // attacker-controlled change to state the client holds. + if (typeof body.params?.requestState === 'string') { + body.params.requestState = breakMac(body.params.requestState); + } + return handler.fetch( + new Request(url, { ...init, body: JSON.stringify(body) }) + ); + }, + }); + const client = new Client( + { name: 'state-test-client', version: '1.2.3' }, + { + capabilities: { elicitation: {} }, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + } + ); + + await client.connect(transport); + cleanups.push( + () => client.close(), + () => handler.close() + ); + + const call = client.callTool({ + name: 'guarded', + arguments: { value: 'hi' }, + }); + + await expect(call).rejects.toMatchObject({ + code: -32602, + message: 'Invalid or expired requestState', + data: { reason: 'invalid_request_state' }, + }); + // The second round never reached the policy. + expect(resolve.mock.calls).toHaveLength(1); + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/state.ts b/packages/mcp-server-supabase/src/elicitations/state.ts new file mode 100644 index 00000000..dda640b8 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/state.ts @@ -0,0 +1,151 @@ +import type { ServerContext } from '@modelcontextprotocol/server'; + +import { + canonicalArgumentsDigest, + createSignedStateCodec, + createStateSigner, +} from './codec.js'; +import { deriveInteractionId } from './interaction-id.js'; + +/** + * Continuation State is stateless: everything a later round needs travels + * inside the signed value the client holds. Nothing is stored server side, so + * nothing has to be evicted, replicated, or consumed. + */ + +/** Hard ceiling on how long a continuation stays redeemable. */ +export const MAX_LIFETIME_SECONDS = 120; + +/** Version of the readable payload shape. */ +export const CONTINUATION_PAYLOAD_VERSION = 1; + +/** + * The readable, MAC-protected payload, minus the correlation id. + * + * `jti` is deliberately absent: it enters the wire payload at mint time and + * leaves this module only as a derived Interaction ID, so no caller can read + * it, log it, or build a single-use check on it. + */ +export type ContinuationState = { + v: number; + policyVersion: number; + policy: string; + tool: string; + argsDigest: string; + proposal: Proposal; + iat: number; + exp: number; +}; + +type SignedContinuationPayload = ContinuationState & { jti: string }; + +export type VerifiedContinuation = + | { kind: 'valid'; interactionId: string; state: ContinuationState } + | { kind: 'expired'; interactionId: string }; + +/** What a policy binds into the state when it asks for another round. */ +export type ContinuationClaims = { + policy: string; + policyVersion: number; + tool: string; + argsDigest: string; + proposal: unknown; +}; + +export type ContinuationStateOptions = { + /** Authenticated actor the state is bound to. */ + actorId: string; + /** Operator secret, at least 32 bytes. */ + stateKey: string | Uint8Array; + /** Redeemable lifetime, capped at {@link MAX_LIFETIME_SECONDS}. */ + lifetimeSeconds?: number; + clock?: () => number; + createJti?: () => string; +}; + +export type ContinuationStateStore = { + /** Digest of the canonical arguments a continuation is bound to. */ + argumentsDigest(value: unknown): Promise; + mint( + claims: ContinuationClaims, + ctx: ServerContext + ): Promise<{ requestState: string; interactionId: string }>; + /** + * Drop-in for `McpServerOptions.requestState.verify`. It throws for + * malformed input, a failed MAC, a wrong actor, and a wrong MCP method, all + * of which the SDK seam answers as `-32602` before any handler runs. + */ + verify(wire: string, ctx: ServerContext): Promise; +}; + +export function createContinuationState( + options: ContinuationStateOptions +): ContinuationStateStore { + const lifetimeSeconds = options.lifetimeSeconds ?? MAX_LIFETIME_SECONDS; + + if (!Number.isFinite(lifetimeSeconds) || lifetimeSeconds <= 0) { + throw new RangeError('lifetimeSeconds must be a positive finite number'); + } + + if (lifetimeSeconds > MAX_LIFETIME_SECONDS) { + throw new RangeError( + `lifetimeSeconds must be at most ${MAX_LIFETIME_SECONDS}` + ); + } + + const clock = options.clock ?? Date.now; + const createJti = options.createJti ?? (() => crypto.randomUUID()); + const signer = createStateSigner(options.stateKey); + const codec = createSignedStateCodec({ + signer, + // The actor keeps one caller from redeeming another's state; the method + // keeps state minted for one MCP method from being echoed into another. + bind: (ctx) => `${options.actorId}\u0000${ctx.mcpReq.method}`, + clock, + }); + + return { + argumentsDigest: canonicalArgumentsDigest, + + async mint(claims, ctx) { + const issuedAt = Math.floor(clock() / 1_000); + const jti = createJti(); + const requestState = await codec.mint( + { + v: CONTINUATION_PAYLOAD_VERSION, + policyVersion: claims.policyVersion, + policy: claims.policy, + tool: claims.tool, + argsDigest: claims.argsDigest, + proposal: claims.proposal, + jti, + iat: issuedAt, + exp: issuedAt + lifetimeSeconds, + }, + ctx + ); + + return { + requestState, + interactionId: await deriveInteractionId(signer, jti), + }; + }, + + async verify(wire, ctx) { + const verified = await codec.verify(wire, ctx); + const { jti, ...state } = verified.payload; + + if (typeof jti !== 'string') { + throw new Error('malformed'); + } + + const interactionId = await deriveInteractionId(signer, jti); + + if (verified.kind === 'expired') { + return { kind: 'expired', interactionId }; + } + + return { kind: 'valid', interactionId, state }; + }, + }; +} From 6e8c92a75d849fe3ce8b8a39de5b0890daa56ed4 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 19:20:22 +0200 Subject: [PATCH 04/11] feat: add private elicitation runtime contracts Adds the private runtime that turns an elicitation policy into a pre-execution guard, composed entirely through the public tool-policy interface: `ToolPolicy`, `ToolPolicyDecision`, `ToolRequestContext`, the structured-result normalization, the telemetry allowlist, and the SDK request-state pass-through. The policy contract owns what is proposed, what is asked, and what an answer means. The runtime owns state integrity, lifetime, correlation, capability, and terminal composition. Cost proposals and product copy live in neither. Signed state resolves before anything else, so current capability can neither promote a request that carries no state nor demote one that does. Preparation runs on the first round only: a later round carries the proposal the caller already approved, and a reissue re-signs that same proposal rather than building a new one. Authenticated failures answer in band and create nothing: an elapsed lifetime, a payload-version mismatch, a policy id or version mismatch, a tool mismatch, and changed canonical arguments each return actionable recovery text with its own telemetry reason. Integrity, actor, and method failures stay with the SDK seam as `-32602`. No package entry point exports any of it. The boundary test proves it by value identity across all three supported entry points, and pins the main entry's published surface. --- .../src/elicitations/package-boundary.test.ts | 86 ++++ .../src/elicitations/policy.ts | 45 ++ .../src/elicitations/runtime.test.ts | 426 ++++++++++++++++++ .../src/elicitations/runtime.ts | 292 ++++++++++++ 4 files changed, 849 insertions(+) create mode 100644 packages/mcp-server-supabase/src/elicitations/package-boundary.test.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/policy.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/runtime.test.ts create mode 100644 packages/mcp-server-supabase/src/elicitations/runtime.ts diff --git a/packages/mcp-server-supabase/src/elicitations/package-boundary.test.ts b/packages/mcp-server-supabase/src/elicitations/package-boundary.test.ts new file mode 100644 index 00000000..828071ac --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/package-boundary.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest'; + +import * as packageEntry from '../index.js'; +import * as apiPlatformEntry from '../platform/api-platform.js'; +import * as platformEntry from '../platform/index.js'; +import * as capability from './capability.js'; +import * as codec from './codec.js'; +import * as interactionId from './interaction-id.js'; +import * as runtime from './runtime.js'; +import * as state from './state.js'; +import * as terminal from './terminal.js'; + +/** + * The elicitation runtime is a private security module. It ships only as part + * of the approved stack and is reachable through package-internal imports + * alone, so an integrator cannot build on it, pin it, or depend on its shape. + * + * `policy.ts` carries types only and contributes no runtime value. + */ +const privateModules = { + capability, + codec, + 'interaction-id': interactionId, + runtime, + state, + terminal, +}; + +const supportedEntryPoints = { + '.': packageEntry, + './platform': platformEntry, + './platform/api': apiPlatformEntry, +}; + +describe('package boundary', () => { + test('no supported entry point re-exports a private runtime value', () => { + const leaked: string[] = []; + const privateValues = new Map(); + + for (const [module, exports] of Object.entries(privateModules)) { + for (const [name, value] of Object.entries(exports)) { + privateValues.set(value, `${module}.${name}`); + } + } + + for (const [entry, exports] of Object.entries(supportedEntryPoints)) { + for (const [name, value] of Object.entries(exports)) { + const source = privateValues.get(value); + if (source !== undefined) { + leaked.push(`${entry} exports ${name} from ${source}`); + } + } + } + + expect(privateValues.size).toBeGreaterThan(0); + expect(leaked).toStrictEqual([]); + }); + + test('the main entry point exposes exactly the published surface', () => { + expect(Object.keys(packageEntry).sort()).toStrictEqual([ + 'CURRENT_FEATURE_GROUPS', + 'createSupabaseMcpHandler', + 'createSupabaseMcpServer', + 'createToolSchemas', + 'supabaseMcpToolSchemas', + 'version', + ]); + expect(Object.keys(apiPlatformEntry).sort()).toStrictEqual([ + 'createSupabaseApiPlatform', + ]); + }); + + test('no entry point publishes a name from the private vocabulary', () => { + const privateVocabulary = + /elicit|continuation|interaction|codec|terminal|capabilit|requeststate/i; + + for (const [entry, exports] of Object.entries(supportedEntryPoints)) { + expect({ + entry, + names: Object.keys(exports).filter((name) => + privateVocabulary.test(name) + ), + }).toStrictEqual({ entry, names: [] }); + } + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/policy.ts b/packages/mcp-server-supabase/src/elicitations/policy.ts new file mode 100644 index 00000000..19f41dc9 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/policy.ts @@ -0,0 +1,45 @@ +import type { InputResponseView } from '@modelcontextprotocol/server'; +import type { ToolRequestContext } from '@supabase/mcp-utils'; + +/** + * What a policy decides before anything is asked of the caller: either the + * action needs no confirmation, or here is the proposal to confirm. + */ +export type ElicitationPreparation = + | { type: 'execute'; resolution: Resolution } + | { type: 'elicit'; proposal: Proposal }; + +/** + * What a policy makes of the caller's answer. + * + * `reissue` asks for another round with the proposal already signed, which is + * how invalid input is corrected without preparing the proposal again. + */ +export type ElicitationResolution = + | { type: 'execute'; resolution: Resolution } + | { type: 'declined'; message: string } + | { type: 'cancelled'; message: string } + | { type: 'reissue' }; + +/** + * The product half of an elicitation flow. The runtime owns state integrity, + * lifetime, correlation, and terminal composition; a policy owns what is + * proposed, what is asked, and what an answer means. + */ +export type ElicitationPolicy = { + /** Stable identifier bound into the signed state. */ + id: string; + /** Contract version bound into the signed state. */ + version: number; + /** Whether this request can carry the policy's elicitation. */ + available(ctx: ToolRequestContext): boolean; + /** The arguments an approval is bound to, minus incidental fields. */ + canonicalArguments(args: Args): unknown; + prepare(args: Args): Promise>; + /** Embedded input requests, keyed by identifiers unique to this request. */ + inputRequests(proposal: Proposal): Record; + resolve( + proposal: Proposal, + inputResponses: Record + ): Promise>; +}; diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts new file mode 100644 index 00000000..31abc1b3 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts @@ -0,0 +1,426 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { + createMcpHandler, + inputRequired, + type ElicitResult, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { + createMcpServer, + tool, + type McpServerOptions, +} from '@supabase/mcp-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { + canonicalArgumentsDigest, + createSignedStateCodec, + createStateSigner, +} from './codec.js'; +import type { ElicitationPolicy } from './policy.js'; +import type { ContinuationState } from './state.js'; +import { + createElicitationRuntime, + type ElicitationRuntimeOptions, +} from './runtime.js'; +import { withTerminalOutput } from './terminal.js'; + +const MODERN_PROTOCOL_VERSION = '2026-07-28'; +const MCP_ENDPOINT = new URL('https://mcp.test'); +const STATE_KEY = 'runtime-continuation-key-long-enough'; +const ACTOR_ID = 'actor-1'; +const TOOL = 'guarded'; + +type Args = { name: string }; +type Proposal = { name: string }; +type Resolution = { approved: boolean }; + +type PolicyCall = Parameters< + NonNullable +>[0]; + +type RequestBody = { + method?: string; + params?: { requestState?: string; arguments?: Record }; +}; + +const businessOutput = z.object({ id: z.string() }); + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } +}); + +function basePolicy(): ElicitationPolicy { + return { + id: 'test-policy', + version: 1, + available: () => true, + canonicalArguments: ({ name }) => ({ name }), + prepare: async ({ name }) => ({ type: 'elicit', proposal: { name } }), + inputRequests: (proposal) => ({ + confirm: inputRequired.elicit({ + message: `Confirm ${proposal.name}`, + // Action-only consent: the request carries no properties, so the + // answer is the `action`, never response content. + requestedSchema: { type: 'object', properties: {} }, + }), + }), + resolve: async (_proposal, responses) => { + const answer = responses.confirm; + if (answer === undefined || answer.kind !== 'elicit') { + return { type: 'reissue' }; + } + if (answer.action === 'decline') { + return { type: 'declined', message: 'Not created.' }; + } + if (answer.action === 'cancel') { + return { type: 'cancelled', message: 'Nothing was created.' }; + } + return { type: 'execute', resolution: { approved: true } }; + }, + }; +} + +type SetupOptions = { + policy?: Partial>; + runtime?: Partial; + answers?: ElicitResult[]; + onElicit?: () => void; + transformRequest?: (body: RequestBody) => void; + toolName?: string; +}; + +function setupRuntime(options: SetupOptions = {}) { + const prepare = vi.fn(basePolicy().prepare); + const execute = vi.fn(async (args: Args, resolution: Resolution) => ({ + id: `${args.name}:${resolution.approved}`, + })); + const policy = { ...basePolicy(), prepare, ...options.policy }; + const policyCalls: PolicyCall[] = []; + const runtime = createElicitationRuntime({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + formDeliveryAvailable: true, + ...options.runtime, + }); + + const handler = createMcpHandler( + () => + createMcpServer({ + name: 'runtime-test-server', + version: '0.0.0', + requestState: runtime.requestState, + onToolPolicyCall: (details) => { + policyCalls.push(details); + }, + tools: { + [TOOL]: tool({ + description: 'Guarded', + parameters: z.object({ name: z.string() }), + outputSchema: businessOutput, + policy: runtime.policy(options.toolName ?? TOOL, policy), + execute, + }), + plain: tool({ + description: 'Plain', + parameters: z.object({ name: z.string() }), + outputSchema: businessOutput, + execute: async ({ name }) => ({ id: name }), + }), + }, + }), + { legacy: 'reject' } + ); + + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: async (url, init) => { + // The body is the JSON-RPC request this test's own client just sent. + const body = (await new Request(url, init).json()) as RequestBody; + options.transformRequest?.(body); + return handler.fetch( + new Request(url, { ...init, body: JSON.stringify(body) }) + ); + }, + }); + + const client = new Client( + { name: 'runtime-test-client', version: '1.2.3' }, + { + capabilities: { elicitation: {} }, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + } + ); + const answers = options.answers ?? [{ action: 'accept' }]; + client.setRequestHandler('elicitation/create', async () => { + options.onElicit?.(); + const answer = answers.shift(); + if (answer === undefined) { + throw new Error('no elicitation answer left'); + } + return answer; + }); + + const connected = client.connect(transport).then(() => { + cleanups.push( + () => client.close(), + () => handler.close() + ); + return client; + }); + + return { client: connected, execute, policyCalls, prepare, runtime }; +} + +/** + * Mints state that authenticates against this server's key and binding but + * disagrees with it semantically, standing in for state a different server + * build issued. It is the only way to reach the payload-version, policy, and + * tool mismatch rows: the live minter always agrees with itself. + */ +async function foreignState( + overrides: Partial<{ + v: number; + policy: string; + policyVersion: number; + tool: string; + }> = {} +) { + const codec = createSignedStateCodec({ + signer: createStateSigner(STATE_KEY), + bind: (ctx) => `${ACTOR_ID}\u0000${ctx.mcpReq.method}`, + clock: Date.now, + }); + const issuedAt = Math.floor(Date.now() / 1_000); + + return codec.mint( + { + v: 1, + policyVersion: 1, + policy: 'test-policy', + tool: TOOL, + argsDigest: await canonicalArgumentsDigest({ name: 'demo' }), + proposal: { name: 'demo' }, + jti: 'foreign-jti', + iat: issuedAt, + exp: issuedAt + 120, + ...overrides, + }, + { mcpReq: { method: 'tools/call' } } as unknown as ServerContext + ); +} + +function textOf(result: { content?: unknown }): string { + const content = result.content as Array<{ type: string; text?: string }>; + return content.map((entry) => entry.text ?? '').join(''); +} + +describe('elicitation runtime composition', () => { + test('carries one prepared proposal across both rounds and executes once', async () => { + const { client, execute, prepare } = setupRuntime(); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + // Preparation runs on the first round only: the proposal the caller + // approved is the one carried by the signed state. + expect(prepare.mock.calls).toHaveLength(1); + expect(execute.mock.calls).toHaveLength(1); + }); + + test('asks with a property-less schema and takes the action as the answer', async () => { + const requests: unknown[] = []; + const { client, execute } = setupRuntime(); + (await client).setRequestHandler('elicitation/create', async (request) => { + requests.push(request.params); + return { action: 'accept' }; + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(requests).toStrictEqual([ + { + mode: 'form', + message: 'Confirm demo', + requestedSchema: { type: 'object', properties: {} }, + }, + ]); + expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + expect(execute.mock.calls).toHaveLength(1); + }); + + test('advertises the widened terminal output through tool discovery', async () => { + const { client } = setupRuntime(); + + const { tools } = await (await client).listTools(); + const guarded = tools.find(({ name }) => name === TOOL); + const plain = tools.find(({ name }) => name === 'plain'); + + expect(guarded?.outputSchema).toStrictEqual( + z.toJSONSchema(withTerminalOutput(businessOutput), { target: 'draft-7' }) + ); + // A policy-free tool keeps the discovery bytes it had before. + expect(plain?.outputSchema).toBeUndefined(); + }); + + test('reports allowlisted telemetry for both rounds of one interaction', async () => { + const { client, policyCalls } = setupRuntime(); + + await (await client).callTool({ name: TOOL, arguments: { name: 'demo' } }); + + expect(policyCalls.map(({ telemetry }) => telemetry)).toStrictEqual([ + { + interactionId: expect.any(String), + policyId: 'test-policy', + policyVersion: 1, + authorityPath: 'form_elicitation', + outcome: 'input_required', + }, + { + interactionId: expect.any(String), + policyId: 'test-policy', + policyVersion: 1, + authorityPath: 'form_elicitation', + outcome: 'executed', + }, + ]); + const [first, second] = policyCalls; + expect(first?.telemetry.interactionId).toBe( + second?.telemetry.interactionId + ); + }); + + test('executes without asking when preparation needs no confirmation', async () => { + const { client, execute, policyCalls } = setupRuntime({ + policy: { + prepare: async () => ({ + type: 'execute', + resolution: { approved: false }, + }), + }, + answers: [], + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.structuredContent).toStrictEqual({ id: 'demo:false' }); + expect(execute.mock.calls).toHaveLength(1); + expect(policyCalls).toHaveLength(1); + expect(policyCalls[0]?.telemetry).toStrictEqual({ + policyId: 'test-policy', + policyVersion: 1, + authorityPath: 'not_required', + outcome: 'executed', + }); + }); +}); + +describe('authenticated failures', () => { + test('answers an elapsed lifetime with recovery text and creates nothing', async () => { + let now = 1_700_000_000_000; + const { client, execute, policyCalls } = setupRuntime({ + runtime: { clock: () => now }, + transformRequest: (body) => { + if (typeof body.params?.requestState === 'string') { + now += 121_000; + } + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toBe( + 'This request expired before it was answered. Run the tool again to start a new one.' + ); + expect(result.structuredContent).toBeUndefined(); + expect(execute.mock.calls).toHaveLength(0); + expect(policyCalls.at(-1)?.telemetry).toStrictEqual({ + interactionId: expect.any(String), + policyId: 'test-policy', + policyVersion: 1, + outcome: 'expired', + reason: 'state_expired', + }); + }); + + test.each([ + ['payload_version', { v: 2 }], + ['policy_id', { policy: 'other-policy' }], + ['policy_version', { policyVersion: 2 }], + ['tool', { tool: 'other_tool' }], + ] as const)( + 'answers a %s mismatch with recovery text and creates nothing', + async (reason, overrides) => { + const replacement = await foreignState(overrides); + const { client, execute, policyCalls } = setupRuntime({ + transformRequest: (body) => { + if (typeof body.params?.requestState === 'string') { + body.params.requestState = replacement; + } + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('Run the tool again'); + expect(result.structuredContent).toBeUndefined(); + expect(execute.mock.calls).toHaveLength(0); + expect(policyCalls.at(-1)?.telemetry).toMatchObject({ + outcome: 'rejected', + reason, + }); + } + ); + + test('answers changed arguments with recovery text and creates nothing', async () => { + const { client, execute, policyCalls } = setupRuntime({ + transformRequest: (body) => { + if ( + typeof body.params?.requestState === 'string' && + body.params.arguments + ) { + body.params.arguments.name = 'something-else'; + } + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toBe( + 'The tool arguments changed after this request was issued. Run the tool again with the arguments you want.' + ); + expect(execute.mock.calls).toHaveLength(0); + expect(policyCalls.at(-1)?.telemetry).toMatchObject({ + outcome: 'rejected', + reason: 'arguments', + }); + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.ts b/packages/mcp-server-supabase/src/elicitations/runtime.ts new file mode 100644 index 00000000..edb4ddb4 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/runtime.ts @@ -0,0 +1,292 @@ +import { + inputRequired, + inputResponse, + type InputResponseView, +} from '@modelcontextprotocol/server'; +import type { + ToolPolicy, + ToolPolicyDecision, + ToolPolicyTelemetry, + ToolRequestContext, +} from '@supabase/mcp-utils'; + +import { + resolveElicitationAvailability, + type ElicitationAvailability, +} from './capability.js'; +import type { ElicitationPolicy } from './policy.js'; +import { + createContinuationState, + CONTINUATION_PAYLOAD_VERSION, + type ContinuationStateStore, + type VerifiedContinuation, +} from './state.js'; +import { + recoveryResult, + terminalResult, + withTerminalOutput, +} from './terminal.js'; + +/** + * Recovery text for the failures a caller can act on. Every one of them + * creates nothing and leaves the caller a way forward. + * + * The wording is infrastructure, not product copy: it names the mechanism + * that failed and the next step, and never what the tool would have cost. + */ +const RECOVERY_TEXT = { + state_expired: + 'This request expired before it was answered. Run the tool again to start a new one.', + payload_version: + 'This request was issued by a different version of this server. Run the tool again.', + policy_id: 'This request belongs to a different policy. Run the tool again.', + policy_version: + 'This request was issued under a policy version this server no longer supports. Run the tool again.', + tool: 'This request belongs to a different tool. Run the tool again.', + arguments: + 'The tool arguments changed after this request was issued. Run the tool again with the arguments you want.', + unsupported_continuation: + 'This client can no longer complete the request it started. Run the tool again from a client that supports form elicitation.', + unsupported_elicitation: + 'This client cannot complete the confirmation this tool requires, so nothing was created. Run the tool again from a client and connection that support form elicitation.', +} as const; + +type RecoveryReason = keyof typeof RECOVERY_TEXT; + +export type ElicitationRuntimeOptions = { + /** Authenticated actor the continuation state is bound to. */ + actorId: string; + /** Operator secret used to sign continuation state, at least 32 bytes. */ + stateKey: string | Uint8Array; + /** Redeemable lifetime of a continuation, capped at 120 seconds. */ + lifetimeSeconds?: number; + /** Whether the serving path in front of this server can deliver a form. */ + formDeliveryAvailable?: boolean; + /** Connection-level form elicitation opt-out. */ + optOut?: boolean; + clock?: () => number; + createJti?: () => string; +}; + +export type ElicitationRuntime = { + /** + * Drop-in for `McpServerOptions.requestState`. The SDK runs it before + * dispatch, so state that fails integrity, actor, or method binding never + * reaches a tool. + */ + readonly requestState: Pick; + /** Form elicitation support for this request, with one stable reason. */ + availability(ctx: ToolRequestContext): ElicitationAvailability; + /** Wraps a policy as the pre-execution guard for one tool. */ + policy( + tool: string, + policy: ElicitationPolicy + ): ToolPolicy; +}; + +export function createElicitationRuntime( + options: ElicitationRuntimeOptions +): ElicitationRuntime { + const state = createContinuationState({ + actorId: options.actorId, + stateKey: options.stateKey, + lifetimeSeconds: options.lifetimeSeconds, + clock: options.clock, + createJti: options.createJti, + }); + const servingFacts = { + formDeliveryAvailable: options.formDeliveryAvailable ?? false, + optOut: options.optOut, + }; + + function recover( + reason: RecoveryReason, + // The outcome is this helper's own: every recovery is a rejection, so a + // caller supplies only the identity the record is filed under. + telemetry: Omit + ): ToolPolicyDecision { + return { + type: 'result', + result: recoveryResult(RECOVERY_TEXT[reason]), + telemetry: { ...telemetry, outcome: 'rejected', reason }, + }; + } + + return { + requestState: { verify: state.verify }, + + availability(ctx) { + return resolveElicitationAvailability(ctx, servingFacts); + }, + + policy( + tool: string, + policy: ElicitationPolicy + ): ToolPolicy { + const identity = { policyId: policy.id, policyVersion: policy.version }; + + async function elicit( + proposal: Proposal, + argsDigest: string, + ctx: ToolRequestContext, + reason?: string + ): Promise> { + const { requestState, interactionId } = await state.mint( + { + policy: policy.id, + policyVersion: policy.version, + tool, + argsDigest, + proposal, + }, + ctx.server + ); + + return { + type: 'result', + result: inputRequired({ + // The private contract keeps the SDK's wire vocabulary out of a + // policy author's way; it is applied once, here. + inputRequests: policy.inputRequests(proposal) as Parameters< + typeof inputRequired + >[0]['inputRequests'], + requestState, + }), + telemetry: { + ...identity, + interactionId, + authorityPath: 'form_elicitation', + outcome: 'input_required', + ...(reason === undefined ? {} : { reason }), + }, + }; + } + + async function continuation( + verified: VerifiedContinuation, + argsDigest: string, + ctx: ToolRequestContext + ): Promise> { + const { interactionId } = verified; + const telemetry = { ...identity, interactionId }; + + if (verified.kind === 'expired') { + return { + type: 'result', + result: recoveryResult(RECOVERY_TEXT.state_expired), + telemetry: { + ...telemetry, + outcome: 'expired', + reason: 'state_expired', + }, + }; + } + + const signed = verified.state; + + if (signed.v !== CONTINUATION_PAYLOAD_VERSION) { + return recover('payload_version', telemetry); + } + if (signed.policy !== policy.id) { + return recover('policy_id', telemetry); + } + if (signed.policyVersion !== policy.version) { + return recover('policy_version', telemetry); + } + if (signed.tool !== tool) { + return recover('tool', telemetry); + } + if (signed.argsDigest !== argsDigest) { + return recover('arguments', telemetry); + } + // Capability is consulted only after the state proved it belongs + // here, so a client that lost form support gets an actionable answer + // instead of a different authority path. + if (!policy.available(ctx)) { + return recover('unsupported_continuation', telemetry); + } + + const proposal = signed.proposal as Proposal; + const requests = policy.inputRequests(proposal); + const responses: Record = Object.fromEntries( + Object.keys(requests).map((key) => [ + key, + inputResponse(ctx.server.mcpReq.inputResponses, key), + ]) + ); + const resolution = await policy.resolve(proposal, responses); + + switch (resolution.type) { + case 'execute': + return { + type: 'execute', + resolution: resolution.resolution, + telemetry: { + ...telemetry, + authorityPath: 'form_elicitation', + outcome: 'executed', + }, + }; + case 'declined': + case 'cancelled': + return { + type: 'result', + result: terminalResult(resolution.type, resolution.message), + telemetry: { ...telemetry, outcome: resolution.type }, + }; + case 'reissue': + // The proposal is the one already signed, so preparation does not + // run again and the caller cannot be shown a changed proposal. + return elicit(proposal, argsDigest, ctx, 'reissued'); + } + } + + return { + // Structured results follow capability. A request that cannot carry + // the elicitation can never reach a terminal variant either, so it + // keeps the tool's pre-normalization output byte for byte instead of + // advertising terminal variants it will never produce. + outputSchema: (schema, ctx) => + policy.available(ctx) ? withTerminalOutput(schema) : undefined, + + resolve: async (args, ctx) => { + // Signed state resolves first. Current capability cannot promote a + // request that carries none, and cannot demote one that does. + const verified = + ctx.server.mcpReq.requestState(); + const argsDigest = await state.argumentsDigest( + policy.canonicalArguments(args) + ); + + if (verified !== undefined) { + return continuation(verified, argsDigest, ctx); + } + + const preparation = await policy.prepare(args); + + if (preparation.type === 'execute') { + return { + type: 'execute', + resolution: preparation.resolution, + telemetry: { + ...identity, + authorityPath: 'not_required', + outcome: 'executed', + }, + }; + } + + // Availability is consulted here and not before preparation: a + // preparation that needs no confirmation must still execute on an + // incapable request. Only the branch that would emit a form is + // refused, and it is refused before anything is emitted or run. + if (!policy.available(ctx)) { + return recover('unsupported_elicitation', identity); + } + + return elicit(preparation.proposal, argsDigest, ctx); + }, + }; + }, + }; +} From 5a6f4e02f273614f82ba428d54fb8463a59ef8f9 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 19:30:10 +0200 Subject: [PATCH 05/11] feat: enforce private elicitation runtime invariants Adds the gate and the contracts for the three runtime invariants that decide how a flow behaves when something changes underneath it. The gate is a kill switch consulted immediately before protected execution. It blocks the attempt, and it neither consumes nor invalidates signed state, so the same continuation succeeds once the gate reopens. Tools without an elicitation policy never reach it and keep running while it is closed. Capability loss between rounds answers with actionable unsupported continuation text. The flow does not silently restart and does not switch to another authority path, proven by preparation running once and business execution never running. Repeating valid accepted state executes again. That is the approved detection-only posture: both attempts carry one Interaction ID and emit their own safe telemetry event, so a duplicate is detectable downstream while nothing here prevents it. There is no consumption, no replay cache, no capacity control, and no same-process ownership. --- .../src/elicitations/runtime.test.ts | 242 +++++++++++++++--- .../src/elicitations/runtime.ts | 36 +++ 2 files changed, 239 insertions(+), 39 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts index 31abc1b3..9bcc043e 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts @@ -92,6 +92,17 @@ function basePolicy(): ElicitationPolicy { type SetupOptions = { policy?: Partial>; runtime?: Partial; + /** + * Options for a second server that answers the continuation rounds, which + * is how a mid-flow change of serving facts or kill-switch state is + * reproduced. It shares the state key and actor, so the state it receives + * still authenticates. + */ + continuation?: Partial; + /** Delegates `policy.available` to the serving runtime's own resolver. */ + availableFromRuntime?: boolean; + /** Sends each continuation round twice, as a client retry would. */ + duplicateRetry?: boolean; answers?: ElicitResult[]; onElicit?: () => void; transformRequest?: (body: RequestBody) => void; @@ -103,51 +114,79 @@ function setupRuntime(options: SetupOptions = {}) { const execute = vi.fn(async (args: Args, resolution: Resolution) => ({ id: `${args.name}:${resolution.approved}`, })); - const policy = { ...basePolicy(), prepare, ...options.policy }; const policyCalls: PolicyCall[] = []; - const runtime = createElicitationRuntime({ - actorId: ACTOR_ID, - stateKey: STATE_KEY, - formDeliveryAvailable: true, - ...options.runtime, - }); - const handler = createMcpHandler( - () => - createMcpServer({ - name: 'runtime-test-server', - version: '0.0.0', - requestState: runtime.requestState, - onToolPolicyCall: (details) => { - policyCalls.push(details); - }, - tools: { - [TOOL]: tool({ - description: 'Guarded', - parameters: z.object({ name: z.string() }), - outputSchema: businessOutput, - policy: runtime.policy(options.toolName ?? TOOL, policy), - execute, - }), - plain: tool({ - description: 'Plain', - parameters: z.object({ name: z.string() }), - outputSchema: businessOutput, - execute: async ({ name }) => ({ id: name }), - }), - }, - }), - { legacy: 'reject' } - ); + function buildHandler(runtimeOptions: Partial) { + const runtime = createElicitationRuntime({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + formDeliveryAvailable: true, + ...runtimeOptions, + }); + const policy: ElicitationPolicy = { + ...basePolicy(), + prepare, + ...options.policy, + ...(options.availableFromRuntime === true + ? { available: (ctx) => runtime.availability(ctx).formElicitation } + : {}), + }; + + return createMcpHandler( + () => + createMcpServer({ + name: 'runtime-test-server', + version: '0.0.0', + requestState: runtime.requestState, + onToolPolicyCall: (details) => { + policyCalls.push(details); + }, + tools: { + [TOOL]: tool({ + description: 'Guarded', + parameters: z.object({ name: z.string() }), + outputSchema: businessOutput, + policy: runtime.policy(options.toolName ?? TOOL, policy), + execute, + }), + plain: tool({ + description: 'Plain', + parameters: z.object({ name: z.string() }), + outputSchema: businessOutput, + execute: async ({ name }) => ({ id: name }), + }), + }, + }), + { legacy: 'reject' } + ); + } + + const handler = buildHandler(options.runtime ?? {}); + const continuationHandler = + options.continuation === undefined + ? undefined + : buildHandler({ ...options.runtime, ...options.continuation }); const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { fetch: async (url, init) => { // The body is the JSON-RPC request this test's own client just sent. const body = (await new Request(url, init).json()) as RequestBody; options.transformRequest?.(body); - return handler.fetch( - new Request(url, { ...init, body: JSON.stringify(body) }) - ); + const forwarded = new Request(url, { + ...init, + body: JSON.stringify(body), + }); + const isContinuation = typeof body.params?.requestState === 'string'; + const target = + isContinuation && continuationHandler !== undefined + ? continuationHandler + : handler; + + if (isContinuation && options.duplicateRetry === true) { + await target.fetch(forwarded.clone()); + } + + return target.fetch(forwarded); }, }); @@ -171,12 +210,15 @@ function setupRuntime(options: SetupOptions = {}) { const connected = client.connect(transport).then(() => { cleanups.push( () => client.close(), - () => handler.close() + () => handler.close(), + ...(continuationHandler === undefined + ? [] + : [() => continuationHandler.close()]) ); return client; }); - return { client: connected, execute, policyCalls, prepare, runtime }; + return { client: connected, execute, policyCalls, prepare }; } /** @@ -424,3 +466,125 @@ describe('authenticated failures', () => { }); }); }); + +describe('continuation authority', () => { + test('answers capability loss mid-flow instead of switching authority path', async () => { + const { client, execute, prepare, policyCalls } = setupRuntime({ + availableFromRuntime: true, + // The connection opts out between the two rounds. + continuation: { optOut: true }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toBe( + 'This client can no longer complete the request it started. Run the tool again from a client that supports form elicitation.' + ); + // Neither authority path ran: no execution, and no second preparation + // that would have started a fresh flow. + expect(execute.mock.calls).toHaveLength(0); + expect(prepare.mock.calls).toHaveLength(1); + expect(policyCalls.at(-1)?.telemetry).toMatchObject({ + outcome: 'rejected', + reason: 'unsupported_continuation', + }); + }); +}); + +describe('gate', () => { + test('blocks protected execution without invalidating signed state', async () => { + let attempts = 0; + const { client, execute, policyCalls } = setupRuntime({ + duplicateRetry: true, + continuation: { + gate: () => + attempts++ === 0 + ? { isError: true, content: [{ type: 'text', text: 'Paused.' }] } + : null, + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + // The blocked attempt neither executed nor consumed the state: the very + // same continuation succeeded once the gate reopened. + expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + expect(execute.mock.calls).toHaveLength(1); + expect( + policyCalls.map(({ telemetry }) => [telemetry.outcome, telemetry.reason]) + ).toStrictEqual([ + ['input_required', undefined], + ['blocked', 'gate'], + ['executed', undefined], + ]); + }); + + test('leaves policy-free tools running while the gate is closed', async () => { + const { client, policyCalls } = setupRuntime({ + runtime: { + gate: () => ({ + isError: true, + content: [{ type: 'text', text: 'Paused.' }], + }), + }, + answers: [], + }); + + const ordinary = await (await client).callTool({ + name: 'plain', + arguments: { name: 'demo' }, + }); + const guarded = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(ordinary.content).toStrictEqual([ + { type: 'text', text: JSON.stringify({ id: 'demo' }) }, + ]); + expect(textOf(guarded)).toBe('Paused.'); + expect(guarded.isError).toBe(true); + // The policy-free tool never reached a policy at all. + expect(policyCalls).toHaveLength(1); + expect(policyCalls[0]?.telemetry).toStrictEqual({ + policyId: 'test-policy', + policyVersion: 1, + outcome: 'blocked', + reason: 'gate', + }); + }); +}); + +describe('detection-only replay posture', () => { + test('executes repeated valid accepted state again under one Interaction ID', async () => { + const { client, execute, policyCalls } = setupRuntime({ + duplicateRetry: true, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + // Both attempts ran. Telemetry can detect the duplicate; nothing prevents + // it, and nothing was consumed. + expect(execute.mock.calls).toHaveLength(2); + + const outcomes = policyCalls.map(({ telemetry }) => telemetry.outcome); + expect(outcomes).toStrictEqual(['input_required', 'executed', 'executed']); + + const interactionIds = policyCalls.map( + ({ telemetry }) => telemetry.interactionId + ); + expect(new Set(interactionIds).size).toBe(1); + expect(interactionIds[0]).toStrictEqual(expect.any(String)); + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.ts b/packages/mcp-server-supabase/src/elicitations/runtime.ts index edb4ddb4..1041c398 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.ts @@ -1,6 +1,7 @@ import { inputRequired, inputResponse, + type CallToolResult, type InputResponseView, } from '@modelcontextprotocol/server'; import type { @@ -64,6 +65,21 @@ export type ElicitationRuntimeOptions = { formDeliveryAvailable?: boolean; /** Connection-level form elicitation opt-out. */ optOut?: boolean; + /** + * Kill switch consulted immediately before protected execution. Returning a + * result blocks this attempt; it neither consumes nor invalidates signed + * state, so the same continuation is redeemable once the gate reopens. + * + * The result must set `isError`. A block is not a success, and on a + * normalized request a content-only success carries no `structuredContent` + * for the schema that request advertised, so it would be refused on the + * way out rather than reaching the caller as the block it is. + * + * Tools without an elicitation policy never reach it. + */ + gate?: ( + ctx: ToolRequestContext + ) => (CallToolResult & { isError: true }) | null; clock?: () => number; createJti?: () => string; }; @@ -206,6 +222,15 @@ export function createElicitationRuntime( return recover('unsupported_continuation', telemetry); } + const blocked = options.gate?.(ctx); + if (blocked != null) { + return { + type: 'result', + result: blocked, + telemetry: { ...telemetry, outcome: 'blocked', reason: 'gate' }, + }; + } + const proposal = signed.proposal as Proposal; const requests = policy.inputRequests(proposal); const responses: Record = Object.fromEntries( @@ -262,6 +287,17 @@ export function createElicitationRuntime( return continuation(verified, argsDigest, ctx); } + // Nothing protected has run yet: preparation itself is part of the + // guarded path, so the gate closes in front of it. + const blocked = options.gate?.(ctx); + if (blocked != null) { + return { + type: 'result', + result: blocked, + telemetry: { ...identity, outcome: 'blocked', reason: 'gate' }, + }; + } + const preparation = await policy.prepare(args); if (preparation.type === 'execute') { From a0235914a9255f78b9d7a325c725a4ee9c702416 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 19:39:32 +0200 Subject: [PATCH 06/11] test: complete private elicitation runtime contracts Closes the private runtime with the terminal contracts the flow reaches through the served stack: a decline and a cancel produce their own distinct non-error variant with explicit text and no business execution, and invalid input asks again on the proposal already signed instead of preparing a second one. Also records in the code that a reissued round opens its own lifetime and Interaction ID, which is the behavior carried over from #367. Nothing here repeats a matrix another layer owns. The output union's parse semantics belong to the terminal module's own tests, the availability matrix to the capability resolver's, and the integrity and binding rows to the continuation state's. --- .../src/elicitations/runtime.test.ts | 260 ++++++++++++++++-- .../src/elicitations/runtime.ts | 2 + 2 files changed, 242 insertions(+), 20 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts index 9bcc043e..1b7ddc64 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts @@ -21,7 +21,7 @@ import { createSignedStateCodec, createStateSigner, } from './codec.js'; -import type { ElicitationPolicy } from './policy.js'; +import type { ElicitationPolicy, ElicitationPreparation } from './policy.js'; import type { ContinuationState } from './state.js'; import { createElicitationRuntime, @@ -36,8 +36,9 @@ const ACTOR_ID = 'actor-1'; const TOOL = 'guarded'; type Args = { name: string }; -type Proposal = { name: string }; -type Resolution = { approved: boolean }; +/** `serial` counts preparations, so a second one is visible in the result. */ +type Proposal = { name: string; serial: number }; +type Resolution = { serial: number | null }; type PolicyCall = Parameters< NonNullable @@ -64,16 +65,19 @@ function basePolicy(): ElicitationPolicy { version: 1, available: () => true, canonicalArguments: ({ name }) => ({ name }), - prepare: async ({ name }) => ({ type: 'elicit', proposal: { name } }), + prepare: async ({ name }) => ({ + type: 'elicit', + proposal: { name, serial: 1 }, + }), inputRequests: (proposal) => ({ confirm: inputRequired.elicit({ - message: `Confirm ${proposal.name}`, + message: `Confirm ${proposal.name} #${proposal.serial}`, // Action-only consent: the request carries no properties, so the // answer is the `action`, never response content. requestedSchema: { type: 'object', properties: {} }, }), }), - resolve: async (_proposal, responses) => { + resolve: async (proposal, responses) => { const answer = responses.confirm; if (answer === undefined || answer.kind !== 'elicit') { return { type: 'reissue' }; @@ -84,7 +88,7 @@ function basePolicy(): ElicitationPolicy { if (answer.action === 'cancel') { return { type: 'cancelled', message: 'Nothing was created.' }; } - return { type: 'execute', resolution: { approved: true } }; + return { type: 'execute', resolution: { serial: proposal.serial } }; }, }; } @@ -110,9 +114,20 @@ type SetupOptions = { }; function setupRuntime(options: SetupOptions = {}) { - const prepare = vi.fn(basePolicy().prepare); + // Preparation is deliberately unstable: preparing twice would hand the + // caller a different proposal, so a stale serial in the result would prove + // the runtime prepared again instead of using the state it signed. + let preparations = 0; + const prepare = vi.fn( + async ({ + name, + }: Args): Promise> => ({ + type: 'elicit', + proposal: { name, serial: ++preparations }, + }) + ); const execute = vi.fn(async (args: Args, resolution: Resolution) => ({ - id: `${args.name}:${resolution.approved}`, + id: `${args.name}:${resolution.serial ?? 'unprompted'}`, })); const policyCalls: PolicyCall[] = []; @@ -148,6 +163,9 @@ function setupRuntime(options: SetupOptions = {}) { outputSchema: businessOutput, policy: runtime.policy(options.toolName ?? TOOL, policy), execute, + // Renders text only for a normalized request; a suppressed one + // must fall back to the default single encoding. + formatResult: ({ id }) => `formatted:${id}`, }), plain: tool({ description: 'Plain', @@ -265,19 +283,32 @@ function textOf(result: { content?: unknown }): string { } describe('elicitation runtime composition', () => { - test('carries one prepared proposal across both rounds and executes once', async () => { - const { client, execute, prepare } = setupRuntime(); + test('resolves the first signed proposal, not one a second preparation would build', async () => { + const asked: string[] = []; + const { client, execute, prepare } = setupRuntime({ + onElicit: () => { + asked.push('asked'); + }, + }); const result = await (await client).callTool({ name: TOOL, arguments: { name: 'demo' }, }); - expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); - // Preparation runs on the first round only: the proposal the caller - // approved is the one carried by the signed state. + // Serial 1 is the proposal the caller was shown. Preparing again on the + // continuation round would have produced serial 2 and executed with it. + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); + expect(asked).toHaveLength(1); expect(prepare.mock.calls).toHaveLength(1); expect(execute.mock.calls).toHaveLength(1); + + // The fixture is what makes the assertion above load bearing: preparing + // once more really does build a different proposal. + expect(await prepare({ name: 'demo' })).toStrictEqual({ + type: 'elicit', + proposal: { name: 'demo', serial: 2 }, + }); }); test('asks with a property-less schema and takes the action as the answer', async () => { @@ -296,11 +327,11 @@ describe('elicitation runtime composition', () => { expect(requests).toStrictEqual([ { mode: 'form', - message: 'Confirm demo', + message: 'Confirm demo #1', requestedSchema: { type: 'object', properties: {} }, }, ]); - expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); expect(execute.mock.calls).toHaveLength(1); }); @@ -350,7 +381,7 @@ describe('elicitation runtime composition', () => { policy: { prepare: async () => ({ type: 'execute', - resolution: { approved: false }, + resolution: { serial: null }, }), }, answers: [], @@ -361,7 +392,7 @@ describe('elicitation runtime composition', () => { arguments: { name: 'demo' }, }); - expect(result.structuredContent).toStrictEqual({ id: 'demo:false' }); + expect(result.structuredContent).toStrictEqual({ id: 'demo:unprompted' }); expect(execute.mock.calls).toHaveLength(1); expect(policyCalls).toHaveLength(1); expect(policyCalls[0]?.telemetry).toStrictEqual({ @@ -515,7 +546,7 @@ describe('gate', () => { // The blocked attempt neither executed nor consumed the state: the very // same continuation succeeded once the gate reopened. - expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); expect(execute.mock.calls).toHaveLength(1); expect( policyCalls.map(({ telemetry }) => [telemetry.outcome, telemetry.reason]) @@ -573,7 +604,7 @@ describe('detection-only replay posture', () => { arguments: { name: 'demo' }, }); - expect(result.structuredContent).toStrictEqual({ id: 'demo:true' }); + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); // Both attempts ran. Telemetry can detect the duplicate; nothing prevents // it, and nothing was consumed. expect(execute.mock.calls).toHaveLength(2); @@ -588,3 +619,192 @@ describe('detection-only replay posture', () => { expect(interactionIds[0]).toStrictEqual(expect.any(String)); }); }); + +describe('terminal outcomes', () => { + test.each([ + ['decline', 'declined', 'Not created.'], + ['cancel', 'cancelled', 'Nothing was created.'], + ] as const)( + 'answers %s with the distinct %s variant and explicit text', + async (action, status, text) => { + const { client, execute } = setupRuntime({ answers: [{ action }] }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toStrictEqual({ status }); + expect(textOf(result)).toBe(text); + expect(execute.mock.calls).toHaveLength(0); + } + ); + + test('asks again for invalid input without preparing a second proposal', async () => { + let asked = 0; + const { client, execute, prepare } = setupRuntime({ + policy: { + resolve: async (proposal, responses) => { + const answer = responses.confirm; + if (answer?.kind !== 'elicit' || answer.action !== 'accept') { + return { type: 'cancelled', message: 'Nothing was created.' }; + } + if (answer.content?.token !== 'ok') { + return { type: 'reissue' }; + } + return { type: 'execute', resolution: { serial: proposal.serial } }; + }, + }, + answers: [ + { action: 'accept', content: { token: 'wrong' } }, + { action: 'accept', content: { token: 'ok' } }, + ], + onElicit: () => { + asked += 1; + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(asked).toBe(2); + // The reissued round re-signs the proposal that was already prepared. + expect(prepare.mock.calls).toHaveLength(1); + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); + expect(execute.mock.calls).toHaveLength(1); + }); +}); + +describe('initial request availability', () => { + test('refuses to emit a form the request cannot carry, and creates nothing', async () => { + const asked: string[] = []; + const { client, execute, prepare, policyCalls } = setupRuntime({ + policy: { available: () => false }, + answers: [], + onElicit: () => { + asked.push('asked'); + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + // Nothing was emitted toward a client that cannot answer, and nothing ran. + expect(asked).toHaveLength(0); + expect(execute.mock.calls).toHaveLength(0); + // This request is suppressed, so the refusal also pins that a policy + // `result` decision reaches the wire unchanged: exactly these bytes, and + // no structured content to be stripped or added. + expect(result.isError).toBe(true); + expect(result.content).toStrictEqual([ + { + type: 'text', + text: 'This client cannot complete the confirmation this tool requires, so nothing was created. Run the tool again from a client and connection that support form elicitation.', + }, + ]); + expect('structuredContent' in result).toBe(false); + expect(prepare.mock.calls).toHaveLength(1); + expect(policyCalls).toHaveLength(1); + expect(policyCalls[0]?.telemetry).toStrictEqual({ + policyId: 'test-policy', + policyVersion: 1, + outcome: 'rejected', + reason: 'unsupported_elicitation', + }); + }); + + test('still executes a preparation that needs no confirmation', async () => { + const { client, execute } = setupRuntime({ + policy: { + available: () => false, + prepare: async () => ({ + type: 'execute', + resolution: { serial: null }, + }), + }, + answers: [], + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + // An incapable request is only refused where a form would be emitted. + // The request is suppressed, so the proof it ran is the pre-normalization + // payload rather than structured content. + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toBeUndefined(); + expect(result.content).toStrictEqual([ + { type: 'text', text: JSON.stringify({ id: 'demo:unprompted' }) }, + ]); + expect(execute.mock.calls).toHaveLength(1); + }); +}); + +describe('contextual structured results', () => { + test('normalizes a capable request and holds an incapable one on pre-normalization bytes', async () => { + const capable = await setupRuntime().client; + const incapable = await setupRuntime({ + policy: { + available: () => false, + prepare: async () => ({ + type: 'execute', + resolution: { serial: null }, + }), + }, + answers: [], + }).client; + + const advertised = (await capable.listTools()).tools; + const suppressed = (await incapable.listTools()).tools; + const guardedEntry = advertised.find(({ name }) => name === TOOL); + const suppressedEntry = suppressed.find(({ name }) => name === TOOL); + // Measured base: the policy-free tool on the same server, which carries + // the discovery and result bytes every tool had before structured + // results existed. + const baseEntry = suppressed.find(({ name }) => name === 'plain'); + + expect(guardedEntry?.outputSchema).toStrictEqual( + z.toJSONSchema(withTerminalOutput(businessOutput), { target: 'draft-7' }) + ); + expect('outputSchema' in (baseEntry ?? {})).toBe(false); + expect('outputSchema' in (suppressedEntry ?? {})).toBe(false); + + const normalized = await capable.callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + // The base runs the policy-free tool on the payload the suppressed lane + // produces, so the two results are directly comparable. + const base = await incapable.callTool({ + name: 'plain', + arguments: { name: 'demo:unprompted' }, + }); + const held = await incapable.callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(normalized.structuredContent).toStrictEqual({ id: 'demo:1' }); + expect(textOf(normalized)).toBe('formatted:demo:1'); + + // The base carries no structured content and single-encoded JSON text. + expect(base.structuredContent).toBeUndefined(); + expect(base.content).toStrictEqual([ + { type: 'text', text: JSON.stringify({ id: 'demo:unprompted' }) }, + ]); + + // The suppressed lane reproduces the base exactly, down to skipping the + // tool's own `formatResult`. + expect(held.isError).toBe(base.isError); + expect(held.structuredContent).toBe(base.structuredContent); + expect(held.content).toStrictEqual(base.content); + expect(textOf(held)).not.toBe('formatted:demo:unprompted'); + }); +}); diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.ts b/packages/mcp-server-supabase/src/elicitations/runtime.ts index 1041c398..0d7b73d1 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.ts @@ -262,6 +262,8 @@ export function createElicitationRuntime( case 'reissue': // The proposal is the one already signed, so preparation does not // run again and the caller cannot be shown a changed proposal. + // The reissued round is signed afresh, so it opens its own + // lifetime and its own Interaction ID. return elicit(proposal, argsDigest, ctx, 'reissued'); } } From 9b3a6900a2d7a5347eabe43b7f19a057982ad9ad Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Wed, 26 Aug 2026 14:19:25 +0200 Subject: [PATCH 07/11] fix: expire continuation state at its maximum lifetime --- packages/mcp-server-supabase/src/elicitations/codec.ts | 2 +- packages/mcp-server-supabase/src/elicitations/state.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/codec.ts b/packages/mcp-server-supabase/src/elicitations/codec.ts index f6e9405d..076c1123 100644 --- a/packages/mcp-server-supabase/src/elicitations/codec.ts +++ b/packages/mcp-server-supabase/src/elicitations/codec.ts @@ -251,7 +251,7 @@ export function createSignedStateCodec(options: { // Authenticate first, classify second: an elapsed lifetime is a fact // about a value this server signed, so the caller can recover from it. - if (envelope.exp < Math.floor(clock() / 1_000)) { + if (envelope.exp <= Math.floor(clock() / 1_000)) { return { kind: 'expired', payload }; } diff --git a/packages/mcp-server-supabase/src/elicitations/state.test.ts b/packages/mcp-server-supabase/src/elicitations/state.test.ts index d6a97fd2..8ed39e16 100644 --- a/packages/mcp-server-supabase/src/elicitations/state.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/state.test.ts @@ -187,8 +187,8 @@ describe('continuation state', () => { ).rejects.toThrow('bind'); }); - test('classifies an elapsed lifetime as expired and keeps the Interaction ID', async () => { - let now = 1_700_000_000_000; + test('expires at the fixed 120 second maximum between whole-second boundaries', async () => { + let now = 1_700_000_000_500; const state = createContinuationState({ actorId: ACTOR_ID, stateKey: STATE_KEY, @@ -196,7 +196,7 @@ describe('continuation state', () => { }); const minted = await state.mint(claims, context()); - now += 121_000; + now += 120_000; const verified = await state.verify(minted.requestState, context()); expect(verified).toStrictEqual({ From e2a61544c07f73bfb52aff0d63dd2551b4ec928c Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Wed, 26 Aug 2026 15:26:26 +0200 Subject: [PATCH 08/11] fix: harden stateless elicitation runtime --- .../src/elicitations/capability.test.ts | 19 ++ .../src/elicitations/capability.ts | 9 +- .../src/elicitations/codec.ts | 8 +- .../src/elicitations/policy.ts | 3 - .../src/elicitations/runtime.test.ts | 232 ++++++++++++------ .../src/elicitations/runtime.ts | 57 +++-- 6 files changed, 231 insertions(+), 97 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/capability.test.ts b/packages/mcp-server-supabase/src/elicitations/capability.test.ts index 6524d990..b4019ecc 100644 --- a/packages/mcp-server-supabase/src/elicitations/capability.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/capability.test.ts @@ -46,6 +46,25 @@ describe('form elicitation availability', () => { ).toStrictEqual({ formElicitation: false, reason: 'capability' }); }); + test.each([ + ['null', null], + ['string', 'form'], + ['number', 1], + ['boolean', true], + ['array', []], + ])( + 'treats a malformed %s elicitation declaration as incapable', + (_, elicitation) => { + const clientCapabilities = { + elicitation, + } as unknown as ToolRequestContext['clientCapabilities']; + + expect( + resolveElicitationAvailability(modern(clientCapabilities), servingPath) + ).toStrictEqual({ formElicitation: false, reason: 'capability' }); + } + ); + test('reports an unsupported serving path and an injected opt-out apart', () => { expect( resolveElicitationAvailability(modern({ elicitation: {} }), { diff --git a/packages/mcp-server-supabase/src/elicitations/capability.ts b/packages/mcp-server-supabase/src/elicitations/capability.ts index 458696ed..813c9f7d 100644 --- a/packages/mcp-server-supabase/src/elicitations/capability.ts +++ b/packages/mcp-server-supabase/src/elicitations/capability.ts @@ -50,12 +50,15 @@ export function resolveElicitationAvailability( return { formElicitation: false, reason: 'opt_out' }; } - const elicitation = ctx.clientCapabilities?.elicitation; + const elicitation: unknown = ctx.clientCapabilities?.elicitation; // A mode-less `elicitation: {}` predates the mode split and means every // mode. A declaration that names its modes must name `form`, which leaves a - // URL-only declaration incapable. + // URL-only declaration incapable. Malformed client-controlled values grant + // no capability. const declaresForm = - elicitation !== undefined && + elicitation !== null && + typeof elicitation === 'object' && + !Array.isArray(elicitation) && (Object.keys(elicitation).length === 0 || 'form' in elicitation); if (!declaresForm) { diff --git a/packages/mcp-server-supabase/src/elicitations/codec.ts b/packages/mcp-server-supabase/src/elicitations/codec.ts index 076c1123..31cf70b2 100644 --- a/packages/mcp-server-supabase/src/elicitations/codec.ts +++ b/packages/mcp-server-supabase/src/elicitations/codec.ts @@ -95,10 +95,16 @@ export function canonicalJson(value: unknown): string { if (Array.isArray(value)) { return `[${value.map(canonicalJson).join(',')}]`; } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError( + 'Canonical arguments must contain only arrays and plain records' + ); + } const entries = Object.entries(value as Record) .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); return `{${entries .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) diff --git a/packages/mcp-server-supabase/src/elicitations/policy.ts b/packages/mcp-server-supabase/src/elicitations/policy.ts index 19f41dc9..d2aabe2e 100644 --- a/packages/mcp-server-supabase/src/elicitations/policy.ts +++ b/packages/mcp-server-supabase/src/elicitations/policy.ts @@ -1,5 +1,4 @@ import type { InputResponseView } from '@modelcontextprotocol/server'; -import type { ToolRequestContext } from '@supabase/mcp-utils'; /** * What a policy decides before anything is asked of the caller: either the @@ -31,8 +30,6 @@ export type ElicitationPolicy = { id: string; /** Contract version bound into the signed state. */ version: number; - /** Whether this request can carry the policy's elicitation. */ - available(ctx: ToolRequestContext): boolean; /** The arguments an approval is bound to, minus incidental fields. */ canonicalArguments(args: Args): unknown; prepare(args: Args): Promise>; diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts index 1b7ddc64..2b21b7bd 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts @@ -18,6 +18,7 @@ import { z } from 'zod/v4'; import { canonicalArgumentsDigest, + canonicalJson, createSignedStateCodec, createStateSigner, } from './codec.js'; @@ -63,7 +64,6 @@ function basePolicy(): ElicitationPolicy { return { id: 'test-policy', version: 1, - available: () => true, canonicalArguments: ({ name }) => ({ name }), prepare: async ({ name }) => ({ type: 'elicit', @@ -103,8 +103,8 @@ type SetupOptions = { * still authenticates. */ continuation?: Partial; - /** Delegates `policy.available` to the serving runtime's own resolver. */ - availableFromRuntime?: boolean; + /** Whether the test client declares form elicitation support. */ + formElicitationCapability?: boolean; /** Sends each continuation round twice, as a client retry would. */ duplicateRetry?: boolean; answers?: ElicitResult[]; @@ -142,9 +142,6 @@ function setupRuntime(options: SetupOptions = {}) { ...basePolicy(), prepare, ...options.policy, - ...(options.availableFromRuntime === true - ? { available: (ctx) => runtime.availability(ctx).formElicitation } - : {}), }; return createMcpHandler( @@ -211,19 +208,22 @@ function setupRuntime(options: SetupOptions = {}) { const client = new Client( { name: 'runtime-test-client', version: '1.2.3' }, { - capabilities: { elicitation: {} }, + capabilities: + options.formElicitationCapability === false ? {} : { elicitation: {} }, versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, } ); - const answers = options.answers ?? [{ action: 'accept' }]; - client.setRequestHandler('elicitation/create', async () => { - options.onElicit?.(); - const answer = answers.shift(); - if (answer === undefined) { - throw new Error('no elicitation answer left'); - } - return answer; - }); + if (options.formElicitationCapability !== false) { + const answers = options.answers ?? [{ action: 'accept' }]; + client.setRequestHandler('elicitation/create', async () => { + options.onElicit?.(); + const answer = answers.shift(); + if (answer === undefined) { + throw new Error('no elicitation answer left'); + } + return answer; + }); + } const connected = client.connect(transport).then(() => { cleanups.push( @@ -282,6 +282,98 @@ function textOf(result: { content?: unknown }): string { return content.map((entry) => entry.text ?? '').join(''); } +describe('canonical arguments', () => { + test('uses code-unit key order and redeems reordered semantic arguments', async () => { + let canonicalizations = 0; + const { client, execute } = setupRuntime({ + policy: { + canonicalArguments: ({ name }) => { + canonicalizations += 1; + return canonicalizations === 1 + ? { a: name, Z: 'fixed' } + : { Z: 'fixed', a: name }; + }, + }, + }); + + expect(canonicalJson({ a: 'demo', Z: 'fixed' })).toBe( + '{"Z":"fixed","a":"demo"}' + ); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); + expect(canonicalizations).toBe(2); + expect(execute.mock.calls).toHaveLength(1); + }); + + test.each([ + ['Date', new Date(0)], + ['Set', new Set()], + ['Map', new Map()], + ['class instance', new (class UnsupportedArguments {})()], + ])( + 'rejects %s values instead of collapsing them into records', + async (_, value) => { + await expect(canonicalArgumentsDigest({ nested: value })).rejects.toThrow( + 'arrays and plain records' + ); + } + ); + + test('rejects unsupported canonical arguments before form emission', async () => { + const asked = vi.fn(); + const { client, execute } = setupRuntime({ + policy: { canonicalArguments: () => new Date(0) }, + onElicit: asked, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain( + 'Canonical arguments must contain only arrays and plain records' + ); + + expect(asked).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + + test('rejects unsupported canonical arguments before continuation execution', async () => { + let canonicalizations = 0; + const asked = vi.fn(); + const { client, execute } = setupRuntime({ + policy: { + canonicalArguments: () => { + canonicalizations += 1; + return canonicalizations === 1 ? {} : new Set(); + }, + }, + onElicit: asked, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain( + 'Canonical arguments must contain only arrays and plain records' + ); + + expect(canonicalizations).toBe(2); + expect(asked).toHaveBeenCalledOnce(); + expect(execute).not.toHaveBeenCalled(); + }); +}); + describe('elicitation runtime composition', () => { test('resolves the first signed proposal, not one a second preparation would build', async () => { const asked: string[] = []; @@ -302,13 +394,6 @@ describe('elicitation runtime composition', () => { expect(asked).toHaveLength(1); expect(prepare.mock.calls).toHaveLength(1); expect(execute.mock.calls).toHaveLength(1); - - // The fixture is what makes the assertion above load bearing: preparing - // once more really does build a different proposal. - expect(await prepare({ name: 'demo' })).toStrictEqual({ - type: 'elicit', - proposal: { name: 'demo', serial: 2 }, - }); }); test('asks with a property-less schema and takes the action as the answer', async () => { @@ -422,9 +507,8 @@ describe('authenticated failures', () => { }); expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - 'This request expired before it was answered. Run the tool again to start a new one.' - ); + expect(textOf(result)).toContain('nothing was created'); + expect(textOf(result)).toContain('Run the tool again to start a new one.'); expect(result.structuredContent).toBeUndefined(); expect(execute.mock.calls).toHaveLength(0); expect(policyCalls.at(-1)?.telemetry).toStrictEqual({ @@ -487,8 +571,9 @@ describe('authenticated failures', () => { }); expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - 'The tool arguments changed after this request was issued. Run the tool again with the arguments you want.' + expect(textOf(result)).toContain('nothing was created'); + expect(textOf(result)).toContain( + 'Run the tool again with the arguments you want.' ); expect(execute.mock.calls).toHaveLength(0); expect(policyCalls.at(-1)?.telemetry).toMatchObject({ @@ -501,7 +586,6 @@ describe('authenticated failures', () => { describe('continuation authority', () => { test('answers capability loss mid-flow instead of switching authority path', async () => { const { client, execute, prepare, policyCalls } = setupRuntime({ - availableFromRuntime: true, // The connection opts out between the two rounds. continuation: { optOut: true }, }); @@ -512,8 +596,9 @@ describe('continuation authority', () => { }); expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - 'This client can no longer complete the request it started. Run the tool again from a client that supports form elicitation.' + expect(textOf(result)).toContain('nothing was created'); + expect(textOf(result)).toContain( + 'Run the tool again from a client that supports form elicitation.' ); // Neither authority path ran: no execution, and no second preparation // that would have started a fresh flow. @@ -521,7 +606,7 @@ describe('continuation authority', () => { expect(prepare.mock.calls).toHaveLength(1); expect(policyCalls.at(-1)?.telemetry).toMatchObject({ outcome: 'rejected', - reason: 'unsupported_continuation', + reason: 'opt_out', }); }); }); @@ -679,49 +764,56 @@ describe('terminal outcomes', () => { }); describe('initial request availability', () => { - test('refuses to emit a form the request cannot carry, and creates nothing', async () => { - const asked: string[] = []; - const { client, execute, prepare, policyCalls } = setupRuntime({ - policy: { available: () => false }, - answers: [], - onElicit: () => { - asked.push('asked'); - }, - }); + test.each([ + [ + 'serving path', + { runtime: { formDeliveryAvailable: false } }, + 'serving_path', + ], + ['connection opt-out', { runtime: { optOut: true } }, 'opt_out'], + ['client capability', { formElicitationCapability: false }, 'capability'], + ] as const)( + 'lets the runtime-owned %s denial prevent form emission', + async (_, deniedBy, reason) => { + const asked: string[] = []; + const { client, execute, prepare, policyCalls } = setupRuntime({ + ...deniedBy, + answers: [], + onElicit: () => { + asked.push('asked'); + }, + }); - const result = await (await client).callTool({ - name: TOOL, - arguments: { name: 'demo' }, - }); + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); - // Nothing was emitted toward a client that cannot answer, and nothing ran. - expect(asked).toHaveLength(0); - expect(execute.mock.calls).toHaveLength(0); - // This request is suppressed, so the refusal also pins that a policy - // `result` decision reaches the wire unchanged: exactly these bytes, and - // no structured content to be stripped or added. - expect(result.isError).toBe(true); - expect(result.content).toStrictEqual([ - { - type: 'text', - text: 'This client cannot complete the confirmation this tool requires, so nothing was created. Run the tool again from a client and connection that support form elicitation.', - }, - ]); - expect('structuredContent' in result).toBe(false); - expect(prepare.mock.calls).toHaveLength(1); - expect(policyCalls).toHaveLength(1); - expect(policyCalls[0]?.telemetry).toStrictEqual({ - policyId: 'test-policy', - policyVersion: 1, - outcome: 'rejected', - reason: 'unsupported_elicitation', - }); - }); + expect(asked).toHaveLength(0); + expect(execute.mock.calls).toHaveLength(0); + expect(result.isError).toBe(true); + expect(result.content).toStrictEqual([ + { + type: 'text', + text: 'This client cannot complete the confirmation this tool requires, so nothing was created. Run the tool again from a client and connection that support form elicitation.', + }, + ]); + expect('structuredContent' in result).toBe(false); + expect(prepare.mock.calls).toHaveLength(1); + expect(policyCalls).toHaveLength(1); + expect(policyCalls[0]?.telemetry).toStrictEqual({ + policyId: 'test-policy', + policyVersion: 1, + outcome: 'rejected', + reason, + }); + } + ); test('still executes a preparation that needs no confirmation', async () => { const { client, execute } = setupRuntime({ + runtime: { formDeliveryAvailable: false }, policy: { - available: () => false, prepare: async () => ({ type: 'execute', resolution: { serial: null }, @@ -751,8 +843,8 @@ describe('contextual structured results', () => { test('normalizes a capable request and holds an incapable one on pre-normalization bytes', async () => { const capable = await setupRuntime().client; const incapable = await setupRuntime({ + runtime: { formDeliveryAvailable: false }, policy: { - available: () => false, prepare: async () => ({ type: 'execute', resolution: { serial: null }, diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.ts b/packages/mcp-server-supabase/src/elicitations/runtime.ts index 0d7b73d1..8b9346f8 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.ts @@ -37,7 +37,7 @@ import { */ const RECOVERY_TEXT = { state_expired: - 'This request expired before it was answered. Run the tool again to start a new one.', + 'This request expired before it was answered, so nothing was created. Run the tool again to start a new one.', payload_version: 'This request was issued by a different version of this server. Run the tool again.', policy_id: 'This request belongs to a different policy. Run the tool again.', @@ -45,9 +45,9 @@ const RECOVERY_TEXT = { 'This request was issued under a policy version this server no longer supports. Run the tool again.', tool: 'This request belongs to a different tool. Run the tool again.', arguments: - 'The tool arguments changed after this request was issued. Run the tool again with the arguments you want.', + 'The tool arguments changed after this request was issued, so nothing was created. Run the tool again with the arguments you want.', unsupported_continuation: - 'This client can no longer complete the request it started. Run the tool again from a client that supports form elicitation.', + 'This client can no longer complete the request it started, so nothing was created. Run the tool again from a client that supports form elicitation.', unsupported_elicitation: 'This client cannot complete the confirmation this tool requires, so nothing was created. Run the tool again from a client and connection that support form elicitation.', } as const; @@ -114,26 +114,31 @@ export function createElicitationRuntime( formDeliveryAvailable: options.formDeliveryAvailable ?? false, optOut: options.optOut, }; + const availability = (ctx: ToolRequestContext) => + resolveElicitationAvailability(ctx, servingFacts); function recover( - reason: RecoveryReason, + recoveryReason: RecoveryReason, // The outcome is this helper's own: every recovery is a rejection, so a // caller supplies only the identity the record is filed under. - telemetry: Omit + telemetry: Omit, + telemetryReason: string = recoveryReason ): ToolPolicyDecision { return { type: 'result', - result: recoveryResult(RECOVERY_TEXT[reason]), - telemetry: { ...telemetry, outcome: 'rejected', reason }, + result: recoveryResult(RECOVERY_TEXT[recoveryReason]), + telemetry: { + ...telemetry, + outcome: 'rejected', + reason: telemetryReason, + }, }; } return { requestState: { verify: state.verify }, - availability(ctx) { - return resolveElicitationAvailability(ctx, servingFacts); - }, + availability, policy( tool: string, @@ -215,11 +220,16 @@ export function createElicitationRuntime( if (signed.argsDigest !== argsDigest) { return recover('arguments', telemetry); } - // Capability is consulted only after the state proved it belongs - // here, so a client that lost form support gets an actionable answer - // instead of a different authority path. - if (!policy.available(ctx)) { - return recover('unsupported_continuation', telemetry); + // Runtime availability is consulted only after the state proved it + // belongs here. A current denial cannot switch authority paths or + // discard the resolver's stable reason. + const currentAvailability = availability(ctx); + if (!currentAvailability.formElicitation) { + return recover( + 'unsupported_continuation', + telemetry, + currentAvailability.reason + ); } const blocked = options.gate?.(ctx); @@ -274,11 +284,13 @@ export function createElicitationRuntime( // keeps the tool's pre-normalization output byte for byte instead of // advertising terminal variants it will never produce. outputSchema: (schema, ctx) => - policy.available(ctx) ? withTerminalOutput(schema) : undefined, + availability(ctx).formElicitation + ? withTerminalOutput(schema) + : undefined, resolve: async (args, ctx) => { - // Signed state resolves first. Current capability cannot promote a - // request that carries none, and cannot demote one that does. + // Signed state resolves first. A request that carries it stays on + // the continuation path, where current availability can reject it. const verified = ctx.server.mcpReq.requestState(); const argsDigest = await state.argumentsDigest( @@ -318,8 +330,13 @@ export function createElicitationRuntime( // preparation that needs no confirmation must still execute on an // incapable request. Only the branch that would emit a form is // refused, and it is refused before anything is emitted or run. - if (!policy.available(ctx)) { - return recover('unsupported_elicitation', identity); + const currentAvailability = availability(ctx); + if (!currentAvailability.formElicitation) { + return recover( + 'unsupported_elicitation', + identity, + currentAvailability.reason + ); } return elicit(preparation.proposal, argsDigest, ctx); From 60ccf08ac4d4fcd67249df27c28d4b72f7885d58 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Wed, 26 Aug 2026 16:34:20 +0200 Subject: [PATCH 09/11] fix: harden elicitation validation boundaries --- .../src/elicitations/capability.test.ts | 16 +++ .../src/elicitations/capability.ts | 21 +++- .../src/elicitations/codec.ts | 15 ++- .../src/elicitations/runtime.test.ts | 117 ++++++++++++++++++ .../src/elicitations/terminal.test.ts | 18 +++ .../src/elicitations/terminal.ts | 15 ++- 6 files changed, 189 insertions(+), 13 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/capability.test.ts b/packages/mcp-server-supabase/src/elicitations/capability.test.ts index b4019ecc..911f3a54 100644 --- a/packages/mcp-server-supabase/src/elicitations/capability.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/capability.test.ts @@ -34,6 +34,22 @@ describe('form elicitation availability', () => { ).toStrictEqual({ formElicitation: true, reason: 'available' }); }); + test.each([ + ['null', null], + ['array', []], + ['string', 'form'], + ['number', 1], + ['boolean', true], + ])('treats a malformed nested form %s as incapable', (_, form) => { + const clientCapabilities = { + elicitation: { form }, + } as unknown as ToolRequestContext['clientCapabilities']; + + expect( + resolveElicitationAvailability(modern(clientCapabilities), servingPath) + ).toStrictEqual({ formElicitation: false, reason: 'capability' }); + }); + test('treats URL-only and absent declarations as incapable', () => { expect( resolveElicitationAvailability( diff --git a/packages/mcp-server-supabase/src/elicitations/capability.ts b/packages/mcp-server-supabase/src/elicitations/capability.ts index 813c9f7d..bf574488 100644 --- a/packages/mcp-server-supabase/src/elicitations/capability.ts +++ b/packages/mcp-server-supabase/src/elicitations/capability.ts @@ -52,14 +52,23 @@ export function resolveElicitationAvailability( const elicitation: unknown = ctx.clientCapabilities?.elicitation; // A mode-less `elicitation: {}` predates the mode split and means every - // mode. A declaration that names its modes must name `form`, which leaves a - // URL-only declaration incapable. Malformed client-controlled values grant - // no capability. - const declaresForm = + // mode. A declaration that names its modes must name a valid object-valued + // `form`, which leaves URL-only and malformed declarations incapable. + const isModeDeclaration = elicitation !== null && typeof elicitation === 'object' && - !Array.isArray(elicitation) && - (Object.keys(elicitation).length === 0 || 'form' in elicitation); + !Array.isArray(elicitation); + const modes = isModeDeclaration + ? (elicitation as Record) + : undefined; + const form = modes?.form; + const declaresForm = + modes !== undefined && + (Object.keys(modes).length === 0 || + ('form' in modes && + form !== null && + typeof form === 'object' && + !Array.isArray(form))); if (!declaresForm) { return { formElicitation: false, reason: 'capability' }; diff --git a/packages/mcp-server-supabase/src/elicitations/codec.ts b/packages/mcp-server-supabase/src/elicitations/codec.ts index 31cf70b2..ac8e8979 100644 --- a/packages/mcp-server-supabase/src/elicitations/codec.ts +++ b/packages/mcp-server-supabase/src/elicitations/codec.ts @@ -84,6 +84,10 @@ function constantTimeEqual(left: string, right: string): boolean { * whatever order they arrived in. */ export function canonicalJson(value: unknown): string { + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new TypeError('Canonical arguments must contain only finite numbers'); + } + if (value === null || typeof value !== 'object') { const encoded = JSON.stringify(value); if (encoded === undefined) { @@ -93,7 +97,16 @@ export function canonicalJson(value: unknown): string { } if (Array.isArray(value)) { - return `[${value.map(canonicalJson).join(',')}]`; + const entries: string[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new TypeError( + 'Canonical arguments must contain only dense arrays' + ); + } + entries.push(canonicalJson(value[index])); + } + return `[${entries.join(',')}]`; } const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) { diff --git a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts index 2b21b7bd..dda54a75 100644 --- a/packages/mcp-server-supabase/src/elicitations/runtime.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts @@ -12,6 +12,7 @@ import { createMcpServer, tool, type McpServerOptions, + type ToolRequestContext, } from '@supabase/mcp-utils'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { z } from 'zod/v4'; @@ -324,6 +325,73 @@ describe('canonical arguments', () => { } ); + test('rejects sparse arrays instead of treating holes as omitted values', async () => { + await expect(canonicalArgumentsDigest(Array(1))).rejects.toThrow( + 'dense arrays' + ); + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects the non-finite number %s', + async (value) => { + await expect(canonicalArgumentsDigest(value)).rejects.toThrow( + 'finite numbers' + ); + } + ); + + test('preserves the supported JSON value domain', () => { + expect( + canonicalJson({ + array: [null, 'text', true, false, 0, 1.5], + record: {}, + }) + ).toBe('{"array":[null,"text",true,false,0,1.5],"record":{}}'); + }); + + test('rejects a sparse canonical array before form emission', async () => { + const asked = vi.fn(); + const { client, execute } = setupRuntime({ + policy: { canonicalArguments: () => Array(1) }, + onElicit: asked, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('dense arrays'); + expect(asked).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + + test('rejects a non-finite canonical number before continuation execution', async () => { + let canonicalizations = 0; + const asked = vi.fn(); + const { client, execute } = setupRuntime({ + policy: { + canonicalArguments: () => { + canonicalizations += 1; + return canonicalizations === 1 ? 1 : Number.NaN; + }, + }, + onElicit: asked, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('finite numbers'); + expect(canonicalizations).toBe(2); + expect(asked).toHaveBeenCalledOnce(); + expect(execute).not.toHaveBeenCalled(); + }); + test('rejects unsupported canonical arguments before form emission', async () => { const asked = vi.fn(); const { client, execute } = setupRuntime({ @@ -810,6 +878,55 @@ describe('initial request availability', () => { } ); + test('rejects a malformed nested form through the runtime with stable telemetry', async () => { + const base = basePolicy(); + const prepare = vi.fn(base.prepare); + const inputRequests = vi.fn(base.inputRequests); + const runtime = createElicitationRuntime({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + formDeliveryAvailable: true, + }); + const guarded = runtime.policy(TOOL, { + ...base, + prepare, + inputRequests, + }); + const ctx = { + server: { + mcpReq: { requestState: () => undefined }, + } as unknown as ServerContext, + era: 'modern', + clientInfo: { name: 'runtime-test-client', version: '1.2.3' }, + clientCapabilities: { + elicitation: { form: null }, + } as unknown as ToolRequestContext['clientCapabilities'], + } satisfies ToolRequestContext; + + const decision = await guarded.resolve({ name: 'demo' }, ctx); + + expect(decision.type).toBe('result'); + expect(decision.telemetry).toStrictEqual({ + policyId: 'test-policy', + policyVersion: 1, + outcome: 'rejected', + reason: 'capability', + }); + expect(prepare).toHaveBeenCalledOnce(); + expect(inputRequests).not.toHaveBeenCalled(); + if (decision.type !== 'result') { + throw new Error('Expected runtime capability rejection'); + } + expect(decision.result.isError).toBe(true); + expect(decision.result.content).toStrictEqual([ + { + type: 'text', + text: 'This client cannot complete the confirmation this tool requires, so nothing was created. Run the tool again from a client and connection that support form elicitation.', + }, + ]); + expect('structuredContent' in decision.result).toBe(false); + }); + test('still executes a preparation that needs no confirmation', async () => { const { client, execute } = setupRuntime({ runtime: { formDeliveryAvailable: false }, diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts index 1f3f9ab3..a835f4e0 100644 --- a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts @@ -12,6 +12,11 @@ const businessSchema = z.object({ status: z.literal('created'), }); +const overlappingBusinessSchema = z.object({ + id: z.string(), + status: z.enum(['created', 'declined', 'cancelled']), +}); + describe('elicitation output widening', () => { test('keeps business output and adds the two terminal variants', () => { const outputSchema = withTerminalOutput(businessSchema); @@ -27,6 +32,19 @@ describe('elicitation output widening', () => { }); }); + test.each(['declined', 'cancelled'] as const)( + 'keeps complete business output whose status is %s', + (status) => { + const outputSchema = withTerminalOutput(overlappingBusinessSchema); + + expect(outputSchema.parse({ id: 'project-1', status })).toStrictEqual({ + id: 'project-1', + status, + }); + expect(outputSchema.parse({ status })).toStrictEqual({ status }); + } + ); + test('advertises an object root MCP structured output accepts', () => { const jsonSchema = z.toJSONSchema(withTerminalOutput(businessSchema), { target: 'draft-7', diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.ts b/packages/mcp-server-supabase/src/elicitations/terminal.ts index c167177f..4c68eb6a 100644 --- a/packages/mcp-server-supabase/src/elicitations/terminal.ts +++ b/packages/mcp-server-supabase/src/elicitations/terminal.ts @@ -53,6 +53,13 @@ export function withTerminalOutput>( }); return root.superRefine((value, ctx) => { + const business = schema.safeParse(value); + if (business.success) { + // The original schema owns complete business output. A terminal word in + // its status field does not turn that valid value into a terminal result. + return; + } + const record = value as Record; const terminal = elicitationTerminalStatusSchema.safeParse(record.status); @@ -71,12 +78,8 @@ export function withTerminalOutput>( return; } - const business = schema.safeParse(value); - - if (!business.success) { - for (const issue of business.error.issues) { - ctx.addIssue({ ...issue }); - } + for (const issue of business.error.issues) { + ctx.addIssue({ ...issue }); } }); } From 8a1fda5433729a36d49eeefda2a43638e4f0f90e Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Wed, 26 Aug 2026 17:27:10 +0200 Subject: [PATCH 10/11] fix: apply terminal output transforms once --- .../src/elicitations/terminal.test.ts | 49 ++++++++ .../src/elicitations/terminal.ts | 107 +++++++++++------- 2 files changed, 115 insertions(+), 41 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts index a835f4e0..ff7ce035 100644 --- a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts @@ -32,6 +32,46 @@ describe('elicitation output widening', () => { }); }); + test('applies business transforms, defaults, and refinements once', () => { + let transformCalls = 0; + let refinementCalls = 0; + const transformedSchema = z + .object({ + token: z.string().transform((value) => { + transformCalls += 1; + return { normalized: value.toUpperCase() }; + }), + status: z.literal('created').default('created'), + }) + .superRefine((value, ctx) => { + refinementCalls += 1; + if (value.token.normalized.length < 3) { + ctx.addIssue({ + code: 'custom', + path: ['token'], + message: 'Token is too short.', + }); + } + }); + const input = { token: 'abc' }; + const expected = transformedSchema.parse(input); + transformCalls = 0; + refinementCalls = 0; + + expect(withTerminalOutput(transformedSchema).parse(input)).toStrictEqual( + expected + ); + expect(transformCalls).toBe(1); + expect(refinementCalls).toBe(1); + expect( + withTerminalOutput(transformedSchema).safeParse({ token: 'x' }).success + ).toBe(false); + const jsonSchema = z.toJSONSchema(withTerminalOutput(transformedSchema), { + target: 'draft-7', + }); + expect(jsonSchema.properties?.token).toMatchObject({ type: 'string' }); + }); + test.each(['declined', 'cancelled'] as const)( 'keeps complete business output whose status is %s', (status) => { @@ -53,6 +93,15 @@ describe('elicitation output widening', () => { expect(jsonSchema.type).toBe('object'); expect(jsonSchema).not.toHaveProperty('anyOf'); expect(jsonSchema).not.toHaveProperty('oneOf'); + expect(jsonSchema.properties).toMatchObject({ + id: { type: 'string' }, + status: { + anyOf: [ + { type: 'string', const: 'created' }, + { type: 'string', enum: ['declined', 'cancelled'] }, + ], + }, + }); }); test('rejects a terminal status the schema does not advertise', () => { diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.ts b/packages/mcp-server-supabase/src/elicitations/terminal.ts index 4c68eb6a..3c0a3985 100644 --- a/packages/mcp-server-supabase/src/elicitations/terminal.ts +++ b/packages/mcp-server-supabase/src/elicitations/terminal.ts @@ -29,59 +29,84 @@ export type ElicitationTerminalStatus = z.infer< * satisfy. A root-level union serializes to `anyOf` with no root `type`, * which fails the entire `tools/list` response. * - * The business schema itself passes through unchanged: an accepted execution - * is validated by the tool's own schema, not by the widened copy, so an - * incomplete business payload stays a rejection. The widened root is derived - * from that same schema, so whatever it says about undeclared fields still - * holds and an accepted payload survives validation byte for byte. + * The business schema itself owns business validation and parsing. The root + * only preserves the object's declared keys and unknown-key policy until that + * parse, so defaults, refinements, catchalls, and transforms each run once. + * Successful business output is then returned exactly as the original schema + * parsed it. */ export function withTerminalOutput>( schema: Schema ) { - const businessStatus = (schema.shape as Record).status; + const businessShape = schema.shape as Record; + const catchall = schema._zod.def.catchall; + const advertisedShape: Record = Object.fromEntries( + Object.entries(businessShape).map(([key, field]) => [key, field.optional()]) + ); + advertisedShape.status = + businessShape.status === undefined + ? elicitationTerminalStatusSchema.optional() + : z + .union([businessShape.status, elicitationTerminalStatusSchema]) + .optional(); - // Deriving the root from the business schema keeps its own unknown-key - // handling: a widened copy that stripped what the tool is allowed to emit - // would turn valid output into an error. - const root = schema.partial().extend({ - // A business schema that already carries `status` keeps its own values; - // the terminal ones are offered beside them rather than replacing them. - status: - businessStatus === undefined - ? elicitationTerminalStatusSchema.optional() - : z.union([businessStatus, elicitationTerminalStatusSchema]).optional(), + const advertisedRoot = + catchall === undefined + ? z.object(advertisedShape) + : z.object(advertisedShape).catchall(catchall); + const advertisedJSONSchema = z.toJSONSchema(advertisedRoot, { + target: 'draft-7', + io: 'input', }); + delete advertisedJSONSchema.$schema; - return root.superRefine((value, ctx) => { - const business = schema.safeParse(value); - if (business.success) { - // The original schema owns complete business output. A terminal word in - // its status field does not turn that valid value into a terminal result. - return; - } + const runtimeShape: Record = Object.fromEntries( + Object.keys(businessShape).map((key) => [key, z.unknown().optional()]) + ); + runtimeShape.status = z.unknown().optional(); - const record = value as Record; - const terminal = elicitationTerminalStatusSchema.safeParse(record.status); + // Preserve raw catchall values for the business parse. A stripping object + // must still strip undeclared keys before that parse, matching the original. + const root = + catchall === undefined + ? z.object(runtimeShape) + : z.looseObject(runtimeShape); + const businessOutputs = new WeakMap>(); - if (terminal.success) { - // A terminal payload carries the status and nothing else: a business - // field beside it would claim work that never ran. - for (const key of Object.keys(record)) { - if (key !== 'status') { - ctx.addIssue({ - code: 'custom', - path: [key], - message: `Unexpected field on a "${terminal.data}" terminal result.`, - }); + return root + .superRefine((value, ctx) => { + const business = schema.safeParse(value); + if (business.success) { + // Keep this result for the overwrite below. Parsing the original + // schema again would apply field transforms and defaults twice. + businessOutputs.set(value, business.data); + return; + } + + const record = value as Record; + const terminal = elicitationTerminalStatusSchema.safeParse(record.status); + + if (terminal.success) { + // A terminal payload carries the status and nothing else: a business + // field beside it would claim work that never ran. + for (const key of Object.keys(record)) { + if (key !== 'status') { + ctx.addIssue({ + code: 'custom', + path: [key], + message: `Unexpected field on a "${terminal.data}" terminal result.`, + }); + } } + return; } - return; - } - for (const issue of business.error.issues) { - ctx.addIssue({ ...issue }); - } - }); + for (const issue of business.error.issues) { + ctx.addIssue({ ...issue }); + } + }) + .overwrite((value) => businessOutputs.get(value) ?? value) + .meta(advertisedJSONSchema); } /** From da05e028254f3b6ba3cd64f6c84230fd393bf717 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Wed, 26 Aug 2026 18:21:29 +0200 Subject: [PATCH 11/11] Revert "fix: apply terminal output transforms once" This reverts commit 8a1fda5433729a36d49eeefda2a43638e4f0f90e. --- .../src/elicitations/terminal.test.ts | 49 -------- .../src/elicitations/terminal.ts | 107 +++++++----------- 2 files changed, 41 insertions(+), 115 deletions(-) diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts index ff7ce035..a835f4e0 100644 --- a/packages/mcp-server-supabase/src/elicitations/terminal.test.ts +++ b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts @@ -32,46 +32,6 @@ describe('elicitation output widening', () => { }); }); - test('applies business transforms, defaults, and refinements once', () => { - let transformCalls = 0; - let refinementCalls = 0; - const transformedSchema = z - .object({ - token: z.string().transform((value) => { - transformCalls += 1; - return { normalized: value.toUpperCase() }; - }), - status: z.literal('created').default('created'), - }) - .superRefine((value, ctx) => { - refinementCalls += 1; - if (value.token.normalized.length < 3) { - ctx.addIssue({ - code: 'custom', - path: ['token'], - message: 'Token is too short.', - }); - } - }); - const input = { token: 'abc' }; - const expected = transformedSchema.parse(input); - transformCalls = 0; - refinementCalls = 0; - - expect(withTerminalOutput(transformedSchema).parse(input)).toStrictEqual( - expected - ); - expect(transformCalls).toBe(1); - expect(refinementCalls).toBe(1); - expect( - withTerminalOutput(transformedSchema).safeParse({ token: 'x' }).success - ).toBe(false); - const jsonSchema = z.toJSONSchema(withTerminalOutput(transformedSchema), { - target: 'draft-7', - }); - expect(jsonSchema.properties?.token).toMatchObject({ type: 'string' }); - }); - test.each(['declined', 'cancelled'] as const)( 'keeps complete business output whose status is %s', (status) => { @@ -93,15 +53,6 @@ describe('elicitation output widening', () => { expect(jsonSchema.type).toBe('object'); expect(jsonSchema).not.toHaveProperty('anyOf'); expect(jsonSchema).not.toHaveProperty('oneOf'); - expect(jsonSchema.properties).toMatchObject({ - id: { type: 'string' }, - status: { - anyOf: [ - { type: 'string', const: 'created' }, - { type: 'string', enum: ['declined', 'cancelled'] }, - ], - }, - }); }); test('rejects a terminal status the schema does not advertise', () => { diff --git a/packages/mcp-server-supabase/src/elicitations/terminal.ts b/packages/mcp-server-supabase/src/elicitations/terminal.ts index 3c0a3985..4c68eb6a 100644 --- a/packages/mcp-server-supabase/src/elicitations/terminal.ts +++ b/packages/mcp-server-supabase/src/elicitations/terminal.ts @@ -29,84 +29,59 @@ export type ElicitationTerminalStatus = z.infer< * satisfy. A root-level union serializes to `anyOf` with no root `type`, * which fails the entire `tools/list` response. * - * The business schema itself owns business validation and parsing. The root - * only preserves the object's declared keys and unknown-key policy until that - * parse, so defaults, refinements, catchalls, and transforms each run once. - * Successful business output is then returned exactly as the original schema - * parsed it. + * The business schema itself passes through unchanged: an accepted execution + * is validated by the tool's own schema, not by the widened copy, so an + * incomplete business payload stays a rejection. The widened root is derived + * from that same schema, so whatever it says about undeclared fields still + * holds and an accepted payload survives validation byte for byte. */ export function withTerminalOutput>( schema: Schema ) { - const businessShape = schema.shape as Record; - const catchall = schema._zod.def.catchall; - const advertisedShape: Record = Object.fromEntries( - Object.entries(businessShape).map(([key, field]) => [key, field.optional()]) - ); - advertisedShape.status = - businessShape.status === undefined - ? elicitationTerminalStatusSchema.optional() - : z - .union([businessShape.status, elicitationTerminalStatusSchema]) - .optional(); + const businessStatus = (schema.shape as Record).status; - const advertisedRoot = - catchall === undefined - ? z.object(advertisedShape) - : z.object(advertisedShape).catchall(catchall); - const advertisedJSONSchema = z.toJSONSchema(advertisedRoot, { - target: 'draft-7', - io: 'input', + // Deriving the root from the business schema keeps its own unknown-key + // handling: a widened copy that stripped what the tool is allowed to emit + // would turn valid output into an error. + const root = schema.partial().extend({ + // A business schema that already carries `status` keeps its own values; + // the terminal ones are offered beside them rather than replacing them. + status: + businessStatus === undefined + ? elicitationTerminalStatusSchema.optional() + : z.union([businessStatus, elicitationTerminalStatusSchema]).optional(), }); - delete advertisedJSONSchema.$schema; - const runtimeShape: Record = Object.fromEntries( - Object.keys(businessShape).map((key) => [key, z.unknown().optional()]) - ); - runtimeShape.status = z.unknown().optional(); + return root.superRefine((value, ctx) => { + const business = schema.safeParse(value); + if (business.success) { + // The original schema owns complete business output. A terminal word in + // its status field does not turn that valid value into a terminal result. + return; + } - // Preserve raw catchall values for the business parse. A stripping object - // must still strip undeclared keys before that parse, matching the original. - const root = - catchall === undefined - ? z.object(runtimeShape) - : z.looseObject(runtimeShape); - const businessOutputs = new WeakMap>(); + const record = value as Record; + const terminal = elicitationTerminalStatusSchema.safeParse(record.status); - return root - .superRefine((value, ctx) => { - const business = schema.safeParse(value); - if (business.success) { - // Keep this result for the overwrite below. Parsing the original - // schema again would apply field transforms and defaults twice. - businessOutputs.set(value, business.data); - return; - } - - const record = value as Record; - const terminal = elicitationTerminalStatusSchema.safeParse(record.status); - - if (terminal.success) { - // A terminal payload carries the status and nothing else: a business - // field beside it would claim work that never ran. - for (const key of Object.keys(record)) { - if (key !== 'status') { - ctx.addIssue({ - code: 'custom', - path: [key], - message: `Unexpected field on a "${terminal.data}" terminal result.`, - }); - } + if (terminal.success) { + // A terminal payload carries the status and nothing else: a business + // field beside it would claim work that never ran. + for (const key of Object.keys(record)) { + if (key !== 'status') { + ctx.addIssue({ + code: 'custom', + path: [key], + message: `Unexpected field on a "${terminal.data}" terminal result.`, + }); } - return; } + return; + } - for (const issue of business.error.issues) { - ctx.addIssue({ ...issue }); - } - }) - .overwrite((value) => businessOutputs.get(value) ?? value) - .meta(advertisedJSONSchema); + for (const issue of business.error.issues) { + ctx.addIssue({ ...issue }); + } + }); } /**