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
166
src/scenarios/server/input-required-result-helpers.ts
Normal file
166
src/scenarios/server/input-required-result-helpers.ts
Normal 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'
|
||||
}
|
||||
];
|
||||
1629
src/scenarios/server/input-required-result.ts
Normal file
1629
src/scenarios/server/input-required-result.ts
Normal file
File diff suppressed because it is too large
Load diff
125
src/scenarios/server/negative-mrtr.test.ts
Normal file
125
src/scenarios/server/negative-mrtr.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue