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
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
79 changes: 32 additions & 47 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ 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";
Expand All @@ -24,12 +26,12 @@ import {
requireMcpRequestStateKey,
} from "@executor-js/cloudflare/mcp/modern-request-router";
import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
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, makeCloudModernMcpServerBuilder } from "./session-durable-object";
import { makeCloudModernMcpServerBuilder } from "./session-durable-object";
import { parseTraceparent } from "./traceparent";

const jsonRpcResponse = (
Expand Down Expand Up @@ -82,7 +84,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 @@ -138,31 +140,14 @@ const propsForPrincipal = (

export const makeCloudMcpAgentHandler = () => {
const modern = makeMcpModernRequestRouter();
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 ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);

return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
if (request.method === "OPTIONS") {
return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
}
// 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.
// 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 @@ -176,11 +161,10 @@ 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,
),
);
}
Expand Down Expand Up @@ -227,8 +211,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 @@ -247,27 +235,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 @@ -278,11 +268,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.
27 changes: 16 additions & 11 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// ---------------------------------------------------------------------------
// Cloud MCP Session Durable Object — the cloud binding of the shared
// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport
// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP 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:
Expand All @@ -23,11 +23,10 @@ import postgres, { type Sql } from "postgres";

import {
PAUSED_APPROVAL_TIMEOUT_MS,
createExecutorMcpServer,
type PausedExecutionHooks,
type ResumeFallbackOutcome,
} from "@executor-js/host-mcp/tool-server";
import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
Expand Down Expand Up @@ -325,13 +324,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
executionId: string,
response: ResumeResponse,
): Effect.Effect<McpSessionModelResumeResult, unknown> {
const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner);
if (!ownerSession) {
return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
}
return Effect.tryPromise({
try: () =>
mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel(
executionId,
identity,
response,
),
try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),
catch: (cause) => new McpModelResumeForwardError({ cause }),
});
}
Expand Down Expand Up @@ -367,7 +365,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
parentSpan: () => self.currentParentSpan(),
});
const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
const mcpServer = yield* createExecutorMcpServer({
const mcpServer = yield* buildMcpServerV2({
engine,
description,
artifacts: executor.artifacts,
Expand All @@ -380,6 +378,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
appsEnabled: false,
sessionful: true,
requestStateSigningKey: self.modernRequestStateSigningKey(),
requestStatePrincipal: mcpRequestStatePrincipal({
accountId: sessionMeta.userId,
organizationId: sessionMeta.organizationId,
}),
loadAppShellHtml,
smokeRenderArtifact,
artifactUrl: artifactUrlFor(
Expand All @@ -405,7 +410,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
}),
}
: { mode: sessionElicitationMode },
}).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));
}).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServerV2"));
return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
}).pipe(
Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
Expand Down
6 changes: 3 additions & 3 deletions apps/cloud/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@
},
],
},
// The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which
// stores state in SQLite. The original `McpSessionDO` was created on the
// The MCP session DO previously moved to a SQLite-backed class. The original
// `McpSessionDO` was created on the
// key-value backend (`new_classes`) and cannot be converted in place. Cloudflare
// also refuses to delete a class in the same deploy that moves its binding (it
// validates the delete against the live binding), so v2 only CREATES the new
// SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moves to it.
// SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moved to it.
// The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in
// server.ts so the migration stays valid); it can be deleted in a later deploy
// now that nothing binds it. Session state is ephemeral, so nothing is lost.
Expand Down
Loading
Loading