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
3 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`
);
});