Skip to content

Commit 4c05a94

Browse files
committed
fix: reconnect stale SSE MCP sessions
1 parent cb27309 commit 4c05a94

4 files changed

Lines changed: 137 additions & 16 deletions

File tree

core/context/mcp/MCPConnection.ts

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ function is401Error(error: unknown) {
5656
);
5757
}
5858

59+
function createMcpClient() {
60+
return new Client(
61+
{
62+
name: "continue-client",
63+
version: "1.0.0",
64+
},
65+
{
66+
capabilities: {},
67+
},
68+
);
69+
}
70+
5971
export type MCPExtras = {
6072
ide: IDE;
6173
};
@@ -85,19 +97,70 @@ class MCPConnection {
8597
// Don't construct transport in constructor to avoid blocking
8698
this.transport = {} as Transport; // Will be set in connectClient
8799

88-
this.client = new Client(
89-
{
90-
name: "continue-client",
91-
version: "1.0.0",
92-
},
93-
{
94-
capabilities: {},
95-
},
96-
);
100+
this.client = createMcpClient();
97101

98102
this.abortController = new AbortController();
99103
}
100104

105+
private async resetClientAndTransport() {
106+
try {
107+
await this.client.close();
108+
} catch {
109+
// Ignore close errors while replacing stale clients/transports.
110+
}
111+
112+
try {
113+
await this.transport.close?.();
114+
} catch {
115+
// Ignore close errors while replacing stale clients/transports.
116+
}
117+
118+
this.client = createMcpClient();
119+
this.transport = {} as Transport;
120+
}
121+
122+
private shouldReconnectAfterError(error: unknown) {
123+
if (this.options.type !== "sse" || this.status === "disabled") {
124+
return false;
125+
}
126+
127+
const message = (
128+
error instanceof Error ? error.message : String(error)
129+
).toLowerCase();
130+
131+
const sessionError =
132+
message.includes("session") &&
133+
(message.includes("invalid") ||
134+
message.includes("unknown") ||
135+
message.includes("expired") ||
136+
message.includes("not found") ||
137+
message.includes("valid") ||
138+
message.includes("missing"));
139+
140+
return (
141+
sessionError ||
142+
message.includes("connection closed") ||
143+
message.includes("transport closed")
144+
);
145+
}
146+
147+
private async withSseReconnectRetry<T>(operation: () => Promise<T>) {
148+
try {
149+
return await operation();
150+
} catch (error) {
151+
if (!this.shouldReconnectAfterError(error)) {
152+
throw error;
153+
}
154+
155+
await this.connectClient(true, new AbortController().signal);
156+
if (this.status !== "connected") {
157+
throw error;
158+
}
159+
160+
return await operation();
161+
}
162+
}
163+
101164
async disconnect(disable = false) {
102165
this.abortController.abort();
103166
await this.client.close();
@@ -147,6 +210,7 @@ class MCPConnection {
147210

148211
this.abortController.abort();
149212
this.abortController = new AbortController();
213+
await this.resetClientAndTransport();
150214

151215
// currently support oauth for sse transports only
152216
if (this.options.type === "sse") {
@@ -613,11 +677,25 @@ Org-level secrets can only be used for MCP by Background Agents (https://docs.co
613677
}
614678

615679
async getResource(uri: string) {
616-
return await this.client.readResource(
617-
{ uri },
618-
{
619-
timeout: this.options.timeout,
620-
},
680+
return await this.withSseReconnectRetry(() =>
681+
this.client.readResource(
682+
{ uri },
683+
{
684+
timeout: this.options.timeout,
685+
},
686+
),
687+
);
688+
}
689+
690+
async getPrompt(...args: Parameters<Client["getPrompt"]>) {
691+
return await this.withSseReconnectRetry(() =>
692+
this.client.getPrompt(...args),
693+
);
694+
}
695+
696+
async callTool(...args: Parameters<Client["callTool"]>) {
697+
return await this.withSseReconnectRetry(() =>
698+
this.client.callTool(...args),
621699
);
622700
}
623701
}

core/context/mcp/MCPConnection.vitest.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,49 @@ describe("MCPConnection", () => {
310310
expect(mockConnect).toHaveBeenCalled();
311311
});
312312

313+
it("should reconnect and retry SSE tool calls after stale session errors", async () => {
314+
const conn = new MCPConnection({
315+
name: "test-mcp",
316+
id: "test-id",
317+
type: "sse",
318+
url: "http://test.com/events",
319+
});
320+
conn.status = "connected";
321+
322+
const mockCallTool = vi
323+
.spyOn(Client.prototype, "callTool")
324+
.mockRejectedValueOnce(new Error("Invalid session ID"))
325+
.mockResolvedValueOnce({ content: [], isError: false } as any);
326+
const mockReconnect = vi
327+
.spyOn(conn, "connectClient")
328+
.mockImplementation(async () => {
329+
conn.status = "connected";
330+
});
331+
332+
const result = await conn.callTool({ name: "test-tool" } as any);
333+
334+
expect(result).toEqual({ content: [], isError: false });
335+
expect(mockReconnect).toHaveBeenCalledWith(true, expect.any(AbortSignal));
336+
expect(mockCallTool).toHaveBeenCalledTimes(2);
337+
});
338+
339+
it("should not retry non-SSE tool calls after stale session errors", async () => {
340+
const conn = new MCPConnection(options);
341+
conn.status = "connected";
342+
343+
const mockCallTool = vi
344+
.spyOn(Client.prototype, "callTool")
345+
.mockRejectedValue(new Error("Invalid session ID"));
346+
const mockReconnect = vi.spyOn(conn, "connectClient");
347+
348+
await expect(conn.callTool({ name: "test-tool" } as any)).rejects.toThrow(
349+
"Invalid session ID",
350+
);
351+
352+
expect(mockReconnect).not.toHaveBeenCalled();
353+
expect(mockCallTool).toHaveBeenCalledTimes(1);
354+
});
355+
313356
it.skip("should include stderr output in error message when stdio command fails", async () => {
314357
// Clear any existing mocks to ensure we get real behavior
315358
vi.restoreAllMocks();

core/context/mcp/MCPManagerSingleton.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export class MCPManagerSingleton {
196196
`Error getting prompt: MCP Connection ${serverName} not found`,
197197
);
198198
}
199-
return await connection.client.getPrompt({
199+
return await connection.getPrompt({
200200
name: promptName,
201201
arguments: args,
202202
});

core/tools/callTool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ async function callToolFromUri(
9999
args,
100100
extras.tool?.function?.parameters,
101101
);
102-
const response = await client.client.callTool(
102+
const response = await client.callTool(
103103
{
104104
name: toolName,
105105
arguments: coercedArgs,

0 commit comments

Comments
 (0)