History

cloud/local/examples: thread per-user ScopeStack through HTTP + MCP

- cloud: resolves userId from the authenticated session and builds a [userOrgScope, orgScope] stack via createScopedExecutor(userId, orgId, orgName). userId threaded through execution-stack, mcp-session, and MCP dispatch init. - cloud env refactor: deletes the lazy Proxy apps/cloud/src/env.ts in favor of cloudflare:workers env imported at call sites; adds env-augment.d.ts to type secrets not present in generated wrangler types. - cloud/server.ts: env-guarded OTEL wiring (skips instrument() when AXIOM_TOKEN absent); captures rawFetch before instrument() mutates it so dev requests still land on the Hono app. - cloud/services/db.ts: prefers DATABASE_URL over the hyperdrive proxy so local dev works without the tunnel. - vite.config.ts: devCrashGuard plugin that prints uncaught Worker errors with a stable exit. - hosts/mcp: optional debug flag surfaces verbose session logging, wired up from cloud mcp-session. - local/examples/marketing: migrated to scopes: [scope].

Rhys Sullivan committed 6 months ago

Commit d4e311e1 parent 427699c25 files · +320 −174Browse files

Unified · Split

25 files
  1. apps/cloud/src/api/autumn.ts +2 −2
  2. apps/cloud/src/api/protected.ts +12 −6
  3. apps/cloud/src/auth/handlers.ts +2 −2
  4. apps/cloud/src/auth/workos.ts +4 −4
  5. apps/cloud/src/env-augment.d.ts +33 −0
  6. apps/cloud/src/env.ts +0 −91
  7. apps/cloud/src/mcp-session.e2e.node.test.ts +1 −1
  8. apps/cloud/src/mcp-session.ts +17 −6
  9. apps/cloud/src/mcp.ts +5 −4
  10. apps/cloud/src/observability.ts +3 −0
  11. apps/cloud/src/org/handlers.ts +2 −2
  12. apps/cloud/src/server.ts +33 −16
  13. apps/cloud/src/services/__test-harness__/api-harness.ts +1 −1
  14. apps/cloud/src/services/autumn.ts +2 −3
  15. apps/cloud/src/services/db.ts +1 −5
  16. apps/cloud/src/services/execution-stack.ts +10 −4
  17. apps/cloud/src/services/executor.ts +31 −14
  18. apps/cloud/src/services/telemetry.ts +5 −6
  19. apps/cloud/vite.config.ts +29 −1
  20. apps/local/src/server/config-sync.ts +9 −0
  21. apps/local/src/server/executor.ts +1 −1
  22. apps/marketing/src/pages/api/detect.ts +2 −0
  23. examples/all-plugins/src/main.ts +4 −1
  24. examples/promise-sdk/src/main.ts +5 −1
  25. packages/hosts/mcp/src/server.ts +106 −3
