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/cloud/src (1 of 6 files). Show all

Unified · Split

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;