History

Harden requestState binding, add inbound rollback switch, fix CLI bridge era handling

Rhys Sullivan committed 2 months ago

Commit ce870831 parent c25ace226 files · +556 −83Browse files

Unified · Split

26 files
  1. apps/cli/package.json +1 −0
  2. apps/cli/src/main.ts +1 −1
  3. apps/cloud/src/env-augment.d.ts +2 −0
  4. apps/cloud/src/mcp/agent-handler.ts +4 −0
  5. apps/cloud/wrangler.jsonc +3 −0
  6. apps/host-cloudflare/src/config.ts +2 −0
  7. apps/host-cloudflare/src/mcp/agent-handler.ts +4 −0
  8. apps/host-cloudflare/wrangler.jsonc +3 −0
  9. apps/host-selfhost/src/app.ts +6 −1
  10. apps/host-selfhost/src/config.ts +3 −0
  11. apps/host-selfhost/src/mcp/index.ts +2 −1
  12. apps/host-selfhost/src/mcp/session-store.ts +2 −0
  13. apps/local/src/main.ts +1 −0
  14. apps/local/src/mcp-modern.test.ts +52 −0
  15. apps/local/src/mcp.ts +18 −0
  16. bun.lock +1 −0
  17. e2e/local/cli-mcp-protocol.test.ts +117 −0
  18. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +25 −27
  19. packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +69 −7
  20. packages/hosts/cloudflare/src/mcp/modern-request-router.ts +19 −6
  21. packages/hosts/mcp/src/envelope.test.ts +21 −0
  22. packages/hosts/mcp/src/envelope.ts +20 −2
  23. packages/hosts/mcp/src/index.ts +1 −0
  24. packages/hosts/mcp/src/seams.ts +4 −0
  25. packages/hosts/mcp/src/tool-server-protocol.test.ts +56 −11
  26. packages/hosts/mcp/src/tool-server.ts +119 −27
