diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f04d972..791f475e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [0.13.1] - Unreleased +### CLI + +- Allow tool requests to proceed after starting a legacy standalone SSE fetch instead of waiting indefinitely for response headers from idle streams. + ## [0.13.0] - 2026-08-02 ### MCP 2.0 diff --git a/src/runtime/http-transport.ts b/src/runtime/http-transport.ts index 49d330fc..7321840b 100644 --- a/src/runtime/http-transport.ts +++ b/src/runtime/http-transport.ts @@ -69,6 +69,7 @@ function removeAuthorizationHeader(headers: Record | undefined): } const NODE_HTTP1_FETCH_HOSTS: ReadonlySet = new Set(['api.sunsama.com']); +const STANDALONE_SSE_START_GRACE_MS = 250; function resolveHttpFetchOverride(definition: ServerDefinition): typeof nodeHttp1Fetch | undefined { if (definition.command.kind !== 'http' || definition.httpFetch === 'default') return undefined; @@ -120,6 +121,16 @@ function trackStandaloneSseFetch(fetchOverride: FetchLike | undefined): { }; } +function waitForStandaloneSseStart(started: Promise): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, STANDALONE_SSE_START_GRACE_MS); + void started.then(() => { + clearTimeout(timer); + resolve(); + }); + }); +} + async function closeOAuthSession(oauthSession?: OAuthSession): Promise { await oauthSession?.close().catch(() => {}); } @@ -268,10 +279,10 @@ async function connectPrimaryHttpTransport( recreateTransport: async () => createStreamableTransport(), }); // v2 starts the legacy standalone SSE receive channel asynchronously from - // notifications/initialized. Wait for its fetch to receive headers so - // callers cannot race their first request ahead of that channel. + // notifications/initialized. Give its fetch a bounded chance to receive + // headers without blocking servers that leave the response header-idle. if (typeof client.getProtocolEra === 'function' && client.getProtocolEra() === 'legacy') { - await transportOptions.standaloneSseStarted; + await waitForStandaloneSseStart(transportOptions.standaloneSseStarted); } return { client, transport, definition, oauthSession }; } diff --git a/tests/cli-idle-sse.integration.test.ts b/tests/cli-idle-sse.integration.test.ts index 8718c8ff..35e035ce 100644 --- a/tests/cli-idle-sse.integration.test.ts +++ b/tests/cli-idle-sse.integration.test.ts @@ -104,7 +104,6 @@ describe('idle standalone SSE CLI integration', () => { connection: 'keep-alive', 'content-type': 'text/event-stream', }); - response.flushHeaders(); return; } @@ -184,7 +183,7 @@ describe('idle standalone SSE CLI integration', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it('lists tools while a standalone SSE stream is open and byte-idle', async () => { + it('lists tools while a standalone SSE response leaves its headers pending', async () => { const result = await runCli(['list', 'idle', '--json', '--timeout', '2000'], configPath, preloadPath); expect(result.stderr).toBe(''); diff --git a/tests/e2e-fixture-servers.test.ts b/tests/e2e-fixture-servers.test.ts index 55d7d9cb..e26b9097 100644 --- a/tests/e2e-fixture-servers.test.ts +++ b/tests/e2e-fixture-servers.test.ts @@ -1,6 +1,8 @@ import { execFile, spawn, type ChildProcess } from 'node:child_process'; import fs from 'node:fs/promises'; +import { createServer, request as httpRequest } from 'node:http'; import { createRequire } from 'node:module'; +import type { AddressInfo } from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -36,7 +38,9 @@ type CliResult = { stdout: string; stderr: string; exitCode: number }; let legacyHttp: RunningFixture; let modernHttp: RunningFixture; +let delayedLegacyHeadersProxy: RunningHttpProxy; const spawnedChildren = new Set(); +const DELAYED_LEGACY_SSE_HEADERS_MS = 500; interface RunningFixture { child: ChildProcess; @@ -44,6 +48,11 @@ interface RunningFixture { stderr: () => string; } +interface RunningHttpProxy { + url: string; + close: () => Promise; +} + beforeAll(async () => { await fs.access(CLI_ENTRY).catch(() => { throw new Error('dist/cli.js is missing; run `pnpm build` before invoking this e2e file directly.'); @@ -52,9 +61,11 @@ beforeAll(async () => { startHttpFixture('legacy', LEGACY_SERVER), startHttpFixture('modern', MODERN_SERVER), ]); + delayedLegacyHeadersProxy = await startDelayedSseHeadersProxy(legacyHttp.url, DELAYED_LEGACY_SSE_HEADERS_MS); }, budget(20_000)); afterAll(async () => { + await delayedLegacyHeadersProxy.close(); await Promise.allSettled([...spawnedChildren].map((child) => stopChild(child))); }); @@ -140,6 +151,23 @@ describe.each(transports)('legacy long-tail over %s', (transport) => { }); }); +it('handles legacy elicitation when standalone SSE headers arrive after the startup grace', async () => { + await withConfig( + { + fixture: { + ...configFor('legacy', 'http'), + baseUrl: delayedLegacyHeadersProxy.url, + }, + }, + async (configPath, env) => { + const elicited = await runCli(['call', 'fixture.elicit_name', '--output', 'text'], configPath, env); + expect(elicited.exitCode, elicited.stderr).toBe(0); + expect(elicited.stderr).toContain('Server requested interactive input; run mcporter in a terminal.'); + expect(elicited.stdout).toContain('elicitation decline'); + } + ); +}); + describe.each(transports)('modern MRTR and identity over %s', (transport) => { it('declines cleanly through the CLI and reports per-request client identity', async () => { await withConfig({ fixture: configFor('modern', transport) }, async (configPath, env) => { @@ -425,6 +453,52 @@ async function startHttpFixture( } } +async function startDelayedSseHeadersProxy(targetUrl: string, delayMs: number): Promise { + const target = new URL(targetUrl); + const proxy = createServer((request, response) => { + const upstreamRequest = httpRequest( + target, + { + method: request.method, + headers: request.headers, + }, + (upstreamResponse) => { + const forwardResponse = () => { + if (response.destroyed) { + upstreamResponse.destroy(); + return; + } + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }; + if (request.method === 'GET') { + setTimeout(forwardResponse, delayMs); + } else { + forwardResponse(); + } + } + ); + upstreamRequest.on('error', (error) => { + if (!response.headersSent) response.writeHead(502); + response.end(error.message); + }); + request.pipe(upstreamRequest); + }); + await new Promise((resolve, reject) => { + proxy.once('error', reject); + proxy.listen(0, '127.0.0.1', resolve); + }); + const address = proxy.address() as AddressInfo; + return { + url: `http://127.0.0.1:${address.port}/mcp`, + close: async () => { + await new Promise((resolve, reject) => { + proxy.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + async function startBridge(configPath: string, env: NodeJS.ProcessEnv): Promise { const child = trackChild( spawn(process.execPath, [CLI_ENTRY, '--config', configPath, 'serve', '--http', '0'], {