apps/cloud/src/api/autumn.ts+2−2
@@ -1,9 +1,9 @@
1+import { env } from "cloudflare:workers";
12 import { Effect } from "effect";
23 import { HttpServerRequest, HttpServerResponse } from "@effect/platform";
34 import { autumnHandler } from "autumn-js/backend";
45
56 import { WorkOSAuth } from "../auth/workos";
6−import { server } from "../env";
77 import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
88 import { SharedServices } from "./layers";
99
@@ -59,7 +59,7 @@ export const AutumnApiApp = Effect.gen(function* () { · 49 unmodified lines
5959 email: session.email,
6060 },
6161 clientOptions: {
62− secretKey: server.AUTUMN_SECRET_KEY,
62+ secretKey: env.AUTUMN_SECRET_KEY ?? "",
6363 },
6464 pathPrefix: "/autumn",
6565 }),
apps/cloud/src/api/protected.ts+12−6
@@ -27,12 +27,18 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 26 unmodified lines
2727 const session = yield* workos.authenticateRequest(webRequest);
2828 if (!session || !session.organizationId) return null;
2929
30− return yield* authorizeOrganization(session.userId, session.organizationId);
30+ const org = yield* authorizeOrganization(session.userId, session.organizationId);
31+ if (!org) return null;
32+ return { org, userId: session.userId };
3133 });
3234
33−const createProtectedApp = (organizationId: string, organizationName: string) =>
35+const createProtectedApp = (userId: string, organizationId: string, organizationName: string) =>
3436 Effect.gen(function* () {
35− const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
37+ const { executor, engine } = yield* makeExecutionStack(
38+ userId,
39+ organizationId,
40+ organizationName,
41+ );
3642
3743 const requestServices = Layer.mergeAll(
3844 Layer.succeed(ExecutorService, executor),
@@ -58,8 +64,8 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 19 unmodified lines
5864
5965 export const ProtectedApiApp = Effect.gen(function* () {
6066 const request = yield* HttpServerRequest.HttpServerRequest;
61− const org = yield* lookupOrgForRequest(request);
62− if (!org) {
67+ const session = yield* lookupOrgForRequest(request);
68+ if (!session) {
6369 return yield* Effect.fail(
6470 new HttpResponseError({
6571 status: 403,
@@ -69,7 +75,7 @@ export const ProtectedApiApp = Effect.gen(function* () { · 3 unmodified lines
6975 );
7076 }
7177
72− const app = yield* createProtectedApp(org.id, org.name);
78+ const app = yield* createProtectedApp(session.userId, session.org.id, session.org.name);
7379 return yield* app;
7480 }).pipe(
7581 Effect.provide(SharedServices),
apps/cloud/src/auth/handlers.ts+2−2
@@ -6,9 +6,9 @@ import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; · 5 unmodified lines
66 import { SessionContext } from "./middleware";
77 import { UserStoreService } from "./context";
88 import { authorizeOrganization } from "./authorize-organization";
9+import { env } from "cloudflare:workers";
910 import { WorkOSError } from "./errors";
1011 import { WorkOSAuth } from "./workos";
11−import { server } from "../env";
1212
1313 const COOKIE_OPTIONS = {
1414 path: "/",
@@ -40,7 +40,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 25 unmodified lines
4040 // Use the explicit public site URL — in dev, the request's Host
4141 // header points at the internal proxy target, not the public URL
4242 // WorkOS needs to redirect back to.
43− const origin = server.VITE_PUBLIC_SITE_URL;
43+ const origin = env.VITE_PUBLIC_SITE_URL ?? "";
4444 const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);
4545 return HttpServerResponse.redirect(url, { status: 302 });
4646 }),
apps/cloud/src/auth/workos.ts+4−4
@@ -2,10 +2,10 @@ · 1 unmodified line
22 // WorkOS AuthKit — Effect-native sealed session management
33 // ---------------------------------------------------------------------------
44
5+import { env } from "cloudflare:workers";
56 import { Context, Effect, Layer } from "effect";
67 import { WorkOS } from "@workos-inc/node/worker";
78 import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
8−import { server } from "../env";
99
1010 const COOKIE_NAME = "wos-session";
1111
@@ -14,9 +14,9 @@ const COOKIE_NAME = "wos-session"; · 2 unmodified lines
1414 // ---------------------------------------------------------------------------
1515
1616 const make = Effect.gen(function* () {
17− const apiKey = server.WORKOS_API_KEY;
18− const clientId = server.WORKOS_CLIENT_ID;
19− const cookiePassword = server.WORKOS_COOKIE_PASSWORD;
17+ const apiKey = env.WORKOS_API_KEY;
18+ const clientId = env.WORKOS_CLIENT_ID;
19+ const cookiePassword = env.WORKOS_COOKIE_PASSWORD;
2020
2121 if (!cookiePassword || cookiePassword.length < 32) {
2222 return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters"));
apps/cloud/src/env-augment.d.ts+33−0
@@ -0,0 +1,33 @@
1+// Augment the wrangler-generated `Cloudflare.Env` with secrets / vars set at
2+// deploy time (via `wrangler secret put`, dashboard, or `.dev.vars`) that
3+// don't show up in `wrangler types` output because they aren't declared in
4+// wrangler.jsonc, but are what `env.X` resolves to at runtime.
5+declare global {
6+ namespace Cloudflare {
7+ interface Env {
8+ // Observability
9+ AXIOM_TOKEN?: string;
10+ AXIOM_DATASET?: string;
11+ AXIOM_TRACES_URL?: string;
12+ AXIOM_TRACES_SAMPLE_RATIO?: string;
13+ SENTRY_DSN?: string;
14+
15+ // Datastore (dev only — prod uses HYPERDRIVE binding)
16+ DATABASE_URL?: string;
17+
18+ // Billing
19+ AUTUMN_SECRET_KEY?: string;
20+
21+ // MCP
22+ MCP_SESSION_REQUEST_SCOPED_RUNTIME?: string;
23+ EXECUTOR_MCP_DEBUG?: string;
24+ MCP_AUTHKIT_DOMAIN?: string;
25+ MCP_RESOURCE_ORIGIN?: string;
26+
27+ // Shared with frontend
28+ VITE_PUBLIC_SITE_URL?: string;
29+ }
30+ }
31+}
32+
33+export {};
apps/cloud/src/env.ts+0−91
@@ -1,91 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Env accessors for apps/cloud.
3−// ---------------------------------------------------------------------------
4−//
5−// `server` / `shared` are lazy Proxies over `cloudflare:workers` `env`, with
6−// a `process.env` fallback for the node-pool tests (which stub
7−// `cloudflare:workers` to an empty `env` and rely on vitest to populate
8−// `process.env`). Reads happen at the call site, not at module load, so
9−// secrets land correctly in both the edge isolate and the Durable Object
10−// isolate — the latter previously captured empty strings at env.ts
11−// module-load time and silently broke `DoTelemetryLive`, `AutumnService`
12−// billing writes, and likely other DO-reachable secret readers.
13−//
14−// Types below mirror the prior `createEnv(...)`-derived shapes so every
15−// caller (`server.X`) keeps the same typed surface.
16−// ---------------------------------------------------------------------------
17−
18−import { env } from "cloudflare:workers";
19−
20−type SharedEnv = Readonly<{
21− NODE_ENV: "development" | "test" | "production";
22−}>;
23−
24−type ServerEnv = SharedEnv &
25− Readonly<{
26− DATABASE_URL: string;
27− MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false" | "true";
28− WORKOS_API_KEY: string;
29− WORKOS_CLIENT_ID: string;
30− WORKOS_COOKIE_PASSWORD: string;
31− VITE_PUBLIC_SITE_URL: string;
32− MCP_AUTHKIT_DOMAIN: string;
33− MCP_RESOURCE_ORIGIN: string;
34− AUTUMN_SECRET_KEY: string;
35− SENTRY_DSN: string;
36− AXIOM_TOKEN: string;
37− AXIOM_DATASET: string;
38− AXIOM_TRACES_URL: string;
39− AXIOM_TRACES_SAMPLE_RATIO: string;
40− }>;
41−
42−type WebEnv = Readonly<Record<string, never>>;
43−
44−const SERVER_DEFAULTS: Record<keyof ServerEnv, string> = {
45− NODE_ENV: "development",
46− DATABASE_URL: "",
47− MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false",
48− WORKOS_API_KEY: "",
49− WORKOS_CLIENT_ID: "",
50− WORKOS_COOKIE_PASSWORD: "",
51− VITE_PUBLIC_SITE_URL: "",
52− MCP_AUTHKIT_DOMAIN: "https://signin.executor.sh",
53− MCP_RESOURCE_ORIGIN: "https://executor.sh",
54− AUTUMN_SECRET_KEY: "",
55− SENTRY_DSN: "",
56− AXIOM_TOKEN: "",
57− AXIOM_DATASET: "executor-cloud",
58− AXIOM_TRACES_URL: "https://api.axiom.co/v1/traces",
59− AXIOM_TRACES_SAMPLE_RATIO: "1",
60−};
61−
62−const SHARED_DEFAULTS: Record<keyof SharedEnv, string> = {
63− NODE_ENV: "development",
64−};
65−
66−const readFromWorkerEnv = (key: string): string | undefined => {
67− const bag = env as unknown as Record<string, unknown>;
68− const v = bag[key];
69− return typeof v === "string" && v.length > 0 ? v : undefined;
70−};
71−
72−const readFromProcessEnv = (key: string): string | undefined => {
73− if (typeof process === "undefined" || !process.env) return undefined;
74− const v = process.env[key];
75− return typeof v === "string" && v.length > 0 ? v : undefined;
76−};
77−
78−const read = (key: string): string | undefined =>
79− readFromWorkerEnv(key) ?? readFromProcessEnv(key);
80−
81−const makeEnvProxy = <T extends object>(defaults: Record<string, string>): T =>
82− new Proxy({} as T, {
83− get: (_target, key) => {
84− if (typeof key !== "string") return undefined;
85− return read(key) ?? defaults[key] ?? "";
86− },
87− });
88−
89−export const shared: SharedEnv = makeEnvProxy<SharedEnv>(SHARED_DEFAULTS);
90−export const server: ServerEnv = makeEnvProxy<ServerEnv>(SERVER_DEFAULTS);
91−export const web: WebEnv = {} as WebEnv;
apps/cloud/src/mcp-session.e2e.node.test.ts+1−1
@@ -122,7 +122,7 @@ const buildScopedExecutor = ( · 121 unmodified lines
122122 name: scopeName,
123123 createdAt: new Date(),
124124 });
125− return yield* createExecutor({ scope, adapter, blobs, plugins });
125+ return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins });
126126 });
127127
128128 // Builds a scope, wires a real execution engine + MCP server, and yields
apps/cloud/src/mcp-session.ts+17−6
@@ -25,7 +25,6 @@ import type { DrizzleDb, DbServiceShape } from "./services/db"; · 24 unmodified lines
2525 import { CoreSharedServices } from "./api/core-shared-services";
2626 import { UserStoreService } from "./auth/context";
2727 import { resolveOrganization } from "./auth/resolve-organization";
28−import { server } from "./env";
2928 import { DbService, combinedSchema } from "./services/db";
3029 import { makeExecutionStack } from "./services/execution-stack";
3130 import { DoTelemetryLive } from "./services/telemetry";
@@ -36,6 +35,7 @@ import { DoTelemetryLive } from "./services/telemetry"; · 4 unmodified lines
3635
3736 export type McpSessionInit = {
3837 organizationId: string;
38+ userId: string;
3939 };
4040
4141 export type IncomingTraceHeaders = {
@@ -109,6 +109,7 @@ type DbHandle = DbServiceShape & { end: () => Promise<void> }; · 67 unmodified lines
109109 type SessionMeta = {
110110 readonly organizationId: string;
111111 readonly organizationName: string;
112+ readonly userId: string;
112113 };
113114
114115 /**
@@ -124,7 +125,12 @@ const makeDbHandle = (options: { · 9 unmodified lines
124125 readonly idleTimeout: number;
125126 readonly maxLifetime: number;
126127 }): DbHandle => {
127− const connectionString = env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL;
128+ // Prefer DATABASE_URL so local dev connects directly to PGlite and
129+ // bypasses Miniflare's Hyperdrive proxy, which resolves to
130+ // `*.hyperdrive.local:5432` and times out — the resulting ECONNRESET
131+ // surfaces as an unhandled socket error that kills the vite dev server.
132+ // Matches the priority in `services/db.ts`.
133+ const connectionString = env.DATABASE_URL || env.HYPERDRIVE?.connectionString || "";
128134 const sql = postgres(connectionString, {
129135 max: 1,
130136 idle_timeout: options.idleTimeout,
@@ -159,6 +165,7 @@ const makeSessionServices = (dbHandle: DbHandle) => · 28 unmodified lines
159165
160166 const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* (
161167 organizationId: string,
168+ userId: string,
162169 ) {
163170 const org = yield* resolveOrganization(organizationId);
164171 if (!org) {
@@ -167,10 +174,11 @@ const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function · 2 unmodified lines
167174 return {
168175 organizationId: org.id,
169176 organizationName: org.name,
177+ userId,
170178 } satisfies SessionMeta;
171179 });
172180
173−const requestScopedRuntimeEnabled = server.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true";
181+const requestScopedRuntimeEnabled = env.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true";
174182
175183 // ---------------------------------------------------------------------------
176184 // Durable Object
@@ -238,6 +246,7 @@ export class McpSessionDO extends DurableObject { · 61 unmodified lines
238246 const self = this;
239247 return Effect.gen(function* () {
240248 const { executor, engine } = yield* makeExecutionStack(
249+ sessionMeta.userId,
241250 sessionMeta.organizationId,
242251 sessionMeta.organizationName,
243252 );
@@ -251,6 +260,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines
251260 engine,
252261 description,
253262 parentSpan: () => self.currentRequestSpan ?? undefined,
263+ debug: env.EXECUTOR_MCP_DEBUG === "true",
254264 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
255265 const transport = new WorkerTransport({
256266 sessionIdGenerator: () => self.ctx.id.toString(),
@@ -272,9 +282,10 @@ export class McpSessionDO extends DurableObject { · 15 unmodified lines
272282 return Effect.gen(function* () {
273283 const dbHandle = makeRequestScopedDb();
274284 try {
275− const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe(
276− Effect.provide(makeResolveOrganizationServices(dbHandle)),
277− );
285+ const sessionMeta = yield* resolveSessionMeta(
286+ token.organizationId,
287+ token.userId,
288+ ).pipe(Effect.provide(makeResolveOrganizationServices(dbHandle)));
278289 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
279290 Effect.withSpan("mcp.session.save_meta"),
280291 );
apps/cloud/src/mcp.ts+5−4
@@ -20,15 +20,14 @@ import * as Sentry from "@sentry/cloudflare"; · 19 unmodified lines
2020 import { Context, Effect, Layer, Option, Schema } from "effect";
2121 import { createRemoteJWKSet, jwtVerify } from "jose";
2222
23−import { server } from "./env";
2423 import { TelemetryLive } from "./services/telemetry";
2524
2625 // ---------------------------------------------------------------------------
2726 // Constants
2827 // ---------------------------------------------------------------------------
2928
30−const AUTHKIT_DOMAIN = server.MCP_AUTHKIT_DOMAIN;
31−const RESOURCE_ORIGIN = server.MCP_RESOURCE_ORIGIN;
29+const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh";
30+const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh";
3231
3332 const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`));
3433
@@ -526,7 +525,9 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 491 unmodified lines
526525 const ns = env.MCP_SESSION;
527526 const stub = ns.get(ns.newUniqueId());
528527 const propagation = yield* currentPropagationHeaders(request);
529− yield* Effect.promise(() => stub.init({ organizationId }, propagation));
528+ yield* Effect.promise(() =>
529+ stub.init({ organizationId, userId: token.accountId }, propagation),
530+ );
530531 const propagated = withPropagationHeaders(request, propagation);
531532 const raw = yield* Effect.promise(
532533 () => stub.handleRequest(propagated) as Promise<Response>,
apps/cloud/src/observability.ts+3−0
@@ -21,6 +21,9 @@ export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( · 20 unmodified lines
2121 captureException: (cause) =>
2222 Effect.sync(() => {
2323 const squashed = Cause.squash(cause);
24+ // Mirror to console so dev terminals see the defect; Sentry gets the
25+ // full structured cause for production correlation.
26+ console.error("[api] unhandled cause:", Cause.pretty(cause));
2427 const eventId = Sentry.captureException(squashed, (scope) => {
2528 scope.setExtra("cause", Cause.pretty(cause));
2629 return scope;
apps/cloud/src/org/handlers.ts+2−2
@@ -3,8 +3,8 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { UserStoreService } from "../auth/context";
55 import { AuthContext } from "../auth/middleware";
6+import { env } from "cloudflare:workers";
67 import { WorkOSAuth } from "../auth/workos";
7−import { server } from "../env";
88 import { AutumnService } from "../services/autumn";
99 import { OrgHttpApi } from "./compose";
1010 import { Forbidden } from "./api";
@@ -144,7 +144,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 133 unmodified lines
144144 const workos = yield* WorkOSAuth;
145145 const { link } = yield* workos.generateDomainVerificationPortalLink(
146146 auth.organizationId,
147− server.VITE_PUBLIC_SITE_URL ? `${server.VITE_PUBLIC_SITE_URL}/org` : "/org",
147+ env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org",
148148 );
149149 return { link };
150150 }),
apps/cloud/src/server.ts+33−16
@@ -3,7 +3,6 @@ import handler from "@tanstack/react-start/server-entry"; · 2 unmodified lines
33 import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers";
44
55 import { McpSessionDO as McpSessionDOBase } from "./mcp-session";
6−import { server } from "./env";
76
87 // ---------------------------------------------------------------------------
98 // OTEL config for the main fetch handler — `otel-cf-workers` owns the global
@@ -15,40 +14,36 @@ import { server } from "./env"; · 5 unmodified lines
1514 // DOMException "Illegal invocation".
1615 // ---------------------------------------------------------------------------
1716
18−const parseSampleRatio = (value: string): number => {
17+const parseSampleRatio = (value: string | undefined): number => {
1918 const n = Number(value);
2019 if (!Number.isFinite(n)) return 1;
2120 return Math.min(1, Math.max(0, n));
2221 };
2322
24−const otelConfig: TraceConfig = {
23+const otelConfig = (env: Env): TraceConfig => ({
2524 service: { name: "executor-cloud", version: "1.0.0" },
2625 exporter: {
27− url: server.AXIOM_TRACES_URL,
26+ url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
2827 headers: {
29− Authorization: `Bearer ${server.AXIOM_TOKEN}`,
30− "X-Axiom-Dataset": server.AXIOM_DATASET,
28+ Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`,
29+ "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
3130 },
3231 },
3332 sampling: {
3433 headSampler: {
3534 // Keep remote parent decisions and make local sampling policy explicit.
3635 acceptRemote: true,
37− ratio: parseSampleRatio(server.AXIOM_TRACES_SAMPLE_RATIO),
36+ ratio: parseSampleRatio(env.AXIOM_TRACES_SAMPLE_RATIO),
3837 },
3938 },
40−};
39+});
4140
4241 // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim
4342 // registers a ProxyTracerProvider of its own, which prevents otel-cf-workers
4443 // from finding its WorkerTracer and breaks the whole request path with
4544 // "global tracer is not of type WorkerTracer".
46−//
47−// The `_env` parameter is unused — `server` from `./env` already gives us
48−// typed access to every secret. It's only in the signature so Sentry's
49−// generics infer the DO's `Env` type correctly.
50−const sentryOptions = (_env: Env) => ({
51− dsn: server.SENTRY_DSN,
45+const sentryOptions = (env: Env) => ({
46+ dsn: env.SENTRY_DSN,
5247 tracesSampleRate: 0,
5348 enableLogs: true,
5449 sendDefaultPii: true,
@@ -76,6 +71,28 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( · 21 unmodified lines
7671 // Worker fetch handler
7772 // ---------------------------------------------------------------------------
7873
79−const instrumentedHandler = instrument({ fetch: handler.fetch }, otelConfig);
74+// Skip OTLP wiring when no Axiom token is configured (dev without secrets).
75+// Otherwise the exporter ships every span with `Bearer ` (empty), which
76+// returns 401 on every batch and eventually drops the keep-alive socket —
77+// the Node http agent's unhandled `'error'` then crashes the process with
78+// ECONNRESET. It also registers otel-cf-workers' `WorkerTracer` as the
79+// global tracer; spans started outside its config ALS then die with
80+// "Config is undefined". Matches the gate in `DoTelemetryLive`.
81+// `instrument()` mutates the handler it's given (replaces `.fetch` with the
82+// proxied version), so capture the raw fetch first and then build the
83+// instrumented handler from a separate object.
84+const rawFetch = handler.fetch;
85+const instrumentedHandler = instrument({ fetch: rawFetch }, otelConfig);
86+
87+const dispatchHandler = {
88+ fetch: (request: Request, env: Env, ctx: unknown) => {
89+ const fn = env.AXIOM_TOKEN ? instrumentedHandler.fetch! : rawFetch;
90+ return (fn as (req: Request, env: Env, ctx: unknown) => Response | Promise<Response>)(
91+ request,
92+ env,
93+ ctx,
94+ );
95+ },
96+};
8097
81−export default Sentry.withSentry(sentryOptions, instrumentedHandler);
98+export default Sentry.withSentry(sentryOptions, dispatchHandler);
apps/cloud/src/services/__test-harness__/api-harness.ts+1−1
@@ -181,7 +181,7 @@ const createTestScopedExecutor = (scopeId: string, scopeName: string) => · 180 unmodified lines
181181 name: scopeName,
182182 createdAt: new Date(),
183183 });
184− return yield* createExecutor({ scope, adapter, blobs, plugins });
184+ return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins });
185185 });
186186
187187 // ---------------------------------------------------------------------------
apps/cloud/src/services/autumn.ts+2−3
@@ -2,12 +2,11 @@ · 1 unmodified line
22 // Autumn billing service — wraps the autumn-js SDK with Effect
33 // ---------------------------------------------------------------------------
44
5+import { env } from "cloudflare:workers";
56 import * as Sentry from "@sentry/cloudflare";
67 import { Autumn } from "autumn-js";
78 import { Context, Data, Effect, Layer } from "effect";
89
9−import { server } from "../env";
10−
1110 // ---------------------------------------------------------------------------
1211 // Errors
1312 // ---------------------------------------------------------------------------
@@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines
3635 // ---------------------------------------------------------------------------
3736
3837 const make = Effect.sync(() => {
39− const secretKey = server.AUTUMN_SECRET_KEY;
38+ const secretKey = env.AUTUMN_SECRET_KEY;
4039
4140 if (!secretKey) {
4241 const notConfigured = Effect.die(
apps/cloud/src/services/db.ts+1−5
@@ -18,7 +18,6 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 17 unmodified lines
1818 import postgres, { type Sql } from "postgres";
1919 import * as cloudSchema from "./schema";
2020 import * as executorSchema from "./executor-schema";
21−import { server } from "../env";
2221
2322 // Exported so every drizzle() call in the cloud app shares one schema
2423 // object. Historically `mcp-session.ts` built its own and forgot to spread
@@ -38,10 +37,7 @@ const resolveConnectionString = () => { · 13 unmodified lines
3837 // In local dev prefer an explicit DATABASE_URL (direct connection to
3938 // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy.
4039 // In production fall back to the Hyperdrive binding.
41− if (server.DATABASE_URL) {
42− return server.DATABASE_URL;
43− }
44− return env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL;
40+ return env.DATABASE_URL || env.HYPERDRIVE?.connectionString || "";
4541 };
4642
4743 const makeSql = (): Sql =>
apps/cloud/src/services/execution-stack.ts+10−4
@@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines
1414 import { AutumnService } from "./autumn";
1515 import { createScopedExecutor } from "./executor";
1616
17−export const makeExecutionStack = (organizationId: string, organizationName: string) =>
17+export const makeExecutionStack = (
18+ userId: string,
19+ organizationId: string,
20+ organizationName: string,
21+) =>
1822 Effect.gen(function* () {
19− const executor = yield* createScopedExecutor(organizationId, organizationName).pipe(
20− Effect.withSpan("McpSessionDO.createScopedExecutor"),
21− );
23+ const executor = yield* createScopedExecutor(
24+ userId,
25+ organizationId,
26+ organizationName,
27+ ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor"));
2228 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
2329 const autumn = yield* AutumnService;
2430 const engine = withExecutionUsageTracking(
apps/cloud/src/services/executor.ts+31−14
@@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines
2525 import { graphqlPlugin } from "@executor/plugin-graphql";
2626 import { workosVaultPlugin } from "@executor/plugin-workos-vault";
2727
28+import { env } from "cloudflare:workers";
2829 import { DbService } from "./db";
29−import { server } from "../env";
3030
3131 // ---------------------------------------------------------------------------
3232 // Plugin list — one place, used for both the runtime and the CLI config
@@ -45,24 +45,31 @@ const createOrgPlugins = () => · 12 unmodified lines
4545 graphqlPlugin(),
4646 workosVaultPlugin({
4747 credentials: {
48− apiKey: server.WORKOS_API_KEY,
49− clientId: server.WORKOS_CLIENT_ID,
48+ apiKey: env.WORKOS_API_KEY,
49+ clientId: env.WORKOS_CLIENT_ID,
5050 },
5151 }),
5252 ] as const;
5353
5454 // ---------------------------------------------------------------------------
55−// Create a fresh executor for a scope (stateless, per-request).
55+// Create a fresh executor for a (user, org) pair (stateless, per-request).
5656 //
57−// Today "scope" is the WorkOS organization — orgs are the only scope
58−// level the cloud app exposes. When workspace / user scopes land, this
59−// function grows to accept a `ScopeStack` instead of a single scope id,
60−// and nothing downstream (executor, plugins, storage) has to change.
57+// Scope stack is `[userOrgScope, orgScope]` — innermost first. The
58+// user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally
59+// includes the org id so the same WorkOS user in a different org gets a
60+// distinct scope row; future workspace scopes can slot in between without
61+// conflicting with a hypothetical global user scope.
62+//
63+// OAuth tokens land at `ctx.scopes[0]` (the user-org scope) by default, so
64+// a member's access/refresh tokens can't leak to other members via
65+// `secrets.list`, while source rows and org-wide credentials live on the
66+// outer scope.
6167 // ---------------------------------------------------------------------------
6268
6369 export const createScopedExecutor = (
64− scopeId: string,
65− scopeName: string,
70+ userId: string,
71+ organizationId: string,
72+ organizationName: string,
6673 ) =>
6774 Effect.gen(function* () {
6875 const { db } = yield* DbService;
@@ -72,9 +79,14 @@ export const createScopedExecutor = ( · 3 unmodified lines
7279 const adapter = makePostgresAdapter({ db, schema });
7380 const blobs = makePostgresBlobStore({ db });
7481
75− const scope = new Scope({
76− id: ScopeId.make(scopeId),
77− name: scopeName,
82+ const orgScope = new Scope({
83+ id: ScopeId.make(organizationId),
84+ name: organizationName,
85+ createdAt: new Date(),
86+ });
87+ const userOrgScope = new Scope({
88+ id: ScopeId.make(`user-org:${userId}:${organizationId}`),
89+ name: `Personal · ${organizationName}`,
7890 createdAt: new Date(),
7991 });
8092
@@ -82,5 +94,10 @@ export const createScopedExecutor = ( · 1 unmodified line
8294 // the opaque `InternalError({ traceId })` happens at the HTTP edge
8395 // via `withCapture` (see `api/protected-layers.ts`). That's
8496 // where `ErrorCaptureLive` (Sentry) gets wired in.
85− return yield* createExecutor({ scope, adapter, blobs, plugins });
97+ return yield* createExecutor({
98+ scopes: [userOrgScope, orgScope],
99+ adapter,
100+ blobs,
101+ plugins,
102+ });
86103 });
apps/cloud/src/services/telemetry.ts+5−6
@@ -53,10 +53,9 @@ import { · 52 unmodified lines
5353 ATTR_SERVICE_NAME,
5454 ATTR_SERVICE_VERSION,
5555 } from "@opentelemetry/semantic-conventions";
56+import { env } from "cloudflare:workers";
5657 import { Effect, Layer } from "effect";
5758
58−import { server } from "../env";
59−
6059 const SERVICE_NAME = "executor-cloud";
6160 const SERVICE_VERSION = "1.0.0";
6261
@@ -71,7 +70,7 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 8 unmodified lines
7170 let installed = false;
7271 const ensureGlobalTracerProvider = (): boolean => {
7372 if (installed) return true;
74− if (!server.AXIOM_TOKEN) return false;
73+ if (!env.AXIOM_TOKEN) return false;
7574 const provider = new WebTracerProvider({
7675 resource: resourceFromAttributes({
7776 [ATTR_SERVICE_NAME]: SERVICE_NAME,
@@ -80,10 +79,10 @@ const ensureGlobalTracerProvider = (): boolean => { · 2 unmodified lines
8079 spanProcessors: [
8180 new SimpleSpanProcessor(
8281 new OTLPTraceExporter({
83− url: server.AXIOM_TRACES_URL,
82+ url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
8483 headers: {
85− Authorization: `Bearer ${server.AXIOM_TOKEN}`,
86− "X-Axiom-Dataset": server.AXIOM_DATASET,
84+ Authorization: `Bearer ${env.AXIOM_TOKEN}`,
85+ "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
8786 },
8887 }),
8988 ),
apps/cloud/vite.config.ts+29−1
@@ -1,12 +1,40 @@
1−import { defineConfig } from "vite";
1+import { defineConfig, type Plugin } from "vite";
22 import { cloudflare } from "@cloudflare/vite-plugin";
33 import { tanstackStart } from "@tanstack/react-start/plugin/vite";
44 import react from "@vitejs/plugin-react";
55 import tailwindcss from "@tailwindcss/vite";
66
7+// Dev-only: the cloudflare vite-plugin bridges outbound fetches (JWKS,
8+// OAuth metadata proxy, etc.) through node undici in the host process. If
9+// a pooled keep-alive socket gets RST'd while no listener is attached, the
10+// `'error'` emit is unhandled and tears down the whole dev server. Log
11+// enough to identify the offender and keep the server alive.
12+const devCrashGuard = (): Plugin => {
13+ let installed = false;
14+ const install = () => {
15+ if (installed) return;
16+ installed = true;
17+ process.on("uncaughtException", (err, origin) => {
18+ console.error(
19+ `[dev-crash-guard] uncaughtException (origin=${origin}):`,
20+ err,
21+ );
22+ });
23+ process.on("unhandledRejection", (reason, promise) => {
24+ console.error("[dev-crash-guard] unhandledRejection:", reason, promise);
25+ });
26+ };
27+ return {
28+ name: "dev-crash-guard",
29+ apply: "serve",
30+ configureServer: install,
31+ };
32+};
33+
734 export default defineConfig({
835 resolve: { tsconfigPaths: true },
936 plugins: [
37+ devCrashGuard(),
1038 tailwindcss(),
1139 cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }),
1240 tanstackStart(),
apps/local/src/server/config-sync.ts+9−0
@@ -84,10 +84,16 @@ const addSourceFromConfig = ( · 83 unmodified lines
8484 executor: LocalExecutor,
8585 source: SourceConfig,
8686 ): Effect.Effect<void, unknown> => {
87+ // `executor.jsonc` is a single-scope artifact today — the file isn't
88+ // aware of per-user tenancy. Pin replayed sources to the outermost
89+ // scope so a future `[user, org]` stack still sees them via org
90+ // fall-through.
91+ const scope = executor.scopes.at(-1)!.id as string;
8792 switch (source.kind) {
8893 case "openapi":
8994 return executor.openapi.addSpec({
9095 spec: source.spec,
96+ scope,
9197 baseUrl: source.baseUrl,
9298 namespace: source.namespace,
9399 headers: translateHeaders(source.headers),
@@ -96,6 +102,7 @@ const addSourceFromConfig = ( · 2 unmodified lines
96102 case "graphql":
97103 return executor.graphql.addSource({
98104 endpoint: source.endpoint,
105+ scope,
99106 namespace: source.namespace,
100107 headers: translateHeaders(source.headers) as Record<string, string> | undefined,
101108 }).pipe(Effect.asVoid);
@@ -104,6 +111,7 @@ const addSourceFromConfig = ( · 2 unmodified lines
104111 if (source.transport === "stdio") {
105112 return executor.mcp.addSource({
106113 transport: "stdio",
114+ scope,
107115 name: source.name,
108116 command: source.command,
109117 args: source.args ? [...source.args] : undefined,
@@ -114,6 +122,7 @@ const addSourceFromConfig = ( · 4 unmodified lines
114122 }
115123 return executor.mcp.addSource({
116124 transport: "remote",
125+ scope,
117126 name: source.name,
118127 endpoint: source.endpoint,
119128 remoteTransport: source.remoteTransport,
apps/local/src/server/executor.ts+1−1
@@ -154,7 +154,7 @@ const createLocalExecutorLayer = () => { · 153 unmodified lines
154154 });
155155
156156 const executor = yield* createExecutor({
157− scope,
157+ scopes: [scope],
158158 adapter,
159159 blobs,
160160 plugins,
apps/marketing/src/pages/api/detect.ts+2−0
@@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request }) => { · 82 unmodified lines
8383 yield* executor.openapi.addSpec({
8484 spec: match.endpoint,
8585 namespace: match.namespace,
86+ scope: "test-scope",
8687 });
8788 } else if (match.kind === "graphql") {
8889 yield* executor.graphql.addSource({
8990 endpoint: match.endpoint,
9091 namespace: match.namespace,
92+ scope: "test-scope",
9193 });
9294 } else {
9395 // For kinds we can't fully add (e.g. Google Discovery needs auth),
examples/all-plugins/src/main.ts+4−1
@@ -83,7 +83,7 @@ const plugins = [ · 82 unmodified lines
8383 ] as const;
8484
8585 const config = {
86− scope,
86+ scopes: [scope],
8787 adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }),
8888 blobs: makeInMemoryBlobStore(),
8989 plugins,
@@ -208,6 +208,7 @@ const program = Effect.gen(function* () { · 118 unmodified lines
208208 yield* executor.secrets.set(
209209 new SetSecretInput({
210210 id: SecretId.make("example-api-token"),
211+ scope: "example-scope" as SetSecretInput["scope"],
211212 name: "Example API Token",
212213 value: "sk-example-redacted",
213214 provider: "file",
@@ -262,6 +263,7 @@ const program = Effect.gen(function* () { · 48 unmodified lines
262263 spec: exampleOpenApiSpec,
263264 namespace: "example-api",
264265 baseUrl: "https://example.com/api",
266+ scope: "example-scope",
265267 });
266268 console.log("Registered OpenAPI source:", addSpecResult);
267269
@@ -371,6 +373,7 @@ const program = Effect.gen(function* () { · 103 unmodified lines
371373 endpoint: "https://example.com/graphql",
372374 introspectionJson,
373375 namespace: "example-graphql",
376+ scope: "example-scope",
374377 });
375378 console.log("Registered GraphQL source:", gqlResult);
376379
examples/promise-sdk/src/main.ts+5−1
@@ -12,7 +12,7 @@ import { graphqlPlugin } from "@executor/plugin-graphql/promise"; · 11 unmodified lines
1212 // ---------------------------------------------------------------------------
1313
1414 const executor = await createExecutor({
15− scope: { name: "my-app" },
15+ scopes: [{ id: "my-app", name: "my-app" }],
1616 plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const,
1717 });
1818
@@ -24,6 +24,7 @@ await executor.mcp.addSource({ · 5 unmodified lines
2424 transport: "remote",
2525 name: "Context7",
2626 endpoint: "https://mcp.context7.com/mcp",
27+ scope: "my-app",
2728 });
2829
2930 // Stdio server
@@ -41,6 +42,7 @@ await executor.mcp.addSource({ · 11 unmodified lines
4142 await executor.openapi.addSpec({
4243 spec: "https://petstore3.swagger.io/api/v3/openapi.json",
4344 namespace: "petstore",
45+ scope: "my-app",
4446 });
4547
4648 // With auth headers (static or secret-backed)
@@ -62,6 +64,7 @@ await executor.openapi.addSpec({ · 15 unmodified lines
6264 await executor.graphql.addSource({
6365 endpoint: "https://graphql.anilist.co",
6466 namespace: "anilist",
67+ scope: "my-app",
6568 });
6669
6770 // ---------------------------------------------------------------------------
@@ -97,6 +100,7 @@ if (anilistTool) { · 29 unmodified lines
97100 await executor.secrets.set(
98101 new SetSecretInput({
99102 id: SecretId.make("api-key"),
103+ scope: "my-app" as SetSecretInput["scope"],
100104 name: "Shared API Key",
101105 value: "sk_...",
102106 }),
packages/hosts/mcp/src/server.ts+106−3
@@ -66,6 +66,10 @@ type SharedMcpServerConfig = { · 65 unmodified lines
6666 * under whichever request triggered it; see the Cloud DO).
6767 */
6868 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);
69+ /**
70+ * Enable verbose MCP capability / elicitation debug logging.
71+ */
72+ readonly debug?: boolean;
6973 };
7074
7175 export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
@@ -85,9 +89,21 @@ const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean · 13 unmodified lines
8589 return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };
8690 };
8791
92+const readDebugDefault = (): boolean => {
93+ if (typeof process === "undefined" || !process.env) return false;
94+ const value = process.env.EXECUTOR_MCP_DEBUG;
95+ return value === "1" || value === "true";
96+};
97+
8898 const supportsManagedElicitation = (server: McpServer): boolean =>
8999 getElicitationSupport(server).form;
90100
101+const capabilitySnapshot = (server: McpServer) => ({
102+ clientCapabilities: server.server.getClientCapabilities() ?? null,
103+ elicitationSupport: getElicitationSupport(server),
104+ managedElicitation: supportsManagedElicitation(server),
105+});
106+
91107 type ElicitInputParams =
92108 | {
93109 mode?: "form";
@@ -118,7 +134,10 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 24 unmodified lines
118134 );
119135
120136 const makeMcpElicitationHandler =
121− (server: McpServer): ElicitationHandler =>
137+ (
138+ server: McpServer,
139+ debugLog?: (event: string, data: Record<string, unknown>) => void,
140+ ): ElicitationHandler =>
122141 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
123142 const { url: supportsUrl } = getElicitationSupport(server);
124143
@@ -133,19 +152,51 @@ const makeMcpElicitationHandler = · 8 unmodified lines
133152 : elicitationRequestToParams(ctx.request);
134153
135154 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
155+ debugLog?.("elicitation.request", {
156+ requestTag: ctx.request._tag,
157+ supportsUrl,
158+ message: ctx.request.message,
159+ hasRequestedSchema:
160+ ctx.request._tag === "FormElicitation"
161+ ? Object.keys(ctx.request.requestedSchema).length > 0
162+ : false,
163+ url: ctx.request._tag === "UrlElicitation" ? ctx.request.url : undefined,
164+ clientCapabilities: server.server.getClientCapabilities() ?? null,
165+ });
136166 try {
137167 const response = await server.server.elicitInput(
138168 params as Parameters<typeof server.server.elicitInput>[0],
139169 );
140170
171+ debugLog?.("elicitation.response", {
172+ requestTag: ctx.request._tag,
173+ action: response.action,
174+ hasContent:
175+ typeof response.content === "object" &&
176+ response.content !== null &&
177+ Object.keys(response.content).length > 0,
178+ });
179+
141180 return {
142181 action: response.action,
143182 content: response.content,
144183 };
145184 } catch (err) {
185+ debugLog?.("elicitation.error", {
186+ requestTag: ctx.request._tag,
187+ error:
188+ err instanceof Error
189+ ? { name: err.name, message: err.message, stack: err.stack }
190+ : { message: String(err) },
191+ clientCapabilities: server.server.getClientCapabilities() ?? null,
192+ });
146193 console.error(
147194 "[executor] elicitInput failed — falling back to cancel.",
148− err instanceof Error ? err.message : err,
195+ JSON.stringify({
196+ error: err instanceof Error ? err.message : String(err),
197+ requestTag: ctx.request._tag,
198+ ...capabilitySnapshot(server),
199+ }),
149200 );
150201 return { action: "cancel" };
151202 }
@@ -205,6 +256,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 53 unmodified lines
205256 // deferred past the outer Effect's await), so we use the runtime to
206257 // re-enter Effect-land at each callback edge.
207258 const runtime = yield* Effect.runtime<never>();
259+ const debugEnabled = config.debug ?? readDebugDefault();
260+ const debugLog = (event: string, data: Record<string, unknown>) => {
261+ if (!debugEnabled) return;
262+ try {
263+ console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`);
264+ } catch {
265+ console.error(`[executor:mcp] ${event}`, data);
266+ }
267+ };
208268
209269 const resolveParentSpan = (): Tracer.AnySpan | undefined => {
210270 const ps = config.parentSpan;
@@ -228,13 +288,27 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 17 unmodified lines
228288
229289 const executeCode = (code: string): Effect.Effect<McpToolResult, E> =>
230290 Effect.gen(function* () {
291+ debugLog("execute.call", {
292+ managedElicitation: supportsManagedElicitation(server),
293+ elicitationSupport: getElicitationSupport(server),
294+ clientCapabilities: server.server.getClientCapabilities() ?? null,
295+ codeLength: code.length,
296+ });
231297 if (supportsManagedElicitation(server)) {
232298 const result = yield* engine.execute(code, {
233− onElicitation: makeMcpElicitationHandler(server),
299+ onElicitation: makeMcpElicitationHandler(server, debugLog),
234300 });
235301 return toMcpResult(formatExecuteResult(result));
236302 }
237303 const outcome = yield* engine.executeWithPause(code);
304+ debugLog("execute.paused_flow_result", {
305+ status: outcome.status,
306+ executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
307+ interactionKind:
308+ outcome.status === "paused"
309+ ? outcome.execution.elicitationContext.request._tag
310+ : undefined,
311+ });
238312 return outcome.status === "completed"
239313 ? toMcpResult(formatExecuteResult(outcome.result))
240314 : toMcpPausedResult(formatPausedExecution(outcome.execution));
@@ -253,8 +327,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines
253327 content: Record<string, unknown> | undefined,
254328 ): Effect.Effect<McpToolResult, E> =>
255329 Effect.gen(function* () {
330+ debugLog("resume.call", {
331+ executionId,
332+ action,
333+ hasContent: content !== undefined,
334+ clientCapabilities: server.server.getClientCapabilities() ?? null,
335+ });
256336 const outcome = yield* engine.resume(executionId, { action, content });
257337 if (!outcome) {
338+ debugLog("resume.missing_execution", { executionId });
258339 return {
259340 content: [
260341 { type: "text" as const, text: `No paused execution: ${executionId}` },
@@ -262,6 +343,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1 unmodified line
262343 isError: true,
263344 } satisfies McpToolResult;
264345 }
346+ debugLog("resume.result", {
347+ executionId,
348+ status: outcome.status,
349+ nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined,
350+ interactionKind:
351+ outcome.status === "paused"
352+ ? outcome.execution.elicitationContext.request._tag
353+ : undefined,
354+ });
265355 return outcome.status === "completed"
266356 ? toMcpResult(formatExecuteResult(outcome.result))
267357 : toMcpPausedResult(formatPausedExecution(outcome.execution));
@@ -331,6 +421,19 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 63 unmodified lines
331421 } else {
332422 resumeTool.enable();
333423 }
424+ console.error(
425+ "[executor] MCP capability snapshot",
426+ JSON.stringify({
427+ ...capabilitySnapshot(server),
428+ resumeEnabled: !supportsManagedElicitation(server),
429+ }),
430+ );
431+ debugLog("tool.visibility", {
432+ clientCapabilities: server.server.getClientCapabilities() ?? null,
433+ elicitationSupport: getElicitationSupport(server),
434+ managedElicitation: supportsManagedElicitation(server),
435+ resumeEnabled: !supportsManagedElicitation(server),
436+ });
334437 };
335438
336439 yield* Effect.sync(() => {