fix(server): trim OWS from standard MCP headers (#2453)
This commit is contained in:
parent
7e697354de
commit
0ab5d1471d
3 changed files with 85 additions and 3 deletions
6
.changeset/standard-header-ows.md
Normal file
6
.changeset/standard-header-ows.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@modelcontextprotocol/server': patch
|
||||
'@modelcontextprotocol/core-internal': patch
|
||||
---
|
||||
|
||||
Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`.
|
||||
|
|
@ -464,6 +464,25 @@ export const MCP_NAME_HEADER_SOURCE: Readonly<Record<string, 'name' | 'uri'>> =
|
|||
'resources/read': 'uri'
|
||||
};
|
||||
|
||||
/** Strip RFC 9110 optional whitespace (SP / HTAB) around a field value in linear time. */
|
||||
function stripHttpOws(value: string): string {
|
||||
let start = 0;
|
||||
while (start < value.length) {
|
||||
const code = value.codePointAt(start);
|
||||
if (code !== 0x09 && code !== 0x20) break;
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let end = value.length;
|
||||
while (end > start) {
|
||||
const code = value.codePointAt(end - 1);
|
||||
if (code !== 0x09 && code !== 0x20) break;
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
return start === 0 && end === value.length ? value : value.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEP-2243 standard-header server-side validation, evaluated by the HTTP
|
||||
* entry on a modern-classified request immediately after
|
||||
|
|
@ -537,11 +556,12 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
|
|||
);
|
||||
}
|
||||
|
||||
const decoded = decodeMcpParamValue(request.mcpNameHeader);
|
||||
const normalizedNameHeader = stripHttpOws(request.mcpNameHeader);
|
||||
const decoded = decodeMcpParamValue(normalizedNameHeader);
|
||||
if (decoded === undefined) {
|
||||
return crossCheckMismatch(
|
||||
'name-header-invalid-encoding',
|
||||
request.mcpNameHeader,
|
||||
normalizedNameHeader,
|
||||
'the Mcp-Name header carries an invalid Base64 sentinel value',
|
||||
'standard-header-validation'
|
||||
);
|
||||
|
|
@ -549,7 +569,7 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
|
|||
if (bodyValue !== undefined && decoded !== bodyValue) {
|
||||
return crossCheckMismatch(
|
||||
'name-header-mismatch',
|
||||
request.mcpNameHeader,
|
||||
normalizedNameHeader,
|
||||
`the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`,
|
||||
'standard-header-validation'
|
||||
);
|
||||
|
|
@ -818,6 +838,18 @@ function classifyNotificationBody(request: InboundHttpRequest, body: JSONRPCNoti
|
|||
* `modern`) or a ladder rejection; it never throws.
|
||||
*/
|
||||
export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome {
|
||||
// RFC 9110 §5.5: field parsing excludes optional whitespace around a
|
||||
// field value. Fetch implementations normally perform this normalization,
|
||||
// but transport-neutral callers and some runtimes can expose raw OWS.
|
||||
request = {
|
||||
...request,
|
||||
...(request.protocolVersionHeader !== undefined && {
|
||||
protocolVersionHeader: stripHttpOws(request.protocolVersionHeader)
|
||||
}),
|
||||
...(request.mcpMethodHeader !== undefined && { mcpMethodHeader: stripHttpOws(request.mcpMethodHeader) }),
|
||||
...(request.mcpNameHeader !== undefined && { mcpNameHeader: stripHttpOws(request.mcpNameHeader) })
|
||||
};
|
||||
|
||||
if (request.httpMethod.toUpperCase() !== 'POST') {
|
||||
// Body-less 2025-era session operations (and any other non-POST
|
||||
// method): the modern era is POST-only.
|
||||
|
|
|
|||
|
|
@ -157,6 +157,50 @@ describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check
|
|||
expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding');
|
||||
});
|
||||
|
||||
test('raw HTTP OWS is stripped from standard MCP header values', () => {
|
||||
const { request } = modernPost(
|
||||
'tools/call',
|
||||
{ name: 'echo', arguments: {} },
|
||||
{ mcpMethod: '\t tools/call ', mcpName: ' echo \t' }
|
||||
);
|
||||
request.protocolVersionHeader = ` ${MODERN}\t`;
|
||||
const classified = classifyInboundRequest(request);
|
||||
expect(classified.kind).toBe('modern');
|
||||
if (classified.kind !== 'modern') {
|
||||
throw new Error(`expected a modern route, got ${classified.kind}`);
|
||||
}
|
||||
expect(validateStandardRequestHeaders(request, classified)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('long OWS runs are stripped without changing an encoded name', () => {
|
||||
const ows = '\t '.repeat(50_000);
|
||||
const name = ' echo ';
|
||||
const { request } = modernPost(
|
||||
'tools/call',
|
||||
{ name, arguments: {} },
|
||||
{
|
||||
mcpMethod: `${ows}tools/call${ows}`,
|
||||
mcpName: `${ows}${encodeMcpParamValue(name)}${ows}`
|
||||
}
|
||||
);
|
||||
request.protocolVersionHeader = `${ows}${MODERN}${ows}`;
|
||||
|
||||
const classified = classifyInboundRequest(request);
|
||||
expect(classified.kind).toBe('modern');
|
||||
if (classified.kind !== 'modern') {
|
||||
throw new Error(`expected a modern route, got ${classified.kind}`);
|
||||
}
|
||||
expect(validateStandardRequestHeaders(request, classified)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('whitespace outside RFC 9110 OWS is not stripped', () => {
|
||||
const { request } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call', mcpName: 'echo' });
|
||||
request.mcpMethodHeader = '\u00a0tools/call';
|
||||
const classified = classifyInboundRequest(request);
|
||||
expect(classified.kind).toBe('reject');
|
||||
expect((classified as InboundLadderRejection).cell).toBe('method-header-mismatch');
|
||||
});
|
||||
|
||||
test('a matching Mcp-Name on a prompts/get passes', () => {
|
||||
const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' });
|
||||
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();
|
||||
|
|
|
|||
Loading…
Reference in a new issue