fix: enforce HTTP 400 and data.requested for per-request _meta and version errors (#343)
The everything-server returned its -32602 missing-_meta rejection with HTTP 200; the draft spec requires 400 Bad Request. It also applied the per-request _meta validation to sessionless requests carrying a legacy session-era version header, although per-request metadata is a 2026-07-28 requirement; such requests now fall through to the session path. The server-stateless scenario gains a companion check (sep-2575-http-server-meta-invalid-400) requiring HTTP 400 on those rejections, and sep-2575-server-unsupported-version-error now also requires error.data.requested to echo the requested version. Two new negative tests pin both.
This commit is contained in:
parent
b9041ea41b
commit
1be8141586
3 changed files with 163 additions and 20 deletions
|
|
@ -1225,6 +1225,16 @@ app.use(
|
|||
})
|
||||
);
|
||||
|
||||
// Protocol revisions that use the initialize/session lifecycle. The
|
||||
// per-request `_meta` and header/body validation requirements apply to
|
||||
// 2026-07-28 and later, not to traffic from these revisions.
|
||||
const LEGACY_SESSION_PROTOCOL_VERSIONS = [
|
||||
'2024-11-05',
|
||||
'2025-03-26',
|
||||
'2025-06-18',
|
||||
'2025-11-25'
|
||||
];
|
||||
|
||||
// Handle POST requests - stateful mode
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
|
@ -1236,7 +1246,15 @@ app.post('/mcp', async (req, res) => {
|
|||
const meta = params._meta;
|
||||
const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion'];
|
||||
|
||||
if (!sessionId && (reqVersion || meta)) {
|
||||
// A request that carries no `_meta` and names a legacy session-era revision
|
||||
// in the header is legacy traffic; it is served by the session path below
|
||||
// instead of being rejected for missing per-request metadata.
|
||||
const isLegacySessionEraRequest =
|
||||
meta === undefined &&
|
||||
reqVersion !== undefined &&
|
||||
LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion);
|
||||
|
||||
if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) {
|
||||
// Missing Transport Header Validation Check
|
||||
if (!reqVersion) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -1246,14 +1264,16 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Per-Request Metadata Integrity Checks (Fields verification)
|
||||
// Per-Request Metadata Integrity Checks (Fields verification).
|
||||
// A request missing any required `_meta` field is malformed: -32602 and,
|
||||
// on HTTP, status 400 Bad Request.
|
||||
if (
|
||||
!meta ||
|
||||
!meta['io.modelcontextprotocol/protocolVersion'] ||
|
||||
!meta['io.modelcontextprotocol/clientInfo'] ||
|
||||
!meta['io.modelcontextprotocol/clientCapabilities']
|
||||
) {
|
||||
return res.status(200).json({
|
||||
return res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,93 @@ describe('Stateless Server Scenario Negative Tests', () => {
|
|||
expect(missingVersionCheck?.status).toBe('FAILURE');
|
||||
});
|
||||
|
||||
test('Fails validation when missing-_meta rejections are returned with HTTP 200', async () => {
|
||||
// This bad server picks the right JSON-RPC error code but the wrong HTTP
|
||||
// status: the spec requires 400 Bad Request for malformed requests.
|
||||
const mockUrl = mockFetchTarget((reqBody) => {
|
||||
const meta = reqBody.params?._meta;
|
||||
const missingRequired =
|
||||
!meta ||
|
||||
!meta['io.modelcontextprotocol/protocolVersion'] ||
|
||||
!meta['io.modelcontextprotocol/clientInfo'] ||
|
||||
!meta['io.modelcontextprotocol/clientCapabilities'];
|
||||
if (missingRequired) {
|
||||
return {
|
||||
status: 200, // Spec Violation: must be HTTP 400
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: reqBody.id,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: 'Invalid params: missing _meta or required fields'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const scenario = new ServerStatelessScenario();
|
||||
const checks = await scenario.run(testContext(mockUrl));
|
||||
|
||||
// The JSON-RPC code is correct, so the per-field checks pass; the wrong
|
||||
// HTTP status is caught by the companion status check.
|
||||
const missingMetaCheck = findCheck(
|
||||
checks,
|
||||
'sep-2575-request-meta-invalid-missing-meta'
|
||||
);
|
||||
const httpStatusCheck = findCheck(
|
||||
checks,
|
||||
'sep-2575-http-server-meta-invalid-400'
|
||||
);
|
||||
|
||||
expect(missingMetaCheck?.status).toBe('SUCCESS');
|
||||
expect(httpStatusCheck?.status).toBe('FAILURE');
|
||||
});
|
||||
|
||||
test('Fails validation when the unsupported-version error omits data.requested', async () => {
|
||||
const mockUrl = mockFetchTarget((reqBody) => {
|
||||
const meta = reqBody.params?._meta;
|
||||
if (meta?.['io.modelcontextprotocol/protocolVersion'] === 'v999.0.0') {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: reqBody.id,
|
||||
error: {
|
||||
code: -32004,
|
||||
message: 'Unsupported protocol version',
|
||||
// Spec Violation: data.requested is a required member
|
||||
data: { supported: ['2026-07-28'] }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (reqBody.method === 'server/discover') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: reqBody.id,
|
||||
result: {
|
||||
supportedVersions: ['2026-07-28'],
|
||||
capabilities: {},
|
||||
serverInfo: { name: 'no-requested-server', version: '1.0.0' }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const scenario = new ServerStatelessScenario();
|
||||
const checks = await scenario.run(testContext(mockUrl));
|
||||
|
||||
const negotiationCheck = findCheck(
|
||||
checks,
|
||||
'sep-2575-server-unsupported-version-error'
|
||||
);
|
||||
expect(negotiationCheck?.status).toBe('FAILURE');
|
||||
});
|
||||
|
||||
test('Fails validation if removed legacy RPCs do not return HTTP 404 Not Found', async () => {
|
||||
// This bad server intercepts the removed 'ping' or 'initialize' methods but incorrectly returns HTTP 200
|
||||
const mockUrl = mockFetchTarget((reqBody) => {
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
|
||||
**Grouped Specification Requirements**:
|
||||
|
||||
1. **Per-Request _meta Validation (4 Checks)**
|
||||
- Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature.
|
||||
1. **Per-Request _meta Validation (5 Checks)**
|
||||
- Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature and an HTTP status code \`400 Bad Request\`.
|
||||
2. **Discovery & Capabilities (3 Checks)**
|
||||
- Implements \`server/discover\` mapping exact mandatory protocol elements.
|
||||
- Dynamically checks prompt capability declaration constraints, validates that active RPC handlers match advertised discovery capacities.
|
||||
|
|
@ -356,26 +356,50 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
}
|
||||
];
|
||||
for (const testCase of metaValidationTestCases) {
|
||||
const metaProbe = await sendRpc(
|
||||
'server/discover',
|
||||
testCase.params,
|
||||
undefined,
|
||||
testCase.rpcId
|
||||
).catch(() => null);
|
||||
const metaRes: any = metaProbe?.res ?? null;
|
||||
const metaData: any = metaProbe?.data ?? null;
|
||||
if (metaData) checkErrorId(metaData, testCase.rpcId);
|
||||
|
||||
await runCheck(
|
||||
`sep-2575-request-meta-invalid-${testCase.slug}`,
|
||||
'RequestMetaInvalid',
|
||||
testCase.description,
|
||||
async () => {
|
||||
const { data } = await sendRpc(
|
||||
'server/discover',
|
||||
testCase.params,
|
||||
undefined,
|
||||
testCase.rpcId
|
||||
);
|
||||
checkErrorId(data, testCase.rpcId);
|
||||
|
||||
if (data?.error?.code !== -32602) {
|
||||
() => {
|
||||
if (!metaProbe)
|
||||
return { error: '_meta validation probe failed completely' };
|
||||
if (metaData?.error?.code !== -32602) {
|
||||
return {
|
||||
error: `Expected error code -32602, got ${data?.error?.code}`,
|
||||
details: { fieldIssue: testCase.slug, response: data }
|
||||
error: `Expected error code -32602, got ${metaData?.error?.code}`,
|
||||
details: { fieldIssue: testCase.slug, response: metaData }
|
||||
};
|
||||
}
|
||||
return { details: { fieldIssue: testCase.slug, response: data } };
|
||||
return { details: { fieldIssue: testCase.slug, response: metaData } };
|
||||
},
|
||||
{ fieldIssue: testCase.slug }
|
||||
);
|
||||
|
||||
// Companion HTTP-status check: a request missing a required _meta field
|
||||
// is malformed and, on HTTP, the rejection MUST use 400 Bad Request.
|
||||
await runCheck(
|
||||
'sep-2575-http-server-meta-invalid-400',
|
||||
'HttpServerMetaInvalid400',
|
||||
'Rejections of requests missing required _meta fields use HTTP 400 Bad Request.',
|
||||
() => {
|
||||
if (!metaRes)
|
||||
return { error: '_meta validation probe failed completely' };
|
||||
if (metaRes.status !== 400) {
|
||||
return {
|
||||
error: `Expected HTTP 400 Bad Request, got status code ${metaRes.status}`,
|
||||
details: { fieldIssue: testCase.slug, response: metaData }
|
||||
};
|
||||
}
|
||||
return { details: { fieldIssue: testCase.slug, response: metaData } };
|
||||
},
|
||||
{ fieldIssue: testCase.slug }
|
||||
);
|
||||
|
|
@ -498,14 +522,15 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
// ==========================================
|
||||
// 3. Version Negotiation & Headers (3 Checks)
|
||||
// ==========================================
|
||||
const requestedUnsupportedVersion = 'v999.0.0';
|
||||
const unsupportedMeta = {
|
||||
...validMeta,
|
||||
'io.modelcontextprotocol/protocolVersion': 'v999.0.0'
|
||||
'io.modelcontextprotocol/protocolVersion': requestedUnsupportedVersion
|
||||
};
|
||||
const response301 = await sendRpc(
|
||||
'server/discover',
|
||||
{ _meta: unsupportedMeta },
|
||||
{ 'MCP-Protocol-Version': 'v999.0.0' },
|
||||
{ 'MCP-Protocol-Version': requestedUnsupportedVersion },
|
||||
301
|
||||
).catch(() => null);
|
||||
const res301: any = response301?.res ?? null;
|
||||
|
|
@ -515,7 +540,7 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
await runCheck(
|
||||
'sep-2575-server-unsupported-version-error',
|
||||
'ServerUnsupportedVersionError',
|
||||
'If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support.',
|
||||
'If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support; the error data carries the supported versions and echoes the requested version.',
|
||||
() => {
|
||||
if (!data301)
|
||||
return { error: 'Unsupported version invocation failed completely' };
|
||||
|
|
@ -533,6 +558,17 @@ export class ServerStatelessScenario implements ClientScenario {
|
|||
return {
|
||||
error: `Returned supported versions data layout does not correlate to active server metrics: ${JSON.stringify(errSupportedVersions)}`
|
||||
};
|
||||
|
||||
// UnsupportedProtocolVersionError data carries both required members:
|
||||
// `supported` (asserted above) and `requested`, which echoes the
|
||||
// version the request asked for.
|
||||
const requestedEcho = data301?.error?.data?.requested;
|
||||
if (requestedEcho !== requestedUnsupportedVersion) {
|
||||
return {
|
||||
error: `error.data.requested must echo the requested version '${requestedUnsupportedVersion}', got ${JSON.stringify(requestedEcho)}`,
|
||||
details: { response: data301 }
|
||||
};
|
||||
}
|
||||
return { details: { response: data301 } };
|
||||
}
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue