Conformance Tests for SEP-2322 MRTR (#188)
This commit is contained in:
parent
513abdbd97
commit
43fbf60d3d
3 changed files with 3507 additions and 3 deletions
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
172
examples/servers/typescript/sep-2322-mrtr-broken-server.ts
Normal file
172
examples/servers/typescript/sep-2322-mrtr-broken-server.ts
Normal 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`
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue