Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
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,
Expand All @@ -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: {
Expand All @@ -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}`
Expand Down Expand Up @@ -1072,6 +1101,79 @@ async function runMRTRClient(serverUrl: string): Promise<void> {

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<string, unknown>
): Record<string, unknown> {
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<void> {
const list = await statelessRequest(serverUrl, 'tools/list', {
_meta: URL_MODE_META
});
for (const tool of list?.tools ?? []) {
let params: Record<string, unknown> = { 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<string, unknown> = {};
for (const [key, request] of Object.entries(
(result.inputRequests ?? {}) as Record<
string,
{ method: string; params?: Record<string, unknown> }
>
)) {
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
// ============================================================================
Expand Down
87 changes: 87 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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, {
Expand Down
142 changes: 142 additions & 0 deletions examples/servers/typescript/sep-1036-url-elicitation-broken-server.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};
const params = body.params ?? {};
const inputResponses = params.inputResponses as
| Record<string, { action?: string }>
| 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`
);
});
Loading
Loading