355 lines · 15.1 KB · TypeScript
RawDownload
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<void> }; 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<ReturnType<typeof makeCloudflareExecutionRuntime>>; type CloudflareModernLifecycle = {  readonly pausedExecutionHooks?: PausedExecutionHooks;  readonly resumeFallback?: (    executionId: string,    response: ResumeResponse,  ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;}; const makeCloudflareModernRuntime = (  sessionMeta: SessionMeta,  runtime: CloudflareExecutionRuntime,  loadAppShellHtml: () => Promise<string>,  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 = <Server extends { close: () => Promise<void> }>(  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<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 forwardModelResumeToOwner(    owner: McpExecutionOwnerRoute,    identity: McpApprovalOwner,    executionId: string,    response: ResumeResponse,  ): Effect.Effect<McpSessionModelResumeResult, unknown> {    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<CfSessionDbHandle> {    const handle = await createD1ExecutorDb(this.cfEnv.DB, this.cfEnv.BLOBS);    return { ...handle, end: () => handle.close() };  }   protected override resolveSessionMeta(token: McpSessionInit): 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.    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<BuiltMcpServer> {    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<BuiltModernMcpRuntime> {    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);  }}