History

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

Rhys Sullivan committed 3 months ago

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

Showing the changes to apps (15 of 26 files). Show all

Unified · Split

15 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
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");

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t