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
9 changes: 9 additions & 0 deletions .changeset/mcp-stale-session-self-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"agents": patch
---

Recover MCP streamable-http connections whose persisted session expired on the server.

A streamable-http session id is persisted on connect and fed back into the transport when the Agent wakes from hibernation. The MCP SDK skips the initialize handshake when resuming a session, so if the server had restarted or expired that session in the meantime, the connection reported `connected` while every request — including discovery — was rejected with HTTP 404. The stale session id was never cleared, so the connection stayed wedged with no tools across every subsequent wake until the server was removed and re-added.

Discovery on a resumed session that fails with a 404 now clears the stale session id from memory and storage and reconnects — performing a fresh initialize handshake without a session ID, as the MCP spec requires — then runs discovery once on the new session. A 404 on a session negotiated in the current lifetime is still reported as a plain discovery failure and does not trigger reconnection.
32 changes: 32 additions & 0 deletions packages/agents/src/mcp/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@modelcontextprotocol/sdk/client/sse.js";
import {
StreamableHTTPClientTransport,
StreamableHTTPError,
type StreamableHTTPClientTransportOptions
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
// Import types directly from MCP SDK
Expand Down Expand Up @@ -109,6 +110,13 @@ export type MCPClientConnectionResult = {
export type MCPDiscoveryResult = {
success: boolean;
error?: string;
/**
* Set when a resumed streamable-http session was rejected with an HTTP 404
* during discovery: the server no longer knows the persisted session, so
* the caller must clear it and re-initialize without a session ID.
* @internal
*/
staleSession?: boolean;
};

/**
Expand Down Expand Up @@ -619,6 +627,17 @@ export class MCPClientConnection {
this.connectionState = MCPConnectionState.CONNECTED;

const error = e instanceof Error ? e.message : String(e);
// An HTTP 404 while probing a resumed streamable-http session means
// the server expired the persisted session. The MCP spec requires
// starting a new session by re-initializing without a session ID —
// flag it so the manager can clear the session and reconnect.
if (
this._probingCapabilities &&
e instanceof StreamableHTTPError &&
e.code === 404
) {
return { success: false, error, staleSession: true };
}
return { success: false, error };
} finally {
// Clean up the abort controller
Expand Down Expand Up @@ -807,6 +826,19 @@ export class MCPClientConnection {
return undefined;
}

/**
* Drop the session resumed from storage so the next init() performs a
* fresh initialize handshake instead of resuming. The live transport is
* discarded by init() re-entry; new transports are built from the
* transport options.
* @internal
*/
clearResumedSession(): void {
if ("sessionId" in this.options.transport) {
delete this.options.transport.sessionId;
}
}

private getTransportName(
transport?:
| StreamableHTTPClientTransport
Expand Down
52 changes: 51 additions & 1 deletion packages/agents/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,7 +1594,57 @@ export class MCPClientManager {
}

// Delegate to connection's discover method which handles cancellation and timeout
const result = await conn.discover(options);
const { staleSession, ...result } = await conn.discover(options);
if (staleSession) {
return this._recoverStaleSession(conn, serverId, options);
}
this._onServerStateChanged.fire();

return {
...result,
state: conn.connectionState
};
}

/**
* A resumed streamable-http session was rejected with a 404: the server no
* longer knows the persisted sessionId. Per the MCP spec the client MUST
* start a new session by re-initializing without a session ID, so drop the
* stale session from memory and storage, reconnect (init() performs a
* fresh initialize handshake) and discover once. Runs at most once per
* discover call — a repeated failure is returned as-is.
*/
private async _recoverStaleSession(
conn: MCPClientConnection,
serverId: string,
options: { timeoutMs?: number }
): Promise<MCPDiscoverResult> {
conn.clearResumedSession();
this.updateStoredSessionId(serverId, undefined);

let connectResult: MCPConnectionResult;
try {
connectResult = await this.connectToServer(serverId);
} catch (error) {
return {
success: false,
error: toErrorMessage(error),
state: conn.connectionState
};
}

if (connectResult.state !== MCPConnectionState.CONNECTED) {
return {
success: false,
error:
connectResult.state === MCPConnectionState.FAILED
? connectResult.error
: `Connection in ${connectResult.state} state after session re-initialization`,
state: conn.connectionState
};
}

const { staleSession: _, ...result } = await conn.discover(options);
this._onServerStateChanged.fire();

return {
Expand Down
Loading
Loading