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:
Felix Weinberger 2026-06-16 17:17:03 +01:00 • committed by GitHub
commit 1be8141586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 163 additions and 20 deletions

View file

@ -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: {

View file

@ -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) => {

View file

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