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 @@
1import { env } from "cloudflare:workers";
1import { Effect } from "effect";2import { Effect } from "effect";
2import { HttpServerRequest, HttpServerResponse } from "@effect/platform";3import { HttpServerRequest, HttpServerResponse } from "@effect/platform";
3import { autumnHandler } from "autumn-js/backend";4import { autumnHandler } from "autumn-js/backend";
4 5
5import { WorkOSAuth } from "../auth/workos";6import { WorkOSAuth } from "../auth/workos";
6import { server } from "../env";
7import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";7import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
8import { SharedServices } from "./layers";8import { SharedServices } from "./layers";
9 9
@@ -59,7 +59,7 @@ export const AutumnApiApp = Effect.gen(function* () { · 49 unmodified lines
59 email: session.email,59 email: session.email,
60 },60 },
61 clientOptions: {61 clientOptions: {
62 secretKey: server.AUTUMN_SECRET_KEY,62 secretKey: env.AUTUMN_SECRET_KEY ?? "",
63 },63 },
64 pathPrefix: "/autumn",64 pathPrefix: "/autumn",
65 }),65 }),
apps/cloud/src/api/protected.ts+12−6
@@ -27,12 +27,18 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 26 unmodified lines
27 const session = yield* workos.authenticateRequest(webRequest);27 const session = yield* workos.authenticateRequest(webRequest);
28 if (!session || !session.organizationId) return null;28 if (!session || !session.organizationId) return null;
29 29
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 };
31 });33 });
32 34
33const createProtectedApp = (organizationId: string, organizationName: string) =>35const createProtectedApp = (userId: string, organizationId: string, organizationName: string) =>
34 Effect.gen(function* () {36 Effect.gen(function* () {
35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);37 const { executor, engine } = yield* makeExecutionStack(
38 userId,
39 organizationId,
40 organizationName,
41 );
36 42
37 const requestServices = Layer.mergeAll(43 const requestServices = Layer.mergeAll(
38 Layer.succeed(ExecutorService, executor),44 Layer.succeed(ExecutorService, executor),
@@ -58,8 +64,8 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 19 unmodified lines
58 64
59export const ProtectedApiApp = Effect.gen(function* () {65export const ProtectedApiApp = Effect.gen(function* () {
60 const request = yield* HttpServerRequest.HttpServerRequest;66 const request = yield* HttpServerRequest.HttpServerRequest;
61 const org = yield* lookupOrgForRequest(request);67 const session = yield* lookupOrgForRequest(request);
62 if (!org) {68 if (!session) {
63 return yield* Effect.fail(69 return yield* Effect.fail(
64 new HttpResponseError({70 new HttpResponseError({
65 status: 403,71 status: 403,
@@ -69,7 +75,7 @@ export const ProtectedApiApp = Effect.gen(function* () { · 3 unmodified lines
69 );75 );
70 }76 }
71 77
72 const app = yield* createProtectedApp(org.id, org.name);78 const app = yield* createProtectedApp(session.userId, session.org.id, session.org.name);
73 return yield* app;79 return yield* app;
74}).pipe(80}).pipe(
75 Effect.provide(SharedServices),81 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
6import { SessionContext } from "./middleware";6import { SessionContext } from "./middleware";
7import { UserStoreService } from "./context";7import { UserStoreService } from "./context";
8import { authorizeOrganization } from "./authorize-organization";8import { authorizeOrganization } from "./authorize-organization";
9import { env } from "cloudflare:workers";
9import { WorkOSError } from "./errors";10import { WorkOSError } from "./errors";
10import { WorkOSAuth } from "./workos";11import { WorkOSAuth } from "./workos";
11import { server } from "../env";
12 12
13const COOKIE_OPTIONS = {13const COOKIE_OPTIONS = {
14 path: "/",14 path: "/",
@@ -40,7 +40,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 25 unmodified lines
40 // Use the explicit public site URL — in dev, the request's Host40 // Use the explicit public site URL — in dev, the request's Host
41 // header points at the internal proxy target, not the public URL41 // header points at the internal proxy target, not the public URL
42 // WorkOS needs to redirect back to.42 // WorkOS needs to redirect back to.
43 const origin = server.VITE_PUBLIC_SITE_URL;43 const origin = env.VITE_PUBLIC_SITE_URL ?? "";
44 const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);44 const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);
45 return HttpServerResponse.redirect(url, { status: 302 });45 return HttpServerResponse.redirect(url, { status: 302 });
46 }),46 }),
apps/cloud/src/auth/workos.ts+4−4
@@ -2,10 +2,10 @@ · 1 unmodified line
2// WorkOS AuthKit — Effect-native sealed session management2// WorkOS AuthKit — Effect-native sealed session management
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";
5import { Context, Effect, Layer } from "effect";6import { Context, Effect, Layer } from "effect";
6import { WorkOS } from "@workos-inc/node/worker";7import { WorkOS } from "@workos-inc/node/worker";
7import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";8import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
8import { server } from "../env";
9 9
10const COOKIE_NAME = "wos-session";10const COOKIE_NAME = "wos-session";
11 11
@@ -14,9 +14,9 @@ const COOKIE_NAME = "wos-session"; · 2 unmodified lines
14// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
15 15
16const make = Effect.gen(function* () {16const make = Effect.gen(function* () {
17 const apiKey = server.WORKOS_API_KEY;17 const apiKey = env.WORKOS_API_KEY;
18 const clientId = server.WORKOS_CLIENT_ID;18 const clientId = env.WORKOS_CLIENT_ID;
19 const cookiePassword = server.WORKOS_COOKIE_PASSWORD;19 const cookiePassword = env.WORKOS_COOKIE_PASSWORD;
20 20
21 if (!cookiePassword || cookiePassword.length < 32) {21 if (!cookiePassword || cookiePassword.length < 32) {
22 return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters"));22 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.
5declare 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
33export {};
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
18import { env } from "cloudflare:workers";
19
20type SharedEnv = Readonly<{
21 NODE_ENV: "development" | "test" | "production";
22}>;
23
24type 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
42type WebEnv = Readonly<Record<string, never>>;
43
44const 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
62const SHARED_DEFAULTS: Record<keyof SharedEnv, string> = {
63 NODE_ENV: "development",
64};
65
66const 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
72const 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
78const read = (key: string): string | undefined =>
79 readFromWorkerEnv(key) ?? readFromProcessEnv(key);
80
81const 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
89export const shared: SharedEnv = makeEnvProxy<SharedEnv>(SHARED_DEFAULTS);
90export const server: ServerEnv = makeEnvProxy<ServerEnv>(SERVER_DEFAULTS);
91export 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
122 name: scopeName,122 name: scopeName,
123 createdAt: new Date(),123 createdAt: new Date(),
124 });124 });
125 return yield* createExecutor({ scope, adapter, blobs, plugins });125 return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins });
126 });126 });
127 127
128// Builds a scope, wires a real execution engine + MCP server, and yields128// 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
25import { CoreSharedServices } from "./api/core-shared-services";25import { CoreSharedServices } from "./api/core-shared-services";
26import { UserStoreService } from "./auth/context";26import { UserStoreService } from "./auth/context";
27import { resolveOrganization } from "./auth/resolve-organization";27import { resolveOrganization } from "./auth/resolve-organization";
28import { server } from "./env";
29import { DbService, combinedSchema } from "./services/db";28import { DbService, combinedSchema } from "./services/db";
30import { makeExecutionStack } from "./services/execution-stack";29import { makeExecutionStack } from "./services/execution-stack";
31import { DoTelemetryLive } from "./services/telemetry";30import { DoTelemetryLive } from "./services/telemetry";
@@ -36,6 +35,7 @@ import { DoTelemetryLive } from "./services/telemetry"; · 4 unmodified lines
36 35
37export type McpSessionInit = {36export type McpSessionInit = {
38 organizationId: string;37 organizationId: string;
38 userId: string;
39};39};
40 40
41export type IncomingTraceHeaders = {41export type IncomingTraceHeaders = {
@@ -109,6 +109,7 @@ type DbHandle = DbServiceShape & { end: () => Promise<void> }; · 67 unmodified lines
109type SessionMeta = {109type SessionMeta = {
110 readonly organizationId: string;110 readonly organizationId: string;
111 readonly organizationName: string;111 readonly organizationName: string;
112 readonly userId: string;
112};113};
113 114
114/**115/**
@@ -124,7 +125,12 @@ const makeDbHandle = (options: { · 9 unmodified lines
124 readonly idleTimeout: number;125 readonly idleTimeout: number;
125 readonly maxLifetime: number;126 readonly maxLifetime: number;
126}): DbHandle => {127}): 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 || "";
128 const sql = postgres(connectionString, {134 const sql = postgres(connectionString, {
129 max: 1,135 max: 1,
130 idle_timeout: options.idleTimeout,136 idle_timeout: options.idleTimeout,
@@ -159,6 +165,7 @@ const makeSessionServices = (dbHandle: DbHandle) => · 28 unmodified lines
159 165
160const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* (166const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* (
161 organizationId: string,167 organizationId: string,
168 userId: string,
162) {169) {
163 const org = yield* resolveOrganization(organizationId);170 const org = yield* resolveOrganization(organizationId);
164 if (!org) {171 if (!org) {
@@ -167,10 +174,11 @@ const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function · 2 unmodified lines
167 return {174 return {
168 organizationId: org.id,175 organizationId: org.id,
169 organizationName: org.name,176 organizationName: org.name,
177 userId,
170 } satisfies SessionMeta;178 } satisfies SessionMeta;
171 });179 });
172 180
173const requestScopedRuntimeEnabled = server.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true";181const requestScopedRuntimeEnabled = env.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true";
174 182
175// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
176// Durable Object184// Durable Object
@@ -238,6 +246,7 @@ export class McpSessionDO extends DurableObject { · 61 unmodified lines
238 const self = this;246 const self = this;
239 return Effect.gen(function* () {247 return Effect.gen(function* () {
240 const { executor, engine } = yield* makeExecutionStack(248 const { executor, engine } = yield* makeExecutionStack(
249 sessionMeta.userId,
241 sessionMeta.organizationId,250 sessionMeta.organizationId,
242 sessionMeta.organizationName,251 sessionMeta.organizationName,
243 );252 );
@@ -251,6 +260,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines
251 engine,260 engine,
252 description,261 description,
253 parentSpan: () => self.currentRequestSpan ?? undefined,262 parentSpan: () => self.currentRequestSpan ?? undefined,
263 debug: env.EXECUTOR_MCP_DEBUG === "true",
254 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));264 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
255 const transport = new WorkerTransport({265 const transport = new WorkerTransport({
256 sessionIdGenerator: () => self.ctx.id.toString(),266 sessionIdGenerator: () => self.ctx.id.toString(),
@@ -272,9 +282,10 @@ export class McpSessionDO extends DurableObject { · 15 unmodified lines
272 return Effect.gen(function* () {282 return Effect.gen(function* () {
273 const dbHandle = makeRequestScopedDb();283 const dbHandle = makeRequestScopedDb();
274 try {284 try {
275 const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe(285 const sessionMeta = yield* resolveSessionMeta(
276 Effect.provide(makeResolveOrganizationServices(dbHandle)),286 token.organizationId,
277 );287 token.userId,
288 ).pipe(Effect.provide(makeResolveOrganizationServices(dbHandle)));
278 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(289 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
279 Effect.withSpan("mcp.session.save_meta"),290 Effect.withSpan("mcp.session.save_meta"),
280 );291 );
apps/cloud/src/mcp.ts+5−4
@@ -20,15 +20,14 @@ import * as Sentry from "@sentry/cloudflare"; · 19 unmodified lines
20import { Context, Effect, Layer, Option, Schema } from "effect";20import { Context, Effect, Layer, Option, Schema } from "effect";
21import { createRemoteJWKSet, jwtVerify } from "jose";21import { createRemoteJWKSet, jwtVerify } from "jose";
22 22
23import { server } from "./env";
24import { TelemetryLive } from "./services/telemetry";23import { TelemetryLive } from "./services/telemetry";
25 24
26// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
27// Constants26// Constants
28// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
29 28
30const AUTHKIT_DOMAIN = server.MCP_AUTHKIT_DOMAIN;29const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh";
31const RESOURCE_ORIGIN = server.MCP_RESOURCE_ORIGIN;30const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh";
32 31
33const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`));32const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`));
34 33
@@ -526,7 +525,9 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 491 unmodified lines
526 const ns = env.MCP_SESSION;525 const ns = env.MCP_SESSION;
527 const stub = ns.get(ns.newUniqueId());526 const stub = ns.get(ns.newUniqueId());
528 const propagation = yield* currentPropagationHeaders(request);527 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 );
530 const propagated = withPropagationHeaders(request, propagation);531 const propagated = withPropagationHeaders(request, propagation);
531 const raw = yield* Effect.promise(532 const raw = yield* Effect.promise(
532 () => stub.handleRequest(propagated) as Promise<Response>,533 () => 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
21 captureException: (cause) =>21 captureException: (cause) =>
22 Effect.sync(() => {22 Effect.sync(() => {
23 const squashed = Cause.squash(cause);23 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));
24 const eventId = Sentry.captureException(squashed, (scope) => {27 const eventId = Sentry.captureException(squashed, (scope) => {
25 scope.setExtra("cause", Cause.pretty(cause));28 scope.setExtra("cause", Cause.pretty(cause));
26 return scope;29 return scope;
apps/cloud/src/org/handlers.ts+2−2
@@ -3,8 +3,8 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { UserStoreService } from "../auth/context";4import { UserStoreService } from "../auth/context";
5import { AuthContext } from "../auth/middleware";5import { AuthContext } from "../auth/middleware";
6import { env } from "cloudflare:workers";
6import { WorkOSAuth } from "../auth/workos";7import { WorkOSAuth } from "../auth/workos";
7import { server } from "../env";
8import { AutumnService } from "../services/autumn";8import { AutumnService } from "../services/autumn";
9import { OrgHttpApi } from "./compose";9import { OrgHttpApi } from "./compose";
10import { Forbidden } from "./api";10import { Forbidden } from "./api";
@@ -144,7 +144,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 133 unmodified lines
144 const workos = yield* WorkOSAuth;144 const workos = yield* WorkOSAuth;
145 const { link } = yield* workos.generateDomainVerificationPortalLink(145 const { link } = yield* workos.generateDomainVerificationPortalLink(
146 auth.organizationId,146 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",
148 );148 );
149 return { link };149 return { link };
150 }),150 }),
apps/cloud/src/server.ts+33−16
@@ -3,7 +3,6 @@ import handler from "@tanstack/react-start/server-entry"; · 2 unmodified lines
3import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers";3import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers";
4 4
5import { McpSessionDO as McpSessionDOBase } from "./mcp-session";5import { McpSessionDO as McpSessionDOBase } from "./mcp-session";
6import { server } from "./env";
7 6
8// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
9// OTEL config for the main fetch handler — `otel-cf-workers` owns the global8// OTEL config for the main fetch handler — `otel-cf-workers` owns the global
@@ -15,40 +14,36 @@ import { server } from "./env"; · 5 unmodified lines
15// DOMException "Illegal invocation".14// DOMException "Illegal invocation".
16// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
17 16
18const parseSampleRatio = (value: string): number => {17const parseSampleRatio = (value: string | undefined): number => {
19 const n = Number(value);18 const n = Number(value);
20 if (!Number.isFinite(n)) return 1;19 if (!Number.isFinite(n)) return 1;
21 return Math.min(1, Math.max(0, n));20 return Math.min(1, Math.max(0, n));
22};21};
23 22
24const otelConfig: TraceConfig = {23const otelConfig = (env: Env): TraceConfig => ({
25 service: { name: "executor-cloud", version: "1.0.0" },24 service: { name: "executor-cloud", version: "1.0.0" },
26 exporter: {25 exporter: {
27 url: server.AXIOM_TRACES_URL,26 url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
28 headers: {27 headers: {
29 Authorization: `Bearer ${server.AXIOM_TOKEN}`,28 Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`,
30 "X-Axiom-Dataset": server.AXIOM_DATASET,29 "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
31 },30 },
32 },31 },
33 sampling: {32 sampling: {
34 headSampler: {33 headSampler: {
35 // Keep remote parent decisions and make local sampling policy explicit.34 // Keep remote parent decisions and make local sampling policy explicit.
36 acceptRemote: true,35 acceptRemote: true,
37 ratio: parseSampleRatio(server.AXIOM_TRACES_SAMPLE_RATIO),36 ratio: parseSampleRatio(env.AXIOM_TRACES_SAMPLE_RATIO),
38 },37 },
39 },38 },
40};39});
41 40
42// otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim41// otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim
43// registers a ProxyTracerProvider of its own, which prevents otel-cf-workers42// registers a ProxyTracerProvider of its own, which prevents otel-cf-workers
44// from finding its WorkerTracer and breaks the whole request path with43// from finding its WorkerTracer and breaks the whole request path with
45// "global tracer is not of type WorkerTracer".44// "global tracer is not of type WorkerTracer".
46//45const sentryOptions = (env: Env) => ({
47// The `_env` parameter is unused — `server` from `./env` already gives us46 dsn: env.SENTRY_DSN,
48// typed access to every secret. It's only in the signature so Sentry's
49// generics infer the DO's `Env` type correctly.
50const sentryOptions = (_env: Env) => ({
51 dsn: server.SENTRY_DSN,
52 tracesSampleRate: 0,47 tracesSampleRate: 0,
53 enableLogs: true,48 enableLogs: true,
54 sendDefaultPii: true,49 sendDefaultPii: true,
@@ -76,6 +71,28 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( · 21 unmodified lines
76// Worker fetch handler71// Worker fetch handler
77// ---------------------------------------------------------------------------72// ---------------------------------------------------------------------------
78 73
79const 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.
84const rawFetch = handler.fetch;
85const instrumentedHandler = instrument({ fetch: rawFetch }, otelConfig);
86
87const 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};
80 97
81export default Sentry.withSentry(sentryOptions, instrumentedHandler);98export 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
181 name: scopeName,181 name: scopeName,
182 createdAt: new Date(),182 createdAt: new Date(),
183 });183 });
184 return yield* createExecutor({ scope, adapter, blobs, plugins });184 return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins });
185 });185 });
186 186
187// ---------------------------------------------------------------------------187// ---------------------------------------------------------------------------
apps/cloud/src/services/autumn.ts+2−3
@@ -2,12 +2,11 @@ · 1 unmodified line
2// Autumn billing service — wraps the autumn-js SDK with Effect2// Autumn billing service — wraps the autumn-js SDK with Effect
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";
5import * as Sentry from "@sentry/cloudflare";6import * as Sentry from "@sentry/cloudflare";
6import { Autumn } from "autumn-js";7import { Autumn } from "autumn-js";
7import { Context, Data, Effect, Layer } from "effect";8import { Context, Data, Effect, Layer } from "effect";
8 9
9import { server } from "../env";
10
11// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
12// Errors11// Errors
13// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
@@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines
36// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
37 36
38const make = Effect.sync(() => {37const make = Effect.sync(() => {
39 const secretKey = server.AUTUMN_SECRET_KEY;38 const secretKey = env.AUTUMN_SECRET_KEY;
40 39
41 if (!secretKey) {40 if (!secretKey) {
42 const notConfigured = Effect.die(41 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
18import postgres, { type Sql } from "postgres";18import postgres, { type Sql } from "postgres";
19import * as cloudSchema from "./schema";19import * as cloudSchema from "./schema";
20import * as executorSchema from "./executor-schema";20import * as executorSchema from "./executor-schema";
21import { server } from "../env";
22 21
23// Exported so every drizzle() call in the cloud app shares one schema22// Exported so every drizzle() call in the cloud app shares one schema
24// object. Historically `mcp-session.ts` built its own and forgot to spread23// object. Historically `mcp-session.ts` built its own and forgot to spread
@@ -38,10 +37,7 @@ const resolveConnectionString = () => { · 13 unmodified lines
38 // In local dev prefer an explicit DATABASE_URL (direct connection to37 // In local dev prefer an explicit DATABASE_URL (direct connection to
39 // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy.38 // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy.
40 // In production fall back to the Hyperdrive binding.39 // In production fall back to the Hyperdrive binding.
41 if (server.DATABASE_URL) {40 return env.DATABASE_URL || env.HYPERDRIVE?.connectionString || "";
42 return server.DATABASE_URL;
43 }
44 return env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL;
45};41};
46 42
47const makeSql = (): Sql =>43const makeSql = (): Sql =>
apps/cloud/src/services/execution-stack.ts+10−4
@@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines
14import { AutumnService } from "./autumn";14import { AutumnService } from "./autumn";
15import { createScopedExecutor } from "./executor";15import { createScopedExecutor } from "./executor";
16 16
17export const makeExecutionStack = (organizationId: string, organizationName: string) =>17export const makeExecutionStack = (
18 userId: string,
19 organizationId: string,
20 organizationName: string,
21) =>
18 Effect.gen(function* () {22 Effect.gen(function* () {
19 const executor = yield* createScopedExecutor(organizationId, organizationName).pipe(23 const executor = yield* createScopedExecutor(
20 Effect.withSpan("McpSessionDO.createScopedExecutor"),24 userId,
21 );25 organizationId,
26 organizationName,
27 ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor"));
22 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });28 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
23 const autumn = yield* AutumnService;29 const autumn = yield* AutumnService;
24 const engine = withExecutionUsageTracking(30 const engine = withExecutionUsageTracking(
apps/cloud/src/services/executor.ts+31−14
@@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines
25import { graphqlPlugin } from "@executor/plugin-graphql";25import { graphqlPlugin } from "@executor/plugin-graphql";
26import { workosVaultPlugin } from "@executor/plugin-workos-vault";26import { workosVaultPlugin } from "@executor/plugin-workos-vault";
27 27
28import { env } from "cloudflare:workers";
28import { DbService } from "./db";29import { DbService } from "./db";
29import { server } from "../env";
30 30
31// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
32// Plugin list — one place, used for both the runtime and the CLI config32// Plugin list — one place, used for both the runtime and the CLI config
@@ -45,24 +45,31 @@ const createOrgPlugins = () => · 12 unmodified lines
45 graphqlPlugin(),45 graphqlPlugin(),
46 workosVaultPlugin({46 workosVaultPlugin({
47 credentials: {47 credentials: {
48 apiKey: server.WORKOS_API_KEY,48 apiKey: env.WORKOS_API_KEY,
49 clientId: server.WORKOS_CLIENT_ID,49 clientId: env.WORKOS_CLIENT_ID,
50 },50 },
51 }),51 }),
52 ] as const;52 ] as const;
53 53
54// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
55// Create a fresh executor for a scope (stateless, per-request).55// Create a fresh executor for a (user, org) pair (stateless, per-request).
56//56//
57// Today "scope" is the WorkOS organization — orgs are the only scope57// Scope stack is `[userOrgScope, orgScope]` — innermost first. The
58// level the cloud app exposes. When workspace / user scopes land, this58// user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally
59// function grows to accept a `ScopeStack` instead of a single scope id,59// includes the org id so the same WorkOS user in a different org gets a
60// and nothing downstream (executor, plugins, storage) has to change.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.
61// ---------------------------------------------------------------------------67// ---------------------------------------------------------------------------
62 68
63export const createScopedExecutor = (69export const createScopedExecutor = (
64 scopeId: string,70 userId: string,
65 scopeName: string,71 organizationId: string,
72 organizationName: string,
66) =>73) =>
67 Effect.gen(function* () {74 Effect.gen(function* () {
68 const { db } = yield* DbService;75 const { db } = yield* DbService;
@@ -72,9 +79,14 @@ export const createScopedExecutor = ( · 3 unmodified lines
72 const adapter = makePostgresAdapter({ db, schema });79 const adapter = makePostgresAdapter({ db, schema });
73 const blobs = makePostgresBlobStore({ db });80 const blobs = makePostgresBlobStore({ db });
74 81
75 const scope = new Scope({82 const orgScope = new Scope({
76 id: ScopeId.make(scopeId),83 id: ScopeId.make(organizationId),
77 name: scopeName,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}`,
78 createdAt: new Date(),90 createdAt: new Date(),
79 });91 });
80 92
@@ -82,5 +94,10 @@ export const createScopedExecutor = ( · 1 unmodified line
82 // the opaque `InternalError({ traceId })` happens at the HTTP edge94 // the opaque `InternalError({ traceId })` happens at the HTTP edge
83 // via `withCapture` (see `api/protected-layers.ts`). That's95 // via `withCapture` (see `api/protected-layers.ts`). That's
84 // where `ErrorCaptureLive` (Sentry) gets wired in.96 // 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 });
86 });103 });
apps/cloud/src/services/telemetry.ts+5−6
@@ -53,10 +53,9 @@ import { · 52 unmodified lines
53 ATTR_SERVICE_NAME,53 ATTR_SERVICE_NAME,
54 ATTR_SERVICE_VERSION,54 ATTR_SERVICE_VERSION,
55} from "@opentelemetry/semantic-conventions";55} from "@opentelemetry/semantic-conventions";
56import { env } from "cloudflare:workers";
56import { Effect, Layer } from "effect";57import { Effect, Layer } from "effect";
57 58
58import { server } from "../env";
59
60const SERVICE_NAME = "executor-cloud";59const SERVICE_NAME = "executor-cloud";
61const SERVICE_VERSION = "1.0.0";60const SERVICE_VERSION = "1.0.0";
62 61
@@ -71,7 +70,7 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 8 unmodified lines
71let installed = false;70let installed = false;
72const ensureGlobalTracerProvider = (): boolean => {71const ensureGlobalTracerProvider = (): boolean => {
73 if (installed) return true;72 if (installed) return true;
74 if (!server.AXIOM_TOKEN) return false;73 if (!env.AXIOM_TOKEN) return false;
75 const provider = new WebTracerProvider({74 const provider = new WebTracerProvider({
76 resource: resourceFromAttributes({75 resource: resourceFromAttributes({
77 [ATTR_SERVICE_NAME]: SERVICE_NAME,76 [ATTR_SERVICE_NAME]: SERVICE_NAME,
@@ -80,10 +79,10 @@ const ensureGlobalTracerProvider = (): boolean => { · 2 unmodified lines
80 spanProcessors: [79 spanProcessors: [
81 new SimpleSpanProcessor(80 new SimpleSpanProcessor(
82 new OTLPTraceExporter({81 new OTLPTraceExporter({
83 url: server.AXIOM_TRACES_URL,82 url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
84 headers: {83 headers: {
85 Authorization: `Bearer ${server.AXIOM_TOKEN}`,84 Authorization: `Bearer ${env.AXIOM_TOKEN}`,
86 "X-Axiom-Dataset": server.AXIOM_DATASET,85 "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
87 },86 },
88 }),87 }),
89 ),88 ),
apps/cloud/vite.config.ts+29−1
@@ -1,12 +1,40 @@
1import { defineConfig } from "vite";1import { defineConfig, type Plugin } from "vite";
2import { cloudflare } from "@cloudflare/vite-plugin";2import { cloudflare } from "@cloudflare/vite-plugin";
3import { tanstackStart } from "@tanstack/react-start/plugin/vite";3import { tanstackStart } from "@tanstack/react-start/plugin/vite";
4import react from "@vitejs/plugin-react";4import react from "@vitejs/plugin-react";
5import tailwindcss from "@tailwindcss/vite";5import tailwindcss from "@tailwindcss/vite";
6 6
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.
12const 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
7export default defineConfig({34export default defineConfig({
8 resolve: { tsconfigPaths: true },35 resolve: { tsconfigPaths: true },
9 plugins: [36 plugins: [
37 devCrashGuard(),
10 tailwindcss(),38 tailwindcss(),
11 cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }),39 cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }),
12 tanstackStart(),40 tanstackStart(),
apps/local/src/server/config-sync.ts+9−0
@@ -84,10 +84,16 @@ const addSourceFromConfig = ( · 83 unmodified lines
84 executor: LocalExecutor,84 executor: LocalExecutor,
85 source: SourceConfig,85 source: SourceConfig,
86): Effect.Effect<void, unknown> => {86): 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;
87 switch (source.kind) {92 switch (source.kind) {
88 case "openapi":93 case "openapi":
89 return executor.openapi.addSpec({94 return executor.openapi.addSpec({
90 spec: source.spec,95 spec: source.spec,
96 scope,
91 baseUrl: source.baseUrl,97 baseUrl: source.baseUrl,
92 namespace: source.namespace,98 namespace: source.namespace,
93 headers: translateHeaders(source.headers),99 headers: translateHeaders(source.headers),
@@ -96,6 +102,7 @@ const addSourceFromConfig = ( · 2 unmodified lines
96 case "graphql":102 case "graphql":
97 return executor.graphql.addSource({103 return executor.graphql.addSource({
98 endpoint: source.endpoint,104 endpoint: source.endpoint,
105 scope,
99 namespace: source.namespace,106 namespace: source.namespace,
100 headers: translateHeaders(source.headers) as Record<string, string> | undefined,107 headers: translateHeaders(source.headers) as Record<string, string> | undefined,
101 }).pipe(Effect.asVoid);108 }).pipe(Effect.asVoid);
@@ -104,6 +111,7 @@ const addSourceFromConfig = ( · 2 unmodified lines
104 if (source.transport === "stdio") {111 if (source.transport === "stdio") {
105 return executor.mcp.addSource({112 return executor.mcp.addSource({
106 transport: "stdio",113 transport: "stdio",
114 scope,
107 name: source.name,115 name: source.name,
108 command: source.command,116 command: source.command,
109 args: source.args ? [...source.args] : undefined,117 args: source.args ? [...source.args] : undefined,
@@ -114,6 +122,7 @@ const addSourceFromConfig = ( · 4 unmodified lines
114 }122 }
115 return executor.mcp.addSource({123 return executor.mcp.addSource({
116 transport: "remote",124 transport: "remote",
125 scope,
117 name: source.name,126 name: source.name,
118 endpoint: source.endpoint,127 endpoint: source.endpoint,
119 remoteTransport: source.remoteTransport,128 remoteTransport: source.remoteTransport,
apps/local/src/server/executor.ts+1−1
@@ -154,7 +154,7 @@ const createLocalExecutorLayer = () => { · 153 unmodified lines
154 });154 });
155 155
156 const executor = yield* createExecutor({156 const executor = yield* createExecutor({
157 scope,157 scopes: [scope],
158 adapter,158 adapter,
159 blobs,159 blobs,
160 plugins,160 plugins,
apps/marketing/src/pages/api/detect.ts+2−0
@@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request }) => { · 82 unmodified lines
83 yield* executor.openapi.addSpec({83 yield* executor.openapi.addSpec({
84 spec: match.endpoint,84 spec: match.endpoint,
85 namespace: match.namespace,85 namespace: match.namespace,
86 scope: "test-scope",
86 });87 });
87 } else if (match.kind === "graphql") {88 } else if (match.kind === "graphql") {
88 yield* executor.graphql.addSource({89 yield* executor.graphql.addSource({
89 endpoint: match.endpoint,90 endpoint: match.endpoint,
90 namespace: match.namespace,91 namespace: match.namespace,
92 scope: "test-scope",
91 });93 });
92 } else {94 } else {
93 // For kinds we can't fully add (e.g. Google Discovery needs auth),95 // 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
83] as const;83] as const;
84 84
85const config = {85const config = {
86 scope,86 scopes: [scope],
87 adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }),87 adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }),
88 blobs: makeInMemoryBlobStore(),88 blobs: makeInMemoryBlobStore(),
89 plugins,89 plugins,
@@ -208,6 +208,7 @@ const program = Effect.gen(function* () { · 118 unmodified lines
208 yield* executor.secrets.set(208 yield* executor.secrets.set(
209 new SetSecretInput({209 new SetSecretInput({
210 id: SecretId.make("example-api-token"),210 id: SecretId.make("example-api-token"),
211 scope: "example-scope" as SetSecretInput["scope"],
211 name: "Example API Token",212 name: "Example API Token",
212 value: "sk-example-redacted",213 value: "sk-example-redacted",
213 provider: "file",214 provider: "file",
@@ -262,6 +263,7 @@ const program = Effect.gen(function* () { · 48 unmodified lines
262 spec: exampleOpenApiSpec,263 spec: exampleOpenApiSpec,
263 namespace: "example-api",264 namespace: "example-api",
264 baseUrl: "https://example.com/api",265 baseUrl: "https://example.com/api",
266 scope: "example-scope",
265 });267 });
266 console.log("Registered OpenAPI source:", addSpecResult);268 console.log("Registered OpenAPI source:", addSpecResult);
267 269
@@ -371,6 +373,7 @@ const program = Effect.gen(function* () { · 103 unmodified lines
371 endpoint: "https://example.com/graphql",373 endpoint: "https://example.com/graphql",
372 introspectionJson,374 introspectionJson,
373 namespace: "example-graphql",375 namespace: "example-graphql",
376 scope: "example-scope",
374 });377 });
375 console.log("Registered GraphQL source:", gqlResult);378 console.log("Registered GraphQL source:", gqlResult);
376 379
examples/promise-sdk/src/main.ts+5−1
@@ -12,7 +12,7 @@ import { graphqlPlugin } from "@executor/plugin-graphql/promise"; · 11 unmodified lines
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
14const executor = await createExecutor({14const executor = await createExecutor({
15 scope: { name: "my-app" },15 scopes: [{ id: "my-app", name: "my-app" }],
16 plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const,16 plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const,
17});17});
18 18
@@ -24,6 +24,7 @@ await executor.mcp.addSource({ · 5 unmodified lines
24 transport: "remote",24 transport: "remote",
25 name: "Context7",25 name: "Context7",
26 endpoint: "https://mcp.context7.com/mcp",26 endpoint: "https://mcp.context7.com/mcp",
27 scope: "my-app",
27});28});
28 29
29// Stdio server30// Stdio server
@@ -41,6 +42,7 @@ await executor.mcp.addSource({ · 11 unmodified lines
41await executor.openapi.addSpec({42await executor.openapi.addSpec({
42 spec: "https://petstore3.swagger.io/api/v3/openapi.json",43 spec: "https://petstore3.swagger.io/api/v3/openapi.json",
43 namespace: "petstore",44 namespace: "petstore",
45 scope: "my-app",
44});46});
45 47
46// With auth headers (static or secret-backed)48// With auth headers (static or secret-backed)
@@ -62,6 +64,7 @@ await executor.openapi.addSpec({ · 15 unmodified lines
62await executor.graphql.addSource({64await executor.graphql.addSource({
63 endpoint: "https://graphql.anilist.co",65 endpoint: "https://graphql.anilist.co",
64 namespace: "anilist",66 namespace: "anilist",
67 scope: "my-app",
65});68});
66 69
67// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
@@ -97,6 +100,7 @@ if (anilistTool) { · 29 unmodified lines
97await executor.secrets.set(100await executor.secrets.set(
98 new SetSecretInput({101 new SetSecretInput({
99 id: SecretId.make("api-key"),102 id: SecretId.make("api-key"),
103 scope: "my-app" as SetSecretInput["scope"],
100 name: "Shared API Key",104 name: "Shared API Key",
101 value: "sk_...",105 value: "sk_...",
102 }),106 }),
packages/hosts/mcp/src/server.ts+106−3
@@ -66,6 +66,10 @@ type SharedMcpServerConfig = { · 65 unmodified lines
66 * under whichever request triggered it; see the Cloud DO).66 * under whichever request triggered it; see the Cloud DO).
67 */67 */
68 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);68 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);
69 /**
70 * Enable verbose MCP capability / elicitation debug logging.
71 */
72 readonly debug?: boolean;
69};73};
70 74
71export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =75export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
@@ -85,9 +89,21 @@ const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean · 13 unmodified lines
85 return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };89 return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };
86};90};
87 91
92const 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
88const supportsManagedElicitation = (server: McpServer): boolean =>98const supportsManagedElicitation = (server: McpServer): boolean =>
89 getElicitationSupport(server).form;99 getElicitationSupport(server).form;
90 100
101const capabilitySnapshot = (server: McpServer) => ({
102 clientCapabilities: server.server.getClientCapabilities() ?? null,
103 elicitationSupport: getElicitationSupport(server),
104 managedElicitation: supportsManagedElicitation(server),
105});
106
91type ElicitInputParams =107type ElicitInputParams =
92 | {108 | {
93 mode?: "form";109 mode?: "form";
@@ -118,7 +134,10 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 24 unmodified lines
118 );134 );
119 135
120const makeMcpElicitationHandler =136const makeMcpElicitationHandler =
121 (server: McpServer): ElicitationHandler =>137 (
138 server: McpServer,
139 debugLog?: (event: string, data: Record<string, unknown>) => void,
140 ): ElicitationHandler =>
122 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {141 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
123 const { url: supportsUrl } = getElicitationSupport(server);142 const { url: supportsUrl } = getElicitationSupport(server);
124 143
@@ -133,19 +152,51 @@ const makeMcpElicitationHandler = · 8 unmodified lines
133 : elicitationRequestToParams(ctx.request);152 : elicitationRequestToParams(ctx.request);
134 153
135 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {154 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 });
136 try {166 try {
137 const response = await server.server.elicitInput(167 const response = await server.server.elicitInput(
138 params as Parameters<typeof server.server.elicitInput>[0],168 params as Parameters<typeof server.server.elicitInput>[0],
139 );169 );
140 170
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
141 return {180 return {
142 action: response.action,181 action: response.action,
143 content: response.content,182 content: response.content,
144 };183 };
145 } catch (err) {184 } 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 });
146 console.error(193 console.error(
147 "[executor] elicitInput failed — falling back to cancel.",194 "[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 }),
149 );200 );
150 return { action: "cancel" };201 return { action: "cancel" };
151 }202 }
@@ -205,6 +256,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 53 unmodified lines
205 // deferred past the outer Effect's await), so we use the runtime to256 // deferred past the outer Effect's await), so we use the runtime to
206 // re-enter Effect-land at each callback edge.257 // re-enter Effect-land at each callback edge.
207 const runtime = yield* Effect.runtime<never>();258 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 };
208 268
209 const resolveParentSpan = (): Tracer.AnySpan | undefined => {269 const resolveParentSpan = (): Tracer.AnySpan | undefined => {
210 const ps = config.parentSpan;270 const ps = config.parentSpan;
@@ -228,13 +288,27 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 17 unmodified lines
228 288
229 const executeCode = (code: string): Effect.Effect<McpToolResult, E> =>289 const executeCode = (code: string): Effect.Effect<McpToolResult, E> =>
230 Effect.gen(function* () {290 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 });
231 if (supportsManagedElicitation(server)) {297 if (supportsManagedElicitation(server)) {
232 const result = yield* engine.execute(code, {298 const result = yield* engine.execute(code, {
233 onElicitation: makeMcpElicitationHandler(server),299 onElicitation: makeMcpElicitationHandler(server, debugLog),
234 });300 });
235 return toMcpResult(formatExecuteResult(result));301 return toMcpResult(formatExecuteResult(result));
236 }302 }
237 const outcome = yield* engine.executeWithPause(code);303 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 });
238 return outcome.status === "completed"312 return outcome.status === "completed"
239 ? toMcpResult(formatExecuteResult(outcome.result))313 ? toMcpResult(formatExecuteResult(outcome.result))
240 : toMcpPausedResult(formatPausedExecution(outcome.execution));314 : toMcpPausedResult(formatPausedExecution(outcome.execution));
@@ -253,8 +327,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines
253 content: Record<string, unknown> | undefined,327 content: Record<string, unknown> | undefined,
254 ): Effect.Effect<McpToolResult, E> =>328 ): Effect.Effect<McpToolResult, E> =>
255 Effect.gen(function* () {329 Effect.gen(function* () {
330 debugLog("resume.call", {
331 executionId,
332 action,
333 hasContent: content !== undefined,
334 clientCapabilities: server.server.getClientCapabilities() ?? null,
335 });
256 const outcome = yield* engine.resume(executionId, { action, content });336 const outcome = yield* engine.resume(executionId, { action, content });
257 if (!outcome) {337 if (!outcome) {
338 debugLog("resume.missing_execution", { executionId });
258 return {339 return {
259 content: [340 content: [
260 { type: "text" as const, text: `No paused execution: ${executionId}` },341 { type: "text" as const, text: `No paused execution: ${executionId}` },
@@ -262,6 +343,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1 unmodified line
262 isError: true,343 isError: true,
263 } satisfies McpToolResult;344 } satisfies McpToolResult;
264 }345 }
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 });
265 return outcome.status === "completed"355 return outcome.status === "completed"
266 ? toMcpResult(formatExecuteResult(outcome.result))356 ? toMcpResult(formatExecuteResult(outcome.result))
267 : toMcpPausedResult(formatPausedExecution(outcome.execution));357 : toMcpPausedResult(formatPausedExecution(outcome.execution));
@@ -331,6 +421,19 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 63 unmodified lines
331 } else {421 } else {
332 resumeTool.enable();422 resumeTool.enable();
333 }423 }
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 });
334 };437 };
335 438
336 yield* Effect.sync(() => {439 yield* Effect.sync(() => {