Unified · Split
| @@ -45,6 +45,25 @@ const jsonRpcResponse = ( · 44 unmodified lines | ||
| 45 | 45 | ? jsonRpcErrorBody(status, code, message) |
| 46 | 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 | + */ | |
| 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 | + | |
| 48 | 67 | const renderAuthError = ( |
| 49 | 68 | auth: McpAuthProvider["Service"], |
| 50 | 69 | request: Request, |
| @@ -217,7 +236,7 @@ export const makeCloudMcpAgentHandler = () => { · 166 unmodified lines | ||
| 217 | 236 | |
| 218 | 237 | const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; |
| 219 | 238 | if (sessionId && !existingSession) { |
| 220 | − return jsonRpcResponse(404, -32001, "Session not found"); | |
| 239 | + return deadSessionResponse(request.method, "Session not found"); | |
| 221 | 240 | } |
| 222 | 241 | if (existingSession) { |
| 223 | 242 | const owner = await existingSession.validateMcpSessionOwner({ |
| @@ -225,13 +244,13 @@ export const makeCloudMcpAgentHandler = () => { · 1 unmodified line | ||
| 225 | 244 | organizationId: outcome.principal.organizationId, |
| 226 | 245 | }); |
| 227 | 246 | if (owner === "not_found") { |
| 228 | − return jsonRpcResponse(404, -32001, "Session not found"); | |
| 247 | + return deadSessionResponse(request.method, "Session not found"); | |
| 229 | 248 | } |
| 230 | 249 | if (owner === "terminated") { |
| 231 | 250 | // DELETE-condemned but the deferred destroy alarm hasn't wiped storage |
| 232 | 251 | // yet. Same envelope as the post-destroy race below: the client must |
| 233 | 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 | 255 | if (owner === "forbidden") { |
| 237 | 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 | 286 | // client to be told to reconnect, matching a timed-out session). |
| 268 | 287 | // 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 | 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 | 291 | // 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 | 292 | throw error; |
| @@ -41,6 +41,25 @@ const jsonRpcResponse = ( · 40 unmodified lines | ||
| 41 | 41 | ? jsonRpcErrorBody(status, code, message) |
| 42 | 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 | + */ | |
| 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 | + | |
| 44 | 63 | const renderAuthError = ( |
| 45 | 64 | auth: McpAuthProvider["Service"], |
| 46 | 65 | request: Request, |
| @@ -145,7 +164,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 98 unmodified lines | ||
| 145 | 164 | |
| 146 | 165 | const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; |
| 147 | 166 | if (sessionId && !existingSession) { |
| 148 | − return jsonRpcResponse(404, -32001, "Session not found"); | |
| 167 | + return deadSessionResponse(request.method, "Session not found"); | |
| 149 | 168 | } |
| 150 | 169 | if (existingSession) { |
| 151 | 170 | const owner = await existingSession.validateMcpSessionOwner({ |
| @@ -153,12 +172,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 1 unmodified line | ||
| 153 | 172 | organizationId: outcome.principal.organizationId, |
| 154 | 173 | }); |
| 155 | 174 | if (owner === "not_found") { |
| 156 | − return jsonRpcResponse(404, -32001, "Session not found"); | |
| 175 | + return deadSessionResponse(request.method, "Session not found"); | |
| 157 | 176 | } |
| 158 | 177 | if (owner === "terminated") { |
| 159 | 178 | // DELETE-condemned but the deferred destroy alarm hasn't wiped storage |
| 160 | 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 | 182 | if (owner === "forbidden") { |
| 164 | 183 | return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); |
| @@ -576,4 +576,32 @@ describe("McpAgentSessionDOBase session serving", () => { · 575 unmodified lines | ||
| 576 | 576 | error: { code: -32001, message: "Session not found" }, |
| 577 | 577 | }); |
| 578 | 578 | }); |
| 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 | + }); | |
| 579 | 607 | }); |
| @@ -298,6 +298,24 @@ type QueuedTransportMessage = { · 297 unmodified lines | ||
| 298 | 298 | readonly extra?: MessageExtraInfo; |
| 299 | 299 | }; |
| 300 | 300 | |
| 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 | + | |
| 301 | 319 | export abstract class McpAgentSessionDOBase< |
| 302 | 320 | Env extends Cloudflare.Env = Cloudflare.Env, |
| 303 | 321 | TDbHandle extends SessionDbHandle = SessionDbHandle, |
| @@ -1182,7 +1200,7 @@ export abstract class McpAgentSessionDOBase< · 878 unmodified lines | ||
| 1182 | 1200 | return this.serializedTransportRequest(async () => { |
| 1183 | 1201 | const transport = this.transport; |
| 1184 | 1202 | if (!transport) { |
| 1185 | − return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); | |
| 1203 | + return deadSessionDoResponse(request.method); | |
| 1186 | 1204 | } |
| 1187 | 1205 | if (request.method === "GET") { |
| 1188 | 1206 | const lastEventId = request.headers.get("last-event-id"); |
| @@ -1282,7 +1300,7 @@ export abstract class McpAgentSessionDOBase< · 93 unmodified lines | ||
| 1282 | 1300 | if (!stored) { |
| 1283 | 1301 | if (!isInitializeBody(parsedBody)) { |
| 1284 | 1302 | return request.headers.has("mcp-session-id") |
| 1285 | − ? jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }) | |
| 1303 | + ? deadSessionDoResponse(request.method) | |
| 1286 | 1304 | : jsonRpcErrorBody(400, -32000, "Bad Request: Server not initialized", { |
| 1287 | 1305 | cors: false, |
| 1288 | 1306 | }); |
| @@ -252,6 +252,43 @@ describe("McpServingRoutes envelope", () => { · 251 unmodified lines | ||
| 252 | 252 | }); |
| 253 | 253 | }); |
| 254 | 254 | |
| 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 | + | |
| 255 | 292 | it("404s a modern request whose toolkit route is not served", async () => { |
| 256 | 293 | const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); |
| 257 | 294 | const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra")); |
| @@ -238,11 +238,29 @@ const renderAuthError = ( · 237 unmodified lines | ||
| 238 | 238 | Match.exhaustive, |
| 239 | 239 | ); |
| 240 | 240 | |
| 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 | +}; | |
| 246 | 264 | |
| 247 | 265 | const withModernMcpCors = (response: Response): Response => { |
| 248 | 266 | const headers = new Headers(response.headers); |
| @@ -398,7 +416,9 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => · 149 unmodified lines | ||
| 398 | 416 | sessionId, |
| 399 | 417 | method: request.method, |
| 400 | 418 | }); |
| 401 | − return fromWebResponse(result instanceof Response ? result : renderDispatchError(result)); | |
| 419 | + return fromWebResponse( | |
| 420 | + result instanceof Response ? result : renderDispatchError(result, request.method), | |
| 421 | + ); | |
| 402 | 422 | }); |
| 403 | 423 | |
| 404 | 424 | /** |