Skip to content

Commit 0b1739b

Browse files
authored
Serve MCP spec 2026-07-28 and consolidate onto SDK v2
* Add SDK v2 tool-server assembly with shared registration core * Serve MCP 2026-07-28 clients on selfhost and local HTTP * Tamper an interior requestState character in the rejection test * Serve MCP 2026-07-28 clients on the Cloudflare hosts * Serve both MCP eras from the v2 stack on neutral hosts * Host cloud MCP sessions on our own DO with the v2 stack * Delete the v1 MCP assembly and retire the transitional naming * Fix session-id parsing, stranded-stream replay, priming, and restore races * Add modern-spec e2e coverage in both protocol directions * Harden requestState binding, add inbound rollback switch, fix CLI bridge era handling
1 parent 8cb0d22 commit 0b1739b

87 files changed

Lines changed: 9017 additions & 5827 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError";
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/auth/handlers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
624624
Effect.gen(function* () {
625625
const owner = yield* requireSelectedOrganization;
626626
const stub = getMcpSessionStub(params.mcpSessionId);
627+
if (!stub) {
628+
return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
629+
}
627630
const result = yield* Effect.promise(() =>
628631
stub.getPausedExecutionForApproval(params.executionId, {
629632
accountId: owner.accountId,
@@ -645,6 +648,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
645648
Effect.gen(function* () {
646649
const owner = yield* requireSelectedOrganization;
647650
const stub = getMcpSessionStub(params.mcpSessionId);
651+
if (!stub) {
652+
return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
653+
}
648654
const result = yield* Effect.promise(() =>
649655
stub.resumeExecutionForApproval(
650656
params.executionId,

apps/cloud/src/env-augment.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ declare global {
6767
MCP_RESOURCE_ORIGIN?: string;
6868
MCP_SESSION_TIMEOUT_MS?: string;
6969
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
70+
/** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
71+
MCP_REQUEST_STATE_KEY?: string;
72+
/** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
73+
MCP_2026_07_28_ENABLED?: string;
7074
NODE_ENV?: string;
7175

7276
// Shared with frontend

apps/cloud/src/mcp-session.e2e.node.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
77
// here; see db/db.schema.test.ts)
88
// - `createExecutionEngine` with an in-process code executor
9-
// - `createExecutorMcpServer` for the MCP request surface
9+
// - `buildMcpServer` for the MCP request surface
1010
// - Real `@modelcontextprotocol/sdk` Client → server round-trips
1111
//
1212
// This test replicates the DO's init path (minus the WorkerTransport and
@@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
2222
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2323
import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
2424

25-
import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
25+
import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
2626
import { createExecutionEngine } from "@executor-js/execution";
2727
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
2828
import { collectTables } from "@executor-js/api/server";
@@ -138,8 +138,12 @@ const openSession = (
138138
Effect.gen(function* () {
139139
const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140140
const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141-
const mcpServer = yield* createExecutorMcpServer({
141+
const mcpServer = yield* buildMcpServer({
142142
engine,
143+
appsEnabled: false,
144+
requestStateSigningKey: new Uint8Array(32).fill(23),
145+
requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146+
sessionful: true,
143147
elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
144148
});
145149
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

apps/cloud/src/mcp/agent-handler.ts

Lines changed: 78 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,37 @@ import { Effect, Predicate } from "effect";
44
import {
55
McpAuthProvider,
66
jsonRpcErrorBody,
7+
mcpModernDisabledResponse,
78
defaultMcpResource,
89
UNAVAILABLE_RETRY_AFTER_SECONDS,
910
type AuthOutcome,
1011
type McpResource,
1112
} from "@executor-js/host-mcp";
13+
import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
1214
import {
1315
currentPropagationHeaders,
1416
readArtifactsEnabled,
1517
readElicitationMode,
18+
withMcpResponseHeaders,
19+
withPropagationHeaders,
1620
withVerifiedIdentityHeaders,
1721
} from "@executor-js/cloudflare/mcp/do-headers";
1822
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
19-
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
23+
import {
24+
classifyMcpProtocolEra,
25+
makeMcpModernRequestRouter,
26+
mcpCorsPreflightResponse,
27+
requireMcpRequestStateKey,
28+
} from "@executor-js/cloudflare/mcp/modern-request-router";
29+
import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
30+
import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
2031

2132
import { wrapMcpSseResponse } from "../observability/memory-metrics";
2233
import { WorkerTelemetryLive } from "../observability/telemetry";
2334
import { cloudMcpAuth } from "./auth-provider";
24-
import { McpSessionDOSqlite } from "./session-durable-object";
35+
import { makeCloudModernMcpServerBuilder } from "./session-durable-object";
2536
import { parseTraceparent } from "./traceparent";
2637

27-
const corsPreflightResponse = (): Response =>
28-
new Response(null, {
29-
status: 204,
30-
headers: {
31-
"access-control-allow-origin": "*",
32-
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
33-
"access-control-allow-headers":
34-
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
35-
"access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
36-
},
37-
});
38-
3938
const jsonRpcResponse = (
4039
status: number,
4140
code: number,
@@ -86,7 +85,7 @@ const authenticate = (request: Request) =>
8685
return { auth, outcome };
8786
}).pipe(Effect.provide(cloudMcpAuth));
8887

89-
// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
88+
// The earlier shared envelope ran the MCP auth path inside the Effect app, whose
9089
// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
9190
// span (client fingerprint, rpc method, auth outcome) exported from. This
9291
// handler dispatches from the raw worker entry instead, so a bare
@@ -141,29 +140,15 @@ const propsForPrincipal = (
141140
});
142141

143142
export const makeCloudMcpAgentHandler = () => {
144-
const serveOptions = {
145-
binding: "MCP_SESSION",
146-
transport: "streamable-http",
147-
} as const;
148-
// The agents SDK builds an exact-match `URLPattern` from the path handed to
149-
// `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
150-
// a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
151-
// through to its own internal 404. A second `serve` mounted on the
152-
// parameterized path picks it up (`URLPattern` supports `:slug` segments);
153-
// the auth/ownership/props logic above is unchanged and shared, only the
154-
// final dispatch target differs.
155-
const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
156-
const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);
157-
143+
const modern = makeMcpModernRequestRouter();
158144
const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
159145

160146
return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
161-
if (request.method === "OPTIONS") return corsPreflightResponse();
162-
// The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything
163-
// outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK
164-
// handler only understands its own transport verbs and falls through to
165-
// a bare 404. Reject before authenticating so PUT/PATCH/etc never reach
166-
// the session engine.
147+
if (request.method === "OPTIONS") {
148+
return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
149+
}
150+
// Preserve the old envelope's JSON-RPC 405 before authenticating, so
151+
// unsupported methods never reach the session engine.
167152
if (!ALLOWED_METHODS.has(request.method)) {
168153
return jsonRpcResponse(405, -32001, "Method not allowed");
169154
}
@@ -177,17 +162,49 @@ export const makeCloudMcpAgentHandler = () => {
177162
// / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
178163
// the session intact, so the condemn path is gated on `Forbidden` alone.
179164
if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
165+
const session = mcpSessionStub(env.MCP_SESSION, sessionId);
180166
await Effect.runPromise(
181167
Effect.ignore(
182-
Effect.tryPromise(() =>
183-
mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
184-
),
168+
session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,
185169
),
186170
);
187171
}
188172
return renderAuthError(auth, request, outcome);
189173
}
190174

175+
const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
176+
const era = await classifyMcpProtocolEra(request, parsedBody);
177+
if (era === "modern") {
178+
if (env.MCP_2026_07_28_ENABLED === "false") {
179+
return mcpModernDisabledResponse();
180+
}
181+
const resource = resourceFromPath(request);
182+
const props = await runTraced(
183+
request,
184+
propsForPrincipal(request, outcome.principal, resource),
185+
);
186+
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
187+
const forwarded = withVerifiedIdentityHeaders(
188+
request,
189+
{
190+
accountId: outcome.principal.accountId,
191+
organizationId: outcome.principal.organizationId,
192+
},
193+
resource,
194+
);
195+
return modern.fetch({
196+
request: forwarded,
197+
parsedBody,
198+
principal: outcome.principal,
199+
resource,
200+
props,
201+
requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
202+
builder: makeCloudModernMcpServerBuilder(props.session),
203+
sessions: env.MCP_SESSION,
204+
executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
205+
});
206+
}
207+
191208
if (!sessionId && request.method === "DELETE") {
192209
// Matches the old envelope's contract (@modelcontextprotocol/sdk's
193210
// `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
@@ -198,8 +215,12 @@ export const makeCloudMcpAgentHandler = () => {
198215
});
199216
}
200217

201-
if (sessionId) {
202-
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
218+
const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
219+
if (sessionId && !existingSession) {
220+
return jsonRpcResponse(404, -32001, "Session not found");
221+
}
222+
if (existingSession) {
223+
const owner = await existingSession.validateMcpSessionOwner({
203224
accountId: outcome.principal.accountId,
204225
organizationId: outcome.principal.organizationId,
205226
});
@@ -218,27 +239,29 @@ export const makeCloudMcpAgentHandler = () => {
218239
}
219240

220241
const resource = resourceFromPath(request);
221-
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
222-
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
223-
const forwarded = withVerifiedIdentityHeaders(
224-
request,
225-
{
226-
accountId: outcome.principal.accountId,
227-
organizationId: outcome.principal.organizationId,
228-
},
229-
resource,
242+
const propagation = await runTraced(request, currentPropagationHeaders(request));
243+
const forwarded = withPropagationHeaders(
244+
withVerifiedIdentityHeaders(
245+
request,
246+
{
247+
accountId: outcome.principal.accountId,
248+
organizationId: outcome.principal.organizationId,
249+
},
250+
resource,
251+
),
252+
propagation,
230253
);
231-
const target = resource.kind === "toolkit" ? serveToolkit : serve;
254+
const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
232255
let response: Response;
233-
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session
256+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch
234257
try {
235-
response = await target.fetch(forwarded, env, ctx);
258+
response = await target.fetch(forwarded);
236259
} catch (error) {
237260
// `_cf_scheduleDestroy` (called above via DELETE) marks the DO
238-
// condemned and schedules its alarm; the alarm's `destroy()` then
261+
// condemned and schedules its alarm; the alarm's storage wipe then
239262
// `ctx.abort("destroyed")`s the isolate. A request that lands after the
240263
// alarm has already fired — same DO, same tick budget as the DELETE in
241-
// tests — throws that abort reason out of `serve.fetch` instead of the
264+
// tests — throws that abort reason out of `stub.fetch` instead of the
242265
// DO ever getting to answer. Map it to the old envelope's reconnect
243266
// error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the
244267
// client to be told to reconnect, matching a timed-out session).
@@ -249,11 +272,6 @@ export const makeCloudMcpAgentHandler = () => {
249272
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged
250273
throw error;
251274
}
252-
// The agents SDK answers a bare DELETE with 204; the old envelope's
253-
// contract (see above) was 200 — rewrite for consistency.
254-
if (request.method === "DELETE" && response.status === 204) {
255-
return new Response(null, { status: 200, headers: response.headers });
256-
}
257-
return wrapMcpSseResponse(request, env, response);
275+
return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));
258276
};
259277
};

apps/cloud/src/mcp/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth
66
// discovery docs)
77
//
8-
// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so
9-
// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or
10-
// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath`
11-
// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable
12-
// Object class itself stays a platform-side export (server.ts) and imports its
13-
// siblings directly, NOT this barrel, to keep the DO bundle react-start-free.
8+
// `server.ts` intercepts `/mcp` transport for direct session Durable Object
9+
// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no
10+
// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount`
11+
// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP
12+
// session Durable Object class itself stays a platform-side export (server.ts)
13+
// and imports its siblings directly, NOT this barrel, to keep the DO bundle
14+
// react-start-free.
1415
// ---------------------------------------------------------------------------
1516

1617
// `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with

apps/cloud/src/mcp/mount.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// `server.ts`'s request dispatch.
44
// ---------------------------------------------------------------------------
55
//
6-
// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
6+
// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch.
77
// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
88
// (the `auth` seam's discovery routes). This module exposes:
99
// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
@@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => {
129129
return rewritten;
130130
};
131131

132-
// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP
133-
// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make`
134-
// serves the OAuth discovery docs through the `auth` seam's discovery routes.
135-
// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request
136-
// dispatch uses them to recognize and normalize MCP paths.
132+
// Production no longer mounts the /mcp transport here. `server.ts` authenticates
133+
// and forwards transport requests directly to their session Durable Objects,
134+
// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth`
135+
// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because
136+
// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths.

0 commit comments

Comments
 (0)