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..911f3a54 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/capability.test.ts @@ -0,0 +1,133 @@ +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.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( + modern({ elicitation: { url: {} } }), + servingPath + ) + ).toStrictEqual({ formElicitation: false, reason: 'capability' }); + expect( + resolveElicitationAvailability(modern({}), servingPath) + ).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: {} }), { + 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..bf574488 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/capability.ts @@ -0,0 +1,78 @@ +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: unknown = ctx.clientCapabilities?.elicitation; + // A mode-less `elicitation: {}` predates the mode split and means every + // 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); + 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' }; + } + + return { formElicitation: true, reason: 'available' }; +} 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..ac8e8979 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/codec.ts @@ -0,0 +1,280 @@ +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 (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) { + throw new TypeError('Canonical arguments must be JSON-serializable'); + } + return encoded; + } + + if (Array.isArray(value)) { + 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) { + 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 < right ? -1 : left > right ? 1 : 0)); + + 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/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..d2aabe2e --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/policy.ts @@ -0,0 +1,42 @@ +import type { InputResponseView } from '@modelcontextprotocol/server'; + +/** + * 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; + /** 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..dda54a75 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/runtime.test.ts @@ -0,0 +1,1019 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { + createMcpHandler, + inputRequired, + type ElicitResult, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { + createMcpServer, + tool, + type McpServerOptions, + type ToolRequestContext, +} from '@supabase/mcp-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { + canonicalArgumentsDigest, + canonicalJson, + createSignedStateCodec, + createStateSigner, +} from './codec.js'; +import type { ElicitationPolicy, ElicitationPreparation } 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 }; +/** `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 +>[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, + canonicalArguments: ({ name }) => ({ name }), + prepare: async ({ name }) => ({ + type: 'elicit', + proposal: { name, serial: 1 }, + }), + inputRequests: (proposal) => ({ + confirm: inputRequired.elicit({ + 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) => { + 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: { serial: proposal.serial } }; + }, + }; +} + +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; + /** Whether the test client declares form elicitation support. */ + formElicitationCapability?: boolean; + /** Sends each continuation round twice, as a client retry would. */ + duplicateRetry?: boolean; + answers?: ElicitResult[]; + onElicit?: () => void; + transformRequest?: (body: RequestBody) => void; + toolName?: string; +}; + +function setupRuntime(options: SetupOptions = {}) { + // 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.serial ?? 'unprompted'}`, + })); + const policyCalls: PolicyCall[] = []; + + function buildHandler(runtimeOptions: Partial) { + const runtime = createElicitationRuntime({ + actorId: ACTOR_ID, + stateKey: STATE_KEY, + formDeliveryAvailable: true, + ...runtimeOptions, + }); + const policy: ElicitationPolicy = { + ...basePolicy(), + prepare, + ...options.policy, + }; + + 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, + // 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', + 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); + 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); + }, + }); + + const client = new Client( + { name: 'runtime-test-client', version: '1.2.3' }, + { + capabilities: + options.formElicitationCapability === false ? {} : { elicitation: {} }, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + } + ); + 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( + () => client.close(), + () => handler.close(), + ...(continuationHandler === undefined + ? [] + : [() => continuationHandler.close()]) + ); + return client; + }); + + return { client: connected, execute, policyCalls, prepare }; +} + +/** + * 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('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 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({ + 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[] = []; + const { client, execute, prepare } = setupRuntime({ + onElicit: () => { + asked.push('asked'); + }, + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + // 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); + }); + + 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 #1', + requestedSchema: { type: 'object', properties: {} }, + }, + ]); + expect(result.structuredContent).toStrictEqual({ id: 'demo:1' }); + 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: { serial: null }, + }), + }, + answers: [], + }); + + const result = await (await client).callTool({ + name: TOOL, + arguments: { name: 'demo' }, + }); + + expect(result.structuredContent).toStrictEqual({ id: 'demo:unprompted' }); + 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)).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({ + 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)).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({ + outcome: 'rejected', + reason: 'arguments', + }); + }); +}); + +describe('continuation authority', () => { + test('answers capability loss mid-flow instead of switching authority path', async () => { + const { client, execute, prepare, policyCalls } = setupRuntime({ + // 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)).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. + expect(execute.mock.calls).toHaveLength(0); + expect(prepare.mock.calls).toHaveLength(1); + expect(policyCalls.at(-1)?.telemetry).toMatchObject({ + outcome: 'rejected', + reason: 'opt_out', + }); + }); +}); + +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:1' }); + 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:1' }); + // 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)); + }); +}); + +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.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' }, + }); + + 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('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 }, + policy: { + 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({ + runtime: { formDeliveryAvailable: false }, + policy: { + 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 new file mode 100644 index 00000000..8b9346f8 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/runtime.ts @@ -0,0 +1,347 @@ +import { + inputRequired, + inputResponse, + type CallToolResult, + 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, 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.', + 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, 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, 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; + +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; + /** + * 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; +}; + +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, + }; + const availability = (ctx: ToolRequestContext) => + resolveElicitationAvailability(ctx, servingFacts); + + function recover( + 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, + telemetryReason: string = recoveryReason + ): ToolPolicyDecision { + return { + type: 'result', + result: recoveryResult(RECOVERY_TEXT[recoveryReason]), + telemetry: { + ...telemetry, + outcome: 'rejected', + reason: telemetryReason, + }, + }; + } + + return { + requestState: { verify: state.verify }, + + availability, + + 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); + } + // 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); + 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( + 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. + // The reissued round is signed afresh, so it opens its own + // lifetime and its own Interaction ID. + 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) => + availability(ctx).formElicitation + ? withTerminalOutput(schema) + : undefined, + + resolve: async (args, ctx) => { + // 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( + policy.canonicalArguments(args) + ); + + if (verified !== undefined) { + 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') { + 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. + const currentAvailability = availability(ctx); + if (!currentAvailability.formElicitation) { + return recover( + 'unsupported_elicitation', + identity, + currentAvailability.reason + ); + } + + return elicit(preparation.proposal, argsDigest, ctx); + }, + }; + }, + }; +} 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..8ed39e16 --- /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('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, + clock: () => now, + }); + + const minted = await state.mint(claims, context()); + now += 120_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 }; + }, + }; +} 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..a835f4e0 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/terminal.test.ts @@ -0,0 +1,125 @@ +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'), +}); + +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); + + 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.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', + }); + + 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..4c68eb6a --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations/terminal.ts @@ -0,0 +1,117 @@ +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 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); + + 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; + } + + 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 }], + }; +}