-
Notifications
You must be signed in to change notification settings - Fork 157
Expand file tree
/
Copy pathsession-durable-object.ts
More file actions
125 lines (116 loc) · 6.07 KB
/
Copy pathsession-durable-object.ts
File metadata and controls
125 lines (116 loc) · 6.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { Effect } from "effect";
import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import type { ExecutorDbHandle } from "@executor-js/api/server";
import {
McpSessionDOBase,
type BuiltMcpServer,
type McpSessionInit,
type SessionMeta,
} from "@executor-js/cloudflare/mcp/durable-object";
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 `McpSessionDOBase` (@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> };
export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> {
private readonly cfEnv: CloudflareEnv;
private readonly cfConfig: CloudflareConfig;
// `ctx`'s type is taken from the base constructor so it tracks whichever
// `@cloudflare/workers-types` the shared package resolves (avoids a
// cross-version `DurableObjectState` mismatch at the `super` call).
constructor(ctx: ConstructorParameters<typeof McpSessionDOBase>[0], env: CloudflareEnv) {
super(ctx, env);
this.cfEnv = env;
this.cfConfig = loadConfig(env);
}
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,
elicitationMode: token.elicitationMode,
codeMode: token.codeMode,
} 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 } = yield* makeExecutionStack(
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
).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";
const mcpServer = yield* createExecutorMcpServer({
engine,
browserApprovalStore: self.browserApprovalStore,
codeMode: sessionMeta.codeMode,
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,
});
},
}
: { 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,
);
}
}