Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sentry/bun": "^10.57.0",
"effect": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Cause from "effect/Cause";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";

Expand Down
6 changes: 6 additions & 0 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
Effect.gen(function* () {
const owner = yield* requireSelectedOrganization;
const stub = getMcpSessionStub(params.mcpSessionId);
if (!stub) {
return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
}
const result = yield* Effect.promise(() =>
stub.getPausedExecutionForApproval(params.executionId, {
accountId: owner.accountId,
Expand All @@ -645,6 +648,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
Effect.gen(function* () {
const owner = yield* requireSelectedOrganization;
const stub = getMcpSessionStub(params.mcpSessionId);
if (!stub) {
return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
}
const result = yield* Effect.promise(() =>
stub.resumeExecutionForApproval(
params.executionId,
Expand Down
4 changes: 4 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ declare global {
MCP_RESOURCE_ORIGIN?: string;
MCP_SESSION_TIMEOUT_MS?: string;
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
/** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
MCP_REQUEST_STATE_KEY?: string;
/** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
MCP_2026_07_28_ENABLED?: string;
NODE_ENV?: string;

// Shared with frontend
Expand Down
10 changes: 7 additions & 3 deletions apps/cloud/src/mcp-session.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
// here; see db/db.schema.test.ts)
// - `createExecutionEngine` with an in-process code executor
// - `createExecutorMcpServer` for the MCP request surface
// - `buildMcpServer` for the MCP request surface
// - Real `@modelcontextprotocol/sdk` Client → server round-trips
//
// This test replicates the DO's init path (minus the WorkerTransport and
Expand All @@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";

import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
import { createExecutionEngine } from "@executor-js/execution";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import { collectTables } from "@executor-js/api/server";
Expand Down Expand Up @@ -138,8 +138,12 @@ const openSession = (
Effect.gen(function* () {
const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
const mcpServer = yield* createExecutorMcpServer({
const mcpServer = yield* buildMcpServer({
engine,
appsEnabled: false,
requestStateSigningKey: new Uint8Array(32).fill(23),
requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
sessionful: true,
elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
Expand Down
138 changes: 78 additions & 60 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,37 @@ import { Effect, Predicate } from "effect";
import {
McpAuthProvider,
jsonRpcErrorBody,
mcpModernDisabledResponse,
defaultMcpResource,
UNAVAILABLE_RETRY_AFTER_SECONDS,
type AuthOutcome,
type McpResource,
} from "@executor-js/host-mcp";
import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
import {
currentPropagationHeaders,
readArtifactsEnabled,
readElicitationMode,
withMcpResponseHeaders,
withPropagationHeaders,
withVerifiedIdentityHeaders,
} from "@executor-js/cloudflare/mcp/do-headers";
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
import {
classifyMcpProtocolEra,
makeMcpModernRequestRouter,
mcpCorsPreflightResponse,
requireMcpRequestStateKey,
} from "@executor-js/cloudflare/mcp/modern-request-router";
import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";

import { wrapMcpSseResponse } from "../observability/memory-metrics";
import { WorkerTelemetryLive } from "../observability/telemetry";
import { cloudMcpAuth } from "./auth-provider";
import { McpSessionDOSqlite } from "./session-durable-object";
import { makeCloudModernMcpServerBuilder } from "./session-durable-object";
import { parseTraceparent } from "./traceparent";

const corsPreflightResponse = (): Response =>
new Response(null, {
status: 204,
headers: {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
"access-control-allow-headers":
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
"access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
},
});

const jsonRpcResponse = (
status: number,
code: number,
Expand Down Expand Up @@ -86,7 +85,7 @@ const authenticate = (request: Request) =>
return { auth, outcome };
}).pipe(Effect.provide(cloudMcpAuth));

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

export const makeCloudMcpAgentHandler = () => {
const serveOptions = {
binding: "MCP_SESSION",
transport: "streamable-http",
} as const;
// The agents SDK builds an exact-match `URLPattern` from the path handed to
// `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
// a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
// through to its own internal 404. A second `serve` mounted on the
// parameterized path picks it up (`URLPattern` supports `:slug` segments);
// the auth/ownership/props logic above is unchanged and shared, only the
// final dispatch target differs.
const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);

const modern = makeMcpModernRequestRouter();
const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);

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

const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
const era = await classifyMcpProtocolEra(request, parsedBody);
if (era === "modern") {
if (env.MCP_2026_07_28_ENABLED === "false") {
return mcpModernDisabledResponse();
}
const resource = resourceFromPath(request);
const props = await runTraced(
request,
propsForPrincipal(request, outcome.principal, resource),
);
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withVerifiedIdentityHeaders(
request,
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
);
return modern.fetch({
request: forwarded,
parsedBody,
principal: outcome.principal,
resource,
props,
requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
builder: makeCloudModernMcpServerBuilder(props.session),
sessions: env.MCP_SESSION,
executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
});
}

if (!sessionId && request.method === "DELETE") {
// Matches the old envelope's contract (@modelcontextprotocol/sdk's
// `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
Expand All @@ -198,8 +215,12 @@ export const makeCloudMcpAgentHandler = () => {
});
}

if (sessionId) {
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
if (sessionId && !existingSession) {
return jsonRpcResponse(404, -32001, "Session not found");
}
if (existingSession) {
const owner = await existingSession.validateMcpSessionOwner({
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
Expand All @@ -218,27 +239,29 @@ export const makeCloudMcpAgentHandler = () => {
}

const resource = resourceFromPath(request);
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withVerifiedIdentityHeaders(
request,
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
const propagation = await runTraced(request, currentPropagationHeaders(request));
const forwarded = withPropagationHeaders(
withVerifiedIdentityHeaders(
request,
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
),
propagation,
);
const target = resource.kind === "toolkit" ? serveToolkit : serve;
const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
let response: Response;
// 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
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch
try {
response = await target.fetch(forwarded, env, ctx);
response = await target.fetch(forwarded);
} catch (error) {
// `_cf_scheduleDestroy` (called above via DELETE) marks the DO
// condemned and schedules its alarm; the alarm's `destroy()` then
// condemned and schedules its alarm; the alarm's storage wipe then
// `ctx.abort("destroyed")`s the isolate. A request that lands after the
// alarm has already fired — same DO, same tick budget as the DELETE in
// tests — throws that abort reason out of `serve.fetch` instead of the
// tests — throws that abort reason out of `stub.fetch` instead of the
// DO ever getting to answer. Map it to the old envelope's reconnect
// error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the
// client to be told to reconnect, matching a timed-out session).
Expand All @@ -249,11 +272,6 @@ export const makeCloudMcpAgentHandler = () => {
// 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
throw error;
}
// The agents SDK answers a bare DELETE with 204; the old envelope's
// contract (see above) was 200 — rewrite for consistency.
if (request.method === "DELETE" && response.status === 204) {
return new Response(null, { status: 200, headers: response.headers });
}
return wrapMcpSseResponse(request, env, response);
return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));
};
};
13 changes: 7 additions & 6 deletions apps/cloud/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth
// discovery docs)
//
// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so
// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or
// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath`
// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable
// Object class itself stays a platform-side export (server.ts) and imports its
// siblings directly, NOT this barrel, to keep the DO bundle react-start-free.
// `server.ts` intercepts `/mcp` transport for direct session Durable Object
// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no
// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount`
// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP
// session Durable Object class itself stays a platform-side export (server.ts)
// and imports its siblings directly, NOT this barrel, to keep the DO bundle
// react-start-free.
// ---------------------------------------------------------------------------

// `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with
Expand Down
12 changes: 6 additions & 6 deletions apps/cloud/src/mcp/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// `server.ts`'s request dispatch.
// ---------------------------------------------------------------------------
//
// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch.
// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
// (the `auth` seam's discovery routes). This module exposes:
// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
Expand Down Expand Up @@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => {
return rewritten;
};

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