diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts new file mode 100644 index 00000000..7df8a6e9 --- /dev/null +++ b/src/scenarios/client/http-base.ts @@ -0,0 +1,156 @@ +/** + * Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios. + * + * A scenario that needs to act as a Streamable-HTTP MCP server, inspect + * incoming client requests, and emit ConformanceChecks should extend this + * class and implement handlePost() + getChecks(). start()/stop() and the + * GET/DELETE/body-parse boilerplate are handled here. + */ + +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + SpecVersion, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; + +export abstract class BaseHttpScenario implements Scenario { + abstract name: string; + abstract description: string; + abstract specVersions: SpecVersion[]; + allowClientError?: boolean; + + protected server: http.Server | null = null; + protected checks: ConformanceCheck[] = []; + protected port: number = 0; + protected sessionId: string = `session-${Date.now()}`; + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + this.server.on('error', reject); + this.server.listen(0, () => { + const address = this.server!.address(); + if (address && typeof address === 'object') { + this.port = address.port; + resolve({ serverUrl: `http://localhost:${this.port}` }); + } else { + reject(new Error('Failed to get server address')); + } + }); + }); + } + + async stop(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + this.server.close((err) => { + if (err) reject(err); + else { + this.server = null; + resolve(); + } + }); + } else { + resolve(); + } + }); + } + + abstract getChecks(): ConformanceCheck[]; + + protected handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'mcp-session-id': this.sessionId + }); + res.write('data: \n\n'); + return; + } + if (req.method === 'DELETE') { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== 'POST') { + res.writeHead(405); + res.end('Method Not Allowed'); + return; + } + + // Decode the stream as UTF-8 so multi-byte characters that straddle a + // chunk boundary aren't corrupted by per-chunk Buffer.toString(). + req.setEncoding('utf8'); + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + const request = JSON.parse(body); + this.handlePost(req, res, request); + } catch (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32700, message: `Parse error: ${error}` } + }) + ); + } + }); + } + + protected abstract handlePost( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void; + + protected sendJson(res: http.ServerResponse, body: object): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + res.end(JSON.stringify(body)); + } + + protected sendInitialize( + res: http.ServerResponse, + request: any, + capabilities: object = { tools: {} } + ): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: DRAFT_PROTOCOL_VERSION, + serverInfo: { name: this.name + '-server', version: '1.0.0' }, + capabilities + } + }); + } + + protected sendNotificationAck(res: http.ServerResponse): void { + res.writeHead(202); + res.end(); + } + + protected sendGenericResult(res: http.ServerResponse, request: any): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: {} + }); + } +} diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index f81acd20..55da7591 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -12,11 +12,12 @@ import http from 'http'; import { - Scenario, ScenarioUrls, ConformanceCheck, - SpecVersion + SpecVersion, + DRAFT_PROTOCOL_VERSION } from '../../types.js'; +import { BaseHttpScenario } from './http-base.js'; const SPEC_REFERENCE_CUSTOM = { id: 'SEP-2243-Custom-Headers', @@ -38,7 +39,7 @@ const SPEC_REFERENCE_TOOL_DEF = { * Base64-encoded values use the format: =?base64?{Base64EncodedValue}?= */ function decodeHeaderValue(value: string): string { - const base64Match = value.match(/^=\?base64\?(.+)\?=$/); + const base64Match = value.match(/^=\?base64\?(.*)\?=$/); if (base64Match) { return Buffer.from(base64Match[1], 'base64').toString('utf-8'); } @@ -78,7 +79,7 @@ function validateEncodedHeader( ): string | null { if (needsBase64Encoding(bodyValue)) { // Value requires Base64 encoding - const base64Match = rawHeader.match(/^=\?base64\?(.+)\?=$/); + const base64Match = rawHeader.match(/^=\?base64\?(.*)\?=$/); if (!base64Match) { return `Value '${bodyValue}' requires Base64 encoding but header was sent as plain: '${rawHeader}'`; @@ -132,146 +133,13 @@ function compareNumericValues( return null; } -// Shared server boilerplate for Scenario implementations -abstract class BaseHttpScenario implements Scenario { - abstract name: string; - abstract description: string; - abstract specVersions: SpecVersion[]; - allowClientError?: boolean; - - protected server: http.Server | null = null; - protected checks: ConformanceCheck[] = []; - protected port: number = 0; - protected sessionId: string = `session-${Date.now()}`; - - async start(): Promise { - return new Promise((resolve, reject) => { - this.server = http.createServer((req, res) => { - this.handleRequest(req, res); - }); - this.server.on('error', reject); - this.server.listen(0, () => { - const address = this.server!.address(); - if (address && typeof address === 'object') { - this.port = address.port; - resolve({ serverUrl: `http://localhost:${this.port}` }); - } else { - reject(new Error('Failed to get server address')); - } - }); - }); - } - - async stop(): Promise { - return new Promise((resolve, reject) => { - if (this.server) { - this.server.close((err) => { - if (err) reject(err); - else { - this.server = null; - resolve(); - } - }); - } else { - resolve(); - } - }); - } - - abstract getChecks(): ConformanceCheck[]; - - protected handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse - ): void { - if (req.method === 'GET') { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'mcp-session-id': this.sessionId - }); - res.write('data: \n\n'); - return; - } - if (req.method === 'DELETE') { - res.writeHead(200); - res.end(); - return; - } - if (req.method !== 'POST') { - res.writeHead(405); - res.end('Method Not Allowed'); - return; - } - - let body = ''; - req.on('data', (chunk) => { - body += chunk.toString(); - }); - req.on('end', () => { - try { - const request = JSON.parse(body); - this.handlePost(req, res, request); - } catch (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32700, message: `Parse error: ${error}` } - }) - ); - } - }); - } - - protected abstract handlePost( - req: http.IncomingMessage, - res: http.ServerResponse, - request: any - ): void; - - protected sendJson(res: http.ServerResponse, body: object): void { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'mcp-session-id': this.sessionId - }); - res.end(JSON.stringify(body)); - } - - protected sendInitialize(res: http.ServerResponse, request: any): void { - this.sendJson(res, { - jsonrpc: '2.0', - id: request.id, - result: { - protocolVersion: 'DRAFT-2026-v1', - serverInfo: { name: this.name + '-server', version: '1.0.0' }, - capabilities: { tools: {} } - } - }); - } - - protected sendNotificationAck(res: http.ServerResponse): void { - res.writeHead(202); - res.end(); - } - - protected sendGenericResult(res: http.ServerResponse, request: any): void { - this.sendJson(res, { - jsonrpc: '2.0', - id: request.id, - result: {} - }); - } -} - // ───────────────────────────────────────────────────────────────────────────── // HttpCustomHeadersScenario - tests that clients mirror x-mcp-header params // ───────────────────────────────────────────────────────────────────────────── export class HttpCustomHeadersScenario extends BaseHttpScenario { name = 'http-custom-headers'; - specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = 'Tests that client mirrors x-mcp-header tool parameters into Mcp-Param headers with correct encoding (SEP-2243)'; @@ -322,7 +190,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { getChecks(): ConformanceCheck[] { if (!this.toolCallReceived) { this.checks.push({ - id: 'client-custom-header-tool-call', + id: 'sep-2243-param-header-tool-call-gate', name: 'ClientCustomHeaderToolCall', description: 'Client calls the tool with x-mcp-header annotations', status: 'FAILURE', @@ -334,7 +202,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { } if (!this.nullToolCallReceived) { this.checks.push({ - id: 'client-custom-header-omit-null', + id: 'sep-2243-client-omit-null', name: 'ClientCustomHeaderOmitNull', description: 'Client MUST omit Mcp-Param header when parameter value is null or not provided', @@ -525,31 +393,11 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { this.checkParamHeader(req, 'Priority', args.priority, 'number'); // Check Mcp-Param-Verbose header (boolean value) + // checkParamHeader already FAILs on missing header, so this also covers + // "optional parameter present → client MUST include header" without a + // separate check id. if (args.verbose !== undefined && args.verbose !== null) { this.checkParamHeader(req, 'Verbose', args.verbose, 'boolean'); - - // Explicit check: optional parameter present → client MUST include header - const verboseHeader = req.headers['mcp-param-verbose'] as - | string - | undefined; - this.checks.push({ - id: 'client-custom-header-optional-present', - name: 'ClientCustomHeaderOptionalPresent', - description: - 'Client MUST include Mcp-Param header when optional parameter is provided', - status: verboseHeader !== undefined ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: - verboseHeader === undefined - ? `Optional parameter 'verbose' was provided with value '${args.verbose}' but Mcp-Param-Verbose header is missing. Client MUST include the header when the parameter is present.` - : undefined, - specReferences: [SPEC_REFERENCE_CUSTOM], - details: { - parameter: 'verbose', - bodyValue: args.verbose, - headerPresent: verboseHeader !== undefined - } - }); } // Check Mcp-Param-Debug header (boolean true value) @@ -646,18 +494,19 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { // Check that 'query' (no x-mcp-header) is NOT mirrored const queryHeader = req.headers['mcp-param-query'] as string | undefined; - if (queryHeader !== undefined) { - this.checks.push({ - id: 'client-custom-header-no-mirror-unannotated', - name: 'ClientCustomHeaderNoMirrorUnannotated', - description: - 'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Found unexpected Mcp-Param-Query header '${queryHeader}' for unannotated parameter`, - specReferences: [SPEC_REFERENCE_CUSTOM] - }); - } + this.checks.push({ + id: 'sep-2243-no-mirror-unannotated', + name: 'ClientCustomHeaderNoMirrorUnannotated', + description: + 'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header', + status: queryHeader === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + queryHeader !== undefined + ? `Found unexpected Mcp-Param-Query header '${queryHeader}' for unannotated parameter` + : undefined, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); } else if (toolName === 'test_custom_headers_null') { this.nullToolCallReceived = true; @@ -666,7 +515,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { | string | undefined; this.checks.push({ - id: 'client-custom-header-omit-null', + id: 'sep-2243-client-omit-null', name: 'ClientCustomHeaderOmitNull', description: 'Client MUST omit Mcp-Param header when parameter value is null or not provided', @@ -734,7 +583,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { } this.checks.push({ - id: `client-custom-header-${headerName.toLowerCase()}`, + id: `sep-2243-param-header-${headerName.toLowerCase()}`, name: `ClientCustomHeader_${headerName}`, description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`, status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -760,7 +609,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { name = 'http-invalid-tool-headers'; - specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = 'Tests that client rejects tools with invalid x-mcp-header annotations (SEP-2243)'; allowClientError = true; @@ -771,7 +620,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { getChecks(): ConformanceCheck[] { if (!this.toolsListSent) { this.checks.push({ - id: 'client-invalid-tool-headers-tools-list', + id: 'sep-2243-invalid-tool-tools-list-gate', name: 'ClientInvalidToolHeadersToolsList', description: 'Client requests tools/list', status: 'FAILURE', @@ -784,7 +633,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { // Check that valid_tool WAS called — proves client kept valid tools const validToolCalled = this.calledTools.has('valid_tool'); this.checks.push({ - id: 'client-keeps-valid-tool', + id: 'sep-2243-keep-valid-tool', name: 'ClientKeepsValidTool', description: 'Client MUST keep valid tools while excluding invalid ones', status: validToolCalled ? 'SUCCESS' : 'FAILURE', @@ -812,7 +661,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { for (const toolName of invalidTools) { const called = this.calledTools.has(toolName); this.checks.push({ - id: `client-rejects-invalid-tool-${toolName}`, + id: `sep-2243-reject-invalid-tool-${toolName.replace(/_/g, '-')}`, name: `ClientRejectsInvalidTool_${toolName}`, description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`, status: called ? 'FAILURE' : 'SUCCESS', diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts new file mode 100644 index 00000000..2d4c3038 --- /dev/null +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { HttpStandardHeadersScenario } from './http-standard-headers'; + +/** + * Negative test for SEP-2243 standard-header checks: a client that omits + * Mcp-Method on a POST must produce a FAILURE row, and one that includes it + * must produce SUCCESS. Pins the check id so coverage is tracked. + */ +describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { + async function postInitialize( + serverUrl: string, + extraHeaders: Record + ): Promise { + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...extraHeaders + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 'DRAFT-2026-v1', + clientInfo: { name: 'neg-test', version: '0' }, + capabilities: {} + } + }) + }); + } + + it('FAILs sep-2243-mcp-method-header-initialize when Mcp-Method is missing', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(); + try { + await postInitialize(serverUrl, {}); // no Mcp-Method header + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === 'sep-2243-mcp-method-header-initialize' + ); + expect(check?.status).toBe('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('SUCCEEDs sep-2243-mcp-method-header-initialize when Mcp-Method matches', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(); + try { + await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === 'sep-2243-mcp-method-header-initialize' + ); + expect(check?.status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); + + it('getChecks() is idempotent', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(); + try { + await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); + const first = scenario.getChecks(); + const second = scenario.getChecks(); + expect(second.length).toBe(first.length); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts index 5f330e47..a17300b4 100644 --- a/src/scenarios/client/http-standard-headers.ts +++ b/src/scenarios/client/http-standard-headers.ts @@ -13,74 +13,37 @@ import http from 'http'; import { - Scenario, - ScenarioUrls, ConformanceCheck, - SpecVersion + SpecVersion, + DRAFT_PROTOCOL_VERSION } from '../../types.js'; +import { BaseHttpScenario } from './http-base.js'; const SPEC_REFERENCE = { id: 'SEP-2243-Standard-Headers', url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers' }; -export class HttpStandardHeadersScenario implements Scenario { +export class HttpStandardHeadersScenario extends BaseHttpScenario { name = 'http-standard-headers'; - specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = 'Tests that client includes Mcp-Method and Mcp-Name headers on HTTP POST requests (SEP-2243)'; - private server: http.Server | null = null; - private checks: ConformanceCheck[] = []; - private port: number = 0; - private sessionId: string = `session-${Date.now()}`; - // Track which header checks have been recorded private methodHeaderChecks = new Map(); // Track which Mcp-Name checks have been recorded private nameHeaderChecks = new Map(); - async start(): Promise { - return new Promise((resolve, reject) => { - this.server = http.createServer((req, res) => { - this.handleRequest(req, res); - }); - - this.server.on('error', reject); - - this.server.listen(0, () => { - const address = this.server!.address(); - if (address && typeof address === 'object') { - this.port = address.port; - resolve({ - serverUrl: `http://localhost:${this.port}` - }); - } else { - reject(new Error('Failed to get server address')); - } - }); - }); - } - - async stop(): Promise { - return new Promise((resolve, reject) => { - if (this.server) { - this.server.close((err) => { - if (err) reject(err); - else { - this.server = null; - resolve(); - } - }); - } else { - resolve(); - } - }); - } - getChecks(): ConformanceCheck[] { - // Enforce that Mcp-Method was checked for all expected request types - // SEP-2243 requires Mcp-Method on "all requests and notifications" + // Build a fresh array each call so getChecks() is idempotent — the runner + // may call it more than once and we must not accumulate duplicates. + const result = [...this.checks]; + + // SEP-2243 requires Mcp-Method on "all requests and notifications". A + // client that never sent prompts/list isn't violating SEP-2243 — it just + // didn't exercise that path. Emit SKIPPED (not FAILURE) so a prompts-less + // client doesn't show red, but the gap is still visible in the report. const expectedMethods = [ 'initialize', 'notifications/initialized', @@ -94,123 +57,68 @@ export class HttpStandardHeadersScenario implements Scenario { for (const method of expectedMethods) { if (!this.methodHeaderChecks.has(method)) { - this.checks.push({ - id: `client-mcp-method-header-${method.replace('/', '-')}`, - name: `ClientMcpMethodHeader_${method.replace('/', '_')}`, + result.push({ + id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`, + name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, description: `Client sends correct Mcp-Method header on ${method} request`, - status: 'FAILURE', + status: 'SKIPPED', timestamp: new Date().toISOString(), - errorMessage: `Client did not send a ${method} request. Expected Mcp-Method header to be tested.`, + errorMessage: `Client did not send a ${method} request; Mcp-Method header was not exercised for this method.`, specReferences: [SPEC_REFERENCE] }); } } - // Enforce that Mcp-Name was checked for methods that require it const expectedNameMethods = ['tools/call', 'resources/read', 'prompts/get']; for (const method of expectedNameMethods) { if (!this.nameHeaderChecks.has(method)) { - this.checks.push({ - id: `client-mcp-name-header-${method.replace('/', '-')}`, - name: `ClientMcpNameHeader_${method.replace('/', '_')}`, + result.push({ + id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`, + name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, description: `Client sends correct Mcp-Name header on ${method} request`, - status: 'FAILURE', + status: 'SKIPPED', timestamp: new Date().toISOString(), - errorMessage: `Client did not send a ${method} request. Expected Mcp-Name header to be tested.`, + errorMessage: `Client did not send a ${method} request; Mcp-Name header was not exercised for this method.`, specReferences: [SPEC_REFERENCE] }); } } - return this.checks; + return result; } - private handleRequest( + protected handlePost( req: http.IncomingMessage, - res: http.ServerResponse + res: http.ServerResponse, + request: any ): void { - if (req.method !== 'POST') { - // Handle GET for SSE resumability - just close - if (req.method === 'GET') { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'mcp-session-id': this.sessionId - }); - res.write('data: \n\n'); - return; - } - if (req.method === 'DELETE') { - res.writeHead(200); - res.end(); - return; - } - res.writeHead(405); - res.end('Method Not Allowed'); - return; + // Check Mcp-Method header for every request + this.checkMcpMethodHeader(req, request); + + // Route to handlers + if (request.method === 'initialize') { + this.handleInitialize(res, request); + } else if (request.method === 'tools/list') { + this.handleToolsList(res, request); + } else if (request.method === 'tools/call') { + this.checkMcpNameHeader(req, request, 'params.name'); + this.handleToolsCall(res, request); + } else if (request.method === 'resources/list') { + this.handleResourcesList(res, request); + } else if (request.method === 'resources/read') { + this.checkMcpNameHeader(req, request, 'params.uri'); + this.handleResourcesRead(res, request); + } else if (request.method === 'prompts/list') { + this.handlePromptsList(res, request); + } else if (request.method === 'prompts/get') { + this.checkMcpNameHeader(req, request, 'params.name'); + this.handlePromptsGet(res, request); + } else if (request.id === undefined) { + // Notifications - return 202 (Mcp-Method already checked above) + this.sendNotificationAck(res); + } else { + this.sendGenericResult(res, request); } - - let body = ''; - req.on('data', (chunk) => { - body += chunk.toString(); - }); - - req.on('end', () => { - try { - const request = JSON.parse(body); - - // Check Mcp-Method header for every request - this.checkMcpMethodHeader(req, request); - - // Route to handlers - if (request.method === 'initialize') { - this.handleInitialize(res, request); - } else if (request.method === 'tools/list') { - this.handleToolsList(res, request); - } else if (request.method === 'tools/call') { - this.checkMcpNameHeader(req, request, 'params.name'); - this.handleToolsCall(res, request); - } else if (request.method === 'resources/list') { - this.handleResourcesList(res, request); - } else if (request.method === 'resources/read') { - this.checkMcpNameHeader(req, request, 'params.uri'); - this.handleResourcesRead(res, request); - } else if (request.method === 'prompts/list') { - this.handlePromptsList(res, request); - } else if (request.method === 'prompts/get') { - this.checkMcpNameHeader(req, request, 'params.name'); - this.handlePromptsGet(res, request); - } else if (request.id === undefined) { - // Notifications - return 202 (Mcp-Method already checked above) - res.writeHead(202); - res.end(); - } else { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'mcp-session-id': this.sessionId - }); - res.end( - JSON.stringify({ - jsonrpc: '2.0', - id: request.id, - result: {} - }) - ); - } - } catch (error) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32700, - message: `Parse error: ${error}` - } - }) - ); - } - }); } private checkMcpMethodHeader(req: http.IncomingMessage, request: any): void { @@ -238,8 +146,8 @@ export class HttpStandardHeadersScenario implements Scenario { this.methodHeaderChecks.set(method, errors.length === 0); this.checks.push({ - id: `client-mcp-method-header-${method.replace('/', '-')}`, - name: `ClientMcpMethodHeader_${method.replace('/', '_')}`, + id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`, + name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, description: `Client sends correct Mcp-Method header on ${method} request`, status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -258,6 +166,12 @@ export class HttpStandardHeadersScenario implements Scenario { sourceField: string ): void { const method = request.method; + + // Same de-dup guard as checkMcpMethodHeader: the harness advertises two + // tools and two resources, so a client that calls both would otherwise + // produce duplicate check rows for the same id. + if (this.nameHeaderChecks.has(method)) return; + const expectedValue = sourceField === 'params.uri' ? request.params?.uri : request.params?.name; @@ -277,8 +191,8 @@ export class HttpStandardHeadersScenario implements Scenario { this.nameHeaderChecks.set(method, errors.length === 0); this.checks.push({ - id: `client-mcp-name-header-${method.replace('/', '-')}`, - name: `ClientMcpNameHeader_${method.replace('/', '_')}`, + id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`, + name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, description: `Client sends correct Mcp-Name header on ${method} request`, status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -294,29 +208,11 @@ export class HttpStandardHeadersScenario implements Scenario { } private handleInitialize(res: http.ServerResponse, request: any): void { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'mcp-session-id': this.sessionId + this.sendInitialize(res, request, { + tools: {}, + resources: {}, + prompts: {} }); - - res.end( - JSON.stringify({ - jsonrpc: '2.0', - id: request.id, - result: { - protocolVersion: 'DRAFT-2026-v1', - serverInfo: { - name: 'http-standard-headers-test-server', - version: '1.0.0' - }, - capabilities: { - tools: {}, - resources: {}, - prompts: {} - } - } - }) - ); } private handleToolsList(res: http.ServerResponse, request: any): void { diff --git a/src/scenarios/sep-2243.yaml b/src/scenarios/sep-2243.yaml deleted file mode 100644 index d5e7b045..00000000 --- a/src/scenarios/sep-2243.yaml +++ /dev/null @@ -1,100 +0,0 @@ -sep: 2243 -spec_url: https://modelcontextprotocol.io/specification/draft/basic/transports -requirements: - - text: 'HTTP POST requests MUST include the Mcp-Method header mirrored from the JSON-RPC method for all requests and notifications.' - check: client-mcp-method-header-initialize - - text: 'HTTP POST notifications MUST include the Mcp-Method header mirrored from the JSON-RPC method.' - check: client-mcp-method-header-notifications-initialized - - text: 'tools/call requests MUST include the Mcp-Name header mirrored from params.name.' - check: client-mcp-name-header-tools-call - - text: 'resources/read requests MUST include the Mcp-Name header mirrored from params.uri.' - check: client-mcp-name-header-resources-read - - text: 'prompts/get requests MUST include the Mcp-Name header mirrored from params.name.' - check: client-mcp-name-header-prompts-get - - text: 'Servers that process the request body MUST reject requests where Mcp-Method does not match the value in the request body.' - check: server-rejects-mismatched-method-header - - text: 'Servers that process the request body MUST reject requests where Mcp-Name does not match the value in the request body.' - check: server-rejects-mismatched-name-header - - text: 'Clients and servers MUST use case-insensitive comparisons for header names.' - check: server-accepts-lowercase-header-name - - text: 'Header names MUST remain case-insensitive even when sent in mixed or uppercase form.' - check: server-accepts-uppercase-header-name - - text: 'Method header values remain case-sensitive, so mismatched casing MUST be rejected.' - check: server-rejects-case-mismatch-value - - text: 'Servers MUST accept extra whitespace around header values and compare the trimmed value to the request body.' - check: server-accepts-whitespace-header-value - - text: 'Clients MUST support x-mcp-header annotations and mirror designated tool parameter values into HTTP headers.' - check: client-custom-header-region - - text: 'x-mcp-header values MUST NOT be empty.' - check: client-rejects-invalid-tool-invalid_empty_header - - text: 'Duplicate x-mcp-header values with the same spelling MUST be rejected.' - check: client-rejects-invalid-tool-invalid_duplicate_same_case - - text: 'x-mcp-header values MUST be case-insensitively unique within a tool inputSchema.' - check: client-rejects-invalid-tool-invalid_duplicate_diff_case - - text: 'x-mcp-header values MUST only be applied to primitive string, number, or boolean parameters.' - check: client-rejects-invalid-tool-invalid_object_header - - text: 'x-mcp-header annotations on array parameters MUST be rejected.' - check: client-rejects-invalid-tool-invalid_array_header - - text: 'x-mcp-header annotations on null parameters MUST be rejected.' - check: client-rejects-invalid-tool-invalid_null_header - - text: 'Clients MUST reject tool definitions whose x-mcp-header values contain spaces.' - check: client-rejects-invalid-tool-invalid_space_in_name - - text: 'Clients MUST reject tool definitions whose x-mcp-header values contain colons.' - check: client-rejects-invalid-tool-invalid_colon_in_name - - text: 'Clients MUST reject tool definitions whose x-mcp-header values contain non-ASCII characters.' - check: client-rejects-invalid-tool-invalid_non_ascii_name - - text: 'Clients MUST reject tool definitions whose x-mcp-header values contain control characters.' - check: client-rejects-invalid-tool-invalid_control_char_name - - text: 'Clients MUST exclude invalid tools from tools/list results while keeping valid tools.' - check: client-keeps-valid-tool - - text: 'Clients MUST convert numeric parameter values to decimal strings before mirroring them into Mcp-Param headers.' - check: client-custom-header-priority - - text: 'Clients MUST convert boolean parameter values to lowercase true/false before mirroring them into Mcp-Param headers.' - check: client-custom-header-verbose - - text: 'Clients MUST convert boolean true to lowercase "true" before mirroring into Mcp-Param headers.' - check: client-custom-header-debug - - text: 'If an x-mcp-header parameter value is provided, the client MUST include the corresponding Mcp-Param header.' - check: client-custom-header-optional-present - - text: 'If an x-mcp-header parameter value is null or omitted, the client MUST omit the corresponding Mcp-Param header.' - check: client-custom-header-omit-null - - text: 'When a value cannot be safely represented as a plain ASCII header value, clients MUST use the =?base64?...?= wrapper for non-ASCII values.' - check: client-custom-header-nonascii - - text: 'When a value has leading or trailing whitespace, clients MUST use the =?base64?...?= wrapper.' - check: client-custom-header-whitespace - - text: 'When a value has leading whitespace only, clients MUST use the =?base64?...?= wrapper.' - check: client-custom-header-leadingspace - - text: 'When a value has trailing whitespace only, clients MUST use the =?base64?...?= wrapper.' - check: client-custom-header-trailingspace - - text: 'When a value has internal spaces only (no leading/trailing), clients MUST send it as plain ASCII without Base64.' - check: client-custom-header-internalspace - - text: 'When a value contains control characters, clients MUST use the =?base64?...?= wrapper.' - check: client-custom-header-controlchar - - text: 'When a value contains carriage return and line feed, clients MUST use the =?base64?...?= wrapper.' - check: client-custom-header-crlf - - text: 'When a value has a leading tab, clients MUST use the =?base64?...?= wrapper.' - check: client-custom-header-tab - - text: 'Servers that inspect Base64-encoded Mcp-Param values MUST decode them before comparing them with the request body.' - check: server-accepts-valid-base64 - - text: 'Servers MUST reject requests with invalid Base64 padding in Mcp-Param values.' - check: server-rejects-invalid-base64-padding - - text: 'Servers MUST reject requests with invalid Base64 characters in Mcp-Param values.' - check: server-rejects-invalid-base64-chars - - - text: 'Servers MUST return HTTP 400 Bad Request when required standard headers are missing.' - check: server-rejects-missing-method-header - - text: 'Servers MUST reject requests where Mcp-Name is omitted but the corresponding body value is present.' - check: server-rejects-missing-name-header - - text: 'Servers MUST reject requests where a required custom header is omitted but the corresponding body value is present.' - check: server-rejects-missing-custom-header - - text: 'When rejecting a request due to header validation failure, servers MUST return JSON-RPC error code -32001 (HeaderMismatch).' - check: server-rejects-mismatched-method-header - - text: 'Server treats value without =?base64? prefix as literal (not Base64).' - check: server-literal-missing-base64-prefix - - text: 'Server treats value without ?= suffix as literal (not Base64).' - check: server-literal-missing-base64-suffix - - text: 'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header annotation.' - check: client-custom-header-no-mirror-unannotated - - text: 'Intermediaries MUST return an appropriate HTTP error status for validation failures.' - excluded: 'Applies to network intermediaries rather than the MCP client or server implementation under test.' - - text: 'Servers MUST reject requests with Mcp-Param headers that contain invalid characters per RFC 9110.' - excluded: 'HTTP itself prevents invalid characters (CR, LF, null, non-ASCII) in header values; standard HTTP libraries reject them before the server can process them, making this untestable through conformance tests.' diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 7629c3ef..af3866e3 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -15,7 +15,12 @@ */ import http from 'http'; -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { + ClientScenario, + ConformanceCheck, + SpecVersion, + DRAFT_PROTOCOL_VERSION +} from '../../types'; import { connectToServer } from './client-helper'; const SPEC_REFERENCE = { @@ -28,6 +33,15 @@ const SPEC_REFERENCE_CASE = { url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#case-sensitivity' }; +// OWS handling is an RFC 9110 §5.5 MUST ("a field parsing implementation MUST +// exclude such whitespace prior to evaluating the field value"), not a +// SEP-2243 requirement. Kept as a check because a server stack that fails it +// has a real HTTP-layer bug that will manifest as header-mismatch rejections. +const SPEC_REFERENCE_RFC9110_OWS = { + id: 'RFC-9110-5.5-Field-Values', + url: 'https://www.rfc-editor.org/rfc/rfc9110#section-5.5' +}; + const SPEC_REFERENCE_BASE64 = { id: 'SEP-2243-Value-Encoding', url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding' @@ -68,9 +82,10 @@ async function sendRawRequest( } }, (res) => { + res.setEncoding('utf8'); let data = ''; res.on('data', (chunk) => { - data += chunk.toString(); + data += chunk; }); res.on('end', () => { let responseBody: any; @@ -99,39 +114,58 @@ async function sendRawRequest( }); } -function createRejectionCheck( +/** + * Builds two checks for a rejection case: one for the HTTP 400 status, one for + * the -32001 JSON-RPC error code. Per SEP-2243 §Server Validation, 400 is MUST + * but -32001 is SHOULD for *standard* headers (and MUST for *custom* headers, + * §Server Behavior for Custom Headers) — so a server returning 400 with a + * different error code is compliant for standard headers and must not FAIL. + */ +function createRejectionChecks( id: string, name: string, description: string, response: { status: number; body: any }, specRef: { id: string; url: string }, - details: Record -): ConformanceCheck { - const errors: string[] = []; - if (response.status !== 400) { - errors.push( - `Expected HTTP 400, got ${response.status}. Server MUST reject with 400 Bad Request.` - ); - } - if (response.body?.error?.code !== HEADER_MISMATCH_ERROR_CODE) { - errors.push( - `Expected JSON-RPC error code ${HEADER_MISMATCH_ERROR_CODE} (HeaderMismatch), got ${response.body?.error?.code ?? '(missing)'}. Server MUST use code -32001.` - ); - } - return { - id, - name, - description, - status: errors.length > 0 ? 'FAILURE' : 'SUCCESS', - timestamp: new Date().toISOString(), - errorMessage: errors.length > 0 ? errors.join('; ') : undefined, - specReferences: [specRef], - details: { - ...details, - responseStatus: response.status, - responseBody: response.body - } + details: Record, + opts: { errorCodeSeverity: 'FAILURE' | 'WARNING' } +): ConformanceCheck[] { + const fullDetails = { + ...details, + responseStatus: response.status, + responseBody: response.body }; + const ts = new Date().toISOString(); + + const statusOk = response.status === 400; + const codeOk = response.body?.error?.code === HEADER_MISMATCH_ERROR_CODE; + + return [ + { + id, + name, + description, + status: statusOk ? 'SUCCESS' : 'FAILURE', + timestamp: ts, + errorMessage: statusOk + ? undefined + : `Expected HTTP 400, got ${response.status}. Server MUST reject with 400 Bad Request.`, + specReferences: [specRef], + details: fullDetails + }, + { + id: `${id}-error-code`, + name: `${name}ErrorCode`, + description: `${description} — uses JSON-RPC error code -32001 (HeaderMismatch)`, + status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity, + timestamp: ts, + errorMessage: codeOk + ? undefined + : `Expected JSON-RPC error code ${HEADER_MISMATCH_ERROR_CODE} (HeaderMismatch), got ${response.body?.error?.code ?? '(missing)'}.`, + specReferences: [specRef], + details: fullDetails + } + ]; } function createAcceptanceCheck( @@ -148,6 +182,17 @@ function createAcceptanceCheck( `Expected successful response, got HTTP ${response.status}. Server MUST accept this request.` ); } + // A server can return HTTP 200 with a JSON-RPC error in the body. Without + // this assertion that case would pass as "accepted". + if ( + response.body && + typeof response.body === 'object' && + 'error' in response.body + ) { + errors.push( + `Expected successful response, but body contains JSON-RPC error ${JSON.stringify(response.body.error)}.` + ); + } return { id, name, @@ -166,7 +211,7 @@ function createAcceptanceCheck( export class HttpHeaderValidationScenario implements ClientScenario { name = 'http-header-validation'; - specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test server validation of standard MCP request headers (SEP-2243). **Server Implementation Requirements:** @@ -200,7 +245,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { id: 1, method: 'initialize', params: { - protocolVersion: 'DRAFT-2026-v1', + protocolVersion: DRAFT_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'conformance-test-raw-client', @@ -226,7 +271,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { } const baseHeaders: Record = { - 'MCP-Protocol-Version': 'DRAFT-2026-v1' + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; if (sessionId) baseHeaders['mcp-session-id'] = sessionId; @@ -241,7 +286,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'server-rejects-mismatched-method-header', + 'sep-2243-server-rejects-mismatched-method-header', 'ServerRejectsMismatchedMethodHeader', 'Server rejects requests where Mcp-Method header does not match body method', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -256,7 +301,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'server-rejects-missing-method-header', + 'sep-2243-server-rejects-missing-method-header', 'ServerRejectsMissingMethodHeader', 'Server rejects requests with missing Mcp-Method header', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -274,7 +319,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'server-rejects-mismatched-name-header', + 'sep-2243-server-rejects-mismatched-name-header', 'ServerRejectsMismatchedNameHeader', 'Server rejects tools/call where Mcp-Name does not match body params.name', { @@ -296,9 +341,9 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'accept', - 'server-accepts-whitespace-header-value', + 'sep-2243-server-accepts-whitespace-header-value', 'ServerAcceptsWhitespaceHeaderValue', - 'Server MUST accept extra whitespace in Mcp-Name value (trimmed per HTTP spec)', + 'Server MUST accept leading/trailing whitespace in Mcp-Name value (RFC 9110 §5.5: field parsing MUST exclude OWS before evaluating)', { jsonrpc: '2.0', id: 0, @@ -309,7 +354,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { 'Mcp-Method': 'tools/call', 'Mcp-Name': ` ${toolName} ` }, - SPEC_REFERENCE, + SPEC_REFERENCE_RFC9110_OWS, { headerValue: ` ${toolName} `, bodyValue: toolName, @@ -325,7 +370,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'server-rejects-missing-name-header', + 'sep-2243-server-rejects-missing-name-header', 'ServerRejectsMissingNameHeader', 'Server MUST reject tools/call with missing Mcp-Name header when body has params.name', { @@ -353,7 +398,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'accept', - 'server-accepts-lowercase-header-name', + 'sep-2243-server-accepts-lowercase-header-name', 'ServerAcceptsLowercaseHeaderName', 'Server MUST accept lowercase header name (mcp-method)', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -368,7 +413,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'accept', - 'server-accepts-uppercase-header-name', + 'sep-2243-server-accepts-uppercase-header-name', 'ServerAcceptsUppercaseHeaderName', 'Server MUST accept uppercase header name (MCP-METHOD)', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -383,7 +428,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'server-rejects-case-mismatch-value', + 'sep-2243-server-rejects-case-mismatch-value', 'ServerRejectsCaseMismatchValue', 'Server MUST reject uppercase method value (TOOLS/LIST) since values are case-sensitive', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -393,7 +438,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { ); } catch (error) { checks.push({ - id: 'http-header-validation-setup', + id: 'sep-2243-server-standard-setup', name: 'HttpHeaderValidationSetup', description: 'Setup for header validation tests', status: 'FAILURE', @@ -426,25 +471,31 @@ export class HttpHeaderValidationScenario implements ClientScenario { ...baseHeaders, ...extraHeaders }); - checks.push( - expectation === 'reject' - ? createRejectionCheck( - checkId, - checkName, - description, - response, - specRef, - details - ) - : createAcceptanceCheck( - checkId, - checkName, - description, - response, - specRef, - details - ) - ); + if (expectation === 'reject') { + // Standard-header rejection: 400 is MUST, -32001 is SHOULD. + checks.push( + ...createRejectionChecks( + checkId, + checkName, + description, + response, + specRef, + details, + { errorCodeSeverity: 'WARNING' } + ) + ); + } else { + checks.push( + createAcceptanceCheck( + checkId, + checkName, + description, + response, + specRef, + details + ) + ); + } } catch (error) { checks.push({ id: checkId, @@ -461,7 +512,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { export class HttpCustomHeaderServerValidationScenario implements ClientScenario { name = 'http-custom-header-server-validation'; - specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test server validation of custom Mcp-Param headers and Base64 encoding (SEP-2243). **Server Implementation Requirements:** @@ -494,7 +545,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario if (!xMcpTool) { checks.push({ - id: 'http-custom-header-server-no-tool', + id: 'sep-2243-server-no-xmcp-tool', name: 'HttpCustomHeaderServerNoTool', description: 'Server has no tools with x-mcp-header annotations to test', @@ -517,7 +568,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario id: 1, method: 'initialize', params: { - protocolVersion: 'DRAFT-2026-v1', + protocolVersion: DRAFT_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'conformance-test-base64-client', @@ -543,7 +594,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } const baseHeaders: Record = { - 'MCP-Protocol-Version': 'DRAFT-2026-v1' + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; if (sessionId) baseHeaders['mcp-session-id'] = sessionId; @@ -556,7 +607,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); if (!annotatedEntry) { checks.push({ - id: 'http-custom-header-server-no-string-param', + id: 'sep-2243-server-no-string-param', name: 'HttpCustomHeaderServerNoStringParam', description: 'Server has no string-typed x-mcp-header parameter to test', @@ -569,18 +620,21 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario const [paramName, paramDef] = annotatedEntry as [string, any]; const headerSuffix = paramDef['x-mcp-header']; - // Build default arguments for all required params to avoid schema validation errors + // Build default arguments for all required params to avoid schema validation errors. + // These go in the JSON body, so number/boolean must be the real types — + // sending '0' or 'false' as strings makes the server reject on JSON-schema + // grounds and the header-validation checks below would false-pass on that 400. const requiredParams: string[] = schema.required || []; - const defaultArgs: Record = {}; + const defaultArgs: Record = {}; const defaultHeaders: Record = {}; for (const rp of requiredParams) { if (rp !== paramName) { const rpDef = schema.properties[rp]; const rpType = rpDef?.type || 'string'; - if (rpType === 'number') { - defaultArgs[rp] = '0' as any; + if (rpType === 'number' || rpType === 'integer') { + defaultArgs[rp] = 0; } else if (rpType === 'boolean') { - defaultArgs[rp] = 'false' as any; + defaultArgs[rp] = false; } else { defaultArgs[rp] = 'test-default'; } @@ -607,7 +661,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'accept', - 'server-accepts-valid-base64', + 'sep-2243-server-accepts-valid-base64', 'ServerAcceptsValidBase64', 'Server decodes valid Base64 header value and validates against body', xMcpTool.name, @@ -619,16 +673,21 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario defaultHeaders ); - // Invalid Base64 padding - server MUST reject + // Invalid Base64 padding — FAILURE per the SEP-2243 conformance-test-case + // table, which is the approved source of truth for these cases. The spec + // body says only "MUST decode them accordingly", but the table specifies + // strict rejection. SDKs whose stdlib decoders are lenient (Node + // Buffer.from, browser atob) will need to validate before decoding; if + // that proves burdensome we'll revisit. await this.testBase64Case( checks, serverUrl, baseHeaders, nextId, 'reject', - 'server-rejects-invalid-base64-padding', + 'sep-2243-server-rejects-invalid-base64-padding', 'ServerRejectsInvalidBase64Padding', - 'Server MUST reject header with invalid Base64 padding', + 'Server MUST reject Mcp-Param header with invalid Base64 padding (per SEP-2243 test-case table)', xMcpTool.name, paramName, 'Hello', @@ -638,16 +697,16 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario defaultHeaders ); - // Invalid Base64 characters - server MUST reject + // Invalid Base64 characters — FAILURE for the same reason as padding. await this.testBase64Case( checks, serverUrl, baseHeaders, nextId, 'reject', - 'server-rejects-invalid-base64-chars', + 'sep-2243-server-rejects-invalid-base64-chars', 'ServerRejectsInvalidBase64Chars', - 'Server MUST reject header with invalid Base64 characters', + 'Server MUST reject Mcp-Param header with non-alphabet Base64 characters (per SEP-2243 test-case table)', xMcpTool.name, paramName, 'Hello', @@ -664,7 +723,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'accept', - 'server-literal-missing-base64-prefix', + 'sep-2243-server-literal-missing-base64-prefix', 'ServerLiteralMissingBase64Prefix', 'Server treats value without =?base64? prefix as literal (not Base64)', xMcpTool.name, @@ -683,7 +742,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'accept', - 'server-literal-missing-base64-suffix', + 'sep-2243-server-literal-missing-base64-suffix', 'ServerLiteralMissingBase64Suffix', 'Server treats value without ?= suffix as literal (not Base64)', xMcpTool.name, @@ -710,7 +769,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); } catch (error) { checks.push({ - id: 'http-custom-header-server-validation-setup', + id: 'sep-2243-server-custom-setup', name: 'HttpCustomHeaderServerValidationSetup', description: 'Setup for custom header server validation tests', status: 'FAILURE', @@ -769,25 +828,32 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario headerValue }; - checks.push( - expectation === 'reject' - ? createRejectionCheck( - checkId, - checkName, - description, - response, - SPEC_REFERENCE_BASE64, - details - ) - : createAcceptanceCheck( - checkId, - checkName, - description, - response, - SPEC_REFERENCE_BASE64, - details - ) - ); + if (expectation === 'accept') { + checks.push( + createAcceptanceCheck( + checkId, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details + ) + ); + } else { + // Custom-header rejection: both 400 and -32001 are MUST per + // §Server Behavior for Custom Headers. + checks.push( + ...createRejectionChecks( + checkId, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details, + { errorCodeSeverity: 'FAILURE' } + ) + ); + } } catch (error) { checks.push({ id: checkId, @@ -834,9 +900,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } ); + // Custom-header rejection: both 400 and -32001 are MUST. checks.push( - createRejectionCheck( - 'server-rejects-missing-custom-header', + ...createRejectionChecks( + 'sep-2243-server-rejects-missing-custom-header', 'ServerRejectsMissingCustomHeader', 'Server MUST reject request where custom header is omitted but value is present in body', response, @@ -847,12 +914,13 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario bodyValue: 'test-value', expectedHeader: `Mcp-Param-${headerSuffix}`, mcpParamHeader: '(missing)' - } + }, + { errorCodeSeverity: 'FAILURE' } ) ); } catch (error) { checks.push({ - id: 'server-rejects-missing-custom-header', + id: 'sep-2243-server-rejects-missing-custom-header', name: 'ServerRejectsMissingCustomHeader', description: 'Server MUST reject request where custom header is omitted but value is present in body', diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml new file mode 100644 index 00000000..e981da06 --- /dev/null +++ b/src/seps/sep-2243.yaml @@ -0,0 +1,59 @@ +sep: 2243 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers +requirements: + - check: sep-2243-client-includes-standard-headers + text: 'The client MUST include the standard MCP request headers on each POST request. These headers are REQUIRED for compliance.' + - check: sep-2243-header-name-case-insensitive + text: 'Clients and servers MUST use case-insensitive comparisons for header names.' + - check: sep-2243-server-reject-mismatch + text: 'Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body.' + - check: sep-2243-server-reject-status + text: 'When rejecting a request due to header validation failure, servers MUST return HTTP status 400 Bad Request.' + - check: sep-2243-server-reject-error-code + text: 'When rejecting a request due to header validation failure, servers SHOULD include a JSON-RPC error response using error code -32001.' + - check: sep-2243-client-supports-custom-headers + text: 'MCP clients MUST support this feature [custom headers via x-mcp-header].' + - check: sep-2243-client-mirrors-designated-params + text: 'When a client invokes a tool whose definition includes such designations, conforming clients MUST mirror the designated parameter values into HTTP headers as described below.' + - check: sep-2243-x-mcp-header-not-empty + text: 'The x-mcp-header value MUST NOT be empty.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-charset + text: 'The x-mcp-header value MUST contain only ASCII characters (excluding space and `:`).' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-unique + text: 'The x-mcp-header value MUST be case-insensitively unique within a single tool definition.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-primitive-only + text: 'x-mcp-header MUST only be applied to parameters with primitive types (number, string, or boolean).' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-client-reject-invalid-tool + text: 'Clients MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the set of tools returned by tools/list.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-client-encode-values + text: 'Clients MUST encode parameter values before including them in HTTP headers: number values MUST be converted to their decimal string representation; boolean values MUST be converted to the lowercase strings "true" or "false".' + - check: sep-2243-client-base64-unsafe + text: 'When a value cannot be safely represented as plain ASCII (e.g., contains non-ASCII characters, control characters, or leading/trailing whitespace), clients MUST use Base64 encoding of the UTF-8 representation, wrapped as =?base64?{encoded}?=.' + - check: sep-2243-server-decode-base64 + text: 'Servers and intermediaries that need to inspect these values MUST decode them accordingly.' + - check: sep-2243-client-omit-null + text: 'Parameter value is null or omitted: Client MUST omit the header.' + - check: sep-2243-server-not-expect-null + text: 'Parameter value is null or omitted: Server MUST NOT expect the header.' + - check: sep-2243-server-reject-missing-required + text: 'Required parameter is omitted: Server MUST reject with JSON-RPC error.' + - check: sep-2243-server-reject-invalid-param-chars + text: 'Servers MUST reject requests with a recognized Mcp-Param-{Name} header that contain invalid characters.' + - check: sep-2243-server-validate-param-match + text: 'Any server that processes the message body MUST validate that encoded header values, after decoding if Base64-encoded, match the corresponding parameter values in the body.' + - check: sep-2243-server-reject-param-mismatch + text: 'Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32001 if any validation fails.' + + - text: 'Clients SHOULD log a warning when rejecting a tool definition due to invalid x-mcp-header, including the tool name and the reason.' + excluded: 'Log output is not wire-observable.' + - text: 'Server developers SHOULD NOT mark sensitive parameters (such as passwords, API keys, tokens, or PII) with x-mcp-header.' + excluded: 'Design guidance to humans; not protocol-observable.' + - text: 'Intermediaries MUST return an appropriate HTTP error status for validation failures.' + excluded: 'Intermediary requirement; conformance harness tests clients and servers, not intermediaries.' + - text: 'Intermediate servers that do not recognize an Mcp-Param-{Name} header MUST forward it and otherwise ignore it.' + excluded: 'Intermediary requirement; conformance harness tests clients and servers, not intermediaries.'