From c8fd5b44f371549c89edbd575f5e85cb6960be1b Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 15 Oct 2026 12:15:27 -0300 Subject: [PATCH] feat(sep-1036): URL mode elicitation conformance scenarios Add client and server scenarios for URL mode elicitation on the 2026-07-28 revision, where elicitation requests travel inside an InputRequiredResult (SEP-2322). - elicitation-sep1036-client-url-mode: rounds of URL-mode requests, mixed URL and form inputs, requests without mode, no prefetch of a declined URL, and retries the server holds open (JSON and SSE). - elicitation-sep1036-url-mode: URL-mode request shape and url validity, decline/cancel handling, no unsupported modes, -32021 for clients without URL mode, and no -32042. - sep-1036.yaml requirement rows; sep-2322.yaml gains client constructs-inputs rows and the decline/cancel row. - everything-client handler (plus SSE response parsing), everything-server test_url_elicitation tool and form-tool gating, a broken server, and negative tests. Closes #542. Co-Authored-By: Claude Opus 5.5 --- .../clients/typescript/everything-client.ts | 106 +- .../servers/typescript/everything-server.ts | 87 ++ .../sep-1036-url-elicitation-broken-server.ts | 142 ++ .../client/elicitation-url-mode.test.ts | 325 +++++ src/scenarios/client/elicitation-url-mode.ts | 1141 +++++++++++++++++ src/scenarios/index.ts | 8 + .../server/elicitation-url-mode.test.ts | 230 ++++ src/scenarios/server/elicitation-url-mode.ts | 631 +++++++++ src/seps/sep-1036.yaml | 99 ++ src/seps/sep-2322.yaml | 14 + 10 files changed, 2781 insertions(+), 2 deletions(-) create mode 100644 examples/servers/typescript/sep-1036-url-elicitation-broken-server.ts create mode 100644 src/scenarios/client/elicitation-url-mode.test.ts create mode 100644 src/scenarios/client/elicitation-url-mode.ts create mode 100644 src/scenarios/server/elicitation-url-mode.test.ts create mode 100644 src/scenarios/server/elicitation-url-mode.ts create mode 100644 src/seps/sep-1036.yaml diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index b3ccbed3..a270eac5 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -115,6 +115,34 @@ const STATELESS_META_BASE = { } }; +/** + * Read the JSON-RPC response to request `id`. A server may answer with + * application/json or with an SSE stream, and the client MUST support both; + * on a stream, notifications and keep-alive comments can precede the response. + */ +async function readJsonRpcResponse( + response: Response, + id: number +): Promise { + if (!response.headers.get('content-type')?.includes('text/event-stream')) { + return response.json(); + } + const text = await response.text(); + for (const event of text.split(/\r?\n\r?\n/)) { + const data = event + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trimStart()) + .join('\n'); + if (!data) continue; + const message = JSON.parse(data); + if (message.id === id && ('result' in message || 'error' in message)) { + return message; + } + } + throw new Error(`SSE stream ended without a response to request ${id}`); +} + let _nextStatelessId = 1; async function statelessRequest( serverUrl: string, @@ -126,6 +154,7 @@ async function statelessRequest( ...STATELESS_META_BASE, ...((params._meta as object | undefined) ?? {}) }; + const id = _nextStatelessId++; const response = await fetch(serverUrl, { method: 'POST', headers: { @@ -137,12 +166,12 @@ async function statelessRequest( }, body: JSON.stringify({ jsonrpc: '2.0', - id: _nextStatelessId++, + id, method, params: { ...params, _meta } }) }); - const body = await response.json(); + const body = await readJsonRpcResponse(response, id); if (body.error) { throw new Error( `${method} failed: ${body.error.code} ${body.error.message}` @@ -1072,6 +1101,79 @@ async function runMRTRClient(serverUrl: string): Promise { registerScenario('sep-2322-client-request-state', runMRTRClient); +// ============================================================================ +// URL mode elicitation, client side (SEP-1036, 2026-07-28) +// +// Scenario contract (the scenario description has the full version): +// - Declare `elicitation: { form: {}, url: {} }` on every request. +// - Call every listed tool once, in order. While the result is an +// InputRequiredResult, answer every input request and retry, echoing +// requestState. +// - URL mode: decline when the message starts with "DECLINE:", otherwise +// accept. The URL is never fetched or opened. +// - Form mode (explicit, or `mode` omitted): accept with { confirmed: true }. +// ============================================================================ + +const URL_MODE_META = { + 'io.modelcontextprotocol/clientCapabilities': { + ...STATELESS_META_BASE['io.modelcontextprotocol/clientCapabilities'], + elicitation: { form: {}, url: {} } + } +}; + +function answerElicitation( + params: Record +): Record { + if (params.mode === 'url') { + // Stands in for the user's consent decision. This client never fetches or + // opens the URL. + return String(params.message).startsWith('DECLINE:') + ? { action: 'decline' } + : { action: 'accept' }; + } + return { action: 'accept', content: { confirmed: true } }; +} + +async function runElicitationUrlModeClient(serverUrl: string): Promise { + const list = await statelessRequest(serverUrl, 'tools/list', { + _meta: URL_MODE_META + }); + for (const tool of list?.tools ?? []) { + let params: Record = { name: tool.name, arguments: {} }; + // Bounded so a misbehaving server cannot keep the client retrying forever. + for (let attempt = 1; attempt <= 10; attempt++) { + const result = await statelessRequest(serverUrl, 'tools/call', { + ...params, + // Lets a server that holds the retry open report progress meanwhile. + _meta: { ...URL_MODE_META, progressToken: `${tool.name}-${attempt}` } + }); + if (result?.resultType !== 'input_required') break; + + const inputResponses: Record = {}; + for (const [key, request] of Object.entries( + (result.inputRequests ?? {}) as Record< + string, + { method: string; params?: Record } + > + )) { + if (request.method === 'elicitation/create') { + inputResponses[key] = answerElicitation(request.params ?? {}); + } + } + params = { name: tool.name, arguments: {}, inputResponses }; + if (result.requestState !== undefined) { + params.requestState = result.requestState; + } + } + logger.debug(`${tool.name}: flow completed`); + } +} + +registerScenario( + 'elicitation-sep1036-client-url-mode', + runElicitationUrlModeClient +); + // ============================================================================ // WIF JWT-bearer scenario // ============================================================================ diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index c76c1f56..c76e7003 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1486,6 +1486,12 @@ app.post('/mcp', async (req, res) => { 'MRTR: respects client capabilities in inputRequests', inputSchema: { type: 'object', properties: {} } }, + { + name: 'test_url_elicitation', + description: + 'SEP-1036: needs URL-mode elicitation; -32021 if the client did not declare it', + inputSchema: { type: 'object', properties: {} } + }, { name: 'test_streaming_elicitation', description: @@ -1720,9 +1726,90 @@ app.post('/mcp', async (req, res) => { }); } + // ===== SEP-1036 URL mode elicitation ===== + + if (name === 'test_url_elicitation') { + // This tool can only work through URL mode. A server MUST NOT send a + // mode the client did not declare, so a client without URL mode gets + // -32021 (HTTP 400) instead. + const elicitation = + meta['io.modelcontextprotocol/clientCapabilities']?.elicitation; + if (!elicitation?.url) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32021, + message: 'MissingRequiredClientCapabilityError', + data: { requiredCapabilities: { elicitation: { url: {} } } } + } + }); + } + const answer = inputResponses?.['connect_account'] as + | { action?: string } + | undefined; + if (answer) { + // `accept` only means the user consented to open the URL. A real + // server would check its own state for the out-of-band result; this + // fixture has no out-of-band page, so it ends the call either way. + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: + answer.action === 'accept' + ? { content: [{ type: 'text', text: 'Account connected.' }] } + : { + content: [ + { + type: 'text', + text: `Account not connected: the user chose ${answer.action}.` + } + ], + isError: true + } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + connect_account: { + method: 'elicitation/create', + params: { + mode: 'url', + message: 'Connect your account to continue.', + url: `http://localhost:${PORT}/connect-account` + } + } + } + } + }); + } + // ===== SEP-2322 MRTR tools/call handlers ===== if (name === 'test_input_required_result_elicitation') { + // Sends a form-mode request, so it needs form mode declared (an empty + // `elicitation: {}` counts as form mode only). + const elicitation = + meta['io.modelcontextprotocol/clientCapabilities']?.elicitation; + const declaresForm = + elicitation !== undefined && + (Object.keys(elicitation).length === 0 || + elicitation.form !== undefined); + if (!declaresForm) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32021, + message: 'MissingRequiredClientCapabilityError', + data: { requiredCapabilities: { elicitation: { form: {} } } } + } + }); + } if (inputResponses?.['user_name']) { const userName = getMrtInputText(inputResponses['user_name'], 'name'); return sendStatelessJson(res, method, { diff --git a/examples/servers/typescript/sep-1036-url-elicitation-broken-server.ts b/examples/servers/typescript/sep-1036-url-elicitation-broken-server.ts new file mode 100644 index 00000000..572c5ea3 --- /dev/null +++ b/examples/servers/typescript/sep-1036-url-elicitation-broken-server.ts @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +/** + * SEP-1036 URL Mode Elicitation Broken Server — Negative Test Case + * + * Deliberately violates the server-side URL mode requirements (2026-07-28): + * 1. Sends its URL-mode request without `mode: "url"`, and with a url that is + * not a valid absolute URI + * 2. Sends that request whatever modes the client declared, so a form-only + * client gets it too, and never answers -32021 + * 3. Fails with an internal error (-32603) when the user declines + * 4. Answers a cancel with -32042, the URLElicitationRequiredError that only + * exists in 2025-11-25 + * 5. Sends a form-mode request to a client that declared URL mode only + * + * The elicitation-sep1036-url-mode scenario should emit FAILURE against this + * server. + */ + +import express from 'express'; + +const PORT = parseInt(process.env.PORT || '3012', 10); + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const body = req.body as { + id: number; + method: string; + params?: Record; + }; + const params = body.params ?? {}; + const inputResponses = params.inputResponses as + | Record + | undefined; + + if (body.method !== 'tools/call') { + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { code: -32601, message: `Method not found: ${body.method}` } + }); + return; + } + + switch (params.name) { + case 'test_url_elicitation': { + const answer = inputResponses?.['connect_account']; + + // BUG 3: An unhandled decline surfaces as an internal error. + if (answer?.action === 'decline') { + res.status(500).json({ + jsonrpc: '2.0', + id: body.id, + error: { code: -32603, message: 'Internal error: unhandled decline' } + }); + return; + } + + // BUG 4: The 2025-11-25 error, which 2026-07-28 servers MUST NOT emit. + if (answer?.action === 'cancel') { + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { + code: -32042, + message: 'This request requires more information.', + data: { + elicitations: [ + { + mode: 'url', + elicitationId: 'connect-account', + url: 'https://example.com/connect', + message: 'Connect your account to continue.' + } + ] + } + } + }); + return; + } + + // BUGS 1 and 2: No `mode`, an invalid url, and no capability check. + res.json({ + jsonrpc: '2.0', + id: body.id, + result: { + resultType: 'input_required', + inputRequests: { + connect_account: { + method: 'elicitation/create', + params: { + message: 'Connect your account to continue.', + url: 'connect your account here' + } + } + } + } + }); + return; + } + + case 'test_input_required_result_elicitation': { + // BUG 5: Sends form mode without checking the declared modes. + res.json({ + jsonrpc: '2.0', + id: body.id, + result: { + resultType: 'input_required', + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + } + } + }); + return; + } + + default: + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { code: -32602, message: `Unknown tool: ${params.name}` } + }); + } +}); + +app.listen(PORT, () => { + console.log( + `sep-1036-url-elicitation-broken-server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/src/scenarios/client/elicitation-url-mode.test.ts b/src/scenarios/client/elicitation-url-mode.test.ts new file mode 100644 index 00000000..7354be2e --- /dev/null +++ b/src/scenarios/client/elicitation-url-mode.test.ts @@ -0,0 +1,325 @@ +/** + * SEP-1036 URL mode elicitation, client side (2026-07-28). + * + * Positive: the everything-client passes every check. + * Negative: inline clients that each break one requirement produce a FAILURE + * for that check, and a client without URL mode is skipped, not failed. + */ +import { describe, test, expect } from 'vitest'; +import { + runClientAgainstScenario, + InlineClientRunner +} from './auth/test_helpers/testClient'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { ElicitationClientUrlModeScenario } from './elicitation-url-mode'; +import { testScenarioContext } from '../../mock-server/testing'; +import type { ConformanceCheck } from '../../types'; + +const SCENARIO = 'elicitation-sep1036-client-url-mode'; +const SPEC_VERSION = '2026-07-28'; + +interface ClientOptions { + /** The `elicitation` capability to declare. */ + elicitation?: unknown; + /** Tools to call, in order. Defaults to every listed tool. */ + tools?: string[]; + /** Replaces the contract's answer. Return undefined to leave a request unanswered. */ + answer?: ( + params: Record + ) => Record | undefined; + /** Fetch every URL-mode URL as soon as the request arrives. */ + prefetch?: boolean; + /** Resend the first retry's inputResponses on every later retry. */ + replayFirstAnswers?: boolean; + /** Echo the first requestState on every later retry. */ + echoFirstState?: boolean; + /** Abort any request that takes longer than this. */ + timeoutMs?: number; +} + +/** The answers the scenario contract asks for. */ +function contractAnswer( + params: Record +): Record { + if (params.mode === 'url') { + return String(params.message).startsWith('DECLINE:') + ? { action: 'decline' } + : { action: 'accept' }; + } + return { action: 'accept', content: { confirmed: true } }; +} + +/** + * A minimal 2026-07-28 client that follows the scenario contract unless an + * option breaks it. + */ +function urlModeClient(options: ClientOptions = {}) { + return async (serverUrl: string): Promise => { + let nextId = 1; + const send = async ( + method: string, + params: Record + ): Promise => { + const id = nextId++; + try { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': SPEC_VERSION + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': SPEC_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'url-mode-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + elicitation: options.elicitation ?? { form: {}, url: {} } + } + } + } + }), + signal: options.timeoutMs + ? AbortSignal.timeout(options.timeoutMs) + : undefined + }); + const text = await response.text(); + if (!response.headers.get('content-type')?.includes('event-stream')) { + return JSON.parse(text); + } + return text + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => JSON.parse(line.slice('data:'.length))) + .find((message) => message.id === id); + } catch { + // Timed out: give up on this call, as a client with a short timeout would. + return undefined; + } + }; + + const list = await send('tools/list', {}); + const tools: string[] = + options.tools ?? + (list?.result?.tools ?? []).map((tool: { name: string }) => tool.name); + + for (const name of tools) { + let params: Record = { name, arguments: {} }; + let firstAnswers: Record | undefined; + let firstState: string | undefined; + for (let attempt = 1; attempt <= 10; attempt++) { + const result = (await send('tools/call', params))?.result; + if (result?.resultType !== 'input_required') break; + + const inputResponses: Record = {}; + for (const [key, request] of Object.entries( + result.inputRequests as Record< + string, + { params: Record } + > + )) { + if (options.prefetch && request.params.mode === 'url') { + await fetch(request.params.url as string); + } + const answer = (options.answer ?? contractAnswer)(request.params); + if (answer !== undefined) inputResponses[key] = answer; + } + firstAnswers ??= inputResponses; + firstState ??= result.requestState; + + params = { + name, + arguments: {}, + inputResponses: options.replayFirstAnswers + ? firstAnswers + : inputResponses, + requestState: options.echoFirstState + ? firstState + : result.requestState + }; + } + } + }; +} + +/** Run a client against a fresh scenario instance and return every check. */ +async function runAgainstScenario( + client: (serverUrl: string) => Promise +): Promise { + const scenario = new ElicitationClientUrlModeScenario(); + const { serverUrl } = await scenario.start(testScenarioContext(SPEC_VERSION)); + try { + await client(serverUrl); + return scenario.getChecks(); + } finally { + await scenario.stop(); + } +} + +function checkNamed( + checks: ConformanceCheck[], + name: string +): ConformanceCheck { + const check = checks.find((c) => c.name === name); + if (!check) throw new Error(`Check ${name} not emitted`); + return check; +} + +/** A FAILURE that reports a violation, not a missing prerequisite. */ +function expectViolation(check: ConformanceCheck): void { + expect(check.status, check.errorMessage).toBe('FAILURE'); + expect(check.details?.untestable).toBeUndefined(); +} + +describe('elicitation-sep1036-client-url-mode (SEP-1036)', () => { + test('everything-client passes every check', async () => { + const clientFn = getHandler(SCENARIO); + if (!clientFn) { + throw new Error(`No handler registered for scenario: ${SCENARIO}`); + } + await runClientAgainstScenario(new InlineClientRunner(clientFn), SCENARIO, { + specVersion: SPEC_VERSION + }); + }, 30000); + + test('a client that fetches a declined URL fails no-url-prefetch', async () => { + const checks = await runAgainstScenario( + urlModeClient({ tools: ['test_url_elicitation_decline'], prefetch: true }) + ); + expectViolation(checkNamed(checks, 'ClientNoUrlPrefetch')); + }); + + test('a client that remembers consent fails constructs-inputs on the third round', async () => { + const checks = await runAgainstScenario( + urlModeClient({ + tools: ['test_url_elicitation_rounds'], + answer: (params) => + params.mode === 'url' ? { action: 'accept' } : contractAnswer(params) + }) + ); + const check = checkNamed(checks, 'ClientConstructsInputsUrlRounds'); + expectViolation(check); + expect(check.errorMessage).toContain('Round 3'); + expect(checkNamed(checks, 'ClientRequestStateEchoedUrlRounds').status).toBe( + 'SUCCESS' + ); + }); + + test('a client that resends its first answers fails constructs-inputs', async () => { + const checks = await runAgainstScenario( + urlModeClient({ + tools: ['test_url_elicitation_rounds'], + replayFirstAnswers: true + }) + ); + const check = checkNamed(checks, 'ClientConstructsInputsUrlRounds'); + expectViolation(check); + expect(check.errorMessage).toContain("did not answer 'connect_service_b'"); + }); + + test('a client that echoes a stale requestState fails request-state-echoed', async () => { + const checks = await runAgainstScenario( + urlModeClient({ + tools: ['test_url_elicitation_rounds'], + echoFirstState: true + }) + ); + const check = checkNamed(checks, 'ClientRequestStateEchoedUrlRounds'); + expectViolation(check); + expect(check.errorMessage).toContain('requestState from round 1'); + expect(checkNamed(checks, 'ClientConstructsInputsUrlRounds').status).toBe( + 'SUCCESS' + ); + }); + + test('a client that answers only the URL request fails constructs-inputs-mixed', async () => { + const checks = await runAgainstScenario( + urlModeClient({ + tools: ['test_url_elicitation_mixed'], + answer: (params) => + params.mode === 'form' ? undefined : contractAnswer(params) + }) + ); + expectViolation(checkNamed(checks, 'ClientConstructsInputsMixedModes')); + }); + + test('a client that treats a request without mode as URL mode fails missing-mode-is-form', async () => { + const checks = await runAgainstScenario( + urlModeClient({ + tools: ['test_elicitation_no_mode'], + // Picks URL mode because the request carries a url field. + answer: (params) => + params.mode === undefined && params.url !== undefined + ? { action: 'accept' } + : contractAnswer(params) + }) + ); + expectViolation(checkNamed(checks, 'ClientMissingModeIsForm')); + }); + + test('a client that times out held retries fails both held-retry gates', async () => { + const checks = await runAgainstScenario( + urlModeClient({ + tools: [ + 'test_url_elicitation_held_json', + 'test_url_elicitation_held_sse' + ], + timeoutMs: 1000 + }) + ); + expectViolation(checkNamed(checks, 'ClientHeldJsonRetryComplete')); + expectViolation(checkNamed(checks, 'ClientHeldSseRetryComplete')); + }); + + test('a client declaring elicitation with neither mode fails supports-declared-mode', async () => { + const checks = await runAgainstScenario( + urlModeClient({ elicitation: { bogus: {} }, tools: [] }) + ); + expectViolation(checkNamed(checks, 'ClientDeclaresElicitationMode')); + }); + + test('a form-only client is skipped for URL mode, not failed', async () => { + const checks = await runAgainstScenario(urlModeClient({ elicitation: {} })); + for (const name of [ + 'ClientConstructsInputsUrlRounds', + 'ClientRequestStateEchoedUrlRounds', + 'ClientConstructsInputsMixedModes', + 'ClientNoUrlPrefetch', + 'ClientHeldJsonRetryComplete', + 'ClientHeldSseRetryComplete' + ]) { + expect(checkNamed(checks, name).status, name).toBe('SKIPPED'); + } + expect(checkNamed(checks, 'ClientDeclaresElicitationMode').status).toBe( + 'SUCCESS' + ); + expect(checkNamed(checks, 'ClientMissingModeIsForm').status).toBe( + 'SUCCESS' + ); + }); + + test('a client that never calls the tools fails as untestable', async () => { + const checks = await runAgainstScenario(urlModeClient({ tools: [] })); + for (const name of [ + 'ClientConstructsInputsUrlRounds', + 'ClientRequestStateEchoedUrlRounds', + 'ClientConstructsInputsMixedModes', + 'ClientMissingModeIsForm', + 'ClientNoUrlPrefetch', + 'ClientHeldJsonRetryComplete', + 'ClientHeldSseRetryComplete' + ]) { + const check = checkNamed(checks, name); + expect(check.status, name).toBe('FAILURE'); + expect(check.details?.untestable, name).toBe(true); + } + }); +}); diff --git a/src/scenarios/client/elicitation-url-mode.ts b/src/scenarios/client/elicitation-url-mode.ts new file mode 100644 index 00000000..22e7dad9 --- /dev/null +++ b/src/scenarios/client/elicitation-url-mode.ts @@ -0,0 +1,1141 @@ +/** + * SEP-1036: URL mode elicitation, client side. + * + * Targets the 2026-07-28 revision only. There, URL-mode requests arrive inside + * an InputRequiredResult (MRTR, SEP-2322), and the 2025-11-25 elicitationId, + * notifications/elicitation/complete, and -32042 error no longer exist. + * + * The harness is the MCP server. Each tool answers with InputRequiredResults + * and grades how the client answers them and retries: + * - test_url_elicitation_decline: a URL the client declines must never be + * fetched + * - test_url_elicitation_rounds: three URL requests in a row; every retry must + * answer the newest request and echo the newest requestState + * - test_elicitation_no_mode: a request without `mode` is form mode, even when + * it carries a `url` field + * - test_url_elicitation_mixed: a URL request and a form request in one result + * - test_url_elicitation_held_json / test_url_elicitation_held_sse: the server + * holds the accepted retry open before answering, which the URL Mode Flow + * allows ("It may need to block until the request is fulfilled") + */ + +import http from 'http'; +import express, { Request, Response } from 'express'; +import { randomUUID } from 'crypto'; +import type { ScenarioContext } from '../../mock-server'; +import { + validateStatelessRequest, + withRequiredDraftResultFields +} from '../../mock-server'; +import { STATELESS_SPEC_VERSIONS } from '../../connection/select'; +import { validateWireMessage } from '../../validation/wire-schema'; +import type { + ConformanceCheck, + Scenario, + ScenarioUrls, + SpecReference, + SpecVersion +} from '../../types'; +import { untestableCheck } from '../untestable'; + +const SPEC_VERSION = '2026-07-28'; +const SPEC_BASE = `https://modelcontextprotocol.io/specification/${SPEC_VERSION}`; + +const CAPABILITIES_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-Capabilities', + url: `${SPEC_BASE}/client/elicitation#capabilities` + } +]; +const ELICITATION_REQUESTS_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-Elicitation-Requests', + url: `${SPEC_BASE}/client/elicitation#elicitation-requests` + } +]; +const SAFE_URL_HANDLING_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-Safe-URL-Handling', + url: `${SPEC_BASE}/client/elicitation#safe-url-handling` + } +]; +const MRTR_CLIENT_REFERENCES: SpecReference[] = [ + { + id: 'SEP-2322-Client-Requirements', + url: `${SPEC_BASE}/basic/patterns/mrtr#client-requirements-basic-workflow` + } +]; +const URL_MODE_FLOW_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-URL-Mode-Flow', + url: `${SPEC_BASE}/client/elicitation#url-mode-flow` + }, + { + id: 'Streamable-HTTP-Receiving-Messages', + url: `${SPEC_BASE}/basic/transports/streamable-http#receiving-messages` + } +]; + +const DECLINE_TOOL = 'test_url_elicitation_decline'; +const ROUNDS_TOOL = 'test_url_elicitation_rounds'; +const NO_MODE_TOOL = 'test_elicitation_no_mode'; +const MIXED_TOOL = 'test_url_elicitation_mixed'; +const HELD_JSON_TOOL = 'test_url_elicitation_held_json'; +const HELD_SSE_TOOL = 'test_url_elicitation_held_sse'; + +/** The client must decline a URL-mode request whose message starts with this. */ +const DECLINE_PREFIX = 'DECLINE:'; + +/** How long the server holds an accepted retry before answering it. */ +const HOLD_MS = 5000; + +/** Gap between SSE keep-alive comments while a retry is held. */ +const KEEP_ALIVE_MS = 1000; + +/** + * Listed in the order the client calls them. The decline tool goes first so a + * prefetch has the whole run to arrive. + */ +const TOOLS = [ + { + name: DECLINE_TOOL, + description: + 'Sends a URL-mode request the client declines. The URL must never be fetched.' + }, + { + name: ROUNDS_TOOL, + description: + 'Sends three URL-mode requests in a row, to be answered accept, accept, decline.' + }, + { + name: NO_MODE_TOOL, + description: + 'Sends an elicitation request without `mode` (and with a stray `url`). It is form mode.' + }, + { + name: MIXED_TOOL, + description: + 'Sends a URL-mode request and a form-mode request in one InputRequiredResult.' + }, + { + name: HELD_JSON_TOOL, + description: `Holds the accepted retry open for ${HOLD_MS / 1000} seconds, then answers with application/json.` + }, + { + name: HELD_SSE_TOOL, + description: `Holds the accepted retry open for ${HOLD_MS / 1000} seconds on an SSE stream with keep-alives, then answers.` + } +].map((tool) => ({ + ...tool, + inputSchema: { type: 'object' as const, properties: {} } +})); + +/** + * The modes each tool's input requests use. A tool whose modes the client did + * not declare answers -32021 instead of sending them. + */ +const REQUIRED_MODES: Record> = { + [DECLINE_TOOL]: ['url'], + [ROUNDS_TOOL]: ['url'], + [NO_MODE_TOOL]: ['form'], + [MIXED_TOOL]: ['url', 'form'], + [HELD_JSON_TOOL]: ['url'], + [HELD_SSE_TOOL]: ['url'] +}; + +type ElicitAction = 'accept' | 'decline' | 'cancel'; + +/** + * The rounds tool's script. Each round is a new input request the client must + * build an answer for. Round 3 sends round 1's URL again with a message that + * asks for a decline, so a client that reuses an earlier answer instead of + * asking its handler gives the wrong one. + */ +const ROUNDS: Array<{ + key: string; + path: string; + message: string; + expected: ElicitAction; +}> = [ + { + key: 'connect_service_a', + path: '/connect/service-a', + message: 'Connect your Service A account to continue.', + expected: 'accept' + }, + { + key: 'connect_service_b', + path: '/connect/service-b', + message: 'Service A is connected. Now connect your Service B account.', + expected: 'accept' + }, + { + key: 'reconnect_service_a', + path: '/connect/service-a', + message: `${DECLINE_PREFIX} Service A needs to be connected again.`, + expected: 'decline' + } +]; + +const DECLINE_KEY = 'connect_service_c'; +const NO_MODE_KEY = 'confirm'; +const MIXED_URL_KEY = 'connect_service_d'; +const MIXED_FORM_KEY = 'confirm_details'; +const HELD_REQUESTS: Record = { + [HELD_JSON_TOOL]: { key: 'connect_service_e', path: '/connect/service-e' }, + [HELD_SSE_TOOL]: { key: 'connect_service_f', path: '/connect/service-f' } +}; + +const CONFIRM_SCHEMA = { + type: 'object', + properties: { confirmed: { type: 'boolean', description: 'Confirm?' } }, + required: ['confirmed'] +}; + +interface Retry { + inputResponses: Record; + requestState?: string; +} + +/** What one tool saw during the run. */ +interface ToolRecord { + called: boolean; + /** Why the tool answered -32021 instead of sending its input requests. */ + missingCapability?: string; + /** Fresh calls after the first. Each one restarts the tool's flow. */ + restarts: number; + /** The requestState sent with each InputRequiredResult, in order. */ + sentStates: string[]; + retries: Retry[]; +} + +/** One held retry: when the client gave up on it, or when it was answered. */ +interface HeldAttempt { + cancelledAfterMs?: number; + answeredAfterMs?: number; +} + +/** A request that reached one of the elicitation URL origins. */ +interface UrlHit { + method: string; + path: string; + at: string; +} + +function actionOf(answer: unknown): ElicitAction | undefined { + if (typeof answer !== 'object' || answer === null) return undefined; + const action = (answer as Record).action; + return action === 'accept' || action === 'decline' || action === 'cancel' + ? action + : undefined; +} + +/** + * The modes a declared `elicitation` capability covers. An empty object means + * form mode only, for clients written before URL mode existed. + */ +function declaredModes(elicitation: unknown): { form: boolean; url: boolean } { + if ( + typeof elicitation !== 'object' || + elicitation === null || + Array.isArray(elicitation) + ) { + return { form: false, url: false }; + } + const keys = Object.keys(elicitation); + return { + form: keys.length === 0 || keys.includes('form'), + url: keys.includes('url') + }; +} + +function urlRequest(url: string, message: string): object { + return { + method: 'elicitation/create', + params: { mode: 'url', message, url } + }; +} + +function formRequest(message: string): object { + return { + method: 'elicitation/create', + params: { mode: 'form', message, requestedSchema: CONFIRM_SCHEMA } + }; +} + +function clientCapabilities( + params: unknown +): Record | undefined { + const meta = (params as Record | undefined)?._meta as + | Record + | undefined; + const capabilities = meta?.['io.modelcontextprotocol/clientCapabilities']; + return typeof capabilities === 'object' && capabilities !== null + ? (capabilities as Record) + : undefined; +} + +/** Start an HTTP server on a free port and resolve to its origin. */ +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + resolve(`http://localhost:${port}`); + }); + }); +} + +export class ElicitationClientUrlModeScenario implements Scenario { + name = 'elicitation-sep1036-client-url-mode'; + readonly source = { introducedIn: SPEC_VERSION } as const; + description = `Tests client URL mode elicitation (SEP-1036) on the ${SPEC_VERSION} revision, where elicitation requests arrive inside an InputRequiredResult (SEP-2322). + +**Client Implementation Requirements:** + +1. Declare \`elicitation: { "form": {}, "url": {} }\` in \`_meta["io.modelcontextprotocol/clientCapabilities"]\` on every request. +2. Call \`tools/list\`, then call every listed tool once, in the order listed, with no arguments. +3. While a tool returns an \`InputRequiredResult\`, answer every entry in \`inputRequests\` and retry the call, echoing \`requestState\`. +4. Answer URL-mode requests with \`decline\` when the message starts with \`${DECLINE_PREFIX}\`, and with \`accept\` otherwise. Never fetch or open the URL. +5. Answer form-mode requests (explicit, or \`mode\` omitted) with \`accept\` and \`{ "confirmed": true }\`.6. Wait for held retries: two tools answer the accepted retry after ${HOLD_MS / 1000} seconds, one as \`application/json\` and one on an SSE stream. + +A client without URL mode support can declare form mode only. Its URL-mode checks are then skipped.`; + + private servers: http.Server[] = []; + private timers = new Set(); + private wireVersion: SpecVersion = SPEC_VERSION; + private consentOrigin = ''; + private declineOrigin = ''; + private tools = new Map(); + /** Every `elicitation` capability the client declared, one per request. */ + private declaredElicitation: unknown[] = []; + private consentHits: UrlHit[] = []; + private declineHits: UrlHit[] = []; + private held = new Map(); + + async start(ctx: ScenarioContext): Promise { + this.tools = new Map( + TOOLS.map((tool): [string, ToolRecord] => [ + tool.name, + { called: false, restarts: 0, sentStates: [], retries: [] } + ]) + ); + this.declaredElicitation = []; + this.consentHits = []; + this.declineHits = []; + this.held = new Map([ + [HELD_JSON_TOOL, []], + [HELD_SSE_TOOL, []] + ]); + // Serve the 2026-07-28 wire, or a later stateless revision if the runner + // resolved one. + this.wireVersion = STATELESS_SPEC_VERSIONS.includes(ctx.specVersion) + ? ctx.specVersion + : SPEC_VERSION; + + // Two origins for the elicitation URLs. Everything the client may open + // after accepting lives on the consent origin. The declined URL gets an + // origin of its own, so any request to it (a favicon, a HEAD, a link + // preview) is a fetch of a URL the user never consented to open. + this.consentOrigin = await this.startUrlOrigin(this.consentHits); + this.declineOrigin = await this.startUrlOrigin(this.declineHits); + + const app = express(); + app.use(express.json()); + app.post('/mcp', (req: Request, res: Response) => + this.handlePost(req, res) + ); + const mcpServer = http.createServer(app); + this.servers.push(mcpServer); + return { serverUrl: `${await listen(mcpServer)}/mcp` }; + } + + async stop() { + for (const timer of this.timers) clearTimeout(timer); + this.timers.clear(); + await Promise.all( + this.servers.map( + (server) => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + ) + ); + this.servers = []; + } + + getChecks(): ConformanceCheck[] { + return [ + this.declaredModeCheck(), + ...this.roundsChecks(), + this.mixedCheck(), + this.noModeCheck(), + this.noPrefetchCheck(), + this.heldRetryCheck( + HELD_JSON_TOOL, + 'sep-1036-client-held-json-retry-complete', + 'ClientHeldJsonRetryComplete', + 'Client waits for a retry the server holds open, answered as application/json' + ), + this.heldRetryCheck( + HELD_SSE_TOOL, + 'sep-1036-client-held-sse-retry-complete', + 'ClientHeldSseRetryComplete', + 'Client waits for a retry the server holds open on an SSE stream with keep-alives' + ) + ]; + } + + private async startUrlOrigin(hits: UrlHit[]): Promise { + const server = http.createServer((req, res) => { + hits.push({ + method: req.method ?? '', + path: req.url ?? '', + at: new Date().toISOString() + }); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Out-of-band interaction (conformance fixture)

