diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 03cf55f5..aa879c89 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -68,6 +68,8 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; import { CachingScenario } from './server/caching'; +import { HttpStreamCancellationScenario } from './server/http-stream-cancellation'; +import { PerRequestLogLevelScenario } from './server/per-request-loglevel'; // InputRequiredResult scenarios from (SEP-2322) import { @@ -215,6 +217,13 @@ const allClientScenariosList: ClientScenario[] = [ // Caching scenarios (SEP-2549) new CachingScenario(), + + // HTTP Stream-Close Cancellation (2026-07-28) + new HttpStreamCancellationScenario(), + + // Per-Request LogLevel (2026-07-28) + new PerRequestLogLevelScenario(), + // HTTP Standardization scenarios (SEP-2243) new HttpHeaderValidationScenario(), new HttpCustomHeaderServerValidationScenario(), diff --git a/src/scenarios/server/http-stream-cancellation.ts b/src/scenarios/server/http-stream-cancellation.ts new file mode 100644 index 00000000..8269a4c8 --- /dev/null +++ b/src/scenarios/server/http-stream-cancellation.ts @@ -0,0 +1,422 @@ +/** + * HTTP Stream-Close Cancellation conformance scenario (2026-07-28). + * + * The 2026-07-28 spec states that on Streamable HTTP, closing the response + * stream (client disconnect) MUST be treated by the server as cancellation of + * that request. The server MUST NOT send further messages for the cancelled + * request after the client disconnects. The server SHOULD stop work on the + * cancelled request as soon as practical. + * + * This is fundamentally different from the 2025-x cancellation model, which + * used an explicit `notifications/cancelled` JSON-RPC message. + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { + buildStandardHeaders, + withRequestMeta, + type RunContext +} from '../../connection'; + +const SPEC_REFS = [ + { + id: 'MCP-2026-07-28-Cancellation', + url: 'https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation' + }, + { + id: 'MCP-2026-07-28-StreamableHTTP', + url: 'https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation' + } +]; + +export class HttpStreamCancellationScenario implements ClientScenario { + name = 'http-stream-cancellation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test HTTP stream-close-as-cancellation behavior (2026-07-28 spec). + +**Server Implementation Requirements:** + +**Endpoints**: +- \`tools/call\`: Implement a slow tool (\`test_tool_slow\`) that streams progress over SSE for at least 5 seconds. +- \`tools/call\`: Implement a fast tool (\`test_tool_fast\`) that returns immediately (health check). + +**Specification Requirements (3 Checks)**: + +1. **Stream Close = Cancellation** + - When the client closes (aborts) the HTTP response stream mid-flight, the server MUST treat this as cancellation of the in-progress request. + - The server MUST NOT send any further messages for that request after the client disconnects. + - The server SHOULD stop work on the cancelled request as soon as practical. + +2. **Server Health After Cancellation** + - After cancelling a request via stream close, the server MUST remain operational and able to serve subsequent requests. + +3. **Rapid Stream-Close Stability** + - Multiple rapid stream opens and immediate closes MUST NOT crash or deadlock the server.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + const timestamp = new Date().toISOString(); + + let nextId = 1; + const makeRequest = (method: string, params?: Record) => { + const id = nextId++; + const headers = buildStandardHeaders(method, params, { specVersion }); + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: withRequestMeta(params, specVersion) + }); + return { id, headers, body }; + }; + + // Check 1: Stream close cancels in-progress request and server stops sending + try { + const { headers, body } = makeRequest('tools/call', { + name: 'test_tool_slow', + arguments: { durationMs: 10000 } + }); + + const controller = new AbortController(); + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + + const contentType = res.headers.get('content-type') ?? ''; + const isStreaming = contentType.includes('text/event-stream'); + + if (!isStreaming || !res.body) { + checks.push({ + id: 'http-stream-cancel-closes-request', + name: 'HttpStreamCancelClosesRequest', + description: + 'Server handles SSE stream close gracefully without crashing or blocking', + status: 'FAILURE', + timestamp, + errorMessage: !isStreaming + ? `Expected text/event-stream response for slow tool, got ${contentType || '(none)'}` + : 'Response body was null', + specReferences: SPEC_REFS, + details: { contentType, hasBody: !!res.body } + }); + } else { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let receivedEvents = 0; + + // Read until we get at least one SSE event (proving the stream is active) + const readUntilFirstEvent = async (): Promise => { + const timeout = setTimeout(() => controller.abort(), 3000); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + const lines = text.split(/\r?\n/); + for (const line of lines) { + if (line.startsWith('data:')) { + receivedEvents++; + return true; + } + } + } + } catch { + // Aborted or closed + } finally { + clearTimeout(timeout); + } + return receivedEvents > 0; + }; + + const gotFirstEvent = await readUntilFirstEvent(); + + if (!gotFirstEvent) { + checks.push({ + id: 'http-stream-cancel-closes-request', + name: 'HttpStreamCancelClosesRequest', + description: + 'Server handles SSE stream close gracefully without crashing or blocking', + status: 'FAILURE', + timestamp, + errorMessage: + 'Slow tool did not produce any SSE events before timeout — cannot test stream cancellation', + specReferences: SPEC_REFS, + details: { receivedEvents } + }); + } else { + // Now abort the stream (simulates client disconnect) + controller.abort(); + try { + reader.releaseLock(); + } catch { + // Already released + } + + // Wait briefly then verify no more data arrives on a fresh connection + await new Promise((r) => setTimeout(r, 500)); + + // The server MUST NOT send further messages for the cancelled request. + // We verify this by checking server health (if it's still up, it handled + // the disconnect gracefully). + const healthReq = makeRequest('tools/call', { + name: 'test_tool_fast', + arguments: {} + }); + const healthController = new AbortController(); + const healthTimeout = setTimeout( + () => healthController.abort(), + 5000 + ); + try { + const healthRes = await fetch(serverUrl, { + method: 'POST', + headers: healthReq.headers, + body: healthReq.body, + signal: healthController.signal + }); + clearTimeout(healthTimeout); + + const healthText = await healthRes.text(); + let healthData: any; + try { + healthData = JSON.parse(healthText); + } catch { + // SSE response — parse first data line + const lines = healthText.split(/\r?\n/); + for (const line of lines) { + if (line.startsWith('data:')) { + try { + healthData = JSON.parse(line.replace(/^data:\s*/, '')); + } catch { + // continue + } + break; + } + } + } + + const serverResponded = + healthRes.status === 200 && healthData?.result !== undefined; + + checks.push({ + id: 'http-stream-cancel-closes-request', + name: 'HttpStreamCancelClosesRequest', + description: + 'Server handles SSE stream close gracefully without crashing or blocking', + status: serverResponded ? 'SUCCESS' : 'FAILURE', + timestamp, + errorMessage: serverResponded + ? undefined + : `Server did not respond healthily after stream close (HTTP ${healthRes.status})`, + specReferences: SPEC_REFS, + details: { + receivedEventsBeforeAbort: receivedEvents, + healthCheckStatus: healthRes.status, + healthCheckResult: healthData?.result + } + }); + } catch (e) { + clearTimeout(healthTimeout); + checks.push({ + id: 'http-stream-cancel-closes-request', + name: 'HttpStreamCancelClosesRequest', + description: + 'Server handles SSE stream close gracefully without crashing or blocking', + status: 'FAILURE', + timestamp, + errorMessage: `Health check after stream abort failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS, + details: { receivedEventsBeforeAbort: receivedEvents } + }); + } + } + } + } catch (e) { + checks.push({ + id: 'http-stream-cancel-closes-request', + name: 'HttpStreamCancelClosesRequest', + description: + 'Server handles SSE stream close gracefully without crashing or blocking', + status: 'FAILURE', + timestamp, + errorMessage: `Stream cancellation test failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + // Check 2: Server remains healthy after cancellation + try { + const { headers, body } = makeRequest('tools/call', { + name: 'test_tool_fast', + arguments: {} + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + clearTimeout(timeout); + + let data: any; + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('text/event-stream')) { + const text = await res.text(); + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('data:')) { + try { + data = JSON.parse(line.replace(/^data:\s*/, '')); + } catch { + // skip + } + break; + } + } + } else { + try { + data = await res.json(); + } catch { + // non-JSON + } + } + + const healthy = res.status === 200 && data?.result !== undefined; + checks.push({ + id: 'http-stream-cancel-server-health', + name: 'HttpStreamCancelServerHealth', + description: + 'Server remains operational after client aborts a streaming response', + status: healthy ? 'SUCCESS' : 'FAILURE', + timestamp, + errorMessage: healthy + ? undefined + : `Server unhealthy after cancellation (HTTP ${res.status})`, + specReferences: SPEC_REFS, + details: { httpStatus: res.status, result: data?.result } + }); + } catch (e) { + checks.push({ + id: 'http-stream-cancel-server-health', + name: 'HttpStreamCancelServerHealth', + description: + 'Server remains operational after client aborts a streaming response', + status: 'FAILURE', + timestamp, + errorMessage: `Post-cancellation health check failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + // Check 3: Rapid stream-close stability (burst of open+immediate-close) + try { + const burstCount = 5; + const errors: string[] = []; + + for (let i = 0; i < burstCount; i++) { + const { headers, body } = makeRequest('tools/call', { + name: 'test_tool_slow', + arguments: { durationMs: 10000 } + }); + const controller = new AbortController(); + const iterTimeout = setTimeout(() => controller.abort(), 3000); + try { + await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + clearTimeout(iterTimeout); + controller.abort(); + } catch { + clearTimeout(iterTimeout); + } + } + + // Brief pause for server to process disconnects + await new Promise((r) => setTimeout(r, 300)); + + // Verify server is still alive + const { headers, body } = makeRequest('tools/call', { + name: 'test_tool_fast', + arguments: {} + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + clearTimeout(timeout); + + let data: any; + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('text/event-stream')) { + const text = await res.text(); + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('data:')) { + try { + data = JSON.parse(line.replace(/^data:\s*/, '')); + } catch { + // skip + } + break; + } + } + } else { + try { + data = await res.json(); + } catch { + // non-JSON + } + } + + const survived = res.status === 200 && data?.result !== undefined; + if (!survived) { + errors.push( + `Server unresponsive after ${burstCount} rapid stream-close operations (HTTP ${res.status})` + ); + } + + checks.push({ + id: 'http-stream-cancel-rapid-burst', + name: 'HttpStreamCancelRapidBurst', + description: `${burstCount} rapid stream opens followed by immediate closes must not crash or deadlock the server`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp, + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: SPEC_REFS, + details: { + burstCount, + serverResponsiveAfterBurst: survived, + httpStatus: res.status + } + }); + } catch (e) { + checks.push({ + id: 'http-stream-cancel-rapid-burst', + name: 'HttpStreamCancelRapidBurst', + description: + 'Rapid stream-close operations must not crash or deadlock the server', + status: 'FAILURE', + timestamp, + errorMessage: `Burst stability test failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/per-request-loglevel.ts b/src/scenarios/server/per-request-loglevel.ts new file mode 100644 index 00000000..70de1470 --- /dev/null +++ b/src/scenarios/server/per-request-loglevel.ts @@ -0,0 +1,514 @@ +/** + * Per-Request LogLevel conformance scenario (2026-07-28). + * + * The 2026-07-28 spec replaces session-level `logging/setLevel` with per-request + * logLevel in `_meta.io.modelcontextprotocol/logLevel`. This scenario tests: + * + * - Positive: log messages ARE emitted when logLevel is set + * - Filtering: only messages at or above the requested severity are delivered + * - Invalid: unrecognized logLevel values are rejected with -32602 + * - Scoping: log messages appear only on the request's own response stream + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { + buildStandardHeaders, + withRequestMeta, + type RunContext +} from '../../connection'; + +const SPEC_REFS = [ + { + id: 'MCP-2026-07-28-Logging', + url: 'https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging' + } +]; + +const LOG_LEVELS = [ + 'debug', + 'info', + 'notice', + 'warning', + 'error', + 'critical', + 'alert', + 'emergency' +] as const; + +function levelIndex(level: string): number { + return LOG_LEVELS.indexOf(level as (typeof LOG_LEVELS)[number]); +} + +export class PerRequestLogLevelScenario implements ClientScenario { + name = 'per-request-loglevel'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test per-request logLevel behavior (2026-07-28 spec). + +**Server Implementation Requirements:** + +**Endpoints**: +- \`tools/call\`: Implement a tool (\`test_logging_tool\`) that emits log messages at multiple severity levels (debug, info, warning, error) via \`notifications/message\` on the response stream. + +**Specification Requirements (4 Checks)**: + +1. **Positive Emission** + - When \`_meta.io.modelcontextprotocol/logLevel\` is present, the server MAY emit \`notifications/message\` on the response stream at or above the requested level. + - Verify at least one log message appears when logLevel is set to "debug". + +2. **Severity Filtering** + - The server MUST NOT deliver log messages below the requested severity level. + - Setting logLevel to "error" means no debug/info/notice/warning messages. + +3. **Invalid LogLevel Rejection** + - If the logLevel value is not a recognized severity level, the server SHOULD respond with a -32602 (Invalid params) error. + +4. **Request-Scoped Delivery** + - Log messages MUST appear only on the response stream of the request that set logLevel, not on concurrent streams.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + const timestamp = new Date().toISOString(); + + let nextId = 1; + + const sendRequest = async ( + method: string, + params?: Record, + timeoutMs = 5000 + ): Promise<{ status: number; events: any[]; body: any }> => { + const id = nextId++; + const headers = buildStandardHeaders(method, params, { specVersion }); + const reqBody = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: withRequestMeta(params, specVersion) + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body: reqBody, + signal: controller.signal + }); + clearTimeout(timeout); + + const ct = res.headers.get('content-type') ?? ''; + const events: any[] = []; + let body: any = null; + + if (ct.includes('text/event-stream')) { + const text = await res.text(); + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.replace(/^data:\s*/, '')); + events.push(parsed); + if (parsed.id === id) body = parsed; + } catch { + // skip + } + } + } + if (!body && events.length > 0) { + body = events.find((e) => e.id === id) ?? events[events.length - 1]; + } + } else { + try { + body = await res.json(); + } catch { + // non-JSON + } + } + + return { status: res.status, events, body }; + } catch (e) { + clearTimeout(timeout); + throw e; + } + }; + + // Track whether the server emits logs at all (used to avoid false positives) + let serverEmitsLogs = false; + + // Check 1: Positive emission — logLevel "debug" produces log messages + try { + const { status, events, body } = await sendRequest('tools/call', { + name: 'test_logging_tool', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/logLevel': 'debug' + } + }); + + const logNotifications = events.filter( + (e) => e.method === 'notifications/message' + ); + + if (status !== 200 && body?.error) { + // Server doesn't implement the tool — mark as untestable + checks.push({ + id: 'per-request-loglevel-positive-emission', + name: 'PerRequestLogLevelPositiveEmission', + description: + 'Server emits notifications/message when logLevel is set', + status: 'FAILURE', + timestamp, + errorMessage: `test_logging_tool not available: error ${body.error.code} — ${body.error.message}`, + specReferences: SPEC_REFS, + details: { error: body.error } + }); + } else if (logNotifications.length === 0) { + checks.push({ + id: 'per-request-loglevel-positive-emission', + name: 'PerRequestLogLevelPositiveEmission', + description: + 'Server emits notifications/message when logLevel is set', + status: 'WARNING', + timestamp, + errorMessage: + 'No notifications/message received with logLevel "debug" — server MAY emit but chose not to', + specReferences: SPEC_REFS, + details: { logLevel: 'debug', eventsReceived: events.length } + }); + } else { + serverEmitsLogs = true; + checks.push({ + id: 'per-request-loglevel-positive-emission', + name: 'PerRequestLogLevelPositiveEmission', + description: + 'Server emits notifications/message when logLevel is set', + status: 'SUCCESS', + timestamp, + specReferences: SPEC_REFS, + details: { + logLevel: 'debug', + notificationCount: logNotifications.length, + levels: logNotifications.map((n) => n.params?.level) + } + }); + } + } catch (e) { + checks.push({ + id: 'per-request-loglevel-positive-emission', + name: 'PerRequestLogLevelPositiveEmission', + description: 'Server emits notifications/message when logLevel is set', + status: 'FAILURE', + timestamp, + errorMessage: `Positive emission test failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + // Check 2: Severity filtering — logLevel "error" suppresses lower levels + if (!serverEmitsLogs) { + checks.push({ + id: 'per-request-loglevel-severity-filtering', + name: 'PerRequestLogLevelSeverityFiltering', + description: + 'Server MUST NOT deliver log messages below the requested level', + status: 'SKIPPED', + timestamp, + errorMessage: + 'Server did not emit any log messages in Check 1, so filtering cannot be verified', + specReferences: SPEC_REFS + }); + } else + try { + const { status, events, body } = await sendRequest('tools/call', { + name: 'test_logging_tool', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/logLevel': 'error' + } + }); + + const logNotifications = events.filter( + (e) => e.method === 'notifications/message' + ); + + if (status !== 200 && body?.error) { + checks.push({ + id: 'per-request-loglevel-severity-filtering', + name: 'PerRequestLogLevelSeverityFiltering', + description: + 'Server MUST NOT deliver log messages below the requested level', + status: 'FAILURE', + timestamp, + errorMessage: `test_logging_tool not available: error ${body.error.code}`, + specReferences: SPEC_REFS + }); + } else { + const errorLevelIdx = levelIndex('error'); + const belowThreshold = logNotifications.filter((n) => { + const msgLevel = n.params?.level; + const idx = levelIndex(msgLevel); + return idx >= 0 && idx < errorLevelIdx; + }); + + if (belowThreshold.length > 0) { + checks.push({ + id: 'per-request-loglevel-severity-filtering', + name: 'PerRequestLogLevelSeverityFiltering', + description: + 'Server MUST NOT deliver log messages below the requested level', + status: 'FAILURE', + timestamp, + errorMessage: `Received ${belowThreshold.length} log message(s) below "error" level: ${belowThreshold.map((n) => n.params?.level).join(', ')}`, + specReferences: SPEC_REFS, + details: { + requestedLevel: 'error', + belowThresholdLevels: belowThreshold.map( + (n) => n.params?.level + ), + allReceivedLevels: logNotifications.map((n) => n.params?.level) + } + }); + } else { + checks.push({ + id: 'per-request-loglevel-severity-filtering', + name: 'PerRequestLogLevelSeverityFiltering', + description: + 'Server MUST NOT deliver log messages below the requested level', + status: 'SUCCESS', + timestamp, + specReferences: SPEC_REFS, + details: { + requestedLevel: 'error', + messagesAtOrAbove: logNotifications.length, + allReceivedLevels: logNotifications.map((n) => n.params?.level) + } + }); + } + } + } catch (e) { + checks.push({ + id: 'per-request-loglevel-severity-filtering', + name: 'PerRequestLogLevelSeverityFiltering', + description: + 'Server MUST NOT deliver log messages below the requested level', + status: 'FAILURE', + timestamp, + errorMessage: `Severity filtering test failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + // Check 3: Invalid logLevel value — server SHOULD reject with -32602 + try { + const { status, body } = await sendRequest('tools/call', { + name: 'test_logging_tool', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/logLevel': 'banana' + } + }); + + if (body?.error?.code === -32602) { + checks.push({ + id: 'per-request-loglevel-invalid-rejection', + name: 'PerRequestLogLevelInvalidRejection', + description: + 'Server SHOULD reject unrecognized logLevel with -32602 Invalid params', + status: 'SUCCESS', + timestamp, + specReferences: SPEC_REFS, + details: { invalidLevel: 'banana', errorCode: body.error.code } + }); + } else if (body?.error) { + checks.push({ + id: 'per-request-loglevel-invalid-rejection', + name: 'PerRequestLogLevelInvalidRejection', + description: + 'Server SHOULD reject unrecognized logLevel with -32602 Invalid params', + status: 'WARNING', + timestamp, + errorMessage: `Server returned error code ${body.error.code} instead of -32602 for invalid logLevel`, + specReferences: SPEC_REFS, + details: { invalidLevel: 'banana', error: body.error } + }); + } else { + // Server accepted the invalid level — this is a SHOULD, so WARNING + checks.push({ + id: 'per-request-loglevel-invalid-rejection', + name: 'PerRequestLogLevelInvalidRejection', + description: + 'Server SHOULD reject unrecognized logLevel with -32602 Invalid params', + status: 'WARNING', + timestamp, + errorMessage: + 'Server accepted unrecognized logLevel "banana" without error (SHOULD reject with -32602)', + specReferences: SPEC_REFS, + details: { invalidLevel: 'banana', httpStatus: status } + }); + } + } catch (e) { + checks.push({ + id: 'per-request-loglevel-invalid-rejection', + name: 'PerRequestLogLevelInvalidRejection', + description: + 'Server SHOULD reject unrecognized logLevel with -32602 Invalid params', + status: 'FAILURE', + timestamp, + errorMessage: `Invalid logLevel test failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + // Check 4: Request-scoped delivery — logs appear only on their own stream + if (!serverEmitsLogs) { + checks.push({ + id: 'per-request-loglevel-request-scoped', + name: 'PerRequestLogLevelRequestScoped', + description: + 'Log messages MUST appear only on the response stream of the request that set logLevel', + status: 'SKIPPED', + timestamp, + errorMessage: + 'Server did not emit any log messages in Check 1, so request-scoped delivery cannot be verified', + specReferences: SPEC_REFS + }); + } else + try { + // Fire two concurrent requests: one WITH logLevel, one WITHOUT. + // The one without logLevel MUST NOT receive any notifications/message. + const id1 = nextId++; + const id2 = nextId++; + + const makeReqBody = ( + id: number, + toolName: string, + meta?: Record + ) => { + const params: Record = { + name: toolName, + arguments: {} + }; + if (meta) { + params._meta = meta; + } + return JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: withRequestMeta(params, specVersion) + }); + }; + + const headers = buildStandardHeaders( + 'tools/call', + { name: 'test_logging_tool' }, + { specVersion } + ); + + // Request 1: WITH logLevel (should get log messages) + const controller1 = new AbortController(); + const timeout1 = setTimeout(() => controller1.abort(), 5000); + const req1Promise = fetch(serverUrl, { + method: 'POST', + headers, + body: makeReqBody(id1, 'test_logging_tool', { + 'io.modelcontextprotocol/logLevel': 'debug' + }), + signal: controller1.signal + }); + + // Request 2: WITHOUT logLevel (should NOT get log messages) + const headers2 = buildStandardHeaders( + 'tools/call', + { name: 'test_tool_fast' }, + { specVersion } + ); + const controller2 = new AbortController(); + const timeout2 = setTimeout(() => controller2.abort(), 5000); + const req2Promise = fetch(serverUrl, { + method: 'POST', + headers: headers2, + body: makeReqBody(id2, 'test_tool_fast', undefined), + signal: controller2.signal + }); + + const [res1, res2] = await Promise.all([req1Promise, req2Promise]); + clearTimeout(timeout1); + clearTimeout(timeout2); + + // Parse events from response 2 (no logLevel) + const events2: any[] = []; + const ct2 = res2.headers.get('content-type') ?? ''; + if (ct2.includes('text/event-stream')) { + const text = await res2.text(); + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('data:')) { + try { + events2.push(JSON.parse(line.replace(/^data:\s*/, ''))); + } catch { + // skip + } + } + } + } + + // Abort response 1 stream to prevent indefinite hang on SSE + controller1.abort(); + try { + await res1.text(); + } catch { + // AbortError expected + } + + const leakedLogs = events2.filter( + (e) => e.method === 'notifications/message' + ); + + if (leakedLogs.length > 0) { + checks.push({ + id: 'per-request-loglevel-request-scoped', + name: 'PerRequestLogLevelRequestScoped', + description: + 'Log messages MUST appear only on the response stream of the request that set logLevel', + status: 'FAILURE', + timestamp, + errorMessage: `${leakedLogs.length} log notification(s) leaked onto a concurrent request that did not set logLevel`, + specReferences: SPEC_REFS, + details: { leakedLogs } + }); + } else { + checks.push({ + id: 'per-request-loglevel-request-scoped', + name: 'PerRequestLogLevelRequestScoped', + description: + 'Log messages MUST appear only on the response stream of the request that set logLevel', + status: 'SUCCESS', + timestamp, + specReferences: SPEC_REFS, + details: { + concurrentStreamEvents: events2.length, + leakedLogCount: 0 + } + }); + } + } catch (e) { + checks.push({ + id: 'per-request-loglevel-request-scoped', + name: 'PerRequestLogLevelRequestScoped', + description: + 'Log messages MUST appear only on the response stream of the request that set logLevel', + status: 'FAILURE', + timestamp, + errorMessage: `Request-scoped delivery test failed: ${e instanceof Error ? e.message : String(e)}`, + specReferences: SPEC_REFS + }); + } + + return checks; + } +}