diff --git a/src/scenarios/server/http-standard-headers.test.ts b/src/scenarios/server/http-standard-headers.test.ts index 056d3b38..d7b9d3be 100644 --- a/src/scenarios/server/http-standard-headers.test.ts +++ b/src/scenarios/server/http-standard-headers.test.ts @@ -1,11 +1,21 @@ import { describe, test, expect, afterEach } from 'vitest'; +import type { AddressInfo } from 'net'; +import http from 'http'; +import net from 'net'; import { testContext } from '../../connection/testing'; +import { withRequestMeta } from '../../connection'; +import { + takeWireViolations, + withWireRecorder, + wireSchemaChecks +} from '../../validation/wire-schema'; import { HttpHeaderValidationScenario, HttpCustomHeaderServerValidationScenario, - CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS + CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS, + sendRawRequest } from './http-standard-headers'; -import type { ConformanceCheck } from '../../types'; +import { DRAFT_PROTOCOL_VERSION, type ConformanceCheck } from '../../types'; /** * Pins the untestable-failure policy (issue #248) for the SEP-2243 server @@ -19,11 +29,11 @@ afterEach(() => { }); function mockFetchTarget( - handler: (reqBody: any, reqHeaders: Record) => any + handler: (reqBody: any, reqHeaders: HeadersInit) => any ) { - global.fetch = (async (_url: any, init: any) => { - const body = JSON.parse(init.body); - const headers = init.headers || {}; + global.fetch = async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(init?.body?.toString() ?? ''); + const headers = init?.headers || {}; const responseConfig = (await handler(body, headers)) ?? { status: 404, body: { @@ -33,13 +43,11 @@ function mockFetchTarget( } }; const text = JSON.stringify(responseConfig.body); - return { + return new Response(text, { status: responseConfig.status ?? 200, - headers: { get: () => 'application/json' }, - json: async () => responseConfig.body, - text: async () => text - } as unknown as Response; - }) as typeof fetch; + headers: { 'content-type': 'application/json' } + }); + }; return 'http://mock-sep2243-server.local'; } @@ -64,7 +72,10 @@ describe('http-custom-header-server-validation — missing fixture policy', () = name: 'plain_tool', inputSchema: { type: 'object', - properties: { q: { type: 'string' } } + properties: { + enabled: true, + q: { type: ['string', 'null'] } + } } } ] @@ -75,7 +86,9 @@ describe('http-custom-header-server-validation — missing fixture policy', () = }); const scenario = new HttpCustomHeaderServerValidationScenario(); - const checks = await scenario.run(testContext(mockUrl)); + const checks = await scenario.run( + testContext(mockUrl, DRAFT_PROTOCOL_VERSION) + ); const gate = findAll(checks, 'sep-2243-server-no-xmcp-tool')[0]; expect(gate?.status).toBe('FAILURE'); @@ -158,8 +171,9 @@ describe('http-header-validation — zero-tools Mcp-Name cases', () => { }); }); await new Promise((resolve) => server.listen(0, resolve)); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; + // SAFETY: server.listen(0) resolves after the server has a TCP address. + const address = server.address() as AddressInfo; + const port = address.port; return { url: `http://localhost:${port}/mcp`, close: () => @@ -253,3 +267,794 @@ describe('http-header-validation — zero-tools Mcp-Name cases', () => { expect(whitespace?.errorMessage).toContain('Not testable:'); }); }); + +interface RawFixtureRequest { + id?: number | string | null; + method?: string; +} + +interface RawFixtureInput { + body: RawFixtureRequest; + rawHeaders: string[]; +} + +interface RawFixtureReply { + status: number; + body?: RawResponse | RawErrorResponse | RawInvalidEnvelopeResponse; + contentType?: string; + rawBody?: string; + sseChunks?: string[]; + keepOpen?: boolean; +} + +type RawResponse = + | { + jsonrpc: '2.0'; + id: number | string | null; + result: { + tools: [] | string; + resultType: 'complete'; + ttlMs: 0; + cacheScope: 'private'; + }; + } + | { + jsonrpc: '2.0'; + id: number | string | null; + error: { code: -32020; message: string }; + }; + +interface RawErrorResponse { + jsonrpc: '2.0'; + error: { code: -32020; message: string }; +} + +interface RawInvalidEnvelopeResponse { + jsonrpc: '1.0'; + id: number | string | null; + result: { + tools: []; + resultType: 'complete'; + ttlMs: 0; + cacheScope: 'private'; + }; +} + +interface RawFixture { + url: string; + responseClosed: Promise; + close: () => Promise; +} + +interface RawSocketFixture { + url: string; + requestBytes: Promise; + close: () => Promise; +} + +function validToolsListResponse( + id: number | string | null | undefined, + tools: [] | string = [] +): RawResponse { + return { + jsonrpc: '2.0', + id: id ?? null, + result: { + tools, + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + } + }; +} + +function errorResponse(id: number | string | null): RawResponse { + return { + jsonrpc: '2.0', + id, + error: { code: -32020, message: 'Header mismatch' } + }; +} + +function errorWithoutId(): RawErrorResponse { + return { + jsonrpc: '2.0', + error: { code: -32020, message: 'Header mismatch' } + }; +} + +async function startRawFixture( + handler: ( + input: RawFixtureInput + ) => RawFixtureReply | Promise +): Promise { + let resolveResponseClosed: () => void = () => {}; + const responseClosed = new Promise((resolve) => { + resolveResponseClosed = resolve; + }); + const server = http.createServer((req, res) => { + res.once('close', resolveResponseClosed); + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + raw += chunk; + }); + req.on('end', async () => { + // SAFETY: every request in these fixtures is a JSON-RPC object. + const body = JSON.parse(raw) as RawFixtureRequest; + const reply = await handler({ body, rawHeaders: req.rawHeaders }); + if (reply.sseChunks) { + res.writeHead(reply.status, { 'Content-Type': 'text/event-stream' }); + for (const chunk of reply.sseChunks) { + res.write(chunk); + await new Promise((resolve) => setImmediate(resolve)); + } + if (!reply.keepOpen) res.end(); + return; + } + res.writeHead(reply.status, { + 'Content-Type': reply.contentType ?? 'application/json' + }); + res.end(reply.rawBody ?? JSON.stringify(reply.body)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + // SAFETY: server.listen(0) resolves after the server has a TCP address. + const address = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${address.port}/mcp`, + responseClosed, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + }; +} + +async function waitForBounded( + signal: Promise, + timeoutMessage: string +): Promise { + let timeoutHandle: ReturnType | undefined; + try { + await Promise.race([ + signal, + new Promise((_resolve, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error(timeoutMessage)), + 1000 + ); + }) + ]); + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + } +} + +const waitForResponseClosed = (responseClosed: Promise) => + waitForBounded(responseClosed, 'fixture response did not close'); + +async function startRawSocketFixture(): Promise { + let resolveRequestBytes: (value: string) => void = () => {}; + const requestBytes = new Promise((resolve) => { + resolveRequestBytes = resolve; + }); + const server = net.createServer((socket) => { + let raw = ''; + let replied = false; + socket.on('data', (chunk: Buffer) => { + raw += chunk.toString('utf8'); + const separator = raw.indexOf('\r\n\r\n'); + if (separator < 0 || replied) return; + const headerBlock = raw.slice(0, separator); + const lengthMatch = headerBlock.match(/\r\ncontent-length:\s*(\d+)/i); + const bodyLength = lengthMatch ? Number(lengthMatch[1]) : 0; + const bodyStart = separator + 4; + if (Buffer.byteLength(raw.slice(bodyStart), 'utf8') < bodyLength) return; + replied = true; + resolveRequestBytes(raw); + const responseBody = JSON.stringify(validToolsListResponse('socket')); + socket.end( + 'HTTP/1.1 200 OK\r\n' + + 'Content-Type: application/json\r\n' + + `Content-Length: ${Buffer.byteLength(responseBody)}\r\n` + + 'Connection: close\r\n\r\n' + + responseBody + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + // SAFETY: server.listen(0) resolves after the server has a TCP address. + const address = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${address.port}/mcp`, + requestBytes, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }) + }; +} + +describe('sendRawRequest wire observation', () => { + test('records valid JSON request and response as two observations', async () => { + const fixture = await startRawFixture(({ body }) => ({ + status: 200, + body: validToolsListResponse(body.id) + })); + try { + const result = await withWireRecorder(async () => { + const response = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'json-valid', + method: 'tools/list', + params: withRequestMeta({}) + } + ); + return { response, wire: takeWireViolations() }; + }); + + expect(result.response.status).toBe(200); + expect(result.wire.observed).toBe(2); + expect(result.wire.violations).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test('records harness and implementation violations in separate scopes', async () => { + let signalSecondSeen: () => void = () => {}; + const secondSeen = new Promise((resolve) => { + signalSecondSeen = resolve; + }); + let releaseSecond: () => void = () => {}; + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const fixture = await startRawFixture(async ({ body }) => { + if (body.id === 'invalid-response') { + signalSecondSeen(); + await secondGate; + } + return { + status: 200, + body: + body.id === 'invalid-request' + ? validToolsListResponse(body.id) + : validToolsListResponse(body.id, 'invalid-tools') + }; + }); + const implementationPromise = withWireRecorder(async () => { + await sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'invalid-response', + method: 'tools/list', + params: withRequestMeta({}) + }); + return takeWireViolations(); + }); + try { + await waitForBounded(secondSeen, 'second fixture response did not start'); + // Drain the first scope while the second scope has an observed request + // and is still waiting for its response. + const harness = await withWireRecorder(async () => { + await sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'invalid-request', + method: 'tools/list', + params: 'invalid' + }); + return takeWireViolations(); + }); + releaseSecond(); + const implementation = await implementationPromise; + + expect(harness.observed).toBe(2); + expect(harness.violations).toHaveLength(1); + expect(harness.violations[0]?.origin).toBe('harness'); + expect(implementation.observed).toBe(2); + expect(implementation.violations).toHaveLength(1); + expect(implementation.violations[0]?.origin).toBe('implementation'); + } finally { + releaseSecond(); + await implementationPromise.catch(() => undefined); + await fixture.close(); + } + }); + + test('rejects a JSON response with a different request ID', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + body: validToolsListResponse('other-id') + })); + try { + const result = await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'expected-id', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('did not contain the response'); + return takeWireViolations(); + }); + + expect(result.observed).toBe(2); + expect(result.violations).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test('keeps an error response without an ID observable', async () => { + const fixture = await startRawFixture(() => ({ + status: 400, + body: errorWithoutId() + })); + try { + const result = await withWireRecorder(async () => { + const response = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'error-without-id', + method: 'tools/list', + params: withRequestMeta({}) + } + ); + return { response, wire: takeWireViolations() }; + }); + + expect(result.response.status).toBe(400); + expect(result.response.body).toMatchObject({ error: { code: -32020 } }); + expect(result.wire.observed).toBe(2); + expect(result.wire.violations).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test('preserves an empty rejected body without fabricating JSON-RPC', async () => { + const fixture = await startRawFixture(() => ({ + status: 400, + contentType: 'application/json', + rawBody: '' + })); + try { + const result = await withWireRecorder(async () => { + const response = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'empty-rejection', + method: 'tools/list', + params: withRequestMeta({}) + } + ); + return { response, wire: takeWireViolations() }; + }); + + expect(result.response.status).toBe(400); + expect(result.response.body).toBeUndefined(); + expect(result.wire.observed).toBe(1); + expect(result.wire.violations).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test('reports non-JSON successful bodies as an observation failure', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + contentType: 'text/plain', + rawBody: 'not-json' + })); + try { + const wire = await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'plain-success', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('did not contain'); + return takeWireViolations(); + }); + expect(wire.observed).toBe(1); + expect(wire.violations).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test.each([ + ['', 'before the response'], + ['{', 'malformed JSON'] + ])( + 'reports invalid successful JSON body %j as an observation failure', + async (rawBody, reason) => { + const fixture = await startRawFixture(() => ({ + status: 200, + contentType: 'application/json', + rawBody + })); + try { + const wire = await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'malformed-json', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow(reason); + return takeWireViolations(); + }); + expect(wire.observed).toBe(1); + expect(wire.violations).toHaveLength(0); + } finally { + await fixture.close(); + } + } + ); + + test('records an invalid JSON-RPC envelope without rejecting the response', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + body: { + jsonrpc: '1.0', + id: 'invalid-envelope', + result: { + tools: [], + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + } + } + })); + try { + const result = await withWireRecorder(async () => { + const response = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'invalid-envelope', + method: 'tools/list', + params: withRequestMeta({}) + } + ); + return { response, wire: takeWireViolations() }; + }); + + expect(result.response.body).toMatchObject({ + jsonrpc: '1.0', + id: 'invalid-envelope' + }); + expect(result.wire.observed).toBe(2); + expect(result.wire.violations).toHaveLength(1); + expect(result.wire.violations[0]).toMatchObject({ + origin: 'implementation', + context: 'raw HTTP response', + message: { jsonrpc: '1.0', id: 'invalid-envelope' } + }); + } finally { + await fixture.close(); + } + }); + + test('distinguishes numeric and string response IDs', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + body: validToolsListResponse('7') + })); + try { + await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 7, + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('did not contain the response'); + takeWireViolations(); + }); + } finally { + await fixture.close(); + } + }); + + test('parses split SSE frames and returns after the matching response', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + keepOpen: true, + sseChunks: [ + ': comment\nretry: 50\nid: prime\ndata:\n\n', + 'id: event-1\ndata: {"jsonrpc":"2.0","id":"sse-valid",', + '"result":{"tools":[],"resultType":"complete","ttlMs":0,"cacheScope":"private"}}\n\n' + ] + })); + try { + const result = await withWireRecorder(async () => { + const response = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'sse-valid', + method: 'tools/list', + params: withRequestMeta({}) + } + ); + return { response, wire: takeWireViolations() }; + }); + + expect(result.response.status).toBe(200); + expect(result.response.body).toMatchObject({ id: 'sse-valid' }); + expect(result.wire.observed).toBe(2); + expect(result.wire.violations).toHaveLength(0); + await waitForResponseClosed(fixture.responseClosed); + } finally { + await fixture.close(); + } + }); + + test('records an invalid SSE method result without rejecting the response', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + keepOpen: true, + sseChunks: [ + 'data: {"jsonrpc":"2.0","id":"sse-invalid","result":{"tools":"invalid-tools","resultType":"complete","ttlMs":0,"cacheScope":"private"}}\n\n' + ] + })); + try { + const result = await withWireRecorder(async () => { + const response = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'sse-invalid', + method: 'tools/list', + params: withRequestMeta({}) + } + ); + return { response, wire: takeWireViolations() }; + }); + + expect(result.response.body).toMatchObject({ id: 'sse-invalid' }); + expect(result.wire.observed).toBe(2); + expect(result.wire.violations).toHaveLength(1); + expect(result.wire.violations[0]?.origin).toBe('implementation'); + } finally { + await fixture.close(); + } + }); + + test('fails a closed SSE stream without a final response', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + sseChunks: [] + })); + try { + const wire = await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'sse-missing', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('before the response'); + return takeWireViolations(); + }); + expect(wire.observed).toBe(1); + await waitForResponseClosed(fixture.responseClosed); + } finally { + await fixture.close(); + } + }); + + test('cleans up an SSE stream when the observation deadline expires', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + keepOpen: true, + sseChunks: [': waiting\n\n'] + })); + try { + const wire = await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'sse-timeout', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('observation deadline'); + return takeWireViolations(); + }); + expect(wire.observed).toBe(1); + await waitForResponseClosed(fixture.responseClosed); + } finally { + await fixture.close(); + } + }, 15000); + + test('stops after the bounded SSE event count', async () => { + const chunks = Array.from( + { length: 101 }, + (_value, index) => + `data: {"jsonrpc":"2.0","id":"other-${index}","result":{"tools":[],"resultType":"complete","ttlMs":0,"cacheScope":"private"}}\n\n` + ); + const fixture = await startRawFixture(() => ({ + status: 200, + sseChunks: chunks + })); + try { + await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'sse-bounded-events', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('observation limit'); + takeWireViolations(); + }); + } finally { + await fixture.close(); + } + }); + + test('stops after the bounded SSE byte count', async () => { + const fixture = await startRawFixture(() => ({ + status: 200, + sseChunks: [`data: ${'x'.repeat(1024 * 1024)}\n\n`] + })); + try { + await withWireRecorder(async () => { + await expect( + sendRawRequest(fixture.url, DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 'sse-bounded-bytes', + method: 'tools/list', + params: withRequestMeta({}) + }) + ).rejects.toThrow('observation limit'); + takeWireViolations(); + }); + } finally { + await fixture.close(); + } + }); +}); + +describe('http-header-validation — raw response observation regression', () => { + test('reports only the malformed lowercase-header response', async () => { + let requestCount = 0; + let lowercaseRequestId: RawFixtureRequest['id']; + const capturedHeaderNames: string[][] = []; + const fixture = await startRawFixture(({ body, rawHeaders }) => { + capturedHeaderNames.push( + rawHeaders.filter((_value, index) => index % 2 === 0) + ); + const isSetup = requestCount === 0; + requestCount += 1; + if (isSetup) { + return { status: 200, body: validToolsListResponse(body.id) }; + } + + const methodHeaderIndex = rawHeaders.findIndex( + (value, index) => + index % 2 === 0 && value.toLowerCase() === 'mcp-method' + ); + const methodHeader = + methodHeaderIndex >= 0 ? rawHeaders[methodHeaderIndex + 1] : undefined; + if (methodHeader !== body.method) { + return { status: 400, body: errorResponse(body.id ?? null) }; + } + if ( + methodHeader === 'tools/list' && + rawHeaders[methodHeaderIndex] === 'mcp-method' + ) { + lowercaseRequestId = body.id; + return { + status: 200, + body: validToolsListResponse(body.id, 'invalid-tools') + }; + } + return { status: 200, body: validToolsListResponse(body.id) }; + }); + + try { + const result = await withWireRecorder(async () => { + const checks = await new HttpHeaderValidationScenario().run( + testContext(fixture.url, DRAFT_PROTOCOL_VERSION) + ); + return { checks, wire: wireSchemaChecks(DRAFT_PROTOCOL_VERSION) }; + }); + + const wireCheck = result.wire.find( + (check) => check.id === 'wire-schema-valid' + ); + expect(wireCheck?.status).toBe('FAILURE'); + expect(wireCheck?.errorMessage).toContain('tools/list'); + expect(lowercaseRequestId).toBeDefined(); + expect(requestCount).toBe(6); + expect(wireCheck?.details?.violations).toHaveLength(1); + expect(wireCheck?.details).toMatchObject({ + messagesValidated: 12, + violations: [ + { + origin: 'implementation', + context: 'raw HTTP response', + message: { + id: lowercaseRequestId, + result: { tools: 'invalid-tools' } + } + } + ] + }); + expect( + result.wire.some((check) => check.id === 'wire-schema-harness-error') + ).toBe(false); + expect( + result.checks.find( + (check) => check.name === 'ServerAcceptsLowercaseHeaderName' + )?.status + ).toBe('SUCCESS'); + expect( + result.checks.find( + (check) => check.name === 'ServerAcceptsUppercaseHeaderName' + )?.status + ).toBe('SUCCESS'); + expect( + capturedHeaderNames.some((headers) => headers.includes('mcp-method')) + ).toBe(true); + expect( + capturedHeaderNames.some((headers) => headers.includes('MCP-METHOD')) + ).toBe(true); + } finally { + await fixture.close(); + } + }); +}); + +describe('sendRawRequest exact header bytes', () => { + test('preserves leading and trailing header whitespace on the socket', async () => { + const fixture = await startRawSocketFixture(); + try { + const response = await withWireRecorder(async () => { + const result = await sendRawRequest( + fixture.url, + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 'socket', + method: 'tools/list', + params: withRequestMeta({}) + }, + { 'Mcp-Name': ' edge-value ' } + ); + takeWireViolations(); + return result; + }); + const requestBytes = await fixture.requestBytes; + + expect(response.status).toBe(200); + expect(requestBytes).toContain('Mcp-Name: edge-value \r\n'); + } finally { + await fixture.close(); + } + }); +}); diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index d5ca0320..5cfeec84 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -15,10 +15,13 @@ */ import http from 'http'; +import { createParser } from 'eventsource-parser'; +import { z } from 'zod'; import { ClientScenario, ConformanceCheck, - DRAFT_PROTOCOL_VERSION + DRAFT_PROTOCOL_VERSION, + type SpecVersion } from '../../types'; import { withRequestMeta, @@ -26,6 +29,7 @@ import { type RunContext } from '../../connection'; import { HEADER_MISMATCH } from '../../spec-types/draft'; +import { validateWireMessage } from '../../validation/wire-schema'; import { untestableCheck } from '../untestable'; const SPEC_REFERENCE = { @@ -131,15 +135,116 @@ function untestableMcpNameCases(checks: ConformanceCheck[], reason: string) { * Uses Node.js http.request to preserve exact header casing and values, * avoiding normalization that fetch()/Headers may apply. */ -async function sendRawRequest( +interface RawJsonRpcRequest { + jsonrpc: string; + id: string | number; + method: string; + params?: any; +} + +interface RawHttpResponse { + status: number; + body: any; + headers: http.IncomingHttpHeaders; +} + +interface HeaderCheckDetails { + requestBodyMethod?: string; + mcpMethodHeader?: string; + requestBodyName?: string; + mcpNameHeader?: string; + headerNameUsed?: string; + headerValue?: string; + bodyValue?: string; + reason?: string; + toolName?: string; + paramName?: string; + headerSuffix?: string; + expectedHeader?: string; + mcpParamHeader?: string; +} + +const TOOL_INPUT_PROPERTY_SCHEMA = z + .object({ + type: z.string().optional(), + 'x-mcp-header': z.string().optional() + }) + .passthrough(); + +const TOOL_INPUT_SCHEMA = z + .object({ + properties: z.record(z.string(), z.json()).optional(), + required: z.array(z.string()).optional() + }) + .passthrough(); + +const TOOLS_RESULT_SCHEMA = z + .object({ + tools: z + .array( + z + .object({ + name: z.string(), + inputSchema: z.json().optional() + }) + .passthrough() + ) + .optional() + }) + .passthrough(); + +const RAW_REQUEST_TIMEOUT_MS = 10_000; +const MAX_SSE_EVENTS = 100; +const MAX_RESPONSE_BYTES = 1024 * 1024; + +function isFinalResponseForRequest( + message: any, + requestId: string | number +): boolean { + if (!message || Array.isArray(message)) return false; + const hasResult = Object.prototype.hasOwnProperty.call(message, 'result'); + const hasError = Object.prototype.hasOwnProperty.call(message, 'error'); + if (!hasResult && !hasError) return false; + return Object.prototype.hasOwnProperty.call(message, 'id') + ? message.id === requestId + : hasError; +} + +export async function sendRawRequest( serverUrl: string, - body: object, + specVersion: SpecVersion, + body: RawJsonRpcRequest, headers: Record = {} -): Promise<{ status: number; body: any; headers: http.IncomingHttpHeaders }> { +): Promise { const url = new URL(serverUrl); const bodyStr = JSON.stringify(body); + const serializedRequest = JSON.parse(bodyStr); + validateWireMessage(specVersion, serializedRequest, { + origin: 'harness', + context: 'raw HTTP request' + }); return new Promise((resolve, reject) => { + let settled = false; + let response: http.IncomingMessage | undefined; + + const finish = (result: RawHttpResponse): void => { + if (settled) return; + settled = true; + clearTimeout(deadline); + response?.destroy(); + resolve(result); + }; + + const fail = (error: Error): void => { + if (settled) return; + settled = true; + clearTimeout(deadline); + response?.destroy(); + req.destroy(); + reject(error); + }; + const req = http.request( { hostname: url.hostname, @@ -154,33 +259,184 @@ async function sendRawRequest( } }, (res) => { + response = res; res.setEncoding('utf8'); + const status = res.statusCode ?? 0; + const responseHeaders = res.headers; + const contentType = responseHeaders['content-type']; + + if (contentType?.includes('text/event-stream')) { + let eventCount = 0; + let messageCount = 0; + let receivedBytes = 0; + const parser = createParser({ + onEvent(event) { + if (settled) return; + eventCount++; + if (eventCount > MAX_SSE_EVENTS) { + fail( + new Error( + `Raw SSE response exceeded the ${MAX_SSE_EVENTS}-event observation limit` + ) + ); + return; + } + if (event.data === '') return; + messageCount++; + + let message: any; + try { + message = JSON.parse(event.data); + } catch { + fail(new Error('Raw SSE response contained malformed JSON')); + return; + } + + const isFinal = isFinalResponseForRequest(message, body.id); + validateWireMessage(specVersion, message, { + origin: 'implementation', + context: 'raw HTTP SSE event', + requestMethod: isFinal ? body.method : undefined + }); + if (isFinal) { + finish({ + status, + body: message, + headers: responseHeaders + }); + } + } + }); + + res.on('data', (chunk: string) => { + if (settled) return; + receivedBytes += Buffer.byteLength(chunk); + if (receivedBytes > MAX_RESPONSE_BYTES) { + fail( + new Error( + `Raw SSE response exceeded the ${MAX_RESPONSE_BYTES}-byte observation limit` + ) + ); + return; + } + parser.feed(chunk); + }); + res.on('end', () => { + parser.reset({ consume: true }); + if (settled) return; + if (status >= 400 && messageCount === 0) { + finish({ + status, + body: undefined, + headers: responseHeaders + }); + return; + } + fail( + new Error( + `Raw SSE response ended before the response to '${body.method}' was observed` + ) + ); + }); + res.on('error', fail); + return; + } + let data = ''; - res.on('data', (chunk) => { + let receivedBytes = 0; + res.on('data', (chunk: string) => { + if (settled) return; + receivedBytes += Buffer.byteLength(chunk); + if (receivedBytes > MAX_RESPONSE_BYTES) { + fail( + new Error( + `Raw HTTP response exceeded the ${MAX_RESPONSE_BYTES}-byte observation limit` + ) + ); + return; + } data += chunk; }); res.on('end', () => { + if (settled) return; let responseBody: any; - const contentType = res.headers['content-type']; if (contentType?.includes('application/json')) { + if (!data) { + if (status >= 400) { + finish({ + status, + body: undefined, + headers: responseHeaders + }); + return; + } + fail( + new Error( + `Raw HTTP response ended before the response to '${body.method}' was observed` + ) + ); + return; + } try { responseBody = JSON.parse(data); } catch { - responseBody = data; + fail(new Error('Raw HTTP response contained malformed JSON')); + return; + } + const isFinal = isFinalResponseForRequest(responseBody, body.id); + validateWireMessage(specVersion, responseBody, { + origin: 'implementation', + context: 'raw HTTP response', + requestMethod: isFinal ? body.method : undefined + }); + if (!isFinal) { + fail( + new Error( + `Raw HTTP response did not contain the response to '${body.method}'` + ) + ); + return; } } else { responseBody = data; + if (status < 400) { + fail( + new Error( + `Raw HTTP response did not contain a JSON-RPC response to '${body.method}'` + ) + ); + return; + } + } + if (status < 400 && !responseBody) { + fail( + new Error( + `Raw HTTP response ended before the response to '${body.method}' was observed` + ) + ); + return; } - resolve({ - status: res.statusCode || 0, + finish({ + status, body: responseBody, - headers: res.headers + headers: responseHeaders }); }); + res.on('error', fail); } ); - req.on('error', reject); + const deadline = setTimeout(() => { + fail( + new Error( + `Raw HTTP request exceeded the ${RAW_REQUEST_TIMEOUT_MS}ms observation deadline` + ) + ); + }, RAW_REQUEST_TIMEOUT_MS); + + req.on('error', (error) => { + fail(error); + }); req.write(bodyStr); req.end(); }); @@ -207,7 +463,7 @@ function createRejectionChecks( description: string, response: { status: number; body: any }, specRef: { id: string; url: string }, - details: Record, + details: HeaderCheckDetails, opts: { errorCodeSeverity: 'FAILURE' | 'WARNING' } ): ConformanceCheck[] { const fullDetails = { @@ -254,7 +510,7 @@ function createAcceptanceCheck( description: string, response: { status: number; body: any }, specRef: { id: string; url: string }, - details: Record + details: HeaderCheckDetails ): ConformanceCheck { const errors: string[] = []; if (response.status >= 400) { @@ -264,11 +520,7 @@ function createAcceptanceCheck( } // A server can return HTTP 200 with a JSON-RPC error in the body. Without // this assertion that case would pass as "accepted". - if ( - response.body && - typeof response.body === 'object' && - 'error' in response.body - ) { + if (response.body?.error !== undefined) { errors.push( `Expected successful response, but body contains JSON-RPC error ${JSON.stringify(response.body.error)}.` ); @@ -314,7 +566,12 @@ export class HttpHeaderValidationScenario implements ClientScenario { try { // Discover the server's tools with a fully-conformant stateless request // (SEP-2575) — that wire protocol has no initialize handshake or sessions. - const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list'); + const toolsResponse = await sendStatelessRequest( + serverUrl, + 'tools/list', + undefined, + { specVersion: ctx.specVersion } + ); if (!toolsResponse.body?.result) { // The server under test could not even answer a conformant tools/list: // report a single explicit setup failure instead of misleading @@ -340,12 +597,16 @@ export class HttpHeaderValidationScenario implements ClientScenario { ); return checks; } - const toolsResult = toolsResponse.body.result as { - tools?: Array<{ name: string; inputSchema?: unknown }>; - }; + const parsedToolsResult = TOOLS_RESULT_SCHEMA.safeParse( + toolsResponse.body.result + ); + if (!parsedToolsResult.success) { + throw new Error('tools/list returned malformed tool descriptors'); + } + const toolsResult = parsedToolsResult.data; - const baseHeaders: Record = { - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + const baseHeaders = { + 'MCP-Protocol-Version': ctx.specVersion }; let idCounter = 100; @@ -355,7 +616,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', @@ -370,7 +631,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', @@ -388,7 +649,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', @@ -410,7 +671,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'accept', @@ -439,7 +700,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', @@ -475,7 +736,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'accept', @@ -490,7 +751,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'accept', @@ -505,7 +766,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { await this.testCase( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', @@ -534,7 +795,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { private async testCase( checks: ConformanceCheck[], - serverUrl: string, + ctx: RunContext, baseHeaders: Record, nextId: () => number, expectation: 'accept' | 'reject', @@ -544,20 +805,26 @@ export class HttpHeaderValidationScenario implements ClientScenario { body: any, extraHeaders: Record, specRef: { id: string; url: string }, - details: Record + details: HeaderCheckDetails ): Promise { try { + const { serverUrl, specVersion } = ctx; // Issue #311: every raw request carries the SEP-2575 _meta fields — the // header-validation cases only mangle headers, never the body metadata. const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id, - params: withRequestMeta(body.params) + params: withRequestMeta(body.params, specVersion) }; - const response = await sendRawRequest(serverUrl, requestBody, { - ...baseHeaders, - ...extraHeaders - }); + const response = await sendRawRequest( + serverUrl, + specVersion, + requestBody, + { + ...baseHeaders, + ...extraHeaders + } + ); if (expectation === 'reject') { // Standard-header rejection: 400 is MUST, -32020 is SHOULD. All // standard-header rejection cases collapse onto the coarse requirement @@ -622,7 +889,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario try { // Discover the server's tools with a fully-conformant stateless request // (SEP-2575) — that wire protocol has no initialize handshake or sessions. - const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list'); + const toolsResponse = await sendStatelessRequest( + serverUrl, + 'tools/list', + undefined, + { specVersion: ctx.specVersion } + ); if (!toolsResponse.body?.result) { // The server under test could not even answer a conformant tools/list: // report a single explicit setup failure (and backfill the declared @@ -646,20 +918,42 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario this.failDeclaredChecks(checks); return checks; } - const toolsResult = toolsResponse.body.result as { - tools?: Array<{ name: string; inputSchema?: unknown }>; - }; - - // Find a tool with x-mcp-header annotations - const xMcpTool = toolsResult.tools?.find((tool) => { - const schema = tool.inputSchema as any; - if (!schema?.properties) return false; - return Object.values(schema.properties).some( - (prop: any) => prop['x-mcp-header'] !== undefined - ); - }); + const parsedToolsResult = TOOLS_RESULT_SCHEMA.safeParse( + toolsResponse.body.result + ); + if (!parsedToolsResult.success) { + throw new Error('tools/list returned malformed tool descriptors'); + } + const toolsResult = parsedToolsResult.data; + + // Find a tool with x-mcp-header annotations. Parse only each candidate + // property because JSON Schema also permits boolean property schemas. + let xMcpToolName: string | undefined; + let xMcpInputSchema: z.output | undefined; + for (const tool of toolsResult.tools ?? []) { + const parsedInputSchema = TOOL_INPUT_SCHEMA.safeParse(tool.inputSchema); + if (!parsedInputSchema.success || !parsedInputSchema.data.properties) { + continue; + } + const hasHeaderProperty = Object.values( + parsedInputSchema.data.properties + ).some((propertyValue) => { + const parsedProperty = + TOOL_INPUT_PROPERTY_SCHEMA.safeParse(propertyValue); + return ( + parsedProperty.success && + parsedProperty.data['x-mcp-header'] !== undefined + ); + }); + if (hasHeaderProperty) { + xMcpToolName = tool.name; + xMcpInputSchema = parsedInputSchema.data; + break; + } + } - if (!xMcpTool) { + const schemaProperties = xMcpInputSchema?.properties; + if (!xMcpToolName || !xMcpInputSchema || !schemaProperties) { checks.push( untestableCheck( 'sep-2243-server-no-xmcp-tool', @@ -676,17 +970,26 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario return checks; } - const baseHeaders: Record = { - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + const baseHeaders = { + 'MCP-Protocol-Version': ctx.specVersion }; // Find the first x-mcp-header annotated STRING property // that is callable with minimal arguments to avoid schema validation failures - const schema = xMcpTool.inputSchema as any; - const annotatedEntry = Object.entries(schema.properties).find( - ([, def]: [string, any]) => - def['x-mcp-header'] !== undefined && (def as any).type === 'string' - ); + const annotatedEntry = Object.entries(schemaProperties) + .map(([name, propertyValue]) => { + const parsedProperty = + TOOL_INPUT_PROPERTY_SCHEMA.safeParse(propertyValue); + return parsedProperty.success + ? { name, property: parsedProperty.data } + : undefined; + }) + .find( + (entry) => + entry !== undefined && + entry.property['x-mcp-header'] !== undefined && + entry.property.type === 'string' + ); if (!annotatedEntry) { checks.push( untestableCheck( @@ -703,19 +1006,27 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); return checks; } - const [paramName, paramDef] = annotatedEntry as [string, any]; + const { name: paramName, property: paramDef } = annotatedEntry; const headerSuffix = paramDef['x-mcp-header']; + if (headerSuffix === undefined) { + throw new Error('x-mcp-header annotation is missing'); + } // Build default arguments for all required params to avoid schema validation errors. // 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 requiredParams = xMcpInputSchema.required ?? []; const defaultArgs: Record = {}; const defaultHeaders: Record = {}; for (const rp of requiredParams) { if (rp !== paramName) { - const rpDef = schema.properties[rp]; + const propertyValue = schemaProperties[rp]; + const parsedProperty = + TOOL_INPUT_PROPERTY_SCHEMA.safeParse(propertyValue); + const rpDef = parsedProperty.success + ? parsedProperty.data + : undefined; const rpType = rpDef?.type || 'string'; if (rpType === 'number' || rpType === 'integer') { defaultArgs[rp] = 0; @@ -743,14 +1054,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // Valid Base64 - server decodes and validates await this.testBase64Case( checks, - serverUrl, + ctx, baseHeaders, nextId, 'accept', 'sep-2243-server-decode-base64', 'ServerAcceptsValidBase64', 'Server decodes valid Base64 header value and validates against body', - xMcpTool.name, + xMcpToolName, paramName, 'Hello', headerSuffix, @@ -767,14 +1078,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // that proves burdensome we'll revisit. await this.testBase64Case( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', 'sep-2243-server-reject-invalid-param-chars', 'ServerRejectsInvalidBase64Padding', 'Server MUST reject Mcp-Param header with invalid Base64 padding (per SEP-2243 test-case table)', - xMcpTool.name, + xMcpToolName, paramName, 'Hello', headerSuffix, @@ -786,14 +1097,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // Invalid Base64 characters — FAILURE for the same reason as padding. await this.testBase64Case( checks, - serverUrl, + ctx, baseHeaders, nextId, 'reject', 'sep-2243-server-reject-invalid-param-chars', 'ServerRejectsInvalidBase64Chars', 'Server MUST reject Mcp-Param header with non-alphabet Base64 characters (per SEP-2243 test-case table)', - xMcpTool.name, + xMcpToolName, paramName, 'Hello', headerSuffix, @@ -805,14 +1116,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // Missing prefix - server treats as literal value await this.testBase64Case( checks, - serverUrl, + ctx, baseHeaders, nextId, 'accept', 'sep-2243-server-validate-param-match', 'ServerLiteralMissingBase64Prefix', 'Server treats value without =?base64? prefix as literal (not Base64)', - xMcpTool.name, + xMcpToolName, paramName, validBase64Value, headerSuffix, @@ -824,14 +1135,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // Missing suffix - server treats as literal value await this.testBase64Case( checks, - serverUrl, + ctx, baseHeaders, nextId, 'accept', 'sep-2243-server-validate-param-match', 'ServerLiteralMissingBase64Suffix', 'Server treats value without ?= suffix as literal (not Base64)', - xMcpTool.name, + xMcpToolName, paramName, `=?base64?${validBase64Value}`, headerSuffix, @@ -844,10 +1155,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario await this.testMissingCustomHeader( checks, - serverUrl, + ctx, baseHeaders, nextId, - xMcpTool.name, + xMcpToolName, paramName, headerSuffix, defaultArgs, @@ -920,7 +1231,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario private async testBase64Case( checks: ConformanceCheck[], - serverUrl: string, + ctx: RunContext, baseHeaders: Record, nextId: () => number, expectation: 'accept' | 'reject', @@ -932,22 +1243,27 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario bodyValue: string, headerSuffix: string, headerValue: string, - defaultArgs: Record, + defaultArgs: Record, defaultHeaders: Record ): Promise { try { + const { serverUrl, specVersion } = ctx; const response = await sendRawRequest( serverUrl, + specVersion, { jsonrpc: '2.0', id: nextId(), method: 'tools/call', // Issue #311: the body always carries the SEP-2575 _meta fields — // these cases only vary the Mcp-Param header value. - params: withRequestMeta({ - name: toolName, - arguments: { ...defaultArgs, [paramName]: bodyValue } - }) + params: withRequestMeta( + { + name: toolName, + arguments: { ...defaultArgs, [paramName]: bodyValue } + }, + specVersion + ) }, { ...baseHeaders, @@ -1011,29 +1327,34 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario private async testMissingCustomHeader( checks: ConformanceCheck[], - serverUrl: string, + ctx: RunContext, baseHeaders: Record, nextId: () => number, toolName: string, paramName: string, headerSuffix: string, - defaultArgs: Record, + defaultArgs: Record, defaultHeaders: Record ): Promise { try { + const { serverUrl, specVersion } = ctx; // Send tools/call with value in body but NO Mcp-Param header const response = await sendRawRequest( serverUrl, + specVersion, { jsonrpc: '2.0', id: nextId(), method: 'tools/call', // Issue #311: the body always carries the SEP-2575 _meta fields — // this case only omits the Mcp-Param header. - params: withRequestMeta({ - name: toolName, - arguments: { ...defaultArgs, [paramName]: 'test-value' } - }) + params: withRequestMeta( + { + name: toolName, + arguments: { ...defaultArgs, [paramName]: 'test-value' } + }, + specVersion + ) }, { ...baseHeaders,