Skip to content

Commit 577e713

Browse files
committed
Gate generated UI behind feature flag
1 parent fc948be commit 577e713

15 files changed

Lines changed: 221 additions & 10 deletions

File tree

apps/cli/src/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ import * as Option from "effect/Option";
5959
import * as Cause from "effect/Cause";
6060

6161
import { ExecutorApi } from "@executor-js/api";
62-
import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local";
62+
import {
63+
startServer,
64+
runMcpStdioServer,
65+
getExecutor,
66+
makeLocalEnvFeatureFlags,
67+
} from "@executor-js/local";
6368
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
6469
import { fetchIntegrations } from "./integrations";
6570
import {
@@ -778,6 +783,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode
778783
runMcpStdioServer({
779784
executor: web.executor,
780785
codeExecutor: makeQuickJsExecutor(),
786+
featureFlags: makeLocalEnvFeatureFlags(),
781787
renderUiFallbackUrl: (code) => {
782788
const url = new URL("/plugins/dynamic-ui/render", web.baseUrl);
783789
url.hash = `code=${encodeURIComponent(code)}`;

apps/cloud/src/api/core-shared-services.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@ import { Layer } from "effect";
1515

1616
import { WorkOSAuth } from "../auth/workos";
1717
import { AutumnService } from "../services/autumn";
18+
import { PostHogFeatureFlags } from "../services/feature-flags";
1819

1920
/**
2021
* Services that are independent of how the DB or tracer is provisioned —
2122
* both the stateless HTTP path (per-request DB via Hyperdrive) and the MCP
2223
* session DO (long-lived DB + isolate-local tracer SDK) merge this with
2324
* their own `DbLive` + `UserStoreLive` + telemetry layer.
2425
*/
25-
export const CoreSharedServices = Layer.mergeAll(WorkOSAuth.Default, AutumnService.Default);
26+
export const CoreSharedServices = Layer.mergeAll(
27+
WorkOSAuth.Default,
28+
AutumnService.Default,
29+
PostHogFeatureFlags,
30+
);

apps/cloud/src/env-augment.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare global {
1414
VITE_PUBLIC_SENTRY_DSN?: string;
1515
VITE_PUBLIC_POSTHOG_KEY?: string;
1616
VITE_PUBLIC_POSTHOG_HOST?: string;
17+
POSTHOG_HOST?: string;
1718

1819
// Datastore. Prod uses HYPERDRIVE when the binding exists; direct
1920
// DATABASE_URL is only selected when explicitly requested for local/test.

apps/cloud/src/mcp-session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,12 @@ export class McpSessionDO extends DurableObject {
371371
plugins,
372372
parentSpan: () => self.currentRequestSpan ?? undefined,
373373
debug: env.EXECUTOR_MCP_DEBUG === "true",
374+
featureFlagContext: {
375+
distinctId: sessionMeta.userId,
376+
accountId: sessionMeta.userId,
377+
organizationId: sessionMeta.organizationId,
378+
groups: { organization: sessionMeta.organizationId },
379+
},
374380
renderUiFallbackUrl: (code) => {
375381
const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh";
376382
const url = new URL("/plugins/dynamic-ui/render", origin);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { env } from "cloudflare:workers";
2+
import { Data, Effect, Layer, Option, Schema } from "effect";
3+
import {
4+
FeatureFlags,
5+
type FeatureFlagContext,
6+
type FeatureFlagsShape,
7+
} from "@executor-js/host-mcp";
8+
9+
class PostHogFeatureFlagError extends Data.TaggedError("PostHogFeatureFlagError")<{
10+
readonly cause: unknown;
11+
}> {}
12+
13+
const FeatureFlagsResponse = Schema.Struct({
14+
featureFlags: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
15+
});
16+
const decodeFeatureFlagsResponse = Schema.decodeUnknownOption(FeatureFlagsResponse);
17+
18+
const postHogHost = (): string => (env.POSTHOG_HOST ?? "https://us.i.posthog.com").replace(/\/$/, "");
19+
20+
const flagValueEnabled = (value: unknown): boolean =>
21+
value !== false && value !== null && value !== undefined;
22+
23+
const distinctIdFor = (context: FeatureFlagContext): string =>
24+
context.distinctId ?? context.accountId ?? context.organizationId ?? "executor-cloud";
25+
26+
const groupsFor = (context: FeatureFlagContext): Record<string, string> | undefined => {
27+
const groups = {
28+
...(context.groups ?? {}),
29+
...(context.organizationId ? { organization: context.organizationId } : {}),
30+
};
31+
return Object.keys(groups).length > 0 ? groups : undefined;
32+
};
33+
34+
export const makePostHogFeatureFlags = (): FeatureFlagsShape => ({
35+
isEnabled: (flag, context) =>
36+
Effect.gen(function* () {
37+
const apiKey = env.VITE_PUBLIC_POSTHOG_KEY;
38+
if (!apiKey) return false;
39+
40+
const response = yield* Effect.tryPromise({
41+
try: () =>
42+
fetch(`${postHogHost()}/decide/?v=3`, {
43+
method: "POST",
44+
headers: { "content-type": "application/json" },
45+
body: JSON.stringify({
46+
api_key: apiKey,
47+
distinct_id: distinctIdFor(context),
48+
groups: groupsFor(context),
49+
}),
50+
}),
51+
catch: (cause) => new PostHogFeatureFlagError({ cause }),
52+
});
53+
54+
if (!response.ok) return false;
55+
56+
const raw = yield* Effect.tryPromise({
57+
try: () => response.json(),
58+
catch: (cause) => new PostHogFeatureFlagError({ cause }),
59+
});
60+
const decoded = decodeFeatureFlagsResponse(raw);
61+
if (Option.isNone(decoded)) return false;
62+
return flagValueEnabled(decoded.value.featureFlags?.[flag]);
63+
}).pipe(Effect.withSpan("feature_flags.posthog.is_enabled", { attributes: { flag } })),
64+
});
65+
66+
export const PostHogFeatureFlags = Layer.succeed(FeatureFlags, makePostHogFeatureFlags());

apps/local/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export {
1313
type LocalExecutor,
1414
} from "./server/executor";
1515
export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./server/mcp";
16+
export { makeLocalEnvFeatureFlags, LocalEnvFeatureFlags } from "./server/feature-flags";
1617
export { startServer, type StartServerOptions, type ServerInstance } from "./serve";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Effect, Layer } from "effect";
2+
import {
3+
FEATURE_FLAG_GENERATED_UI_MCP_APPS,
4+
FeatureFlags,
5+
type FeatureFlagsShape,
6+
} from "@executor-js/host-mcp";
7+
8+
const truthy = (value: string | undefined): boolean =>
9+
value === "1" || value === "true" || value === "TRUE" || value === "yes" || value === "on";
10+
11+
const envNameForFlag = (flag: string): string =>
12+
`EXECUTOR_FEATURE_${flag.replaceAll(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase()}`;
13+
14+
const readFlag = (flag: string, env: NodeJS.ProcessEnv): boolean => {
15+
const generic = env[envNameForFlag(flag)];
16+
if (generic !== undefined) return truthy(generic);
17+
18+
if (flag === FEATURE_FLAG_GENERATED_UI_MCP_APPS) {
19+
return truthy(env.EXECUTOR_GENERATED_UI) || truthy(env.EXECUTOR_DYNAMIC_UI);
20+
}
21+
22+
return false;
23+
};
24+
25+
export const makeLocalEnvFeatureFlags = (
26+
env: NodeJS.ProcessEnv = process.env,
27+
): FeatureFlagsShape => ({
28+
isEnabled: (flag) => Effect.sync(() => readFlag(flag, env)),
29+
});
30+
31+
export const LocalEnvFeatureFlags = Layer.succeed(FeatureFlags, makeLocalEnvFeatureFlags());

apps/local/src/server/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
1515
import { getExecutorBundle } from "./executor";
1616
import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
1717
import { ErrorCaptureLive } from "./observability";
18+
import { makeLocalEnvFeatureFlags } from "./feature-flags";
1819

1920
// ---------------------------------------------------------------------------
2021
// Local server API.
@@ -98,7 +99,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => {
9899
dispose: api.dispose,
99100
};
100101

101-
const mcp = createMcpRequestHandler({ engine, plugins });
102+
const mcp = createMcpRequestHandler({ engine, plugins, featureFlags: makeLocalEnvFeatureFlags() });
102103

103104
return { api: apiHandler, mcp };
104105
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Context, Effect, Layer } from "effect";
2+
3+
export const FEATURE_FLAG_GENERATED_UI_MCP_APPS = "generated-ui-mcp-apps";
4+
5+
export type FeatureFlagContext = {
6+
readonly distinctId?: string;
7+
readonly accountId?: string;
8+
readonly organizationId?: string;
9+
readonly groups?: Record<string, string>;
10+
};
11+
12+
export type FeatureFlagsShape = {
13+
readonly isEnabled: (
14+
flag: string,
15+
context: FeatureFlagContext,
16+
) => Effect.Effect<boolean, unknown, never>;
17+
};
18+
19+
export class FeatureFlags extends Context.Service<FeatureFlags, FeatureFlagsShape>()(
20+
"@executor-js/host-mcp/FeatureFlags",
21+
) {
22+
static readonly Disabled: Layer.Layer<FeatureFlags> = Layer.succeed(FeatureFlags, {
23+
isEnabled: () => Effect.succeed(false),
24+
});
25+
26+
static readonly Enabled: Layer.Layer<FeatureFlags> = Layer.succeed(FeatureFlags, {
27+
isEnabled: () => Effect.succeed(true),
28+
});
29+
}

packages/hosts/mcp/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
export { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./server";
2+
export {
3+
FEATURE_FLAG_GENERATED_UI_MCP_APPS,
4+
FeatureFlags,
5+
type FeatureFlagContext,
6+
type FeatureFlagsShape,
7+
} from "./feature-flags";
28
export {
39
defineMcpContribution,
410
type McpDebugLog,

0 commit comments

Comments
 (0)