diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts index 94674562..55a2335e 100644 --- a/src/scenarios/client/http-standard-headers.test.ts +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -67,6 +67,91 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { } }); + it('does NOT emit an Mcp-Method check for notification POSTs', async () => { + // Header rules for notification POSTs are explicitly undefined by the + // spec, so a notification without Mcp-Method must not produce a row. + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }) + }); + const checks = scenario.getChecks(); + const notifCheck = checks.find( + (c) => + c.id === COARSE_ID && + c.name === 'ClientMcpMethodHeader_notifications_initialized' + ); + expect(notifCheck).toBeUndefined(); + } finally { + await scenario.stop(); + } + }); + + const BASE64_ID = 'sep-2243-client-base64-mcp-name'; + const UNICODE_TOOL_NAME = 'tööl_unicode'; + + async function postToolsCall( + serverUrl: string, + toolName: string, + extraHeaders: Record + ): Promise { + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Method': 'tools/call', + ...extraHeaders + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }) + }); + } + + it('FAILs the Base64 Mcp-Name check when Mcp-Name is sent unencoded', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + // Node fetch() rejects raw non-ASCII header values, so use a header-safe + // wrong value to exercise the "not Base64-sentinel-wrapped" branch. + await postToolsCall(serverUrl, UNICODE_TOOL_NAME, { + 'Mcp-Name': 'tool_unicode' + }); + const check = scenario.getChecks().find((c) => c.id === BASE64_ID); + expect(check?.status).toBe('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('SUCCEEDs the Base64 Mcp-Name check when Mcp-Name is sentinel-encoded', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const encoded = `=?base64?${Buffer.from(UNICODE_TOOL_NAME, 'utf-8').toString('base64')}?=`; + await postToolsCall(serverUrl, UNICODE_TOOL_NAME, { + 'Mcp-Name': encoded + }); + const check = scenario.getChecks().find((c) => c.id === BASE64_ID); + expect(check?.status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); + it('getChecks() is idempotent', async () => { const scenario = new HttpStandardHeadersScenario(); const { serverUrl } = await scenario.start(testScenarioContext()); diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts index 227546b2..1fbc092c 100644 --- a/src/scenarios/client/http-standard-headers.ts +++ b/src/scenarios/client/http-standard-headers.ts @@ -20,6 +20,18 @@ const SPEC_REFERENCE = { url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers' }; +const SPEC_REFERENCE_ENCODING = { + id: 'SEP-2243-Value-Encoding', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding' +}; + +/** + * Tool name containing non-ASCII characters. Tool/prompt names are only + * SHOULD-constrained to header-safe characters, so a name outside the safe + * set MUST be carried in `Mcp-Name` via the Base64 sentinel encoding. + */ +const UNICODE_TOOL_NAME = 'tööl_unicode'; + export class HttpStandardHeadersScenario extends BaseHttpScenario { name = 'http-standard-headers'; description = @@ -29,19 +41,21 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { private methodHeaderChecks = new Map(); // Track which Mcp-Name checks have been recorded private nameHeaderChecks = new Map(); + // Track whether the non-header-safe tool was called (Base64 Mcp-Name check) + private unicodeToolCallReceived = false; 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. + // SEP-2243 requires Mcp-Method on "all requests". 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. Notifications are NOT + // listed: header rules for notification POSTs are explicitly undefined. const expectedMethods = [ 'initialize', - 'notifications/initialized', 'tools/list', 'tools/call', 'resources/list', @@ -79,6 +93,19 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { } } + if (!this.unicodeToolCallReceived) { + result.push({ + id: 'sep-2243-client-base64-mcp-name', + name: 'ClientMcpNameHeaderBase64', + description: + 'Client Base64-encodes Mcp-Name when the source value is not header-safe', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: `Client did not call '${UNICODE_TOOL_NAME}'; Base64 Mcp-Name encoding was not exercised.`, + specReferences: [SPEC_REFERENCE_ENCODING] + }); + } + return result; } @@ -87,7 +114,8 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { res: http.ServerResponse, request: any ): void { - // Check Mcp-Method header for every request + // Check Mcp-Method header for every request (notifications excluded — + // header rules for notification POSTs are explicitly undefined by the spec) this.checkMcpMethodHeader(req, request); // Route to handlers @@ -96,7 +124,13 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { } else if (request.method === 'tools/list') { this.handleToolsList(res, request); } else if (request.method === 'tools/call') { - this.checkMcpNameHeader(req, request, 'params.name'); + if (request.params?.name === UNICODE_TOOL_NAME) { + // Non-header-safe name → check Base64 sentinel encoding instead of + // the plain Mcp-Name comparison (which would mismatch by design). + this.checkMcpNameBase64Header(req, request); + } else { + this.checkMcpNameHeader(req, request, 'params.name'); + } this.handleToolsCall(res, request); } else if (request.method === 'resources/list') { this.handleResourcesList(res, request); @@ -109,7 +143,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { this.checkMcpNameHeader(req, request, 'params.name'); this.handlePromptsGet(res, request); } else if (request.id === undefined) { - // Notifications - return 202 (Mcp-Method already checked above) + // Notifications - return 202 (Mcp-Method not required on notifications) this.sendNotificationAck(res); } else { this.sendGenericResult(res, request); @@ -120,6 +154,11 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { const method = request.method; if (!method) return; + // Mcp-Method is required on requests only; header rules for notification + // POSTs are explicitly undefined by the spec, so a missing Mcp-Method on + // a notification (no JSON-RPC id) is neither SUCCESS nor FAILURE. + if (request.id === undefined) return; + // Already recorded a check for this method if (this.methodHeaderChecks.has(method)) return; @@ -202,6 +241,57 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { }); } + private checkMcpNameBase64Header( + req: http.IncomingMessage, + request: any + ): void { + // Record once: subsequent calls to the same unicode tool are ignored so + // getChecks() stays idempotent. + if (this.unicodeToolCallReceived) return; + this.unicodeToolCallReceived = true; + + const bodyName = request.params?.name as string; + const mcpNameHeader = req.headers['mcp-name'] as string | undefined; + const expectedHeader = `=?base64?${Buffer.from(bodyName, 'utf-8').toString('base64')}?=`; + + const errors: string[] = []; + if (!mcpNameHeader) { + errors.push( + `Missing Mcp-Name header on tools/call for '${bodyName}'. Clients MUST include the Mcp-Name header for tools/call requests.` + ); + } else { + const base64Match = mcpNameHeader.match(/^=\?base64\?(.*)\?=$/); + if (!base64Match) { + errors.push( + `Mcp-Name source value '${bodyName}' is not header-safe but header was sent unencoded as '${mcpNameHeader}'. Clients MUST encode it using the Base64 sentinel format =?base64?{encoded}?=.` + ); + } else { + const decoded = Buffer.from(base64Match[1], 'base64').toString('utf-8'); + if (decoded !== bodyName) { + errors.push( + `Base64-decoded Mcp-Name '${decoded}' (raw: '${mcpNameHeader}') does not match body params.name '${bodyName}'.` + ); + } + } + } + + this.checks.push({ + id: 'sep-2243-client-base64-mcp-name', + name: 'ClientMcpNameHeaderBase64', + description: + 'Client Base64-encodes Mcp-Name when the source value is not header-safe', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE_ENCODING], + details: { + bodyName, + actualHeader: mcpNameHeader, + expectedHeader + } + }); + } + protected discoverCapabilities(): object { return { tools: {}, resources: {}, prompts: {} }; } @@ -244,6 +334,16 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { properties: {}, required: [] } + }, + { + name: UNICODE_TOOL_NAME, + description: + 'Tool with non-ASCII name to test Base64 sentinel encoding of Mcp-Name header', + inputSchema: { + type: 'object', + properties: {}, + required: [] + } } ] } diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 94ef9e82..496584a9 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -362,6 +362,36 @@ export class HttpHeaderValidationScenario implements ClientScenario { { requestBodyName: toolName, mcpNameHeader: 'wrong_tool_name' } ); + // --- Base64-encoded Mcp-Name accepted --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-server-decode-base64-mcp-name', + 'ServerAcceptsBase64EncodedMcpName', + 'Server MUST decode Base64-sentinel-encoded Mcp-Name before comparing to body params.name', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': `=?base64?${Buffer.from(toolName, 'utf-8').toString('base64')}?=` + }, + SPEC_REFERENCE_BASE64, + { + headerValue: `=?base64?${Buffer.from(toolName, 'utf-8').toString('base64')}?=`, + bodyValue: toolName, + reason: + 'Mcp-Name carried as =?base64?{b64(utf8(name))}?= must decode to params.name' + } + ); + // --- Whitespace Test --- await this.testCase( diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml index bb7803f1..3164f5b3 100644 --- a/src/seps/sep-2243.yaml +++ b/src/seps/sep-2243.yaml @@ -32,8 +32,12 @@ requirements: 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-client-base64-mcp-name + text: 'If the Mcp-Name source value cannot be safely represented as a plain ASCII header value, clients MUST encode it using the Base64 sentinel format described in Value Encoding.' - check: sep-2243-server-decode-base64 text: 'Servers and intermediaries that need to inspect these values MUST decode them accordingly.' + - check: sep-2243-server-decode-base64-mcp-name + text: 'In particular, servers MUST decode an encoded Mcp-Name or Mcp-Param-{Name} value before comparing it to the corresponding request body value during Server Validation.' - 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