Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions src/runtime/http-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function removeAuthorizationHeader(headers: Record<string, string> | undefined):
}

const NODE_HTTP1_FETCH_HOSTS: ReadonlySet<string> = 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;
Expand Down Expand Up @@ -120,6 +121,16 @@ function trackStandaloneSseFetch(fetchOverride: FetchLike | undefined): {
};
}

function waitForStandaloneSseStart(started: Promise<void>): Promise<void> {
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<void> {
await oauthSession?.close().catch(() => {});
}
Expand Down Expand Up @@ -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 };
}
Expand Down
3 changes: 1 addition & 2 deletions tests/cli-idle-sse.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ describe('idle standalone SSE CLI integration', () => {
connection: 'keep-alive',
'content-type': 'text/event-stream',
});
response.flushHeaders();
return;
}

Expand Down Expand Up @@ -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('');
Expand Down
74 changes: 74 additions & 0 deletions tests/e2e-fixture-servers.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,14 +38,21 @@ type CliResult = { stdout: string; stderr: string; exitCode: number };

let legacyHttp: RunningFixture;
let modernHttp: RunningFixture;
let delayedLegacyHeadersProxy: RunningHttpProxy;
const spawnedChildren = new Set<ChildProcess>();
const DELAYED_LEGACY_SSE_HEADERS_MS = 500;

interface RunningFixture {
child: ChildProcess;
url: string;
stderr: () => string;
}

interface RunningHttpProxy {
url: string;
close: () => Promise<void>;
}

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.');
Expand All @@ -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)));
});

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -425,6 +453,52 @@ async function startHttpFixture(
}
}

async function startDelayedSseHeadersProxy(targetUrl: string, delayMs: number): Promise<RunningHttpProxy> {
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<void>((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<void>((resolve, reject) => {
proxy.close((error) => (error ? reject(error) : resolve()));
});
},
};
}

async function startBridge(configPath: string, env: NodeJS.ProcessEnv): Promise<RunningFixture> {
const child = trackChild(
spawn(process.execPath, [CLI_ENTRY, '--config', configPath, 'serve', '--http', '0'], {
Expand Down