import { Data, Effect } from "effect"; import { PAUSED_APPROVAL_TIMEOUT_MS, buildMcpServer, mcpRequestStatePrincipal, type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; 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"; 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 BuiltModernMcpRuntime, type McpApprovalOwner, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; import { buildExecuteDescription, 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 }; class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ readonly cause: unknown; }> {} class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{ readonly cause: unknown; }> {} const makeCloudflareExecutionRuntime = ( sessionMeta: SessionMeta, dbHandle: CfSessionDbHandle, config: CloudflareConfig, ) => Effect.gen(function* () { yield* Effect.promise(() => preloadQuickJs()); const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, { mcpResource: sessionMeta.resource }, ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); const description = yield* buildExecuteDescription(executor); return { engine, executor, description }; }); type CloudflareExecutionRuntime = Effect.Success>; type CloudflareModernLifecycle = { readonly pausedExecutionHooks?: PausedExecutionHooks; readonly resumeFallback?: ( executionId: string, response: ResumeResponse, ) => Effect.Effect; }; const makeCloudflareModernRuntime = ( sessionMeta: SessionMeta, runtime: CloudflareExecutionRuntime, loadAppShellHtml: () => Promise, config: CloudflareConfig, lifecycle: CloudflareModernLifecycle = {}, ): BuiltModernMcpRuntime => { const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; return { engine: runtime.engine, buildServer: (options) => buildMcpServer({ engine: runtime.engine, description: runtime.description, artifacts: runtime.executor.artifacts, connections: runtime.executor.connections, artifactsEnabled: sessionMeta.artifactsEnabled ?? true, loadAppShellHtml, smokeRenderArtifact, ...(artifactOrigin ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } : {}), elicitationMode: { mode: "native" }, ...(lifecycle.pausedExecutionHooks ? { pausedExecutionHooks: lifecycle.pausedExecutionHooks, pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, } : {}), ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), ...options, }), }; }; const closeModernServerWithDb = Promise }>( server: Server, dbHandle: CfSessionDbHandle, ): Server => { const closeServer = server.close.bind(server); server.close = () => Effect.runPromise( Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), ); return server; }; /** Build the worker-side MCP server over a fresh D1 execution runtime. */ export const makeCloudflareModernMcpServerBuilder = ( env: CloudflareEnv, config: CloudflareConfig, session: McpSessionInit, ): McpModernServerBuilder["Service"] => ({ build: (principal: Principal, options) => Effect.promise(async () => { const handle = await createD1ExecutorDb(env.DB, env.BLOBS); return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle; }).pipe( Effect.flatMap((dbHandle) => { const { resource, ...requestOptions } = options; const sessionMeta: SessionMeta = { organizationId: principal.organizationId, organizationName: config.organizationName, organizationSlug: config.organizationSlug, userId: principal.accountId, resource, elicitationMode: session.elicitationMode, artifactsEnabled: session.artifactsEnabled, webOrigin: session.webOrigin, }; return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe( Effect.flatMap((runtime) => makeCloudflareModernRuntime( sessionMeta, runtime, makeAssetsShellHtmlLoader({ assets: env.ASSETS }), config, ).buildServer(requestOptions), ), Effect.map((server) => closeModernServerWithDb(server, dbHandle)), Effect.tapCause(() => Effect.promise(() => dbHandle.end())), Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })), ); }), ), }); export class McpSessionDO extends McpAgentSessionDOBase { 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; constructor( ctx: ConstructorParameters>[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 forwardModelResumeToOwner( owner: McpExecutionOwnerRoute, identity: McpApprovalOwner, executionId: string, response: ResumeResponse, ): Effect.Effect { const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner); if (!ownerSession) { return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); } return Effect.tryPromise({ try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } protected override async openSessionDb(): Promise { const handle = await createD1ExecutorDb(this.cfEnv.DB, this.cfEnv.BLOBS); return { ...handle, end: () => handle.close() }; } protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { // Single-tenant: every Access principal belongs to the one configured org, // so there is nothing to resolve — stamp the configured org name. 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, webOrigin: token.webOrigin, } satisfies SessionMeta); } protected override buildMcpServer( sessionMeta: SessionMeta, dbHandle: CfSessionDbHandle, ): Effect.Effect { const config = this.cfConfig; const self = this; return Effect.gen(function* () { const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config); const { engine, executor, description } = runtime; const modernRuntime = makeCloudflareModernRuntime( sessionMeta, runtime, self.loadAppShellHtml, config, { pausedExecutionHooks: self.modernPausedExecutionHooks, resumeFallback: self.modernModelResumeFallback, }, ); // 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* buildMcpServer({ engine, description, 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, // 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), appsEnabled: false, sessionful: true, requestStateSigningKey: self.modernRequestStateSigningKey(), requestStatePrincipal: mcpRequestStatePrincipal({ accountId: sessionMeta.userId, organizationId: sessionMeta.organizationId, }), 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, modernRuntime } 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, ); } protected override buildModernMcpRuntime( sessionMeta: SessionMeta, dbHandle: CfSessionDbHandle, ): Effect.Effect { const self = this; return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe( Effect.map((runtime) => makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, { pausedExecutionHooks: self.modernPausedExecutionHooks, resumeFallback: self.modernModelResumeFallback, }), ), Effect.withSpan("McpSessionDO.buildModernMcpRuntime"), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path Effect.orDie, ); } protected override modernRequestStateSigningKey(): string { return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY); } }