Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/scenarios/client/http-standard-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
): Promise<void> {
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());
Expand Down
116 changes: 108 additions & 8 deletions src/scenarios/client/http-standard-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -29,19 +41,21 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario {
private methodHeaderChecks = new Map<string, boolean>();
// Track which Mcp-Name checks have been recorded
private nameHeaderChecks = new Map<string, boolean>();
// 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',
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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;

Expand Down Expand Up @@ -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: {} };
}
Expand Down Expand Up @@ -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: []
}
}
]
}
Expand Down
30 changes: 30 additions & 0 deletions src/scenarios/server/http-standard-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/seps/sep-2243.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading