From 5a91fc9d2aee30f81f53cd0e4910aa6515be8ba1 Mon Sep 17 00:00:00 2001 From: jw_ond Date: Fri, 16 Oct 2026 08:11:56 +0800 Subject: [PATCH] test: cover empty custom-header presence mismatches --- .../typescript/custom-header-presence.ts | 99 +++++++++++++++ .../server/http-standard-headers.test.ts | 117 ++++++++++++++++++ src/scenarios/server/http-standard-headers.ts | 112 +++++++++++++---- 3 files changed, 307 insertions(+), 21 deletions(-) create mode 100644 examples/servers/typescript/custom-header-presence.ts diff --git a/examples/servers/typescript/custom-header-presence.ts b/examples/servers/typescript/custom-header-presence.ts new file mode 100644 index 00000000..4bf76f61 --- /dev/null +++ b/examples/servers/typescript/custom-header-presence.ts @@ -0,0 +1,99 @@ +import { createServer, type IncomingHttpHeaders } from 'http'; + +export type HeaderPresenceBehavior = + | 'correct' + | 'empty-is-absent' + | 'absent-is-empty'; + +function decodeHeader(value: string): string | undefined { + if (!value.startsWith('=?base64?') || !value.endsWith('?=')) return value; + const encoded = value.slice('=?base64?'.length, -2); + const decoded = Buffer.from(encoded, 'base64'); + return decoded.toString('base64') === encoded + ? decoded.toString('utf8') + : undefined; +} + +/** + * A narrow HTTP fixture, not a complete MCP server. The two broken modes + * deliberately conflate missing and empty headers in opposite directions. + */ +export function createCustomHeaderPresenceServer( + behavior: HeaderPresenceBehavior = 'correct', + allParamsRequired = false +) { + const properties = { + required_region: { type: 'string', 'x-mcp-header': 'RequiredRegion' }, + region: { type: 'string', 'x-mcp-header': 'Region' } + }; + const requests: Array<{ body: any; headers: IncomingHttpHeaders }> = []; + const server = createServer(async (req, res) => { + let body: any; + try { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + res.writeHead(400).end(); + return; + } + requests.push({ body, headers: req.headers }); + + const reply = (status: number, payload: object) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, ...payload })); + }; + if (body.method === 'tools/list') { + reply(200, { + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [ + { + name: 'test_header_presence', + inputSchema: { + type: 'object', + properties, + required: allParamsRequired + ? ['required_region', 'region'] + : ['required_region'] + } + } + ] + } + }); + return; + } + if (body.method !== 'tools/call') { + reply(404, { error: { code: -32601, message: 'Method not found' } }); + return; + } + + const args = body.params?.arguments ?? {}; + for (const [param, schema] of Object.entries(properties)) { + const header = req.headers[ + `mcp-param-${schema['x-mcp-header'].toLowerCase()}` + ] as string | undefined; + const present = + behavior === 'empty-is-absent' ? Boolean(header) : header !== undefined; + const value = args[param]; + const mismatch = + value === undefined || value === null + ? present + : (!present && behavior !== 'absent-is-empty') || + decodeHeader(header ?? '') !== value; + if (mismatch) { + reply(400, { error: { code: -32020, message: 'Header mismatch' } }); + return; + } + } + reply(200, { + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'ok' }] + } + }); + }); + return { server, requests }; +} diff --git a/src/scenarios/server/http-standard-headers.test.ts b/src/scenarios/server/http-standard-headers.test.ts index 056d3b38..95793f43 100644 --- a/src/scenarios/server/http-standard-headers.test.ts +++ b/src/scenarios/server/http-standard-headers.test.ts @@ -6,6 +6,10 @@ import { CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS } from './http-standard-headers'; import type { ConformanceCheck } from '../../types'; +import { + createCustomHeaderPresenceServer, + type HeaderPresenceBehavior +} from '../../../examples/servers/typescript/custom-header-presence'; /** * Pins the untestable-failure policy (issue #248) for the SEP-2243 server @@ -46,6 +50,119 @@ function mockFetchTarget( const findAll = (checks: ConformanceCheck[], id: string) => checks.filter((c) => c.id === id); +describe('http-custom-header-server-validation - empty versus absent', () => { + const caseNames = [ + 'ServerRejectsMissingEmptyCustomHeader', + 'ServerRejectsEmptyHeaderForOmittedParam', + 'ServerRejectsEmptyHeaderForNullParam' + ]; + + async function runPresenceScenario( + behavior: HeaderPresenceBehavior, + allParamsRequired = false + ) { + const fixture = createCustomHeaderPresenceServer( + behavior, + allParamsRequired + ); + await new Promise((resolve) => + fixture.server.listen(0, '127.0.0.1', resolve) + ); + const address = fixture.server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture did not listen on a TCP port'); + } + try { + const checks = await new HttpCustomHeaderServerValidationScenario().run( + testContext(`http://127.0.0.1:${address.port}/mcp`) + ); + return { checks, requests: fixture.requests }; + } finally { + fixture.server.closeAllConnections(); + await new Promise((resolve, reject) => + fixture.server.close((error) => (error ? reject(error) : resolve())) + ); + } + } + + test('rejects all three mismatches and preserves required parameters', async () => { + const { checks, requests } = await runPresenceScenario('correct'); + for (const name of caseNames) { + for (const checkName of [name, `${name}ErrorCode`]) { + expect( + checks.find((c) => c.name === checkName)?.status, + checkName + ).toBe('SUCCESS'); + } + } + expect(checks.every((c) => c.status === 'SUCCESS')).toBe(true); + + const emptyHeaders = requests.filter( + (r) => r.headers['mcp-param-region'] === '' + ); + expect(emptyHeaders).toHaveLength(2); + expect(emptyHeaders[0].body.params.arguments).not.toHaveProperty('region'); + expect(emptyHeaders[1].body.params.arguments).toHaveProperty( + 'region', + null + ); + for (const request of emptyHeaders) { + expect(request.body.params.arguments.required_region).toBe( + 'test-default' + ); + expect(request.headers['mcp-param-requiredregion']).toBe('test-default'); + } + const missingHeader = requests.find( + (r) => r.body.params?.arguments?.region === '' + ); + expect(missingHeader).toBeDefined(); + expect(missingHeader?.headers).not.toHaveProperty('mcp-param-region'); + }); + + test.each<{ + behavior: HeaderPresenceBehavior; + failedCases: string[]; + }>([ + { + behavior: 'empty-is-absent', + failedCases: caseNames.slice(1) + }, + { + behavior: 'absent-is-empty', + failedCases: caseNames.slice(0, 1) + } + ])('detects $behavior behavior', async ({ behavior, failedCases }) => { + const { checks } = await runPresenceScenario(behavior); + expect( + checks.filter((c) => c.status === 'FAILURE').map((c) => c.name) + ).toEqual(failedCases.flatMap((name) => [name, `${name}ErrorCode`])); + for (const name of caseNames.filter((n) => !failedCases.includes(n))) { + expect(checks.find((c) => c.name === name)?.status, name).toBe('SUCCESS'); + } + }); + + test('marks omitted/null probes untestable without an optional string parameter', async () => { + const { checks } = await runPresenceScenario('correct', true); + expect( + checks.find((c) => c.name === 'ServerRejectsMissingEmptyCustomHeader') + ?.status + ).toBe('SUCCESS'); + for (const name of caseNames.slice(1)) { + expect( + checks.find((c) => c.name === name), + name + ).toMatchObject({ + status: 'FAILURE', + details: { untestable: true }, + errorMessage: expect.stringContaining( + 'optional string-typed x-mcp-header' + ) + }); + } + expect(checks.some((c) => c.status === 'SKIPPED')).toBe(false); + }); +}); + describe('http-custom-header-server-validation — missing fixture policy', () => { test('emits untestable FAILUREs for every declared check when no x-mcp-header tool exists', async () => { const mockUrl = mockFetchTarget((reqBody) => { diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index d5ca0320..231d4cfd 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -609,11 +609,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario **Endpoint**: Streamable HTTP with at least one tool using \`x-mcp-header\` +**Fixture**: The omitted/null probes require an optional string-typed \`x-mcp-header\` parameter on the selected tool. + **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`; +- Server MUST reject requests where custom header is omitted but value is in body, including an empty string +- Server MUST reject a present empty custom header for an omitted or null parameter`; async run(ctx: RunContext): Promise { const { serverUrl } = ctx; @@ -680,13 +683,17 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; - // Find the first x-mcp-header annotated STRING property - // that is callable with minimal arguments to avoid schema validation failures + // Prefer an optional string so omission probes do not also violate a + // required-parameter constraint unrelated to header validation. const schema = xMcpTool.inputSchema as any; - const annotatedEntry = Object.entries(schema.properties).find( + const requiredParams: string[] = schema.required || []; + const annotatedEntries = Object.entries(schema.properties).filter( ([, def]: [string, any]) => def['x-mcp-header'] !== undefined && (def as any).type === 'string' ); + const annotatedEntry = + annotatedEntries.find(([name]) => !requiredParams.includes(name)) ?? + annotatedEntries[0]; if (!annotatedEntry) { checks.push( untestableCheck( @@ -710,7 +717,6 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // 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) { @@ -741,7 +747,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario const validBase64Value = Buffer.from('Hello').toString('base64'); // Valid Base64 - server decodes and validates - await this.testBase64Case( + await this.testCustomHeaderCase( checks, serverUrl, baseHeaders, @@ -765,7 +771,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // strict rejection. SDKs whose stdlib decoders are lenient (Node // Buffer.from, browser atob) will need to validate before decoding; if // that proves burdensome we'll revisit. - await this.testBase64Case( + await this.testCustomHeaderCase( checks, serverUrl, baseHeaders, @@ -784,7 +790,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); // Invalid Base64 characters — FAILURE for the same reason as padding. - await this.testBase64Case( + await this.testCustomHeaderCase( checks, serverUrl, baseHeaders, @@ -803,7 +809,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); // Missing prefix - server treats as literal value - await this.testBase64Case( + await this.testCustomHeaderCase( checks, serverUrl, baseHeaders, @@ -822,7 +828,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); // Missing suffix - server treats as literal value - await this.testBase64Case( + await this.testCustomHeaderCase( checks, serverUrl, baseHeaders, @@ -853,6 +859,67 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario defaultArgs, defaultHeaders ); + + await this.testMissingCustomHeader( + checks, + serverUrl, + baseHeaders, + nextId, + xMcpTool.name, + paramName, + headerSuffix, + defaultArgs, + defaultHeaders, + '', + 'ServerRejectsMissingEmptyCustomHeader' + ); + + for (const testCase of [ + { + name: 'ServerRejectsEmptyHeaderForOmittedParam', + value: undefined, + description: + 'Server MUST reject a present empty custom header when the parameter is omitted' + }, + { + name: 'ServerRejectsEmptyHeaderForNullParam', + value: null, + description: + 'Server MUST reject a present empty custom header when the parameter is null' + } + ]) { + const checkId = 'sep-2243-server-validate-param-match'; + if (requiredParams.includes(paramName)) { + checks.push( + untestableCheck( + checkId, + testCase.name, + testCase.description, + 'selected tool exposes no optional string-typed x-mcp-header parameter for the omitted/null probes', + [SPEC_REFERENCE_CUSTOM] + ) + ); + continue; + } + await this.testCustomHeaderCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + checkId, + testCase.name, + testCase.description, + xMcpTool.name, + paramName, + testCase.value, + headerSuffix, + '', + defaultArgs, + defaultHeaders, + SPEC_REFERENCE_CUSTOM + ); + } } catch (error) { checks.push({ id: 'sep-2243-server-custom-setup', @@ -918,7 +985,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } } - private async testBase64Case( + private async testCustomHeaderCase( checks: ConformanceCheck[], serverUrl: string, baseHeaders: Record, @@ -929,11 +996,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario description: string, toolName: string, paramName: string, - bodyValue: string, + bodyValue: string | null | undefined, headerSuffix: string, headerValue: string, defaultArgs: Record, - defaultHeaders: Record + defaultHeaders: Record, + specRef: { id: string; url: string } = SPEC_REFERENCE_BASE64 ): Promise { try { const response = await sendRawRequest( @@ -973,7 +1041,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario checkName, description, response, - SPEC_REFERENCE_BASE64, + specRef, details ) ); @@ -990,7 +1058,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario checkName, description, response, - SPEC_REFERENCE_BASE64, + specRef, details, { errorCodeSeverity: 'FAILURE' } ) @@ -1004,7 +1072,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: [SPEC_REFERENCE_BASE64] + specReferences: [specRef] }); } } @@ -1018,7 +1086,9 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario paramName: string, headerSuffix: string, defaultArgs: Record, - defaultHeaders: Record + defaultHeaders: Record, + bodyValue = 'test-value', + checkName = 'ServerRejectsMissingCustomHeader' ): Promise { try { // Send tools/call with value in body but NO Mcp-Param header @@ -1032,7 +1102,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // this case only omits the Mcp-Param header. params: withRequestMeta({ name: toolName, - arguments: { ...defaultArgs, [paramName]: 'test-value' } + arguments: { ...defaultArgs, [paramName]: bodyValue } }) }, { @@ -1051,14 +1121,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ...createRejectionChecks( 'sep-2243-server-validate-param-match', PARAM_REJECT_ERROR_CODE_CHECK_ID, - 'ServerRejectsMissingCustomHeader', + checkName, 'Server MUST reject request where custom header is omitted but value is present in body', response, SPEC_REFERENCE_CUSTOM, { toolName, paramName, - bodyValue: 'test-value', + bodyValue, expectedHeader: `Mcp-Param-${headerSuffix}`, mcpParamHeader: '(missing)' }, @@ -1068,7 +1138,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } catch (error) { checks.push({ id: 'sep-2243-server-validate-param-match', - name: 'ServerRejectsMissingCustomHeader', + name: checkName, description: 'Server MUST reject request where custom header is omitted but value is present in body', status: 'FAILURE',