Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/streamable-http-sse-keepalive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

Add configurable SSE keep-alive comment frames to Streamable HTTP transports and apply `createMcpHandler`'s existing `keepAliveMs` option to every HTTP SSE stream it serves.
4 changes: 4 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ Rewrite the imports:

The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](./serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](./serving/http.md), which still reaches 2025-era clients through [legacy client support](./serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above.

## `SSE stream disconnected: TypeError: terminated`

HTTP SSE streams emit a `: keepalive` comment every 15 seconds by default so client body-idle timeouts and intermediaries do not terminate an otherwise idle connection. Configure the interval with `keepAliveMs` on the transport or `createMcpHandler`; set it to `0` to disable heartbeats.

## Recap

- Every heading on this page is the exact message you searched for.
Expand Down
30 changes: 22 additions & 8 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,14 @@ import {
} from '@modelcontextprotocol/core-internal';

import { invoke } from './invoke';
import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
import { McpServer } from './mcp';
import type { PerRequestResponseMode } from './perRequestTransport';
import type { Server } from './server';
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus';
import { DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
import { WebStandardStreamableHTTPServerTransport } from './streamableHttp';

/* ------------------------------------------------------------------------ *
Expand Down Expand Up @@ -194,8 +195,8 @@ export interface CreateMcpHandlerOptions {
*/
maxSubscriptions?: number;
/**
* SSE comment-frame keepalive interval for `subscriptions/listen` streams,
* in milliseconds. Set to `0` to disable.
* SSE comment-frame keepalive interval for every SSE stream this handler
* serves. In modern `auto` mode it starts after SSE upgrade. Set to `0` to disable.
* @default 15000
*/
keepAliveMs?: number;
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -306,7 +307,11 @@ function internalServerErrorResponse(id: RequestId | null = null): Response {
* The entry passes its own `onerror` here when expanding the default, so
* legacy-leg failures are never silently swallowed.
*/
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
function createLegacyStatelessFallback(
factory: McpServerFactory,
onerror?: (error: Error) => void,
keepAliveMs?: number
): LegacyHttpHandler {
return async (request, options) => {
if (request.method.toUpperCase() !== 'POST') {
return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.');
Comment thread
claude[bot] marked this conversation as resolved.
Expand All @@ -317,7 +322,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
requestInfo: request
});
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
...(keepAliveMs !== undefined && { keepAliveMs })
});
await product.connect(transport);

const teardown = () => {
Expand Down Expand Up @@ -390,6 +398,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
};
}

export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
return createLegacyStatelessFallback(factory, onerror);
}

/* ------------------------------------------------------------------------ *
* The entry's classification step (shared with isLegacyRequest)
* ------------------------------------------------------------------------ */
Expand Down Expand Up @@ -619,7 +631,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
const listenRouter = createListenRouter({
bus,
maxSubscriptions: options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS,
keepAliveMs: options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS,
keepAliveMs: options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS,
onerror: reportError
});
if (responseMode === 'json') {
Expand All @@ -632,7 +644,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa

// The default posture is the stateless fallback; 'reject' is the only way
// to turn legacy serving off (modern-only strict).
const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError);
const legacyHandler: LegacyHttpHandler | undefined =
legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs);

async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
const claimedRevision = route.classification.revision;
Expand Down Expand Up @@ -778,7 +791,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
classification: route.classification,
request,
...(authInfo !== undefined && { authInfo }),
...(responseMode !== undefined && { responseMode })
...(responseMode !== undefined && { responseMode }),
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
});
if (route.messageKind === 'notification') {
// Notification exchanges have no terminal response to ride the
Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/server/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface InvokeContext {
authInfo?: AuthInfo;
/** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */
responseMode?: PerRequestResponseMode;
/** SSE keep-alive interval for the exchange. */
keepAliveMs?: number;
}

/**
Expand All @@ -58,7 +60,8 @@ export async function invoke(
): Promise<Response> {
const transport = new PerRequestHTTPServerTransport({
classification: ctx.classification,
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode })
...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }),
...(ctx.keepAliveMs !== undefined && { keepAliveMs: ctx.keepAliveMs })
});
await server.connect(transport);
return transport.handleMessage(message, {
Expand Down
23 changes: 9 additions & 14 deletions packages/server/src/server/listenRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ import { codecForVersion, MODERN_WIRE_REVISION, SERVER_INFO_META_KEY, SUBSCRIPTI

import type { ServerEventBus } from './serverEventBus';
import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus';

/** Default SSE comment-frame keepalive interval for listen streams. */
export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000;
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';

/** Default capacity guard: refuse a new subscription when this many are already open. */
export const DEFAULT_MAX_SUBSCRIPTIONS = 1024;
Expand Down Expand Up @@ -124,7 +122,7 @@ export interface ListenRouter {
export function createListenRouter(options: ListenRouterOptions): ListenRouter {
const { bus, onerror } = options;
const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS;
const keepAliveMs = options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS;
const keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;

const open = new Set<(graceful: boolean) => void>();

Expand Down Expand Up @@ -188,7 +186,11 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
);
}
closed = true;
unsubscribe?.();
try {
unsubscribe?.();
} catch (error) {
onerror?.(error instanceof Error ? error : new Error(String(error)));
}
if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer);
abortCleanup?.();
open.delete(teardown);
Expand Down Expand Up @@ -218,14 +220,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
writeNotification(note.method, note.params);
});

