From dbfc32088659364aef6e0914342acbb80f800d0d Mon Sep 17 00:00:00 2001 From: Benjamin Tang Date: Sun, 27 Sep 2026 16:25:11 -0300 Subject: [PATCH] fix: start stateless stream collection after response headers --- src/connection/index.ts | 1 + src/connection/stateless.ts | 4 +- src/scenarios/server/stateless.test.ts | 177 ++++++++++++++++++++++++- src/scenarios/server/stateless.ts | 13 +- 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/src/connection/index.ts b/src/connection/index.ts index 47952c64..02c8d8b3 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -112,6 +112,7 @@ export { readSseJsonRpcResponse, CONFORMANCE_CLIENT_INFO, DEFAULT_CLIENT_CAPABILITIES, + DEFAULT_STATELESS_REQUEST_TIMEOUT_MS, type JsonRpcResponse, type StatelessResponse } from './stateless'; diff --git a/src/connection/stateless.ts b/src/connection/stateless.ts index 38890ac8..c5d32e70 100644 --- a/src/connection/stateless.ts +++ b/src/connection/stateless.ts @@ -43,6 +43,8 @@ export const DEFAULT_CLIENT_CAPABILITIES = { roots: { listChanged: true } } as const; +export const DEFAULT_STATELESS_REQUEST_TIMEOUT_MS = 10000; + export interface StatelessResponse { status: number; headers: Headers; @@ -288,7 +290,7 @@ export async function sendStatelessRequest( const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(), - options.timeoutMs ?? 10000 + options.timeoutMs ?? DEFAULT_STATELESS_REQUEST_TIMEOUT_MS ); try { const res = await fetch(serverUrl, { diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 9bfd0b53..6b0370c3 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -1,12 +1,37 @@ import { testContext } from '../../connection/testing'; +import { DEFAULT_STATELESS_REQUEST_TIMEOUT_MS } from '../../connection'; import { ServerStatelessScenario } from './stateless'; -import { describe, test, expect } from 'vitest'; +import { afterEach, beforeEach, describe, test, expect, vi } from 'vitest'; import { ConformanceCheck } from '../../types'; const findCheck = (checks: ConformanceCheck[], id: string) => checks.find((c) => c.id === id); describe('Stateless Server Scenario Negative Tests', () => { + // Model fetch/body cancellation, including a stream that stays open without + // sending frames. A delayed mock that ignores AbortSignal cannot reproduce + // the premature-abort regression. + function abortableDelay(ms: number | undefined, signal: AbortSignal) { + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener('abort', abort, { once: true }); + if (ms !== undefined) { + timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, ms); + } + }); + } + // Inline network mocking helper function mockFetchTarget( handler: (reqBody: any, reqHeaders: Record) => any @@ -17,6 +42,10 @@ describe('Stateless Server Scenario Negative Tests', () => { let responseConfig = await handler(body, headers); + if (responseConfig?.headerDelayMs) { + await abortableDelay(responseConfig.headerDelayMs, init.signal); + } + // GLOBAL SPEC-COMPLIANT FALLBACKS: Provides successful data loops for downstream checks // if individual tests are focusing exclusively on separate fields (like discovery or meta checks). if (!responseConfig) { @@ -61,9 +90,16 @@ describe('Stateless Server Scenario Negative Tests', () => { const mockReader = { read: async () => { + init.signal?.throwIfAborted(); if (frameIndex >= streamData.length) { + if (responseConfig.keepOpen) { + await abortableDelay(undefined, init.signal); + } return { value: undefined, done: true }; } + if (responseConfig.frameDelayMs) { + await abortableDelay(responseConfig.frameDelayMs, init.signal); + } const chunk = new TextEncoder().encode(streamData[frameIndex++]); return { value: chunk, done: false }; }, @@ -115,6 +151,145 @@ describe('Stateless Server Scenario Negative Tests', () => { } }); + describe.each([ + { + tool: 'test_logging_tool', + checkId: 'sep-2575-server-no-log-without-loglevel', + timeoutMs: 500, + forbiddenFrame: { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'debug', data: 'Unrequested log' } + }, + violation: 'Server dispatched a notifications/message payload' + }, + { + tool: 'test_streaming_elicitation', + checkId: 'sep-2575-http-server-no-independent-requests-on-stream', + timeoutMs: 600, + forbiddenFrame: { + jsonrpc: '2.0', + id: 'server-request', + method: 'client/unrequested', + params: {} + }, + violation: 'Server emitted an independent standard request' + } + ])('$tool stream collection', (probe) => { + const successFrame = { + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text', text: 'Complete' }] } + }; + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.useRealTimers(); + }); + + function runProbe(config: { + frames: object[]; + headerDelayMs?: number; + frameDelayMs?: number; + }) { + const url = mockFetchTarget((request) => { + if ( + request.method === 'tools/call' && + request.params?.name === probe.tool + ) { + return { isStream: true, keepOpen: true, ...config }; + } + }); + return new ServerStatelessScenario().run(testContext(url)); + } + + test('collects frames for the full window after delayed headers', async () => { + const checks = runProbe({ + headerDelayMs: 1000, + frameDelayMs: probe.timeoutMs - 1, + frames: [successFrame] + }); + await vi.advanceTimersByTimeAsync(1000 + probe.timeoutMs); + expect(findCheck(await checks, probe.checkId)?.status).toBe('SUCCESS'); + expect(vi.getTimerCount()).toBe(0); + }); + + test('preserves a delayed diagnostic-tool rejection as untestable', async () => { + const checks = runProbe({ + headerDelayMs: 1000, + frames: [ + { + jsonrpc: '2.0', + id: 1, + error: { code: -32602, message: 'Unknown tool' } + } + ] + }); + await vi.advanceTimersByTimeAsync(1000 + probe.timeoutMs); + const check = findCheck(await checks, probe.checkId); + expect(check?.status).toBe('FAILURE'); + expect(check?.errorMessage).toContain('Not testable:'); + expect(check?.details?.untestable).toBe(true); + }); + + test('still detects forbidden content after delayed headers', async () => { + const checks = runProbe({ + headerDelayMs: 1000, + frameDelayMs: probe.timeoutMs - 1, + frames: [probe.forbiddenFrame] + }); + await vi.advanceTimersByTimeAsync(1000 + probe.timeoutMs); + const check = findCheck(await checks, probe.checkId); + expect(check?.status).toBe('FAILURE'); + expect(check?.errorMessage).toContain(probe.violation); + }); + + test.each([false, true])( + 'bounds an open body with no frames inside the window (late frame: %s)', + async (lateFrame) => { + let settled = false; + const checks = runProbe({ + frames: lateFrame ? [successFrame] : [], + frameDelayMs: probe.timeoutMs + 1 + }).then((result) => { + settled = true; + return result; + }); + await vi.advanceTimersByTimeAsync(probe.timeoutMs - 1); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(settled).toBe(true); + expect(findCheck(await checks, probe.checkId)?.status).toBe('FAILURE'); + expect(vi.getTimerCount()).toBe(0); + } + ); + + test('bounds response headers independently of the collection window', async () => { + let settled = false; + const checks = runProbe({ + headerDelayMs: DEFAULT_STATELESS_REQUEST_TIMEOUT_MS + 1, + frames: [successFrame] + }).then((result) => { + settled = true; + return result; + }); + await vi.advanceTimersByTimeAsync( + DEFAULT_STATELESS_REQUEST_TIMEOUT_MS - 1 + ); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(settled).toBe(true); + expect(findCheck(await checks, probe.checkId)?.status).toBe('FAILURE'); + expect(vi.getTimerCount()).toBe(0); + }); + }); + test('Fails validation if missing required fields in _meta are allowed to pass', async () => { // This bad server completely ignores missing params/_meta fields and returns a fake success result const mockUrl = mockFetchTarget((reqBody) => { diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index 618dc0c5..61949dce 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -9,6 +9,7 @@ import { } from '../../types'; import { buildStandardHeaders, + DEFAULT_STATELESS_REQUEST_TIMEOUT_MS, readSseJsonRpcResponse, type RunContext } from '../../connection'; @@ -187,9 +188,10 @@ export class ServerStatelessScenario implements ClientScenario { }); const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, timeoutMs); + let timeoutId = setTimeout( + () => controller.abort(), + DEFAULT_STATELESS_REQUEST_TIMEOUT_MS + ); try { const res = await fetch(serverUrl, { @@ -199,6 +201,11 @@ export class ServerStatelessScenario implements ClientScenario { signal: controller.signal }); + // Give the stream its full collection window after headers arrive. + // Opening the response has its own ordinary request timeout. + clearTimeout(timeoutId); + timeoutId = setTimeout(() => controller.abort(), timeoutMs); + if (!res.body) { clearTimeout(timeoutId); return [];