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

@ -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);
});