History

Fix session-id parsing, stranded-stream replay, priming, and restore races

Rhys Sullivan committed 2 months ago

Commit 803b0871 parent d2e97bc15 files · +521 −90Browse files

Unified · Split

15 files
  1. apps/cloud/src/auth/handlers.ts +6 −0
  2. apps/cloud/src/mcp/agent-handler.ts +9 −8
  3. apps/cloud/src/mcp/session-durable-object.ts +5 −6
  4. apps/host-cloudflare/src/mcp/agent-handler.ts +9 −8
  5. apps/host-cloudflare/src/mcp/index.ts +6 −2
  6. apps/host-cloudflare/src/mcp/session-durable-object.ts +5 −6
  7. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +133 −4
  8. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +145 −42
  9. packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +3 −5
  10. packages/hosts/cloudflare/src/mcp/do-event-store.test.ts +32 −0
  11. packages/hosts/cloudflare/src/mcp/do-event-store.ts +79 −0
  12. packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +36 −0
  13. packages/hosts/cloudflare/src/mcp/modern-request-router.ts +8 −2
  14. packages/hosts/cloudflare/src/mcp/session-stub.test.ts +30 −0
  15. packages/hosts/cloudflare/src/mcp/session-stub.ts +15 −7
apps/cloud/src/auth/handlers.ts+6−0
@@ -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,
apps/cloud/src/mcp/agent-handler.ts+9−8
@@ -161,11 +161,10 @@ export const makeCloudMcpAgentHandler = () => { · 160 unmodified lines
161 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave161 // / 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 = sessionId250 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 fetch252 // 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 {
apps/cloud/src/mcp/session-durable-object.ts+5−6
@@ -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 }
apps/host-cloudflare/src/mcp/agent-handler.ts+9−8
@@ -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 = sessionId176 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};
apps/host-cloudflare/src/mcp/index.ts+6−2
@@ -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,
apps/host-cloudflare/src/mcp/session-durable-object.ts+5−6
@@ -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 }
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+133−4
@@ -1,4 +1,4 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it, vi } from "@effect/vitest";
2import { Cause, Effect } from "effect";2import { Cause, Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { 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
184class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly end: () => void }> {184class 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
259describe("McpAgentSessionDOBase session serving", () => {270describe("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 () => {
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+145−42
@@ -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
259const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array =>
260 new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`);
261
262const 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
259const mcpResourceFromKey = (resourceKey: string): McpResource =>274const mcpResourceFromKey = (resourceKey: string): McpResource =>
260 resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length275 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 = streamId1163 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(
packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts+3−5
@@ -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
packages/hosts/cloudflare/src/mcp/do-event-store.test.ts+32−0
@@ -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) => ({
packages/hosts/cloudflare/src/mcp/do-event-store.ts+79−0
@@ -1,6 +1,7 @@
1import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server";1import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server";
2 2
3const EVENT_KEY_PREFIX = "executor:mcp:v2:event:";3const EVENT_KEY_PREFIX = "executor:mcp:v2:event:";
4const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:";
4const SEQUENCE_WIDTH = 16;5const SEQUENCE_WIDTH = 16;
5const REPLAY_LIMIT = 1_000;6const REPLAY_LIMIT = 1_000;
6const DELETE_CHUNK_SIZE = 128;7const DELETE_CHUNK_SIZE = 128;
@@ -26,6 +27,9 @@ type StoredEntry = { · 19 unmodified lines
26 27
27const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`;28const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`;
28 29
30const undeliveredStreamKey = (streamId: StreamId): string =>
31 `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`;
32
29const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length);33const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length);
30 34
31const streamIdFromEventId = (eventId: EventId): StreamId | undefined => {35const 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,
packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts+36−0
@@ -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);
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+8−2
@@ -134,7 +134,10 @@ const toModernSessionStub = (stub: unknown): McpModernSessionStub => · 133 unmodified lines
134const stubForOwner = <Id>(134const 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
139const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub =>142const 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(
packages/hosts/cloudflare/src/mcp/session-stub.test.ts+30−0
@@ -0,0 +1,30 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2
3import { mcpSessionStub } from "./session-stub";
4
5describe("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});
packages/hosts/cloudflare/src/mcp/session-stub.ts+15−7
@@ -49,9 +49,19 @@ export interface McpSessionStub { · 48 unmodified lines
49export const mcpSessionStub = <Id>(49export 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. */
57export const createMcpSessionStub = <Id>(67export const createMcpSessionStub = <Id>(
@@ -69,9 +79,7 @@ export const createMcpSessionStub = <Id>( · 11 unmodified lines
69export const mcpSessionStubForOwner = <Id>(79export 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};