Skip to content

Commit 3291467

Browse files
committed
Improve SEP-2243 conformance test coverage
Close gaps in HTTP header conformance scenarios: Client standard headers (http-standard-headers.ts): - Enforce all expected methods in checks, failing any that were not observed - Track Mcp-Name header per-method separately from Mcp-Method - Advertise resources and prompts capabilities so clients exercise those endpoints - Add a prompt entry for prompts/get testing Client custom headers (http-custom-headers.ts): - Add Base64 encoding checks for non-ASCII, whitespace, and control-char values - Add null/omitted parameter test (second tool call with null value) - Add client-keeps-valid-tool check verifying clients still call valid tools after filtering out invalid ones Server header validation (server/http-standard-headers.ts): - Replace fetch-based sendRawRequest with http.request to preserve exact header casing on the wire (fetch/Headers lowercases names) - Compute defaultArgs and defaultHeaders from tool schema so test requests satisfy all required parameters while varying only the param under test Traceability (sep-2243.yaml): - Add 7 new spec-to-check mappings (nonascii, whitespace, controlchar, keeps-valid-tool, literal-missing-base64-prefix/suffix, no-mirror-unannotated) - Fix 2 incorrect existing mappings
1 parent 156cdf2 commit 3291467

4 files changed

Lines changed: 391 additions & 139 deletions

File tree

src/scenarios/client/http-custom-headers.ts

Lines changed: 195 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -239,24 +239,39 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
239239
'Tests that client mirrors x-mcp-header tool parameters into Mcp-Param headers with correct encoding (SEP-2243)';
240240

241241
private toolCallReceived: boolean = false;
242+
private nullToolCallReceived: boolean = false;
242243

243244
async start(): Promise<ScenarioUrls> {
244245
const urls = await super.start();
245246
// Pass test values via context for encoding edge cases.
246247
// The conformance client should use these values when calling test_custom_headers.
247248
urls.context = {
248-
toolCall: {
249-
name: 'test_custom_headers',
250-
arguments: {
251-
region: 'us-west1',
252-
priority: 42,
253-
verbose: false,
254-
empty_val: '',
255-
method_val: 'test-method',
256-
float_val: 3.14159,
257-
query: 'SELECT * FROM users'
249+
toolCalls: [
250+
{
251+
name: 'test_custom_headers',
252+
arguments: {
253+
region: 'us-west1',
254+
priority: 42,
255+
verbose: false,
256+
empty_val: '',
257+
method_val: 'test-method',
258+
float_val: 3.14159,
259+
non_ascii_val: 'Hello, 世界',
260+
whitespace_val: ' padded ',
261+
control_char_val: 'line1\nline2',
262+
query: 'SELECT * FROM users'
263+
}
264+
},
265+
{
266+
name: 'test_custom_headers_null',
267+
arguments: {
268+
region: 'us-east1',
269+
priority: 1,
270+
verbose: null,
271+
query: 'SELECT 1'
272+
}
258273
}
259-
}
274+
]
260275
};
261276
return urls;
262277
}
@@ -274,6 +289,19 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
274289
specReferences: [SPEC_REFERENCE_CUSTOM]
275290
});
276291
}
292+
if (!this.nullToolCallReceived) {
293+
this.checks.push({
294+
id: 'client-custom-header-omit-null',
295+
name: 'ClientCustomHeaderOmitNull',
296+
description:
297+
'Client MUST omit Mcp-Param header when parameter value is null or not provided',
298+
status: 'FAILURE',
299+
timestamp: new Date().toISOString(),
300+
errorMessage:
301+
'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.',
302+
specReferences: [SPEC_REFERENCE_CUSTOM]
303+
});
304+
}
277305
return this.checks;
278306
}
279307

