Skip to content
Closed
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: 0 additions & 1 deletion apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@
"@tanstack/react-router": "catalog:",
"@tanstack/react-start": "catalog:",
"@workos-inc/node": "^8.11.1",
"agents": "^0.10.0",
"autumn-js": "^1.2.8",
"drizzle-orm": "catalog:",
"effect": "catalog:",
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/api/protected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const makeBaseEngine = (): ExecutionEngine =>
result: { result: "ok", logs: [] },
}),
getPausedExecution: () => Effect.succeed(null),
pausedExecutionCount: () => Effect.succeed(0),
hasPausedExecutions: () => Effect.succeed(false),
getDescription: Effect.succeed("desc"),
}) as ExecutionEngine;

Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/engine/execution-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>(
// resume doesn't count as usage
resume: (executionId, response) => engine.resume(executionId, response),
getPausedExecution: (executionId) => engine.getPausedExecution(executionId),
pausedExecutionCount: () => engine.pausedExecutionCount(),
hasPausedExecutions: () => engine.hasPausedExecutions(),
getDescription: engine.getDescription,
});
138 changes: 138 additions & 0 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Effect, Predicate } from "effect";

import { McpAuthProvider, jsonRpcErrorBody, type AuthOutcome } from "@executor-js/host-mcp";
import {
currentPropagationHeaders,
readElicitationMode,
withVerifiedIdentityHeaders,
} from "@executor-js/cloudflare/mcp/do-headers";
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";

import { cloudMcpAuth } from "./auth-provider";
import { McpSessionDO } from "./session-durable-object";

interface McpAgentSessionStub {
readonly validateMcpSessionOwner: (identity: {
readonly accountId: string;
readonly organizationId: string;
}) => Promise<"ok" | "not_found" | "forbidden">;
readonly _cf_scheduleDestroy: () => Promise<void>;
}

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,
message: string,
challenge?: string,
): Response =>
challenge === undefined
? jsonRpcErrorBody(status, code, message)
: jsonRpcErrorBody(status, code, message, { challenge });

const renderAuthError = (
auth: McpAuthProvider["Service"],
request: Request,
outcome: Exclude<AuthOutcome, { readonly _tag: "Authenticated" }>,
): Response => {
if (Predicate.isTagged(outcome, "Unauthorized")) {
return jsonRpcResponse(
401,
-32001,
"Unauthorized",
outcome.challenge ?? `Bearer resource_metadata="${auth.resourceMetadataUrl(request)}"`,
);
}
if (Predicate.isTagged(outcome, "Forbidden")) {
return jsonRpcResponse(403, outcome.code ?? -32001, outcome.message);
}
return jsonRpcResponse(503, -32001, outcome.message);
};

const sessionStub = (env: Env, sessionId: string): McpAgentSessionStub =>
// oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class.
env.MCP_SESSION.get(
env.MCP_SESSION.idFromName(`streamable-http:${sessionId}`),
) as unknown as McpAgentSessionStub;

const authenticate = (request: Request) =>
Effect.gen(function* () {
const auth = yield* McpAuthProvider;
const outcome = yield* auth.authenticate(request);
return { auth, outcome };
}).pipe(Effect.provide(cloudMcpAuth));

const propsForPrincipal = (
request: Request,
principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],
): Effect.Effect<McpSessionProps> =>
Effect.gen(function* () {
const propagation = yield* currentPropagationHeaders(request);
return {
session: {
organizationId: principal.organizationId,
userId: principal.accountId,
elicitationMode: readElicitationMode(request),
webOrigin: new URL(request.url).origin,
},
propagation,
};
});

export const makeCloudMcpAgentHandler = () => {
const serve = McpSessionDO.serve("/mcp", {
binding: "MCP_SESSION",
transport: "streamable-http",
});

return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
if (request.method === "OPTIONS") return corsPreflightResponse();
const sessionId = request.headers.get("mcp-session-id");

const { auth, outcome } = await Effect.runPromise(authenticate(request));
if (!Predicate.isTagged(outcome, "Authenticated")) {
if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
await Effect.runPromise(
Effect.ignore(Effect.tryPromise(() => sessionStub(env, sessionId)._cf_scheduleDestroy())),
);
}
return renderAuthError(auth, request, outcome);
}

if (!sessionId && request.method === "DELETE") {
return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
}