'); + }); + this.servers.push(server); + return listen(server); + } + + private handlePost(req: Request, res: Response): void { + const body = req.body as Record | undefined; + if (body !== undefined) { + validateWireMessage(this.wireVersion, body, { + origin: 'implementation', + context: `client request '${body.method ?? '(unknown)'}' to ${this.name} mock` + }); + const capabilities = clientCapabilities(body.params); + if (capabilities && 'elicitation' in capabilities) { + this.declaredElicitation.push(capabilities.elicitation); + } + } + + const v = validateStatelessRequest(req, { tools: {} }, [this.wireVersion]); + if (v.kind !== 'route') { + this.sendJson( + res, + v.status, + v.body, + v.kind === 'handled' ? (body?.method as string) : undefined + ); + return; + } + + const { id, method, params } = v; + if (method === 'tools/list') { + this.sendJson( + res, + 200, + { + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, { tools: TOOLS }) + }, + method + ); + return; + } + if (method === 'tools/call') { + this.handleToolCall(res, id, params); + return; + } + this.sendJson(res, 404, { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` } + }); + } + + private handleToolCall( + res: Response, + id: string | number | null, + params: Record + ): void { + const name = params.name as string; + const record = this.tools.get(name); + if (!record) { + this.sendJson( + res, + 200, + { + jsonrpc: '2.0', + id, + error: { code: -32602, message: `Unknown tool: ${name}` } + }, + 'tools/call' + ); + return; + } + + const inputResponses = params.inputResponses as + | Record + | undefined; + if (inputResponses === undefined) { + // A fresh call starts the tool's flow over. + if (record.called) record.restarts++; + record.called = true; + record.sentStates = []; + record.retries = []; + record.missingCapability = undefined; + + const elicitation = clientCapabilities(params)?.elicitation; + const declared = declaredModes(elicitation); + const missing = REQUIRED_MODES[name].filter((mode) => !declared[mode]); + if (elicitation === undefined || missing.length > 0) { + record.missingCapability = + elicitation === undefined + ? 'Client did not declare the elicitation capability' + : `Client did not declare elicitation ${missing.join(' and ')} mode`; + // A server MUST NOT send elicitation requests in modes the client did + // not declare. -32021 (HTTP 400) is what the revision prescribes + // when a request needs a capability the client lacks. + this.sendJson( + res, + 400, + { + jsonrpc: '2.0', + id, + error: { + code: -32021, + message: `${name} needs an elicitation mode the client did not declare`, + data: { + requiredCapabilities: { + elicitation: Object.fromEntries( + missing.map((mode) => [mode, {}]) + ) + } + } + } + }, + 'tools/call' + ); + return; + } + this.sendInputRequired(res, id, name, record); + return; + } + + record.retries.push({ + inputResponses, + requestState: params.requestState as string | undefined + }); + if (name === ROUNDS_TOOL && record.retries.length < ROUNDS.length) { + this.sendInputRequired(res, id, name, record); + return; + } + if (name === HELD_JSON_TOOL) { + this.holdJson(res, id); + return; + } + if (name === HELD_SSE_TOOL) { + this.holdSse(res, id, params); + return; + } + this.sendJson(res, 200, this.completeResponse(id, name), 'tools/call'); + } + + private sendInputRequired( + res: Response, + id: string | number | null, + name: string, + record: ToolRecord + ): void { + const round = record.sentStates.length; + const requestState = `${name}:${round + 1}:${randomUUID()}`; + record.sentStates.push(requestState); + const message = { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: this.inputRequests(name, round), + requestState + } + }; + this.sendJson(res, 200, message, 'tools/call'); + } + + private inputRequests(name: string, round: number): Record { + switch (name) { + case DECLINE_TOOL: + return { + [DECLINE_KEY]: urlRequest( + `${this.declineOrigin}/connect/service-c`, + `${DECLINE_PREFIX} Connect your Service C account to continue.` + ) + }; + case ROUNDS_TOOL: { + const { key, path, message } = ROUNDS[round]; + return { [key]: urlRequest(`${this.consentOrigin}${path}`, message) }; + } + case NO_MODE_TOOL: + // No `mode`, so this is form mode. The stray `url` field catches a + // client that picks URL mode by looking for one. + return { + [NO_MODE_KEY]: { + method: 'elicitation/create', + params: { + message: 'Please confirm to continue.', + requestedSchema: CONFIRM_SCHEMA, + url: `${this.consentOrigin}/not-a-url-mode-request` + } + } + }; + case MIXED_TOOL: + return { + [MIXED_URL_KEY]: urlRequest( + `${this.consentOrigin}/connect/service-d`, + 'Connect your Service D account to continue.' + ), + [MIXED_FORM_KEY]: formRequest('Confirm the details for Service D.') + }; + default: { + // HELD_JSON_TOOL and HELD_SSE_TOOL + const { key, path } = HELD_REQUESTS[name]; + return { + [key]: urlRequest( + `${this.consentOrigin}${path}`, + 'Finish connecting your account in the browser.' + ) + }; + } + } + } + + private completeResponse(id: string | number | null, name: string): object { + return { + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields('tools/call', { + content: [{ type: 'text', text: `${name}: complete` }] + }) + }; + } + + /** Record when the client gives up on a held retry, or when it is answered. */ + private startHeldAttempt(name: string, res: Response): HeldAttempt { + const attempt: HeldAttempt = {}; + this.held.get(name)!.push(attempt); + const startedAt = Date.now(); + res.on('finish', () => { + attempt.answeredAfterMs = Date.now() - startedAt; + }); + // `close` also follows a normal finish. Only a close before the response + // was fully written means the client cancelled the request. + res.on('close', () => { + if (!res.writableFinished) { + attempt.cancelledAfterMs = Date.now() - startedAt; + } + }); + return attempt; + } + + private holdJson(res: Response, id: string | number | null): void { + const attempt = this.startHeldAttempt(HELD_JSON_TOOL, res); + // Nothing is written while the retry is held: the client sees no bytes + // until the response arrives. + this.schedule(HOLD_MS, () => { + if (attempt.cancelledAfterMs !== undefined || res.writableEnded) return; + this.sendJson( + res, + 200, + this.completeResponse(id, HELD_JSON_TOOL), + 'tools/call' + ); + }); + } + + private holdSse( + res: Response, + id: string | number | null, + params: Record + ): void { + const attempt = this.startHeldAttempt(HELD_SSE_TOOL, res); + const progressToken = (params._meta as Record | undefined) + ?.progressToken; + const open = () => + attempt.cancelledAfterMs === undefined && !res.writableEnded; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + res.flushHeaders(); + + const ticks = HOLD_MS / KEEP_ALIVE_MS; + for (let tick = 1; tick < ticks; tick++) { + this.schedule(tick * KEEP_ALIVE_MS, () => { + if (!open()) return; + // An SSE comment line carries no event. It keeps intermediaries and + // idle timeouts from closing a quiet stream. + res.write(': keep-alive\n\n'); + if ( + typeof progressToken === 'string' || + typeof progressToken === 'number' + ) { + this.writeSseEvent(res, { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: tick, + total: ticks, + message: 'Waiting for the out-of-band interaction to finish' + } + }); + } + }); + } + this.schedule(HOLD_MS, () => { + if (!open()) return; + this.writeSseEvent( + res, + this.completeResponse(id, HELD_SSE_TOOL), + 'tools/call' + ); + res.end(); + }); + } + + private schedule(ms: number, fn: () => void): void { + const timer = setTimeout(() => { + this.timers.delete(timer); + fn(); + }, ms); + this.timers.add(timer); + } + + private sendJson( + res: Response, + status: number, + message: object, + requestMethod?: string + ): void { + validateWireMessage(this.wireVersion, message, { + origin: 'harness', + context: `${this.name} mock response${requestMethod ? ` to '${requestMethod}'` : ''}`, + requestMethod + }); + res.status(status).json(message); + } + + private writeSseEvent( + res: Response, + message: object, + requestMethod?: string + ): void { + validateWireMessage(this.wireVersion, message, { + origin: 'harness', + context: `${this.name} mock SSE event`, + requestMethod + }); + res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } + + private declaredModeCheck(): ConformanceCheck { + if (this.declaredElicitation.length === 0) { + return { + id: 'sep-1036-client-supports-declared-mode', + name: 'ClientDeclaresElicitationMode', + description: + 'Clients declaring the elicitation capability MUST support at least one mode (form or url)', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: 'Client never declared the elicitation capability', + specReferences: CAPABILITIES_REFERENCES + }; + } + + // `{}` is form mode only, so the only declarations that break the rule + // name neither mode (e.g. `{ "foo": {} }`). + const withoutMode = [ + ...new Set( + this.declaredElicitation + .filter((elicitation) => { + const modes = declaredModes(elicitation); + return !modes.form && !modes.url; + }) + .map((elicitation) => JSON.stringify(elicitation)) + ) + ]; + return { + id: 'sep-1036-client-supports-declared-mode', + name: 'ClientDeclaresElicitationMode', + description: + 'Clients declaring the elicitation capability MUST support at least one mode (form or url)', + status: withoutMode.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + withoutMode.length > 0 + ? `Client declared elicitation without form or url mode: ${withoutMode.join(', ')}` + : undefined, + specReferences: CAPABILITIES_REFERENCES, + details: { + declared: [ + ...new Set(this.declaredElicitation.map((e) => JSON.stringify(e))) + ] + } + }; + } + + private roundsChecks(): ConformanceCheck[] { + const record = this.tools.get(ROUNDS_TOOL)!; + if (record.missingCapability) { + return [ + { + id: 'sep-2322-client-constructs-inputs', + name: 'ClientConstructsInputsUrlRounds', + description: + 'Client MUST construct the input for every new URL-mode request before retrying, round after round', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: record.missingCapability, + specReferences: MRTR_CLIENT_REFERENCES + }, + { + id: 'sep-2322-client-request-state-echoed', + name: 'ClientRequestStateEchoedUrlRounds', + description: + 'Client MUST echo back the exact value of the newest requestState on every retry of a URL-mode flow', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: record.missingCapability, + specReferences: MRTR_CLIENT_REFERENCES + } + ]; + } + + const constructErrors: string[] = []; + const stateErrors: string[] = []; + ROUNDS.forEach((round, i) => { + const retry = record.retries[i]; + if (!retry) return; + + const answer = retry.inputResponses[round.key]; + const action = actionOf(answer); + if (answer === undefined) { + constructErrors.push( + `Round ${i + 1}: the retry did not answer '${round.key}' (it answered: ${Object.keys(retry.inputResponses).join(', ') || 'nothing'})` + ); + } else if (action === undefined) { + constructErrors.push( + `Round ${i + 1}: '${round.key}' was answered with ${JSON.stringify(answer)}, which is not an ElicitResult` + ); + } else if (action !== round.expected) { + constructErrors.push( + `Round ${i + 1}: '${round.key}' was answered '${action}', but its message ${round.expected === 'decline' ? 'starts' : 'does not start'} with "${DECLINE_PREFIX}", so the client's handler answers '${round.expected}'. The answer was not built from this request.` + ); + } + + const sentState = record.sentStates[i]; + if (retry.requestState === undefined) { + stateErrors.push( + `Round ${i + 1}: the retry did not include requestState` + ); + } else if (retry.requestState !== sentState) { + const earlier = record.sentStates.indexOf(retry.requestState); + stateErrors.push( + earlier >= 0 && earlier < i + ? `Round ${i + 1}: the retry echoed the requestState from round ${earlier + 1}` + : `Round ${i + 1}: requestState was not echoed back exactly` + ); + } + }); + + const roundsAnswered = Math.min(record.retries.length, ROUNDS.length); + const incompleteReason = !record.called + ? `Client never called ${ROUNDS_TOOL}` + : `Client stopped retrying ${ROUNDS_TOOL} after ${roundsAnswered} of ${ROUNDS.length} rounds`; + const details = { + roundsAnswered, + restarts: record.restarts, + requestStatesSent: record.sentStates, + retries: record.retries + }; + + const checks: ConformanceCheck[] = []; + if (constructErrors.length === 0 && roundsAnswered < ROUNDS.length) { + checks.push( + untestableCheck( + 'sep-2322-client-constructs-inputs', + 'ClientConstructsInputsUrlRounds', + 'Client MUST construct the input for every new URL-mode request before retrying, round after round', + incompleteReason, + MRTR_CLIENT_REFERENCES + ) + ); + } else { + checks.push({ + id: 'sep-2322-client-constructs-inputs', + name: 'ClientConstructsInputsUrlRounds', + description: + 'Client MUST construct the input for every new URL-mode request before retrying, round after round', + status: constructErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + constructErrors.length > 0 ? constructErrors.join('; ') : undefined, + specReferences: MRTR_CLIENT_REFERENCES, + details + }); + } + + if (stateErrors.length === 0 && roundsAnswered < ROUNDS.length) { + checks.push( + untestableCheck( + 'sep-2322-client-request-state-echoed', + 'ClientRequestStateEchoedUrlRounds', + 'Client MUST echo back the exact value of the newest requestState on every retry of a URL-mode flow', + incompleteReason, + MRTR_CLIENT_REFERENCES + ) + ); + } else { + checks.push({ + id: 'sep-2322-client-request-state-echoed', + name: 'ClientRequestStateEchoedUrlRounds', + description: + 'Client MUST echo back the exact value of the newest requestState on every retry of a URL-mode flow', + status: stateErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + stateErrors.length > 0 ? stateErrors.join('; ') : undefined, + specReferences: MRTR_CLIENT_REFERENCES, + details + }); + } + return checks; + } + + private mixedCheck(): ConformanceCheck { + const record = this.tools.get(MIXED_TOOL)!; + if (record.missingCapability) { + return { + id: 'sep-2322-client-constructs-inputs-mixed', + name: 'ClientConstructsInputsMixedModes', + description: + 'Client MUST construct both a URL-mode and a form-mode input sent together before retrying', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: record.missingCapability, + specReferences: MRTR_CLIENT_REFERENCES + }; + } + const retry = record.retries[0]; + if (!retry) { + return untestableCheck( + 'sep-2322-client-constructs-inputs-mixed', + 'ClientConstructsInputsMixedModes', + 'Client MUST construct both a URL-mode and a form-mode input sent together before retrying', + record.called + ? `Client never retried ${MIXED_TOOL}` + : `Client never called ${MIXED_TOOL}`, + MRTR_CLIENT_REFERENCES + ); + } + + const errors: string[] = []; + const urlAnswer = retry.inputResponses[MIXED_URL_KEY]; + const formAnswer = retry.inputResponses[MIXED_FORM_KEY]; + const formContent = (formAnswer as Record | undefined) + ?.content; + if (urlAnswer === undefined) { + errors.push( + `The retry did not answer the URL-mode request '${MIXED_URL_KEY}'` + ); + } else if (actionOf(urlAnswer) !== 'accept') { + errors.push( + `'${MIXED_URL_KEY}' was answered ${JSON.stringify(urlAnswer)}; the client's handler accepts this URL-mode request` + ); + } + if (formAnswer === undefined) { + errors.push( + `The retry did not answer the form-mode request '${MIXED_FORM_KEY}'` + ); + } else if ( + actionOf(formAnswer) !== 'accept' || + typeof formContent !== 'object' || + formContent === null + ) { + errors.push( + `'${MIXED_FORM_KEY}' was answered ${JSON.stringify(formAnswer)}; the client's handler accepts this form-mode request with content` + ); + } + + return { + id: 'sep-2322-client-constructs-inputs-mixed', + name: 'ClientConstructsInputsMixedModes', + description: + 'Client MUST construct both a URL-mode and a form-mode input sent together before retrying', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_CLIENT_REFERENCES, + details: { inputResponses: retry.inputResponses } + }; + } + + private noModeCheck(): ConformanceCheck { + const record = this.tools.get(NO_MODE_TOOL)!; + if (record.missingCapability) { + return { + id: 'sep-1036-client-missing-mode-is-form', + name: 'ClientMissingModeIsForm', + description: + 'Clients MUST treat elicitation requests without a mode field as form mode', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: record.missingCapability, + specReferences: ELICITATION_REQUESTS_REFERENCES + }; + } + const retry = record.retries[0]; + const answer = retry?.inputResponses[NO_MODE_KEY]; + const action = actionOf(answer); + if (!retry || action === 'decline' || action === 'cancel') { + return untestableCheck( + 'sep-1036-client-missing-mode-is-form', + 'ClientMissingModeIsForm', + 'Clients MUST treat elicitation requests without a mode field as form mode', + !record.called + ? `Client never called ${NO_MODE_TOOL}` + : !retry + ? `Client never retried ${NO_MODE_TOOL}, so it never answered the request without mode` + : `Client answered '${action}' where its handler accepts form-mode requests; a ${action} does not show which mode the client assumed`, + ELICITATION_REQUESTS_REFERENCES + ); + } + + const content = (answer as Record | undefined)?.content; + let error: string | undefined; + if (answer === undefined) { + error = `The retry did not answer '${NO_MODE_KEY}'`; + } else if (action !== 'accept') { + error = `'${NO_MODE_KEY}' was answered with ${JSON.stringify(answer)}, which is not an ElicitResult`; + } else if (typeof content !== 'object' || content === null) { + // An accept without content is how a URL-mode request is answered. + error = + 'The client accepted without content, as it would a URL-mode request. A request without mode is form mode, even when it carries a url field.'; + } + + return { + id: 'sep-1036-client-missing-mode-is-form', + name: 'ClientMissingModeIsForm', + description: + 'Clients MUST treat elicitation requests without a mode field as form mode', + status: error === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error, + specReferences: ELICITATION_REQUESTS_REFERENCES, + details: { answer } + }; + } + + private noPrefetchCheck(): ConformanceCheck { + const record = this.tools.get(DECLINE_TOOL)!; + if (record.missingCapability) { + return { + id: 'sep-1036-client-no-url-prefetch', + name: 'ClientNoUrlPrefetch', + description: + 'Client MUST NOT pre-fetch a URL-mode URL (judged on a URL the user declined)', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: record.missingCapability, + specReferences: SAFE_URL_HANDLING_REFERENCES + }; + } + + // Only a declined URL can be judged. In the URL Mode Flow the client + // opens the URL before it sends the accept retry, so on the accept path a + // consented open looks identical to a prefetch. Cancel can't be judged + // either: the spec's cancel examples include "browser failed to load", + // which implies the URL was opened. + const answer = record.retries[0]?.inputResponses[DECLINE_KEY]; + if (actionOf(answer) !== 'decline') { + return untestableCheck( + 'sep-1036-client-no-url-prefetch', + 'ClientNoUrlPrefetch', + 'Client MUST NOT pre-fetch a URL-mode URL (judged on a URL the user declined)', + !record.called + ? `Client never called ${DECLINE_TOOL}` + : record.retries.length === 0 + ? `Client never retried ${DECLINE_TOOL}, so it never declined the URL` + : `Client answered ${JSON.stringify(answer)} instead of declining a request whose message starts with "${DECLINE_PREFIX}"; a fetch can only be judged on a declined URL`, + SAFE_URL_HANDLING_REFERENCES + ); + } + + return { + id: 'sep-1036-client-no-url-prefetch', + name: 'ClientNoUrlPrefetch', + description: + 'Client MUST NOT pre-fetch a URL-mode URL (judged on a URL the user declined)', + status: this.declineHits.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + this.declineHits.length > 0 + ? `The client declined the URL but still requested its origin ${this.declineHits.length} time(s): ${this.declineHits.map((hit) => `${hit.method} ${hit.path}`).join(', ')}` + : undefined, + specReferences: SAFE_URL_HANDLING_REFERENCES, + details: { declineHits: this.declineHits, consentHits: this.consentHits } + }; + } + + private heldRetryCheck( + name: string, + id: string, + checkName: string, + description: string + ): ConformanceCheck { + const record = this.tools.get(name)!; + if (record.missingCapability) { + return { + id, + name: checkName, + description, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: record.missingCapability, + specReferences: URL_MODE_FLOW_REFERENCES + }; + } + const attempts = this.held.get(name)!; + const first = attempts[0]; + if (!first) { + return untestableCheck( + id, + checkName, + description, + record.called + ? `Client never retried ${name} after the URL-mode request` + : `Client never called ${name}`, + URL_MODE_FLOW_REFERENCES + ); + } + + let error: string | undefined; + if (first.cancelledAfterMs !== undefined) { + error = `The client closed the held retry after ${first.cancelledAfterMs} ms; the server answers after ${HOLD_MS} ms`; + } else if (first.answeredAfterMs === undefined) { + error = `The held retry was still open when the client finished; the server answers after ${HOLD_MS} ms`; + } + + return { + id, + name: checkName, + description, + status: error === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error, + specReferences: URL_MODE_FLOW_REFERENCES, + details: { holdMs: HOLD_MS, attempts } + }; + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index ff377f54..ad708a24 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -14,6 +14,7 @@ import { SkillsNoPrefetchScenario } from './client/skills/no-prefetch'; import { SkillsVerificationScenario } from './client/skills/verification'; import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; +import { ElicitationClientUrlModeScenario } from './client/elicitation-url-mode'; import { SSERetryScenario } from './client/sse-retry'; import { RequestMetadataScenario } from './client/request-metadata'; import { MRTRClientScenario } from './client/mrtr-client'; @@ -47,6 +48,7 @@ import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12'; import { ElicitationDefaultsScenario } from './server/elicitation-defaults'; import { ElicitationEnumsScenario } from './server/elicitation-enums'; +import { ElicitationUrlModeScenario } from './server/elicitation-url-mode'; import { ServerSSEPollingScenario } from './server/sse-polling'; import { ServerSSEMultipleStreamsScenario } from './server/sse-multiple-streams'; @@ -200,6 +202,9 @@ const allClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1034) new ElicitationDefaultsScenario(), + // URL mode elicitation, server side (SEP-1036) + new ElicitationUrlModeScenario(), + // SSE Polling scenarios (SEP-1699) new ServerSSEPollingScenario(), new ServerSSEMultipleStreamsScenario(), @@ -330,6 +335,9 @@ const scenariosList: Scenario[] = [ // MRTR client conformance (SEP-2322) new MRTRClientScenario(), + // URL mode elicitation, client side (SEP-1036) + new ElicitationClientUrlModeScenario(), + // HTTP Standardization scenarios (SEP-2243) new HttpStandardHeadersScenario(), new HttpCustomHeadersScenario(), diff --git a/src/scenarios/server/elicitation-url-mode.test.ts b/src/scenarios/server/elicitation-url-mode.test.ts new file mode 100644 index 00000000..568910c8 --- /dev/null +++ b/src/scenarios/server/elicitation-url-mode.test.ts @@ -0,0 +1,230 @@ +import { testContext } from '../../connection/testing'; +/** + * SEP-1036 URL mode elicitation, server side: negative tests. + * + * The positive run is all-scenarios.test.ts against the everything-server. + * These run the scenario against a deliberately broken server, and against a + * server without the fixture tool, to verify the checks emit FAILURE. + */ + +import { spawn, ChildProcess } from 'child_process'; +import http from 'http'; +import { createServer } from 'net'; +import path from 'path'; +import { ElicitationUrlModeScenario } from './elicitation-url-mode'; +import { + formatWireViolation, + takeWireViolations +} from '../../validation/wire-schema'; +import type { ConformanceCheck } from '../../types'; + +const SPEC_VERSION = '2026-07-28'; + +/** + * Run the scenario, then drain the wire-schema recorder: the broken fixture + * violates the schema by design. Only the implementation may be invalid; a + * harness-origin violation is a real harness bug and still fails. + */ +async function runScenario(serverUrl: string): Promise { + const checks = await new ElicitationUrlModeScenario().run( + testContext(serverUrl, SPEC_VERSION) + ); + const { violations } = takeWireViolations(); + const harnessViolations = violations.filter((v) => v.origin === 'harness'); + if (harnessViolations.length > 0) { + throw new Error( + 'Harness-origin wire-schema violations in a URL mode negative test ' + + '(only the fixture may be invalid here):\n ' + + harnessViolations.map(formatWireViolation).join('\n ') + ); + } + return checks; +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function startServer(scriptPath: string, port: number): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString() }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +function findCheck(checks: ConformanceCheck[], id: string): ConformanceCheck { + const check = checks.find((c) => c.id === id); + if (!check) throw new Error(`Check ${id} not emitted`); + return check; +} + +/** A FAILURE that reports a violation, not a missing prerequisite. */ +function expectViolation(check: ConformanceCheck): void { + expect(check.status, check.errorMessage).toBe('FAILURE'); + expect(check.details?.untestable).toBeUndefined(); +} + +describe('SEP-1036 URL mode elicitation negative tests', () => { + let serverProcess: ChildProcess | null = null; + let checks: ConformanceCheck[] = []; + + beforeAll(async () => { + const port = await getFreePort(); + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-1036-url-elicitation-broken-server.ts' + ), + port + ); + checks = await runScenario(`http://localhost:${port}/mcp`); + }, 45000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('fails sep-1036-url-request-params when the URL-mode request has no mode', () => { + const check = findCheck(checks, 'sep-1036-url-request-params'); + expectViolation(check); + expect(check.errorMessage).toContain('no mode "url"'); + }); + + it('fails sep-1036-url-valid when the url is not an absolute URI', () => { + const check = findCheck(checks, 'sep-1036-url-valid'); + expectViolation(check); + expect(check.errorMessage).toContain('not a valid absolute URI'); + }); + + it('fails sep-2322-server-handles-decline-cancel when a decline is an internal error', () => { + const check = findCheck(checks, 'sep-2322-server-handles-decline-cancel'); + expectViolation(check); + expect(check.errorMessage).toContain('decline retry failed'); + // The cancel retry's -32042 is a deliberate error, so it counts as handled. + expect(check.errorMessage).not.toContain('cancel retry'); + }); + + it('fails sep-1036-server-no-unsupported-mode in both directions', () => { + const check = findCheck(checks, 'sep-1036-server-no-unsupported-mode'); + expectViolation(check); + expect(check.errorMessage).toContain('(form mode only)'); + expect(check.errorMessage).toContain('(URL mode only)'); + }); + + it('fails sep-2575-server-rejects-undeclared-capability when no -32021 comes back', () => { + const check = findCheck( + checks, + 'sep-2575-server-rejects-undeclared-capability' + ); + expectViolation(check); + expect(check.errorMessage).toContain('instead of -32021'); + }); + + it('reports sep-2575-missing-capability-http-400 as untestable when -32021 never comes back', () => { + const check = findCheck(checks, 'sep-2575-missing-capability-http-400'); + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Not testable:'); + expect(check.details?.untestable).toBe(true); + }); + + it('fails sep-1036-server-no-url-elicitation-required-error on a -32042 answer', () => { + const check = findCheck( + checks, + 'sep-1036-server-no-url-elicitation-required-error' + ); + expectViolation(check); + expect(check.errorMessage).toContain('cancel retry'); + }); +}); + +describe('SEP-1036 URL mode elicitation against a server without the fixture tool', () => { + let server: http.Server; + let checks: ConformanceCheck[] = []; + + beforeAll(async () => { + // Answers every request as an unknown tool, like an SDK server that has + // not implemented test_url_elicitation yet. + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const { id } = JSON.parse(body) as { id: number }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32602, message: 'Unknown tool' } + }) + ); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as { port: number }; + checks = await runScenario(`http://localhost:${port}/mcp`); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('reports every check as untestable rather than passing or failing it', () => { + for (const id of [ + 'sep-1036-url-request-params', + 'sep-1036-url-valid', + 'sep-2322-server-handles-decline-cancel', + 'sep-1036-server-no-unsupported-mode', + 'sep-2575-server-rejects-undeclared-capability', + 'sep-2575-missing-capability-http-400', + 'sep-1036-server-no-url-elicitation-required-error' + ]) { + const check = findCheck(checks, id); + expect(check.status, id).toBe('FAILURE'); + expect(check.details?.untestable, id).toBe(true); + } + }); +}); diff --git a/src/scenarios/server/elicitation-url-mode.ts b/src/scenarios/server/elicitation-url-mode.ts new file mode 100644 index 00000000..b36f756d --- /dev/null +++ b/src/scenarios/server/elicitation-url-mode.ts @@ -0,0 +1,631 @@ +/** + * SEP-1036: URL mode elicitation, server side. + * + * Targets the 2026-07-28 revision only. There, URL-mode requests travel inside + * an InputRequiredResult (MRTR, SEP-2322), and the 2025-11-25 elicitationId, + * notifications/elicitation/complete, and -32042 error no longer exist. + * + * The harness is the client. It calls the server's `test_url_elicitation` + * tool while declaring different elicitation modes, and checks that: + * - the URL-mode request carries mode, message, and a valid absolute url + * - declining or cancelling the request still gets a well-formed answer + * - a client without URL mode gets -32021 (HTTP 400) instead of a URL-mode + * request, and a URL-only client never gets a form-mode request + * - the server never answers with the removed -32042 error + */ + +import { fullFormats } from 'ajv-formats/dist/formats.js'; +import type { + ClientScenario, + ConformanceCheck, + SpecReference +} from '../../types'; +import { + sendStatelessRequest, + type RunContext, + type StatelessResponse +} from '../../connection'; +import { STATELESS_SPEC_VERSIONS } from '../../connection/select'; +import { isInputRequiredResult } from './input-required-result-helpers'; +import { untestableCheck } from '../untestable'; + +const SPEC_VERSION = '2026-07-28'; +const SPEC_BASE = `https://modelcontextprotocol.io/specification/${SPEC_VERSION}`; + +const URL_MODE_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-URL-Mode-Requests', + url: `${SPEC_BASE}/client/elicitation#url-mode-elicitation-requests` + } +]; +const CAPABILITIES_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-Capabilities', + url: `${SPEC_BASE}/client/elicitation#capabilities` + } +]; +const ERROR_HANDLING_REFERENCES: SpecReference[] = [ + { + id: 'SEP-1036-Error-Handling', + url: `${SPEC_BASE}/client/elicitation#error-handling` + } +]; +const MISSING_CAPABILITY_REFERENCES: SpecReference[] = [ + { id: 'SEP-2575-Meta', url: `${SPEC_BASE}/basic/index#meta` } +]; +const ERROR_CODES_REFERENCES: SpecReference[] = [ + { id: 'MCP-Error-Codes', url: `${SPEC_BASE}/basic/index#error-codes` } +]; + +/** The tool under test. It can only work through URL mode. */ +const URL_TOOL = 'test_url_elicitation'; + +/** The SEP-2322 scenarios' form-mode tool, reused to probe a URL-only client. */ +const FORM_TOOL = 'test_input_required_result_elicitation'; + +const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; +const INTERNAL_ERROR = -32603; +/** URLElicitationRequiredError, which exists in 2025-11-25 only. */ +const URL_ELICITATION_REQUIRED = -32042; + +/** + * The schema's `format: uri` check (an absolute RFC 3986 URI), the same one + * the wire validator applies. + */ +const isUri = fullFormats.uri as (value: string) => boolean; + +/** One request the scenario sent, and what came back. */ +interface Exchange { + label: string; + response?: StatelessResponse; + /** Set when no HTTP response arrived (a timeout or a dropped connection). */ + transportError?: string; +} + +type ElicitationParams = Record; + +function elicitationRequests( + response: StatelessResponse | undefined +): Array<[string, ElicitationParams]> { + const result = response?.body?.result; + if (!isInputRequiredResult(result)) return []; + return Object.entries(result.inputRequests ?? {}) + .filter(([, request]) => request.method === 'elicitation/create') + .map(([key, request]) => [key, request.params ?? {}]); +} + +/** + * URL-mode requests, including one that carries a `url` but forgot + * `mode: "url"` (which a client would read as form mode). + */ +function urlModeRequests( + response: StatelessResponse | undefined +): Array<[string, ElicitationParams]> { + return elicitationRequests(response).filter( + ([, params]) => + params.mode === 'url' || (params.mode === undefined && 'url' in params) + ); +} + +/** Form-mode requests: `mode: "form"`, or no mode and no url. */ +function formModeRequests( + response: StatelessResponse | undefined +): Array<[string, ElicitationParams]> { + return elicitationRequests(response).filter( + ([, params]) => + params.mode === 'form' || + (params.mode === undefined && !('url' in params)) + ); +} + +function summarize(exchange: Exchange): string { + if (exchange.transportError) { + return `no response (${exchange.transportError})`; + } + const { status, body } = exchange.response!; + if (body?.error) { + return `HTTP ${status} with error ${body.error.code} (${body.error.message})`; + } + if (body?.result) { + return isInputRequiredResult(body.result) + ? `HTTP ${status} with an InputRequiredResult` + : `HTTP ${status} with a complete result`; + } + return `HTTP ${status} with no JSON-RPC response`; +} + +export class ElicitationUrlModeScenario implements ClientScenario { + name = 'elicitation-sep1036-url-mode'; + readonly source = { introducedIn: SPEC_VERSION } as const; + description = `Tests server URL mode elicitation (SEP-1036) on the ${SPEC_VERSION} revision, where elicitation requests travel inside an InputRequiredResult (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`${URL_TOOL}\` (no arguments) that can only work through URL mode: + +1. If \`_meta["io.modelcontextprotocol/clientCapabilities"]\` does not declare \`elicitation.url\`, return a \`MissingRequiredClientCapabilityError\`: JSON-RPC error \`-32021\` with HTTP status 400 and \`data.requiredCapabilities\` set to \`{ "elicitation": { "url": {} } }\`. +2. Otherwise, when called without \`inputResponses\`, return an \`InputRequiredResult\` holding one URL-mode \`elicitation/create\` request: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "connect_account": { + "method": "elicitation/create", + "params": { + "mode": "url", + "message": "Connect your account to continue.", + "url": "https://example.com/connect" + } + } + } +} +\`\`\` + +3. When retried with an answer to that request, return a result. A \`decline\` or \`cancel\` must still get a well-formed answer, for example a tool result with \`isError: true\`. + +The scenario also calls the SEP-2322 tool \`${FORM_TOOL}\` while declaring URL mode only, and expects no form-mode request back.`; + + async run(ctx: RunContext): Promise { + // Speak the 2026-07-28 wire, or a later stateless revision if the runner + // resolved one. + const specVersion = STATELESS_SPEC_VERSIONS.includes(ctx.specVersion) + ? ctx.specVersion + : SPEC_VERSION; + const call = async ( + label: string, + tool: string, + elicitation: Record, + extra: Record = {} + ): Promise => { + try { + const response = await sendStatelessRequest( + ctx.serverUrl, + 'tools/call', + { + name: tool, + arguments: {}, + ...extra, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { elicitation } + } + }, + { specVersion } + ); + return { label, response }; + } catch (error) { + return { + label, + transportError: error instanceof Error ? error.message : String(error) + }; + } + }; + + // Retry with an answer to the URL-mode request the first call returned. + const answer = async ( + first: Exchange, + action: 'decline' | 'cancel' + ): Promise => { + const [request] = urlModeRequests(first.response); + if (!request) return undefined; + const requestState = first.response?.body?.result?.requestState; + return call( + `${action} retry`, + URL_TOOL, + { form: {}, url: {} }, + { + inputResponses: { [request[0]]: { action } }, + ...(requestState !== undefined && { requestState }) + } + ); + }; + + // The full declaration goes first: a server that remembers capabilities + // from an earlier request would then send URL mode to the form-only + // probes below. + const declined = await call('form and URL mode declared', URL_TOOL, { + form: {}, + url: {} + }); + const declineRetry = await answer(declined, 'decline'); + const cancelled = await call( + 'form and URL mode declared (second call)', + URL_TOOL, + { form: {}, url: {} } + ); + const cancelRetry = await answer(cancelled, 'cancel'); + + // `elicitation: {}` means form mode only, the same as `{ form: {} }`. + const formOnly = [ + await call('elicitation: {}', URL_TOOL, {}), + await call('elicitation: { form: {} }', URL_TOOL, { form: {} }) + ]; + const urlOnly = await call('elicitation: { url: {} }', FORM_TOOL, { + url: {} + }); + + // Without a working URL tool, an error to a form-only client can't be + // told apart from a missing tool, so the capability checks would prove + // nothing; they report the missing prerequisite instead. + const toolMissing = + urlModeRequests(declined.response).length === 0 + ? `${URL_TOOL} never sent a URL-mode request, even to a client that declared URL mode (it answered with ${summarize(declined)})` + : undefined; + + return [ + this.urlRequestParamsCheck(declined), + this.urlValidCheck(declined), + this.declineCancelCheck([ + { first: declined, retry: declineRetry, action: 'decline' }, + { first: cancelled, retry: cancelRetry, action: 'cancel' } + ]), + this.noUnsupportedModeCheck(formOnly, urlOnly, toolMissing), + ...this.missingCapabilityChecks(formOnly, toolMissing), + this.noUrlElicitationRequiredCheck( + [declined, declineRetry, cancelled, cancelRetry, ...formOnly, urlOnly], + toolMissing + ) + ]; + } + + private urlRequestParamsCheck(exchange: Exchange): ConformanceCheck { + const requests = urlModeRequests(exchange.response); + if (requests.length === 0) { + return untestableCheck( + 'sep-1036-url-request-params', + 'UrlModeRequestParams', + 'URL mode elicitation requests MUST specify mode "url", a message, and a url', + `${URL_TOOL} answered a client that declared URL mode with ${summarize(exchange)}, not a URL-mode request`, + URL_MODE_REFERENCES + ); + } + + const errors: string[] = []; + for (const [key, params] of requests) { + if (params.mode !== 'url') { + errors.push( + `'${key}' has a url but no mode "url"; a client treats a request without mode as form mode` + ); + } + if (typeof params.message !== 'string') { + errors.push(`'${key}' has no string message`); + } + if (typeof params.url !== 'string') { + errors.push(`'${key}' has no string url`); + } + } + + return { + id: 'sep-1036-url-request-params', + name: 'UrlModeRequestParams', + description: + 'URL mode elicitation requests MUST specify mode "url", a message, and a url', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: URL_MODE_REFERENCES, + details: { inputRequests: Object.fromEntries(requests) } + }; + } + + private urlValidCheck(exchange: Exchange): ConformanceCheck { + const requests = urlModeRequests(exchange.response).filter( + ([, params]) => typeof params.url === 'string' + ); + if (requests.length === 0) { + return untestableCheck( + 'sep-1036-url-valid', + 'UrlModeRequestValidUrl', + 'The url parameter of a URL mode elicitation request MUST contain a valid URL', + `${URL_TOOL} answered a client that declared URL mode with ${summarize(exchange)}, not a URL-mode request with a url`, + URL_MODE_REFERENCES + ); + } + + const errors = requests + .filter(([, params]) => !isUri(params.url as string)) + .map( + ([key, params]) => + `'${key}' has url ${JSON.stringify(params.url)}, which is not a valid absolute URI` + ); + + return { + id: 'sep-1036-url-valid', + name: 'UrlModeRequestValidUrl', + description: + 'The url parameter of a URL mode elicitation request MUST contain a valid URL', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: URL_MODE_REFERENCES, + details: { + urls: Object.fromEntries( + requests.map(([key, params]) => [key, params.url]) + ) + } + }; + } + + private declineCancelCheck( + flows: Array<{ + first: Exchange; + retry: Exchange | undefined; + action: 'decline' | 'cancel'; + }> + ): ConformanceCheck { + // Any well-formed answer is handling: a tool result (isError included), + // another InputRequiredResult, or a deliberate JSON-RPC error. Only an + // internal error, a timeout, or a dropped connection is not. + const errors: string[] = []; + const notExercised: string[] = []; + for (const { first, retry, action } of flows) { + if (!retry) { + notExercised.push( + `the ${action} flow: ${URL_TOOL} answered with ${summarize(first)}, not a URL-mode request` + ); + continue; + } + const body = retry.response?.body; + if (retry.transportError) { + errors.push( + `The ${action} retry got no response: ${retry.transportError}` + ); + } else if (body?.error?.code === INTERNAL_ERROR) { + errors.push( + `The ${action} retry failed with an internal error (-32603): ${body.error.message}` + ); + } else if (!body?.result && !body?.error) { + errors.push( + `The ${action} retry got HTTP ${retry.response!.status} with no JSON-RPC response` + ); + } + } + + if (errors.length === 0 && notExercised.length > 0) { + return untestableCheck( + 'sep-2322-server-handles-decline-cancel', + 'ServerHandlesDeclineCancel', + 'Servers MUST handle cases where the user declines or cancels the elicitation', + `Could not exercise ${notExercised.join('; ')}`, + ERROR_HANDLING_REFERENCES + ); + } + + return { + id: 'sep-2322-server-handles-decline-cancel', + name: 'ServerHandlesDeclineCancel', + description: + 'Servers MUST handle cases where the user declines or cancels the elicitation', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: ERROR_HANDLING_REFERENCES, + details: Object.fromEntries( + flows.map(({ retry, action }) => [ + action, + retry ? summarize(retry) : 'not exercised' + ]) + ) + }; + } + + private noUnsupportedModeCheck( + formOnly: Exchange[], + urlOnly: Exchange, + toolMissing: string | undefined + ): ConformanceCheck { + const errors: string[] = []; + for (const exchange of formOnly) { + const sent = urlModeRequests(exchange.response).map(([key]) => key); + if (sent.length > 0) { + errors.push( + `With ${exchange.label} (form mode only), ${URL_TOOL} sent URL-mode request(s): ${sent.join(', ')}` + ); + } + } + + // The URL-only probe needs the SEP-2322 form tool. A server without it + // answers with an error other than -32021, which exercises nothing. + const urlOnlyError = urlOnly.response?.body?.error; + const urlOnlyExercised = + !urlOnly.transportError && + (urlOnlyError === undefined || + urlOnlyError.code === MISSING_REQUIRED_CLIENT_CAPABILITY); + if (urlOnlyExercised) { + const sent = formModeRequests(urlOnly.response).map(([key]) => key); + if (sent.length > 0) { + errors.push( + `With ${urlOnly.label} (URL mode only), ${FORM_TOOL} sent form-mode request(s): ${sent.join(', ')}` + ); + } + } + + const formOnlyAnswered = formOnly.filter((e) => !e.transportError); + if (errors.length === 0 && (toolMissing || formOnlyAnswered.length === 0)) { + return untestableCheck( + 'sep-1036-server-no-unsupported-mode', + 'ServerNoUnsupportedElicitationMode', + 'Servers MUST NOT send elicitation requests with modes that are not supported by the client', + toolMissing ?? + `${URL_TOOL} gave no response to any form-only probe: ${formOnly.map(summarize).join('; ')}`, + CAPABILITIES_REFERENCES + ); + } + + return { + id: 'sep-1036-server-no-unsupported-mode', + name: 'ServerNoUnsupportedElicitationMode', + description: + 'Servers MUST NOT send elicitation requests with modes that are not supported by the client', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: CAPABILITIES_REFERENCES, + details: { + probes: Object.fromEntries( + [...formOnly, urlOnly].map((e) => [e.label, summarize(e)]) + ), + urlOnlyProbeExercised: urlOnlyExercised + } + }; + } + + private missingCapabilityChecks( + formOnly: Exchange[], + toolMissing: string | undefined + ): ConformanceCheck[] { + if (toolMissing) { + return [ + untestableCheck( + 'sep-2575-server-rejects-undeclared-capability', + 'ServerRejectsUndeclaredUrlMode', + 'A tool that needs URL mode MUST return MissingRequiredClientCapabilityError (-32021) naming elicitation.url to a client without it', + toolMissing, + MISSING_CAPABILITY_REFERENCES + ), + untestableCheck( + 'sep-2575-missing-capability-http-400', + 'MissingUrlModeCapabilityHttp400', + 'MissingRequiredClientCapabilityError (-32021) MUST come with HTTP 400 Bad Request', + toolMissing, + MISSING_CAPABILITY_REFERENCES + ) + ]; + } + + const codeErrors: string[] = []; + const statusErrors: string[] = []; + const rejected: Exchange[] = []; + for (const exchange of formOnly) { + if (exchange.transportError) continue; + const error = exchange.response!.body?.error; + if (error?.code !== MISSING_REQUIRED_CLIENT_CAPABILITY) { + codeErrors.push( + `With ${exchange.label}, ${URL_TOOL} answered ${summarize(exchange)} instead of -32021` + ); + continue; + } + rejected.push(exchange); + const requiredCapabilities = ( + error.data as Record | undefined + )?.requiredCapabilities as Record | undefined; + const elicitation = requiredCapabilities?.elicitation as + | Record + | undefined; + if (typeof elicitation?.url !== 'object' || elicitation.url === null) { + codeErrors.push( + `With ${exchange.label}, the -32021 error's data.requiredCapabilities does not name elicitation.url: ${JSON.stringify(error.data)}` + ); + } + if (exchange.response!.status !== 400) { + statusErrors.push( + `With ${exchange.label}, -32021 came with HTTP ${exchange.response!.status}` + ); + } + } + + const checks: ConformanceCheck[] = []; + if ( + codeErrors.length === 0 && + formOnly.every((exchange) => exchange.transportError) + ) { + checks.push( + untestableCheck( + 'sep-2575-server-rejects-undeclared-capability', + 'ServerRejectsUndeclaredUrlMode', + 'A tool that needs URL mode MUST return MissingRequiredClientCapabilityError (-32021) naming elicitation.url to a client without it', + `${URL_TOOL} gave no response to any form-only probe`, + MISSING_CAPABILITY_REFERENCES + ) + ); + } else { + checks.push({ + id: 'sep-2575-server-rejects-undeclared-capability', + name: 'ServerRejectsUndeclaredUrlMode', + description: + 'A tool that needs URL mode MUST return MissingRequiredClientCapabilityError (-32021) naming elicitation.url to a client without it', + status: codeErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: codeErrors.length > 0 ? codeErrors.join('; ') : undefined, + specReferences: MISSING_CAPABILITY_REFERENCES, + details: { + probes: Object.fromEntries( + formOnly.map((e) => [e.label, e.response?.body ?? summarize(e)]) + ) + } + }); + } + + if (rejected.length === 0) { + checks.push( + untestableCheck( + 'sep-2575-missing-capability-http-400', + 'MissingUrlModeCapabilityHttp400', + 'MissingRequiredClientCapabilityError (-32021) MUST come with HTTP 400 Bad Request', + `${URL_TOOL} never returned -32021, so its HTTP status could not be checked`, + MISSING_CAPABILITY_REFERENCES + ) + ); + } else { + checks.push({ + id: 'sep-2575-missing-capability-http-400', + name: 'MissingUrlModeCapabilityHttp400', + description: + 'MissingRequiredClientCapabilityError (-32021) MUST come with HTTP 400 Bad Request', + status: statusErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + statusErrors.length > 0 ? statusErrors.join('; ') : undefined, + specReferences: MISSING_CAPABILITY_REFERENCES, + details: { + statuses: Object.fromEntries( + rejected.map((e) => [e.label, e.response!.status]) + ) + } + }); + } + return checks; + } + + private noUrlElicitationRequiredCheck( + exchanges: Array, + toolMissing: string | undefined + ): ConformanceCheck { + const answered = exchanges.filter( + (exchange): exchange is Exchange => exchange?.response !== undefined + ); + const offenders = answered + .filter( + (exchange) => + exchange.response!.body?.error?.code === URL_ELICITATION_REQUIRED + ) + .map((exchange) => exchange.label); + + // A server that never got as far as a URL-mode request hasn't been + // exercised on the path where -32042 used to appear. + if (offenders.length === 0 && (toolMissing || answered.length === 0)) { + return untestableCheck( + 'sep-1036-server-no-url-elicitation-required-error', + 'ServerNoUrlElicitationRequiredError', + 'Implementations of 2026-07-28 MUST NOT emit -32042 (URL elicitation required, 2025-11-25 only)', + toolMissing ?? 'No request in this scenario got a response', + ERROR_CODES_REFERENCES + ); + } + + return { + id: 'sep-1036-server-no-url-elicitation-required-error', + name: 'ServerNoUrlElicitationRequiredError', + description: + 'Implementations of 2026-07-28 MUST NOT emit -32042 (URL elicitation required, 2025-11-25 only)', + status: offenders.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + offenders.length > 0 + ? `The server answered with -32042 (URLElicitationRequiredError) for: ${offenders.join(', ')}. On 2026-07-28 a URL-mode request goes in an InputRequiredResult instead.` + : undefined, + specReferences: ERROR_CODES_REFERENCES, + details: { requestsChecked: answered.map((exchange) => exchange.label) } + }; + } +} diff --git a/src/seps/sep-1036.yaml b/src/seps/sep-1036.yaml new file mode 100644 index 00000000..d12357eb --- /dev/null +++ b/src/seps/sep-1036.yaml @@ -0,0 +1,99 @@ +sep: 1036 +spec_url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation +requirements: + - check: sep-1036-client-supports-declared-mode + text: 'Clients declaring the `elicitation` capability MUST support at least one mode (`form` or `url`).' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities + - check: sep-1036-server-no-unsupported-mode + text: 'Servers MUST NOT send elicitation requests with modes that are not supported by the client.' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities + - check: sep-1036-client-missing-mode-is-form + text: 'Clients MUST treat requests without a `mode` field as form mode.' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#elicitation-requests + - check: sep-1036-url-request-params + text: 'URL mode elicitation requests MUST specify `mode: "url"`, a `message`, and include these additional parameters: `url` / All elicitation requests MUST include the following parameters: `mode`, `message`' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#url-mode-elicitation-requests + - check: sep-1036-url-valid + text: 'The `url` parameter MUST contain a valid URL.' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#url-mode-elicitation-requests + # Added to basic/index.mdx by spec PR #2907 (error code allocation policy), + # which is not a SEP. Tracked here because -32042 was SEP-1036's error code. + - check: sep-1036-server-no-url-elicitation-required-error + text: 'Codes defined by earlier protocol versions remain reserved and will not be reused. Implementations of this protocol version MUST NOT emit these codes: -32042 — URL elicitation required (2025-11-25 only).' + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes + # Observed only on an elicitation the client declines. A declined request + # means the user refused consent, so any request to that URL is a prefetch + # (or an unconsented open). The accept path can't be judged: in the spec's + # URL Mode Flow the client opens the URL before it sends the accept retry, + # so a consented open looks identical to a prefetch. Cancel can't be + # judged either, because the spec's cancel examples include "browser + # failed to load", which implies the URL was opened. + - check: sep-1036-client-no-url-prefetch + text: 'When handling URL mode elicitation requests, MCP clients: MUST NOT automatically pre-fetch the URL or any of its metadata.' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling + + # Scenario flow gates (sep-1036-client-held-json-retry-complete, + # sep-1036-client-held-sse-retry-complete) intentionally have no rows here. + # They verify the end-to-end URL flow against a server that holds the retry + # open. The URL Mode Flow allows a server to do that, but no RFC-2119 + # sentence requires clients to wait for it, so there is no requirement to + # track. They surface in the traceability manifest's `untracked` list. + + - text: 'Servers MUST NOT use form mode elicitation to request sensitive information such as passwords, API keys, access tokens, or payment credentials' + excluded: 'Whether requested data is sensitive is a semantic judgment about server intent; not protocol-observable' + - text: 'Servers MUST use URL mode for interactions involving such sensitive information' + excluded: 'Whether an interaction involves sensitive information is a semantic judgment about server intent; not protocol-observable' + - text: 'MCP clients MUST: Provide UI that makes it clear which server is requesting information / Clients MUST provide clear indication of which server is requesting information' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'MCP clients MUST: Respect user privacy and provide clear decline and cancel options' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'MCP clients MUST: For form mode, allow users to review and modify their responses before sending' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'MCP clients MUST: For URL mode, clearly display the target domain/host and gather user consent before navigation to the target URL' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'Clients SHOULD provide manual controls that let the user retry or cancel the original request (or otherwise resume interacting with the client).' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'Clients implementing URL mode elicitation MUST handle URLs carefully to prevent users from unknowingly clicking malicious links.' + excluded: 'Umbrella requirement for the Safe URL Handling list; the testable item is tracked as sep-1036-client-no-url-prefetch' + - text: 'When handling URL mode elicitation requests, MCP clients: MUST NOT open the URL without explicit consent from the user.' + excluded: 'User consent happens in client UI; the harness observes wire traffic only' + - text: 'When handling URL mode elicitation requests, MCP clients: MUST show the full URL to the user for examination before consent.' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'When handling URL mode elicitation requests, MCP clients: MUST open the URL provided by the server in a secure manner that does not enable the client or LLM to inspect the content or user inputs.' + excluded: 'How the client opens the URL (user agent choice, isolation from the LLM) is not protocol-observable' + - text: 'When handling URL mode elicitation requests, MCP clients: SHOULD highlight the domain of the URL to mitigate subdomain spoofing.' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'When handling URL mode elicitation requests, MCP clients: SHOULD have warnings for ambiguous/suspicious URIs (i.e., containing Punycode).' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'When handling URL mode elicitation requests, MCP clients: SHOULD NOT render URLs as clickable in any field of an elicitation request, except for the `url` field in a URL elicitation request (with the restrictions detailed above).' + excluded: 'Client UI requirement; the harness observes wire traffic only' + - text: 'If state is stored, servers implementing elicitation MUST securely associate this state with individual users following the guidelines in the security best practices document. Specifically: State storage MUST be protected against unauthorized access / For remote MCP servers, user identification MUST be derived from credentials acquired via MCP authorization when possible (e.g. `sub` claim)' + excluded: 'Server-internal state handling; not protocol-observable' + - text: 'MCP servers MUST NOT rely on URL mode elicitation to authorize users for themselves.' + excluded: 'Architectural constraint on server authorization design; not protocol-observable' + - text: 'The third-party credentials MUST NOT transit through the MCP client' + excluded: 'Third-party credentials are opaque to the harness; cannot distinguish them from other payloads' + - text: "The MCP server MUST NOT use the client's credentials for the third-party service" + excluded: 'Server-to-third-party traffic is out of band; not observable by the harness' + - text: 'The user MUST authorize the MCP server directly' + excluded: 'Out-of-band browser interaction; not protocol-observable' + - text: 'The MCP server MUST NOT transmit credentials obtained through URL mode elicitation to the MCP client.' + excluded: 'Third-party credentials are opaque to the harness; cannot distinguish them from other payloads' + - text: 'Servers MUST bind elicitation requests to the client and user identity' + excluded: 'Server-internal state handling; not protocol-observable' + - text: 'MCP servers requesting elicitation: MUST NOT include sensitive information about the end-user, including credentials, personally identifiable information, etc., in the URL sent to the client in a URL elicitation request.' + excluded: 'Whether URL contents are sensitive is a semantic judgment; not protocol-observable' + - text: 'MCP servers requesting elicitation: MUST NOT provide a URL which is pre-authenticated to access a protected resource, as the URL could be used to impersonate the user by a malicious client.' + excluded: 'Whether a URL is pre-authenticated depends on server-side semantics; not protocol-observable' + - text: 'MCP servers requesting elicitation: SHOULD NOT include URLs intended to be clickable in any field of a form mode elicitation request.' + excluded: 'Whether a URL is intended to be clickable is a semantic judgment; not protocol-observable' + - text: 'MCP servers requesting elicitation: SHOULD use HTTPS URLs for non-development environments.' + excluded: 'The harness only exercises local development servers, where the requirement does not apply' + - text: 'Servers MUST NOT rely on client-provided user identification without server verification, as this can be forged. Instead, servers SHOULD follow security best practices.' + excluded: 'Server-internal identity handling; not protocol-observable' + - text: 'The MCP Server MUST verify the identity of the user who opens the URL before accepting information.' + excluded: 'Out-of-band browser interaction; not protocol-observable' + - text: 'The server MUST ensure that the user who started the elicitation request (the end-user who is accessing the server via the MCP client) is the same user who completes the authorization flow.' + excluded: 'Out-of-band browser interaction; not protocol-observable' + - text: "In all implementations, the server MUST ensure that the mechanism to determine the user's identity is resilient to attacks where an attacker can modify the elicitation URL." + excluded: 'Out-of-band browser interaction; not protocol-observable' diff --git a/src/seps/sep-2322.yaml b/src/seps/sep-2322.yaml index 143a7ed1..7032e4c2 100644 --- a/src/seps/sep-2322.yaml +++ b/src/seps/sep-2322.yaml @@ -34,6 +34,14 @@ requirements: text: 'Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities.' # ── Client Requirements (Basic Workflow) ─────────────────────────────────── + - check: sep-2322-client-constructs-inputs + text: 'If a client receives an InputRequiredResult that contains the inputRequests field, the client MUST construct the requested inputs before retrying the original request.' + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow + + - check: sep-2322-client-constructs-inputs-mixed + text: 'If a client receives an InputRequiredResult that contains the inputRequests field, the client MUST construct the requested inputs before retrying the original request. (mixed URL and form input requests variant)' + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow + - check: sep-2322-client-request-state-echoed text: 'If an InputRequiredResult contains the requestState field, the client MUST echo back the exact value of that field when retrying the original request. Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents.' @@ -65,6 +73,12 @@ requirements: - check: sep-2322-missing-response-rerequests text: 'If the client fails to send all the information requested in a previous InputRequests, and the missing information is necessary for the server to process the request, the server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error.' + # Added to client/elicitation.mdx by the SEP-2322 PR (commit 4c31fcd), not + # by SEP-1036. Exercised by the URL-mode elicitation server scenario. + - check: sep-2322-server-handles-decline-cancel + text: 'Servers SHOULD NOT assume that elicitation requests will always succeed, and MUST handle cases where the user declines or cancels the elicitation, or where the client fails to process the request.' + url: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#error-handling + # ── Excluded requirements ────────────────────────────────────────────────── - text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request.'