Skip to content

Commit 7ccfc42

Browse files
committed
Gate generated UI at MCP entry points
1 parent 6ed04a9 commit 7ccfc42

19 files changed

Lines changed: 217 additions & 223 deletions

File tree

apps/cli/src/main.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ import { ExecutorApi } from "@executor-js/api";
6262
import {
6363
startServer,
6464
runMcpStdioServer,
65-
getExecutor,
65+
getExecutorBundle,
66+
filterDynamicUiMcpPlugins,
67+
isGeneratedUiMcpAppsEnabled,
6668
makeLocalEnvFeatureFlags,
6769
} from "@executor-js/local";
6870
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
@@ -763,7 +765,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode
763765
const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);
764766

765767
try {
766-
const executor = await getExecutor();
768+
const executor = await getExecutorBundle();
767769
const server = await startServer({
768770
port,
769771
hostname: host,
@@ -779,11 +781,15 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode
779781
);
780782

781783
try {
784+
const featureFlags = makeLocalEnvFeatureFlags();
785+
const generatedUiMcpAppsEnabled = yield* isGeneratedUiMcpAppsEnabled(featureFlags);
786+
const mcpPlugins = filterDynamicUiMcpPlugins(web.executor.plugins, generatedUiMcpAppsEnabled);
787+
782788
yield* Effect.promise(() =>
783789
runMcpStdioServer({
784-
executor: web.executor,
790+
executor: web.executor.executor,
785791
codeExecutor: makeQuickJsExecutor(),
786-
featureFlags: makeLocalEnvFeatureFlags(),
792+
plugins: mcpPlugins,
787793
renderUiFallbackUrl: (code) => {
788794
const url = new URL("/plugins/dynamic-ui/render", web.baseUrl);
789795
url.hash = `code=${encodeURIComponent(code)}`;

apps/cloud/src/mcp-session.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
1313
import postgres, { type Sql } from "postgres";
1414

1515
import { createExecutorMcpServer } from "@executor-js/host-mcp";
16+
import { filterDynamicUiMcpPlugins } from "@executor-js/plugin-dynamic-ui";
1617
import {
1718
buildExecuteDescription,
1819
formatPausedExecution,
@@ -31,7 +32,7 @@ import { UserStoreService } from "./auth/context";
3132
import { resolveOrganization } from "./auth/resolve-organization";
3233
import { DbService, combinedSchema, resolveConnectionString } from "./services/db";
3334
import { makeExecutionStack } from "./services/execution-stack";
34-
import { PostHogFeatureFlags } from "./services/feature-flags";
35+
import { isGeneratedUiMcpAppsEnabled, PostHogFeatureFlags } from "./services/feature-flags";
3536
import { makeMcpWorkerTransport, type McpWorkerTransport } from "./services/mcp-worker-transport";
3637
import { DoTelemetryLive } from "./services/telemetry";
3738
import { captureCause } from "./observability";
@@ -355,11 +356,30 @@ export class McpSessionDO extends DurableObject {
355356
) {
356357
const self = this;
357358
return Effect.gen(function* () {
359+
const featureFlagContext = {
360+
distinctId: sessionMeta.userId,
361+
accountId: sessionMeta.userId,
362+
organizationId: sessionMeta.organizationId,
363+
groups: { organization: sessionMeta.organizationId },
364+
};
365+
const generatedUiMcpAppsEnabled = yield* isGeneratedUiMcpAppsEnabled(featureFlagContext).pipe(
366+
Effect.catch((error: unknown) =>
367+
Effect.sync(() => {
368+
console.error("[executor:mcp] generated UI feature flag failed", error);
369+
return false;
370+
}),
371+
),
372+
);
373+
yield* Effect.annotateCurrentSpan({
374+
"feature.generated_ui_mcp_apps.enabled": generatedUiMcpAppsEnabled,
375+
});
376+
358377
const { executor, engine, plugins } = yield* makeExecutionStack(
359378
sessionMeta.userId,
360379
sessionMeta.organizationId,
361380
sessionMeta.organizationName,
362381
);
382+
const mcpPlugins = filterDynamicUiMcpPlugins(plugins, generatedUiMcpAppsEnabled);
363383
// Build the description here so the postgres query it runs
364384
// (`executor.sources.list`) lands as a child of
365385
// `McpSessionDO.createRuntime`. host-mcp would otherwise call
@@ -370,15 +390,9 @@ export class McpSessionDO extends DurableObject {
370390
const mcpServer = yield* createExecutorMcpServer({
371391
engine,
372392
description,
373-
plugins,
393+
plugins: mcpPlugins,
374394
parentSpan: () => self.currentRequestSpan ?? undefined,
375395
debug: env.EXECUTOR_MCP_DEBUG === "true",
376-
featureFlagContext: {
377-
distinctId: sessionMeta.userId,
378-
accountId: sessionMeta.userId,
379-
organizationId: sessionMeta.organizationId,
380-
groups: { organization: sessionMeta.organizationId },
381-
},
382396
renderUiFallbackUrl: (code) => {
383397
const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh";
384398
const url = new URL("/plugins/dynamic-ui/render", origin);

apps/cloud/src/services/feature-flags.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
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";
2+
import { Context, Data, Effect, Layer, Option, Schema } from "effect";
3+
4+
import { DYNAMIC_UI_MCP_APPS_FEATURE_FLAG } from "@executor-js/plugin-dynamic-ui";
85

96
class PostHogFeatureFlagError extends Data.TaggedError("PostHogFeatureFlagError")<{
107
readonly cause: unknown;
@@ -15,7 +12,26 @@ const FeatureFlagsResponse = Schema.Struct({
1512
});
1613
const decodeFeatureFlagsResponse = Schema.decodeUnknownOption(FeatureFlagsResponse);
1714

18-
const postHogHost = (): string => (env.POSTHOG_HOST ?? "https://us.i.posthog.com").replace(/\/$/, "");
15+
export type FeatureFlagContext = {
16+
readonly distinctId?: string;
17+
readonly accountId?: string;
18+
readonly organizationId?: string;
19+
readonly groups?: Record<string, string>;
20+
};
21+
22+
export type FeatureFlagsShape = {
23+
readonly isEnabled: (
24+
flag: string,
25+
context: FeatureFlagContext,
26+
) => Effect.Effect<boolean, PostHogFeatureFlagError>;
27+
};
28+
29+
export class CloudFeatureFlags extends Context.Service<CloudFeatureFlags, FeatureFlagsShape>()(
30+
"executor.cloud/FeatureFlags",
31+
) {}
32+
33+
const postHogHost = (): string =>
34+
(env.POSTHOG_HOST ?? "https://us.i.posthog.com").replace(/\/$/, "");
1935

2036
const flagValueEnabled = (value: unknown): boolean =>
2137
value !== false && value !== null && value !== undefined;
@@ -63,4 +79,11 @@ export const makePostHogFeatureFlags = (): FeatureFlagsShape => ({
6379
}).pipe(Effect.withSpan("feature_flags.posthog.is_enabled", { attributes: { flag } })),
6480
});
6581

66-
export const PostHogFeatureFlags = Layer.succeed(FeatureFlags, makePostHogFeatureFlags());
82+
export const isGeneratedUiMcpAppsEnabled = (context: FeatureFlagContext) =>
83+
CloudFeatureFlags.asEffect().pipe(
84+
Effect.flatMap((featureFlags) =>
85+
featureFlags.isEnabled(DYNAMIC_UI_MCP_APPS_FEATURE_FLAG, context),
86+
),
87+
);
88+
89+
export const PostHogFeatureFlags = Layer.succeed(CloudFeatureFlags, makePostHogFeatureFlags());

apps/local/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ export {
88
createExecutorHandle,
99
disposeExecutor,
1010
getExecutor,
11+
getExecutorBundle,
1112
reloadExecutor,
1213
type ExecutorHandle,
1314
type LocalExecutor,
1415
} from "./server/executor";
1516
export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./server/mcp";
16-
export { makeLocalEnvFeatureFlags, LocalEnvFeatureFlags } from "./server/feature-flags";
17+
export {
18+
isGeneratedUiMcpAppsEnabled,
19+
makeLocalEnvFeatureFlags,
20+
LocalEnvFeatureFlags,
21+
} from "./server/feature-flags";
22+
export { filterDynamicUiMcpPlugins } from "@executor-js/plugin-dynamic-ui";
1723
export { startServer, type StartServerOptions, type ServerInstance } from "./serve";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import { isGeneratedUiMcpAppsEnabled, makeLocalEnvFeatureFlags } from "./feature-flags";
5+
6+
describe("local feature flags", () => {
7+
it.effect("keeps generated UI MCP apps disabled by default", () =>
8+
Effect.gen(function* () {
9+
const enabled = yield* isGeneratedUiMcpAppsEnabled(makeLocalEnvFeatureFlags({}));
10+
11+
expect(enabled).toBe(false);
12+
}),
13+
);
14+
15+
it.effect("enables generated UI MCP apps from the canonical env flag", () =>
16+
Effect.gen(function* () {
17+
const enabled = yield* isGeneratedUiMcpAppsEnabled(
18+
makeLocalEnvFeatureFlags({ EXECUTOR_FEATURE_GENERATED_UI_MCP_APPS: "true" }),
19+
);
20+
21+
expect(enabled).toBe(true);
22+
}),
23+
);
24+
25+
it.effect("ignores unrelated env variables", () =>
26+
Effect.gen(function* () {
27+
const enabled = yield* isGeneratedUiMcpAppsEnabled(
28+
makeLocalEnvFeatureFlags({ EXECUTOR_DYNAMIC_UI: "1" }),
29+
);
30+
31+
expect(enabled).toBe(false);
32+
}),
33+
);
34+
});
Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
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";
1+
import { Context, Effect, Layer } from "effect";
2+
3+
import { DYNAMIC_UI_MCP_APPS_FEATURE_FLAG } from "@executor-js/plugin-dynamic-ui";
4+
5+
export type FeatureFlagContext = Record<string, never>;
6+
7+
export type FeatureFlagsShape = {
8+
readonly isEnabled: (flag: string, context: FeatureFlagContext) => Effect.Effect<boolean, never>;
9+
};
10+
11+
export class LocalFeatureFlags extends Context.Service<LocalFeatureFlags, FeatureFlagsShape>()(
12+
"@executor-js/local/FeatureFlags",
13+
) {}
714

815
const truthy = (value: string | undefined): boolean =>
916
value === "1" || value === "true" || value === "TRUE" || value === "yes" || value === "on";
1017

1118
const envNameForFlag = (flag: string): string =>
12-
`EXECUTOR_FEATURE_${flag.replaceAll(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase()}`;
19+
`EXECUTOR_FEATURE_${flag
20+
.replaceAll(/[^A-Za-z0-9]+/g, "_")
21+
.replace(/^_+|_+$/g, "")
22+
.toUpperCase()}`;
1323

1424
const readFlag = (flag: string, env: NodeJS.ProcessEnv): boolean => {
1525
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;
26+
return generic !== undefined ? truthy(generic) : false;
2327
};
2428

2529
export const makeLocalEnvFeatureFlags = (
@@ -28,4 +32,7 @@ export const makeLocalEnvFeatureFlags = (
2832
isEnabled: (flag) => Effect.sync(() => readFlag(flag, env)),
2933
});
3034

31-
export const LocalEnvFeatureFlags = Layer.succeed(FeatureFlags, makeLocalEnvFeatureFlags());
35+
export const isGeneratedUiMcpAppsEnabled = (featureFlags: FeatureFlagsShape) =>
36+
featureFlags.isEnabled(DYNAMIC_UI_MCP_APPS_FEATURE_FLAG, {});
37+
38+
export const LocalEnvFeatureFlags = Layer.succeed(LocalFeatureFlags, makeLocalEnvFeatureFlags());

apps/local/src/server/main.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
} from "@executor-js/api/server";
1313
import { createExecutionEngine } from "@executor-js/execution";
1414
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
15+
import { filterDynamicUiMcpPlugins } from "@executor-js/plugin-dynamic-ui";
1516
import { getExecutorBundle } from "./executor";
1617
import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
1718
import { ErrorCaptureLive } from "./observability";
18-
import { makeLocalEnvFeatureFlags } from "./feature-flags";
19+
import { isGeneratedUiMcpAppsEnabled, makeLocalEnvFeatureFlags } from "./feature-flags";
1920

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

102-
const mcp = createMcpRequestHandler({ engine, plugins, featureFlags: makeLocalEnvFeatureFlags() });
103+
const featureFlags = makeLocalEnvFeatureFlags();
104+
const generatedUiMcpAppsEnabled = await Effect.runPromise(
105+
isGeneratedUiMcpAppsEnabled(featureFlags),
106+
);
107+
const mcpPlugins = filterDynamicUiMcpPlugins(plugins, generatedUiMcpAppsEnabled);
108+
const mcp = createMcpRequestHandler({ engine, plugins: mcpPlugins });
103109

104110
return { api: apiHandler, mcp };
105111
};

apps/local/src/server/mcp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq
169169
createExecutorMcpServer({
170170
...config,
171171
renderUiFallbackUrl:
172-
config.renderUiFallbackUrl ?? ((code) => renderUiFallbackUrlForRequest(request, code)),
172+
config.renderUiFallbackUrl ??
173+
((code) => renderUiFallbackUrlForRequest(request, code)),
173174
browserApprovalStore: {
174175
takeResponse: (executionId) =>
175176
Effect.sync(() => {

packages/core/execution/src/description.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ describe("buildExecuteDescription", () => {
6767

6868
// Stable anchor from the workflow preamble.
6969
expect(description).toContain("Execute TypeScript in a sandboxed runtime");
70+
expect(description).not.toContain("## Generative UI");
71+
expect(description).not.toContain("render-ui");
7072
// The namespaces section header.
7173
expect(description).toContain("## Available namespaces");
7274
// Each source renders with its ACTUAL id, without display labels or plugin ids.
@@ -93,6 +95,7 @@ describe("buildExecuteDescription", () => {
9395
const description = yield* buildExecuteDescription(executor);
9496

9597
expect(description).toContain("Execute TypeScript in a sandboxed runtime");
98+
expect(description).not.toContain("## Generative UI");
9699
expect(description).not.toContain("## Available namespaces");
97100
}),
98101
);

packages/core/execution/src/description.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -58,32 +58,6 @@ const formatDescription = (sources: readonly Source[]): string => {
5858
"- Do not use `fetch` — all API calls go through `tools.*`.",
5959
"- If execution pauses for interaction, resume it with the returned `resumePayload`.",
6060
"- TypeScript type syntax (`: T`, `as T`, generics, interfaces, type aliases) is stripped before execution — feel free to write idiomatic TypeScript using the shapes from `tools.describe.tool()`. Decorators and `enum` are not supported.",
61-
"",
62-
"## Generative UI",
63-
"",
64-
"When it would be helpful to show an interactive UI, write a React component named `App` with JSX in the `code` parameter. It renders in an iframe alongside the conversation.",
65-
"",
66-
"**No imports** — everything is already in scope:",
67-
"- React: `useState`, `useEffect`, `useRef`, `useCallback`, `useMemo`",
68-
"- TanStack Query v5: `useQuery`, `useMutation`, `useQueryClient`, `queryOptions`, `mutationOptions`, `skipToken`; the component is already wrapped in `QueryClientProvider`.",
69-
"- Do not redeclare or destructure provided globals. Do not write `const { useState } = React`; use `useState(...)` directly or `React.useState(...)`.",
70-
"- Fetch live data with TanStack options from the tool proxy: `useQuery(tools.<namespace>.<tool>.queryOptions(args))`. Do not call tools before generating the UI and paste returned data into JSX.",
71-
"- For user-triggered writes or actions, use `useMutation(tools.<namespace>.<tool>.mutationOptions({ onSuccess }))` and call `mutate(input)` from event handlers.",
72-
"- Invalidate or refetch reads with `useQueryClient()` and stable keys from `tools.<namespace>.<tool>.queryKey(args)`.",
73-
"- Use the discovered output shape exactly. Do not invent wrapper fields like `data.domain` or `data.items` unless the schema/sample shows them.",
74-
"- For toggles and switches, mutate with the checked value from the event instead of inverting possibly stale query data.",
75-
"- For optimistic writes, use TanStack `onMutate` / `onError` / `onSettled`: cancel the query, snapshot old data, `setQueryData`, roll back on error, then invalidate.",
76-
"- Only hardcode small display constants like labels, colors, tab names, and chart configuration. Never embed tool response rows, API results, summaries, or dashboard data as literals in the component.",
77-
"- Always render loading and error states from `useQuery` / `useMutation`; do not replace them with hardcoded fallback data.",
78-
"- Tools: `tools.<namespace>.<tool>(args)` — call any configured API tool (never use raw `fetch`). Tool helpers: `.queryOptions(args, options)`, `.mutationOptions(options)`, `.queryKey(args)`, `.pathKey()`, and `.mutationKey()`.",
79-
"- shadcn/ui components available by name: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button, Input, Textarea, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Switch, Slider, Toggle, Tabs, TabsList, TabsTrigger, TabsContent, Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Badge, Avatar, AvatarFallback, Alert, AlertTitle, AlertDescription, Dialog, Sheet, Popover, Tooltip, Separator, ScrollArea, Skeleton, Progress, Accordion, AccordionItem, AccordionTrigger, AccordionContent, DropdownMenu + sub-components",
80-
"- Charts (Recharts): BarChart, Bar, LineChart, Line, AreaChart, Area, PieChart, Pie, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Legend, ChartContainer, ChartTooltip, ChartTooltipContent",
81-
"- Icons (Lucide): Plus, Minus, Check, X, Search, Loader2, AlertCircle, ExternalLink, Copy, Trash2, Edit, Settings, User, Globe, Star, TrendingUp, Activity, Database, Shield, Package, and more",
82-
"- Utility: `cn()` for className merging, `run(code)` escape hatch for multi-step tool composition",
83-
"- Use Tailwind classes for styling. The UI must look good in both light and dark mode — the user's system theme is applied automatically.",
84-
"- Always use `dark:` variants when applying custom colors: e.g. `bg-white dark:bg-gray-900`, `text-gray-900 dark:text-gray-100`. Or prefer theme variables that adapt automatically: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-muted`, `text-muted-foreground`, `bg-primary`, `text-primary-foreground`, `bg-secondary`, `text-secondary-foreground`, `bg-accent`, `text-accent-foreground`, `bg-destructive`, `border-border`, `ring-ring`.",
85-
"- Never use hardcoded colors without a `dark:` counterpart — e.g. `bg-gray-50` alone will look wrong in dark mode.",
86-
"- The UI container defaults to `maxHeight: 800` (pixels). Override by declaring `const config = { maxHeight: 400 }` for small widgets or `const config = { maxHeight: 1000 }` for large lists/tables.",
8761
];
8862

8963
if (sources.length > 0) {

0 commit comments

Comments
 (0)