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

Showing the changes to apps (2 of 6 files). Show all

Unified · Split

2 files
  1. apps/cloud/src/mcp/agent-handler.ts +23 −4
  2. apps/host-cloudflare/src/mcp/agent-handler.ts +22 −3
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");