History

Answer dead-session standalone GETs with 405 to stop reconnect loops (#1622)

Rhys Sullivan committed 2 months ago

Commit f3ec48d1 parent a8d3d3c6 files · +156 −15Browse files

Unified · Split

6 files
  1. apps/cloud/src/mcp/agent-handler.ts +23 −4
  2. apps/host-cloudflare/src/mcp/agent-handler.ts +22 −3
  3. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +28 −0
  4. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +20 −2
  5. packages/hosts/mcp/src/envelope.test.ts +37 −0
  6. packages/hosts/mcp/src/envelope.ts +26 −6
apps/cloud/src/mcp/agent-handler.ts+23−4
@@ -45,6 +45,25 @@ const jsonRpcResponse = ( · 44 unmodified lines
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,7 +236,7 @@ export const makeCloudMcpAgentHandler = () => { · 166 unmodified lines
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({
@@ -225,13 +244,13 @@ export const makeCloudMcpAgentHandler = () => { · 1 unmodified line
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 = () => { · 29 unmodified lines
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+22−3
@@ -41,6 +41,25 @@ const jsonRpcResponse = ( · 40 unmodified lines
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,7 +164,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 98 unmodified lines
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({
@@ -153,12 +172,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 1 unmodified line
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+28−0
@@ -576,4 +576,32 @@ describe("McpAgentSessionDOBase session serving", () => { · 575 unmodified lines
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+20−2
@@ -298,6 +298,24 @@ type QueuedTransportMessage = { · 297 unmodified lines
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< · 878 unmodified lines
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< · 93 unmodified lines
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+37−0
@@ -252,6 +252,43 @@ describe("McpServingRoutes envelope", () => { · 251 unmodified lines
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+26−6
@@ -238,11 +238,29 @@ const renderAuthError = ( · 237 unmodified lines
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) => · 149 unmodified lines
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 /**