apps/cli/package.json+1−0
@@ -30,6 +30,7 @@ · 29 unmodified lines
3030 "@executor-js/runtime-quickjs": "workspace:*",
3131 "@executor-js/sdk": "workspace:*",
3232 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
33+ "@modelcontextprotocol/client": "2.0.0",
3334 "@modelcontextprotocol/sdk": "^1.29.0",
3435 "@sentry/bun": "^10.57.0",
3536 "effect": "catalog:",
apps/cli/src/main.ts+1−1
@@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines
7575 import * as Effect from "effect/Effect";
7676 import * as Option from "effect/Option";
7777 import * as Cause from "effect/Cause";
78−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
78+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7979 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8080 import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
8181
apps/cloud/src/env-augment.d.ts+2−0
@@ -69,6 +69,8 @@ declare global { · 68 unmodified lines
6969 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
7070 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
7171 MCP_REQUEST_STATE_KEY?: string;
72+ /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
73+ MCP_2026_07_28_ENABLED?: string;
7274 NODE_ENV?: string;
7375
7476 // Shared with frontend
apps/cloud/src/mcp/agent-handler.ts+4−0
@@ -4,6 +4,7 @@ import { Effect, Predicate } from "effect"; · 3 unmodified lines
44 import {
55 McpAuthProvider,
66 jsonRpcErrorBody,
7+ mcpModernDisabledResponse,
78 defaultMcpResource,
89 UNAVAILABLE_RETRY_AFTER_SECONDS,
910 type AuthOutcome,
@@ -174,6 +175,9 @@ export const makeCloudMcpAgentHandler = () => { · 164 unmodified lines
174175 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
175176 const era = await classifyMcpProtocolEra(request, parsedBody);
176177 if (era === "modern") {
178+ if (env.MCP_2026_07_28_ENABLED === "false") {
179+ return mcpModernDisabledResponse();
180+ }
177181 const resource = resourceFromPath(request);
178182 const props = await runTraced(
179183 request,
apps/cloud/wrangler.jsonc+3−0
@@ -101,6 +101,9 @@ · 100 unmodified lines
101101 // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`.
102102 // It must never be placed in `vars` or generated independently per isolate.
103103 "vars": {
104+ // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern
105+ // inbound serving. Set the Worker var to "false" for emergency rollback;
106+ // legacy serving remains available.
104107 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
105108 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
106109 // Browser OTLP spans → same-origin, forwarded to Axiom by the worker
apps/host-cloudflare/src/config.ts+2−0
@@ -49,6 +49,8 @@ export interface CloudflareEnv { · 48 unmodified lines
4949 readonly EXECUTOR_SECRET_KEY?: string;
5050 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
5151 readonly MCP_REQUEST_STATE_KEY?: string;
52+ /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
53+ readonly MCP_2026_07_28_ENABLED?: string;
5254 readonly ALLOW_LOCAL_NETWORK?: string;
5355 readonly VITE_PUBLIC_SITE_URL?: string;
5456 /**
apps/host-cloudflare/src/mcp/agent-handler.ts+4−0
@@ -3,6 +3,7 @@ import { Effect, Predicate } from "effect"; · 2 unmodified lines
33 import {
44 McpAuthProvider,
55 jsonRpcErrorBody,
6+ mcpModernDisabledResponse,
67 defaultMcpResource,
78 type AuthOutcome,
89 type Principal,
@@ -112,6 +113,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 103 unmodified lines
112113 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
113114 const era = await classifyMcpProtocolEra(request, parsedBody);
114115 if (era === "modern") {
116+ if (env.MCP_2026_07_28_ENABLED === "false") {
117+ return mcpModernDisabledResponse();
118+ }
115119 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
116120 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
117121 const forwarded = withVerifiedIdentityHeaders(
apps/host-cloudflare/wrangler.jsonc+3−0
@@ -73,6 +73,9 @@ · 72 unmodified lines
7373 // MCP key is a deployment prerequisite shared by Worker and session DOs;
7474 // never generate it independently per isolate.
7575 "vars": {
76+ // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern
77+ // inbound serving. Set the Worker var to "false" for emergency rollback;
78+ // legacy serving remains available.
7679 "ACCESS_NAME_CLAIM": "name",
7780 "ACCESS_GROUPS_CLAIM": "groups",
7881 // Never preserve a production dev-auth override through keep_vars.
apps/host-selfhost/src/app.ts+6−1
@@ -78,7 +78,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 77 unmodified lines
7878 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
7979 // Pass the pinned public origin so browser-approval URLs are reachable behind
8080 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
81− const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
81+ const mcp = makeSelfHostMcpSeams(
82+ dbHandle,
83+ betterAuth,
84+ config.webBaseUrl,
85+ config.mcp20260728Enabled,
86+ );
8287
8388 // CLI device-login discovery (`executor login`). Points the CLI at Better
8489 // Auth's device endpoints; `requestFormat: "json"` because those endpoints
apps/host-selfhost/src/config.ts+3−0
@@ -32,6 +32,8 @@ export interface SelfHostConfig { · 31 unmodified lines
3232 * internal network unless an operator opts in.
3333 */
3434 readonly allowLocalNetwork: boolean;
35+ /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
36+ readonly mcp20260728Enabled: boolean;
3537 // Better Auth session secret. Always resolved (env, else generated + persisted
3638 // under the data dir) so a single-container deploy boots with no env; the auth
3739 // layer still validates an explicitly-set env secret is long enough.
@@ -142,6 +144,7 @@ export const loadConfig = (): SelfHostConfig => { · 104 unmodified lines
142144 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
143145 webBaseUrl: resolveWebBaseUrl(port),
144146 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
147+ mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false",
145148 authSecret: resolveAuthSecret(),
146149 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
147150 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,
apps/host-selfhost/src/mcp/index.ts+2−1
@@ -140,6 +140,7 @@ export const makeSelfHostMcpSeams = ( · 139 unmodified lines
140140 dbHandle: SelfHostDbHandle,
141141 betterAuth: BetterAuthHandle,
142142 webBaseUrl?: string,
143+ modernEnabled = true,
143144 ): SelfHostMcpSeams => {
144145 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
145146 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
@@ -148,7 +149,7 @@ export const makeSelfHostMcpSeams = ( · 2 unmodified lines
148149 return {
149150 auth,
150151 sessions: selfHostMcpSessions(sessionStore),
151− modern: makeSelfHostMcpModernServerBuilder(dbHandle),
152+ modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled),
152153 reporter: selfHostMcpReporter,
153154 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
154155 close: sessionStore.close,
apps/host-selfhost/src/mcp/session-store.ts+2−0
@@ -54,8 +54,10 @@ export const makeSelfHostMcpSessionStore = ( · 53 unmodified lines
5454 /** Build the stateless MCP server seam over the same self-host stack/config. */
5555 export const makeSelfHostMcpModernServerBuilder = (
5656 db: SelfHostDbHandle,
57+ enabled = true,
5758 ): Layer.Layer<McpModernServerBuilder> =>
5859 Layer.succeed(McpModernServerBuilder)({
60+ enabled,
5961 build: makeMcpBuildServer(
6062 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
6163 {
apps/local/src/main.ts+1−0
@@ -119,6 +119,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 118 unmodified lines
119119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
120120 };
121121 mcp = createMcpRequestHandler({
122+ modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false",
122123 defaultConfig: {
123124 engine,
124125 artifacts: executor.artifacts,
apps/local/src/mcp-modern.test.ts+52−0
@@ -51,4 +51,56 @@ describe("local modern MCP HTTP", () => { · 50 unmodified lines
5151 await mcp.close();
5252 }
5353 });
54+
55+ it("rejects a pinned modern client with the unsupported-version protocol error", async () => {
56+ const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false });
57+ const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
58+ fetch: (input, init) =>
59+ mcp.handleRequest(
60+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
61+ ),
62+ });
63+ const client = new Client(
64+ { name: "local-modern-disabled-test", version: "1.0.0" },
65+ { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
66+ );
67+
68+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close a client whose pinned negotiation is expected to fail
69+ try {
70+ await expect(client.connect(transport)).rejects.toThrow(/version negotiation failed/i);
71+ } finally {
72+ await client.close();
73+ await mcp.close();
74+ }
75+ });
76+
77+ it("lets an auto-mode v2 client fall back to legacy when modern inbound is disabled", async () => {
78+ const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false });
79+ const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
80+ fetch: (input, init) =>
81+ mcp.handleRequest(
82+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
83+ ),
84+ });
85+ const client = new Client(
86+ { name: "local-auto-fallback-test", version: "1.0.0" },
87+ { capabilities: {}, versionNegotiation: { mode: "auto" } },
88+ );
89+
90+ await client.connect(transport);
91+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the fallback client and local handler
92+ try {
93+ expect(client.getProtocolEra()).toBe("legacy");
94+ expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
95+ const result = await client.callTool({
96+ name: "execute",
97+ arguments: { code: "3 + 4" },
98+ });
99+ expect(result.content).toEqual([{ type: "text", text: "ran: 3 + 4" }]);
100+ expect(transport.sessionId).toBeTruthy();
101+ } finally {
102+ await client.close();
103+ await mcp.close();
104+ }
105+ });
54106 });
apps/local/src/mcp.ts+18−0
@@ -11,6 +11,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio"; · 10 unmodified lines
1111 import {
1212 defaultMcpResource,
1313 jsonRpcErrorBody,
14+ mcpModernDisabledResponse,
1415 mcpResourceKey,
1516 type McpResource,
1617 } from "@executor-js/host-mcp";
@@ -18,6 +19,7 @@ import { · 1 unmodified line
1819 appsEnabledForClientCapabilities,
1920 buildMcpServer,
2021 clientCapabilitiesFromRequest,
22+ mcpRequestStateBindingFromBody,
2123 requestBodyFromRequest,
2224 type ExecutorMcpToolConfig,
2325 } from "@executor-js/host-mcp/tool-server";
@@ -59,6 +61,8 @@ export interface LocalMcpServerConfig { · 35 unmodified lines
5961
6062 export interface LocalMcpRequestHandlerConfig {
6163 readonly defaultConfig: ExecutorMcpToolConfig;
64+ /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
65+ readonly modernEnabled?: boolean;
6266 readonly createConfigForResource?: (
6367 resource: McpResource,
6468 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -138,6 +142,7 @@ export const createMcpRequestHandler = ( · 73 unmodified lines
138142 const sessionEngines = new Map<string, AnyExecutionEngine>();
139143 const sessionClosers = new Map<string, () => Promise<void>>();
140144 const modernHandlers = new Map<string, McpHttpHandler>();
145+ const modernRequestBodies = new WeakMap<Request, unknown>();
141146 const approvals = makeInProcessBrowserApprovalStore();
142147 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
143148 let requestStateSigningKey: Uint8Array | undefined;
@@ -189,16 +194,25 @@ export const createMcpRequestHandler = ( · 45 unmodified lines
189194 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect
190195 return Effect.runPromise(Effect.die("Modern MCP request context has no request"));
191196 }
197+ const parsedBody = modernRequestBodies.get(request);
192198 return Effect.runPromise(
193199 Effect.gen(function* () {
194200 const resourceConfig = yield* Effect.promise(() => configForResource(resource));
195201 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
202+ const requestStateBinding = yield* Effect.promise(() =>
203+ mcpRequestStateBindingFromBody({
204+ body: parsedBody,
205+ principal: "local",
206+ resource,
207+ }),
208+ );
196209 const server = yield* buildMcpServer({
197210 ...resourceConfig.config,
198211 artifactsEnabled: readArtifactsEnabled(request),
199212 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
200213 requestStateSigningKey: signingKey(),
201214 requestStatePrincipal: "local",
215+ ...(requestStateBinding === null ? {} : { requestStateBinding }),
202216 });
203217 if (resourceConfig.close) {
204218 const closeServer = server.close.bind(server);
@@ -226,7 +240,11 @@ export const createMcpRequestHandler = ( · 21 unmodified lines
226240 const resource = resourceFromRequest(request);
227241 if (!resource) return jsonError(404, -32001, "MCP resource not found");
228242 if (!(await isLegacyRequest(request))) {
243+ if (handlerConfig.modernEnabled === false) {
244+ return mcpModernDisabledResponse({ cors: false });
245+ }
229246 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
247+ modernRequestBodies.set(request, parsedBody);
230248 return modernHandlerFor(resource).fetch(request, { parsedBody });
231249 }
232250 const sessionId = request.headers.get("mcp-session-id");
bun.lock+1−0
@@ -43,6 +43,7 @@ · 42 unmodified lines
4343 "@executor-js/runtime-quickjs": "workspace:*",
4444 "@executor-js/sdk": "workspace:*",
4545 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
46+ "@modelcontextprotocol/client": "2.0.0",
4647 "@modelcontextprotocol/sdk": "^1.29.0",
4748 "@sentry/bun": "^10.57.0",
4849 "effect": "catalog:",
e2e/local/cli-mcp-protocol.test.ts+117−0
@@ -0,0 +1,117 @@
1+// Regression for #1449: a modern MCP client starts stdio negotiation with
2+// `server/discover`, before `initialize`. This crosses the real CLI bridge:
3+//
4+// v2/v1 stdio client -> `executor mcp` -> local daemon HTTP MCP endpoint
5+//
6+// It reconnects with the v1 SDK too, proving discovery forwarding does not
7+// regress established legacy stdio clients.
8+import { expect } from "@effect/vitest";
9+import { Client as ModernClient } from "@modelcontextprotocol/client";
10+import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio";
11+import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js";
12+import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13+import { Effect } from "effect";
14+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
15+import { tmpdir } from "node:os";
16+import { join } from "node:path";
17+import { fileURLToPath } from "node:url";
18+
19+import { scenario } from "../src/scenario";
20+
21+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
22+const testScope = join(repoRoot, "apps/local");
23+
24+const bridgeCommand = (dataDir: string) => ({
25+ command: "bun",
26+ args: ["run", "dev:cli", "mcp", "--scope", testScope],
27+ cwd: repoRoot,
28+ env: {
29+ ...process.env,
30+ EXECUTOR_DATA_DIR: dataDir,
31+ EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1",
32+ } as Record<string, string>,
33+ stderr: "pipe" as const,
34+});
35+
36+const stopAutoSpawnedDaemon = (dataDir: string): void => {
37+ // The bridge is transient, while its auto-started daemon is detached. Reap
38+ // that owner before deleting this scenario's private data directory.
39+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest
40+ try {
41+ const manifest = JSON.parse(
42+ readFileSync(join(dataDir, "server-control", "server.json"), "utf8"),
43+ ) as { readonly pid?: number };
44+ if (manifest.pid) process.kill(manifest.pid, "SIGTERM");
45+ } catch {
46+ // No manifest means there is no auto-started daemon to stop.
47+ }
48+};
49+
50+const withTempData = Effect.acquireRelease(
51+ Effect.sync(() => {
52+ const root = mkdtempSync(join(tmpdir(), "executor-mcp-protocol-"));
53+ return { root, dataDir: join(root, "data") };
54+ }),
55+ ({ root, dataDir }) =>
56+ Effect.sync(() => {
57+ stopAutoSpawnedDaemon(dataDir);
58+ rmSync(root, { recursive: true, force: true });
59+ }),
60+);
61+
62+scenario(
63+ "Local CLI MCP · modern discovery and legacy initialize cross the stdio bridge",
64+ { timeout: 240_000 },
65+ Effect.gen(function* () {
66+ const { dataDir } = yield* withTempData;
67+
68+ const modernTransport = new ModernStdioClientTransport(bridgeCommand(dataDir));
69+ const modernClient = new ModernClient(
70+ { name: "executor-cli-modern-e2e", version: "1.0.0" },
71+ { versionNegotiation: { mode: "auto" } },
72+ );
73+
74+ yield* Effect.promise(async () => {
75+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always reap the real CLI child when an assertion fails
76+ try {
77+ await modernClient.connect(modernTransport);
78+ expect(modernClient.getProtocolEra()).toBe("modern");
79+ expect((await modernClient.listTools()).tools.map(({ name }) => name)).toContain("execute");
80+ const executed = await modernClient.callTool({
81+ name: "execute",
82+ arguments: { code: "return 42" },
83+ });
84+ expect(executed.structuredContent).toMatchObject({
85+ status: "completed",
86+ result: 42,
87+ });
88+ } finally {
89+ await modernClient.close();
90+ }
91+ });
92+
93+ const legacyTransport = new LegacyStdioClientTransport(bridgeCommand(dataDir));
94+ const legacyClient = new LegacyClient({
95+ name: "executor-cli-legacy-e2e",
96+ version: "1.0.0",
97+ });
98+
99+ yield* Effect.promise(async () => {
100+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always reap the real CLI child when an assertion fails
101+ try {
102+ await legacyClient.connect(legacyTransport);
103+ expect((await legacyClient.listTools()).tools.map(({ name }) => name)).toContain("execute");
104+ const executed = await legacyClient.callTool({
105+ name: "execute",
106+ arguments: { code: "return 42" },
107+ });
108+ expect(executed.structuredContent).toMatchObject({
109+ status: "completed",
110+ result: 42,
111+ });
112+ } finally {
113+ await legacyClient.close();
114+ }
115+ });
116+ }).pipe(Effect.scoped),
117+);
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+25−27
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
2424 import {
2525 appsEnabledForClientCapabilities,
2626 clientCapabilitiesFromRequestBody,
27+ mcpRequestStateBindingFromBody,
2728 PAUSED_APPROVAL_TIMEOUT_MS,
2829 formatMcpExecutionOutcome,
2930 mcpRequestStatePrincipal,
@@ -162,6 +163,7 @@ export interface ModernMcpServerRequestOptions { · 132 unmodified lines
162163 readonly appsEnabled: boolean;
163164 readonly requestStateSigningKey: Uint8Array | string;
164165 readonly requestStatePrincipal: string;
166+ readonly requestStateBinding?: string;
165167 }
166168
167169 /** Long-lived DO execution runtime shared by per-request MCP servers. */
@@ -579,29 +581,16 @@ export abstract class McpAgentSessionDOBase< · 411 unmodified lines
579581 }),
580582 );
581583 await Effect.runPromise(this.closeRuntime());
582−<<<<<<< HEAD
583− await Effect.runPromise(
584− Effect.all([
585− Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
586− Effect.ignore(
587− Effect.tryPromise(() =>
588− this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]),
589− ),
590− ),
591− ]),
592− );
593−=======
594584 const activityKey =
595− this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY;
585+ this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY;
596586 const cleared = await this.ctx.storage.transaction(async (transaction) => {
597587 const current = await transaction.get<number>(activityKey);
598588 if (current !== input.lastActivityMs) return false;
599− await transaction.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]);
589+ await transaction.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]);
600590 await transaction.deleteAlarm();
601591 return true;
602592 });
603593 if (cleared) this.lastActivityMs = 0;
604−>>>>>>> d62e666df (Fix session-id parsing, stranded-stream replay, priming, and restore races)
605594 }
606595
607596 private resolveAndStoreSessionMeta(token: McpSessionInit) {
@@ -828,20 +817,29 @@ export abstract class McpAgentSessionDOBase< · 220 unmodified lines
828817 const propagation = self.modernRequestPropagation.get(request);
829818 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
830819 return Effect.runPromise(
831− runtime
832− .buildServer({
820+ Effect.gen(function* () {
821+ const requestStatePrincipal = mcpRequestStatePrincipal({
822+ accountId: sessionMeta.userId,
823+ organizationId: sessionMeta.organizationId,
824+ });
825+ const requestStateBinding = yield* Effect.promise(() =>
826+ mcpRequestStateBindingFromBody({
827+ body: parsedBody,
828+ principal: requestStatePrincipal,
829+ resource: sessionMeta.resource,
830+ }),
831+ );
832+ return yield* runtime.buildServer({
833833 appsEnabled: appsEnabledForClientCapabilities(capabilities),
834834 requestStateSigningKey: self.modernRequestStateSigningKey(),
835− requestStatePrincipal: mcpRequestStatePrincipal({
836− accountId: sessionMeta.userId,
837− organizationId: sessionMeta.organizationId,
838− }),
839− })
840− .pipe(
841− (effect) => self.withTelemetry(effect, propagation),
842− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject
843− Effect.orDie,
844− ),
835+ requestStatePrincipal,
836+ ...(requestStateBinding === null ? {} : { requestStateBinding }),
837+ });
838+ }).pipe(
839+ (effect) => self.withTelemetry(effect, propagation),
840+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject
841+ Effect.orDie,
842+ ),
845843 );
846844 },
847845 { legacy: "reject" },
packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts+69−7
@@ -6,9 +6,14 @@ import type { ExecutionEngine } from "@executor-js/execution"; · 5 unmodified lines
66 import {
77 defaultMcpResource,
88 type McpModernServerBuilder,
9+ type McpResource,
910 type Principal,
1011 } from "@executor-js/host-mcp";
11−import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";
12+import {
13+ buildMcpServer,
14+ mcpRequestStateBindingFromBody,
15+ mcpRequestStatePrincipal,
16+} from "@executor-js/host-mcp/tool-server";
1217
1318 import type { McpSessionProps } from "./agent-session-durable-object";
1419 import {
@@ -168,13 +173,14 @@ const dispatch = async (input: { · 153 unmodified lines
168173 readonly sessions: MemorySessions;
169174 readonly directory: MemoryDirectory;
170175 readonly builder: McpModernServerBuilder["Service"];
176+ readonly resource?: McpResource;
171177 }) => {
172178 const request = modernRequest(input.body);
173179 return makeMcpModernRequestRouter().fetch({
174180 request,
175181 parsedBody: input.body,
176182 principal,
177− resource: defaultMcpResource,
183+ resource: input.resource ?? defaultMcpResource,
178184 props,
179185 requestStateSigningKey: REQUEST_STATE_KEY,
180186 builder: input.builder,
@@ -183,12 +189,29 @@ const dispatch = async (input: { · 2 unmodified lines
183189 });
184190 };
185191
186−const mintRequestState = async (executionId: string, ttlSeconds = 60): Promise<string> => {
187− const binding = `tools/call\u0000${mcpRequestStatePrincipal(principal)}`;
192+const mintRequestState = async (
193+ executionId: string,
194+ options: {
195+ readonly code?: string;
196+ readonly resource?: McpResource;
197+ readonly ttlSeconds?: number;
198+ } = {},
199+): Promise<string> => {
200+ const body = modernBody({
201+ method: "tools/call",
202+ name: "execute",
203+ arguments: { code: options.code ?? "1 + 1" },
204+ });
205+ const binding = await mcpRequestStateBindingFromBody({
206+ body,
207+ principal: mcpRequestStatePrincipal(principal),
208+ resource: options.resource ?? defaultMcpResource,
209+ });
210+ expect(binding).not.toBeNull();
188211 const codec = createRequestStateCodec<{ readonly executionId: string }>({
189212 key: REQUEST_STATE_KEY,
190− ttlSeconds,
191− bind: () => binding,
213+ ttlSeconds: options.ttlSeconds ?? 60,
214+ bind: () => binding ?? "",
192215 });
193216 const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]);
194217 return typeof encoded === "string" ? encoded : "";
@@ -392,7 +415,7 @@ describe("modern Cloudflare MCP worker routing", () => { · 197 unmodified lines
392415 const valid = await mintRequestState("exec-invalid");
393416 const middle = Math.floor(valid.length / 2);
394417 const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`;
395− const expired = await mintRequestState("exec-expired", -1);
418+ const expired = await mintRequestState("exec-expired", { ttlSeconds: -1 });
396419
397420 for (const requestState of [tampered, expired]) {
398421 const sessions = new MemorySessions();
@@ -414,4 +437,43 @@ describe("modern Cloudflare MCP worker routing", () => { · 15 unmodified lines
414437 expect(sessions.forwarded).toEqual([]);
415438 }
416439 });
440+
441+ it("rejects continuation state when the resource or code digest binding changes", async () => {
442+ const requestState = await mintRequestState("exec-bound");
443+ const cases = [
444+ {
445+ body: modernBody({
446+ method: "tools/call",
447+ name: "execute",
448+ arguments: { code: "different code" },
449+ requestState,
450+ }),
451+ resource: defaultMcpResource,
452+ },
453+ {
454+ body: modernBody({
455+ method: "tools/call",
456+ name: "execute",
457+ arguments: { code: "1 + 1" },
458+ requestState,
459+ }),
460+ resource: { kind: "toolkit", slug: "other" } as const,
461+ },
462+ ];
463+
464+ for (const { body, resource } of cases) {
465+ const sessions = new MemorySessions();
466+ const response = await dispatch({
467+ body,
468+ resource,
469+ sessions,
470+ directory: new MemoryDirectory(),
471+ builder: makeBuilder({ count: 0 }),
472+ });
473+
474+ expect(await response.json()).toMatchObject({ error: { code: -32602 } });
475+ expect(sessions.uniqueIds).toBe(0);
476+ expect(sessions.forwarded).toEqual([]);
477+ }
478+ });
417479 });
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+19−6
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 import {
1717 appsEnabledForClientCapabilities,
1818 clientCapabilitiesFromRequestBody,
19+ mcpRequestStateBindingFromBody,
1920 mcpRequestStatePrincipal,
2021 verifyNativeRequestState,
2122 } from "@executor-js/host-mcp/tool-server";
@@ -168,11 +169,22 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { · 146 unmodified lines
168169 }
169170 const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody);
170171 return Effect.runPromise(
171− inputs.builder.build(inputs.principal, {
172− resource,
173− appsEnabled: appsEnabledForClientCapabilities(capabilities),
174− requestStateSigningKey: inputs.requestStateSigningKey,
175− requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal),
172+ Effect.gen(function* () {
173+ const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal);
174+ const requestStateBinding = yield* Effect.promise(() =>
175+ mcpRequestStateBindingFromBody({
176+ body: inputs.parsedBody,
177+ principal: requestStatePrincipal,
178+ resource,
179+ }),
180+ );
181+ return yield* inputs.builder.build(inputs.principal, {
182+ resource,
183+ appsEnabled: appsEnabledForClientCapabilities(capabilities),
184+ requestStateSigningKey: inputs.requestStateSigningKey,
185+ requestStatePrincipal,
186+ ...(requestStateBinding === null ? {} : { requestStateBinding }),
187+ });
176188 }),
177189 );
178190 },
@@ -214,7 +226,8 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { · 35 unmodified lines
214226 const verified = await Effect.runPromiseExit(
215227 verifyNativeRequestState({
216228 state: call.params.requestState,
217− method: call.method,
229+ body: input.parsedBody,
230+ resource: input.resource,
218231 requestStateSigningKey: input.requestStateSigningKey,
219232 requestStatePrincipal: mcpRequestStatePrincipal(input.principal),
220233 }),
packages/hosts/mcp/src/envelope.test.ts+21−0
@@ -231,6 +231,27 @@ describe("McpServingRoutes envelope", () => { · 230 unmodified lines
231231 expect(response.headers.get("www-authenticate")).toBe(challenge);
232232 });
233233
234+ it("gracefully rejects modern discovery when inbound 2026-07-28 is disabled", async () => {
235+ const DisabledModernBuilder = Layer.succeed(McpModernServerBuilder)({
236+ enabled: false,
237+ build: () => Effect.die("disabled modern builder should not run"),
238+ });
239+ const handler = buildHandler(
240+ OkStoreLive,
241+ McpErrorReporterNoop,
242+ AuthProviderLive,
243+ DisabledModernBuilder,
244+ );
245+
246+ const response = await handler(modernRequest("https://host.test/mcp"));
247+ expect(response.status).toBe(400);
248+ expect(await response.json()).toEqual({
249+ jsonrpc: "2.0",
250+ error: { code: -32022, message: "MCP 2026-07-28 support is disabled" },
251+ id: null,
252+ });
253+ });
254+
234255 it("404s a modern request whose toolkit route is not served", async () => {
235256 const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
236257 const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra"));
packages/hosts/mcp/src/envelope.ts+20−2
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 import {
2323 appsEnabledForClientCapabilities,
2424 clientCapabilitiesFromRequest,
25+ mcpRequestStateBindingFromBody,
2526 mcpRequestStatePrincipal,
2627 requestBodyFromRequest,
2728 } from "./tool-server";
@@ -155,6 +156,10 @@ export const jsonRpcErrorBody = ( · 127 unmodified lines
155156 });
156157 };
157158
159+/** Graceful rollback response that lets auto-negotiating v2 clients try legacy. */
160+export const mcpModernDisabledResponse = (opts?: { readonly cors?: boolean }): Response =>
161+ jsonRpcErrorBody(400, -32022, "MCP 2026-07-28 support is disabled", opts);
162+
158163 /**
159164 * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back
160165 * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP
@@ -252,6 +257,7 @@ const withModernMcpCors = (response: Response): Response => { · 91 unmodified lines
252257
253258 interface ModernRequestInputs {
254259 readonly builder: McpModernServerBuilder["Service"];
260+ readonly parsedBody: unknown;
255261 readonly principal: Principal;
256262 }
257263
@@ -289,11 +295,20 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 31 unmodified lines
289295 return Effect.runPromise(
290296 Effect.gen(function* () {
291297 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
298+ const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal);
299+ const requestStateBinding = yield* Effect.promise(() =>
300+ mcpRequestStateBindingFromBody({
301+ body: inputs.parsedBody,
302+ principal: requestStatePrincipal,
303+ resource,
304+ }),
305+ );
292306 return yield* inputs.builder.build(inputs.principal, {
293307 resource,
294308 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
295309 requestStateSigningKey: getSigningKey(),
296− requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal),
310+ requestStatePrincipal,
311+ ...(requestStateBinding === null ? {} : { requestStateBinding }),
297312 });
298313 }),
299314 );
@@ -306,8 +321,8 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 6 unmodified lines
306321
307322 return {
308323 fetch: async (request, principal, resource, builder) => {
309− requestInputs.set(request, { builder, principal });
310324 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
325+ requestInputs.set(request, { builder, parsedBody, principal });
311326 return handlerFor(resource).fetch(request, { parsedBody });
312327 },
313328 };
@@ -350,6 +365,9 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => · 36 unmodified lines
350365
351366 if (!(yield* Effect.promise(() => isLegacyRequest(request)))) {
352367 const builder = yield* McpModernServerBuilder;
368+ if (builder.enabled === false) {
369+ return fromWebResponse(mcpModernDisabledResponse());
370+ }
353371 const response = yield* Effect.promise(() =>
354372 modern.fetch(request, principal, resource, builder),
355373 );
packages/hosts/mcp/src/index.ts+1−0
@@ -41,5 +41,6 @@ export { · 40 unmodified lines
4141 McpServingRoutes,
4242 McpDiscoveryRoutes,
4343 jsonRpcErrorBody,
44+ mcpModernDisabledResponse,
4445 UNAVAILABLE_RETRY_AFTER_SECONDS,
4546 } from "./envelope";
packages/hosts/mcp/src/seams.ts+4−0
@@ -286,6 +286,8 @@ export interface McpModernServerBuildOptions { · 285 unmodified lines
286286 readonly requestStateSigningKey: Uint8Array | string;
287287 /** Stable authenticated-owner key bound into signed continuation state. */
288288 readonly requestStatePrincipal: string;
289+ /** Closed resource/tool/code binding for this parsed modern request. */
290+ readonly requestStateBinding?: string;
289291 }
290292
291293 /**
@@ -297,6 +299,8 @@ export interface McpModernServerBuildOptions { · 5 unmodified lines
297299 export class McpModernServerBuilder extends Context.Service<
298300 McpModernServerBuilder,
299301 {
302+ /** Inbound-only emergency switch. Unset means modern serving is enabled. */
303+ readonly enabled?: boolean;
300304 readonly build: (
301305 principal: Principal,
302306 options: McpModernServerBuildOptions,
packages/hosts/mcp/src/tool-server-protocol.test.ts+56−11
@@ -14,9 +14,14 @@ import { · 13 unmodified lines
1414 import { Effect } from "effect";
1515
1616 import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
17+import { defaultMcpResource, type McpResource } from "./seams";
1718 import { FormElicitation, ToolAddress } from "@executor-js/sdk";
1819
19−import { appsEnabledForClientCapabilities, buildMcpServer } from "./tool-server";
20+import {
21+ appsEnabledForClientCapabilities,
22+ buildMcpServer,
23+ mcpRequestStateBindingFromBody,
24+} from "./tool-server";
2025 import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
2126
2227 const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
@@ -30,6 +35,8 @@ type TestServerConfig = { · 7 unmodified lines
3035 readonly loadAppShellHtml?: () => Promise<string>;
3136 /** Evaluated per request, so a test can swap principals between rounds. */
3237 readonly requestStatePrincipal?: () => string;
38+ /** Evaluated per request, so a test can swap resources between rounds. */
39+ readonly requestStateResource?: () => McpResource;
3340 };
3441
3542 const makeStubEngine = (
@@ -56,22 +63,37 @@ const withClient = async ( · 20 unmodified lines
5663 run: (client: Client) => Promise<void>,
5764 options?: { readonly manualInputRequired?: boolean },
5865 ) => {
66+ const requestBodies = new WeakMap<Request, unknown>();
5967 const handler = createMcpHandler(
60− () =>
68+ (context) =>
6169 Effect.runPromise(
62− buildMcpServer({
63− ...config,
64− requestStateSigningKey: REQUEST_STATE_KEY,
65− requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test",
70+ Effect.gen(function* () {
71+ const requestStatePrincipal = config.requestStatePrincipal?.() ?? "principal-test";
72+ const requestStateResource = config.requestStateResource?.() ?? defaultMcpResource;
73+ const requestStateBinding = yield* Effect.promise(() =>
74+ mcpRequestStateBindingFromBody({
75+ body: context.requestInfo ? requestBodies.get(context.requestInfo) : undefined,
76+ principal: requestStatePrincipal,
77+ resource: requestStateResource,
78+ }),
79+ );
80+ return yield* buildMcpServer({
81+ ...config,
82+ requestStateSigningKey: REQUEST_STATE_KEY,
83+ requestStatePrincipal,
84+ ...(requestStateBinding === null ? {} : { requestStateBinding }),
85+ });
6686 }),
6787 ),
6888 { legacy: "reject" },
6989 );
7090 const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), {
71− fetch: (input, init) =>
72− handler.fetch(
73− input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
74− ),
91+ fetch: async (input, init) => {
92+ const request =
93+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
94+ requestBodies.set(request, await request.clone().json());
95+ return handler.fetch(request, { parsedBody: requestBodies.get(request) });
96+ },
7597 });
7698 const client = new Client(
7799 { name: "executor-protocol-test", version: "1.0.0" },
@@ -271,7 +293,7 @@ describe("Executor MCP protocol assembly", () => { · 193 unmodified lines
271293 );
272294 });
273295
274− it("rejects a requestState echoed by a different principal", async () => {
296+ it("rejects a requestState echoed with a different principal, resource, or code", async () => {
275297 const request = FormElicitation.make({
276298 message: "Confirm",
277299 requestedSchema: {},
@@ -294,12 +316,14 @@ describe("Executor MCP protocol assembly", () => { · 16 unmodified lines
294316 });
295317
296318 let principal = "user-a";
319+ let resource: McpResource = defaultMcpResource;
297320 await withClient(
298321 {
299322 engine,
300323 appsEnabled: false,
301324 elicitationMode: { mode: "native" },
302325 requestStatePrincipal: () => principal,
326+ requestStateResource: () => resource,
303327 },
304328 async (client) => {
305329 const first = await manualToolCall(client, {
@@ -318,6 +342,27 @@ describe("Executor MCP protocol assembly", () => { · 12 unmodified lines
318342 requestState: first.requestState,
319343 }),
320344 ).rejects.toMatchObject({ code: -32602 });
345+
346+ principal = "user-a";
347+ resource = { kind: "toolkit", slug: "other" };
348+ await expect(
349+ manualToolCall(client, {
350+ name: "execute",
351+ arguments: { code: "await tools.test.echo()" },
352+ inputResponses: { elicitation: { action: "accept", content: {} } },
353+ requestState: first.requestState,
354+ }),
355+ ).rejects.toMatchObject({ code: -32602 });
356+
357+ resource = defaultMcpResource;
358+ await expect(
359+ manualToolCall(client, {
360+ name: "execute",
361+ arguments: { code: "await tools.test.different()" },
362+ inputResponses: { elicitation: { action: "accept", content: {} } },
363+ requestState: first.requestState,
364+ }),
365+ ).rejects.toMatchObject({ code: -32602 });
321366 expect(resumeCalls).toBe(0);
322367 },
323368 { manualInputRequired: true },
packages/hosts/mcp/src/tool-server.ts+119−27
@@ -22,6 +22,7 @@ import * as z from "zod/v4"; · 21 unmodified lines
2222
2323 import type { ElicitationRequest } from "@executor-js/sdk";
2424
25+import { mcpResourceKey, type McpResource } from "./seams";
2526 import {
2627 getUiCapability,
2728 EXTENSION_ID,
@@ -61,6 +62,15 @@ const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); · 33 unmodified lines
6162 export type NativeRequestState = typeof NativeRequestStateSchema.Type;
6263 const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
6364
65+const NativeRequestStateCallSchema = Schema.Struct({
66+ method: Schema.Literal("tools/call"),
67+ params: Schema.Struct({
68+ name: Schema.String,
69+ arguments: Schema.Struct({ code: Schema.String }),
70+ }),
71+});
72+const decodeNativeRequestStateCall = Schema.decodeUnknownOption(NativeRequestStateCallSchema);
73+
6474 type McpServerRequestContext = McpRequestJoinKeys & {
6575 readonly serverContext: ServerContext;
6676 };
@@ -86,6 +96,8 @@ export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.Yield · 19 unmodified lines
8696 * influences authorization. Single-user hosts pass a constant.
8797 */
8898 readonly requestStatePrincipal: string;
99+ /** Closed request binding derived before the modern server is constructed. */
100+ readonly requestStateBinding?: string;
89101 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */
90102 readonly requestStateTtlSeconds?: number;
91103 };
@@ -101,39 +113,108 @@ export const mcpRequestStatePrincipal = (principal: { · 9 unmodified lines
101113 readonly organizationId: string;
102114 }): string => `${principal.accountId}\u0000${principal.organizationId}`;
103115
104−const requestStateBinding = (method: string, principal: string): string =>
105− `${method}\u0000${principal}`;
116+/** Fields whose exact values bind one modern native-elicitation continuation. */
117+export interface McpRequestStateBindingInput {
118+ readonly principal: string;
119+ readonly resource: McpResource;
120+ readonly method: string;
121+ readonly toolName: string;
122+ readonly codeDigest: string;
123+}
124+
125+/** Build the canonical NUL-separated modern continuation binding. */
126+export const mcpRequestStateBinding = (input: McpRequestStateBindingInput): string =>
127+ [
128+ input.principal,
129+ mcpResourceKey(input.resource),
130+ input.method,
131+ input.toolName,
132+ input.codeDigest,
133+ ].join("\u0000");
134+
135+/** Return the lowercase SHA-256 digest used to bind an execute code argument. */
136+export const mcpCodeDigest = async (code: string): Promise<string> => {
137+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code));
138+ return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
139+};
140+
141+/** Derive a continuation binding from an already-parsed modern tools/call body. */
142+export const mcpRequestStateBindingFromBody = async (input: {
143+ readonly body: unknown;
144+ readonly principal: string;
145+ readonly resource: McpResource;
146+}): Promise<string | null> => {
147+ const call = decodeNativeRequestStateCall(input.body);
148+ if (Option.isNone(call)) return null;
149+ return mcpRequestStateBinding({
150+ principal: input.principal,
151+ resource: input.resource,
152+ method: call.value.method,
153+ toolName: call.value.params.name,
154+ codeDigest: await mcpCodeDigest(call.value.params.arguments.code),
155+ });
156+};
106157
107158 /** Route-level failure verifying untrusted modern continuation state. */
108159 export class McpRequestStateVerificationError extends Data.TaggedError(
109160 "McpRequestStateVerificationError",
110161 )<{ readonly cause: unknown }> {}
111162
163+class McpRequestStateBindingError extends Data.TaggedError("McpRequestStateBindingError")<{}> {}
164+
165+const requestStateBindingForContext = (
166+ binding: string | undefined,
167+ principal: string,
168+ context: ServerContext,
169+): Promise<string> => {
170+ if (binding !== undefined) return Promise.resolve(binding);
171+ if (context.mcpReq.envelope === undefined) {
172+ return Promise.resolve(`${context.mcpReq.method}\u0000${principal}`);
173+ }
174+ return Effect.runPromise(Effect.fail(new McpRequestStateBindingError()));
175+};
176+
112177 /**
113178 * Verify and parse a modern continuation before a stateless worker uses its
114179 * execution id for Durable Object routing.
115180 */
116181 export const verifyNativeRequestState = (input: {
117182 readonly state: string;
118− readonly method: string;
183+ readonly body: unknown;
184+ readonly resource: McpResource;
119185 readonly requestStateSigningKey: Uint8Array | string;
120186 readonly requestStatePrincipal: string;
121−}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => {
122− const codec = createRequestStateCodec<NativeRequestState>({
123− key: input.requestStateSigningKey,
124− bind: () => requestStateBinding(input.method, input.requestStatePrincipal),
125− });
126− return Effect.tryPromise({
127− // The route-level verifier has no handler context. Its codec binding is a
128− // closed value derived from the already-parsed method and principal, so the
129− // SDK callback never observes this inert placeholder.
130− try: async () => {
131− const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]);
132− return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
133− },
134− catch: (cause) => new McpRequestStateVerificationError({ cause }),
187+}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> =>
188+ Effect.gen(function* () {
189+ const binding = yield* Effect.tryPromise({
190+ try: () =>
191+ mcpRequestStateBindingFromBody({
192+ body: input.body,
193+ principal: input.requestStatePrincipal,
194+ resource: input.resource,
195+ }),
196+ catch: (cause) => new McpRequestStateVerificationError({ cause }),
197+ });
198+ if (binding === null) {
199+ return yield* new McpRequestStateVerificationError({
200+ cause: "invalid request-state binding",
201+ });
202+ }
203+ const codec = createRequestStateCodec<NativeRequestState>({
204+ key: input.requestStateSigningKey,
205+ bind: () => binding,
206+ });
207+ const decoded = yield* Effect.tryPromise({
208+ // The route-level verifier has no handler context. Its codec binding is a
209+ // closed value derived from the parsed call, resource, and principal, so
210+ // the SDK callback never observes this inert placeholder.
211+ try: () => Reflect.apply(codec.verify, codec, [input.state, null]) as Promise<unknown>,
212+ catch: (cause) => new McpRequestStateVerificationError({ cause }),
213+ });
214+ return yield* Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded).pipe(
215+ Effect.mapError((cause) => new McpRequestStateVerificationError({ cause })),
216+ );
135217 });
136−};
137218
138219 const isRecord = (value: unknown): value is Record<string, unknown> =>
139220 typeof value === "object" && value !== null && !Array.isArray(value);
@@ -285,15 +366,21 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 145 unmodified lines
285366 const initialAppsEnabled = sessionful
286367 ? (config.restoredAppsEnabled ?? config.appsEnabled)
287368 : config.appsEnabled;
288− const requestStateCodec = createRequestStateCodec<NativeRequestState>({
289− key: config.requestStateSigningKey,
290− ...(config.requestStateTtlSeconds === undefined
291− ? {}
292− : { ttlSeconds: config.requestStateTtlSeconds }),
293− bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal),
294− });
369+ const requestStateCodec = (binding: string) =>
370+ createRequestStateCodec<NativeRequestState>({
371+ key: config.requestStateSigningKey,
372+ ...(config.requestStateTtlSeconds === undefined
373+ ? {}
374+ : { ttlSeconds: config.requestStateTtlSeconds }),
375+ bind: () => binding,
376+ });
295377 const verifyRequestState = async (state: string, context: ServerContext) => {
296− const decoded = await requestStateCodec.verify(state, context);
378+ const binding = await requestStateBindingForContext(
379+ config.requestStateBinding,
380+ config.requestStatePrincipal,
381+ context,
382+ );
383+ const decoded = await requestStateCodec(binding).verify(state, context);
297384 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
298385 };
299386 const server = new McpServer(
@@ -353,7 +440,12 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 53 unmodified lines
353440 NativeExecutionServices<NativeE, McpServerRequestContext>["executionPaused"]
354441 >[0],
355442 ): Promise<InputRequiredResult> => {
356− const requestState = await requestStateCodec.mint(
443+ const binding = await requestStateBindingForContext(
444+ config.requestStateBinding,
445+ config.requestStatePrincipal,
446+ services.requestContext.serverContext,
447+ );
448+ const requestState = await requestStateCodec(binding).mint(
357449 { executionId: execution.id },
358450 services.requestContext.serverContext,
359451 );