refactor(mcp): address review of #244 — document SSE backstop, test teardown

Code review refinements (no behavior change):
- Make the SSE socket-exemption trade-off explicit: with setTimeout(0)
  there is no socket-layer idle reap, so a truly-dead SSE socket is
  bounded by the SessionManager's 1h idle eviction (which calls
  transport.close()) and the 32-session cap, not leaked unbounded. Note
  the finite-timeout alternative is deferred to live validation.
- Clarify the comment: the exemption matches any GET, but non-SSE GETs
  are short-lived so it's a harmless no-op for them.
- Add afterEach teardown disposing the SessionManager + connection-pool
  intervals (server.stop() short-circuits when never started), removing
  the test's leaked-timer warning.
- Strengthen the missing-socket test to assert the request still routes
  to the spec §2 400, not just that it doesn't throw.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
This commit is contained in:
Aaron Bockelie 2026-06-24 02:00:33 -05:00
parent 0b9d5b09dc
commit 926b8fb363
2 changed files with 26 additions and 4 deletions

View file

@ -443,8 +443,19 @@ export class MCPHttpServer {
// idle by design (this server pushes no server-initiated notifications).
// The global 120s socket timeout set in start() would otherwise reap it
// every ~2 min, surfacing as a "stream terminated" reconnect churn in
// bridges and noisy logs (#221). Exempt only this socket from the idle
// timeout; POST request sockets keep the server-wide default.
// bridges and noisy logs (#221). Disable the idle timeout on this GET
// socket; POST request sockets keep the server-wide default. (A non-SSE
// GET is short-lived — its response is sent immediately — so the
// exemption is a harmless no-op for those.)
//
// Trade-off: with no socket-layer idle reap, a truly-dead SSE socket
// (client vanished without a FIN) is no longer dropped at ~2 min. The
// backstop is the SessionManager's 1h idle eviction, which calls
// transport.close() and thereby closes the socket — so abandoned streams
// are bounded by that timeout and the 32-session cap, not leaked
// unbounded. A long finite timeout was weighed against 0; the choice is
// deferred to the live-instance validation, since a finite value would
// reintroduce some (less frequent) reconnect churn.
if (req.method === 'GET') {
req.socket?.setTimeout(0);
}

View file

@ -68,6 +68,14 @@ describe('SSE notification-stream socket timeout exemption (#221)', () => {
server = new MCPHttpServer(mockApp, 3001);
});
afterEach(async () => {
// The constructor arms the SessionManager + connection-pool intervals;
// server.stop() short-circuits when never started, so dispose them directly
// to avoid leaking timers across tests.
(server as any).sessionManager?.stop?.();
await (server as any).connectionPool?.shutdown?.();
});
test('GET /mcp (SSE stream) disables its socket idle timeout', async () => {
const { req, socket } = makeReq('GET', undefined, 'some-session');
await (server as any).handleMCPRequest(req, makeRes());
@ -80,9 +88,12 @@ describe('SSE notification-stream socket timeout exemption (#221)', () => {
expect(socket.setTimeout).not.toHaveBeenCalled();
});
test('exemption tolerates a missing socket (no throw)', async () => {
test('exemption tolerates a missing socket and still routes the request', async () => {
const req = { method: 'GET', body: undefined, headers: {} as Record<string, string> };
const res = makeRes();
await expect((server as any).handleMCPRequest(req, res)).resolves.toBeUndefined();
await (server as any).handleMCPRequest(req, res);
// No socket to exempt → optional chaining no-ops; the GET (no session, not
// an initialize) still routes to the spec §2 "session required" 400.
expect(res.statusCode).toBe(400);
});
});