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
30 "@executor-js/runtime-quickjs": "workspace:*",30 "@executor-js/runtime-quickjs": "workspace:*",
31 "@executor-js/sdk": "workspace:*",31 "@executor-js/sdk": "workspace:*",
32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
33 "@modelcontextprotocol/client": "2.0.0",
33 "@modelcontextprotocol/sdk": "^1.29.0",34 "@modelcontextprotocol/sdk": "^1.29.0",
34 "@sentry/bun": "^10.57.0",35 "@sentry/bun": "^10.57.0",
35 "effect": "catalog:",36 "effect": "catalog:",
apps/cli/src/main.ts+1−1
@@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines
75import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
76import * as Option from "effect/Option";76import * as Option from "effect/Option";
77import * as Cause from "effect/Cause";77import * as Cause from "effect/Cause";
78import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";78import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
79import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";79import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
80import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";80import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
81 81
apps/cloud/src/env-augment.d.ts+2−0
@@ -69,6 +69,8 @@ declare global { · 68 unmodified lines
69 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;69 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
70 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */70 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
71 MCP_REQUEST_STATE_KEY?: string;71 MCP_REQUEST_STATE_KEY?: string;
72 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
73 MCP_2026_07_28_ENABLED?: string;
72 NODE_ENV?: string;74 NODE_ENV?: string;
73 75
74 // Shared with frontend76 // Shared with frontend
apps/cloud/src/mcp/agent-handler.ts+4−0
@@ -4,6 +4,7 @@ import { Effect, Predicate } from "effect"; · 3 unmodified lines
4import {4import {
5 McpAuthProvider,5 McpAuthProvider,
6 jsonRpcErrorBody,6 jsonRpcErrorBody,
7 mcpModernDisabledResponse,
7 defaultMcpResource,8 defaultMcpResource,
8 UNAVAILABLE_RETRY_AFTER_SECONDS,9 UNAVAILABLE_RETRY_AFTER_SECONDS,
9 type AuthOutcome,10 type AuthOutcome,
@@ -174,6 +175,9 @@ export const makeCloudMcpAgentHandler = () => { · 164 unmodified lines
174 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));175 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
175 const era = await classifyMcpProtocolEra(request, parsedBody);176 const era = await classifyMcpProtocolEra(request, parsedBody);
176 if (era === "modern") {177 if (era === "modern") {
178 if (env.MCP_2026_07_28_ENABLED === "false") {
179 return mcpModernDisabledResponse();
180 }
177 const resource = resourceFromPath(request);181 const resource = resourceFromPath(request);
178 const props = await runTraced(182 const props = await runTraced(
179 request,183 request,
apps/cloud/wrangler.jsonc+3−0
@@ -101,6 +101,9 @@ · 100 unmodified lines
101 // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`.101 // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`.
102 // It must never be placed in `vars` or generated independently per isolate.102 // It must never be placed in `vars` or generated independently per isolate.
103 "vars": {103 "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.
104 "VITE_PUBLIC_SITE_URL": "https://executor.sh",107 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
105 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",108 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
106 // Browser OTLP spans → same-origin, forwarded to Axiom by the worker109 // 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
49 readonly EXECUTOR_SECRET_KEY?: string;49 readonly EXECUTOR_SECRET_KEY?: string;
50 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */50 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
51 readonly MCP_REQUEST_STATE_KEY?: string;51 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;
52 readonly ALLOW_LOCAL_NETWORK?: string;54 readonly ALLOW_LOCAL_NETWORK?: string;
53 readonly VITE_PUBLIC_SITE_URL?: string;55 readonly VITE_PUBLIC_SITE_URL?: string;
54 /**56 /**
apps/host-cloudflare/src/mcp/agent-handler.ts+4−0
@@ -3,6 +3,7 @@ import { Effect, Predicate } from "effect"; · 2 unmodified lines
3import {3import {
4 McpAuthProvider,4 McpAuthProvider,
5 jsonRpcErrorBody,5 jsonRpcErrorBody,
6 mcpModernDisabledResponse,
6 defaultMcpResource,7 defaultMcpResource,
7 type AuthOutcome,8 type AuthOutcome,
8 type Principal,9 type Principal,
@@ -112,6 +113,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 103 unmodified lines
112 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));113 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
113 const era = await classifyMcpProtocolEra(request, parsedBody);114 const era = await classifyMcpProtocolEra(request, parsedBody);
114 if (era === "modern") {115 if (era === "modern") {
116 if (env.MCP_2026_07_28_ENABLED === "false") {
117 return mcpModernDisabledResponse();
118 }
115 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));119 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
116 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;120 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
117 const forwarded = withVerifiedIdentityHeaders(121 const forwarded = withVerifiedIdentityHeaders(
apps/host-cloudflare/wrangler.jsonc+3−0
@@ -73,6 +73,9 @@ · 72 unmodified lines
73 // MCP key is a deployment prerequisite shared by Worker and session DOs;73 // MCP key is a deployment prerequisite shared by Worker and session DOs;
74 // never generate it independently per isolate.74 // never generate it independently per isolate.
75 "vars": {75 "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.
76 "ACCESS_NAME_CLAIM": "name",79 "ACCESS_NAME_CLAIM": "name",
77 "ACCESS_GROUPS_CLAIM": "groups",80 "ACCESS_GROUPS_CLAIM": "groups",
78 // Never preserve a production dev-auth override through keep_vars.81 // 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
78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
79 // Pass the pinned public origin so browser-approval URLs are reachable behind79 // Pass the pinned public origin so browser-approval URLs are reachable behind
80 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).80 // 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 );
82 87
83 // CLI device-login discovery (`executor login`). Points the CLI at Better88 // CLI device-login discovery (`executor login`). Points the CLI at Better
84 // Auth's device endpoints; `requestFormat: "json"` because those endpoints89 // 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
32 * internal network unless an operator opts in.32 * internal network unless an operator opts in.
33 */33 */
34 readonly allowLocalNetwork: boolean;34 readonly allowLocalNetwork: boolean;
35 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
36 readonly mcp20260728Enabled: boolean;
35 // Better Auth session secret. Always resolved (env, else generated + persisted37 // Better Auth session secret. Always resolved (env, else generated + persisted
36 // under the data dir) so a single-container deploy boots with no env; the auth38 // under the data dir) so a single-container deploy boots with no env; the auth
37 // layer still validates an explicitly-set env secret is long enough.39 // layer still validates an explicitly-set env secret is long enough.
@@ -142,6 +144,7 @@ export const loadConfig = (): SelfHostConfig => { · 104 unmodified lines
142 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),144 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
143 webBaseUrl: resolveWebBaseUrl(port),145 webBaseUrl: resolveWebBaseUrl(port),
144 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",146 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
147 mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false",
145 authSecret: resolveAuthSecret(),148 authSecret: resolveAuthSecret(),
146 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,149 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
147 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,150 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
140 dbHandle: SelfHostDbHandle,140 dbHandle: SelfHostDbHandle,
141 betterAuth: BetterAuthHandle,141 betterAuth: BetterAuthHandle,
142 webBaseUrl?: string,142 webBaseUrl?: string,
143 modernEnabled = true,
143): SelfHostMcpSeams => {144): SelfHostMcpSeams => {
144 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);145 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
145 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(146 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
@@ -148,7 +149,7 @@ export const makeSelfHostMcpSeams = ( · 2 unmodified lines
148 return {149 return {
149 auth,150 auth,
150 sessions: selfHostMcpSessions(sessionStore),151 sessions: selfHostMcpSessions(sessionStore),
151 modern: makeSelfHostMcpModernServerBuilder(dbHandle),152 modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled),
152 reporter: selfHostMcpReporter,153 reporter: selfHostMcpReporter,
153 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),154 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
154 close: sessionStore.close,155 close: sessionStore.close,
apps/host-selfhost/src/mcp/session-store.ts+2−0
@@ -54,8 +54,10 @@ export const makeSelfHostMcpSessionStore = ( · 53 unmodified lines
54/** Build the stateless MCP server seam over the same self-host stack/config. */54/** Build the stateless MCP server seam over the same self-host stack/config. */
55export const makeSelfHostMcpModernServerBuilder = (55export const makeSelfHostMcpModernServerBuilder = (
56 db: SelfHostDbHandle,56 db: SelfHostDbHandle,
57 enabled = true,
57): Layer.Layer<McpModernServerBuilder> =>58): Layer.Layer<McpModernServerBuilder> =>
58 Layer.succeed(McpModernServerBuilder)({59 Layer.succeed(McpModernServerBuilder)({
60 enabled,
59 build: makeMcpBuildServer(61 build: makeMcpBuildServer(
60 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),62 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
61 {63 {
apps/local/src/main.ts+1−0
@@ -119,6 +119,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 118 unmodified lines
119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
120 };120 };
121 mcp = createMcpRequestHandler({121 mcp = createMcpRequestHandler({
122 modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false",
122 defaultConfig: {123 defaultConfig: {
123 engine,124 engine,
124 artifacts: executor.artifacts,125 artifacts: executor.artifacts,
apps/local/src/mcp-modern.test.ts+52−0
@@ -51,4 +51,56 @@ describe("local modern MCP HTTP", () => { · 50 unmodified lines
51 await mcp.close();51 await mcp.close();
52 }52 }
53 });53 });
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 });
54});106});
apps/local/src/mcp.ts+18−0
@@ -11,6 +11,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio"; · 10 unmodified lines
11import {11import {
12 defaultMcpResource,12 defaultMcpResource,
13 jsonRpcErrorBody,13 jsonRpcErrorBody,
14 mcpModernDisabledResponse,
14 mcpResourceKey,15 mcpResourceKey,
15 type McpResource,16 type McpResource,
16} from "@executor-js/host-mcp";17} from "@executor-js/host-mcp";
@@ -18,6 +19,7 @@ import { · 1 unmodified line
18 appsEnabledForClientCapabilities,19 appsEnabledForClientCapabilities,
19 buildMcpServer,20 buildMcpServer,
20 clientCapabilitiesFromRequest,21 clientCapabilitiesFromRequest,
22 mcpRequestStateBindingFromBody,
21 requestBodyFromRequest,23 requestBodyFromRequest,
22 type ExecutorMcpToolConfig,24 type ExecutorMcpToolConfig,
23} from "@executor-js/host-mcp/tool-server";25} from "@executor-js/host-mcp/tool-server";
@@ -59,6 +61,8 @@ export interface LocalMcpServerConfig { · 35 unmodified lines
59 61
60export interface LocalMcpRequestHandlerConfig {62export interface LocalMcpRequestHandlerConfig {
61 readonly defaultConfig: ExecutorMcpToolConfig;63 readonly defaultConfig: ExecutorMcpToolConfig;
64 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
65 readonly modernEnabled?: boolean;
62 readonly createConfigForResource?: (66 readonly createConfigForResource?: (
63 resource: McpResource,67 resource: McpResource,
64 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;68 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -138,6 +142,7 @@ export const createMcpRequestHandler = ( · 73 unmodified lines
138 const sessionEngines = new Map<string, AnyExecutionEngine>();142 const sessionEngines = new Map<string, AnyExecutionEngine>();
139 const sessionClosers = new Map<string, () => Promise<void>>();143 const sessionClosers = new Map<string, () => Promise<void>>();
140 const modernHandlers = new Map<string, McpHttpHandler>();144 const modernHandlers = new Map<string, McpHttpHandler>();
145 const modernRequestBodies = new WeakMap<Request, unknown>();
141 const approvals = makeInProcessBrowserApprovalStore();146 const approvals = makeInProcessBrowserApprovalStore();
142 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);147 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
143 let requestStateSigningKey: Uint8Array | undefined;148 let requestStateSigningKey: Uint8Array | undefined;
@@ -189,16 +194,25 @@ export const createMcpRequestHandler = ( · 45 unmodified lines
189 // 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 defect194 // 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
190 return Effect.runPromise(Effect.die("Modern MCP request context has no request"));195 return Effect.runPromise(Effect.die("Modern MCP request context has no request"));
191 }196 }
197 const parsedBody = modernRequestBodies.get(request);
192 return Effect.runPromise(198 return Effect.runPromise(
193 Effect.gen(function* () {199 Effect.gen(function* () {
194 const resourceConfig = yield* Effect.promise(() => configForResource(resource));200 const resourceConfig = yield* Effect.promise(() => configForResource(resource));
195 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);201 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
202 const requestStateBinding = yield* Effect.promise(() =>
203 mcpRequestStateBindingFromBody({
204 body: parsedBody,
205 principal: "local",
206 resource,
207 }),
208 );
196 const server = yield* buildMcpServer({209 const server = yield* buildMcpServer({
197 ...resourceConfig.config,210 ...resourceConfig.config,
198 artifactsEnabled: readArtifactsEnabled(request),211 artifactsEnabled: readArtifactsEnabled(request),
199 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),212 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
200 requestStateSigningKey: signingKey(),213 requestStateSigningKey: signingKey(),
201 requestStatePrincipal: "local",214 requestStatePrincipal: "local",
215 ...(requestStateBinding === null ? {} : { requestStateBinding }),
202 });216 });
203 if (resourceConfig.close) {217 if (resourceConfig.close) {
204 const closeServer = server.close.bind(server);218 const closeServer = server.close.bind(server);
@@ -226,7 +240,11 @@ export const createMcpRequestHandler = ( · 21 unmodified lines
226 const resource = resourceFromRequest(request);240 const resource = resourceFromRequest(request);
227 if (!resource) return jsonError(404, -32001, "MCP resource not found");241 if (!resource) return jsonError(404, -32001, "MCP resource not found");
228 if (!(await isLegacyRequest(request))) {242 if (!(await isLegacyRequest(request))) {
243 if (handlerConfig.modernEnabled === false) {
244 return mcpModernDisabledResponse({ cors: false });
245 }
229 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));246 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
247 modernRequestBodies.set(request, parsedBody);
230 return modernHandlerFor(resource).fetch(request, { parsedBody });248 return modernHandlerFor(resource).fetch(request, { parsedBody });
231 }249 }
232 const sessionId = request.headers.get("mcp-session-id");250 const sessionId = request.headers.get("mcp-session-id");
bun.lock+1−0
@@ -43,6 +43,7 @@ · 42 unmodified lines
43 "@executor-js/runtime-quickjs": "workspace:*",43 "@executor-js/runtime-quickjs": "workspace:*",
44 "@executor-js/sdk": "workspace:*",44 "@executor-js/sdk": "workspace:*",
45 "@jitl/quickjs-wasmfile-release-sync": "catalog:",45 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
46 "@modelcontextprotocol/client": "2.0.0",
46 "@modelcontextprotocol/sdk": "^1.29.0",47 "@modelcontextprotocol/sdk": "^1.29.0",
47 "@sentry/bun": "^10.57.0",48 "@sentry/bun": "^10.57.0",
48 "effect": "catalog:",49 "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.
8import { expect } from "@effect/vitest";
9import { Client as ModernClient } from "@modelcontextprotocol/client";
10import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio";
11import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js";
12import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13import { Effect } from "effect";
14import { mkdtempSync, readFileSync, rmSync } from "node:fs";
15import { tmpdir } from "node:os";
16import { join } from "node:path";
17import { fileURLToPath } from "node:url";
18
19import { scenario } from "../src/scenario";
20
21const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
22const testScope = join(repoRoot, "apps/local");
23
24const 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
36const 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
50const 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
62scenario(
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
24import {24import {
25 appsEnabledForClientCapabilities,25 appsEnabledForClientCapabilities,
26 clientCapabilitiesFromRequestBody,26 clientCapabilitiesFromRequestBody,
27 mcpRequestStateBindingFromBody,
27 PAUSED_APPROVAL_TIMEOUT_MS,28 PAUSED_APPROVAL_TIMEOUT_MS,
28 formatMcpExecutionOutcome,29 formatMcpExecutionOutcome,
29 mcpRequestStatePrincipal,30 mcpRequestStatePrincipal,
@@ -162,6 +163,7 @@ export interface ModernMcpServerRequestOptions { · 132 unmodified lines
162 readonly appsEnabled: boolean;163 readonly appsEnabled: boolean;
163 readonly requestStateSigningKey: Uint8Array | string;164 readonly requestStateSigningKey: Uint8Array | string;
164 readonly requestStatePrincipal: string;165 readonly requestStatePrincipal: string;
166 readonly requestStateBinding?: string;
165}167}
166 168
167/** Long-lived DO execution runtime shared by per-request MCP servers. */169/** Long-lived DO execution runtime shared by per-request MCP servers. */
@@ -579,29 +581,16 @@ export abstract class McpAgentSessionDOBase< · 411 unmodified lines
579 }),581 }),
580 );582 );
581 await Effect.runPromise(this.closeRuntime());583 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=======
594 const activityKey =584 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;
596 const cleared = await this.ctx.storage.transaction(async (transaction) => {586 const cleared = await this.ctx.storage.transaction(async (transaction) => {
597 const current = await transaction.get<number>(activityKey);587 const current = await transaction.get<number>(activityKey);
598 if (current !== input.lastActivityMs) return false;588 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]);
600 await transaction.deleteAlarm();590 await transaction.deleteAlarm();
601 return true;591 return true;
602 });592 });
603 if (cleared) this.lastActivityMs = 0;593 if (cleared) this.lastActivityMs = 0;
604>>>>>>> d62e666df (Fix session-id parsing, stranded-stream replay, priming, and restore races)
605 }594 }
606 595
607 private resolveAndStoreSessionMeta(token: McpSessionInit) {596 private resolveAndStoreSessionMeta(token: McpSessionInit) {
@@ -828,20 +817,29 @@ export abstract class McpAgentSessionDOBase< · 220 unmodified lines
828 const propagation = self.modernRequestPropagation.get(request);817 const propagation = self.modernRequestPropagation.get(request);
829 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);818 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
830 return Effect.runPromise(819 return Effect.runPromise(
831 runtime820 Effect.gen(function* () {
832 .buildServer({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({
833 appsEnabled: appsEnabledForClientCapabilities(capabilities),833 appsEnabled: appsEnabledForClientCapabilities(capabilities),
834 requestStateSigningKey: self.modernRequestStateSigningKey(),834 requestStateSigningKey: self.modernRequestStateSigningKey(),
835 requestStatePrincipal: mcpRequestStatePrincipal({835 requestStatePrincipal,
836 accountId: sessionMeta.userId,836 ...(requestStateBinding === null ? {} : { requestStateBinding }),
837 organizationId: sessionMeta.organizationId,837 });
838 }),838 }).pipe(
839 })839 (effect) => self.withTelemetry(effect, propagation),
840 .pipe(840 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject
841 (effect) => self.withTelemetry(effect, propagation),841 Effect.orDie,
842 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject842 ),
843 Effect.orDie,
844 ),
845 );843 );
846 },844 },
847 { legacy: "reject" },845 { 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
6import {6import {
7 defaultMcpResource,7 defaultMcpResource,
8 type McpModernServerBuilder,8 type McpModernServerBuilder,
9 type McpResource,
9 type Principal,10 type Principal,
10} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
11import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";12import {
13 buildMcpServer,
14 mcpRequestStateBindingFromBody,
15 mcpRequestStatePrincipal,
16} from "@executor-js/host-mcp/tool-server";
12 17
13import type { McpSessionProps } from "./agent-session-durable-object";18import type { McpSessionProps } from "./agent-session-durable-object";
14import {19import {
@@ -168,13 +173,14 @@ const dispatch = async (input: { · 153 unmodified lines
168 readonly sessions: MemorySessions;173 readonly sessions: MemorySessions;
169 readonly directory: MemoryDirectory;174 readonly directory: MemoryDirectory;
170 readonly builder: McpModernServerBuilder["Service"];175 readonly builder: McpModernServerBuilder["Service"];
176 readonly resource?: McpResource;
171}) => {177}) => {
172 const request = modernRequest(input.body);178 const request = modernRequest(input.body);
173 return makeMcpModernRequestRouter().fetch({179 return makeMcpModernRequestRouter().fetch({
174 request,180 request,
175 parsedBody: input.body,181 parsedBody: input.body,
176 principal,182 principal,
177 resource: defaultMcpResource,183 resource: input.resource ?? defaultMcpResource,
178 props,184 props,
179 requestStateSigningKey: REQUEST_STATE_KEY,185 requestStateSigningKey: REQUEST_STATE_KEY,
180 builder: input.builder,186 builder: input.builder,
@@ -183,12 +189,29 @@ const dispatch = async (input: { · 2 unmodified lines
183 });189 });
184};190};
185 191
186const mintRequestState = async (executionId: string, ttlSeconds = 60): Promise<string> => {192const mintRequestState = async (
187 const binding = `tools/call\u0000${mcpRequestStatePrincipal(principal)}`;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();
188 const codec = createRequestStateCodec<{ readonly executionId: string }>({211 const codec = createRequestStateCodec<{ readonly executionId: string }>({
189 key: REQUEST_STATE_KEY,212 key: REQUEST_STATE_KEY,
190 ttlSeconds,213 ttlSeconds: options.ttlSeconds ?? 60,
191 bind: () => binding,214 bind: () => binding ?? "",
192 });215 });
193 const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]);216 const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]);
194 return typeof encoded === "string" ? encoded : "";217 return typeof encoded === "string" ? encoded : "";
@@ -392,7 +415,7 @@ describe("modern Cloudflare MCP worker routing", () => { · 197 unmodified lines
392 const valid = await mintRequestState("exec-invalid");415 const valid = await mintRequestState("exec-invalid");
393 const middle = Math.floor(valid.length / 2);416 const middle = Math.floor(valid.length / 2);
394 const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`;417 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 });
396 419
397 for (const requestState of [tampered, expired]) {420 for (const requestState of [tampered, expired]) {
398 const sessions = new MemorySessions();421 const sessions = new MemorySessions();
@@ -414,4 +437,43 @@ describe("modern Cloudflare MCP worker routing", () => { · 15 unmodified lines
414 expect(sessions.forwarded).toEqual([]);437 expect(sessions.forwarded).toEqual([]);
415 }438 }
416 });439 });
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 });
417});479});
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+19−6
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16import {16import {
17 appsEnabledForClientCapabilities,17 appsEnabledForClientCapabilities,
18 clientCapabilitiesFromRequestBody,18 clientCapabilitiesFromRequestBody,
19 mcpRequestStateBindingFromBody,
19 mcpRequestStatePrincipal,20 mcpRequestStatePrincipal,
20 verifyNativeRequestState,21 verifyNativeRequestState,
21} from "@executor-js/host-mcp/tool-server";22} from "@executor-js/host-mcp/tool-server";
@@ -168,11 +169,22 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { · 146 unmodified lines
168 }169 }
169 const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody);170 const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody);
170 return Effect.runPromise(171 return Effect.runPromise(
171 inputs.builder.build(inputs.principal, {172 Effect.gen(function* () {
172 resource,173 const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal);
173 appsEnabled: appsEnabledForClientCapabilities(capabilities),174 const requestStateBinding = yield* Effect.promise(() =>
174 requestStateSigningKey: inputs.requestStateSigningKey,175 mcpRequestStateBindingFromBody({
175 requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal),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 });
176 }),188 }),
177 );189 );
178 },190 },
@@ -214,7 +226,8 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { · 35 unmodified lines
214 const verified = await Effect.runPromiseExit(226 const verified = await Effect.runPromiseExit(
215 verifyNativeRequestState({227 verifyNativeRequestState({
216 state: call.params.requestState,228 state: call.params.requestState,
217 method: call.method,229 body: input.parsedBody,
230 resource: input.resource,
218 requestStateSigningKey: input.requestStateSigningKey,231 requestStateSigningKey: input.requestStateSigningKey,
219 requestStatePrincipal: mcpRequestStatePrincipal(input.principal),232 requestStatePrincipal: mcpRequestStatePrincipal(input.principal),
220 }),233 }),
packages/hosts/mcp/src/envelope.test.ts+21−0
@@ -231,6 +231,27 @@ describe("McpServingRoutes envelope", () => { · 230 unmodified lines
231 expect(response.headers.get("www-authenticate")).toBe(challenge);231 expect(response.headers.get("www-authenticate")).toBe(challenge);
232 });232 });
233 233
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
234 it("404s a modern request whose toolkit route is not served", async () => {255 it("404s a modern request whose toolkit route is not served", async () => {
235 const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);256 const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
236 const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra"));257 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
22import {22import {
23 appsEnabledForClientCapabilities,23 appsEnabledForClientCapabilities,
24 clientCapabilitiesFromRequest,24 clientCapabilitiesFromRequest,
25 mcpRequestStateBindingFromBody,
25 mcpRequestStatePrincipal,26 mcpRequestStatePrincipal,
26 requestBodyFromRequest,27 requestBodyFromRequest,
27} from "./tool-server";28} from "./tool-server";
@@ -155,6 +156,10 @@ export const jsonRpcErrorBody = ( · 127 unmodified lines
155 });156 });
156};157};
157 158
159/** Graceful rollback response that lets auto-negotiating v2 clients try legacy. */
160export const mcpModernDisabledResponse = (opts?: { readonly cors?: boolean }): Response =>
161 jsonRpcErrorBody(400, -32022, "MCP 2026-07-28 support is disabled", opts);
162
158/**163/**
159 * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back164 * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back
160 * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP165 * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP
@@ -252,6 +257,7 @@ const withModernMcpCors = (response: Response): Response => { · 91 unmodified lines
252 257
253interface ModernRequestInputs {258interface ModernRequestInputs {
254 readonly builder: McpModernServerBuilder["Service"];259 readonly builder: McpModernServerBuilder["Service"];
260 readonly parsedBody: unknown;
255 readonly principal: Principal;261 readonly principal: Principal;
256}262}
257 263
@@ -289,11 +295,20 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 31 unmodified lines
289 return Effect.runPromise(295 return Effect.runPromise(
290 Effect.gen(function* () {296 Effect.gen(function* () {
291 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);297 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 );
292 return yield* inputs.builder.build(inputs.principal, {306 return yield* inputs.builder.build(inputs.principal, {
293 resource,307 resource,
294 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),308 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
295 requestStateSigningKey: getSigningKey(),309 requestStateSigningKey: getSigningKey(),
296 requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal),310 requestStatePrincipal,
311 ...(requestStateBinding === null ? {} : { requestStateBinding }),
297 });312 });
298 }),313 }),
299 );314 );
@@ -306,8 +321,8 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 6 unmodified lines
306 321
307 return {322 return {
308 fetch: async (request, principal, resource, builder) => {323 fetch: async (request, principal, resource, builder) => {
309 requestInputs.set(request, { builder, principal });
310 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));324 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
325 requestInputs.set(request, { builder, parsedBody, principal });
311 return handlerFor(resource).fetch(request, { parsedBody });326 return handlerFor(resource).fetch(request, { parsedBody });
312 },327 },
313 };328 };
@@ -350,6 +365,9 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => · 36 unmodified lines
350 365
351 if (!(yield* Effect.promise(() => isLegacyRequest(request)))) {366 if (!(yield* Effect.promise(() => isLegacyRequest(request)))) {
352 const builder = yield* McpModernServerBuilder;367 const builder = yield* McpModernServerBuilder;
368 if (builder.enabled === false) {
369 return fromWebResponse(mcpModernDisabledResponse());
370 }
353 const response = yield* Effect.promise(() =>371 const response = yield* Effect.promise(() =>
354 modern.fetch(request, principal, resource, builder),372 modern.fetch(request, principal, resource, builder),
355 );373 );
packages/hosts/mcp/src/index.ts+1−0
@@ -41,5 +41,6 @@ export { · 40 unmodified lines
41 McpServingRoutes,41 McpServingRoutes,
42 McpDiscoveryRoutes,42 McpDiscoveryRoutes,
43 jsonRpcErrorBody,43 jsonRpcErrorBody,
44 mcpModernDisabledResponse,
44 UNAVAILABLE_RETRY_AFTER_SECONDS,45 UNAVAILABLE_RETRY_AFTER_SECONDS,
45} from "./envelope";46} from "./envelope";
packages/hosts/mcp/src/seams.ts+4−0
@@ -286,6 +286,8 @@ export interface McpModernServerBuildOptions { · 285 unmodified lines
286 readonly requestStateSigningKey: Uint8Array | string;286 readonly requestStateSigningKey: Uint8Array | string;
287 /** Stable authenticated-owner key bound into signed continuation state. */287 /** Stable authenticated-owner key bound into signed continuation state. */
288 readonly requestStatePrincipal: string;288 readonly requestStatePrincipal: string;
289 /** Closed resource/tool/code binding for this parsed modern request. */
290 readonly requestStateBinding?: string;
289}291}
290 292
291/**293/**
@@ -297,6 +299,8 @@ export interface McpModernServerBuildOptions { · 5 unmodified lines
297export class McpModernServerBuilder extends Context.Service<299export class McpModernServerBuilder extends Context.Service<
298 McpModernServerBuilder,300 McpModernServerBuilder,
299 {301 {
302 /** Inbound-only emergency switch. Unset means modern serving is enabled. */
303 readonly enabled?: boolean;
300 readonly build: (304 readonly build: (
301 principal: Principal,305 principal: Principal,
302 options: McpModernServerBuildOptions,306 options: McpModernServerBuildOptions,
packages/hosts/mcp/src/tool-server-protocol.test.ts+56−11
@@ -14,9 +14,14 @@ import { · 13 unmodified lines
14import { Effect } from "effect";14import { Effect } from "effect";
15 15
16import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";16import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
17import { defaultMcpResource, type McpResource } from "./seams";
17import { FormElicitation, ToolAddress } from "@executor-js/sdk";18import { FormElicitation, ToolAddress } from "@executor-js/sdk";
18 19
19import { appsEnabledForClientCapabilities, buildMcpServer } from "./tool-server";20import {
21 appsEnabledForClientCapabilities,
22 buildMcpServer,
23 mcpRequestStateBindingFromBody,
24} from "./tool-server";
20import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";25import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
21 26
22const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);27const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
@@ -30,6 +35,8 @@ type TestServerConfig = { · 7 unmodified lines
30 readonly loadAppShellHtml?: () => Promise<string>;35 readonly loadAppShellHtml?: () => Promise<string>;
31 /** Evaluated per request, so a test can swap principals between rounds. */36 /** Evaluated per request, so a test can swap principals between rounds. */
32 readonly requestStatePrincipal?: () => string;37 readonly requestStatePrincipal?: () => string;
38 /** Evaluated per request, so a test can swap resources between rounds. */
39 readonly requestStateResource?: () => McpResource;
33};40};
34 41
35const makeStubEngine = (42const makeStubEngine = (
@@ -56,22 +63,37 @@ const withClient = async ( · 20 unmodified lines
56 run: (client: Client) => Promise<void>,63 run: (client: Client) => Promise<void>,
57 options?: { readonly manualInputRequired?: boolean },64 options?: { readonly manualInputRequired?: boolean },
58) => {65) => {
66 const requestBodies = new WeakMap<Request, unknown>();
59 const handler = createMcpHandler(67 const handler = createMcpHandler(
60 () =>68 (context) =>
61 Effect.runPromise(69 Effect.runPromise(
62 buildMcpServer({70 Effect.gen(function* () {
63 ...config,71 const requestStatePrincipal = config.requestStatePrincipal?.() ?? "principal-test";
64 requestStateSigningKey: REQUEST_STATE_KEY,72 const requestStateResource = config.requestStateResource?.() ?? defaultMcpResource;
65 requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test",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 });
66 }),86 }),
67 ),87 ),
68 { legacy: "reject" },88 { legacy: "reject" },
69 );89 );
70 const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), {90 const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), {
71 fetch: (input, init) =>91 fetch: async (input, init) => {
72 handler.fetch(92 const request =
73 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),93 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
74 ),94 requestBodies.set(request, await request.clone().json());
95 return handler.fetch(request, { parsedBody: requestBodies.get(request) });
96 },
75 });97 });
76 const client = new Client(98 const client = new Client(
77 { name: "executor-protocol-test", version: "1.0.0" },99 { name: "executor-protocol-test", version: "1.0.0" },
@@ -271,7 +293,7 @@ describe("Executor MCP protocol assembly", () => { · 193 unmodified lines
271 );293 );
272 });294 });
273 295
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 () => {
275 const request = FormElicitation.make({297 const request = FormElicitation.make({
276 message: "Confirm",298 message: "Confirm",
277 requestedSchema: {},299 requestedSchema: {},
@@ -294,12 +316,14 @@ describe("Executor MCP protocol assembly", () => { · 16 unmodified lines
294 });316 });
295 317
296 let principal = "user-a";318 let principal = "user-a";
319 let resource: McpResource = defaultMcpResource;
297 await withClient(320 await withClient(
298 {321 {
299 engine,322 engine,
300 appsEnabled: false,323 appsEnabled: false,
301 elicitationMode: { mode: "native" },324 elicitationMode: { mode: "native" },
302 requestStatePrincipal: () => principal,325 requestStatePrincipal: () => principal,
326 requestStateResource: () => resource,
303 },327 },
304 async (client) => {328 async (client) => {
305 const first = await manualToolCall(client, {329 const first = await manualToolCall(client, {
@@ -318,6 +342,27 @@ describe("Executor MCP protocol assembly", () => { · 12 unmodified lines
318 requestState: first.requestState,342 requestState: first.requestState,
319 }),343 }),
320 ).rejects.toMatchObject({ code: -32602 });344 ).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 });
321 expect(resumeCalls).toBe(0);366 expect(resumeCalls).toBe(0);
322 },367 },
323 { manualInputRequired: true },368 { manualInputRequired: true },
packages/hosts/mcp/src/tool-server.ts+119−27
@@ -22,6 +22,7 @@ import * as z from "zod/v4"; · 21 unmodified lines
22 22
23import type { ElicitationRequest } from "@executor-js/sdk";23import type { ElicitationRequest } from "@executor-js/sdk";
24 24
25import { mcpResourceKey, type McpResource } from "./seams";
25import {26import {
26 getUiCapability,27 getUiCapability,
27 EXTENSION_ID,28 EXTENSION_ID,
@@ -61,6 +62,15 @@ const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); · 33 unmodified lines
61export type NativeRequestState = typeof NativeRequestStateSchema.Type;62export type NativeRequestState = typeof NativeRequestStateSchema.Type;
62const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);63const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
63 64
65const 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});
72const decodeNativeRequestStateCall = Schema.decodeUnknownOption(NativeRequestStateCallSchema);
73
64type McpServerRequestContext = McpRequestJoinKeys & {74type McpServerRequestContext = McpRequestJoinKeys & {
65 readonly serverContext: ServerContext;75 readonly serverContext: ServerContext;
66};76};
@@ -86,6 +96,8 @@ export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.Yield · 19 unmodified lines
86 * influences authorization. Single-user hosts pass a constant.96 * influences authorization. Single-user hosts pass a constant.
87 */97 */
88 readonly requestStatePrincipal: string;98 readonly requestStatePrincipal: string;
99 /** Closed request binding derived before the modern server is constructed. */
100 readonly requestStateBinding?: string;
89 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */101 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */
90 readonly requestStateTtlSeconds?: number;102 readonly requestStateTtlSeconds?: number;
91 };103 };
@@ -101,39 +113,108 @@ export const mcpRequestStatePrincipal = (principal: { · 9 unmodified lines
101 readonly organizationId: string;113 readonly organizationId: string;
102}): string => `${principal.accountId}\u0000${principal.organizationId}`;114}): string => `${principal.accountId}\u0000${principal.organizationId}`;
103 115
104const requestStateBinding = (method: string, principal: string): string =>116/** Fields whose exact values bind one modern native-elicitation continuation. */
105 `${method}\u0000${principal}`;117export 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. */
126export 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. */
136export 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. */
142export 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};
106 157
107/** Route-level failure verifying untrusted modern continuation state. */158/** Route-level failure verifying untrusted modern continuation state. */
108export class McpRequestStateVerificationError extends Data.TaggedError(159export class McpRequestStateVerificationError extends Data.TaggedError(
109 "McpRequestStateVerificationError",160 "McpRequestStateVerificationError",
110)<{ readonly cause: unknown }> {}161)<{ readonly cause: unknown }> {}
111 162
163class McpRequestStateBindingError extends Data.TaggedError("McpRequestStateBindingError")<{}> {}
164
165const 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
112/**177/**
113 * Verify and parse a modern continuation before a stateless worker uses its178 * Verify and parse a modern continuation before a stateless worker uses its
114 * execution id for Durable Object routing.179 * execution id for Durable Object routing.
115 */180 */
116export const verifyNativeRequestState = (input: {181export const verifyNativeRequestState = (input: {
117 readonly state: string;182 readonly state: string;
118 readonly method: string;183 readonly body: unknown;
184 readonly resource: McpResource;
119 readonly requestStateSigningKey: Uint8Array | string;185 readonly requestStateSigningKey: Uint8Array | string;
120 readonly requestStatePrincipal: string;186 readonly requestStatePrincipal: string;
121}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => {187}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> =>
122 const codec = createRequestStateCodec<NativeRequestState>({188 Effect.gen(function* () {
123 key: input.requestStateSigningKey,189 const binding = yield* Effect.tryPromise({
124 bind: () => requestStateBinding(input.method, input.requestStatePrincipal),190 try: () =>
125 });191 mcpRequestStateBindingFromBody({
126 return Effect.tryPromise({192 body: input.body,
127 // The route-level verifier has no handler context. Its codec binding is a193 principal: input.requestStatePrincipal,
128 // closed value derived from the already-parsed method and principal, so the194 resource: input.resource,
129 // SDK callback never observes this inert placeholder.195 }),
130 try: async () => {196 catch: (cause) => new McpRequestStateVerificationError({ cause }),
131 const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]);197 });
132 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));198 if (binding === null) {
133 },199 return yield* new McpRequestStateVerificationError({
134 catch: (cause) => new McpRequestStateVerificationError({ cause }),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 );
135 });217 });
136};
137 218
138const isRecord = (value: unknown): value is Record<string, unknown> =>219const isRecord = (value: unknown): value is Record<string, unknown> =>
139 typeof value === "object" && value !== null && !Array.isArray(value);220 typeof value === "object" && value !== null && !Array.isArray(value);
@@ -285,15 +366,21 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 145 unmodified lines
285 const initialAppsEnabled = sessionful366 const initialAppsEnabled = sessionful
286 ? (config.restoredAppsEnabled ?? config.appsEnabled)367 ? (config.restoredAppsEnabled ?? config.appsEnabled)
287 : config.appsEnabled;368 : config.appsEnabled;
288 const requestStateCodec = createRequestStateCodec<NativeRequestState>({369 const requestStateCodec = (binding: string) =>
289 key: config.requestStateSigningKey,370 createRequestStateCodec<NativeRequestState>({
290 ...(config.requestStateTtlSeconds === undefined371 key: config.requestStateSigningKey,
291 ? {}372 ...(config.requestStateTtlSeconds === undefined
292 : { ttlSeconds: config.requestStateTtlSeconds }),373 ? {}
293 bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal),374 : { ttlSeconds: config.requestStateTtlSeconds }),
294 });375 bind: () => binding,
376 });
295 const verifyRequestState = async (state: string, context: ServerContext) => {377 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);
297 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));384 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
298 };385 };
299 const server = new McpServer(386 const server = new McpServer(
@@ -353,7 +440,12 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 53 unmodified lines
353 NativeExecutionServices<NativeE, McpServerRequestContext>["executionPaused"]440 NativeExecutionServices<NativeE, McpServerRequestContext>["executionPaused"]
354 >[0],441 >[0],
355 ): Promise<InputRequiredResult> => {442 ): 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(
357 { executionId: execution.id },449 { executionId: execution.id },
358 services.requestContext.serverContext,450 services.requestContext.serverContext,
359 );451 );