Conformance Tests for SEP-2322 MRTR (#188)

This commit is contained in:
Caitie McCaffrey 2026-05-22 14:26:11 -07:00 • committed by GitHub
commit 43fbf60d3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 3507 additions and 3 deletions

View file

@ -726,6 +726,149 @@ registerScenario(
runEnterpriseManagedAuthorization
);
// ============================================================================
// MRTR client conformance (SEP-2322)
// ============================================================================
async function runMRTRClient(serverUrl: string): Promise<void> {
let nextId = 1;
async function sendRpc(
method: string,
params?: Record<string, unknown>
): Promise<{
id: number;
result?: Record<string, unknown>;
error?: { code: number; message: string };
}> {
const id = nextId++;
const body: Record<string, unknown> = {
jsonrpc: '2.0',
id,
method
};
if (params) body.params = params;
const resp = await fetch(serverUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (resp.status === 204) return { id, result: {} };
return (await resp.json()) as {
id: number;
result?: Record<string, unknown>;
error?: { code: number; message: string };
};
}
// List tools
const toolsResp = await sendRpc('tools/list');
const tools =
(toolsResp.result as { tools: Array<{ name: string }> })?.tools ?? [];
logger.debug(
'Available tools:',
tools.map((t) => t.name)
);
// Tool 1: test_mrtr_echo_state — call, get InputRequiredResult with requestState, retry
const r1 = await sendRpc('tools/call', {
name: 'test_mrtr_echo_state',
arguments: {}
});
const r1Result = r1.result as Record<string, unknown> | undefined;
if (r1Result?.resultType === 'input_required') {
const inputRequests = r1Result.inputRequests as Record<string, unknown>;
const requestState = r1Result.requestState as string | undefined;
// Build inputResponses by fulfilling each inputRequest
const inputResponses: Record<string, unknown> = {};
for (const [key, req] of Object.entries(inputRequests)) {
const request = req as { method: string; params: unknown };
if (request.method === 'elicitation/create') {
inputResponses[key] = {
action: 'accept',
content: { confirmed: true }
};
}
}
// Call an unrelated tool BEFORE retrying — must NOT carry over inputResponses/requestState
await sendRpc('tools/call', {
name: 'test_mrtr_unrelated',
arguments: {}
});
logger.debug(
'test_mrtr_unrelated: called without MRTR state (isolation check)'
);
// Retry with inputResponses + requestState echoed back unchanged
const retryParams: Record<string, unknown> = {
name: 'test_mrtr_echo_state',
arguments: {},
inputResponses
};
if (requestState !== undefined) {
retryParams.requestState = requestState;
}
await sendRpc('tools/call', retryParams);
logger.debug('test_mrtr_echo_state: MRTR flow completed');
}
// Tool 2: test_mrtr_no_state — call, get InputRequiredResult WITHOUT requestState, retry without it
const r2 = await sendRpc('tools/call', {
name: 'test_mrtr_no_state',
arguments: {}
});
const r2Result = r2.result as Record<string, unknown> | undefined;
if (r2Result?.resultType === 'input_required') {
const inputRequests = r2Result.inputRequests as Record<string, unknown>;
// Build inputResponses
const inputResponses: Record<string, unknown> = {};
for (const [key, req] of Object.entries(inputRequests)) {
const request = req as { method: string; params: unknown };
if (request.method === 'elicitation/create') {
inputResponses[key] = {
action: 'accept',
content: { confirmed: true }
};
}
}
// Retry WITHOUT requestState (server didn't send one)
await sendRpc('tools/call', {
name: 'test_mrtr_no_state',
arguments: {},
inputResponses
});
logger.debug('test_mrtr_no_state: MRTR flow completed');
}
// Tool 3: test_mrtr_no_result_type — returns result without resultType field
// Client must treat it as complete (default) and NOT retry
const r3 = await sendRpc('tools/call', {
name: 'test_mrtr_no_result_type',
arguments: {}
});
const r3Result = r3.result as Record<string, unknown> | undefined;
if (r3Result && !r3Result.resultType) {
// No resultType means default to "complete" — do nothing, don't retry
logger.debug(
'test_mrtr_no_result_type: result has no resultType, treating as complete'
);
}
logger.debug('MRTR client scenario completed');
}
registerScenario('sep-2322-client-request-state', runMRTRClient);
// ============================================================================
// Main entry point
// ============================================================================

View file

@ -31,12 +31,43 @@ import {
import { z } from 'zod';
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
import cors from 'cors';
import { randomUUID } from 'crypto';
import { randomUUID, createHmac } from 'crypto';
// Server state
const resourceSubscriptions = new Set<string>();
const watchedResourceContent = 'Watched resource content';
// HMAC-based requestState for SEP-2322 MRTR integrity tests
const MRTR_STATE_SECRET = 'conformance-mrtr-secret-' + randomUUID();
function signMrtState(payload: Record<string, unknown>): string {
const data = JSON.stringify(payload);
const hmac = createHmac('sha256', MRTR_STATE_SECRET)
.update(data)
.digest('hex');
return JSON.stringify({ data, hmac });
}
function verifyMrtState(raw: string): Record<string, unknown> | null {
try {
const { data, hmac } = JSON.parse(raw) as { data: string; hmac: string };
const expected = createHmac('sha256', MRTR_STATE_SECRET)
.update(data)
.digest('hex');
if (hmac !== expected) return null;
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
function getMrtInputText(inputResponse: unknown, field: string): string {
const content = (inputResponse as Record<string, unknown> | undefined)
?.content as Record<string, unknown> | undefined;
const value = content?.[field];
return typeof value === 'string' ? value : 'unknown';
}
// Session management
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};
@ -1264,6 +1295,52 @@ app.post('/mcp', async (req, res) => {
description: 'Test tool requiring sampling',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_elicitation',
description:
'MRTR: returns InputRequiredResult with elicitation request',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_sampling',
description:
'MRTR: returns InputRequiredResult with sampling request',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_list_roots',
description:
'MRTR: returns InputRequiredResult with roots/list request',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_request_state',
description:
'MRTR: returns InputRequiredResult with requestState',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_multiple_inputs',
description:
'MRTR: returns InputRequiredResult with multiple input requests',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_multi_round',
description: 'MRTR: multi-round InputRequiredResult workflow',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_tampered_state',
description: 'MRTR: HMAC-signed requestState integrity test',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_input_required_result_capabilities',
description:
'MRTR: respects client capabilities in inputRequests',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'test_streaming_elicitation',
description:
@ -1285,12 +1362,74 @@ app.post('/mcp', async (req, res) => {
return res.json({
jsonrpc: '2.0',
id,
result: { prompts: [] }
result: {
prompts: [
{
name: 'test_input_required_result_prompt',
description: 'MRTR: prompt that requires elicitation input'
}
]
}
});
}
// SEP-2322 MRTR: prompts/get handler
if (method === 'prompts/get') {
if (params.name === 'test_input_required_result_prompt') {
const inputResponses = params.inputResponses as
| Record<string, unknown>
| undefined;
if (inputResponses?.['user_context']) {
const context = getMrtInputText(
inputResponses['user_context'],
'context'
);
return res.json({
jsonrpc: '2.0',
id,
result: {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Prompt with context: ${context}`
}
}
]
}
});
}
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
user_context: {
method: 'elicitation/create',
params: {
message: 'What context should the prompt use?',
requestedSchema: {
type: 'object',
properties: { context: { type: 'string' } },
required: ['context']
}
}
}
}
}
});
}
}
if (method === 'tools/call') {
const name = params.name;
const inputResponses = params.inputResponses as
| Record<string, unknown>
| undefined;
const requestState = params.requestState as string | undefined;
if (name === 'test_missing_capability') {
const clientCaps = meta['io.modelcontextprotocol/clientCapabilities'];
@ -1313,6 +1452,455 @@ app.post('/mcp', async (req, res) => {
});
}
// ===== SEP-2322 MRTR tools/call handlers =====
if (name === 'test_input_required_result_elicitation') {
if (inputResponses?.['user_name']) {
const userName = getMrtInputText(inputResponses['user_name'], 'name');
return res.json({
jsonrpc: '2.0',
id,
result: { content: [{ type: 'text', text: `Hello, ${userName}!` }] }
});
}
return res.json({
jsonrpc: '2.0',
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']
}
}
}
}
}
});
}
if (name === 'test_input_required_result_sampling') {
if (inputResponses?.['sample_request']) {
const sample = inputResponses['sample_request'] as Record<
string,
unknown
>;
const content = sample.content as Record<string, unknown> | undefined;
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}`
}
]
}
});
}
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
sample_request: {
method: 'sampling/createMessage',
params: {
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'What is the capital of France?'
}
}
],
maxTokens: 100
}
}
}
}
});
}
if (name === 'test_input_required_result_list_roots') {
if (inputResponses?.['roots_request']) {
const rootsResult = inputResponses['roots_request'] as Record<
string,
unknown
>;
const roots = Array.isArray(rootsResult.roots)
? rootsResult.roots
: [];
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: `Found ${roots.length} root(s)` }]
}
});
}
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
roots_request: { method: 'roots/list', params: {} }
}
}
});
}
if (name === 'test_input_required_result_request_state') {
if (requestState && inputResponses?.['confirm']) {
const state = JSON.parse(requestState) as Record<string, unknown>;
const ok = (inputResponses['confirm'] as Record<string, unknown>)
?.content as Record<string, unknown> | undefined;
if (state.kind === 'request-state' && ok?.ok === true) {
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{ type: 'text', text: 'state-ok: requestState validated' }
]
}
});
}
}
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
confirm: {
method: 'elicitation/create',
params: {
message: 'Please confirm',
requestedSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok']
}
}
}
},
requestState: JSON.stringify({
kind: 'request-state',
nonce: randomUUID()
})
}
});
}
if (name === 'test_input_required_result_multiple_inputs') {
if (
requestState &&
inputResponses?.['user_name'] &&
inputResponses['greeting'] &&
inputResponses['client_roots']
) {
const state = JSON.parse(requestState) as Record<string, unknown>;
if (state.kind === 'multiple-inputs') {
const userName = getMrtInputText(
inputResponses['user_name'],
'name'
);
const greetingContent = (
inputResponses['greeting'] as Record<string, unknown>
).content as Record<string, unknown> | undefined;
const greeting =
typeof greetingContent?.text === 'string'
? greetingContent.text
: 'Hello there!';
const rootsResult = inputResponses['client_roots'] as Record<
string,
unknown
>;
const roots = Array.isArray(rootsResult.roots)
? rootsResult.roots
: [];
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: `Name: ${userName}; Greeting: ${greeting}; Roots: ${roots.length}`
}
]
}
});
}
}
return res.json({
jsonrpc: '2.0',
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']
}
}
},
greeting: {
method: 'sampling/createMessage',
params: {
messages: [
{
role: 'user',
content: { type: 'text', text: 'Generate a greeting' }
}
],
maxTokens: 50
}
},
client_roots: { method: 'roots/list', params: {} }
},
requestState: JSON.stringify({
kind: 'multiple-inputs',
nonce: randomUUID()
})
}
});
}
if (name === 'test_input_required_result_multi_round') {
if (!requestState) {
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
step1: {
method: 'elicitation/create',
params: {
message: 'Step 1: What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
}
}
},
requestState: JSON.stringify({ round: 1, nonce: randomUUID() })
}
});
}
const state = JSON.parse(requestState) as Record<string, unknown>;
if (state.round === 1 && inputResponses?.['step1']) {
const userName = getMrtInputText(inputResponses['step1'], 'name');
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
step2: {
method: 'elicitation/create',
params: {
message: 'Step 2: What is your favorite color?',
requestedSchema: {
type: 'object',
properties: { color: { type: 'string' } },
required: ['color']
}
}
}
},
requestState: JSON.stringify({
round: 2,
name: userName,
nonce: randomUUID()
})
}
});
}
if (state.round === 2 && inputResponses?.['step2']) {
const userName =
typeof state.name === 'string' ? state.name : 'friend';
const color = getMrtInputText(inputResponses['step2'], 'color');
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: `Multi-round complete for ${userName} who likes ${color}`
}
]
}
});
}
// Fallback: restart
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
step1: {
method: 'elicitation/create',
params: {
message: 'Step 1: What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
}
}
},
requestState: JSON.stringify({ round: 1, nonce: randomUUID() })
}
});
}
if (name === 'test_input_required_result_tampered_state') {
if (requestState) {
const verified = verifyMrtState(requestState);
if (!verified) {
return res.json({
jsonrpc: '2.0',
id,
error: {
code: -32602,
message: 'requestState integrity check failed'
}
});
}
if (verified.kind === 'tamper-test' && inputResponses?.['confirm']) {
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{ type: 'text', text: 'integrity-ok: state verified' }
]
}
});
}
}
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
confirm: {
method: 'elicitation/create',
params: {
message: 'Please confirm',
requestedSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok']
}
}
}
},
requestState: signMrtState({
kind: 'tamper-test',
nonce: randomUUID()
})
}
});
}
if (name === 'test_input_required_result_capabilities') {
const clientCaps = meta[
'io.modelcontextprotocol/clientCapabilities'
] as Record<string, unknown> | undefined;
const inputRequests: Record<string, unknown> = {};
if (clientCaps?.elicitation) {
inputRequests['elicit_input'] = {
method: 'elicitation/create',
params: {
message: 'Elicitation input',
requestedSchema: {
type: 'object',
properties: { value: { type: 'string' } },
required: ['value']
}
}
};
}
if (clientCaps?.sampling) {
inputRequests['sample_input'] = {
method: 'sampling/createMessage',
params: {
messages: [
{
role: 'user',
content: { type: 'text', text: 'Sample request' }
}
],
maxTokens: 50
}
};
}
if (inputResponses && Object.keys(inputResponses).length > 0) {
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: `capabilities-ok: received ${Object.keys(inputResponses).join(',')}`
}
]
}
});
}
if (Object.keys(inputRequests).length === 0) {
return res.json({
jsonrpc: '2.0',
id,
result: {
content: [
{ type: 'text', text: 'No supported capabilities declared' }
]
}
});
}
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests,
requestState: signMrtState({
kind: 'capabilities-test',
nonce: randomUUID()
})
}
});
}
// Progressive IncompleteResult Stream Generator Handling
if (name === 'test_streaming_elicitation') {
res.writeHead(200, {

View file

@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* SEP-2322 MRTR Broken Server — Negative Test Case
*
* Deliberately violates several SEP-2322 MUST requirements:
* 1. Omits `resultType` field from InputRequiredResult responses
* 2. Returns InputRequiredResult on `tools/list` (unsupported method)
* 3. Accepts tampered requestState without integrity verification
*
* The conformance scenarios should emit FAILURE against this server.
*/
import express from 'express';
import { randomUUID } from 'crypto';
const PORT = parseInt(process.env.PORT || '3011', 10);
// --- JSON-RPC dispatch ---
type Handler = (params: Record<string, unknown>) => unknown;
const handlers: Record<string, Handler> = {};
handlers['server/discover'] = () => ({
supportedVersions: ['DRAFT-2026-v1'],
capabilities: {
tools: {},
prompts: {},
elicitation: {}
},
serverInfo: { name: 'sep-2322-mrtr-broken-server', version: '1.0.0' }
});
// BUG 2: Returns InputRequiredResult on tools/list (unsupported method)
handlers['tools/list'] = () => ({
resultType: 'input_required',
inputRequests: {
bogus: {
method: 'elicitation/create',
params: {
message: 'This should not happen on tools/list',
requestedSchema: { type: 'object', properties: {} }
}
}
},
tools: [
{
name: 'test_input_required_result_elicitation',
description: 'Test tool for elicitation',
inputSchema: { type: 'object' as const, properties: {} }
},
{
name: 'test_input_required_result_tampered_state',
description: 'Test tool for tampered state',
inputSchema: { type: 'object' as const, properties: {} }
}
]
});
handlers['prompts/list'] = () => ({
prompts: []
});
handlers['tools/call'] = (params) => {
const toolName = params.name as string;
const inputResponses = params.inputResponses as
| Record<string, unknown>
| undefined;
switch (toolName) {
case 'test_input_required_result_elicitation': {
if (inputResponses?.['user_name']) {
return {
content: [{ type: 'text', text: 'Hello!' }]
};
}
// BUG 1: Omits `resultType` field — spec says MUST include it
return {
// resultType: 'input_required', <-- deliberately omitted
inputRequests: {
user_name: {
method: 'elicitation/create',
params: {
message: 'What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
}
}
}
};
}
case 'test_input_required_result_tampered_state': {
if (inputResponses) {
// BUG 3: Accepts ANY requestState without verification
// A compliant server MUST reject tampered state
return {
content: [{ type: 'text', text: 'Accepted (no integrity check)' }]
};
}
return {
resultType: 'input_required',
inputRequests: {
confirm: {
method: 'elicitation/create',
params: {
message: 'Confirm?',
requestedSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok']
}
}
}
},
requestState: 'unprotected-state-' + randomUUID()
};
}
default:
throw { code: -32602, message: `Unknown tool: ${toolName}` };
}
};
// --- Express app ---
const app = express();
app.use(express.json());
app.post('/mcp', (req, res) => {
const body = req.body as {
jsonrpc: string;
id: number;
method: string;
params?: Record<string, unknown>;
};
const handler = handlers[body.method];
if (!handler) {
res.json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32601, message: `Method not found: ${body.method}` }
});
return;
}
try {
const result = handler(body.params || {});
res.json({ jsonrpc: '2.0', id: body.id, result });
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
res.json({
jsonrpc: '2.0',
id: body.id,
error: {
code: error.code || -32603,
message: error.message || 'Internal error'
}
});
}
});
app.listen(PORT, () => {
console.log(
`sep-2322-mrtr-broken-server running on http://localhost:${PORT}/mcp`
);
});

