Skip to content

Commit f3ec48d

Browse files
authored
Answer dead-session standalone GETs with 405 to stop reconnect loops (#1622)
1 parent a8d3d3c commit f3ec48d

6 files changed

Lines changed: 156 additions & 15 deletions

File tree

apps/cloud/src/mcp/agent-handler.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@ const jsonRpcResponse = (
4545
? jsonRpcErrorBody(status, code, message)
4646
: jsonRpcErrorBody(status, code, message, { challenge });
4747

48+
/**
49+
* A dead session id answers by request method. POST/DELETE keep the 404 that
50+
* tells a compliant client to re-initialize. A standalone GET gets 405: the
51+
* v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
52+
* which breaks the reconnect loops of pre-cutover always-on deployments —
53+
* their GET-404 path never re-initialized, it just retried forever.
54+
*/
55+
const deadSessionResponse = (method: string, message: string): Response =>
56+
method === "GET"
57+
? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
58+
status: 405,
59+
headers: {
60+
"content-type": "application/json",
61+
allow: "POST, DELETE",
62+
"access-control-allow-origin": "*",
63+
},
64+
})
65+
: jsonRpcResponse(404, -32001, message);
66+
4867
const renderAuthError = (
4968
auth: McpAuthProvider["Service"],
5069
request: Request,
@@ -217,21 +236,21 @@ export const makeCloudMcpAgentHandler = () => {
217236

218237
const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
219238
if (sessionId && !existingSession) {
220-
return jsonRpcResponse(404, -32001, "Session not found");
239+
return deadSessionResponse(request.method, "Session not found");
221240
}
222241
if (existingSession) {
223242
const owner = await existingSession.validateMcpSessionOwner({
224243
accountId: outcome.principal.accountId,
225244
organizationId: outcome.principal.organizationId,
226245
});
227246
if (owner === "not_found") {
228-
return jsonRpcResponse(404, -32001, "Session not found");
247+
return deadSessionResponse(request.method, "Session not found");
229248
}
230249
if (owner === "terminated") {
231250
// DELETE-condemned but the deferred destroy alarm hasn't wiped storage
232251
// yet. Same envelope as the post-destroy race below: the client must
233252
// treat the id as dead and reconnect.
234-
return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
253+
return deadSessionResponse(request.method, "Session timed out, please reconnect");
235254
}
236255
if (owner === "forbidden") {
237256
return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
@@ -267,7 +286,7 @@ export const makeCloudMcpAgentHandler = () => {
267286
// client to be told to reconnect, matching a timed-out session).
268287
// oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
269288
if (Predicate.isError(error) && error.message === "destroyed") {
270-
return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
289+
return deadSessionResponse(request.method, "Session timed out, please reconnect");
271290
}
272291
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged
273292
throw error;

apps/host-cloudflare/src/mcp/agent-handler.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ const jsonRpcResponse = (
4141
? jsonRpcErrorBody(status, code, message)
4242
: jsonRpcErrorBody(status, code, message, { challenge });
4343

44+
/**
45+
* A dead session id answers by request method. POST/DELETE keep the 404 that
46+
* tells a compliant client to re-initialize. A standalone GET gets 405: the
47+
* v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
48+
* which breaks the reconnect loops of pre-cutover always-on deployments —
49+
* their GET-404 path never re-initialized, it just retried forever.
50+
*/
51+
const deadSessionResponse = (method: string, message: string): Response =>
52+
method === "GET"
53+
? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
54+
status: 405,
55+
headers: {
56+
"content-type": "application/json",
57+
allow: "POST, DELETE",
58+
"access-control-allow-origin": "*",
59+
},
60+
})
61+
: jsonRpcResponse(404, -32001, message);
62+
4463
const renderAuthError = (
4564
auth: McpAuthProvider["Service"],
4665
request: Request,
@@ -145,20 +164,20 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
145164

146165
const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
147166
if (sessionId && !existingSession) {
148-
return jsonRpcResponse(404, -32001, "Session not found");
167+
return deadSessionResponse(request.method, "Session not found");
149168
}
150169
if (existingSession) {
151170
const owner = await existingSession.validateMcpSessionOwner({
152171
accountId: outcome.principal.accountId,
153172
organizationId: outcome.principal.organizationId,
154173
});
155174
if (owner === "not_found") {
156-
return jsonRpcResponse(404, -32001, "Session not found");
175+
return deadSessionResponse(request.method, "Session not found");
157176
}
158177
if (owner === "terminated") {
159178
// DELETE-condemned but the deferred destroy alarm hasn't wiped storage
160179
// yet; the terminated id must read as dead immediately.
161-
return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
180+
return deadSessionResponse(request.method, "Session timed out, please reconnect");
162181
}
163182
if (owner === "forbidden") {
164183
return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,4 +576,32 @@ describe("McpAgentSessionDOBase session serving", () => {
576576
error: { code: -32001, message: "Session not found" },
577577
});
578578
});
579+
580+
it("answers a dead-session standalone GET with 405 so old clients stop retrying", async () => {
581+
const state = new MemoryDurableObjectState();
582+
await state.storage.put("session-meta", {
583+
organizationId: ORGANIZATION_ID,
584+
organizationName: "Old Agent Org",
585+
userId: ACCOUNT_ID,
586+
resource: defaultMcpResource,
587+
} satisfies SessionMeta);
588+
const session = new HarnessSession(state, {} as Cloudflare.Env);
589+
const request = verifiedRequest(
590+
new Request("https://executor.test/mcp", {
591+
method: "GET",
592+
headers: {
593+
accept: "text/event-stream",
594+
"mcp-session-id": SESSION_ID,
595+
},
596+
}),
597+
);
598+
599+
const response = await session.fetch(request);
600+
601+
expect(response.status).toBe(405);
602+
expect(response.headers.get("allow")).toBe("POST, DELETE");
603+
await expect(response.json()).resolves.toMatchObject({
604+
error: { code: -32001, message: "Session not found" },
605+
});
606+
});
579607
});

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,24 @@ type QueuedTransportMessage = {
298298
readonly extra?: MessageExtraInfo;
299299
};
300300

301+
/**
302+
* Dead-session answer by method: POST/DELETE keep the 404 that drives client
303+
* re-initialization; a standalone GET gets 405, which the v1 SDK reads as
304+
* "no SSE stream offered" and stops retrying — breaking the reconnect loops
305+
* of pre-cutover deployments whose GET-404 path never re-initialized.
306+
*/
307+
const deadSessionDoResponse = (method: string): Response =>
308+
method === "GET"
309+
? new Response(
310+
JSON.stringify({
311+
jsonrpc: "2.0",
312+
error: { code: -32001, message: "Session not found" },
313+
id: null,
314+
}),
315+
{ status: 405, headers: { "content-type": "application/json", allow: "POST, DELETE" } },
316+
)
317+
: jsonRpcErrorBody(404, -32001, "Session not found", { cors: false });
318+
301319
export abstract class McpAgentSessionDOBase<
302320
Env extends Cloudflare.Env = Cloudflare.Env,
303321
TDbHandle extends SessionDbHandle = SessionDbHandle,
@@ -1182,7 +1200,7 @@ export abstract class McpAgentSessionDOBase<
11821200
return this.serializedTransportRequest(async () => {
11831201
const transport = this.transport;
11841202
if (!transport) {
1185-
return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false });
1203+
return deadSessionDoResponse(request.method);
11861204
}
11871205
if (request.method === "GET") {
11881206
const lastEventId = request.headers.get("last-event-id");
@@ -1282,7 +1300,7 @@ export abstract class McpAgentSessionDOBase<
12821300
if (!stored) {
12831301
if (!isInitializeBody(parsedBody)) {
12841302
return request.headers.has("mcp-session-id")
1285-
? jsonRpcErrorBody(404, -32001, "Session not found", { cors: false })
1303+
? deadSessionDoResponse(request.method)
12861304
: jsonRpcErrorBody(400, -32000, "Bad Request: Server not initialized", {
12871305
cors: false,
12881306
});

packages/hosts/mcp/src/envelope.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,43 @@ describe("McpServingRoutes envelope", () => {
252252
});
253253
});
254254

255+
it("answers a dead-session standalone GET with 405 so old clients stop retrying", async () => {
256+
const NotFoundStoreLive = Layer.succeed(McpSessionStore)({
257+
dispatch: (): Effect.Effect<McpDispatchResult> => Effect.succeed("not-found"),
258+
dispose: () => Effect.void,
259+
});
260+
const handler = buildHandler(NotFoundStoreLive, McpErrorReporterNoop);
261+
262+
const get = await handler(
263+
new Request("https://host.test/mcp", {
264+
method: "GET",
265+
headers: {
266+
authorization: "Bearer x",
267+
accept: "text/event-stream",
268+
"mcp-session-id": "dead-session",
269+
"mcp-protocol-version": "2025-06-18",
270+
},
271+
}),
272+
);
273+
expect(get.status).toBe(405);
274+
expect(get.headers.get("allow")).toBe("POST, DELETE");
275+
expect(await get.json()).toMatchObject({ error: { code: -32001 } });
276+
277+
const post = await handler(
278+
new Request("https://host.test/mcp", {
279+
method: "POST",
280+
headers: {
281+
authorization: "Bearer x",
282+
"content-type": "application/json",
283+
"mcp-session-id": "dead-session",
284+
"mcp-protocol-version": "2025-06-18",
285+
},
286+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
287+
}),
288+
);
289+
expect(post.status).toBe(404);
290+
});
291+
255292
it("404s a modern request whose toolkit route is not served", async () => {
256293
const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
257294
const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra"));

packages/hosts/mcp/src/envelope.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,29 @@ const renderAuthError = (
238238
Match.exhaustive,
239239
);
240240

241-
/** Render a non-`Response` {@link McpDispatchResult} discriminant. */
242-
const renderDispatchError = (lookup: "not-found" | "forbidden"): Response =>
243-
lookup === "not-found"
244-
? jsonRpcResponse(404, -32001, "Session not found")
245-
: jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
241+
/**
242+
* Render a non-`Response` {@link McpDispatchResult} discriminant. A dead
243+
* session answers by method: POST/DELETE keep the 404 that drives client
244+
* re-initialization; a standalone GET gets 405, which the v1 SDK reads as
245+
* "no SSE stream offered" and stops retrying — breaking pre-cutover
246+
* reconnect loops whose GET-404 path never re-initialized.
247+
*/
248+
const renderDispatchError = (lookup: "not-found" | "forbidden", method: string): Response => {
249+
if (lookup === "forbidden") {
250+
return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
251+
}
252+
if (method === "GET") {
253+
return new Response(
254+
JSON.stringify({
255+
jsonrpc: "2.0",
256+
error: { code: -32001, message: "Session not found" },
257+
id: null,
258+
}),
259+
{ status: 405, headers: { "content-type": "application/json", allow: "POST, DELETE" } },
260+
);
261+
}
262+
return jsonRpcResponse(404, -32001, "Session not found");
263+
};
246264

247265
const withModernMcpCors = (response: Response): Response => {
248266
const headers = new Headers(response.headers);
@@ -398,7 +416,9 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) =>
398416
sessionId,
399417
method: request.method,
400418
});
401-
return fromWebResponse(result instanceof Response ? result : renderDispatchError(result));
419+
return fromWebResponse(
420+
result instanceof Response ? result : renderDispatchError(result, request.method),
421+
);
402422
});
403423

404424
/**

0 commit comments

Comments
 (0)