if (keepAliveMs > 0) {
keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs);
// Do not hold the event loop open on idle subscriptions. Node's
// setInterval returns a Timeout with .unref(); browsers/Workers
// return a number — the cast is an environment shim, not a
// workaround for SDK typing.
(keepAliveTimer as { unref?: () => void }).unref?.();
}
keepAliveTimer = armSseKeepAlive(keepAliveMs, () => writeFrame(': keepalive\n\n'));

open.add(teardown);
},
Expand All @@ -251,7 +246,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
Expand Down
16 changes: 15 additions & 1 deletion packages/server/src/server/perRequestTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
SdkErrorCode
} from '@modelcontextprotocol/core-internal';

import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';

/**
* How the transport shapes its HTTP response for a request:
*
Expand All @@ -79,6 +81,8 @@ export interface PerRequestHTTPServerTransportOptions {
classification: MessageClassification;
/** Response shaping for the exchange; defaults to `auto`. */
responseMode?: PerRequestResponseMode;
/** SSE keep-alive interval in milliseconds; defaults to `15000`, `0` disables. */
keepAliveMs?: number;
}

/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */
Expand Down Expand Up @@ -107,6 +111,7 @@ interface SseSink {
controller: ReadableStreamDefaultController<Uint8Array>;
encoder: InstanceType<typeof TextEncoder>;
closed: boolean;
keepAliveTimer?: ReturnType<typeof setInterval>;
}

/**
Expand Down Expand Up @@ -140,10 +145,12 @@ export class PerRequestHTTPServerTransport implements Transport {
private _deferredResponse?: DeferredResponse;
private _sse?: SseSink;
private _abortCleanup?: () => void;
private readonly _keepAliveMs: number;

constructor(options: PerRequestHTTPServerTransportOptions) {
this._classification = options.classification;
this._responseMode = options.responseMode ?? 'auto';
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
}

async start(): Promise<void> {
Expand Down Expand Up @@ -343,6 +350,9 @@ export class PerRequestHTTPServerTransport implements Transport {
this._abortCleanup?.();
this._abortCleanup = undefined;

if (this._sse?.keepAliveTimer !== undefined) {
clearInterval(this._sse.keepAliveTimer);
}
if (this._sse !== undefined && !this._sse.closed) {
this._sse.closed = true;
try {
Expand Down Expand Up @@ -382,13 +392,14 @@ export class PerRequestHTTPServerTransport implements Transport {
}
});
this._sse = { controller, encoder: new TextEncoder(), closed: false };
this._sse.keepAliveTimer = armSseKeepAlive(this._keepAliveMs, () => this.writeCommentFrame('keepalive'));

this.settleResponse(
new Response(readable, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Disable proxy buffering so streamed messages are
// delivered as they are written.
Expand All @@ -399,6 +410,9 @@ export class PerRequestHTTPServerTransport implements Transport {
}

private finalizeStream(): void {
if (this._sse?.keepAliveTimer !== undefined) {
clearInterval(this._sse.keepAliveTimer);
}
if (this._sse !== undefined && !this._sse.closed) {
this._sse.closed = true;
try {
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/server/sseKeepAlive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** Default interval between SSE keep-alive comment frames. */
export const DEFAULT_SSE_KEEP_ALIVE_MS = 15_000;

const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;

/** Arms an unref'd timer, or disables keep-alive for invalid delays. */
export function armSseKeepAlive(intervalMs: number, onTick: () => void): ReturnType<typeof setInterval> | undefined {
if (!Number.isFinite(intervalMs) || intervalMs < 1) {
return undefined;
}

const timer = setInterval(onTick, Math.min(intervalMs, MAX_TIMER_DELAY_MS));
(timer as { unref?: () => void }).unref?.();
return timer;
}
Loading
Loading