Unified · Split
| @@ -624,6 +624,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 623 unmodified lines | |||
| 624 | Effect.gen(function* () { | 624 | Effect.gen(function* () { |
| 625 | const owner = yield* requireSelectedOrganization; | 625 | const owner = yield* requireSelectedOrganization; |
| 626 | const stub = getMcpSessionStub(params.mcpSessionId); | 626 | const stub = getMcpSessionStub(params.mcpSessionId); |
| 627 | if (!stub) { | ||
| 628 | return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); | ||
| 629 | } | ||
| 627 | const result = yield* Effect.promise(() => | 630 | const result = yield* Effect.promise(() => |
| 628 | stub.getPausedExecutionForApproval(params.executionId, { | 631 | stub.getPausedExecutionForApproval(params.executionId, { |
| 629 | accountId: owner.accountId, | 632 | accountId: owner.accountId, |
| @@ -645,6 +648,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines | |||
| 645 | Effect.gen(function* () { | 648 | Effect.gen(function* () { |
| 646 | const owner = yield* requireSelectedOrganization; | 649 | const owner = yield* requireSelectedOrganization; |
| 647 | const stub = getMcpSessionStub(params.mcpSessionId); | 650 | const stub = getMcpSessionStub(params.mcpSessionId); |
| 651 | if (!stub) { | ||
| 652 | return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); | ||
| 653 | } | ||
| 648 | const result = yield* Effect.promise(() => | 654 | const result = yield* Effect.promise(() => |
| 649 | stub.resumeExecutionForApproval( | 655 | stub.resumeExecutionForApproval( |
| 650 | params.executionId, | 656 | params.executionId, |
| @@ -161,11 +161,10 @@ export const makeCloudMcpAgentHandler = () => { · 160 unmodified lines | |||
| 161 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave | 161 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave |
| 162 | // the session intact, so the condemn path is gated on `Forbidden` alone. | 162 | // the session intact, so the condemn path is gated on `Forbidden` alone. |
| 163 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | 163 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 164 | const session = mcpSessionStub(env.MCP_SESSION, sessionId); | ||
| 164 | await Effect.runPromise( | 165 | await Effect.runPromise( |
| 165 | Effect.ignore( | 166 | Effect.ignore( |
| 166 | Effect.tryPromise(() => | 167 | session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, |
| 167 | mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), | ||
| 168 | ), | ||
| 169 | ), | 168 | ), |
| 170 | ); | 169 | ); |
| 171 | } | 170 | } |
| @@ -212,8 +211,12 @@ export const makeCloudMcpAgentHandler = () => { · 40 unmodified lines | |||
| 212 | }); | 211 | }); |
| 213 | } | 212 | } |
| 214 | 213 | ||
| 215 | if (sessionId) { | 214 | const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; |
| 216 | const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ | 215 | if (sessionId && !existingSession) { |
| 216 | return jsonRpcResponse(404, -32001, "Session not found"); | ||
| 217 | } | ||
| 218 | if (existingSession) { | ||
| 219 | const owner = await existingSession.validateMcpSessionOwner({ | ||
| 217 | accountId: outcome.principal.accountId, | 220 | accountId: outcome.principal.accountId, |
| 218 | organizationId: outcome.principal.organizationId, | 221 | organizationId: outcome.principal.organizationId, |
| 219 | }); | 222 | }); |
| @@ -244,9 +247,7 @@ export const makeCloudMcpAgentHandler = () => { · 24 unmodified lines | |||
| 244 | ), | 247 | ), |
| 245 | propagation, | 248 | propagation, |
| 246 | ); | 249 | ); |
| 247 | const target = sessionId | 250 | const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; |
| 248 | ? mcpSessionStub(env.MCP_SESSION, sessionId) | ||
| 249 | : createMcpSessionStub(env.MCP_SESSION).stub; | ||
| 250 | let response: Response; | 251 | let response: Response; |
| 251 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch | 252 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch |
| 252 | try { | 253 | try { |
| @@ -325,13 +325,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 324 unmodified lines | |||
| 325 | executionId: string, | 325 | executionId: string, |
| 326 | response: ResumeResponse, | 326 | response: ResumeResponse, |
| 327 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { | 327 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 328 | const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner); | ||
| 329 | if (!ownerSession) { | ||
| 330 | return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); | ||
| 331 | } | ||
| 328 | return Effect.tryPromise({ | 332 | return Effect.tryPromise({ |
| 329 | try: () => | 333 | try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), |
| 330 | mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel( | ||
| 331 | executionId, | ||
| 332 | identity, | ||
| 333 | response, | ||
| 334 | ), | ||
| 335 | catch: (cause) => new McpModelResumeForwardError({ cause }), | 334 | catch: (cause) => new McpModelResumeForwardError({ cause }), |
| 336 | }); | 335 | }); |
| 337 | } | 336 | } |
| @@ -99,11 +99,10 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 98 unmodified lines | |||
| 99 | const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); | 99 | const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); |
| 100 | if (!Predicate.isTagged(outcome, "Authenticated")) { | 100 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 101 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | 101 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 102 | const session = mcpSessionStub(env.MCP_SESSION, sessionId); | ||
| 102 | await Effect.runPromise( | 103 | await Effect.runPromise( |
| 103 | Effect.ignore( | 104 | Effect.ignore( |
| 104 | Effect.tryPromise(() => | 105 | session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, |
| 105 | mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), | ||
| 106 | ), | ||
| 107 | ), | 106 | ), |
| 108 | ); | 107 | ); |
| 109 | } | 108 | } |
| @@ -140,8 +139,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 30 unmodified lines | |||
| 140 | return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); | 139 | return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); |
| 141 | } | 140 | } |
| 142 | 141 | ||
| 143 | if (sessionId) { | 142 | const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; |
| 144 | const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ | 143 | if (sessionId && !existingSession) { |
| 144 | return jsonRpcResponse(404, -32001, "Session not found"); | ||
| 145 | } | ||
| 146 | if (existingSession) { | ||
| 147 | const owner = await existingSession.validateMcpSessionOwner({ | ||
| 145 | accountId: outcome.principal.accountId, | 148 | accountId: outcome.principal.accountId, |
| 146 | organizationId: outcome.principal.organizationId, | 149 | organizationId: outcome.principal.organizationId, |
| 147 | }); | 150 | }); |
| @@ -170,9 +173,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 22 unmodified lines | |||
| 170 | ), | 173 | ), |
| 171 | propagation, | 174 | propagation, |
| 172 | ); | 175 | ); |
| 173 | const target = sessionId | 176 | const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; |
| 174 | ? mcpSessionStub(env.MCP_SESSION, sessionId) | ||
| 175 | : createMcpSessionStub(env.MCP_SESSION).stub; | ||
| 176 | return withMcpResponseHeaders(await target.fetch(forwarded)); | 177 | return withMcpResponseHeaders(await target.fetch(forwarded)); |
| 177 | }; | 178 | }; |
| 178 | }; | 179 | }; |
| @@ -35,7 +35,9 @@ export const makeCloudflareApprovalHandler = ( · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | const paused = PAUSED_PATH.exec(pathname); | 36 | const paused = PAUSED_PATH.exec(pathname); |
| 37 | if (paused && request.method === "GET") { | 37 | if (paused && request.method === "GET") { |
| 38 | const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( | 38 | const stub = stubFor(decodeURIComponent(paused[1]!)); |
| 39 | if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); | ||
| 40 | const result = await stub.getPausedExecutionForApproval( | ||
| 39 | decodeURIComponent(paused[2]!), | 41 | decodeURIComponent(paused[2]!), |
| 40 | owner, | 42 | owner, |
| 41 | ); | 43 | ); |
| @@ -53,7 +55,9 @@ export const makeCloudflareApprovalHandler = ( · 11 unmodified lines | |||
| 53 | const response = raw === null ? null : decodeResumeResponse(raw); | 55 | const response = raw === null ? null : decodeResumeResponse(raw); |
| 54 | if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); | 56 | if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); |
| 55 | 57 | ||
| 56 | const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( | 58 | const stub = stubFor(decodeURIComponent(resume[1]!)); |
| 59 | if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); | ||
| 60 | const result = await stub.resumeExecutionForApproval( | ||
| 57 | decodeURIComponent(resume[2]!), | 61 | decodeURIComponent(resume[2]!), |
| 58 | owner, | 62 | owner, |
| 59 | response, | 63 | response, |
| @@ -210,13 +210,12 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 209 unmodified lines | |||
| 210 | executionId: string, | 210 | executionId: string, |
| 211 | response: ResumeResponse, | 211 | response: ResumeResponse, |
| 212 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { | 212 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 213 | const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner); | ||
| 214 | if (!ownerSession) { | ||
| 215 | return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); | ||
| 216 | } | ||
| 213 | return Effect.tryPromise({ | 217 | return Effect.tryPromise({ |
| 214 | try: () => | 218 | try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), |
| 215 | mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel( | ||
| 216 | executionId, | ||
| 217 | identity, | ||
| 218 | response, | ||
| 219 | ), | ||
| 220 | catch: (cause) => new McpModelResumeForwardError({ cause }), | 219 | catch: (cause) => new McpModelResumeForwardError({ cause }), |
| 221 | }); | 220 | }); |
| 222 | } | 221 | } |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import { Cause, Effect } from "effect"; | 2 | import { Cause, Effect } from "effect"; |
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -181,17 +181,28 @@ const engine: ExecutionEngine<Cause.YieldableError> = { · 176 unmodified lines | |||
| 181 | getDescription: Effect.succeed("test Durable Object executor"), | 181 | getDescription: Effect.succeed("test Durable Object executor"), |
| 182 | }; | 182 | }; |
| 183 | 183 | ||
| 184 | class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly end: () => void }> { | 184 | class HarnessSession extends McpAgentSessionDOBase< |
| 185 | Cloudflare.Env, | ||
| 186 | { readonly end: () => void | Promise<void> } | ||
| 187 | > { | ||
| 185 | constructor( | 188 | constructor( |
| 186 | ctx: DurableObjectState, | 189 | ctx: DurableObjectState, |
| 187 | env: Cloudflare.Env, | 190 | env: Cloudflare.Env, |
| 188 | private readonly sessionEngine: ExecutionEngine<Cause.YieldableError> = engine, | 191 | private readonly sessionEngine: ExecutionEngine<Cause.YieldableError> = engine, |
| 192 | private readonly runtimeOptions: { | ||
| 193 | readonly end?: () => void | Promise<void>; | ||
| 194 | readonly sessionTimeoutMs?: number; | ||
| 195 | } = {}, | ||
| 189 | ) { | 196 | ) { |
| 190 | super(ctx, env); | 197 | super(ctx, env); |
| 191 | } | 198 | } |
| 192 | 199 | ||
| 193 | protected override openSessionDb(): { readonly end: () => void } { | 200 | protected override openSessionDb(): { readonly end: () => void | Promise<void> } { |
| 194 | return { end: () => undefined }; | 201 | return { end: this.runtimeOptions.end ?? (() => undefined) }; |
| 202 | } | ||
| 203 | |||
| 204 | protected override sessionTimeoutMs(): number { | ||
| 205 | return this.runtimeOptions.sessionTimeoutMs ?? super.sessionTimeoutMs(); | ||
| 195 | } | 206 | } |
| 196 | 207 | ||
| 197 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { | 208 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { |
| @@ -257,6 +268,10 @@ const makeClientHarness = (state = new MemoryDurableObjectState()) => { · 59 unmodified lines | |||
| 257 | }; | 268 | }; |
| 258 | 269 | ||
| 259 | describe("McpAgentSessionDOBase session serving", () => { | 270 | describe("McpAgentSessionDOBase session serving", () => { |
| 271 | afterEach(() => { | ||
| 272 | vi.restoreAllMocks(); | ||
| 273 | }); | ||
| 274 | |||
| 260 | it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { | 275 | it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { |
| 261 | const harness = makeClientHarness(); | 276 | const harness = makeClientHarness(); |
| 262 | await harness.client.connect(harness.transport); | 277 | await harness.client.connect(harness.transport); |
| @@ -390,6 +405,120 @@ describe("McpAgentSessionDOBase session serving", () => { · 127 unmodified lines | |||
| 390 | const replayBody = await replay.text(); | 405 | const replayBody = await replay.text(); |
| 391 | expect(replayBody).toContain("slow result"); | 406 | expect(replayBody).toContain("slow result"); |
| 392 | expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`); | 407 | expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`); |
| 408 | |||
| 409 | const standaloneReplay = await session.fetch( | ||
| 410 | verifiedRequest( | ||
| 411 | new Request("https://executor.test/mcp", { | ||
| 412 | method: "GET", | ||
| 413 | headers: { | ||
| 414 | accept: "text/event-stream", | ||
| 415 | "mcp-session-id": SESSION_ID, | ||
| 416 | "mcp-protocol-version": "2025-06-18", | ||
| 417 | }, | ||
| 418 | }), | ||
| 419 | ), | ||
| 420 | ); | ||
| 421 | const standaloneReplayBody = await standaloneReplay.text(); | ||
| 422 | expect(standaloneReplayBody).toContain("slow result"); | ||
| 423 | expect(standaloneReplayBody).toContain("event: message"); | ||
| 424 | await state.flushWaitUntil(); | ||
| 425 | }); | ||
| 426 | |||
| 427 | it("restores once while an idle runtime generation is still closing", async () => { | ||
| 428 | let now = 1_000; | ||
| 429 | vi.spyOn(Date, "now").mockImplementation(() => now); | ||
| 430 | let closeStarted = (): void => undefined; | ||
| 431 | const closing = new Promise<void>((resolve) => { | ||
| 432 | closeStarted = resolve; | ||
| 433 | }); | ||
| 434 | let finishClose = (): void => undefined; | ||
| 435 | const closeGate = new Promise<void>((resolve) => { | ||
| 436 | finishClose = resolve; | ||
| 437 | }); | ||
| 438 | let closeCount = 0; | ||
| 439 | const state = new MemoryDurableObjectState(); | ||
| 440 | const session = new HarnessSession(state, {} as Cloudflare.Env, engine, { | ||
| 441 | sessionTimeoutMs: 10, | ||
| 442 | end: () => { | ||
| 443 | closeCount += 1; | ||
| 444 | if (closeCount !== 1) return; | ||
| 445 | closeStarted(); | ||
| 446 | return closeGate; | ||
| 447 | }, | ||
| 448 | }); | ||
| 449 | const post = (body: unknown): Request => | ||
| 450 | verifiedRequest( | ||
| 451 | new Request("https://executor.test/mcp", { | ||
| 452 | method: "POST", | ||
| 453 | headers: { | ||
| 454 | accept: "application/json, text/event-stream", | ||
| 455 | "content-type": "application/json", | ||
| 456 | "mcp-session-id": SESSION_ID, | ||
| 457 | "mcp-protocol-version": "2025-06-18", | ||
| 458 | }, | ||
| 459 | body: JSON.stringify(body), | ||
| 460 | }), | ||
| 461 | ); | ||
| 462 | |||
| 463 | const initialize = await session.fetch( | ||
| 464 | verifiedRequest( | ||
| 465 | new Request("https://executor.test/mcp", { | ||
| 466 | method: "POST", | ||
| 467 | headers: { | ||
| 468 | accept: "application/json, text/event-stream", | ||
| 469 | "content-type": "application/json", | ||
| 470 | }, | ||
| 471 | body: JSON.stringify({ | ||
| 472 | jsonrpc: "2.0", | ||
| 473 | id: "initialize", | ||
| 474 | method: "initialize", | ||
| 475 | params: { | ||
| 476 | protocolVersion: "2025-06-18", | ||
| 477 | capabilities: {}, | ||
| 478 | clientInfo: { name: "restore-race", version: "1.0.0" }, | ||
| 479 | }, | ||
| 480 | }), | ||
| 481 | }), | ||
| 482 | ), | ||
| 483 | ); | ||
| 484 | await initialize.text(); | ||
| 485 | await session.fetch(post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })); | ||
| 486 | |||
| 487 | now += 100; | ||
| 488 | const alarm = session.alarm(); | ||
| 489 | await closing; | ||
| 490 | |||
| 491 | const get = session.fetch( | ||
| 492 | verifiedRequest( | ||
| 493 | new Request("https://executor.test/mcp", { | ||
| 494 | method: "GET", | ||
| 495 | headers: { | ||
| 496 | accept: "text/event-stream", | ||
| 497 | "mcp-session-id": SESSION_ID, | ||
| 498 | "mcp-protocol-version": "2025-06-18", | ||
| 499 | }, | ||
| 500 | }), | ||
| 501 | ), | ||
| 502 | ); | ||
| 503 | const list = session.fetch( | ||
| 504 | post({ jsonrpc: "2.0", id: "concurrent-list", method: "tools/list", params: {} }), | ||
| 505 | ); | ||
| 506 | |||
| 507 | finishClose(); | ||
| 508 | const [getResponse, listResponse] = await Promise.all([get, list]); | ||
| 509 | expect(getResponse.status).toBe(200); | ||
| 510 | await getResponse.body?.cancel(); | ||
| 511 | expect(listResponse.status).toBe(200); | ||
| 512 | expect(await listResponse.text()).toContain("execute"); | ||
| 513 | await alarm; | ||
| 514 | await expect(state.storage.get("executor:mcp:v2:last-activity-ms")).resolves.toBe(now); | ||
| 515 | await expect(state.storage.getAlarm()).resolves.toBe(now + 10); | ||
| 516 | |||
| 517 | const followUp = await session.fetch( | ||
| 518 | post({ jsonrpc: "2.0", id: "follow-up-list", method: "tools/list", params: {} }), | ||
| 519 | ); | ||
| 520 | expect(followUp.status).toBe(200); | ||
| 521 | expect(await followUp.text()).toContain("execute"); | ||
| 393 | }); | 522 | }); |
| 394 | 523 | ||
| 395 | it("persists session metadata and rejects a different principal", async () => { | 524 | it("persists session metadata and rejects a different principal", async () => { |
| @@ -256,6 +256,21 @@ const legacyPrimingFrame = (eventId: string): Uint8Array => · 255 unmodified lines | |||
| 256 | `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`, | 256 | `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`, |
| 257 | ); | 257 | ); |
| 258 | 258 | ||
| 259 | const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array => | ||
| 260 | new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`); | ||
| 261 | |||
| 262 | const combineFrames = (frames: readonly Uint8Array[]): ArrayBuffer => { | ||
| 263 | const byteLength = frames.reduce((total, frame) => total + frame.byteLength, 0); | ||
| 264 | const buffer = new ArrayBuffer(byteLength); | ||
| 265 | const combined = new Uint8Array(buffer); | ||
| 266 | let offset = 0; | ||
| 267 | for (const frame of frames) { | ||
| 268 | combined.set(frame, offset); | ||
| 269 | offset += frame.byteLength; | ||
| 270 | } | ||
| 271 | return buffer; | ||
| 272 | }; | ||
| 273 | |||
| 259 | const mcpResourceFromKey = (resourceKey: string): McpResource => | 274 | const mcpResourceFromKey = (resourceKey: string): McpResource => |
| 260 | resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length | 275 | resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length |
| 261 | ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) } | 276 | ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) } |
| @@ -552,6 +567,7 @@ export abstract class McpAgentSessionDOBase< · 290 unmodified lines | |||
| 552 | 567 | ||
| 553 | private async disposeIdleRuntime(input: { | 568 | private async disposeIdleRuntime(input: { |
| 554 | readonly idleMs: number; | 569 | readonly idleMs: number; |
| 570 | readonly lastActivityMs: number; | ||
| 555 | readonly pausedExecutionCount: number; | 571 | readonly pausedExecutionCount: number; |
| 556 | }): Promise<void> { | 572 | }): Promise<void> { |
| 557 | console.info( | 573 | console.info( |
| @@ -563,6 +579,7 @@ export abstract class McpAgentSessionDOBase< · 5 unmodified lines | |||
| 563 | }), | 579 | }), |
| 564 | ); | 580 | ); |
| 565 | await Effect.runPromise(this.closeRuntime()); | 581 | await Effect.runPromise(this.closeRuntime()); |
| 582 | <<<<<<< HEAD | ||
| 566 | await Effect.runPromise( | 583 | await Effect.runPromise( |
| 567 | Effect.all([ | 584 | Effect.all([ |
| 568 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), | 585 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), |
| @@ -573,6 +590,18 @@ export abstract class McpAgentSessionDOBase< · 4 unmodified lines | |||
| 573 | ), | 590 | ), |
| 574 | ]), | 591 | ]), |
| 575 | ); | 592 | ); |
| 593 | ======= | ||
| 594 | const activityKey = | ||
| 595 | this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY; | ||
| 596 | const cleared = await this.ctx.storage.transaction(async (transaction) => { | ||
| 597 | const current = await transaction.get<number>(activityKey); | ||
| 598 | if (current !== input.lastActivityMs) return false; | ||
| 599 | await transaction.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]); | ||
| 600 | await transaction.deleteAlarm(); | ||
| 601 | return true; | ||
| 602 | }); | ||
| 603 | if (cleared) this.lastActivityMs = 0; | ||
| 604 | >>>>>>> d62e666df (Fix session-id parsing, stranded-stream replay, priming, and restore races) | ||
| 576 | } | 605 | } |
| 577 | 606 | ||
| 578 | private resolveAndStoreSessionMeta(token: McpSessionInit) { | 607 | private resolveAndStoreSessionMeta(token: McpSessionInit) { |
| @@ -823,34 +852,39 @@ export abstract class McpAgentSessionDOBase< · 244 unmodified lines | |||
| 823 | private closeRuntime(): Effect.Effect<void> { | 852 | private closeRuntime(): Effect.Effect<void> { |
| 824 | const self = this; | 853 | const self = this; |
| 825 | return Effect.gen(function* () { | 854 | return Effect.gen(function* () { |
| 826 | yield* self.releaseAllPendingApprovalLeases(); | 855 | // Detach the complete generation before awaiting cleanup. A request that |
| 827 | if (self.transport) { | 856 | // interleaves with a slow server/DB close must build a fresh generation, |
| 828 | const transport = self.transport; | 857 | // never observe initialized=true with a closing/null transport, and the |
| 829 | self.transport = null; | 858 | // old cleanup must never clear fields belonging to that fresh runtime. |
| 830 | yield* Effect.promise(() => transport.close()).pipe(Effect.ignore); | 859 | const transport = self.transport; |
| 831 | } | 860 | const server = self.server; |
| 832 | if (self.server) { | 861 | const modernHandler = self.modernHandler; |
| 833 | const server = self.server; | 862 | const dbHandle = self.dbHandle; |
| 834 | delete (self as { server?: McpServer }).server; | 863 | self.transport = null; |
| 835 | yield* Effect.promise(() => server.close()).pipe(Effect.ignore); | 864 | delete (self as { server?: McpServer }).server; |
| 836 | } | 865 | self.modernHandler = null; |
| 837 | if (self.modernHandler) { | 866 | self.dbHandle = null; |
| 838 | const handler = self.modernHandler; | ||
| 839 | self.modernHandler = null; | ||
| 840 | yield* Effect.promise(() => handler.close()).pipe(Effect.ignore); | ||
| 841 | } | ||
| 842 | self.engine = null; | 867 | self.engine = null; |
| 843 | self.modernRuntime = null; | 868 | self.modernRuntime = null; |
| 844 | self.activeLegacyStreamCount = 0; | 869 | self.activeLegacyStreamCount = 0; |
| 845 | self.legacyRunningRequestCount = 0; | 870 | self.legacyRunningRequestCount = 0; |
| 846 | self.modernRequestBodies = new WeakMap<Request, unknown>(); | 871 | self.modernRequestBodies = new WeakMap<Request, unknown>(); |
| 847 | self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>(); | 872 | self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>(); |
| 848 | if (self.dbHandle) { | 873 | self.initialized = false; |
| 849 | const dbHandle = self.dbHandle; | 874 | |
| 850 | self.dbHandle = null; | 875 | yield* self.releaseAllPendingApprovalLeases(); |
| 876 | if (transport) { | ||
| 877 | yield* Effect.promise(() => transport.close()).pipe(Effect.ignore); | ||
| 878 | } | ||
| 879 | if (server) { | ||
| 880 | yield* Effect.promise(() => server.close()).pipe(Effect.ignore); | ||
| 881 | } | ||
| 882 | if (modernHandler) { | ||
| 883 | yield* Effect.promise(() => modernHandler.close()).pipe(Effect.ignore); | ||
| 884 | } | ||
| 885 | if (dbHandle) { | ||
| 851 | yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore); | 886 | yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore); |
| 852 | } | 887 | } |
| 853 | self.initialized = false; | ||
| 854 | }); | 888 | }); |
| 855 | } | 889 | } |
| 856 | 890 | ||
| @@ -1000,6 +1034,69 @@ export abstract class McpAgentSessionDOBase< · 143 unmodified lines | |||
| 1000 | return typeof streamId === "string" ? streamId : null; | 1034 | return typeof streamId === "string" ? streamId : null; |
| 1001 | } | 1035 | } |
| 1002 | 1036 | ||
| 1037 | private async supersedeReplayStream( | ||
| 1038 | transport: WebStandardStreamableHTTPServerTransport, | ||
| 1039 | lastEventId: string, | ||
| 1040 | ): Promise<void> { | ||
| 1041 | const streamId = await this.eventStore.getStreamIdForEventId(lastEventId); | ||
| 1042 | if (!streamId) return; | ||
| 1043 | // SAFETY: the installed SDK exposes closeSSEStream(requestId), but not the | ||
| 1044 | // reverse request-id map needed to supersede a stale POST connection before | ||
| 1045 | // replay. This is the same pinned map used by requestStreamId above. | ||
| 1046 | const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping"); | ||
| 1047 | if (!(mapping instanceof Map)) return; | ||
| 1048 | for (const [requestId, mappedStreamId] of mapping) { | ||
| 1049 | if ( | ||
| 1050 | mappedStreamId === streamId && | ||
| 1051 | (typeof requestId === "string" || typeof requestId === "number") | ||
| 1052 | ) { | ||
| 1053 | transport.closeSSEStream(requestId); | ||
| 1054 | return; | ||
| 1055 | } | ||
| 1056 | } | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | private trackedLegacyResponse = ( | ||
| 1060 | response: Response, | ||
| 1061 | options: { readonly initialFrame?: Uint8Array; readonly acknowledge?: readonly string[] } = {}, | ||
| 1062 | ): Response => | ||
| 1063 | rotateSseResponse(response, { | ||
| 1064 | ...(options.initialFrame ? { initialFrame: options.initialFrame } : {}), | ||
| 1065 | onOpen: () => { | ||
| 1066 | this.activeLegacyStreamCount += 1; | ||
| 1067 | }, | ||
| 1068 | onClose: (reason) => { | ||
| 1069 | this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1); | ||
| 1070 | if (reason === "complete" && options.acknowledge && options.acknowledge.length > 0) { | ||
| 1071 | this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge)); | ||
| 1072 | } | ||
| 1073 | }, | ||
| 1074 | }); | ||
| 1075 | |||
| 1076 | private async replayUndeliveredOnStandaloneGet(request: Request): Promise<Response | null> { | ||
| 1077 | if (request.method !== "GET" || request.headers.has("last-event-id")) return null; | ||
| 1078 | const frames: Uint8Array[] = []; | ||
| 1079 | const streamIds = await this.eventStore.replayUndeliveredStreams({ | ||
| 1080 | send: (eventId, message) => { | ||
| 1081 | frames.push(replayFrame(eventId, message)); | ||
| 1082 | return Promise.resolve(); | ||
| 1083 | }, | ||
| 1084 | }); | ||
| 1085 | if (frames.length === 0) return null; | ||
| 1086 | return this.trackedLegacyResponse( | ||
| 1087 | new Response(combineFrames(frames), { | ||
| 1088 | headers: { | ||
| 1089 | "content-type": "text/event-stream", | ||
| 1090 | "cache-control": "no-cache, no-transform", | ||
| 1091 | connection: "keep-alive", | ||
| 1092 | "x-accel-buffering": "no", | ||
| 1093 | "mcp-session-id": this.sessionId, | ||
| 1094 | }, | ||
| 1095 | }), | ||
| 1096 | { acknowledge: streamIds }, | ||
| 1097 | ); | ||
| 1098 | } | ||
| 1099 | |||
| 1003 | private async serializedTransportRequest<A>(run: () => Promise<A>): Promise<A> { | 1100 | private async serializedTransportRequest<A>(run: () => Promise<A>): Promise<A> { |
| 1004 | const previous = this.transportRequestTail; | 1101 | const previous = this.transportRequestTail; |
| 1005 | let release = (): void => undefined; | 1102 | let release = (): void => undefined; |
| @@ -1019,25 +1116,34 @@ export abstract class McpAgentSessionDOBase< · 13 unmodified lines | |||
| 1019 | request: Request, | 1116 | request: Request, |
| 1020 | parsedBody: unknown, | 1117 | parsedBody: unknown, |
| 1021 | ): Promise<Response> { | 1118 | ): Promise<Response> { |
| 1022 | const transport = this.transport; | ||
| 1023 | if (!transport) { | ||
| 1024 | return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); | ||
| 1025 | } | ||
| 1026 | return this.serializedTransportRequest(async () => { | 1119 | return this.serializedTransportRequest(async () => { |
| 1120 | const transport = this.transport; | ||
| 1121 | if (!transport) { | ||
| 1122 | return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); | ||
| 1123 | } | ||
| 1124 | if (request.method === "GET") { | ||
| 1125 | const lastEventId = request.headers.get("last-event-id"); | ||
| 1126 | if (lastEventId) { | ||
| 1127 | await this.supersedeReplayStream(transport, lastEventId); | ||
| 1128 | } else { | ||
| 1129 | // Latest-listener-wins. Client cancellation is not reliably relayed | ||
| 1130 | // through every workerd/Vite streaming hop, so explicitly retire a | ||
| 1131 | // stale standalone mapping before opening its replacement. | ||
| 1132 | transport.closeStandaloneSSEStream(); | ||
| 1133 | const replay = await this.replayUndeliveredOnStandaloneGet(request); | ||
| 1134 | if (replay) return replay; | ||
| 1135 | } | ||
| 1136 | } | ||
| 1027 | const toolCallIds = legacyToolCallRequestIds(parsedBody); | 1137 | const toolCallIds = legacyToolCallRequestIds(parsedBody); |
| 1028 | const protocolVersion = | 1138 | const protocolVersion = |
| 1029 | request.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; | 1139 | request.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; |
| 1030 | const needsLegacyPrime = | 1140 | const needsLegacyPrime = |
| 1031 | toolCallIds.length > 0 && protocolVersion < LEGACY_PRIMING_PROTOCOL_VERSION; | 1141 | toolCallIds.length > 0 && protocolVersion < LEGACY_PRIMING_PROTOCOL_VERSION; |
| 1032 | if (!needsLegacyPrime) { | 1142 | if (!needsLegacyPrime) { |
| 1033 | return rotateSseResponse(await transport.handleRequest(request), { | 1143 | const response = await transport.handleRequest(request); |
| 1034 | onOpen: () => { | 1144 | const streamId = toolCallIds[0] ? this.requestStreamId(transport, toolCallIds[0]) : null; |
| 1035 | this.activeLegacyStreamCount += 1; | 1145 | if (streamId) await this.eventStore.markStreamUndelivered(streamId); |
| 1036 | }, | 1146 | return this.trackedLegacyResponse(response); |
| 1037 | onClose: () => { | ||
| 1038 | this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1); | ||
| 1039 | }, | ||
| 1040 | }); | ||
| 1041 | } | 1147 | } |
| 1042 | 1148 | ||
| 1043 | const originalOnMessage = transport.onmessage; | 1149 | const originalOnMessage = transport.onmessage; |
| @@ -1057,17 +1163,14 @@ export abstract class McpAgentSessionDOBase< · 13 unmodified lines | |||
| 1057 | const eventId = streamId | 1163 | const eventId = streamId |
| 1058 | ? await this.eventStore.storeEvent(streamId, LEGACY_PRIMING_MESSAGE) | 1164 | ? await this.eventStore.storeEvent(streamId, LEGACY_PRIMING_MESSAGE) |
| 1059 | : null; | 1165 | : null; |
| 1060 | for (const item of queued) originalOnMessage?.(item.message, item.extra); | 1166 | if (streamId) await this.eventStore.markStreamUndelivered(streamId); |
| 1061 | 1167 | const rotated = this.trackedLegacyResponse(response, { | |
| 1062 | return rotateSseResponse(response, { | ||
| 1063 | ...(eventId ? { initialFrame: legacyPrimingFrame(eventId) } : {}), | 1168 | ...(eventId ? { initialFrame: legacyPrimingFrame(eventId) } : {}), |
| 1064 | onOpen: () => { | ||
| 1065 | this.activeLegacyStreamCount += 1; | ||
| 1066 | }, | ||
| 1067 | onClose: () => { | ||
| 1068 | this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1); | ||
| 1069 | }, | ||
| 1070 | }); | 1169 | }); |
| 1170 | // ReadableStream.start enqueues the priming frame synchronously while | ||
| 1171 | // building `rotated`; only then may the server see the tools/call. | ||
| 1172 | for (const item of queued) originalOnMessage?.(item.message, item.extra); | ||
| 1173 | return rotated; | ||
| 1071 | }); | 1174 | }); |
| 1072 | } | 1175 | } |
| 1073 | 1176 | ||
| @@ -1444,7 +1547,7 @@ export abstract class McpAgentSessionDOBase< · 370 unmodified lines | |||
| 1444 | return; | 1547 | return; |
| 1445 | } | 1548 | } |
| 1446 | 1549 | ||
| 1447 | await this.disposeIdleRuntime({ idleMs, pausedExecutionCount }); | 1550 | await this.disposeIdleRuntime({ idleMs, lastActivityMs, pausedExecutionCount }); |
| 1448 | } | 1551 | } |
| 1449 | 1552 | ||
| 1450 | private validateApprovalIdentity( | 1553 | private validateApprovalIdentity( |
| @@ -425,11 +425,9 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { · 424 unmodified lines | |||
| 425 | response: ResumeResponse, | 425 | response: ResumeResponse, |
| 426 | ) => | 426 | ) => |
| 427 | Effect.promise(async () => { | 427 | Effect.promise(async () => { |
| 428 | return mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel( | 428 | const ownerSession = mcpSessionStub(sessionNamespace, owner.sessionId); |
| 429 | executionId, | 429 | if (!ownerSession) return { status: "execution_expired" as const, ttlMs: 0 }; |
| 430 | identity, | 430 | return ownerSession.resumeExecutionForModel(executionId, identity, response); |
| 431 | response, | ||
| 432 | ); | ||
| 433 | }), | 431 | }), |
| 434 | ); | 432 | ); |
| 435 | 433 | ||
| @@ -113,6 +113,38 @@ describe("DurableObjectMcpEventStore", () => { · 112 unmodified lines | |||
| 113 | expect(replayed).toEqual([second]); | 113 | expect(replayed).toEqual([second]); |
| 114 | }); | 114 | }); |
| 115 | 115 | ||
| 116 | it("replays a marked POST stream on standalone recovery and clears it after acknowledgement", async () => { | ||
| 117 | const { storage, store } = makeStore(); | ||
| 118 | await store.markStreamUndelivered("post-stream"); | ||
| 119 | const prime = await store.storeEvent("post-stream", { | ||
| 120 | jsonrpc: "2.0", | ||
| 121 | method: "notifications/message", | ||
| 122 | params: { level: "debug", data: "prime" }, | ||
| 123 | }); | ||
| 124 | const result = await store.storeEvent("post-stream", { | ||
| 125 | jsonrpc: "2.0", | ||
| 126 | id: "call-1", | ||
| 127 | result: { content: [{ type: "text", text: "completed" }] }, | ||
| 128 | }); | ||
| 129 | const replayed: string[] = []; | ||
| 130 | |||
| 131 | const streamIds = await store.replayUndeliveredStreams({ | ||
| 132 | send: (eventId) => { | ||
| 133 | replayed.push(eventId); | ||
| 134 | return Promise.resolve(); | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | |||
| 138 | expect(streamIds).toEqual(["post-stream"]); | ||
| 139 | expect(replayed).toEqual([prime, result]); | ||
| 140 | |||
| 141 | await store.acknowledgeUndeliveredStreams(streamIds); | ||
| 142 | await expect( | ||
| 143 | store.replayUndeliveredStreams({ send: () => Promise.resolve() }), | ||
| 144 | ).resolves.toEqual([]); | ||
| 145 | expect(eventKeys(storage)).toEqual([]); | ||
| 146 | }); | ||
| 147 | |||
| 116 | it("evicts oldest events at the byte cap but never the newest", async () => { | 148 | it("evicts oldest events at the byte cap but never the newest", async () => { |
| 117 | const { storage, store } = makeStore(); | 149 | const { storage, store } = makeStore(); |
| 118 | const bigMessage = (marker: string) => ({ | 150 | const bigMessage = (marker: string) => ({ |
| @@ -1,6 +1,7 @@ | |||
| 1 | import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server"; | 1 | import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server"; |
| 2 | 2 | ||
| 3 | const EVENT_KEY_PREFIX = "executor:mcp:v2:event:"; | 3 | const EVENT_KEY_PREFIX = "executor:mcp:v2:event:"; |
| 4 | const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:"; | ||
| 4 | const SEQUENCE_WIDTH = 16; | 5 | const SEQUENCE_WIDTH = 16; |
| 5 | const REPLAY_LIMIT = 1_000; | 6 | const REPLAY_LIMIT = 1_000; |
| 6 | const DELETE_CHUNK_SIZE = 128; | 7 | const DELETE_CHUNK_SIZE = 128; |
| @@ -26,6 +27,9 @@ type StoredEntry = { · 19 unmodified lines | |||
| 26 | 27 | ||
| 27 | const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`; | 28 | const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`; |
| 28 | 29 | ||
| 30 | const undeliveredStreamKey = (streamId: StreamId): string => | ||
| 31 | `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`; | ||
| 32 | |||
| 29 | const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length); | 33 | const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length); |
| 30 | 34 | ||
| 31 | const streamIdFromEventId = (eventId: EventId): StreamId | undefined => { | 35 | const streamIdFromEventId = (eventId: EventId): StreamId | undefined => { |
| @@ -165,6 +169,81 @@ export class DurableObjectMcpEventStore implements EventStore { · 133 unmodified lines | |||
| 165 | return Promise.resolve(streamIdFromEventId(eventId)); | 169 | return Promise.resolve(streamIdFromEventId(eventId)); |
| 166 | } | 170 | } |
| 167 | 171 | ||
| 172 | /** | ||
| 173 | * Mark a tool-call stream as requiring at-least-once delivery confirmation. | ||
| 174 | * | ||
| 175 | * Direct workerd streaming cannot prove that a completed POST body reached | ||
| 176 | * the remote client, so the marker remains until a later standalone GET | ||
| 177 | * drains the stream and its response body completes. | ||
| 178 | */ | ||
| 179 | async markStreamUndelivered(streamId: StreamId): Promise<void> { | ||
| 180 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: a marker failure cannot block the live tool request | ||
| 181 | try { | ||
| 182 | await this.storage.put(undeliveredStreamKey(streamId), true); | ||
| 183 | } catch { | ||
| 184 | logStoreWarning("mcp_event_store_put_failed", { | ||
| 185 | operation: "mark_undelivered", | ||
| 186 | streamId, | ||
| 187 | }); | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | /** Replay every marked POST stream onto a standalone recovery response. */ | ||
| 192 | async replayUndeliveredStreams({ | ||
| 193 | send, | ||
| 194 | }: { | ||
| 195 | readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>; | ||
| 196 | }): Promise<readonly StreamId[]> { | ||
| 197 | const replayedStreamIds: StreamId[] = []; | ||
| 198 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: recovery is best-effort and leaves markers intact for a later GET | ||
| 199 | try { | ||
| 200 | const markers = await this.storage.list<boolean>({ | ||
| 201 | prefix: UNDELIVERED_STREAM_KEY_PREFIX, | ||
| 202 | limit: REPLAY_LIMIT, | ||
| 203 | }); | ||
| 204 | for (const key of markers.keys()) { | ||
| 205 | const streamId = key.slice(UNDELIVERED_STREAM_KEY_PREFIX.length); | ||
| 206 | let replayed = false; | ||
| 207 | const rows = await this.storage.list<JSONRPCMessage>({ | ||
| 208 | prefix: eventPrefix(streamId), | ||
| 209 | limit: REPLAY_LIMIT, | ||
| 210 | }); | ||
| 211 | for (const [eventKey, message] of rows) { | ||
| 212 | await send(eventIdFromKey(eventKey), message); | ||
| 213 | replayed = true; | ||
| 214 | } | ||
| 215 | if (replayed) replayedStreamIds.push(streamId); | ||
| 216 | } | ||
| 217 | } catch { | ||
| 218 | logStoreWarning("mcp_event_store_list_failed", { | ||
| 219 | operation: "replay_undelivered", | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | return replayedStreamIds; | ||
| 223 | } | ||
| 224 | |||
| 225 | /** Clear successfully drained recovery streams and their delivery markers. */ | ||
| 226 | async acknowledgeUndeliveredStreams(streamIds: readonly StreamId[]): Promise<void> { | ||
| 227 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: acknowledgement cleanup is best-effort and duplicate replay is safe | ||
| 228 | try { | ||
| 229 | for (const streamId of streamIds) { | ||
| 230 | const rows = await this.storage.list<JSONRPCMessage>({ | ||
| 231 | prefix: eventPrefix(streamId), | ||
| 232 | limit: REPLAY_LIMIT, | ||
| 233 | }); | ||
| 234 | const keys = [undeliveredStreamKey(streamId), ...rows.keys()]; | ||
| 235 | for (let index = 0; index < keys.length; index += DELETE_CHUNK_SIZE) { | ||
| 236 | await this.storage.delete(keys.slice(index, index + DELETE_CHUNK_SIZE)); | ||
| 237 | } | ||
| 238 | } | ||
| 239 | } catch { | ||
| 240 | logStoreWarning("mcp_event_store_delete_failed", { | ||
| 241 | operation: "acknowledge_undelivered", | ||
| 242 | streamCount: streamIds.length, | ||
| 243 | }); | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 168 | /** Replay persisted events after the supplied ID, in storage-key order. */ | 247 | /** Replay persisted events after the supplied ID, in storage-key order. */ |
| 169 | async replayEventsAfter( | 248 | async replayEventsAfter( |
| 170 | lastEventId: EventId, | 249 | lastEventId: EventId, |
| @@ -120,6 +120,8 @@ class MemorySessions implements McpModernSessionNamespace<string> { · 119 unmodified lines | |||
| 120 | readonly forwarded: ForwardedRequest[] = []; | 120 | readonly forwarded: ForwardedRequest[] = []; |
| 121 | uniqueIds = 0; | 121 | uniqueIds = 0; |
| 122 | 122 | ||
| 123 | constructor(private readonly rejectStringIds = false) {} | ||
| 124 | |||
| 123 | newUniqueId(): string { | 125 | newUniqueId(): string { |
| 124 | this.uniqueIds += 1; | 126 | this.uniqueIds += 1; |
| 125 | return `unique-${this.uniqueIds}`; | 127 | return `unique-${this.uniqueIds}`; |
| @@ -130,6 +132,10 @@ class MemorySessions implements McpModernSessionNamespace<string> { · 4 unmodified lines | |||
| 130 | } | 132 | } |
| 131 | 133 | ||
| 132 | idFromString(id: string): string { | 134 | idFromString(id: string): string { |
| 135 | if (this.rejectStringIds) { | ||
| 136 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare rejecting a foreign/checksum-invalid owner id | ||
| 137 | throw new Error("invalid Durable Object id"); | ||
| 138 | } | ||
| 133 | return `id:${id}`; | 139 | return `id:${id}`; |
| 134 | } | 140 | } |
| 135 | 141 | ||
| @@ -352,6 +358,36 @@ describe("modern Cloudflare MCP worker routing", () => { · 216 unmodified lines | |||
| 352 | expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:legacy-session"]); | 358 | expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:legacy-session"]); |
| 353 | }); | 359 | }); |
| 354 | 360 | ||
| 361 | it("falls back to the worker when a persisted modern owner id is invalid", async () => { | ||
| 362 | const executionId = "exec-invalid-owner"; | ||
| 363 | const directory = new MemoryDirectory(); | ||
| 364 | directory.records.set(executionId, { | ||
| 365 | executionId, | ||
| 366 | owner: modernMcpExecutionOwnerRoute("foreign-do-id"), | ||
| 367 | accountId: principal.accountId, | ||
| 368 | organizationId: principal.organizationId, | ||
| 369 | expiresAt: new Date(Date.now() + 60_000).toISOString(), | ||
| 370 | ttlMs: 60_000, | ||
| 371 | }); | ||
| 372 | const sessions = new MemorySessions(true); | ||
| 373 | const builds = { count: 0 }; | ||
| 374 | |||
| 375 | const response = await dispatch({ | ||
| 376 | body: modernBody({ | ||
| 377 | method: "tools/call", | ||
| 378 | name: "resume", | ||
| 379 | arguments: { executionId, action: "accept" }, | ||
| 380 | }), | ||
| 381 | sessions, | ||
| 382 | directory, | ||
| 383 | builder: makeBuilder(builds), | ||
| 384 | }); | ||
| 385 | |||
| 386 | expect(await response.json()).toMatchObject({ error: { code: -32602 } }); | ||
| 387 | expect(builds.count).toBe(1); | ||
| 388 | expect(sessions.forwarded).toEqual([]); | ||
| 389 | }); | ||
| 390 | |||
| 355 | it("rejects tampered and expired continuation state without touching a DO", async () => { | 391 | it("rejects tampered and expired continuation state without touching a DO", async () => { |
| 356 | const valid = await mintRequestState("exec-invalid"); | 392 | const valid = await mintRequestState("exec-invalid"); |
| 357 | const middle = Math.floor(valid.length / 2); | 393 | const middle = Math.floor(valid.length / 2); |
| @@ -134,7 +134,10 @@ const toModernSessionStub = (stub: unknown): McpModernSessionStub => · 133 unmodified lines | |||
| 134 | const stubForOwner = <Id>( | 134 | const stubForOwner = <Id>( |
| 135 | sessions: McpModernSessionNamespace<Id>, | 135 | sessions: McpModernSessionNamespace<Id>, |
| 136 | owner: { readonly sessionId: string }, | 136 | owner: { readonly sessionId: string }, |
| 137 | ): McpModernSessionStub => toModernSessionStub(mcpSessionStubForOwner(sessions, owner)); | 137 | ): McpModernSessionStub | null => { |
| 138 | const stub = mcpSessionStubForOwner(sessions, owner); | ||
| 139 | return stub ? toModernSessionStub(stub) : null; | ||
| 140 | }; | ||
| 138 | 141 | ||
| 139 | const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub => | 142 | const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub => |
| 140 | toModernSessionStub(sessions.get(sessions.newUniqueId())); | 143 | toModernSessionStub(sessions.get(sessions.newUniqueId())); |
| @@ -240,7 +243,10 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { · 99 unmodified lines | |||
| 240 | jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"), | 243 | jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"), |
| 241 | ); | 244 | ); |
| 242 | } | 245 | } |
| 243 | return withModernMcpCors(await serveDo(stubForOwner(input.sessions, owner.owner), input)); | 246 | const ownerStub = stubForOwner(input.sessions, owner.owner); |
| 247 | return withModernMcpCors( | ||
| 248 | ownerStub ? await serveDo(ownerStub, input) : await serveWorker(input), | ||
| 249 | ); | ||
| 244 | }, | 250 | }, |
| 245 | close: () => | 251 | close: () => |
| 246 | Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then( | 252 | Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then( |
| @@ -0,0 +1,30 @@ | |||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { mcpSessionStub } from "./session-stub"; | ||
| 4 | |||
| 5 | describe("mcpSessionStub", () => { | ||
| 6 | it("returns null when Cloudflare rejects a client-supplied Durable Object id", () => { | ||
| 7 | const get = vi.fn(); | ||
| 8 | const namespace = { | ||
| 9 | idFromString: (_id: string): string => { | ||
| 10 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare's throwing namespace checksum parser | ||
| 11 | throw new Error("Durable Object ID is not valid for this namespace"); | ||
| 12 | }, | ||
| 13 | get, | ||
| 14 | }; | ||
| 15 | |||
| 16 | expect(mcpSessionStub(namespace, "0".repeat(64))).toBeNull(); | ||
| 17 | expect(get).not.toHaveBeenCalled(); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("resolves a namespace-validated id to its generated RPC stub", () => { | ||
| 21 | const stub = { fetch: vi.fn() }; | ||
| 22 | const namespace = { | ||
| 23 | idFromString: (id: string): string => `parsed:${id}`, | ||
| 24 | get: vi.fn(() => stub), | ||
| 25 | }; | ||
| 26 | |||
| 27 | expect(mcpSessionStub(namespace, "issued-id")).toBe(stub); | ||
| 28 | expect(namespace.get).toHaveBeenCalledWith("parsed:issued-id"); | ||
| 29 | }); | ||
| 30 | }); | ||
| @@ -49,9 +49,19 @@ export interface McpSessionStub { · 48 unmodified lines | |||
| 49 | export const mcpSessionStub = <Id>( | 49 | export const mcpSessionStub = <Id>( |
| 50 | namespace: McpSessionNamespace<Id>, | 50 | namespace: McpSessionNamespace<Id>, |
| 51 | sessionId: string, | 51 | sessionId: string, |
| 52 | ): McpSessionStub => | 52 | ): McpSessionStub | null => { |
| 53 | // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but fetch and RPC methods are generated from the bound DO class. | 53 | let id: Id; |
| 54 | namespace.get(namespace.idFromString(sessionId)) as unknown as McpSessionStub; | 54 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- platform boundary: Cloudflare validates the namespace checksum only through throwing idFromString |
| 55 | try { | ||
| 56 | id = namespace.idFromString(sessionId); | ||
| 57 | } catch { | ||
| 58 | return null; | ||
| 59 | } | ||
| 60 | return ( | ||
| 61 | // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but fetch and RPC methods are generated from the bound DO class. | ||
| 62 | namespace.get(id) as unknown as McpSessionStub | ||
| 63 | ); | ||
| 64 | }; | ||
| 55 | 65 | ||
| 56 | /** Allocate one unique session DO and return its client-visible ID and stub. */ | 66 | /** Allocate one unique session DO and return its client-visible ID and stub. */ |
| 57 | export const createMcpSessionStub = <Id>( | 67 | export const createMcpSessionStub = <Id>( |
| @@ -69,9 +79,7 @@ export const createMcpSessionStub = <Id>( · 11 unmodified lines | |||
| 69 | export const mcpSessionStubForOwner = <Id>( | 79 | export const mcpSessionStubForOwner = <Id>( |
| 70 | namespace: McpOwnerSessionNamespace<Id>, | 80 | namespace: McpOwnerSessionNamespace<Id>, |
| 71 | owner: McpExecutionOwnerRoute, | 81 | owner: McpExecutionOwnerRoute, |
| 72 | ): McpSessionStub => { | 82 | ): McpSessionStub | null => { |
| 73 | const modernId = modernMcpDurableObjectId(owner); | 83 | const modernId = modernMcpDurableObjectId(owner); |
| 74 | const id = namespace.idFromString(modernId ?? owner.sessionId); | 84 | return mcpSessionStub(namespace, modernId ?? owner.sessionId); |
| 75 | // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates this RPC surface from the bound DO class. | ||
| 76 | return namespace.get(id) as unknown as McpSessionStub; | ||
| 77 | }; | 85 | }; |