@@ -339,6 +367,24 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
339367
description: 'Floating point numeric value',
340368
'x-mcp-header': 'FloatVal'
341369
},
370+
non_ascii_val: {
371+
type: 'string',
372+
description:
373+
'Non-ASCII string value — requires Base64 encoding',
374+
'x-mcp-header': 'NonAscii'
375+
},
376+
whitespace_val: {
377+
type: 'string',
378+
description:
379+
'String with leading/trailing whitespace — requires Base64 encoding',
380+
'x-mcp-header': 'Whitespace'
381+
},
382+
control_char_val: {
383+
type: 'string',
384+
description:
385+
'String with control characters — requires Base64 encoding',
386+
'x-mcp-header': 'ControlChar'
387+
},
342388
query: {
343389
type: 'string',
344390
description:
@@ -347,6 +393,36 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
347393
},
348394
required: ['region', 'priority', 'query']
349395
}
396+
},
397+
{
398+
name: 'test_custom_headers_null',
399+
description:
400+
'A tool for testing null/omitted x-mcp-header parameter handling',
401+
inputSchema: {
402+
type: 'object',
403+
properties: {
404+
region: {
405+
type: 'string',
406+
description: 'Plain ASCII string value',
407+
'x-mcp-header': 'Region'
408+
},
409+
priority: {
410+
type: 'number',
411+
description: 'Integer numeric value',
412+
'x-mcp-header': 'Priority'
413+
},
414+
verbose: {
415+
type: 'boolean',
416+
description: 'Boolean value — will be null to test omission',
417+
'x-mcp-header': 'Verbose'
418+
},
419+
query: {
420+
type: 'string',
421+
description: 'No x-mcp-header annotation'
422+
}
423+
},
424+
required: ['region', 'priority', 'query']
425+
}
350426
}
351427
]
352428
}
@@ -358,91 +434,120 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
358434
res: http.ServerResponse,
359435
request: any
360436
): void {
361-
this.toolCallReceived = true;
437+
const toolName = request.params?.name;
362438
const args = request.params?.arguments || {};
363439

364-
// Check Mcp-Param-Region header (plain ASCII string)
365-
this.checkParamHeader(req, 'Region', args.region, 'string');
440+
if (toolName === 'test_custom_headers') {
441+
this.toolCallReceived = true;
442+
443+
// Check Mcp-Param-Region header (plain ASCII string)
444+
this.checkParamHeader(req, 'Region', args.region, 'string');
445+
446+
// Check Mcp-Param-Priority header (integer number)
447+
this.checkParamHeader(req, 'Priority', args.priority, 'number');
448+
449+
// Check Mcp-Param-Verbose header (boolean value)
450+
if (args.verbose !== undefined && args.verbose !== null) {
451+
this.checkParamHeader(req, 'Verbose', args.verbose, 'boolean');
452+
453+
// Explicit check: optional parameter present → client MUST include header
454+
const verboseHeader = req.headers['mcp-param-verbose'] as
455+
| string
456+
| undefined;
457+
this.checks.push({
458+
id: 'client-custom-header-optional-present',
459+
name: 'ClientCustomHeaderOptionalPresent',
460+
description:
461+
'Client MUST include Mcp-Param header when optional parameter is provided',
462+
status: verboseHeader !== undefined ? 'SUCCESS' : 'FAILURE',
463+
timestamp: new Date().toISOString(),
464+
errorMessage:
465+
verboseHeader === undefined
466+
? `Optional parameter 'verbose' was provided with value '${args.verbose}' but Mcp-Param-Verbose header is missing. Client MUST include the header when the parameter is present.`
467+
: undefined,
468+
specReferences: [SPEC_REFERENCE_CUSTOM],
469+
details: {
470+
parameter: 'verbose',
471+
bodyValue: args.verbose,
472+
headerPresent: verboseHeader !== undefined
473+
}
474+
});
475+
}
366476

367-
// Check Mcp-Param-Priority header (integer number)
368-
this.checkParamHeader(req, 'Priority', args.priority, 'number');
477+
// Check Mcp-Param-EmptyVal header (empty string → empty header value)
478+
if (args.empty_val !== undefined && args.empty_val !== null) {
479+
this.checkParamHeader(req, 'EmptyVal', args.empty_val, 'string');
480+
}
369481

370-
// Check Mcp-Param-Verbose header (boolean value)
371-
if (args.verbose !== undefined && args.verbose !== null) {
372-
this.checkParamHeader(req, 'Verbose', args.verbose, 'boolean');
482+
// Check Mcp-Param-Method header (x-mcp-header "Method" → Mcp-Param-Method, NOT Mcp-Method)
483+
if (args.method_val !== undefined && args.method_val !== null) {
484+
this.checkParamHeader(req, 'Method', args.method_val, 'string');
485+
}
486+
487+
// Check Mcp-Param-FloatVal header (floating point number)
488+
if (args.float_val !== undefined && args.float_val !== null) {
489+
this.checkParamHeader(req, 'FloatVal', args.float_val, 'number');
490+
}
491+
492+
// Check Mcp-Param-NonAscii header (requires Base64 encoding)
493+
if (args.non_ascii_val !== undefined && args.non_ascii_val !== null) {
494+
this.checkParamHeader(req, 'NonAscii', args.non_ascii_val, 'string');
495+
}
496+
497+
// Check Mcp-Param-Whitespace header (leading/trailing whitespace → Base64)
498+
if (args.whitespace_val !== undefined && args.whitespace_val !== null) {
499+
this.checkParamHeader(req, 'Whitespace', args.whitespace_val, 'string');
500+
}
501+
502+
// Check Mcp-Param-ControlChar header (control characters → Base64)
503+
if (
504+
args.control_char_val !== undefined &&
505+
args.control_char_val !== null
506+
) {
507+
this.checkParamHeader(
508+
req,
509+
'ControlChar',
510+
args.control_char_val,
511+
'string'
512+
);
513+
}
514+
515+
// Check that 'query' (no x-mcp-header) is NOT mirrored
516+
const queryHeader = req.headers['mcp-param-query'] as string | undefined;
517+
if (queryHeader !== undefined) {
518+
this.checks.push({
519+
id: 'client-custom-header-no-mirror-unannotated',
520+
name: 'ClientCustomHeaderNoMirrorUnannotated',
521+
description:
522+
'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header',
523+
status: 'FAILURE',
524+
timestamp: new Date().toISOString(),
525+
errorMessage: `Found unexpected Mcp-Param-Query header '${queryHeader}' for unannotated parameter`,
526+
specReferences: [SPEC_REFERENCE_CUSTOM]
527+
});
528+
}
529+
} else if (toolName === 'test_custom_headers_null') {
530+
this.nullToolCallReceived = true;
373531

374-
// Explicit check: optional parameter present → client MUST include header
375-
const verboseHeader = req.headers['mcp-param-verbose'] as
376-
| string
377-
| undefined;
378-
this.checks.push({
379-
id: 'client-custom-header-optional-present',
380-
name: 'ClientCustomHeaderOptionalPresent',
381-
description:
382-
'Client MUST include Mcp-Param header when optional parameter is provided',
383-
status: verboseHeader !== undefined ? 'SUCCESS' : 'FAILURE',
384-
timestamp: new Date().toISOString(),
385-
errorMessage:
386-
verboseHeader === undefined
387-
? `Optional parameter 'verbose' was provided with value '${args.verbose}' but Mcp-Param-Verbose header is missing. Client MUST include the header when the parameter is present.`
388-
: undefined,
389-
specReferences: [SPEC_REFERENCE_CUSTOM],
390-
details: {
391-
parameter: 'verbose',
392-
bodyValue: args.verbose,
393-
headerPresent: verboseHeader !== undefined
394-
}
395-
});
396-
} else {
397532
// When value is null or not provided, client MUST omit the header
398-
const headerValue = req.headers['mcp-param-verbose'] as
533+
const verboseHeader = req.headers['mcp-param-verbose'] as
399534
| string
400535
| undefined;
401536
this.checks.push({
402537
id: 'client-custom-header-omit-null',
403538
name: 'ClientCustomHeaderOmitNull',
404539
description:
405540
'Client MUST omit Mcp-Param header when parameter value is null or not provided',
406-
status: headerValue === undefined ? 'SUCCESS' : 'FAILURE',
541+
status: verboseHeader === undefined ? 'SUCCESS' : 'FAILURE',
407542
timestamp: new Date().toISOString(),
408543
errorMessage:
409-
headerValue !== undefined
410-
? `Mcp-Param-Verbose should be omitted when null/undefined, but got '${headerValue}'`
544+
verboseHeader !== undefined
545+
? `Mcp-Param-Verbose should be omitted when null/undefined, but got '${verboseHeader}'`
411546
: undefined,
412547
specReferences: [SPEC_REFERENCE_CUSTOM]
413548
});
414549
}
415550

416-
// Check Mcp-Param-EmptyVal header (empty string → empty header value)
417-
if (args.empty_val !== undefined && args.empty_val !== null) {
418-
this.checkParamHeader(req, 'EmptyVal', args.empty_val, 'string');
419-
}
420-
421-
// Check Mcp-Param-Method header (x-mcp-header "Method" → Mcp-Param-Method, NOT Mcp-Method)
422-
if (args.method_val !== undefined && args.method_val !== null) {
423-
this.checkParamHeader(req, 'Method', args.method_val, 'string');
424-
}
425-
426-
// Check Mcp-Param-FloatVal header (floating point number)
427-
if (args.float_val !== undefined && args.float_val !== null) {
428-
this.checkParamHeader(req, 'FloatVal', args.float_val, 'number');
429-
}
430-
431-
// Check that 'query' (no x-mcp-header) is NOT mirrored
432-
const queryHeader = req.headers['mcp-param-query'] as string | undefined;
433-
if (queryHeader !== undefined) {
434-
this.checks.push({
435-
id: 'client-custom-header-no-mirror-unannotated',
436-
name: 'ClientCustomHeaderNoMirrorUnannotated',
437-
description:
438-
'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header',
439-
status: 'FAILURE',
440-
timestamp: new Date().toISOString(),
441-
errorMessage: `Found unexpected Mcp-Param-Query header '${queryHeader}' for unannotated parameter`,
442-
specReferences: [SPEC_REFERENCE_CUSTOM]
443-
});
444-
}
445-
446551
this.sendJson(res, {
447552
jsonrpc: '2.0',
448553
id: request.id,
@@ -540,6 +645,20 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario {
540645
});
541646
}
542647

648+
// Check that valid_tool WAS called — proves client kept valid tools
649+
const validToolCalled = this.calledTools.has('valid_tool');
650+
this.checks.push({
651+
id: 'client-keeps-valid-tool',
652+
name: 'ClientKeepsValidTool',
653+
description: 'Client MUST keep valid tools while excluding invalid ones',
654+
status: validToolCalled ? 'SUCCESS' : 'FAILURE',
655+
timestamp: new Date().toISOString(),
656+
errorMessage: validToolCalled
657+
? undefined
658+
: "Client did not call 'valid_tool'. A single malformed tool definition must not prevent other valid tools from being used.",
659+
specReferences: [SPEC_REFERENCE_TOOL_DEF]
660+
});
661+
543662
// Check that the client did NOT call any of the invalid tools
544663
const invalidTools = [
545664
'invalid_empty_header',

0 commit comments

Comments
 (0)