@@ -45,6 +45,25 @@ const jsonRpcResponse = (
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+
4867const renderAuthError = (
4968 auth : McpAuthProvider [ "Service" ] ,
5069 request : Request ,
@@ -217,21 +236,21 @@ export const makeCloudMcpAgentHandler = () => {
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 ( {
224243 accountId : outcome . principal . accountId ,
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 = () => {
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 ;
0 commit comments