diff --git a/package.json b/package.json index b28437f..79535b0 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "tier-check": "node dist/index.js tier-check", "check": "npm run typecheck && npm run lint", "typecheck": "tsgo --noEmit", - "prepack": "npm run build" + "prepack": "npm run build", + "prepare": "npm run build" }, "files": [ "dist" diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts new file mode 100644 index 0000000..def4c03 --- /dev/null +++ b/src/scenarios/client/http-custom-headers.ts @@ -0,0 +1,924 @@ +/** + * HTTP Custom Headers conformance test scenario for MCP clients (SEP-2243) + * + * Tests that clients correctly handle the `x-mcp-header` extension property: + * 1. Mirror annotated tool parameter values into `Mcp-Param-{Name}` headers + * 2. Apply correct value encoding (plain ASCII, Base64 for non-ASCII) + * 3. Reject tool definitions with invalid `x-mcp-header` annotations + * + * This is a Scenario (acts as a test server that inspects incoming requests + * from the client under test). + */ + +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + SpecVersion +} from '../../types.js'; + +const SPEC_REFERENCE_CUSTOM = { + id: 'SEP-2243-Custom-Headers', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#custom-headers-from-tool-parameters' +}; + +const SPEC_REFERENCE_ENCODING = { + id: 'SEP-2243-Value-Encoding', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding' +}; + +const SPEC_REFERENCE_TOOL_DEF = { + id: 'SEP-2243-x-mcp-header', + url: 'https://modelcontextprotocol.io/specification/draft/server/tools#x-mcp-header' +}; + +/** + * Decodes a header value that may be Base64-encoded. + * Base64-encoded values use the format: =?base64?{Base64EncodedValue}?= + */ +function decodeHeaderValue(value: string): string { + const base64Match = value.match(/^=\?base64\?(.+)\?=$/i); + if (base64Match) { + return Buffer.from(base64Match[1], 'base64').toString('utf-8'); + } + return value; +} + +/** + * Check if a value needs Base64 encoding per the spec: + * - Non-ASCII characters + * - Control characters + * - Leading/trailing whitespace + */ +function needsBase64Encoding(value: string): boolean { + // Check for non-ASCII or control characters + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + // Allow space (0x20) and tab (0x09) only inside values, not at edges + if (code === 0x09) return true; // tab always needs encoding + if (code < 0x20) return true; // other control chars + if (code > 0x7e) return true; // non-ASCII + } + } + // Check for leading/trailing whitespace + if (value !== value.trim()) return true; + return false; +} + +/** + * Checks if a raw header value is properly encoded for a body value that + * needs Base64 encoding. Returns null if valid, error string if invalid. + */ +function validateEncodedHeader( + rawHeader: string, + bodyValue: string +): string | null { + if (needsBase64Encoding(bodyValue)) { + // Value requires Base64 encoding + const base64Match = rawHeader.match(/^=\?base64\?(.+)\?=$/i); + if (!base64Match) { + return `Value '${bodyValue}' requires Base64 encoding but header was sent as plain: '${rawHeader}'`; + } + const decoded = Buffer.from(base64Match[1], 'base64').toString('utf-8'); + if (decoded !== bodyValue) { + return `Base64-decoded header value '${decoded}' does not match body value '${bodyValue}'`; + } + return null; + } + // Plain ASCII - compare directly (after decoding if Base64 was used) + const decoded = decodeHeaderValue(rawHeader); + if (decoded !== bodyValue) { + return `Header value '${decoded}' (raw: '${rawHeader}') does not match body value '${bodyValue}'`; + } + 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']; + description = + 'Tests that client mirrors x-mcp-header tool parameters into Mcp-Param headers with correct encoding (SEP-2243)'; + + private toolCallReceived: boolean = false; + private nullToolCallReceived: boolean = false; + + async start(): Promise { + const urls = await super.start(); + // Pass test values via context for encoding edge cases. + // The conformance client should use these values when calling test_custom_headers. + urls.context = { + toolCalls: [ + { + name: 'test_custom_headers', + arguments: { + region: 'us-west1', + priority: 42, + verbose: false, + empty_val: '', + method_val: 'test-method', + float_val: 3.14159, + non_ascii_val: 'Hello, 世界', + whitespace_val: ' padded ', + control_char_val: 'line1\nline2', + query: 'SELECT * FROM users' + } + }, + { + name: 'test_custom_headers_null', + arguments: { + region: 'us-east1', + priority: 1, + verbose: null, + query: 'SELECT 1' + } + } + ] + }; + return urls; + } + + getChecks(): ConformanceCheck[] { + if (!this.toolCallReceived) { + this.checks.push({ + id: 'client-custom-header-tool-call', + name: 'ClientCustomHeaderToolCall', + description: 'Client calls the tool with x-mcp-header annotations', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Client did not send a tools/call request for test_custom_headers.', + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + if (!this.nullToolCallReceived) { + this.checks.push({ + id: 'client-custom-header-omit-null', + name: 'ClientCustomHeaderOmitNull', + description: + 'Client MUST omit Mcp-Param header when parameter value is null or not provided', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.', + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + return this.checks; + } + + protected handlePost( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + if (request.method === 'initialize') { + this.sendInitialize(res, request); + } else if (request.method === 'tools/list') { + this.handleToolsList(res, request); + } else if (request.method === 'tools/call') { + this.handleToolsCall(req, res, request); + } else if (request.id === undefined) { + this.sendNotificationAck(res); + } else { + this.sendGenericResult(res, request); + } + } + + private handleToolsList(res: http.ServerResponse, request: any): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + tools: [ + { + name: 'test_custom_headers', + description: + 'A tool with x-mcp-header annotations to test custom header mirroring and encoding', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + description: 'Plain ASCII string value', + 'x-mcp-header': 'Region' + }, + priority: { + type: 'number', + description: 'Integer numeric value', + 'x-mcp-header': 'Priority' + }, + verbose: { + type: 'boolean', + description: 'Boolean value', + 'x-mcp-header': 'Verbose' + }, + empty_val: { + type: 'string', + description: 'Empty string value', + 'x-mcp-header': 'EmptyVal' + }, + method_val: { + type: 'string', + description: + 'Value for header named "Method" — tests that x-mcp-header "Method" produces Mcp-Param-Method (not Mcp-Method)', + 'x-mcp-header': 'Method' + }, + float_val: { + type: 'number', + description: 'Floating point numeric value', + 'x-mcp-header': 'FloatVal' + }, + non_ascii_val: { + type: 'string', + description: + 'Non-ASCII string value — requires Base64 encoding', + 'x-mcp-header': 'NonAscii' + }, + whitespace_val: { + type: 'string', + description: + 'String with leading/trailing whitespace — requires Base64 encoding', + 'x-mcp-header': 'Whitespace' + }, + control_char_val: { + type: 'string', + description: + 'String with control characters — requires Base64 encoding', + 'x-mcp-header': 'ControlChar' + }, + query: { + type: 'string', + description: + 'No x-mcp-header annotation - should not be mirrored' + } + }, + required: ['region', 'priority', 'query'] + } + }, + { + name: 'test_custom_headers_null', + description: + 'A tool for testing null/omitted x-mcp-header parameter handling', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + description: 'Plain ASCII string value', + 'x-mcp-header': 'Region' + }, + priority: { + type: 'number', + description: 'Integer numeric value', + 'x-mcp-header': 'Priority' + }, + verbose: { + type: 'boolean', + description: 'Boolean value — will be null to test omission', + 'x-mcp-header': 'Verbose' + }, + query: { + type: 'string', + description: 'No x-mcp-header annotation' + } + }, + required: ['region', 'priority', 'query'] + } + } + ] + } + }); + } + + private handleToolsCall( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + const toolName = request.params?.name; + const args = request.params?.arguments || {}; + + if (toolName === 'test_custom_headers') { + this.toolCallReceived = true; + + // Check Mcp-Param-Region header (plain ASCII string) + this.checkParamHeader(req, 'Region', args.region, 'string'); + + // Check Mcp-Param-Priority header (integer number) + this.checkParamHeader(req, 'Priority', args.priority, 'number'); + + // Check Mcp-Param-Verbose header (boolean value) + 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-EmptyVal header (empty string → empty header value) + if (args.empty_val !== undefined && args.empty_val !== null) { + this.checkParamHeader(req, 'EmptyVal', args.empty_val, 'string'); + } + + // Check Mcp-Param-Method header (x-mcp-header "Method" → Mcp-Param-Method, NOT Mcp-Method) + if (args.method_val !== undefined && args.method_val !== null) { + this.checkParamHeader(req, 'Method', args.method_val, 'string'); + } + + // Check Mcp-Param-FloatVal header (floating point number) + if (args.float_val !== undefined && args.float_val !== null) { + this.checkParamHeader(req, 'FloatVal', args.float_val, 'number'); + } + + // Check Mcp-Param-NonAscii header (requires Base64 encoding) + if (args.non_ascii_val !== undefined && args.non_ascii_val !== null) { + this.checkParamHeader(req, 'NonAscii', args.non_ascii_val, 'string'); + } + + // Check Mcp-Param-Whitespace header (leading/trailing whitespace → Base64) + if (args.whitespace_val !== undefined && args.whitespace_val !== null) { + this.checkParamHeader(req, 'Whitespace', args.whitespace_val, 'string'); + } + + // Check Mcp-Param-ControlChar header (control characters → Base64) + if ( + args.control_char_val !== undefined && + args.control_char_val !== null + ) { + this.checkParamHeader( + req, + 'ControlChar', + args.control_char_val, + 'string' + ); + } + + // 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] + }); + } + } else if (toolName === 'test_custom_headers_null') { + this.nullToolCallReceived = true; + + // When value is null or not provided, client MUST omit the header + const verboseHeader = req.headers['mcp-param-verbose'] as + | string + | undefined; + this.checks.push({ + id: 'client-custom-header-omit-null', + name: 'ClientCustomHeaderOmitNull', + description: + 'Client MUST omit Mcp-Param header when parameter value is null or not provided', + status: verboseHeader === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + verboseHeader !== undefined + ? `Mcp-Param-Verbose should be omitted when null/undefined, but got '${verboseHeader}'` + : undefined, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ type: 'text', text: 'Custom headers test completed' }] + } + }); + } + + private checkParamHeader( + req: http.IncomingMessage, + headerName: string, + bodyValue: any, + valueType: string + ): void { + const headerKey = `mcp-param-${headerName.toLowerCase()}`; + const rawHeaderValue = req.headers[headerKey] as string | undefined; + + if (bodyValue === undefined || bodyValue === null) return; + + const errors: string[] = []; + + if (rawHeaderValue === undefined) { + errors.push( + `Missing Mcp-Param-${headerName} header. Client MUST include headers for x-mcp-header parameters.` + ); + } else { + // Convert body value to expected string representation + let expectedString: string; + switch (valueType) { + case 'number': + expectedString = String(bodyValue); + break; + case 'boolean': + expectedString = bodyValue ? 'true' : 'false'; + break; + default: + expectedString = String(bodyValue); + } + + const validationError = validateEncodedHeader( + rawHeaderValue, + expectedString + ); + if (validationError) { + errors.push(validationError); + } + } + + this.checks.push({ + id: `client-custom-header-${headerName.toLowerCase()}`, + name: `ClientCustomHeader_${headerName}`, + description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE_ENCODING], + details: { + headerName: `Mcp-Param-${headerName}`, + rawHeaderValue, + bodyValue, + valueType, + needsBase64: + typeof bodyValue === 'string' && + needsBase64Encoding(String(bodyValue)) + } + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HttpInvalidToolHeadersScenario - tests that clients reject invalid tools +// ───────────────────────────────────────────────────────────────────────────── + +export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { + name = 'http-invalid-tool-headers'; + specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + description = + 'Tests that client rejects tools with invalid x-mcp-header annotations (SEP-2243)'; + allowClientError = true; + + private calledTools: Set = new Set(); + private toolsListSent = false; + + getChecks(): ConformanceCheck[] { + if (!this.toolsListSent) { + this.checks.push({ + id: 'client-invalid-tool-headers-tools-list', + name: 'ClientInvalidToolHeadersToolsList', + description: 'Client requests tools/list', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'Client did not send a tools/list request.', + specReferences: [SPEC_REFERENCE_TOOL_DEF] + }); + } + + // 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', + name: 'ClientKeepsValidTool', + description: 'Client MUST keep valid tools while excluding invalid ones', + status: validToolCalled ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: validToolCalled + ? undefined + : "Client did not call 'valid_tool'. A single malformed tool definition must not prevent other valid tools from being used.", + specReferences: [SPEC_REFERENCE_TOOL_DEF] + }); + + // Check that the client did NOT call any of the invalid tools + const invalidTools = [ + 'invalid_empty_header', + 'invalid_object_header', + 'invalid_array_header', + 'invalid_null_header', + 'invalid_nested_header', + 'invalid_duplicate_same_case', + 'invalid_duplicate_diff_case', + 'invalid_space_in_name', + 'invalid_colon_in_name', + 'invalid_non_ascii_name', + 'invalid_control_char_name' + ]; + + for (const toolName of invalidTools) { + const called = this.calledTools.has(toolName); + this.checks.push({ + id: `client-rejects-invalid-tool-${toolName}`, + name: `ClientRejectsInvalidTool_${toolName}`, + description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`, + status: called ? 'FAILURE' : 'SUCCESS', + timestamp: new Date().toISOString(), + errorMessage: called + ? `Client called '${toolName}' which has an invalid x-mcp-header. Clients MUST reject (exclude) such tools.` + : undefined, + specReferences: [SPEC_REFERENCE_TOOL_DEF] + }); + } + + return this.checks; + } + + protected handlePost( + _req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + if (request.method === 'initialize') { + this.sendInitialize(res, request); + } else if (request.method === 'tools/list') { + this.handleToolsList(res, request); + } else if (request.method === 'tools/call') { + this.handleToolsCall(res, request); + } else if (request.id === undefined) { + this.sendNotificationAck(res); + } else { + this.sendGenericResult(res, request); + } + } + + private handleToolsList(res: http.ServerResponse, request: any): void { + this.toolsListSent = true; + + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + tools: [ + // ── Valid tool (should be kept by client) ── + { + name: 'valid_tool', + description: 'A valid tool with correct x-mcp-header', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + 'x-mcp-header': 'Region' + } + }, + required: ['region'] + } + }, + + // ── Invalid: empty x-mcp-header value ── + { + name: 'invalid_empty_header', + description: + 'x-mcp-header MUST NOT be empty (MUST be rejected by client)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': '' } + }, + required: ['value'] + } + }, + + // ── Invalid: x-mcp-header on object type ── + { + name: 'invalid_object_header', + description: + 'x-mcp-header MUST only be on primitive types (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + data: { type: 'object', 'x-mcp-header': 'Data' } + }, + required: ['data'] + } + }, + + // ── Invalid: x-mcp-header on array type ── + { + name: 'invalid_array_header', + description: + 'x-mcp-header MUST only be on primitive types (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'string' }, + 'x-mcp-header': 'Items' + } + }, + required: ['items'] + } + }, + + // ── Invalid: x-mcp-header on null type ── + { + name: 'invalid_null_header', + description: + 'x-mcp-header MUST only be on primitive types (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + nil: { type: 'null', 'x-mcp-header': 'Nil' } + }, + required: ['nil'] + } + }, + + // ── Invalid: x-mcp-header on nested property inside object ── + { + name: 'invalid_nested_header', + description: + 'x-mcp-header on property inside nested object (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + outer: { + type: 'object', + properties: { + inner: { + type: 'string', + 'x-mcp-header': 'Inner' + } + } + } + }, + required: ['outer'] + } + }, + + // ── Invalid: duplicate same-case x-mcp-header values ── + { + name: 'invalid_duplicate_same_case', + description: + 'Duplicate x-mcp-header "Region" on two properties (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + field1: { type: 'string', 'x-mcp-header': 'Region' }, + field2: { type: 'string', 'x-mcp-header': 'Region' } + }, + required: ['field1', 'field2'] + } + }, + + // ── Invalid: duplicate case-insensitive x-mcp-header values ── + { + name: 'invalid_duplicate_diff_case', + description: + 'Duplicate case-insensitive x-mcp-header "MyField"/"myfield" (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + field1: { type: 'string', 'x-mcp-header': 'MyField' }, + field2: { type: 'string', 'x-mcp-header': 'myfield' } + }, + required: ['field1', 'field2'] + } + }, + + // ── Invalid: space in x-mcp-header name ── + { + name: 'invalid_space_in_name', + description: + 'x-mcp-header MUST NOT contain space (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': 'My Region' } + }, + required: ['value'] + } + }, + + // ── Invalid: colon in x-mcp-header name ── + { + name: 'invalid_colon_in_name', + description: + 'x-mcp-header MUST NOT contain colon (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { + type: 'string', + 'x-mcp-header': 'Region:Primary' + } + }, + required: ['value'] + } + }, + + // ── Invalid: non-ASCII in x-mcp-header name ── + { + name: 'invalid_non_ascii_name', + description: + 'x-mcp-header MUST contain only ASCII chars (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': 'Région' } + }, + required: ['value'] + } + }, + + // ── Invalid: control character in x-mcp-header name ── + { + name: 'invalid_control_char_name', + description: + 'x-mcp-header MUST NOT contain control chars (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': 'Region\t1' } + }, + required: ['value'] + } + } + ] + } + }); + } + + private handleToolsCall(res: http.ServerResponse, request: any): void { + const toolName = request.params?.name; + if (toolName) this.calledTools.add(toolName); + + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ type: 'text', text: 'Tool call received' }] + } + }); + } +} diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts new file mode 100644 index 0000000..846b6af --- /dev/null +++ b/src/scenarios/client/http-standard-headers.ts @@ -0,0 +1,463 @@ +/** + * HTTP Standard Headers conformance test scenario for MCP clients (SEP-2243) + * + * Tests that clients include the required standard MCP request headers on + * Streamable HTTP POST requests: + * - `Mcp-Method`: mirrors the `method` field from the JSON-RPC request body + * - `Mcp-Name`: mirrors `params.name` or `params.uri` for tools/call, + * resources/read, and prompts/get requests + * + * This is a Scenario (acts as a test server that inspects incoming requests + * from the client under test). + */ + +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + SpecVersion +} from '../../types.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 { + name = 'http-standard-headers'; + specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + 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 + const expectedMethods = [ + 'initialize', + 'tools/list', + 'tools/call', + 'resources/list', + 'resources/read', + 'prompts/list', + 'prompts/get' + ]; + + for (const method of expectedMethods) { + if (!this.methodHeaderChecks.has(method)) { + this.checks.push({ + id: `client-mcp-method-header-${method.replace('/', '-')}`, + name: `ClientMcpMethodHeader_${method.replace('/', '_')}`, + description: `Client sends correct Mcp-Method header on ${method} request`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Client did not send a ${method} request. Expected Mcp-Method header to be tested.`, + 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('/', '_')}`, + description: `Client sends correct Mcp-Name header on ${method} request`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Client did not send a ${method} request. Expected Mcp-Name header to be tested.`, + specReferences: [SPEC_REFERENCE] + }); + } + } + + return this.checks; + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): 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; + } + + 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 { + const method = request.method; + if (!method) return; + + // Already recorded a check for this method + if (this.methodHeaderChecks.has(method)) return; + + // Header names are lowercased by Node.js http parser + const mcpMethodHeader = req.headers['mcp-method'] as string | undefined; + + const errors: string[] = []; + if (!mcpMethodHeader) { + errors.push( + `Missing Mcp-Method header on ${method} request. Clients MUST include the Mcp-Method header on all POST requests.` + ); + } else if (mcpMethodHeader !== method) { + // Header values are case-sensitive + errors.push( + `Mcp-Method header value '${mcpMethodHeader}' does not match body method '${method}'. Header values are case-sensitive.` + ); + } + + this.methodHeaderChecks.set(method, errors.length === 0); + + this.checks.push({ + id: `client-mcp-method-header-${method.replace('/', '-')}`, + name: `ClientMcpMethodHeader_${method.replace('/', '_')}`, + description: `Client sends correct Mcp-Method header on ${method} request`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE], + details: { + expectedMethod: method, + actualHeader: mcpMethodHeader + } + }); + } + + private checkMcpNameHeader( + req: http.IncomingMessage, + request: any, + sourceField: string + ): void { + const method = request.method; + const expectedValue = + sourceField === 'params.uri' ? request.params?.uri : request.params?.name; + + const mcpNameHeader = req.headers['mcp-name'] as string | undefined; + + const errors: string[] = []; + if (!mcpNameHeader) { + errors.push( + `Missing Mcp-Name header on ${method} request. Clients MUST include the Mcp-Name header for ${method} requests.` + ); + } else if (mcpNameHeader !== expectedValue) { + errors.push( + `Mcp-Name header value '${mcpNameHeader}' does not match body ${sourceField} '${expectedValue}'.` + ); + } + + this.nameHeaderChecks.set(method, errors.length === 0); + + this.checks.push({ + id: `client-mcp-name-header-${method.replace('/', '-')}`, + name: `ClientMcpNameHeader_${method.replace('/', '_')}`, + description: `Client sends correct Mcp-Name header on ${method} request`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE], + details: { + method, + sourceField, + expectedValue, + actualHeader: mcpNameHeader + } + }); + } + + private handleInitialize(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + 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 { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + tools: [ + { + name: 'test_headers', + description: + 'A simple tool used to test that HTTP headers are sent correctly', + inputSchema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'my-hyphenated-tool', + description: + 'Tool with hyphen in name to test special chars in Mcp-Name header', + inputSchema: { + type: 'object', + properties: {}, + required: [] + } + } + ] + } + }) + ); + } + + private handleToolsCall(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + content: [ + { + type: 'text', + text: 'Headers test completed' + } + ] + } + }) + ); + } + + private handleResourcesList(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resources: [ + { + uri: 'file:///path/to/file%20name.txt', + name: 'File with spaces', + description: 'Resource URI with percent-encoded spaces' + }, + { + uri: 'https://example.com/resource?id=123', + name: 'Resource with query string', + description: 'Resource URI with query string' + } + ] + } + }) + ); + } + + private handleResourcesRead(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + contents: [] + } + }) + ); + } + + private handlePromptsList(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + prompts: [ + { + name: 'test_prompt', + description: 'A simple prompt for header testing' + } + ] + } + }) + ); + } + + private handlePromptsGet(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + messages: [] + } + }) + ); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 0e2191a..63b70d7 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -63,6 +63,11 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { + HttpHeaderValidationScenario, + HttpCustomHeaderServerValidationScenario +} from './server/http-standard-headers'; + import { authScenariosList, backcompatScenariosList, @@ -72,6 +77,12 @@ import { import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; +import { HttpStandardHeadersScenario } from './client/http-standard-headers'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario +} from './client/http-custom-headers'; + // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // JSON Schema 2020-12 (SEP-1613) @@ -81,7 +92,13 @@ const pendingClientScenariosList: ClientScenario[] = [ // On hold until server-side SSE improvements are made // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 - new ServerSSEPollingScenario() + new ServerSSEPollingScenario(), + + // HTTP Standardization (SEP-2243) + // Pending until the everything-server fully implements SEP-2243 + // header validation (case-insensitive names, whitespace trimming, -32001 error code) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario() ]; // All client scenarios @@ -139,7 +156,11 @@ const allClientScenariosList: ClientScenario[] = [ new PromptsGetWithImageScenario(), // Security scenarios - new DNSRebindingProtectionScenario() + new DNSRebindingProtectionScenario(), + + // HTTP Standardization scenarios (SEP-2243) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario() ]; // Active client scenarios (excludes pending) @@ -182,7 +203,12 @@ const scenariosList: Scenario[] = [ ...authScenariosList, ...backcompatScenariosList, ...draftScenariosList, - ...extensionScenariosList + ...extensionScenariosList, + + // HTTP Standardization scenarios (SEP-2243) + new HttpStandardHeadersScenario(), + new HttpCustomHeadersScenario(), + new HttpInvalidToolHeadersScenario() ]; // Core scenarios (tier 1 requirements) diff --git a/src/scenarios/sep-2243.yaml b/src/scenarios/sep-2243.yaml new file mode 100644 index 0000000..01a3139 --- /dev/null +++ b/src/scenarios/sep-2243.yaml @@ -0,0 +1,87 @@ +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: '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: 'x-mcp-header annotations nested inside object properties MUST be rejected.' + check: client-rejects-invalid-tool-invalid_nested_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: '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 contains control characters, clients MUST use the =?base64?...?= wrapper.' + check: client-custom-header-controlchar + - 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 accept case-insensitive =?base64? wrappers.' + check: server-accepts-case-insensitive-base64 + - 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.' diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts new file mode 100644 index 0000000..b7a7636 --- /dev/null +++ b/src/scenarios/server/http-standard-headers.ts @@ -0,0 +1,886 @@ +/** + * HTTP Standard Headers server validation test scenarios (SEP-2243) + * + * Tests that servers properly validate the standard MCP request headers: + * - Reject requests where Mcp-Method header doesn't match the body + * - Reject requests where Mcp-Name header doesn't match the body + * - Accept case variations of header names (case-insensitive) + * - Reject case variations of header values (case-sensitive) + * - Handle whitespace trimming per HTTP spec + * - Validate Base64-encoded custom header values + * - Return 400 Bad Request with error code -32001 (HeaderMismatch) + * + * This is a ClientScenario (connects to a server under test and validates + * its behavior). + */ + +import http from 'http'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { connectToServer } from './client-helper'; + +const SPEC_REFERENCE = { + id: 'SEP-2243-Server-Validation', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-validation' +}; + +const SPEC_REFERENCE_CASE = { + id: 'SEP-2243-Case-Sensitivity', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#case-sensitivity' +}; + +const SPEC_REFERENCE_BASE64 = { + id: 'SEP-2243-Value-Encoding', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding' +}; + +const SPEC_REFERENCE_CUSTOM = { + id: 'SEP-2243-Custom-Headers', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-behavior-for-custom-headers' +}; + +const HEADER_MISMATCH_ERROR_CODE = -32001; + +/** + * Helper to send a raw HTTP POST request with custom headers. + * Uses Node.js http.request to preserve exact header casing and values, + * avoiding normalization that fetch()/Headers may apply. + */ +async function sendRawRequest( + serverUrl: string, + body: object, + headers: Record = {} +): Promise<{ status: number; body: any; headers: http.IncomingHttpHeaders }> { + const url = new URL(serverUrl); + const bodyStr = JSON.stringify(body); + + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(bodyStr), + ...headers + } + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk.toString(); + }); + res.on('end', () => { + let responseBody: any; + const contentType = res.headers['content-type']; + if (contentType?.includes('application/json')) { + try { + responseBody = JSON.parse(data); + } catch { + responseBody = data; + } + } else { + responseBody = data; + } + resolve({ + status: res.statusCode || 0, + body: responseBody, + headers: res.headers + }); + }); + } + ); + + req.on('error', reject); + req.write(bodyStr); + req.end(); + }); +} + +function createRejectionCheck( + 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 + } + }; +} + +function createAcceptanceCheck( + 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 successful response, got HTTP ${response.status}. Server MUST accept this request.` + ); + } + return { + id, + name, + description, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [specRef], + details: { + ...details, + responseStatus: response.status, + responseBody: response.body + } + }; +} + +export class HttpHeaderValidationScenario implements ClientScenario { + name = 'http-header-validation'; + specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + description = `Test server validation of standard MCP request headers (SEP-2243). + +**Server Implementation Requirements:** + +**Endpoint**: Streamable HTTP + +**Requirements**: +- Server MUST reject requests where Mcp-Method header doesn't match the body method +- Server MUST reject requests where Mcp-Name header doesn't match the body params.name/uri +- Server MUST accept header names case-insensitively +- Server MUST reject case-mismatched header values (method values are case-sensitive) +- Server MUST accept extra whitespace around header values (per HTTP spec) +- Server MUST return HTTP 400 Bad Request for validation failures +- Server MUST return JSON-RPC error with code -32001 (HeaderMismatch)`; + + async run(serverUrl: string): Promise { + 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-2026-v1', + 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 = { + 'Mcp-Method': 'notifications/initialized' + }; + if (sessionId) notifHeaders['mcp-session-id'] = sessionId; + await sendRawRequest( + serverUrl, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + notifHeaders + ); + } + + const baseHeaders: Record = { + 'MCP-Protocol-Version': 'DRAFT-2026-v1' + }; + if (sessionId) baseHeaders['mcp-session-id'] = sessionId; + + let idCounter = 100; + const nextId = () => idCounter++; + + // --- Header/Body Mismatch Tests --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + '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' }, + { 'Mcp-Method': 'prompts/list' }, + SPEC_REFERENCE, + { requestBodyMethod: 'tools/list', mcpMethodHeader: 'prompts/list' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'server-rejects-missing-method-header', + 'ServerRejectsMissingMethodHeader', + 'Server rejects requests with missing Mcp-Method header', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + {}, + SPEC_REFERENCE, + { requestBodyMethod: 'tools/list', mcpMethodHeader: '(missing)' } + ); + + if (toolsResult.tools && toolsResult.tools.length > 0) { + const toolName = toolsResult.tools[0].name; + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'server-rejects-mismatched-name-header', + 'ServerRejectsMismatchedNameHeader', + 'Server rejects tools/call where Mcp-Name does not match body params.name', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { 'Mcp-Method': 'tools/call', 'Mcp-Name': 'wrong_tool_name' }, + SPEC_REFERENCE, + { requestBodyName: toolName, mcpNameHeader: 'wrong_tool_name' } + ); + + // --- Whitespace Test --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-accepts-whitespace-header-value', + 'ServerAcceptsWhitespaceHeaderValue', + 'Server MUST accept extra whitespace in Mcp-Name value (trimmed per HTTP spec)', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': ` ${toolName} ` + }, + SPEC_REFERENCE, + { + headerValue: ` ${toolName} `, + bodyValue: toolName, + reason: 'HTTP spec requires trimming OWS around field values' + } + ); + + // --- Missing Standard Header with Value in Body (Case 47) --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'server-rejects-missing-name-header', + 'ServerRejectsMissingNameHeader', + 'Server MUST reject tools/call with missing Mcp-Name header when body has params.name', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { 'Mcp-Method': 'tools/call' }, + SPEC_REFERENCE, + { + requestBodyName: toolName, + mcpNameHeader: '(missing)', + reason: + 'Standard header omitted but value present in body → MUST reject' + } + ); + } + + // --- Case Sensitivity Tests --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-accepts-lowercase-header-name', + 'ServerAcceptsLowercaseHeaderName', + 'Server MUST accept lowercase header name (mcp-method)', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + { 'mcp-method': 'tools/list' }, + SPEC_REFERENCE_CASE, + { headerNameUsed: 'mcp-method' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-accepts-uppercase-header-name', + 'ServerAcceptsUppercaseHeaderName', + 'Server MUST accept uppercase header name (MCP-METHOD)', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + { 'MCP-METHOD': 'tools/list' }, + SPEC_REFERENCE_CASE, + { headerNameUsed: 'MCP-METHOD' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + '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' }, + { 'Mcp-Method': 'TOOLS/LIST' }, + SPEC_REFERENCE_CASE, + { headerValue: 'TOOLS/LIST', bodyValue: 'tools/list' } + ); + } catch (error) { + checks.push({ + id: 'http-header-validation-setup', + name: 'HttpHeaderValidationSetup', + description: 'Setup for header validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to set up tests: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE] + }); + } + + return checks; + } + + private async testCase( + checks: ConformanceCheck[], + serverUrl: string, + baseHeaders: Record, + nextId: () => number, + expectation: 'accept' | 'reject', + checkId: string, + checkName: string, + description: string, + body: any, + extraHeaders: Record, + specRef: { id: string; url: string }, + details: Record + ): Promise { + try { + const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id }; + const response = await sendRawRequest(serverUrl, requestBody, { + ...baseHeaders, + ...extraHeaders + }); + checks.push( + expectation === 'reject' + ? createRejectionCheck( + checkId, + checkName, + description, + response, + specRef, + details + ) + : createAcceptanceCheck( + checkId, + checkName, + description, + response, + specRef, + details + ) + ); + } catch (error) { + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [specRef] + }); + } + } +} + +export class HttpCustomHeaderServerValidationScenario implements ClientScenario { + name = 'http-custom-header-server-validation'; + specVersions: SpecVersion[] = ['DRAFT-2026-v1']; + description = `Test server validation of custom Mcp-Param headers and Base64 encoding (SEP-2243). + +**Server Implementation Requirements:** + +**Endpoint**: Streamable HTTP with at least one tool using \`x-mcp-header\` + +**Requirements**: +- Server MUST validate Base64-encoded header values +- Server MUST reject requests with invalid Base64 padding or characters +- Server MUST treat values without =?base64?...?= wrapper as literal +- Server MUST accept case-insensitive =?base64? prefix +- Server MUST reject requests where custom header is omitted but value is in body`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + let sessionId: string | null = null; + + try { + const connection = await connectToServer(serverUrl); + const toolsResult = await connection.client.listTools(); + await connection.close(); + + // Find a tool with x-mcp-header annotations + const xMcpTool = toolsResult.tools?.find((tool) => { + const schema = tool.inputSchema as any; + if (!schema?.properties) return false; + return Object.values(schema.properties).some( + (prop: any) => prop['x-mcp-header'] !== undefined + ); + }); + + if (!xMcpTool) { + checks.push({ + id: 'http-custom-header-server-no-tool', + name: 'HttpCustomHeaderServerNoTool', + description: + 'Server has no tools with x-mcp-header annotations to test', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REFERENCE_CUSTOM], + details: { + reason: + 'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.' + } + }); + return checks; + } + + // Get a fresh session for raw requests + const initResponse = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 'DRAFT-2026-v1', + 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 = { + 'Mcp-Method': 'notifications/initialized' + }; + if (sessionId) notifHeaders['mcp-session-id'] = sessionId; + await sendRawRequest( + serverUrl, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + notifHeaders + ); + } + + const baseHeaders: Record = { + 'MCP-Protocol-Version': 'DRAFT-2026-v1' + }; + 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 + const schema = xMcpTool.inputSchema as any; + const annotatedEntry = Object.entries(schema.properties).find( + ([, def]: [string, any]) => + def['x-mcp-header'] !== undefined && (def as any).type === 'string' + ); + if (!annotatedEntry) { + checks.push({ + id: 'http-custom-header-server-no-string-param', + name: 'HttpCustomHeaderServerNoStringParam', + description: + 'Server has no string-typed x-mcp-header parameter to test', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + return checks; + } + 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 + const requiredParams: string[] = schema.required || []; + 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; + } else if (rpType === 'boolean') { + defaultArgs[rp] = 'false' as any; + } else { + defaultArgs[rp] = 'test-default'; + } + // If this required param also has x-mcp-header, include its header too + if (rpDef?.['x-mcp-header']) { + defaultHeaders[`Mcp-Param-${rpDef['x-mcp-header']}`] = String( + defaultArgs[rp] + ); + } + } + } + + let idCounter = 200; + const nextId = () => idCounter++; + + // --- Base64 Decoding Tests --- + + const validBase64Value = Buffer.from('Hello').toString('base64'); + + // Valid Base64 - server decodes and validates + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-accepts-valid-base64', + 'ServerAcceptsValidBase64', + 'Server decodes valid Base64 header value and validates against body', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + `=?base64?${validBase64Value}?=`, + defaultArgs, + defaultHeaders + ); + + // Invalid Base64 padding - server MUST reject + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'server-rejects-invalid-base64-padding', + 'ServerRejectsInvalidBase64Padding', + 'Server MUST reject header with invalid Base64 padding', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + '=?base64?SGVsbG8?=', + defaultArgs, + defaultHeaders + ); + + // Invalid Base64 characters - server MUST reject + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'server-rejects-invalid-base64-chars', + 'ServerRejectsInvalidBase64Chars', + 'Server MUST reject header with invalid Base64 characters', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + '=?base64?SGVs!!!bG8=?=', + defaultArgs, + defaultHeaders + ); + + // Missing prefix - server treats as literal value + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-literal-missing-base64-prefix', + 'ServerLiteralMissingBase64Prefix', + 'Server treats value without =?base64? prefix as literal (not Base64)', + xMcpTool.name, + paramName, + validBase64Value, + headerSuffix, + validBase64Value, + defaultArgs, + defaultHeaders + ); + + // Missing suffix - server treats as literal value + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-literal-missing-base64-suffix', + 'ServerLiteralMissingBase64Suffix', + 'Server treats value without ?= suffix as literal (not Base64)', + xMcpTool.name, + paramName, + `=?base64?${validBase64Value}`, + headerSuffix, + `=?base64?${validBase64Value}`, + defaultArgs, + defaultHeaders + ); + + // Case-insensitive Base64 prefix - server MUST accept + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'server-accepts-case-insensitive-base64', + 'ServerAcceptsCaseInsensitiveBase64', + 'Server MUST accept case-insensitive =?BASE64? prefix', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + `=?BASE64?${validBase64Value}?=`, + defaultArgs, + defaultHeaders + ); + + // --- Missing Custom Header with Value in Body --- + + await this.testMissingCustomHeader( + checks, + serverUrl, + baseHeaders, + nextId, + xMcpTool.name, + paramName, + headerSuffix, + defaultArgs, + defaultHeaders + ); + } catch (error) { + checks.push({ + id: 'http-custom-header-server-validation-setup', + name: 'HttpCustomHeaderServerValidationSetup', + description: 'Setup for custom header server validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + + return checks; + } + + private async testBase64Case( + checks: ConformanceCheck[], + serverUrl: string, + baseHeaders: Record, + nextId: () => number, + expectation: 'accept' | 'reject', + checkId: string, + checkName: string, + description: string, + toolName: string, + paramName: string, + bodyValue: string, + headerSuffix: string, + headerValue: string, + defaultArgs: Record, + defaultHeaders: Record + ): Promise { + try { + const response = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: nextId(), + method: 'tools/call', + params: { + name: toolName, + arguments: { ...defaultArgs, [paramName]: bodyValue } + } + }, + { + ...baseHeaders, + ...defaultHeaders, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': toolName, + [`Mcp-Param-${headerSuffix}`]: headerValue + } + ); + + const details = { + toolName, + paramName, + bodyValue, + headerSuffix, + headerValue + }; + + checks.push( + expectation === 'reject' + ? createRejectionCheck( + checkId, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details + ) + : createAcceptanceCheck( + checkId, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details + ) + ); + } catch (error) { + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_BASE64] + }); + } + } + + private async testMissingCustomHeader( + checks: ConformanceCheck[], + serverUrl: string, + baseHeaders: Record, + nextId: () => number, + toolName: string, + paramName: string, + headerSuffix: string, + defaultArgs: Record, + defaultHeaders: Record + ): Promise { + try { + // Send tools/call with value in body but NO Mcp-Param header + const response = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: nextId(), + method: 'tools/call', + params: { + name: toolName, + arguments: { ...defaultArgs, [paramName]: 'test-value' } + } + }, + { + ...baseHeaders, + ...defaultHeaders, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': toolName + // Deliberately omit Mcp-Param-{headerSuffix} header + } + ); + + checks.push( + createRejectionCheck( + 'server-rejects-missing-custom-header', + 'ServerRejectsMissingCustomHeader', + 'Server MUST reject request where custom header is omitted but value is present in body', + response, + SPEC_REFERENCE_CUSTOM, + { + toolName, + paramName, + bodyValue: 'test-value', + expectedHeader: `Mcp-Param-${headerSuffix}`, + mcpParamHeader: '(missing)' + } + ) + ); + } catch (error) { + checks.push({ + id: 'server-rejects-missing-custom-header', + name: 'ServerRejectsMissingCustomHeader', + description: + 'Server MUST reject request where custom header is omitted but value is present in body', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + } +}