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
44 changes: 44 additions & 0 deletions src/scenarios/server/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,48 @@ describe('ServerInitializeScenario', () => {
);
expect(deleteCalls).toHaveLength(0);
});

it('bounds the session-id probe so a server that never answers cannot hang it', async () => {
fetchMock.mockResolvedValue(new Response(null));

await new ServerInitializeScenario().run(testContext(serverUrl));

const probe = fetchMock.mock.calls.find(
([, init]) => (init as RequestInit | undefined)?.method === 'POST'
);
expect(probe?.[1]?.signal).toBeInstanceOf(AbortSignal);
});

it('cancels the probe response body so an SSE answer does not leak the connection', async () => {
// `Accept` on the probe includes `text/event-stream`, so a server is free
// to answer with a stream that stays open. Only the header is read, so the
// body has to be released explicitly.
const cancel = vi.fn().mockResolvedValue(undefined);
const body = new ReadableStream({
start() {
// Never enqueue, never close: an SSE stream awaiting its first event.
},
cancel
});

fetchMock.mockResolvedValueOnce(
new Response(body, {
headers: {
'content-type': 'text/event-stream',
'mcp-session-id': 'session-123_ABC'
}
})
);
fetchMock.mockResolvedValue(new Response(null));

const checks = await new ServerInitializeScenario().run(
testContext(serverUrl)
);

expect(cancel).toHaveBeenCalled();
expect(checks[1]).toMatchObject({
id: 'server-session-id-visible-ascii',
status: 'SUCCESS'
});
});
});
13 changes: 12 additions & 1 deletion src/scenarios/server/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {

const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/;

// The session-id probe only needs the response header. Bound the request so a
// server that accepts the connection and never answers fails this check
// instead of hanging the scenario before teardown ever runs.
const SESSION_ID_PROBE_TIMEOUT_MS = 5000;

const SESSION_SPEC_REFERENCES = [
{
id: 'MCP-Session-Management',
Expand Down Expand Up @@ -115,12 +120,18 @@ and validates session ID format if one is assigned.`;
version: '1.0.0'
}
}
})
}),
signal: AbortSignal.timeout(SESSION_ID_PROBE_TIMEOUT_MS)
});

const sessionId = response.headers.get('mcp-session-id');
rawSessionId = sessionId;

// Only the header is needed. `Accept` includes `text/event-stream`, so a
// server answering with an open SSE stream would otherwise leave this
// body unconsumed and leak the connection for the rest of the run.
await response.body?.cancel();

if (!sessionId) {
checks.push({
id: 'server-session-id-visible-ascii',
Expand Down