if (sessionId) {
const owner = await sessionStub(env, sessionId).validateMcpSessionOwner({
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
if (owner === "not_found") {
return jsonRpcResponse(404, -32001, "Session not found");
}
if (owner === "forbidden") {
return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
}
}

const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withVerifiedIdentityHeaders(request, {
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
return serve.fetch(forwarded, env, ctx);
};
};
21 changes: 10 additions & 11 deletions apps/cloud/src/mcp/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
// host-mcp serving envelope (@executor-js/host-mcp) behind cloud's two seams.
// ---------------------------------------------------------------------------
//
// PRODUCTION serves /mcp through `app.ts`'s unified `ExecutorApp.make` handler
// (the same `McpServingRoutes` envelope provided `cloudMcpAuth` +
// `cloudMcpSessions`), dispatched by start.ts alongside /api. This module is the
// TEST-WORKER counterpart: it exposes the two pieces `test-worker.ts` needs to
// build the identical envelope with swapped auth seams —
// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
// Discovery docs still flow through `app.ts`'s unified `ExecutorApp.make`
// handler, dispatched by start.ts alongside /api. This module is the TEST-WORKER
// counterpart: it exposes the two pieces `test-worker.ts` needs to build the
// legacy envelope with swapped auth seams —
// - `makeMcpWebHandler` — bind `McpServingRoutes` to a web handler over a
// given auth provider + seam requirements + telemetry runtime, mirroring the
// self-host mount (`HttpRouter.toWebHandler`).
Expand Down Expand Up @@ -172,9 +172,8 @@ export const makeMcpWebHandler = <SeamsError = never>(options: {
).handler;
};

// Production no longer mounts /mcp here — `app.ts`'s unified `ExecutorApp.make`
// handler serves it (the same `McpServingRoutes` envelope + cloud seams as
// `cloudMcpAuth`/`cloudMcpSessions`), dispatched by start.ts alongside /api.
// `classifyMcpPath` + `makeMcpWebHandler` remain because the workerd/miniflare
// test worker (`test-worker.ts`) builds the same envelope with swapped auth
// seams and classifies MCP paths with the identical predicate.
// Production no longer mounts /mcp here. `server.ts` intercepts MCP transport
// requests for the hibernatable Agent bridge, while `ExecutorApp.make` continues
// serving discovery docs. `classifyMcpPath` + `makeMcpWebHandler` remain because
// the workerd/miniflare test worker (`test-worker.ts`) builds the same envelope
// with swapped auth seams and classifies MCP paths with the identical predicate.
15 changes: 8 additions & 7 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// ---------------------------------------------------------------------------
// Cloud MCP Session Durable Object — the cloud binding of the shared
// `McpSessionDOBase` (@executor-js/cloudflare). All session lifecycle (cold
// restore, the inactivity alarm, owner validation, transport upgrade, the
// browser-approval store, the per-request span bridge) lives in the base; cloud
// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport
// serving, cold restore, the inactivity alarm, owner validation, browser
// approval storage, and the per-request span bridge live in the base. Cloud
// supplies ONLY its injected dependencies:
// - openSessionDb → a long-lived postgres.js handle
// - resolveSessionMeta → WorkOS/UserStore organization resolution
Expand All @@ -24,12 +24,12 @@ import postgres, { type Sql } from "postgres";
import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import {
McpSessionDOBase,
McpAgentSessionDOBase,
type BuiltMcpServer,
type IncomingTraceHeaders,
type McpSessionInit,
type SessionMeta,
} from "@executor-js/cloudflare/mcp/durable-object";
} from "@executor-js/cloudflare/mcp/agent-durable-object";
import { buildExecuteDescription } from "@executor-js/execution";

// The DO only needs the neutral boot-scoped service (WorkOSClient). It never
Expand Down Expand Up @@ -62,7 +62,7 @@ export type {
McpSessionResumeApprovalResult,
McpSessionInit,
IncomingTraceHeaders,
} from "@executor-js/cloudflare/mcp/durable-object";
} from "@executor-js/cloudflare/mcp/agent-durable-object";

