import { Data, Effect } from "effect"; import { PAUSED_APPROVAL_TIMEOUT_MS, createExecutorMcpServer,} from "@executor-js/host-mcp/tool-server";import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";import type { ExecutorDbHandle } from "@executor-js/api/server";import { McpAgentSessionDOBase, type BuiltMcpServer, type McpApprovalOwner, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta,} from "@executor-js/cloudflare/mcp/agent-durable-object";import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute,} from "@executor-js/cloudflare/mcp/execution-owner-directory";import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";import type { ResumeResponse } from "@executor-js/execution"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";import { createD1ExecutorDb } from "../db/d1";import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution";import { preloadQuickJs } from "../quickjs"; // ---------------------------------------------------------------------------// Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare// binding of the shared `McpAgentSessionDOBase` (@executor-js/cloudflare). Identical// base to cloud; the ONLY differences are the injected dependencies:// - openSessionDb → a long-lived D1 `ExecutorDbHandle` (same FumaDB// assembly the HTTP path uses), adapted to the base's// `end` disposal contract.// - resolveSessionMeta → single-tenant: the org is fixed in config, so no// lookup — just stamp the configured org name.// - buildMcpServer → the QuickJS execution stack + the MCP tool server.// host-cf has no OTel/Sentry, so it keeps the base's default no-op telemetry +// error seams. Replacing the prior in-memory store with this DO is what fixes// `tools/list` failing across Worker isolates (a session created on one isolate// was invisible to the next; the DO id == session id routes them all back).// --------------------------------------------------------------------------- // The long-lived D1 handle, adapted to the base's `end` contract. D1 owns its// own lifecycle (the binding is the connection), so `end` is `close` — a no-op.type CfSessionDbHandle = ExecutorDbHandle & { readonly end: () => Promise<void> }; class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ readonly cause: unknown;}> {} export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { private readonly cfEnv: CloudflareEnv; private readonly cfConfig: CloudflareConfig; /** * The `ui://executor/shell.html` document, over the ASSETS binding: a * deployed Worker has no filesystem, so the shell is the stable-named asset * the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode * escape hatch here, unlike cloud: this Worker is bundled by wrangler, not * Vite, and `wrangler dev` serves the same built `./dist` the binding reads. */ private readonly loadAppShellHtml: () => Promise<string>; constructor( ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0], env: CloudflareEnv, ) { super(ctx, env); this.cfEnv = env; this.cfConfig = loadConfig(env); this.loadAppShellHtml = makeAssetsShellHtmlLoader({ assets: env.ASSETS }); } protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null { return mcpExecutionOwnerDirectoryFromNamespace(this.cfEnv.MCP_EXECUTION_OWNER); } protected override supportsCapEviction(): boolean { return true; } protected override requestSelfEviction(): Promise<void> { // Routed through this session's OWN stub (never a direct in-process call) // so `requestCapEviction`'s teardown runs under an IoContext scoped to // this request, not whatever request happened to trigger the eviction // check — see the base class's `requestSelfEviction` doc comment. return mcpSessionStub(this.cfEnv.MCP_SESSION, this.sessionId).requestCapEviction(); } protected override forwardModelResumeToOwner( owner: McpExecutionOwnerRoute, identity: McpApprovalOwner, executionId: string, response: ResumeResponse, ): Effect.Effect<McpSessionModelResumeResult, unknown> { return Effect.tryPromise({ try: () => mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( executionId, identity, response, ), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } protected override async openSessionDb(): Promise<CfSessionDbHandle> { const handle = await createD1ExecutorDb(this.cfEnv.DB, this.cfEnv.BLOBS); return { ...handle, end: () => handle.close() }; } protected override resolveSessionMeta( token: McpSessionInit, _storedMeta: SessionMeta | null, ): Effect.Effect<SessionMeta> { // Single-tenant: every Access principal belongs to the one configured org, // so there is nothing to resolve — stamp the configured org name. Nothing // to reuse from the stored meta either; config is already the cheapest and // freshest source there is. return Effect.succeed({ organizationId: token.organizationId, organizationName: this.cfConfig.organizationName, organizationSlug: this.cfConfig.organizationSlug, userId: token.userId, resource: token.resource, elicitationMode: token.elicitationMode, artifactsEnabled: token.artifactsEnabled, searchToolsEnabled: token.searchToolsEnabled, } satisfies SessionMeta); } protected override buildMcpServer( sessionMeta: SessionMeta, dbHandle: CfSessionDbHandle, ): Effect.Effect<BuiltMcpServer> { const config = this.cfConfig; const self = this; return Effect.gen(function* () { // QuickJS-WASM must be loaded before the executor layer builds it (the // default variant can't fetch its .wasm on Workers). Idempotent per isolate. yield* Effect.promise(() => preloadQuickJs()); const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, { mcpResource: sessionMeta.resource }, ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); // Browser elicitation mode (the base owns the approval store + the HTTP // approval RPCs): a gated execution pauses and returns an approvalUrl into // the console resume page. The URL origin is the create request's origin // (captured by the base), falling back to the configured site URL. const elicitationMode = sessionMeta.elicitationMode ?? "model"; // Same origin story as the approval URL below: an artifact deep link is // only offered when this deployment knows its own public URL. Without one // the artifact is still saved, and the tool says so. const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; const mcpServer = yield* createExecutorMcpServer({ engine, artifacts: executor.artifacts, connections: executor.connections, // Artifacts are on by default, opt-out per connection. A session // persisted without a value restores to the default, same as a fresh // connection whose URL says nothing about `?artifacts=`. artifactsEnabled: sessionMeta.artifactsEnabled ?? true, // Per-integration search tools are off by default, opt-in per // connection (`?search_tools=true`). Same restore rule as artifacts. searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false, // Cold restores rebuild this server with no `initialize` to replay, so // the negotiated apps support comes back from storage instead. restoredAppsEnabled: sessionMeta.appsEnabled ?? false, onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), loadAppShellHtml: self.loadAppShellHtml, smokeRenderArtifact, ...(artifactOrigin ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } : {}), browserApprovalStore: self.browserApprovalStore, pausedExecutionHooks: self.pausedExecutionHooks, pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, resumeFallback: self.modelResumeFallback, elicitationMode: elicitationMode === "browser" ? { mode: "browser" as const, approvalUrl: (executionId) => { // webOrigin is captured per-request at session create; for a // legacy session cold-restored without it, fall back to the // pinned site URL. If neither exists, the link would be // unreachable — fail VISIBLY (a logged, obviously-invalid host) // instead of silently pointing the human at http://localhost. const origin = sessionMeta.webOrigin ?? config.webBaseUrl; if (!origin) { console.error( "[executor-cloudflare] cannot build MCP approval URL: no session web origin and VITE_PUBLIC_SITE_URL is unset. Set VITE_PUBLIC_SITE_URL so approval links are reachable.", ); } return buildResumeApprovalUrl({ origin: origin ?? "https://unconfigured-origin.invalid", executionId, sessionId: self.sessionId, organizationSlug: sessionMeta.organizationSlug, }); }, } : { mode: elicitationMode }, }); return { mcpServer, engine } satisfies BuiltMcpServer; }).pipe( Effect.withSpan("McpSessionDO.buildMcpServer"), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect Effect.orDie, ); }}
import { Data, Effect } from "effect";
import {
PAUSED_APPROVAL_TIMEOUT_MS,
createExecutorMcpServer,
} from "@executor-js/host-mcp/tool-server";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
import type { ExecutorDbHandle } from "@executor-js/api/server";
import {
McpAgentSessionDOBase,
type BuiltMcpServer,
type McpApprovalOwner,
type McpSessionModelResumeResult,
type McpSessionInit,
type SessionMeta,
} from "@executor-js/cloudflare/mcp/agent-durable-object";
import {
mcpExecutionOwnerDirectoryFromNamespace,
type McpExecutionOwnerDirectory,
type McpExecutionOwnerRoute,
} from "@executor-js/cloudflare/mcp/execution-owner-directory";
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
import type { ResumeResponse } from "@executor-js/execution";
import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
import { createD1ExecutorDb } from "../db/d1";
import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution";
import { preloadQuickJs } from "../quickjs";
// ---------------------------------------------------------------------------
// Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare
// binding of the shared `McpAgentSessionDOBase` (@executor-js/cloudflare). Identical
// base to cloud; the ONLY differences are the injected dependencies:
// - openSessionDb → a long-lived D1 `ExecutorDbHandle` (same FumaDB
// assembly the HTTP path uses), adapted to the base's
// `end` disposal contract.
// - resolveSessionMeta → single-tenant: the org is fixed in config, so no
// lookup — just stamp the configured org name.
// - buildMcpServer → the QuickJS execution stack + the MCP tool server.
// host-cf has no OTel/Sentry, so it keeps the base's default no-op telemetry +
// error seams. Replacing the prior in-memory store with this DO is what fixes
// `tools/list` failing across Worker isolates (a session created on one isolate
// was invisible to the next; the DO id == session id routes them all back).
// ---------------------------------------------------------------------------
// The long-lived D1 handle, adapted to the base's `end` contract. D1 owns its
// own lifecycle (the binding is the connection), so `end` is `close` — a no-op.
type CfSessionDbHandle = ExecutorDbHandle & { readonly end: () => Promise<void> };
class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{
readonly cause: unknown;
}> {}
export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
private readonly cfEnv: CloudflareEnv;
private readonly cfConfig: CloudflareConfig;
/**
* The `ui://executor/shell.html` document, over the ASSETS binding: a
* deployed Worker has no filesystem, so the shell is the stable-named asset
* the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode
* escape hatch here, unlike cloud: this Worker is bundled by wrangler, not
* Vite, and `wrangler dev` serves the same built `./dist` the binding reads.
*/
private readonly loadAppShellHtml: () => Promise<string>;
constructor(
ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0],
env: CloudflareEnv,
) {
super(ctx, env);
this.cfEnv = env;
this.cfConfig = loadConfig(env);
this.loadAppShellHtml = makeAssetsShellHtmlLoader({ assets: env.ASSETS });
}
protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null {
return mcpExecutionOwnerDirectoryFromNamespace(this.cfEnv.MCP_EXECUTION_OWNER);
}
protected override supportsCapEviction(): boolean {
return true;
}
protected override requestSelfEviction(): Promise<void> {
// Routed through this session's OWN stub (never a direct in-process call)
// so `requestCapEviction`'s teardown runs under an IoContext scoped to
// this request, not whatever request happened to trigger the eviction
// check — see the base class's `requestSelfEviction` doc comment.
return mcpSessionStub(this.cfEnv.MCP_SESSION, this.sessionId).requestCapEviction();
}
protected override forwardModelResumeToOwner(
owner: McpExecutionOwnerRoute,
identity: McpApprovalOwner,
executionId: string,
response: ResumeResponse,
): Effect.Effect<McpSessionModelResumeResult, unknown> {
return Effect.tryPromise({
try: () =>
mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
executionId,
identity,
response,
),
catch: (cause) => new McpModelResumeForwardError({ cause }),
});
}
protected override async openSessionDb(): Promise<CfSessionDbHandle> {
const handle = await createD1ExecutorDb(this.cfEnv.DB, this.cfEnv.BLOBS);
return { ...handle, end: () => handle.close() };
}
protected override resolveSessionMeta(
token: McpSessionInit,
_storedMeta: SessionMeta | null,
): Effect.Effect<SessionMeta> {
// Single-tenant: every Access principal belongs to the one configured org,
// so there is nothing to resolve — stamp the configured org name. Nothing
// to reuse from the stored meta either; config is already the cheapest and
// freshest source there is.
return Effect.succeed({
organizationId: token.organizationId,
organizationName: this.cfConfig.organizationName,
organizationSlug: this.cfConfig.organizationSlug,
userId: token.userId,
resource: token.resource,
elicitationMode: token.elicitationMode,
artifactsEnabled: token.artifactsEnabled,
searchToolsEnabled: token.searchToolsEnabled,
} satisfies SessionMeta);
}
protected override buildMcpServer(
sessionMeta: SessionMeta,
dbHandle: CfSessionDbHandle,
): Effect.Effect<BuiltMcpServer> {
const config = this.cfConfig;
const self = this;
return Effect.gen(function* () {
// QuickJS-WASM must be loaded before the executor layer builds it (the
// default variant can't fetch its .wasm on Workers). Idempotent per isolate.
yield* Effect.promise(() => preloadQuickJs());
const { engine, executor } = yield* makeExecutionStack(
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
{ mcpResource: sessionMeta.resource },
).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
// Browser elicitation mode (the base owns the approval store + the HTTP
// approval RPCs): a gated execution pauses and returns an approvalUrl into
// the console resume page. The URL origin is the create request's origin
// (captured by the base), falling back to the configured site URL.
const elicitationMode = sessionMeta.elicitationMode ?? "model";
// Same origin story as the approval URL below: an artifact deep link is
// only offered when this deployment knows its own public URL. Without one
// the artifact is still saved, and the tool says so.
const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
const mcpServer = yield* createExecutorMcpServer({
engine,
artifacts: executor.artifacts,
connections: executor.connections,
// Artifacts are on by default, opt-out per connection. A session
// persisted without a value restores to the default, same as a fresh
// connection whose URL says nothing about `?artifacts=`.
artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
// Per-integration search tools are off by default, opt-in per
// connection (`?search_tools=true`). Same restore rule as artifacts.
searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false,
// Cold restores rebuild this server with no `initialize` to replay, so
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
loadAppShellHtml: self.loadAppShellHtml,
smokeRenderArtifact,
...(artifactOrigin
? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }
: {}),
browserApprovalStore: self.browserApprovalStore,
pausedExecutionHooks: self.pausedExecutionHooks,
pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
resumeFallback: self.modelResumeFallback,
elicitationMode:
elicitationMode === "browser"
? {
mode: "browser" as const,
approvalUrl: (executionId) => {
// webOrigin is captured per-request at session create; for a
// legacy session cold-restored without it, fall back to the
// pinned site URL. If neither exists, the link would be
// unreachable — fail VISIBLY (a logged, obviously-invalid host)
// instead of silently pointing the human at http://localhost.
const origin = sessionMeta.webOrigin ?? config.webBaseUrl;
if (!origin) {
console.error(
"[executor-cloudflare] cannot build MCP approval URL: no session web origin and VITE_PUBLIC_SITE_URL is unset. Set VITE_PUBLIC_SITE_URL so approval links are reachable.",
);
}
return buildResumeApprovalUrl({
origin: origin ?? "https://unconfigured-origin.invalid",
executionId,
sessionId: self.sessionId,
organizationSlug: sessionMeta.organizationSlug,
});
},
}
: { mode: elicitationMode },
});
return { mcpServer, engine } satisfies BuiltMcpServer;
}).pipe(
Effect.withSpan("McpSessionDO.buildMcpServer"),
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect
Effect.orDie,
);
}
}