View file

@ -0,0 +1,41 @@
/**
* Integration test for MRTR client conformance scenario (SEP-2322).
*
* Runs the everything-client's MRTR handler in-process against the scenario server
* and verifies all checks pass.
*/
import { describe, test, expect } from 'vitest';
import {
runClientAgainstScenario,
InlineClientRunner
} from './auth/test_helpers/testClient';
import { getHandler } from '../../../examples/clients/typescript/everything-client';
import { getScenario } from '../index';
describe('MRTR client scenario (SEP-2322)', () => {
test('everything-client passes sep-2322-client-request-state scenario', async () => {
const clientFn = getHandler('sep-2322-client-request-state');
if (!clientFn) {
throw new Error(
'No handler registered for scenario: sep-2322-client-request-state'
);
}
const scenario = getScenario('sep-2322-client-request-state');
if (!scenario) {
throw new Error('Scenario not found: sep-2322-client-request-state');
}
const runner = new InlineClientRunner(clientFn);
await runClientAgainstScenario(runner, 'sep-2322-client-request-state');
const checks = scenario.getChecks();
for (const check of checks) {
expect(
check.status,
`Check "${check.id}" failed: ${check.errorMessage ?? ''}`
).toBe('SUCCESS');
}
});
});

View file

@ -0,0 +1,486 @@
/**
* SEP-2322: MRTR Client Conformance Tests
*
* Tests that clients correctly handle the MRTR (Multi-Round Tool Resolution) flow:
* - Echo requestState back unchanged when retrying
* - Don't include requestState when server didn't send one
* - Use a different JSON-RPC id on retry
*
* The server exposes two tools. The client calls each tool, gets InputRequiredResult,
* fulfills the elicitation, and retries. The server verifies correct client behavior.
*/
import type { Scenario, ConformanceCheck } from '../../types';
import { DRAFT_PROTOCOL_VERSION, ScenarioUrls } from '../../types';
import express, { Request, Response } from 'express';
import { randomUUID } from 'crypto';
const MRTR_SPEC_REFERENCES = [
{
id: 'SEP-2322-MRTR',
url: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr'
}
];
const TOOLS = [
{
name: 'test_mrtr_echo_state',
description:
'Test tool: triggers MRTR flow with requestState. Client must echo state back unchanged.',
inputSchema: {
type: 'object' as const,
properties: {},
required: [] as string[]
}
},
{
name: 'test_mrtr_no_state',
description:
'Test tool: triggers MRTR flow WITHOUT requestState. Client must NOT include requestState in retry.',
inputSchema: {
type: 'object' as const,
properties: {},
required: [] as string[]
}
},
{
name: 'test_mrtr_unrelated',
description:
'Test tool: simple tool called between MRTR rounds. Must NOT carry inputResponses or requestState from another tool.',
inputSchema: {
type: 'object' as const,
properties: {},
required: [] as string[]
}
},
{
name: 'test_mrtr_no_result_type',
description:
'Test tool: returns a result without resultType. Client must treat it as complete (default).',
inputSchema: {
type: 'object' as const,
properties: {},
required: [] as string[]
}
}
];
interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: Record<string, unknown>;
}
function createMRTRServer(checks: ConformanceCheck[]): express.Application {
const app = express();
app.use(express.json());
// Track original JSON-RPC ids per tool to verify they change on retry
const originalIds = new Map<string, string | number>();
// Track the exact requestState string sent, to verify byte-exact echo on retry
const sentStates = new Map<string, string>();
app.post('/mcp', (req: Request, res: Response) => {
const body = req.body as JsonRpcRequest;
const { id, method, params } = body;
switch (method) {
case 'notifications/initialized': {
res.status(204).end();
return;
}
case 'tools/list': {
res.json({
jsonrpc: '2.0',
id,
result: { tools: TOOLS }
});
return;
}
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string;
const inputResponses = (params as Record<string, unknown>)
?.inputResponses as Record<string, unknown> | undefined;
const requestState = (params as Record<string, unknown>)
?.requestState as string | undefined;
if (toolName === 'test_mrtr_echo_state') {
handleEchoState(id, inputResponses, requestState, checks, res);
return;
}
if (toolName === 'test_mrtr_no_state') {
handleNoState(id, inputResponses, requestState, checks, res);
return;
}
if (toolName === 'test_mrtr_unrelated') {
handleUnrelated(inputResponses, requestState, checks, res, id);
return;
}
if (toolName === 'test_mrtr_no_result_type') {
handleNoResultType(id, inputResponses, checks, res);
return;
}
res.json({
jsonrpc: '2.0',
id,
error: { code: -32601, message: `Unknown tool: ${toolName}` }
});
return;
}
case 'elicitation/create': {
// Client is fulfilling our ElicitRequest — accept it
res.json({
jsonrpc: '2.0',
id,
result: { action: 'accept', content: { confirmed: true } }
});
return;
}
default: {
res.json({
jsonrpc: '2.0',
id,
error: { code: -32601, message: `Method not found: ${method}` }
});
return;
}
}
});
function handleEchoState(
id: string | number,
inputResponses: Record<string, unknown> | undefined,
requestState: string | undefined,
checks: ConformanceCheck[],
res: Response
) {
if (!inputResponses) {
// Initial call — store original id, return InputRequiredResult with requestState
originalIds.set('echo_state', id);
const state = JSON.stringify({
nonce: randomUUID(),
originalId: id
});
sentStates.set('echo_state', state);
res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
confirm: {
method: 'elicitation/create',
params: {
message: 'Please confirm to continue',
requestedSchema: {
type: 'object',
properties: {
confirmed: { type: 'boolean', description: 'Confirm?' }
}
}
}
}
},
requestState: state
}
});
return;
}
// Retry — verify requestState was echoed back correctly
const originalId = originalIds.get('echo_state');
const sentState = sentStates.get('echo_state');
// Check 1: requestState must be present and byte-for-byte identical to
// what the server sent. The spec requires the client to echo back the
// *exact value* without inspecting, parsing, or modifying it — so a
// semantically-equal but re-serialized string is still a failure.
const stateErrors: string[] = [];
if (!requestState) {
stateErrors.push('Client did not include requestState in retry');
} else if (requestState !== sentState) {
stateErrors.push(
'requestState was not echoed back exactly — clients MUST NOT inspect, parse, or modify it'
);
}
checks.push({
id: 'sep-2322-client-request-state-echoed',
name: 'MRTRClientRequestStateEchoed',
description:
'Client MUST echo back the exact value of requestState when retrying',
status: stateErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: stateErrors.length > 0 ? stateErrors.join('; ') : undefined,
specReferences: MRTR_SPEC_REFERENCES,
details: {
requestStateSent: sentState,
requestStateReceived: requestState,
originalId
}
});
// Check 2: JSON-RPC id must differ from original
const idErrors: string[] = [];
if (id === originalId) {
idErrors.push(
`JSON-RPC id is the same on retry (${id}) — MUST be different`
);
}
checks.push({
id: 'sep-2322-client-jsonrpc-id-different',
name: 'MRTRClientJsonRpcIdDifferent',
description:
'The JSON-RPC id MUST be different between the initial request and the retry',
status: idErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: idErrors.length > 0 ? idErrors.join('; ') : undefined,
specReferences: MRTR_SPEC_REFERENCES,
details: {
originalId,
retryId: id
}
});
// Return complete result
res.json({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: 'echo-state-ok' }]
}
});
}
function handleNoState(
id: string | number,
inputResponses: Record<string, unknown> | undefined,
requestState: string | undefined,
checks: ConformanceCheck[],
res: Response
) {
if (!inputResponses) {
// Initial call — return InputRequiredResult WITHOUT requestState
res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'input_required',
inputRequests: {
confirm: {
method: 'elicitation/create',
params: {
message: 'Please confirm to continue (no state test)',
requestedSchema: {
type: 'object',
properties: {
confirmed: { type: 'boolean', description: 'Confirm?' }
}
}
}
}
}
// No requestState field!
}
});
return;
}
// Retry — verify client did NOT include requestState
const errors: string[] = [];
if (requestState !== undefined) {
errors.push(
`Client included requestState ("${requestState}") but server did not send one — MUST NOT include it`
);
}
checks.push({
id: 'sep-2322-client-no-state-omitted',
name: 'MRTRClientNoStateOmitted',
description:
'If InputRequiredResult does not contain requestState, client MUST NOT include one in the retry',
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
specReferences: MRTR_SPEC_REFERENCES,
details: {
requestStateReceived: requestState
}
});
// Return complete result
res.json({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: 'no-state-ok' }]
}
});
}
function handleUnrelated(
inputResponses: Record<string, unknown> | undefined,
requestState: string | undefined,
checks: ConformanceCheck[],
res: Response,
id: string | number
) {
// This tool should NEVER receive inputResponses or requestState —
// those belong to a different tool's MRTR flow
const errors: string[] = [];
if (inputResponses !== undefined) {
errors.push(
`Unrelated tool call included inputResponses from another tool's MRTR flow`
);
}
if (requestState !== undefined) {
errors.push(
`Unrelated tool call included requestState from another tool's MRTR flow`
);
}
checks.push({
id: 'sep-2322-client-parallel-isolation',
name: 'MRTRClientParallelIsolation',
description:
'inputRequests and requestState MUST NOT be used for any other request the client may be sending',
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
specReferences: MRTR_SPEC_REFERENCES,
details: {
inputResponsesReceived: inputResponses,
requestStateReceived: requestState
}
});
// Return a normal complete result
res.json({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: 'unrelated-ok' }]
}
});
}
function handleNoResultType(
id: string | number,
inputResponses: Record<string, unknown> | undefined,
checks: ConformanceCheck[],
res: Response
) {
const checkId = 'sep-2322-default-result-type-complete';
// If the client retries this tool, it means it did NOT treat the result as complete
if (inputResponses) {
// Remove any prior SUCCESS for this check (emitted when we first sent the response)
const existingIdx = checks.findIndex((c) => c.id === checkId);
if (existingIdx !== -1) checks.splice(existingIdx, 1);
checks.push({
id: checkId,
name: 'DefaultResultTypeComplete',
description:
'Client MUST assume resultType "complete" when not specified',
status: 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage:
'Client retried with inputResponses even though the result had no resultType (should default to complete)',
specReferences: MRTR_SPEC_REFERENCES
});
res.json({
jsonrpc: '2.0',
id,
result: { content: [{ type: 'text', text: 'unexpected-retry' }] }
});
return;
}
// Return a result WITHOUT resultType — client should treat as complete
checks.push({
id: checkId,
name: 'DefaultResultTypeComplete',
description:
'Client MUST assume resultType "complete" when not specified',
status: 'SUCCESS',
timestamp: new Date().toISOString(),
specReferences: MRTR_SPEC_REFERENCES
});
res.json({
jsonrpc: '2.0',
id,
result: {
// Deliberately NO resultType field
content: [{ type: 'text', text: 'no-result-type-test-ok' }]
}
});
}
return app;
}
export class MRTRClientScenario implements Scenario {
name = 'sep-2322-client-request-state';
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
description =
'Tests client MRTR behavior: requestState echo, no-state omission, and JSON-RPC id uniqueness (SEP-2322)';
private app: express.Application | null = null;
private httpServer: ReturnType<express.Application['listen']> | null = null;
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
this.app = createMRTRServer(this.checks);
this.httpServer = this.app.listen(0);
const addr = this.httpServer.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
return { serverUrl: `http://localhost:${port}/mcp` };
}
async stop() {
if (this.httpServer) {
await new Promise((resolve) => this.httpServer!.close(resolve));
this.httpServer = null;
}
this.app = null;
}
getChecks(): ConformanceCheck[] {
const expectedSlugs = [
'sep-2322-client-request-state-echoed',
'sep-2322-client-jsonrpc-id-different',
'sep-2322-client-no-state-omitted',
'sep-2322-client-parallel-isolation',
'sep-2322-default-result-type-complete'
];
for (const slug of expectedSlugs) {
if (!this.checks.find((c) => c.id === slug)) {
this.checks.push({
id: slug,
name: slug,
description: `MRTR client check: ${slug}`,
status: 'FAILURE',
timestamp: new Date().toISOString(),
details: {
message: 'Tool was not called by client or MRTR flow not completed'
},
specReferences: MRTR_SPEC_REFERENCES
});
}
}
return this.checks;
}
}