// ---------------------------------------------------------------------------
// Cloud DB handle — one postgres.js client per session runtime
Expand Down Expand Up @@ -152,7 +152,7 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => {
// Durable Object
// ---------------------------------------------------------------------------

export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
export class McpSessionDO extends McpAgentSessionDOBase<Env, CloudSessionDbHandle> {
protected override openSessionDb(): CloudSessionDbHandle {
return makeDbHandle({
idleTimeout: LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -208,6 +208,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
parentSpan: () => self.currentParentSpan(),
debug: env.EXECUTOR_MCP_DEBUG === "true",
browserApprovalStore: self.browserApprovalStore,
pausedExecutionHooks: self.pausedExecutionHooks,
elicitationMode:
sessionElicitationMode === "browser"
? {
Expand Down
15 changes: 15 additions & 0 deletions apps/cloud/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import * as Sentry from "@sentry/cloudflare";
import handler from "@tanstack/react-start/server-entry";

import { isAppOwnedPath } from "./app-paths";
import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object";
import { browserTracesResponse } from "./observability/browser-traces";
import { flushTracerProvider, installTracerProvider } from "./observability/telemetry";
Expand Down Expand Up @@ -77,6 +79,7 @@ const fetchHandler = handler.fetch as (
) => Response | Promise<Response>;

const tracer = trace.getTracer("executor-cloud-worker");
const mcpAgentHandler = makeCloudMcpAgentHandler();

const cloudflareHandler: ExportedHandler<Env> = {
fetch: async (request, env, ctx) => {
Expand All @@ -89,6 +92,18 @@ const cloudflareHandler: ExportedHandler<Env> = {
return fetchHandler(request, env, ctx);
}
const url = new URL(request.url);
const mcpRoute = classifyMcpPath(url.pathname);
if (mcpRoute?.kind === "mcp") {
// The Cloudflare Agents MCP bridge needs the platform ExecutionContext
// to pass authenticated session props into the hibernatable DO.
// Discovery docs still flow through the app-level MCP envelope.
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; keep trace export alive after the Agents bridge resolves or rejects
try {
return await mcpAgentHandler(prepareMcpOrgScope(request), env, ctx);
} finally {
ctx.waitUntil(flushTracerProvider());
}
}
// Effect-served paths bring their own http.server span (with traceparent
// join) — opening one here too would duplicate it. See the header note.
if (isAppOwnedPath(url.pathname)) {
Expand Down
27 changes: 12 additions & 15 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ import {
} from "./execution";
import { ErrorCaptureLive } from "./observability";
import { cloudflareAccountMiddleware } from "./account/account-provider";
import { makeCloudflareMcpSeams } from "./mcp";
import { makeCloudflareApprovalHandler } from "./mcp";
import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";
import { preloadQuickJs } from "./quickjs";

// ===========================================================================
// The Cloudflare host, as ONE `ExecutorApp.make` call — the 4th app alongside
// The Cloudflare host, as ONE `ExecutorApp.make` call: the 4th app alongside
// cloud / self-host / local, differing only by the injected Layers.
//
// The whole scenario in 60 seconds: Cloudflare Access is the identity (validate
// the Cf-Access-Jwt-Assertion JWT — no Better Auth, no WorkOS, no app login),
// the Cf-Access-Jwt-Assertion JWT, no Better Auth, no WorkOS, no app login),
// D1 is the SQLite store (same FumaDB assembly as self-host), QuickJS is the
// in-process code substrate, no billing, single-tenant. `diff` against
// host-selfhost/src/app.ts is three injected Layers: identity, db, plugins/config.
Expand All @@ -37,23 +38,22 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
const plugins = makeCloudflarePlugins(config.secretKey);

// Load the Workers-compatible (WASM-inlined) QuickJS variant before any
// executor is built — the default variant can't fetch its .wasm on Workers.
// executor is built, the default variant cannot fetch its .wasm on Workers.
await preloadQuickJs();

// Open + idempotently bring up the D1 schema once (the long-lived handle the
// per-request scoped executor reads through the DbProvider seam).
// Open and idempotently bring up the D1 schema once. This is the long-lived
// handle the per-request scoped executor reads through the DbProvider seam.
const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
const identityLayer = cloudflareAccessIdentityLayer(config);
// MCP runs through the `MCP_SESSION` Durable Object (cross-isolate sessions);
// each session DO opens its own D1 handle, so it takes `env`, not `dbHandle`.
const mcp = makeCloudflareMcpSeams(config, env);
const mcpAgentHandler = makeCloudflareMcpAgentHandler(config);
const approvalHandler = makeCloudflareApprovalHandler(config, env);

const { appLayer, toWebHandler } = ExecutorApp.make({
plugins,
providers: {
identity: identityLayer,
db: dbProviderLayer(Effect.succeed(dbHandle)),
engine: { codeExecutor: CloudflareCodeExecutorProvider }, // decorator defaults to no-op
engine: { codeExecutor: CloudflareCodeExecutorProvider },
plugins: {
provider: makeCloudflarePluginsProvider(config),
config: makeCloudflareHostConfig(config),
Expand All @@ -63,21 +63,18 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
// auth context; `me` reflects the Access principal. Members/keys are
// Access-managed, so the rest of the surface is stubbed.
account: cloudflareAccountMiddleware(config),
// The MCP serving envelope: Access-JWT auth + the shared in-process session
// store over the QuickJS engine.
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
},
extensions: {
routes: [
// Browser approval of paused MCP executions: the console resume page
// reads paused detail (GET) and records the decision (POST .../resume),
// Access-gated, routed to the owning session's Durable Object.
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(approvalHandler)),
],
},
config: { mountPrefix: "/api", failure: textFailureStrategy },
boot: identityLayer,
});

return { appLayer, toWebHandler };
return { appLayer, toWebHandler, mcpAgentHandler };
};
Loading
Loading