* feat(everything-server): serve resources and caching hints on the stateless draft path The stateless (SEP-2575) request path now answers resources/list, resources/templates/list and resources/read — including the SEP-2164 -32602 + data.uri error for unknown URIs — and includes the SEP-2549 ttlMs/cacheScope hints on its cacheable list results, matching what the draft-spec scenarios exercise. * feat: make the harness's own draft-spec traffic conformant Fixes the class of issues where a strictly-conformant SDK rejects the harness's requests for reasons unrelated to the behaviour under test (#311, #312, #315). - add a shared stateless draft request helper (draft-client.ts) that puts the cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, MCP-Protocol-Version, Accept) and SEP-2575 _meta (protocolVersion, clientInfo, clientCapabilities) on every request, with overrides for negative cases and the SHOULD-level retry on UnsupportedProtocolVersionError - migrate the draft scenarios onto it: the SEP-2322 helpers and server-stateless now send Mcp-Method/Mcp-Name (#312); the SEP-2243 server scenarios drop the legacy initialize handshake and carry _meta on every raw request (#311); caching and sep-2164-resource-not-found are stateless DRAFT-2026-v1 requests instead of an SDK 2025-11-25 session (#315) - add a draft self-conformance gate (draft-self-conformance.test.ts) that points the harness's own draft drivers at the existing client-testing judge scenarios (request-metadata, http-standard-headers) and requires zero failures or warnings - align the request-metadata mock's unsupported-version rejection with the draft schema (-32004) and teach the everything-client to recognize it - rewrite the SEP-2549/SEP-2164 negative example servers as stateless draft servers so they keep exercising the violation, not the transport * refactor: rename the stateless request helpers and harden them per review - rename draft-client.ts to stateless-client.ts and name the helpers by feature (sendStatelessRequest, buildStandardHeaders, buildRequestMeta, withRequestMeta, CONFORMANCE_CLIENT_INFO); 'draft' now only appears in DRAFT_PROTOCOL_VERSION, the one name that tracks the moving target - retry only genuine UnsupportedProtocolVersionError rejections (-32004, tolerating -32001/-32602) and only with recognized versions; record the retry on the response - parse text/event-stream responses incrementally and resolve on the matching event instead of blocking until the timeout - keep the MCP-Protocol-Version header and _meta protocolVersion in sync when the version is overridden via meta overrides - make server-stateless's sendRpc SSE-tolerant - emit explicit setup failures in the SEP-2243 server scenarios when the tools/list discovery fails instead of misleading per-case results - latch the worst status per check in the request-metadata judge so an early violation cannot be masked by a later conformant request - rename the gate to harness-traffic-conformance.test.ts and derive its driver list from the draft suite with documented per-judge exclusions (35 pairings) - everything-server: advertise resources in stateless server/discover and use -32004 for unsupported protocol version - add unit tests for the helper (retry guard, SSE handling, header/_meta version sync, default headers) * refactor: slim the stateless client helper for the initial merge
This commit is contained in:
parent
e2e65e5595
commit
076b417b23
13 changed files with 894 additions and 596 deletions
|
|
@ -155,7 +155,12 @@ async function runRequestMetadataClient(serverUrl: string): Promise<void> {
|
|||
const clone = response.clone();
|
||||
try {
|
||||
const errorResult = await clone.json();
|
||||
if (errorResult.error?.code === -32001) {
|
||||
// -32004 is UnsupportedProtocolVersionError in the draft schema;
|
||||
// -32001 is tolerated for servers that predate the dedicated code.
|
||||
if (
|
||||
errorResult.error?.code === -32004 ||
|
||||
errorResult.error?.code === -32001
|
||||
) {
|
||||
logger.debug(
|
||||
'Received UnsupportedProtocolVersionError, starting negotiation...'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1227,13 +1227,13 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Protocol Version Negotiation Matrix (-32602, HTTP 400)
|
||||
// Protocol Version Negotiation Matrix (-32004, HTTP 400)
|
||||
if (metaVersion !== 'DRAFT-2026-v1') {
|
||||
return res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32602,
|
||||
code: -32004,
|
||||
message: 'UnsupportedProtocolVersionError',
|
||||
data: { supported: ['DRAFT-2026-v1'] }
|
||||
}
|
||||
|
|
@ -1296,7 +1296,10 @@ app.post('/mcp', async (req, res) => {
|
|||
supportedVersions: ['DRAFT-2026-v1'],
|
||||
capabilities: {
|
||||
tools: { listChanged: true }, // Explicitly announce dynamic capabilities matching Section 7 expectations
|
||||
prompts: { listChanged: true }
|
||||
prompts: { listChanged: true },
|
||||
// resources/list, resources/templates/list and resources/read are
|
||||
// served on this path, so the capability must be declared too.
|
||||
resources: {}
|
||||
},
|
||||
serverInfo: { name: 'everything-stateless-server', version: '1.0.0' }
|
||||
}
|
||||
|
|
@ -1371,7 +1374,10 @@ app.post('/mcp', async (req, res) => {
|
|||
description: 'Diagnostic logging validator tool',
|
||||
inputSchema: { type: 'object', properties: {} }
|
||||
}
|
||||
]
|
||||
],
|
||||
// SEP-2549 caching hints are required on cacheable list results.
|
||||
ttlMs: 300000,
|
||||
cacheScope: 'public'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1387,7 +1393,9 @@ app.post('/mcp', async (req, res) => {
|
|||
name: 'test_input_required_result_prompt',
|
||||
description: 'MRTR: prompt that requires elicitation input'
|
||||
}
|
||||
]
|
||||
],
|
||||
ttlMs: 300000,
|
||||
cacheScope: 'public'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1442,6 +1450,69 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Resources on the stateless path (SEP-2575): SEP-2549 hints + SEP-2164 errors.
|
||||
if (method === 'resources/list') {
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://stateless-static-text',
|
||||
name: 'Stateless Static Text',
|
||||
description: 'A static text resource served on the draft path',
|
||||
mimeType: 'text/plain'
|
||||
}
|
||||
],
|
||||
ttlMs: 300000,
|
||||
cacheScope: 'public'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'resources/templates/list') {
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resourceTemplates: [],
|
||||
ttlMs: 300000,
|
||||
cacheScope: 'public'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'resources/read') {
|
||||
const uri = params.uri as string | undefined;
|
||||
if (uri === 'test://stateless-static-text') {
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: 'Static text content from the stateless draft path.'
|
||||
}
|
||||
],
|
||||
ttlMs: 300000,
|
||||
cacheScope: 'private'
|
||||
}
|
||||
});
|
||||
}
|
||||
// SEP-2164: unknown resources get -32602 with the requested uri in data.
|
||||
return res.status(200).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: 'Resource not found',
|
||||
data: { uri }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const name = params.name;
|
||||
const inputResponses = params.inputResponses as
|
||||
|
|
|
|||
|
|
@ -3,58 +3,44 @@
|
|||
/**
|
||||
* SEP-2164 Negative Test Server
|
||||
*
|
||||
* Returns an empty contents array for any resources/read request, violating
|
||||
* the SEP-2164 MUST NOT. The sep-2164-resource-not-found scenario should
|
||||
* emit FAILURE for sep-2164-no-empty-contents against this server.
|
||||
* Speaks the stateless wire protocol (SEP-2575) but returns an empty
|
||||
* contents array for any resources/read request, violating the SEP-2164 MUST
|
||||
* NOT. The sep-2164-resource-not-found scenario should emit FAILURE for
|
||||
* sep-2164-no-empty-contents against this server.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import express from 'express';
|
||||
|
||||
function createServer() {
|
||||
const server = new Server(
|
||||
{ name: 'sep-2164-empty-contents', version: '1.0.0' },
|
||||
{ capabilities: { resources: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: []
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async () => ({
|
||||
contents: []
|
||||
}));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
try {
|
||||
const server = createServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
app.post('/mcp', (req, res) => {
|
||||
const body = req.body || {};
|
||||
const id = body.id ?? null;
|
||||
const method = body.method;
|
||||
|
||||
switch (method) {
|
||||
case 'server/discover':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
},
|
||||
id: null
|
||||
id,
|
||||
result: {
|
||||
supportedVersions: ['DRAFT-2026-v1'],
|
||||
capabilities: { resources: {} },
|
||||
serverInfo: { name: 'sep-2164-empty-contents', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
case 'resources/list':
|
||||
return res.json({ jsonrpc: '2.0', id, result: { resources: [] } });
|
||||
case 'resources/read':
|
||||
// Deliberately return an empty contents array instead of an error.
|
||||
return res.json({ jsonrpc: '2.0', id, result: { contents: [] } });
|
||||
default:
|
||||
return res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -32601, message: 'Method not found' }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,151 +3,96 @@
|
|||
/**
|
||||
* SEP-2549 Negative Test Server
|
||||
*
|
||||
* Returns list and read results WITHOUT ttlMs and cacheScope fields,
|
||||
* violating the SEP-2549 MUST. The caching scenario should emit FAILURE
|
||||
* for presence checks against this server.
|
||||
* Speaks the stateless wire protocol (SEP-2575) but returns list and
|
||||
* read results WITHOUT ttlMs and cacheScope fields, violating the SEP-2549
|
||||
* MUST. The caching scenario should emit FAILURE for presence checks against
|
||||
* this server.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ReadResourceRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import express from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {};
|
||||
|
||||
function isInitializeRequest(body: any): boolean {
|
||||
return body?.method === 'initialize';
|
||||
}
|
||||
|
||||
function createServer() {
|
||||
const server = new Server(
|
||||
{ name: 'sep-2549-no-caching-hints', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Deliberately omit ttlMs and cacheScope from all responses
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object' as const }
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
||||
prompts: [
|
||||
{
|
||||
name: 'test_prompt',
|
||||
description: 'A test prompt'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://static-text',
|
||||
name: 'Static Text',
|
||||
description: 'A static text resource'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
||||
resourceTemplates: []
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'test://static-text',
|
||||
mimeType: 'text/plain',
|
||||
text: 'Static text content.'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
app.post('/mcp', (req, res) => {
|
||||
const body = req.body || {};
|
||||
const id = body.id ?? null;
|
||||
const method = body.method;
|
||||
|
||||
try {
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId && isInitializeRequest(req.body)) {
|
||||
const server = createServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (newSessionId) => {
|
||||
transports[newSessionId] = transport;
|
||||
// Deliberately omit ttlMs and cacheScope from every result below.
|
||||
switch (method) {
|
||||
case 'server/discover':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
supportedVersions: ['DRAFT-2026-v1'],
|
||||
capabilities: { tools: {}, resources: {}, prompts: {} },
|
||||
serverInfo: { name: 'sep-2549-no-caching-hints', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid) delete transports[sid];
|
||||
};
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Invalid or missing session ID' },
|
||||
id: null
|
||||
});
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
case 'tools/list':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
},
|
||||
id: null
|
||||
id,
|
||||
result: {
|
||||
tools: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object' }
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
case 'prompts/list':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
prompts: [{ name: 'test_prompt', description: 'A test prompt' }]
|
||||
}
|
||||
});
|
||||
case 'resources/list':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://static-text',
|
||||
name: 'Static Text',
|
||||
description: 'A static text resource'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
case 'resources/templates/list':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { resourceTemplates: [] }
|
||||
});
|
||||
case 'resources/read':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
contents: [
|
||||
{
|
||||
uri: 'test://static-text',
|
||||
mimeType: 'text/plain',
|
||||
text: 'Static text content.'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
default:
|
||||
return res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -32601, message: 'Method not found' }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res);
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or missing session ID' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res);
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or missing session ID' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -104,9 +104,10 @@ async function incompatibleVersionClient(serverUrl: string) {
|
|||
|
||||
if (response.status === 400) {
|
||||
const body = await response.json();
|
||||
if (body.error?.code === -32001) {
|
||||
if (body.error?.code === -32004 || body.error?.code === -32001) {
|
||||
return body; // Abort cleanly
|
||||
}
|
||||
return body;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,23 @@ import {
|
|||
Scenario,
|
||||
ScenarioUrls,
|
||||
ConformanceCheck,
|
||||
CheckStatus,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
|
||||
/**
|
||||
* Severity ranking used to latch per-id check results: a single
|
||||
* non-conformant request is a violation even if later requests are
|
||||
* conformant, so a later better status must never overwrite a worse one.
|
||||
*/
|
||||
const STATUS_SEVERITY: Record<CheckStatus, number> = {
|
||||
FAILURE: 3,
|
||||
WARNING: 2,
|
||||
SUCCESS: 1,
|
||||
INFO: 1,
|
||||
SKIPPED: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Every check ID this scenario can emit. Declared-but-unemitted checks are
|
||||
* backfilled as FAILURE by getChecks(), so the emitted ID set is the same for
|
||||
|
|
@ -92,10 +106,17 @@ export class RequestMetadataScenario implements Scenario {
|
|||
|
||||
private addOrUpdateCheck(check: ConformanceCheck): void {
|
||||
const index = this.checks.findIndex((c) => c.id === check.id);
|
||||
if (index !== -1) {
|
||||
this.checks[index] = check;
|
||||
} else {
|
||||
if (index === -1) {
|
||||
this.checks.push(check);
|
||||
return;
|
||||
}
|
||||
// Keep the worst status observed for this id (FAILURE > WARNING > SUCCESS
|
||||
// > SKIPPED): an equal-or-worse result replaces the stored check (so its
|
||||
// details stay fresh), but a better result must not erase a violation
|
||||
// recorded from an earlier request.
|
||||
const existing = this.checks[index];
|
||||
if (STATUS_SEVERITY[check.status] >= STATUS_SEVERITY[existing.status]) {
|
||||
this.checks[index] = check;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +277,8 @@ export class RequestMetadataScenario implements Scenario {
|
|||
jsonrpc: '2.0',
|
||||
id: request.id ?? null,
|
||||
error: {
|
||||
code: -32001,
|
||||
// UnsupportedProtocolVersionError per the draft schema.
|
||||
code: -32004,
|
||||
message: 'Unsupported protocol version',
|
||||
data: {
|
||||
supported: [DRAFT_PROTOCOL_VERSION]
|
||||
|
|
|
|||
|
|
@ -10,14 +10,7 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import {
|
||||
ListToolsResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { sendStatelessRequest } from './stateless-client';
|
||||
|
||||
const SPEC_REFS = [
|
||||
{
|
||||
|
|
@ -98,259 +91,181 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p
|
|||
const checks: ConformanceCheck[] = [];
|
||||
const allFields: Array<{ endpoint: string; fields: CachingFields }> = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
|
||||
// 1. tools/list
|
||||
// SEP-2549 only exists in the draft spec, so each cacheable endpoint is
|
||||
// queried over the stateless path (SEP-2575): protocolVersion DRAFT-2026-v1
|
||||
// plus the cross-cutting _meta and standard headers (issue #315).
|
||||
const queryEndpoint = async (
|
||||
checkId: string,
|
||||
checkName: string,
|
||||
endpoint: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<Record<string, unknown> | undefined> => {
|
||||
const description = `${endpoint} response includes ttlMs and cacheScope caching hints`;
|
||||
try {
|
||||
const toolsResult = await connection.client.request(
|
||||
{ method: 'tools/list', params: {} },
|
||||
ListToolsResultSchema
|
||||
const response = await sendStatelessRequest(
|
||||
serverUrl,
|
||||
endpoint,
|
||||
params
|
||||
);
|
||||
const fields = extractCachingFields(
|
||||
toolsResult as Record<string, unknown>
|
||||
);
|
||||
allFields.push({ endpoint: 'tools/list', fields });
|
||||
checks.push(
|
||||
buildPresenceCheck(
|
||||
'sep-2549-tools-list-caching-hints',
|
||||
'ToolsListCachingHints',
|
||||
'tools/list',
|
||||
fields
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2549-tools-list-caching-hints',
|
||||
name: 'ToolsListCachingHints',
|
||||
description:
|
||||
'tools/list response includes ttlMs and cacheScope caching hints',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `tools/list request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
}
|
||||
|
||||
// 2. prompts/list
|
||||
try {
|
||||
const promptsResult = await connection.client.request(
|
||||
{ method: 'prompts/list', params: {} },
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
const fields = extractCachingFields(
|
||||
promptsResult as Record<string, unknown>
|
||||
);
|
||||
allFields.push({ endpoint: 'prompts/list', fields });
|
||||
checks.push(
|
||||
buildPresenceCheck(
|
||||
'sep-2549-prompts-list-caching-hints',
|
||||
'PromptsListCachingHints',
|
||||
'prompts/list',
|
||||
fields
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2549-prompts-list-caching-hints',
|
||||
name: 'PromptsListCachingHints',
|
||||
description:
|
||||
'prompts/list response includes ttlMs and cacheScope caching hints',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `prompts/list request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
}
|
||||
|
||||
// 3. resources/list
|
||||
let firstResourceUri: string | undefined;
|
||||
try {
|
||||
const resourcesResult = await connection.client.request(
|
||||
{ method: 'resources/list', params: {} },
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
const fields = extractCachingFields(
|
||||
resourcesResult as Record<string, unknown>
|
||||
);
|
||||
allFields.push({ endpoint: 'resources/list', fields });
|
||||
checks.push(
|
||||
buildPresenceCheck(
|
||||
'sep-2549-resources-list-caching-hints',
|
||||
'ResourcesListCachingHints',
|
||||
'resources/list',
|
||||
fields
|
||||
)
|
||||
);
|
||||
// Capture the first resource URI for the resources/read check
|
||||
if (resourcesResult.resources && resourcesResult.resources.length > 0) {
|
||||
firstResourceUri = resourcesResult.resources[0].uri;
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2549-resources-list-caching-hints',
|
||||
name: 'ResourcesListCachingHints',
|
||||
description:
|
||||
'resources/list response includes ttlMs and cacheScope caching hints',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `resources/list request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
}
|
||||
|
||||
// 4. resources/templates/list
|
||||
try {
|
||||
const templatesResult = await connection.client.request(
|
||||
{ method: 'resources/templates/list', params: {} },
|
||||
ListResourceTemplatesResultSchema
|
||||
);
|
||||
const fields = extractCachingFields(
|
||||
templatesResult as Record<string, unknown>
|
||||
);
|
||||
allFields.push({ endpoint: 'resources/templates/list', fields });
|
||||
checks.push(
|
||||
buildPresenceCheck(
|
||||
'sep-2549-resources-templates-list-caching-hints',
|
||||
'ResourcesTemplatesListCachingHints',
|
||||
'resources/templates/list',
|
||||
fields
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2549-resources-templates-list-caching-hints',
|
||||
name: 'ResourcesTemplatesListCachingHints',
|
||||
description:
|
||||
'resources/templates/list response includes ttlMs and cacheScope caching hints',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `resources/templates/list request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
}
|
||||
|
||||
// 5. resources/read — use first resource from resources/list
|
||||
if (firstResourceUri) {
|
||||
try {
|
||||
const readResult = await connection.client.request(
|
||||
{
|
||||
method: 'resources/read',
|
||||
params: { uri: firstResourceUri }
|
||||
},
|
||||
ReadResourceResultSchema
|
||||
);
|
||||
const fields = extractCachingFields(
|
||||
readResult as Record<string, unknown>
|
||||
);
|
||||
allFields.push({ endpoint: 'resources/read', fields });
|
||||
checks.push(
|
||||
buildPresenceCheck(
|
||||
'sep-2549-resources-read-caching-hints',
|
||||
'ResourcesReadCachingHints',
|
||||
'resources/read',
|
||||
fields
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
const result = response.body?.result;
|
||||
if (!result) {
|
||||
const error = response.body?.error;
|
||||
checks.push({
|
||||
id: 'sep-2549-resources-read-caching-hints',
|
||||
name: 'ResourcesReadCachingHints',
|
||||
description:
|
||||
'resources/read response includes ttlMs and cacheScope caching hints',
|
||||
id: checkId,
|
||||
name: checkName,
|
||||
description,
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
errorMessage: error
|
||||
? `${endpoint} returned JSON-RPC error ${error.code}: ${error.message}`
|
||||
: `${endpoint} returned HTTP ${response.status} with no result`,
|
||||
specReferences: SPEC_REFS,
|
||||
details: { httpStatus: response.status, error }
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const fields = extractCachingFields(result);
|
||||
allFields.push({ endpoint, fields });
|
||||
checks.push(buildPresenceCheck(checkId, checkName, endpoint, fields));
|
||||
return result;
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: checkId,
|
||||
name: checkName,
|
||||
description,
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Aggregate: ttlMs must be a non-negative integer
|
||||
const ttlErrors: string[] = [];
|
||||
const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs);
|
||||
if (endpointsWithTtl.length === 0) {
|
||||
ttlErrors.push('no endpoints returned ttlMs');
|
||||
} else {
|
||||
for (const { endpoint, fields } of endpointsWithTtl) {
|
||||
const val = fields.ttlMs;
|
||||
if (typeof val !== 'number') {
|
||||
ttlErrors.push(
|
||||
`${endpoint}: ttlMs is ${typeof val}, expected number`
|
||||
);
|
||||
} else if (!Number.isInteger(val)) {
|
||||
ttlErrors.push(`${endpoint}: ttlMs is ${val}, expected integer`);
|
||||
} else if (val < 0) {
|
||||
ttlErrors.push(`${endpoint}: ttlMs is ${val}, must be >= 0`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1. tools/list
|
||||
await queryEndpoint(
|
||||
'sep-2549-tools-list-caching-hints',
|
||||
'ToolsListCachingHints',
|
||||
'tools/list'
|
||||
);
|
||||
|
||||
checks.push({
|
||||
id: 'sep-2549-ttl-non-negative',
|
||||
name: 'TtlNonNegative',
|
||||
description: 'All ttlMs values are non-negative integers',
|
||||
status: ttlErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: ttlErrors.length > 0 ? ttlErrors.join('; ') : undefined,
|
||||
specReferences: SPEC_REFS,
|
||||
details: {
|
||||
endpoints: allFields.map((f) => ({
|
||||
endpoint: f.endpoint,
|
||||
ttlMs: f.fields.ttlMs
|
||||
}))
|
||||
}
|
||||
});
|
||||
// 2. prompts/list
|
||||
await queryEndpoint(
|
||||
'sep-2549-prompts-list-caching-hints',
|
||||
'PromptsListCachingHints',
|
||||
'prompts/list'
|
||||
);
|
||||
|
||||
// 7. Aggregate: cacheScope must be "public" or "private"
|
||||
const scopeErrors: string[] = [];
|
||||
const endpointsWithScope = allFields.filter(
|
||||
(f) => f.fields.hasCacheScope
|
||||
// 3. resources/list
|
||||
const resourcesResult = await queryEndpoint(
|
||||
'sep-2549-resources-list-caching-hints',
|
||||
'ResourcesListCachingHints',
|
||||
'resources/list'
|
||||
);
|
||||
|
||||
// 4. resources/templates/list
|
||||
await queryEndpoint(
|
||||
'sep-2549-resources-templates-list-caching-hints',
|
||||
'ResourcesTemplatesListCachingHints',
|
||||
'resources/templates/list'
|
||||
);
|
||||
|
||||
// 5. resources/read — use first resource from resources/list
|
||||
const resources = resourcesResult?.resources as
|
||||
| Array<{ uri?: string }>
|
||||
| undefined;
|
||||
const firstResourceUri = resources?.[0]?.uri;
|
||||
if (firstResourceUri) {
|
||||
await queryEndpoint(
|
||||
'sep-2549-resources-read-caching-hints',
|
||||
'ResourcesReadCachingHints',
|
||||
'resources/read',
|
||||
{ uri: firstResourceUri }
|
||||
);
|
||||
if (endpointsWithScope.length === 0) {
|
||||
scopeErrors.push('no endpoints returned cacheScope');
|
||||
} else {
|
||||
for (const { endpoint, fields } of endpointsWithScope) {
|
||||
const val = fields.cacheScope;
|
||||
if (val !== 'public' && val !== 'private') {
|
||||
scopeErrors.push(
|
||||
`${endpoint}: cacheScope is ${JSON.stringify(val)}, expected "public" or "private"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Keep the emitted check-ID set stable even when there is nothing to
|
||||
// read (resources/list failed or the server exposes no resources).
|
||||
checks.push({
|
||||
id: 'sep-2549-cache-scope-valid',
|
||||
name: 'CacheScopeValid',
|
||||
description: 'All cacheScope values are "public" or "private"',
|
||||
status: scopeErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||
id: 'sep-2549-resources-read-caching-hints',
|
||||
name: 'ResourcesReadCachingHints',
|
||||
description:
|
||||
'resources/read response includes ttlMs and cacheScope caching hints',
|
||||
status: 'SKIPPED',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage:
|
||||
scopeErrors.length > 0 ? scopeErrors.join('; ') : undefined,
|
||||
specReferences: SPEC_REFS,
|
||||
details: {
|
||||
endpoints: allFields.map((f) => ({
|
||||
endpoint: f.endpoint,
|
||||
cacheScope: f.fields.cacheScope
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
} catch (error) {
|
||||
// Connection-level failure — push a single failure check
|
||||
checks.push({
|
||||
id: 'sep-2549-caching-connection',
|
||||
name: 'CachingConnection',
|
||||
description: 'Caching hints scenario failed to connect',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Connection failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'resources/read was not exercised: resources/list failed or returned no resources.',
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Aggregate: ttlMs must be a non-negative integer
|
||||
const ttlErrors: string[] = [];
|
||||
const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs);
|
||||
if (endpointsWithTtl.length === 0) {
|
||||
ttlErrors.push('no endpoints returned ttlMs');
|
||||
} else {
|
||||
for (const { endpoint, fields } of endpointsWithTtl) {
|
||||
const val = fields.ttlMs;
|
||||
if (typeof val !== 'number') {
|
||||
ttlErrors.push(
|
||||
`${endpoint}: ttlMs is ${typeof val}, expected number`
|
||||
);
|
||||
} else if (!Number.isInteger(val)) {
|
||||
ttlErrors.push(`${endpoint}: ttlMs is ${val}, expected integer`);
|
||||
} else if (val < 0) {
|
||||
ttlErrors.push(`${endpoint}: ttlMs is ${val}, must be >= 0`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: 'sep-2549-ttl-non-negative',
|
||||
name: 'TtlNonNegative',
|
||||
description: 'All ttlMs values are non-negative integers',
|
||||
status: ttlErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: ttlErrors.length > 0 ? ttlErrors.join('; ') : undefined,
|
||||
specReferences: SPEC_REFS,
|
||||
details: {
|
||||
endpoints: allFields.map((f) => ({
|
||||
endpoint: f.endpoint,
|
||||
ttlMs: f.fields.ttlMs
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Aggregate: cacheScope must be "public" or "private"
|
||||
const scopeErrors: string[] = [];
|
||||
const endpointsWithScope = allFields.filter((f) => f.fields.hasCacheScope);
|
||||
if (endpointsWithScope.length === 0) {
|
||||
scopeErrors.push('no endpoints returned cacheScope');
|
||||
} else {
|
||||
for (const { endpoint, fields } of endpointsWithScope) {
|
||||
const val = fields.cacheScope;
|
||||
if (val !== 'public' && val !== 'private') {
|
||||
scopeErrors.push(
|
||||
`${endpoint}: cacheScope is ${JSON.stringify(val)}, expected "public" or "private"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: 'sep-2549-cache-scope-valid',
|
||||
name: 'CacheScopeValid',
|
||||
description: 'All cacheScope values are "public" or "private"',
|
||||
status: scopeErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: scopeErrors.length > 0 ? scopeErrors.join('; ') : undefined,
|
||||
specReferences: SPEC_REFS,
|
||||
details: {
|
||||
endpoints: allFields.map((f) => ({
|
||||
endpoint: f.endpoint,
|
||||
cacheScope: f.fields.cacheScope
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import { withRequestMeta, sendStatelessRequest } from './stateless-client';
|
||||
|
||||
const SPEC_REFERENCE = {
|
||||
id: 'SEP-2243-Server-Validation',
|
||||
|
|
@ -264,51 +264,39 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
|||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
try {
|
||||
// Establish a session via normal SDK initialization
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const toolsResult = await connection.client.listTools();
|
||||
await connection.close();
|
||||
|
||||
// Get a fresh session for raw requests
|
||||
const initResponse = await sendRawRequest(
|
||||
serverUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: DRAFT_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'conformance-test-raw-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 'Mcp-Method': 'initialize' }
|
||||
);
|
||||
|
||||
if (initResponse.status === 200) {
|
||||
const rawSid = initResponse.headers['mcp-session-id'];
|
||||
sessionId = (Array.isArray(rawSid) ? rawSid[0] : rawSid) || null;
|
||||
const notifHeaders: Record<string, string> = {
|
||||
'Mcp-Method': 'notifications/initialized'
|
||||
};
|
||||
if (sessionId) notifHeaders['mcp-session-id'] = sessionId;
|
||||
await sendRawRequest(
|
||||
serverUrl,
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||
notifHeaders
|
||||
);
|
||||
// Discover the server's tools with a fully-conformant stateless request
|
||||
// (SEP-2575) — that wire protocol has no initialize handshake or sessions.
|
||||
const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list');
|
||||
if (!toolsResponse.body?.result) {
|
||||
// The server under test could not even answer a conformant tools/list:
|
||||
// report a single explicit setup failure instead of misleading
|
||||
// per-case results against a broken server.
|
||||
const rpcError = toolsResponse.body?.error;
|
||||
checks.push({
|
||||
id: 'sep-2243-server-standard-setup',
|
||||
name: 'HttpHeaderValidationSetup',
|
||||
description: 'Setup for header validation tests',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage:
|
||||
`tools/list discovery failed: HTTP ${toolsResponse.status}` +
|
||||
(rpcError
|
||||
? `, JSON-RPC error ${rpcError.code}: ${rpcError.message}`
|
||||
: ', no result in response body'),
|
||||
specReferences: [SPEC_REFERENCE],
|
||||
details: { httpStatus: toolsResponse.status, error: rpcError }
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
const toolsResult = toolsResponse.body.result as {
|
||||
tools?: Array<{ name: string; inputSchema?: unknown }>;
|
||||
};
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION
|
||||
};
|
||||
if (sessionId) baseHeaders['mcp-session-id'] = sessionId;
|
||||
|
||||
let idCounter = 100;
|
||||
const nextId = () => idCounter++;
|
||||
|
|
@ -501,7 +489,13 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
|||
details: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id };
|
||||
// Issue #311: every raw request carries the SEP-2575 _meta fields — the
|
||||
// header-validation cases only mangle headers, never the body metadata.
|
||||
const requestBody = {
|
||||
...body,
|
||||
id: body.id === 0 ? nextId() : body.id,
|
||||
params: withRequestMeta(body.params)
|
||||
};
|
||||
const response = await sendRawRequest(serverUrl, requestBody, {
|
||||
...baseHeaders,
|
||||
...extraHeaders
|
||||
|
|
@ -565,12 +559,37 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const toolsResult = await connection.client.listTools();
|
||||
await connection.close();
|
||||
// Discover the server's tools with a fully-conformant stateless request
|
||||
// (SEP-2575) — that wire protocol has no initialize handshake or sessions.
|
||||
const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list');
|
||||
if (!toolsResponse.body?.result) {
|
||||
// The server under test could not even answer a conformant tools/list:
|
||||
// report a single explicit setup failure (and backfill the declared
|
||||
// checks, mirroring the catch path) instead of pretending the
|
||||
// requirements are not applicable to a broken server.
|
||||
const rpcError = toolsResponse.body?.error;
|
||||
checks.push({
|
||||
id: 'sep-2243-server-custom-setup',
|
||||
name: 'HttpCustomHeaderServerValidationSetup',
|
||||
description: 'Setup for custom header server validation tests',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage:
|
||||
`tools/list discovery failed: HTTP ${toolsResponse.status}` +
|
||||
(rpcError
|
||||
? `, JSON-RPC error ${rpcError.code}: ${rpcError.message}`
|
||||
: ', no result in response body'),
|
||||
specReferences: [SPEC_REFERENCE_CUSTOM],
|
||||
details: { httpStatus: toolsResponse.status, error: rpcError }
|
||||
});
|
||||
this.failDeclaredChecks(checks);
|
||||
return checks;
|
||||
}
|
||||
const toolsResult = toolsResponse.body.result as {
|
||||
tools?: Array<{ name: string; inputSchema?: unknown }>;
|
||||
};
|
||||
|
||||
// Find a tool with x-mcp-header annotations
|
||||
const xMcpTool = toolsResult.tools?.find((tool) => {
|
||||
|
|
@ -602,43 +621,9 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
return checks;
|
||||
}
|
||||
|
||||
// Get a fresh session for raw requests
|
||||
const initResponse = await sendRawRequest(
|
||||
serverUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: DRAFT_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'conformance-test-base64-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 'Mcp-Method': 'initialize' }
|
||||
);
|
||||
|
||||
if (initResponse.status === 200) {
|
||||
const rawSid2 = initResponse.headers['mcp-session-id'];
|
||||
sessionId = (Array.isArray(rawSid2) ? rawSid2[0] : rawSid2) || null;
|
||||
const notifHeaders: Record<string, string> = {
|
||||
'Mcp-Method': 'notifications/initialized'
|
||||
};
|
||||
if (sessionId) notifHeaders['mcp-session-id'] = sessionId;
|
||||
await sendRawRequest(
|
||||
serverUrl,
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||
notifHeaders
|
||||
);
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION
|
||||
};
|
||||
if (sessionId) baseHeaders['mcp-session-id'] = sessionId;
|
||||
|
||||
// Find the first x-mcp-header annotated STRING property
|
||||
// that is callable with minimal arguments to avoid schema validation failures
|
||||
|
|
@ -828,6 +813,16 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
// Declared-but-unemitted -> FAILURE. Reached only when setup threw partway
|
||||
// through (the gate-out paths emit SKIPPED rows and the happy path emits
|
||||
// every declared ID).
|
||||
this.failDeclaredChecks(checks);
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill every declared-but-unemitted check ID as FAILURE when setup
|
||||
* failed before the cases could run, keeping the emitted ID set stable.
|
||||
*/
|
||||
private failDeclaredChecks(checks: ConformanceCheck[]): void {
|
||||
for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) {
|
||||
if (checks.some((c) => c.id === id)) continue;
|
||||
checks.push({
|
||||
|
|
@ -841,8 +836,6 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -890,10 +883,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
// Issue #311: the body always carries the SEP-2575 _meta fields —
|
||||
// these cases only vary the Mcp-Param header value.
|
||||
params: withRequestMeta({
|
||||
name: toolName,
|
||||
arguments: { ...defaultArgs, [paramName]: bodyValue }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
...baseHeaders,
|
||||
|
|
@ -974,10 +969,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
// Issue #311: the body always carries the SEP-2575 _meta fields —
|
||||
// this case only omits the Mcp-Param header.
|
||||
params: withRequestMeta({
|
||||
name: toolName,
|
||||
arguments: { ...defaultArgs, [paramName]: 'test-value' }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
...baseHeaders,
|
||||
|
|
|
|||
|
|
@ -5,60 +5,32 @@
|
|||
* 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 };
|
||||
}
|
||||
export type { JsonRpcResponse } from './stateless-client';
|
||||
|
||||
// ─── Stateless RPC Helper ────────────────────────────────────────────────────
|
||||
|
||||
let nextId = 1;
|
||||
import { sendStatelessRequest, JsonRpcResponse } from './stateless-client';
|
||||
|
||||
/**
|
||||
* Send a stateless JSON-RPC request (SEP-2575 pattern).
|
||||
* Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities.
|
||||
* The shared stateless helper injects the cross-cutting requirements: _meta
|
||||
* (protocolVersion, clientInfo, clientCapabilities) and the standard
|
||||
* MCP-Protocol-Version / Mcp-Method / Mcp-Name headers (SEP-2243).
|
||||
*/
|
||||
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;
|
||||
const response = await sendStatelessRequest(serverUrl, method, params);
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
`Expected a JSON-RPC response for ${method}, got HTTP ${response.status} (${response.contentType ?? 'no content-type'})`
|
||||
);
|
||||
}
|
||||
return response.body as JsonRpcResponse;
|
||||
}
|
||||
|
||||
// ─── InputRequiredResult Types ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import {
|
|||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import { sendStatelessRequest } from './stateless-client';
|
||||
import {
|
||||
TextResourceContents,
|
||||
BlobResourceContents,
|
||||
McpError
|
||||
BlobResourceContents
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export class ResourcesListScenario implements ClientScenario {
|
||||
|
|
@ -484,9 +484,13 @@ This scenario does not require the server to register any specific resource —
|
|||
}
|
||||
];
|
||||
|
||||
let connection;
|
||||
// SEP-2164 is a draft-spec requirement, so the request is sent statelessly
|
||||
// with the draft protocol version and the cross-cutting _meta/headers.
|
||||
let response;
|
||||
try {
|
||||
connection = await connectToServer(serverUrl);
|
||||
response = await sendStatelessRequest(serverUrl, 'resources/read', {
|
||||
uri: nonexistentUri
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2164-error-code',
|
||||
|
|
@ -495,19 +499,16 @@ This scenario does not require the server to register any specific resource —
|
|||
'Server returns -32602 (Invalid Params) for non-existent resource',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
|
||||
errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
|
||||
let caughtError: unknown;
|
||||
let result: { contents: unknown[] } | undefined;
|
||||
try {
|
||||
result = await connection.client.readResource({ uri: nonexistentUri });
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
}
|
||||
const result = response.body?.result as
|
||||
| { contents?: unknown[] }
|
||||
| undefined;
|
||||
const rpcError = response.body?.error;
|
||||
|
||||
// Check 1: MUST NOT return an empty contents array
|
||||
const returnedEmptyContents =
|
||||
|
|
@ -531,13 +532,12 @@ This scenario does not require the server to register any specific resource —
|
|||
});
|
||||
|
||||
// Check 2: SHOULD return JSON-RPC error with code -32602
|
||||
const errorCode =
|
||||
caughtError instanceof McpError ? caughtError.code : undefined;
|
||||
const errorCode = rpcError?.code;
|
||||
let errorCodeMessage: string | undefined;
|
||||
if (result !== undefined) {
|
||||
errorCodeMessage = `Server returned a result instead of an error (contents length: ${result.contents?.length ?? 'undefined'}). Servers SHOULD return a JSON-RPC error for non-existent resources.`;
|
||||
} else if (!(caughtError instanceof McpError)) {
|
||||
errorCodeMessage = `Expected a JSON-RPC error, got: ${caughtError instanceof Error ? caughtError.message : String(caughtError)}`;
|
||||
} else if (!rpcError) {
|
||||
errorCodeMessage = `Expected a JSON-RPC error, got HTTP ${response.status} with no JSON-RPC error in the body.`;
|
||||
} else if (errorCode !== -32602) {
|
||||
errorCodeMessage =
|
||||
`Expected error code -32602 (Invalid Params), got ${errorCode}. ` +
|
||||
|
|
@ -563,10 +563,7 @@ This scenario does not require the server to register any specific resource —
|
|||
});
|
||||
|
||||
// Check 3: SHOULD include uri in error data field
|
||||
const errorData =
|
||||
caughtError instanceof McpError
|
||||
? (caughtError.data as { uri?: string } | undefined)
|
||||
: undefined;
|
||||
const errorData = rpcError?.data as { uri?: string } | undefined;
|
||||
const dataUriMatches = errorData?.uri === nonexistentUri;
|
||||
|
||||
checks.push({
|
||||
|
|
@ -574,19 +571,13 @@ This scenario does not require the server to register any specific resource —
|
|||
name: 'ResourcesNotFoundDataUri',
|
||||
description:
|
||||
'Server includes the requested URI in the error data field (SHOULD)',
|
||||
status:
|
||||
caughtError instanceof McpError
|
||||
? dataUriMatches
|
||||
? 'SUCCESS'
|
||||
: 'WARNING'
|
||||
: 'FAILURE',
|
||||
status: rpcError ? (dataUriMatches ? 'SUCCESS' : 'WARNING') : 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage:
|
||||
caughtError instanceof McpError
|
||||
? dataUriMatches
|
||||
? undefined
|
||||
: `Error data.uri is ${JSON.stringify(errorData?.uri)}, expected "${nonexistentUri}". This is a SHOULD requirement.`
|
||||
: 'No JSON-RPC error received; cannot evaluate data field.',
|
||||
errorMessage: rpcError
|
||||
? dataUriMatches
|
||||
? undefined
|
||||
: `Error data.uri is ${JSON.stringify(errorData?.uri)}, expected "${nonexistentUri}". This is a SHOULD requirement.`
|
||||
: 'No JSON-RPC error received; cannot evaluate data field.',
|
||||
specReferences,
|
||||
details: {
|
||||
requestedUri: nonexistentUri,
|
||||
|
|
@ -594,7 +585,6 @@ This scenario does not require the server to register any specific resource —
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
src/scenarios/server/stateless-client.test.ts
Normal file
107
src/scenarios/server/stateless-client.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Unit tests for the shared stateless request helper (SEP-2575 + SEP-2243):
|
||||
* standard header defaults/overrides, `_meta` injection, and JSON parsing.
|
||||
*/
|
||||
import http from 'http';
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
buildStandardHeaders,
|
||||
withRequestMeta,
|
||||
sendStatelessRequest,
|
||||
CONFORMANCE_CLIENT_INFO,
|
||||
DEFAULT_CLIENT_CAPABILITIES
|
||||
} from './stateless-client';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../types';
|
||||
|
||||
describe('buildStandardHeaders', () => {
|
||||
test('sets the standard headers pinned to the draft protocol version', () => {
|
||||
const headers = buildStandardHeaders('tools/list');
|
||||
expect(headers['Mcp-Method']).toBe('tools/list');
|
||||
expect(headers['MCP-Protocol-Version']).toBe(DRAFT_PROTOCOL_VERSION);
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
expect(headers.Accept).toContain('application/json');
|
||||
expect(headers.Accept).toContain('text/event-stream');
|
||||
expect(headers['Mcp-Name']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sets Mcp-Name from params.name (tools/call) and params.uri (resources/read)', () => {
|
||||
expect(
|
||||
buildStandardHeaders('tools/call', { name: 'echo' })['Mcp-Name']
|
||||
).toBe('echo');
|
||||
expect(
|
||||
buildStandardHeaders('resources/read', { uri: 'file:///a.txt' })[
|
||||
'Mcp-Name'
|
||||
]
|
||||
).toBe('file:///a.txt');
|
||||
});
|
||||
|
||||
test('overrides replace defaults case-insensitively', () => {
|
||||
const headers = buildStandardHeaders('tools/list', undefined, {
|
||||
headers: { 'mcp-protocol-version': '2025-06-18' }
|
||||
});
|
||||
expect(headers['MCP-Protocol-Version']).toBeUndefined();
|
||||
expect(headers['mcp-protocol-version']).toBe('2025-06-18');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRequestMeta', () => {
|
||||
test('injects the required _meta fields', () => {
|
||||
const params = withRequestMeta({ name: 'echo' });
|
||||
const meta = params._meta as Record<string, unknown>;
|
||||
expect(meta['io.modelcontextprotocol/protocolVersion']).toBe(
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
);
|
||||
expect(meta['io.modelcontextprotocol/clientInfo']).toEqual(
|
||||
CONFORMANCE_CLIENT_INFO
|
||||
);
|
||||
expect(meta['io.modelcontextprotocol/clientCapabilities']).toEqual(
|
||||
DEFAULT_CLIENT_CAPABILITIES
|
||||
);
|
||||
expect(params.name).toBe('echo');
|
||||
});
|
||||
|
||||
test('keys already present in params._meta win over the defaults', () => {
|
||||
const params = withRequestMeta({
|
||||
_meta: { 'io.modelcontextprotocol/protocolVersion': '2025-06-18' }
|
||||
});
|
||||
const meta = params._meta as Record<string, unknown>;
|
||||
expect(meta['io.modelcontextprotocol/protocolVersion']).toBe('2025-06-18');
|
||||
expect(meta['io.modelcontextprotocol/clientInfo']).toEqual(
|
||||
CONFORMANCE_CLIENT_INFO
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendStatelessRequest', () => {
|
||||
test('parses a plain JSON response', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const request = JSON.parse(body);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: { tools: [] }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
try {
|
||||
const response = await sendStatelessRequest(
|
||||
`http://localhost:${port}/`,
|
||||
'tools/list'
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body?.result).toEqual({ tools: [] });
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
274
src/scenarios/server/stateless-client.ts
Normal file
274
src/scenarios/server/stateless-client.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/**
|
||||
* Stateless request helpers for server scenarios (SEP-2575 + SEP-2243).
|
||||
*
|
||||
* Every request the harness sends to a server under test carries the
|
||||
* cross-cutting obligations regardless of what a scenario actually tests:
|
||||
* the standard headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name, Accept)
|
||||
* and the `_meta` fields (protocolVersion, clientInfo, clientCapabilities),
|
||||
* pinned to `DRAFT_PROTOCOL_VERSION`. Scenarios exercising these SEPs MUST
|
||||
* build their requests through these helpers so a strictly-conformant server
|
||||
* never rejects harness traffic for reasons unrelated to the behaviour under
|
||||
* test (issues #311, #312, #315).
|
||||
*/
|
||||
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../types';
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string | null;
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export const CONFORMANCE_CLIENT_INFO = {
|
||||
name: 'conformance-test-client',
|
||||
version: '1.0.0'
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
sampling: {},
|
||||
elicitation: {},
|
||||
roots: { listChanged: true }
|
||||
} as const;
|
||||
|
||||
export interface StatelessResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
contentType?: string;
|
||||
/** The parsed JSON-RPC message (for SSE: the event matching the request id). */
|
||||
body?: JsonRpcResponse;
|
||||
/** All parsed events when the response was an SSE / chunked stream. */
|
||||
events?: unknown[];
|
||||
/** Raw response text when it could not be parsed as JSON. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
let nextRequestId = 1;
|
||||
|
||||
/**
|
||||
* The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and
|
||||
* prompts/get, `params.uri` for resources/read; absent otherwise.
|
||||
*/
|
||||
export function mcpNameForRequest(
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): string | undefined {
|
||||
if (method === 'tools/call' || method === 'prompts/get') {
|
||||
return typeof params?.name === 'string' ? params.name : undefined;
|
||||
}
|
||||
if (method === 'resources/read') {
|
||||
return typeof params?.uri === 'string' ? params.uri : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the conformant header set for a stateless request: Content-Type,
|
||||
* Accept (both content types), MCP-Protocol-Version, Mcp-Method and (when the
|
||||
* method carries one) Mcp-Name. `options.headers` overrides or extends the
|
||||
* defaults, replacing any default whose name matches case-insensitively.
|
||||
*/
|
||||
export function buildStandardHeaders(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options: { headers?: Record<string, string> } = {}
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION,
|
||||
'Mcp-Method': method
|
||||
};
|
||||
const name = mcpNameForRequest(method, params);
|
||||
if (name !== undefined) {
|
||||
headers['Mcp-Name'] = name;
|
||||
}
|
||||
|
||||
if (options.headers) {
|
||||
for (const [key, value] of Object.entries(options.headers)) {
|
||||
// Replace any default that differs only by case, then set the override.
|
||||
for (const existing of Object.keys(headers)) {
|
||||
if (existing.toLowerCase() === key.toLowerCase()) {
|
||||
delete headers[existing];
|
||||
}
|
||||
}
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge params with the conformant `_meta` required on every stateless
|
||||
* request. Keys already present in `params._meta` win over the defaults.
|
||||
*/
|
||||
export function withRequestMeta(
|
||||
params?: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...params,
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
|
||||
'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO,
|
||||
'io.modelcontextprotocol/clientCapabilities': DEFAULT_CLIENT_CAPABILITIES,
|
||||
...(params?._meta as Record<string, unknown> | undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isJsonRpcResponseShaped(event: unknown): event is JsonRpcResponse {
|
||||
return (
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
('result' in event || 'error' in event)
|
||||
);
|
||||
}
|
||||
|
||||
function parseSseLineInto(events: unknown[], rawLine: string): void {
|
||||
const line = rawLine.trim();
|
||||
if (!line) return;
|
||||
const jsonText = line.startsWith('data:')
|
||||
? line.replace(/^data:\s*/, '')
|
||||
: line;
|
||||
try {
|
||||
events.push(JSON.parse(jsonText));
|
||||
} catch {
|
||||
// Non-JSON line (comments, partial frames) — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an SSE / chunked-stream response incrementally and resolve as soon as
|
||||
* the JSON-RPC response matching `requestId` arrives, without waiting for the
|
||||
* stream to close. If the stream ends (or is aborted) first, returns whatever
|
||||
* events were parsed, with `body` set to the last response-shaped event.
|
||||
*/
|
||||
export async function readSseJsonRpcResponse(
|
||||
res: Response,
|
||||
requestId: number | string | null
|
||||
): Promise<{ events: unknown[]; body?: JsonRpcResponse }> {
|
||||
const events: unknown[] = [];
|
||||
const matchesRequest = (event: unknown): event is JsonRpcResponse =>
|
||||
isJsonRpcResponseShaped(event) && event.id === requestId;
|
||||
const finish = (): { events: unknown[]; body?: JsonRpcResponse } => {
|
||||
const match = events.find(matchesRequest);
|
||||
const lastResponseShaped = [...events]
|
||||
.reverse()
|
||||
.find(isJsonRpcResponseShaped);
|
||||
return { events, body: match ?? lastResponseShaped };
|
||||
};
|
||||
|
||||
if (!res.body) return finish();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
let value: Uint8Array | undefined;
|
||||
let done = false;
|
||||
try {
|
||||
({ value, done } = await reader.read());
|
||||
} catch {
|
||||
// The stream was aborted (timeout) or dropped — return what arrived.
|
||||
break;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) parseSseLineInto(events, line);
|
||||
|
||||
if (events.some(matchesRequest)) {
|
||||
// The response we were waiting for arrived; stop reading the stream.
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
parseSseLineInto(events, buffer);
|
||||
buffer = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Lock already released (e.g. after cancel) — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
return finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single stateless JSON-RPC request with the full set of cross-cutting
|
||||
* headers and `_meta`. Handles both JSON and SSE responses.
|
||||
*/
|
||||
export async function sendStatelessRequest(
|
||||
serverUrl: string,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options: { headers?: Record<string, string>; timeoutMs?: number } = {}
|
||||
): Promise<StatelessResponse> {
|
||||
const id = nextRequestId++;
|
||||
const headers = buildStandardHeaders(method, params, {
|
||||
headers: options.headers
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params: withRequestMeta(params)
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeoutMs ?? 10000
|
||||
);
|
||||
try {
|
||||
const res = await fetch(serverUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? undefined;
|
||||
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// Read the stream incrementally and resolve on the matching response —
|
||||
// a server that keeps the stream open must not stall the harness.
|
||||
const { events, body: matched } = await readSseJsonRpcResponse(res, id);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
contentType,
|
||||
events,
|
||||
body: matched
|
||||
};
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
try {
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
contentType,
|
||||
body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined
|
||||
};
|
||||
} catch {
|
||||
return { status: res.status, headers: res.headers, contentType, text };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
// Tear down any still-open SSE stream so sockets don't linger.
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,10 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import {
|
||||
buildStandardHeaders,
|
||||
readSseJsonRpcResponse
|
||||
} from './stateless-client';
|
||||
|
||||
const SPEC_REF = [
|
||||
{
|
||||
|
|
@ -115,12 +119,13 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
headersOverrides?: Record<string, string>,
|
||||
id: string | number | null = 1
|
||||
) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION,
|
||||
...headersOverrides
|
||||
};
|
||||
// The cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, Accept,
|
||||
// MCP-Protocol-Version) are not what this scenario exercises, so they
|
||||
// are always sent conformantly; overrides only alter the dimension a
|
||||
// test case is about (issue #312).
|
||||
const headers = buildStandardHeaders(method, params, {
|
||||
headers: headersOverrides
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
|
|
@ -131,10 +136,22 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
|
||||
const res = await fetch(serverUrl, { method: 'POST', headers, body });
|
||||
let data: any = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Response might not be JSON
|
||||
// Servers may answer single requests over text/event-stream; pick the
|
||||
// JSON-RPC message matching this request id instead of failing to parse
|
||||
// the stream as JSON.
|
||||
const contentType =
|
||||
typeof res.headers?.get === 'function'
|
||||
? (res.headers.get('content-type') ?? '')
|
||||
: '';
|
||||
if (contentType.includes('text/event-stream')) {
|
||||
const { body: matched } = await readSseJsonRpcResponse(res, id);
|
||||
data = matched ?? null;
|
||||
} else {
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Response might not be JSON
|
||||
}
|
||||
}
|
||||
return { res, data };
|
||||
};
|
||||
|
|
@ -150,11 +167,7 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
timeoutMs = 1000,
|
||||
onFirstFrame?: () => Promise<void>
|
||||
): Promise<any[]> => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION
|
||||
};
|
||||
const headers = buildStandardHeaders(method, params);
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
|
|
|
|||
Loading…
Reference in a new issue