View file

@ -14,6 +14,7 @@ import { ToolsCallScenario } from './client/tools_call';
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults';
import { SSERetryScenario } from './client/sse-retry';
import { RequestMetadataScenario } from './client/request-metadata';
import { MRTRClientScenario } from './client/mrtr-client';
// Import all new server test scenarios
import { ServerInitializeScenario } from './server/lifecycle';
@ -67,6 +68,24 @@ import {
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
import { CachingScenario } from './server/caching';
// InputRequiredResult scenarios from (SEP-2322)
import {
InputRequiredResultBasicElicitationScenario,
InputRequiredResultBasicSamplingScenario,
InputRequiredResultBasicListRootsScenario,
InputRequiredResultRequestStateScenario,
InputRequiredResultMultipleInputRequestsScenario,
InputRequiredResultMultiRoundScenario,
InputRequiredResultMissingInputResponseScenario,
InputRequiredResultNonToolRequestScenario,
InputRequiredResultResultTypeScenario,
InputRequiredResultUnsupportedMethodsScenario,
InputRequiredResultTamperedStateScenario,
InputRequiredResultCapabilityCheckScenario,
InputRequiredResultIgnoreExtraParamsScenario,
InputRequiredResultValidateInputScenario
} from './server/input-required-result';
import {
HttpHeaderValidationScenario,
HttpCustomHeaderServerValidationScenario
@ -167,7 +186,23 @@ const allClientScenariosList: ClientScenario[] = [
new CachingScenario(),
// HTTP Standardization scenarios (SEP-2243)
new HttpHeaderValidationScenario(),
new HttpCustomHeaderServerValidationScenario()
new HttpCustomHeaderServerValidationScenario(),
// InputRequiredResult scenarios (SEP-2322)
new InputRequiredResultBasicElicitationScenario(),
new InputRequiredResultBasicSamplingScenario(),
new InputRequiredResultBasicListRootsScenario(),
new InputRequiredResultRequestStateScenario(),
new InputRequiredResultMultipleInputRequestsScenario(),
new InputRequiredResultMultiRoundScenario(),
new InputRequiredResultMissingInputResponseScenario(),
new InputRequiredResultNonToolRequestScenario(),
new InputRequiredResultResultTypeScenario(),
new InputRequiredResultUnsupportedMethodsScenario(),
new InputRequiredResultTamperedStateScenario(),
new InputRequiredResultCapabilityCheckScenario(),
new InputRequiredResultIgnoreExtraParamsScenario(),
new InputRequiredResultValidateInputScenario()
];
// Active client scenarios (excludes pending)
@ -213,6 +248,9 @@ const scenariosList: Scenario[] = [
...draftScenariosList,
...extensionScenariosList,
// MRTR client conformance (SEP-2322)
new MRTRClientScenario(),
// HTTP Standardization scenarios (SEP-2243)
new HttpStandardHeadersScenario(),
new HttpCustomHeadersScenario(),

View file

@ -0,0 +1,166 @@
/**
* Helpers for SEP-2322 conformance tests.
*
* Provides InputRequiredResult-specific type guards, mock response builders,
* and a stateless JSON-RPC transport helper.
*/
import { DRAFT_PROTOCOL_VERSION } from '../../types';
// ─── JSON-RPC Types ──────────────────────────────────────────────────────────
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: number;
result?: Record<string, unknown>;
error?: { code: number; message: string; data?: unknown };
}
// ─── Stateless RPC Helper ────────────────────────────────────────────────────
let nextId = 1;
/**
* Send a stateless JSON-RPC request (SEP-2575 pattern).
* Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities.
*/
export async function sendRpc(
serverUrl: string,
method: string,
params?: Record<string, unknown>
): Promise<JsonRpcResponse> {
const id = nextId++;
const enrichedParams = {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': {
name: 'conformance-test-client',
version: '1.0.0'
},
'io.modelcontextprotocol/clientCapabilities': {
sampling: {},
elicitation: {},
roots: { listChanged: true }
},
...(params?._meta as Record<string, unknown> | undefined)
}
};
const response = await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION
},
body: JSON.stringify({ jsonrpc: '2.0', id, method, params: enrichedParams })
});
return (await response.json()) as JsonRpcResponse;
}
// ─── InputRequiredResult Types ───────────────────────────────────────────────
export interface InputRequiredResultData {
resultType?: 'input_required';
inputRequests?: Record<string, InputRequestObject>;
requestState?: string;
_meta?: Record<string, unknown>;
[key: string]: unknown;
}
export interface InputRequestObject {
method: string;
params?: Record<string, unknown>;
}
// ─── Type Guards ─────────────────────────────────────────────────────────────
/**
* Check if a JSON-RPC result is an InputRequiredResult.
*/
export function isInputRequiredResult(
result: Record<string, unknown> | undefined
): result is InputRequiredResultData {
if (!result) return false;
if (result.resultType === 'input_required') return true;
return false;
}
/**
* Check if a JSON-RPC result is a complete result (not input_required).
* complete is the default so if resultType is missing we assume it's complete.
*/
export function isCompleteResult(
result: Record<string, unknown> | undefined
): boolean {
if (!result) return false;
if (result.resultType === 'input_required') return false;
return true;
}
/**
* Extract inputRequests from an InputRequiredResult.
*/
export function getInputRequests(
result: InputRequiredResultData
): Record<string, InputRequestObject> | undefined {
return result.inputRequests;
}
// ─── Mock Response Builders ──────────────────────────────────────────────────
/**
* Build a mock elicitation response (ElicitResult).
*/
export function mockElicitResponse(
content: Record<string, unknown>
): Record<string, unknown> {
return {
action: 'accept',
content
};
}
/**
* Build a mock sampling response (CreateMessageResult).
*/
export function mockSamplingResponse(text: string): Record<string, unknown> {
return {
role: 'assistant',
content: {
type: 'text',
text
},
model: 'test-model',
stopReason: 'endTurn'
};
}
/**
* Build a mock list roots response (ListRootsResult).
*/
export function mockListRootsResponse(): Record<string, unknown> {
return {
roots: [
{
uri: 'file:///test/root',
name: 'Test Root'
}
]
};
}
// ─── Spec References ─────────────────────────────────────────────────────────
/**
* SEP reference for InputRequiredResult / MRTR tests.
*/
export const MRTR_SPEC_REFERENCES = [
{
id: 'SEP-2322',
url: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr'
}
];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
/**
* SEP-2322 MRTR negative tests.
*
* Positive tests run via all-scenarios.test.ts against the everything-server
* (which implements MRTR in its stateless path). These negative tests run
* against a deliberately broken server to verify checks emit FAILURE.
*/
import { spawn, ChildProcess } from 'child_process';
import { createServer } from 'net';
import path from 'path';
import {
InputRequiredResultResultTypeScenario,
InputRequiredResultUnsupportedMethodsScenario,
InputRequiredResultTamperedStateScenario
} from './input-required-result';
function getFreePort(): Promise<number> {
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<ChildProcess> {
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<void> {
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');
});
}
describe('SEP-2322 MRTR negative tests', () => {
let serverProcess: ChildProcess | null = null;
let SERVER_URL: string;
beforeAll(async () => {
const port = await getFreePort();
SERVER_URL = `http://localhost:${port}/mcp`;
serverProcess = await startServer(
path.join(
process.cwd(),
'examples/servers/typescript/sep-2322-mrtr-broken-server.ts'
),
port
);
}, 35000);
afterAll(async () => {
await stopServer(serverProcess);
});
it('emits FAILURE for sep-2322-result-type-included against server that omits resultType', async () => {
const scenario = new InputRequiredResultResultTypeScenario();
const checks = await scenario.run(SERVER_URL);
const resultTypeCheck = checks.find(
(c) => c.id === 'sep-2322-result-type-included'
);
expect(resultTypeCheck).toBeDefined();
expect(resultTypeCheck?.status).toBe('FAILURE');
}, 10000);
it('emits FAILURE for sep-2322-not-on-unsupported-requests against server returning InputRequiredResult on tools/list', async () => {
const scenario = new InputRequiredResultUnsupportedMethodsScenario();
const checks = await scenario.run(SERVER_URL);
const unsupportedCheck = checks.find(
(c) => c.id === 'sep-2322-not-on-unsupported-requests'
);
expect(unsupportedCheck).toBeDefined();
expect(unsupportedCheck?.status).toBe('FAILURE');
}, 10000);
it('emits FAILURE for sep-2322-reject-tampered-state against server that accepts tampered state', async () => {
const scenario = new InputRequiredResultTamperedStateScenario();
const checks = await scenario.run(SERVER_URL);
const tamperedCheck = checks.find(
(c) => c.id === 'sep-2322-reject-tampered-state'
);
expect(tamperedCheck).toBeDefined();
expect(tamperedCheck?.status).toBe('FAILURE');
}, 10000);
});

116
src/seps/sep-2322.yaml Normal file
View file

@ -0,0 +1,116 @@
sep: 2322
spec_url: https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr
requirements:
# ── ResultType (basic/index.mdx) ───────────────────────────────────────────
- check: sep-2322-result-type-included
text: 'The resultType field MUST be included to indicate the type of the result.'
url: https://modelcontextprotocol.io/specification/draft/basic/index#resulttype
- check: sep-2322-default-result-type-complete
text: 'If resultType is not specified, clients MUST assume a default value of "complete" for backwards compatibility.'
url: https://modelcontextprotocol.io/specification/draft/basic/index#resulttype
# ── Supported Requests ─────────────────────────────────────────────────────
- check: sep-2322-not-on-unsupported-requests
text: 'Servers MUST NOT send InputRequiredResult responses on any other client requests.'
# ── Server Requirements (Basic Workflow) ───────────────────────────────────
- check: sep-2322-elicitation-incomplete
text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (elicitation variant)'
- check: sep-2322-sampling-incomplete
text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling variant)'
- check: sep-2322-list-roots-incomplete
text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots variant)'
- check: sep-2322-reject-tampered-state
text: 'If requestState influences authorization, resource access, or business logic, servers MUST protect its integrity and MUST reject state that fails verification.'
- check: sep-2322-request-state-incomplete
text: 'Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response.'
- check: sep-2322-respect-client-capabilities
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-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.'
- check: sep-2322-client-no-state-omitted
text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry.'
- check: sep-2322-client-jsonrpc-id-different
text: 'The JSON-RPC id MUST be different between the initial request and the retry, as they are independent requests.'
- check: sep-2322-client-parallel-isolation
text: "Both the inputRequests and requestState fields affect only the client's retry of the original request. They MUST NOT be used for any other request that the client may be sending in parallel."
# Scenario flow gates (sep-2322-*-complete, sep-2322-multi-round-r*,
# sep-2322-non-tool-*, sep-2322-multiple-inputs-incomplete) intentionally
# have no rows here: they verify the end-to-end MRTR flow rather than a
# specific RFC-2119 sentence, so they surface in the traceability manifest's
# `untracked` list instead of claiming requirement coverage.
# ── Error Handling ─────────────────────────────────────────────────────────
- check: sep-2322-validate-input-responses
text: 'Servers SHOULD validate that the data provided by the client is a valid InputResponses object and that the information inside can be correctly parsed.'
- check: sep-2322-error-on-protocol-error
text: 'Protocol errors (malformed JSON, invalid schema, internal server errors) SHOULD return a JSON-RPC error response with an appropriate error code and message.'
- check: sep-2322-ignore-unexpected-params
text: 'If additional, unexpected parameters are provided in the InputResponses object, the server SHOULD ignore any information it does not recognize or need.'
- 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.'
# ── Excluded requirements ──────────────────────────────────────────────────
- text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request.'
excluded: 'inputRequests is a JSON object; duplicate keys are collapsed by JSON parsing before the harness can observe them, so key uniqueness is not testable at the protocol level'
- text: 'Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern.'
excluded: 'Architectural migration statement; tested indirectly through all MRTR scenarios'
- text: 'servers MUST treat requestState as an attacker-controlled input'
excluded: 'Internal security posture; not observable at protocol level'
- text: 'servers MUST protect its integrity (e.g. HMAC or AEAD)'
excluded: 'Internal implementation choice about encryption/signing; not observable at protocol level'
- text: 'servers SHOULD include the authenticated principal, a short expiry (TTL), and an identifier for the originating request inside the integrity-protected requestState payload and verify each on receipt'
excluded: 'Internal requestState format; not observable at protocol level'
- text: 'Servers for which a given requestState must be consumed at most once MUST enforce that invariant server-side'
excluded: 'Internal enforcement policy; conformance harness cannot determine which servers require single-use semantics'
- text: 'Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request'
excluded: 'Server-internal robustness assumption; not observable at protocol level'
- text: 'Servers MUST validate request state as described in the server requirements above.'
excluded: 'Duplicates integrity-protection requirements above; internal security detail'
- text: 'Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required.'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task.'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'When tasks/get shows status input_required, clients MUST call tasks/result to get the inputRequests and optional requestState.'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'Clients SHOULD construct the results of those requests and call tasks/input_response with the inputResponses & requestState (if present).'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'Receivers MUST reject tasks/input_response requests for tasks that are not in input_required status with error code -32602 (Invalid params).'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'When a receiver receives a tasks/result request for a task in working status, it MUST block the response until the task reaches a terminal status or input_required status.'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'When a receiver receives a tasks/result request for a task in input_required status, it MUST return an InputRequiredResult containing the inputRequests that the requestor must fulfill.'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'
- text: 'After sending tasks/input_response, the requestor SHOULD resume polling via tasks/get.'
excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'