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-base.ts b/src/scenarios/client/http-base.ts new file mode 100644 index 0000000..06c2afa --- /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, + ScenarioSource, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; + +export abstract class BaseHttpScenario implements Scenario { + abstract name: string; + abstract description: string; + readonly source: ScenarioSource = { introducedIn: DRAFT_PROTOCOL_VERSION }; + 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 new file mode 100644 index 0000000..1df5cbe --- /dev/null +++ b/src/scenarios/client/http-custom-headers.ts @@ -0,0 +1,879 @@ +/** + * 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 { ScenarioUrls, ConformanceCheck } from '../../types.js'; +import { BaseHttpScenario } from './http-base.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\?(.*)\?=$/); + 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, + valueType?: string +): string | null { + if (needsBase64Encoding(bodyValue)) { + // Value requires Base64 encoding + const base64Match = rawHeader.match(/^=\?base64\?(.*)\?=$/); + + 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 (valueType === 'number') { + return compareNumericValues(decoded, bodyValue); + } + 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 (valueType === 'number') { + return compareNumericValues(decoded, bodyValue); + } + if (decoded !== bodyValue) { + return `Header value '${decoded}' (raw: '${rawHeader}') does not match body value '${bodyValue}'`; + } + return null; +} + +/** + * Compare two string representations of numbers numerically. + * For integers, requires exact match. For decimals, allows + * a tolerance of 1e-9 to account for cross-SDK floating point + * representation differences. + */ +function compareNumericValues( + headerValue: string, + bodyValue: string +): string | null { + const headerNum = Number(headerValue); + const bodyNum = Number(bodyValue); + if (isNaN(headerNum) || isNaN(bodyNum)) { + return `Non-numeric value in number comparison: header='${headerValue}', body='${bodyValue}'`; + } + if (Number.isInteger(bodyNum)) { + // Integer: require exact numeric match (e.g. 42 === 42.0) + if (headerNum !== bodyNum) { + return `Numeric header value ${headerNum} does not match body value ${bodyNum}`; + } + } else { + // Decimal: allow tolerance of 1e-9 + if (Math.abs(headerNum - bodyNum) > 1e-9) { + return `Numeric header value ${headerNum} does not match body value ${bodyNum} (difference ${Math.abs(headerNum - bodyNum)} exceeds tolerance 1e-9)`; + } + } + return null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// HttpCustomHeadersScenario - tests that clients mirror x-mcp-header params +// ───────────────────────────────────────────────────────────────────────────── + +export class HttpCustomHeadersScenario extends BaseHttpScenario { + name = 'http-custom-headers'; + 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, + debug: true, + empty_val: '', + method_val: 'test-method', + float_val: 3.14159, + non_ascii_val: 'Hello, 世界', + whitespace_val: ' padded ', + leading_space_val: ' us-west1', + trailing_space_val: 'us-west1 ', + internal_space_val: 'us west 1', + control_char_val: 'line1\nline2', + crlf_val: 'line1\r\nline2', + tab_val: '\tindented', + 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: 'sep-2243-param-header-tool-call-gate', + 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: 'sep-2243-client-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' + }, + debug: { + type: 'boolean', + description: 'Boolean true value', + 'x-mcp-header': 'Debug' + }, + 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' + }, + leading_space_val: { + type: 'string', + description: + 'String with leading space only — requires Base64 encoding', + 'x-mcp-header': 'LeadingSpace' + }, + trailing_space_val: { + type: 'string', + description: + 'String with trailing space only — requires Base64 encoding', + 'x-mcp-header': 'TrailingSpace' + }, + internal_space_val: { + type: 'string', + description: + 'String with internal spaces only — plain ASCII, no Base64', + 'x-mcp-header': 'InternalSpace' + }, + control_char_val: { + type: 'string', + description: + 'String with control characters — requires Base64 encoding', + 'x-mcp-header': 'ControlChar' + }, + crlf_val: { + type: 'string', + description: + 'String with carriage return and line feed — requires Base64 encoding', + 'x-mcp-header': 'CrLf' + }, + tab_val: { + type: 'string', + description: + 'String with leading tab — requires Base64 encoding', + 'x-mcp-header': 'Tab' + }, + 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) + // 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'); + } + + // Check Mcp-Param-Debug header (boolean true value) + if (args.debug !== undefined && args.debug !== null) { + this.checkParamHeader(req, 'Debug', args.debug, 'boolean'); + } + + // 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-LeadingSpace header (leading space only → Base64) + if ( + args.leading_space_val !== undefined && + args.leading_space_val !== null + ) { + this.checkParamHeader( + req, + 'LeadingSpace', + args.leading_space_val, + 'string' + ); + } + + // Check Mcp-Param-TrailingSpace header (trailing space only → Base64) + if ( + args.trailing_space_val !== undefined && + args.trailing_space_val !== null + ) { + this.checkParamHeader( + req, + 'TrailingSpace', + args.trailing_space_val, + 'string' + ); + } + + // Check Mcp-Param-InternalSpace header (internal spaces only → plain ASCII, no Base64) + if ( + args.internal_space_val !== undefined && + args.internal_space_val !== null + ) { + this.checkParamHeader( + req, + 'InternalSpace', + args.internal_space_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 Mcp-Param-CrLf header (carriage return + line feed → Base64) + if (args.crlf_val !== undefined && args.crlf_val !== null) { + this.checkParamHeader(req, 'CrLf', args.crlf_val, 'string'); + } + + // Check Mcp-Param-Tab header (leading tab → Base64) + if (args.tab_val !== undefined && args.tab_val !== null) { + this.checkParamHeader(req, 'Tab', args.tab_val, 'string'); + } + + // Check that 'query' (no x-mcp-header) is NOT mirrored + const queryHeader = req.headers['mcp-param-query'] as string | undefined; + 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; + + // 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: 'sep-2243-client-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); + } + + // For numbers, compare numerically to allow for cross-SDK + // floating point representation differences (e.g., "42" vs "42.0"). + // See SEP-2243 discussion on number precision. + const validationError = validateEncodedHeader( + rawHeaderValue, + expectedString, + valueType + ); + if (validationError) { + errors.push(validationError); + } + } + + this.checks.push({ + 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', + 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'; + 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: 'sep-2243-invalid-tool-tools-list-gate', + 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: 'sep-2243-keep-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_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: `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', + 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: 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.test.ts b/src/scenarios/client/http-standard-headers.test.ts new file mode 100644 index 0000000..2d4c303 --- /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 new file mode 100644 index 0000000..b3efd05 --- /dev/null +++ b/src/scenarios/client/http-standard-headers.ts @@ -0,0 +1,356 @@ +/** + * 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 { ConformanceCheck } 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 extends BaseHttpScenario { + name = 'http-standard-headers'; + description = + 'Tests that client includes Mcp-Method and Mcp-Name headers on HTTP POST requests (SEP-2243)'; + + // Track which header checks have been recorded + private methodHeaderChecks = new Map(); + // Track which Mcp-Name checks have been recorded + private nameHeaderChecks = new Map(); + + getChecks(): ConformanceCheck[] { + // 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', + 'tools/list', + 'tools/call', + 'resources/list', + 'resources/read', + 'prompts/list', + 'prompts/get' + ]; + + for (const method of expectedMethods) { + if (!this.methodHeaderChecks.has(method)) { + 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: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: `Client did not send a ${method} request; Mcp-Method header was not exercised for this method.`, + specReferences: [SPEC_REFERENCE] + }); + } + } + + const expectedNameMethods = ['tools/call', 'resources/read', 'prompts/get']; + for (const method of expectedNameMethods) { + if (!this.nameHeaderChecks.has(method)) { + 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: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: `Client did not send a ${method} request; Mcp-Name header was not exercised for this method.`, + specReferences: [SPEC_REFERENCE] + }); + } + } + + return result; + } + + protected handlePost( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + // 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); + } + } + + 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: `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(), + 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; + + // 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; + + 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: `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(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE], + details: { + method, + sourceField, + expectedValue, + actualHeader: mcpNameHeader + } + }); + } + + private handleInitialize(res: http.ServerResponse, request: any): void { + this.sendInitialize(res, request, { + 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 396d3bc..a46604f 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -64,6 +64,11 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { + HttpHeaderValidationScenario, + HttpCustomHeaderServerValidationScenario +} from './server/http-standard-headers'; + import { authScenariosList, backcompatScenariosList, @@ -73,6 +78,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) @@ -82,7 +93,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 @@ -140,7 +157,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) @@ -183,7 +204,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/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts new file mode 100644 index 0000000..37ada9f --- /dev/null +++ b/src/scenarios/server/http-standard-headers.ts @@ -0,0 +1,939 @@ +/** + * 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, + DRAFT_PROTOCOL_VERSION +} 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' +}; + +// 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' +}; + +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) => { + res.setEncoding('utf8'); + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + 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(); + }); +} + +/** + * 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, + opts: { + statusSeverity?: 'FAILURE' | 'INFO'; + errorCodeSeverity: 'FAILURE' | 'WARNING' | 'INFO'; + } +): ConformanceCheck[] { + const statusSeverity = opts.statusSeverity ?? 'FAILURE'; + 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' : statusSeverity, + 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( + 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.` + ); + } + // 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, + 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'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + 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_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'conformance-test-raw-client', + version: '1.0.0' + } + } + }, + { 'Mcp-Method': 'initialize' } + ); + + if (initResponse.status === 200) { + const rawSid = initResponse.headers['mcp-session-id']; + sessionId = (Array.isArray(rawSid) ? rawSid[0] : rawSid) || null; + const notifHeaders: Record = { + '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_PROTOCOL_VERSION + }; + 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', + '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' }, + { 'Mcp-Method': 'prompts/list' }, + SPEC_REFERENCE, + { requestBodyMethod: 'tools/list', mcpMethodHeader: 'prompts/list' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'sep-2243-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', + 'sep-2243-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', + 'sep-2243-server-accepts-whitespace-header-value', + 'ServerAcceptsWhitespaceHeaderValue', + '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, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': ` ${toolName} ` + }, + SPEC_REFERENCE_RFC9110_OWS, + { + 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', + 'sep-2243-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', + 'sep-2243-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', + 'sep-2243-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', + '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' }, + { 'Mcp-Method': 'TOOLS/LIST' }, + SPEC_REFERENCE_CASE, + { headerValue: 'TOOLS/LIST', bodyValue: 'tools/list' } + ); + } catch (error) { + checks.push({ + id: 'sep-2243-server-standard-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 + }); + 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, + 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'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + 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 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: 'sep-2243-server-no-xmcp-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_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'conformance-test-base64-client', + version: '1.0.0' + } + } + }, + { 'Mcp-Method': 'initialize' } + ); + + if (initResponse.status === 200) { + const rawSid2 = initResponse.headers['mcp-session-id']; + sessionId = (Array.isArray(rawSid2) ? rawSid2[0] : rawSid2) || null; + const notifHeaders: Record = { + '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_PROTOCOL_VERSION + }; + if (sessionId) baseHeaders['mcp-session-id'] = sessionId; + + // Find the first x-mcp-header annotated STRING property + // that is callable with minimal arguments to avoid schema validation failures + 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: 'sep-2243-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. + // 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 defaultHeaders: Record = {}; + for (const rp of requiredParams) { + if (rp !== paramName) { + const rpDef = schema.properties[rp]; + const rpType = rpDef?.type || 'string'; + if (rpType === 'number' || rpType === 'integer') { + defaultArgs[rp] = 0; + } else if (rpType === 'boolean') { + defaultArgs[rp] = false; + } 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', + 'sep-2243-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 — INFO, not FAILURE/WARNING. SEP-2243 says only + // "MUST decode them accordingly" without specifying RFC 4648 strictness. + // Lenient decoders (Node Buffer.from, browser atob) accept 'SGVsbG8' → + // server matches → accepts. Strict decoders (.NET Convert.FromBase64String) + // throw → server rejects. Either is currently spec-compliant. INFO records + // the behavior so cross-SDK divergence is visible without affecting tier. + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject-info', + 'sep-2243-server-rejects-invalid-base64-padding', + 'ServerRejectsInvalidBase64Padding', + 'Records whether server rejects unpadded Base64 in Mcp-Param value (informational — spec does not mandate strict decoding)', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + '=?base64?SGVsbG8?=', + defaultArgs, + defaultHeaders + ); + + // Invalid Base64 characters — INFO for the same reason as padding. + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject-info', + 'sep-2243-server-rejects-invalid-base64-chars', + 'ServerRejectsInvalidBase64Chars', + 'Records whether server rejects non-alphabet chars in Base64 Mcp-Param value (informational — spec does not mandate strict decoding)', + 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', + 'sep-2243-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', + 'sep-2243-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 + ); + + // --- 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: 'sep-2243-server-custom-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' | 'reject-info', + 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 + }; + + 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 — except the two + // 'reject-info' malformed-base64 probes which are observational. + const sev = expectation === 'reject-info' ? 'INFO' : 'FAILURE'; + checks.push( + ...createRejectionChecks( + checkId, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details, + { statusSeverity: sev, errorCodeSeverity: sev } + ) + ); + } + } 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 + } + ); + + // Custom-header rejection: both 400 and -32001 are MUST. + checks.push( + ...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, + SPEC_REFERENCE_CUSTOM, + { + toolName, + paramName, + bodyValue: 'test-value', + expectedHeader: `Mcp-Param-${headerSuffix}`, + mcpParamHeader: '(missing)' + }, + { errorCodeSeverity: 'FAILURE' } + ) + ); + } catch (error) { + checks.push({ + 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', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + } +} diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml new file mode 100644 index 0000000..e981da0 --- /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.'