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
45 ? jsonRpcErrorBody(status, code, message)45 ? jsonRpcErrorBody(status, code, message)
46 : jsonRpcErrorBody(status, code, message, { challenge });46 : jsonRpcErrorBody(status, code, message, { challenge });
47 47
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 */
55const 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
48const renderAuthError = (67const renderAuthError = (
49 auth: McpAuthProvider["Service"],68 auth: McpAuthProvider["Service"],
50 request: Request,69 request: Request,
@@ -217,7 +236,7 @@ export const makeCloudMcpAgentHandler = () => { · 166 unmodified lines
217 236
218 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;237 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
219 if (sessionId && !existingSession) {238 if (sessionId && !existingSession) {
220 return jsonRpcResponse(404, -32001, "Session not found");239 return deadSessionResponse(request.method, "Session not found");
221 }240 }
222 if (existingSession) {241 if (existingSession) {
223 const owner = await existingSession.validateMcpSessionOwner({242 const owner = await existingSession.validateMcpSessionOwner({
@@ -225,13 +244,13 @@ export const makeCloudMcpAgentHandler = () => { · 1 unmodified line
225 organizationId: outcome.principal.organizationId,244 organizationId: outcome.principal.organizationId,
226 });245 });
227 if (owner === "not_found") {246 if (owner === "not_found") {
228 return jsonRpcResponse(404, -32001, "Session not found");247 return deadSessionResponse(request.method, "Session not found");
229 }248 }
230 if (owner === "terminated") {249 if (owner === "terminated") {
231 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage250 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
232 // yet. Same envelope as the post-destroy race below: the client must251 // yet. Same envelope as the post-destroy race below: the client must
233 // treat the id as dead and reconnect.252 // 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");
235 }254 }
236 if (owner === "forbidden") {255 if (owner === "forbidden") {
237 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");256 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
@@ -267,7 +286,7 @@ export const makeCloudMcpAgentHandler = () => { · 29 unmodified lines
267 // client to be told to reconnect, matching a timed-out session).286 // client to be told to reconnect, matching a timed-out session).
268 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal287 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
269 if (Predicate.isError(error) && error.message === "destroyed") {288 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");
271 }290 }
272 // 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 unchanged291 // 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
273 throw error;292 throw error;
apps/host-cloudflare/src/mcp/agent-handler.ts+22−3
@@ -41,6 +41,25 @@ const jsonRpcResponse = ( · 40 unmodified lines
41 ? jsonRpcErrorBody(status, code, message)41 ? jsonRpcErrorBody(status, code, message)
42 : jsonRpcErrorBody(status, code, message, { challenge });42 : jsonRpcErrorBody(status, code, message, { challenge });
43 43
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 */
51const 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
44const renderAuthError = (63const renderAuthError = (
45 auth: McpAuthProvider["Service"],64 auth: McpAuthProvider["Service"],
46 request: Request,65 request: Request,
@@ -145,7 +164,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 98 unmodified lines
145 164
146 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;165 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
147 if (sessionId && !existingSession) {166 if (sessionId && !existingSession) {
148 return jsonRpcResponse(404, -32001, "Session not found");167 return deadSessionResponse(request.method, "Session not found");
149 }168 }
150 if (existingSession) {169 if (existingSession) {
151 const owner = await existingSession.validateMcpSessionOwner({170 const owner = await existingSession.validateMcpSessionOwner({
@@ -153,12 +172,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 1 unmodified line
153 organizationId: outcome.principal.organizationId,172 organizationId: outcome.principal.organizationId,
154 });173 });
155 if (owner === "not_found") {174 if (owner === "not_found") {
156 return jsonRpcResponse(404, -32001, "Session not found");175 return deadSessionResponse(request.method, "Session not found");
157 }176 }
158 if (owner === "terminated") {177 if (owner === "terminated") {
159 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage178 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
160 // yet; the terminated id must read as dead immediately.179 // 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");
162 }181 }
163 if (owner === "forbidden") {182 if (owner === "forbidden") {
164 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");183 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");