Skip to content

Commit fc948be

Browse files
committed
Add generated UI browser fallback
1 parent efd16a0 commit fc948be

18 files changed

Lines changed: 973 additions & 630 deletions

File tree

apps/cli/src/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,11 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode
778778
runMcpStdioServer({
779779
executor: web.executor,
780780
codeExecutor: makeQuickJsExecutor(),
781+
renderUiFallbackUrl: (code) => {
782+
const url = new URL("/plugins/dynamic-ui/render", web.baseUrl);
783+
url.hash = `code=${encodeURIComponent(code)}`;
784+
return url.toString();
785+
},
781786
elicitationMode:
782787
input.elicitationMode === "browser"
783788
? {

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+
renderUiFallbackUrl: (code) => {
375+
const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh";
376+
const url = new URL("/plugins/dynamic-ui/render", origin);
377+
url.hash = `code=${encodeURIComponent(code)}`;
378+
return url.toString();
379+
},
374380
browserApprovalStore: {
375381
takeResponse: (executionId) => self.takeApprovalResponse(executionId),
376382
waitForResponse: (executionId) => self.waitForApprovalResponse(executionId),

apps/cloud/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespac
2323
import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId'
2424
import { Route as BillingPlansRouteImport } from './routes/billing_.plans'
2525
import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'
26+
import { Route as PluginsPluginIdSplatRouteImport } from './routes/plugins.$pluginId.$'
2627

2728
const ToolsRoute = ToolsRouteImport.update({
2829
id: '/tools',
@@ -94,6 +95,11 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
9495
path: '/sources/add/$pluginKey',
9596
getParentRoute: () => rootRouteImport,
9697
} as any)
98+
const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({
99+
id: '/plugins/$pluginId/$',
100+
path: '/plugins/$pluginId/$',
101+
getParentRoute: () => rootRouteImport,
102+
} as any)
97103

98104
export interface FileRoutesByFullPath {
99105
'/': typeof IndexRoute
@@ -109,6 +115,7 @@ export interface FileRoutesByFullPath {
109115
'/billing/plans': typeof BillingPlansRoute
110116
'/resume/$executionId': typeof ResumeExecutionIdRoute
111117
'/sources/$namespace': typeof SourcesNamespaceRoute
118+
'/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute
112119
'/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
113120
}
114121
export interface FileRoutesByTo {
@@ -125,6 +132,7 @@ export interface FileRoutesByTo {
125132
'/billing/plans': typeof BillingPlansRoute
126133
'/resume/$executionId': typeof ResumeExecutionIdRoute
127134
'/sources/$namespace': typeof SourcesNamespaceRoute
135+
'/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute
128136
'/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
129137
}
130138
export interface FileRoutesById {
@@ -142,6 +150,7 @@ export interface FileRoutesById {
142150
'/billing_/plans': typeof BillingPlansRoute
143151
'/resume/$executionId': typeof ResumeExecutionIdRoute
144152
'/sources/$namespace': typeof SourcesNamespaceRoute
153+
'/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute
145154
'/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
146155
}
147156
export interface FileRouteTypes {
@@ -160,6 +169,7 @@ export interface FileRouteTypes {
160169
| '/billing/plans'
161170
| '/resume/$executionId'
162171
| '/sources/$namespace'
172+
| '/plugins/$pluginId/$'
163173
| '/sources/add/$pluginKey'
164174
fileRoutesByTo: FileRoutesByTo
165175
to:
@@ -176,6 +186,7 @@ export interface FileRouteTypes {
176186
| '/billing/plans'
177187
| '/resume/$executionId'
178188
| '/sources/$namespace'
189+
| '/plugins/$pluginId/$'
179190
| '/sources/add/$pluginKey'
180191
id:
181192
| '__root__'
@@ -192,6 +203,7 @@ export interface FileRouteTypes {
192203
| '/billing_/plans'
193204
| '/resume/$executionId'
194205
| '/sources/$namespace'
206+
| '/plugins/$pluginId/$'
195207
| '/sources/add/$pluginKey'
196208
fileRoutesById: FileRoutesById
197209
}
@@ -209,6 +221,7 @@ export interface RootRouteChildren {
209221
BillingPlansRoute: typeof BillingPlansRoute
210222
ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute
211223
SourcesNamespaceRoute: typeof SourcesNamespaceRoute
224+
PluginsPluginIdSplatRoute: typeof PluginsPluginIdSplatRoute
212225
SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
213226
}
214227

@@ -291,6 +304,13 @@ declare module '@tanstack/react-router' {
291304
preLoaderRoute: typeof SourcesNamespaceRouteImport
292305
parentRoute: typeof rootRouteImport
293306
}
307+
'/plugins/$pluginId/$': {
308+
id: '/plugins/$pluginId/$'
309+
path: '/plugins/$pluginId/$'
310+
fullPath: '/plugins/$pluginId/$'
311+
preLoaderRoute: typeof PluginsPluginIdSplatRouteImport
312+
parentRoute: typeof rootRouteImport
313+
}
294314
'/resume/$executionId': {
295315
id: '/resume/$executionId'
296316
path: '/resume/$executionId'
@@ -329,6 +349,7 @@ const rootRouteChildren: RootRouteChildren = {
329349
BillingPlansRoute: BillingPlansRoute,
330350
ResumeExecutionIdRoute: ResumeExecutionIdRoute,
331351
SourcesNamespaceRoute: SourcesNamespaceRoute,
352+
PluginsPluginIdSplatRoute: PluginsPluginIdSplatRoute,
332353
SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
333354
}
334355
export const routeTree = rootRouteImport
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createFileRoute, notFound } from "@tanstack/react-router";
2+
import { useClientPlugins } from "@executor-js/sdk/client";
3+
4+
export const Route = createFileRoute("/plugins/$pluginId/$")({
5+
component: PluginRouteComponent,
6+
});
7+
8+
function normalizePath(input: string): string {
9+
if (!input || input === "/") return "/";
10+
return input.startsWith("/") ? input : `/${input}`;
11+
}
12+
13+
function PluginRouteComponent() {
14+
const { pluginId, _splat: rest } = Route.useParams();
15+
const plugins = useClientPlugins();
16+
const plugin = plugins.find((p) => p.id === pluginId);
17+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
18+
if (!plugin) throw notFound();
19+
20+
const target = normalizePath(rest ?? "/");
21+
const page = plugin.pages?.find((p) => normalizePath(p.path) === target);
22+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
23+
if (!page) throw notFound();
24+
25+
const Component = page.component;
26+
return <Component />;
27+
}

apps/local/src/server/mcp.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ const approvalUrlForRequest = (
5959
return url.toString();
6060
};
6161

62+
const renderUiFallbackUrlForRequest = (request: Request, code: string): string => {
63+
const url = new URL("/plugins/dynamic-ui/render", new URL(request.url).origin);
64+
url.hash = `code=${encodeURIComponent(code)}`;
65+
return url.toString();
66+
};
67+
6268
const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
6369
close
6470
? Effect.runPromise(
@@ -162,6 +168,8 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq
162168
created = await Effect.runPromise(
163169
createExecutorMcpServer({
164170
...config,
171+
renderUiFallbackUrl:
172+
config.renderUiFallbackUrl ?? ((code) => renderUiFallbackUrlForRequest(request, code)),
165173
browserApprovalStore: {
166174
takeResponse: (executionId) =>
167175
Effect.sync(() => {

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/vite-plugin/src/index.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import type { ExecutorCliConfig } from "@executor-js/sdk";
3232

3333
const VIRTUAL_ID = "virtual:executor/plugins-client";
3434
const RESOLVED_ID = `\0${VIRTUAL_ID}`;
35+
const INNER_RENDERER_ID = "virtual:executor-inner-renderer";
36+
const RESOLVED_INNER_RENDERER_ID = `\0${INNER_RENDERER_ID}`;
3537

3638
const DEFAULT_CONFIG_CANDIDATES = [
3739
"executor.config.ts",
@@ -65,6 +67,35 @@ const tryResolveClient = (packageName: string, fromDir: string): string | null =
6567
}
6668
};
6769

70+
const tryResolveDynamicUiInnerRenderer = (fromDir: string): string | null => {
71+
const require = createRequire(resolvePath(fromDir, "_anchor.js"));
72+
try {
73+
const clientEntry = require.resolve("@executor-js/plugin-dynamic-ui/client");
74+
return resolvePath(dirname(clientEntry), "shell/inner-renderer.tsx");
75+
} catch {
76+
return null;
77+
}
78+
};
79+
80+
type EsbuildApi = {
81+
readonly build: (options: {
82+
readonly entryPoints: readonly string[];
83+
readonly absWorkingDir: string;
84+
readonly bundle: boolean;
85+
readonly write: boolean;
86+
readonly format: "iife";
87+
readonly platform: "browser";
88+
readonly target: "es2022";
89+
readonly jsx: "automatic";
90+
readonly define: Record<string, string>;
91+
}) => Promise<{ readonly outputFiles: readonly { readonly text: string }[] }>;
92+
};
93+
94+
const loadEsbuild = (anchor: string): EsbuildApi => {
95+
const require = createRequire(anchor);
96+
return require("esbuild") as EsbuildApi;
97+
};
98+
6899
interface ExecutorVitePluginOptions {
69100
/**
70101
* Path to the executor config file. Resolved relative to the Vite
@@ -218,10 +249,39 @@ export default function executorVitePlugin(options: ExecutorVitePluginOptions =
218249
projectRoot = config.root;
219250
},
220251
resolveId(id) {
252+
if (id === INNER_RENDERER_ID) return RESOLVED_INNER_RENDERER_ID;
221253
if (id === VIRTUAL_ID) return RESOLVED_ID;
222254
return undefined;
223255
},
224256
async load(id) {
257+
if (id === RESOLVED_INNER_RENDERER_ID) {
258+
const configPath = resolveConfigPath();
259+
const fromDir = configPath ? dirname(configPath) : projectRoot;
260+
const entryPoint = tryResolveDynamicUiInnerRenderer(fromDir);
261+
if (!entryPoint) {
262+
throw new Error(
263+
"virtual:executor-inner-renderer was requested but @executor-js/plugin-dynamic-ui could not be resolved.",
264+
);
265+
}
266+
267+
const result = await loadEsbuild(entryPoint).build({
268+
entryPoints: [entryPoint],
269+
absWorkingDir: dirname(entryPoint),
270+
bundle: true,
271+
write: false,
272+
format: "iife",
273+
platform: "browser",
274+
target: "es2022",
275+
jsx: "automatic",
276+
define: {
277+
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
278+
},
279+
});
280+
281+
const js = result.outputFiles[0];
282+
if (!js) throw new Error("Failed to bundle Executor inner renderer.");
283+
return `export default ${JSON.stringify(js.text)};`;
284+
}
225285
if (id !== RESOLVED_ID) return undefined;
226286
return loadVirtualSource();
227287
},

packages/hosts/mcp/src/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type McpPluginRegisterContext<E extends Cause.YieldableError = Cause.Yiel
2525
readonly debugLog: McpDebugLog;
2626
readonly runToolEffect: McpRunToolEffect;
2727
readonly executeCodeFromApp: (code: string) => Effect.Effect<McpToolResult, E>;
28+
readonly renderUiFallbackUrl?: (code: string) => string;
2829
readonly resumeExecution: (
2930
executionId: string,
3031
action: "accept" | "decline" | "cancel",

packages/hosts/mcp/src/server.test.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const withClient = async <E extends Cause.YieldableError>(
4545
fn: (client: Client) => Promise<void>,
4646
config?: Pick<
4747
ExecutorMcpServerConfig<E>,
48-
"debug" | "elicitationMode" | "browserApprovalStore" | "plugins"
48+
"debug" | "elicitationMode" | "browserApprovalStore" | "plugins" | "renderUiFallbackUrl"
4949
>,
5050
) => {
5151
const mcpServer = await Effect.runPromise(createExecutorMcpServer({ engine, ...config }));
@@ -169,18 +169,31 @@ describe("MCP host server — native elicitation mode", () => {
169169
});
170170
});
171171

172-
it("does not expose dynamic UI app tools to clients without MCP Apps support", async () => {
172+
it("exposes render-ui fallback but not app-only tools to clients without MCP Apps support", async () => {
173173
await withClient(
174174
makeStubEngine({}),
175175
NO_CAPS,
176176
async (client) => {
177177
const { tools } = await client.listTools();
178178
const names = tools.map((tool) => tool.name);
179-
expect(names).not.toContain("render-ui");
179+
expect(names).toContain("render-ui");
180180
expect(names).not.toContain("execute-action");
181181
expect(names).not.toContain("execute-action-resume");
182+
183+
const result = await client.callTool({
184+
name: "render-ui",
185+
arguments: { code: 'function App() { return <Card className="p-4" />; }' },
186+
});
187+
expect(textOf(result)).toContain("https://executor.test/render?code=42");
188+
expect(result.structuredContent).toEqual({
189+
status: "fallback_url",
190+
url: "https://executor.test/render?code=42",
191+
});
192+
},
193+
{
194+
plugins: [DYNAMIC_UI_PLUGIN],
195+
renderUiFallbackUrl: () => "https://executor.test/render?code=42",
182196
},
183-
{ plugins: [DYNAMIC_UI_PLUGIN] },
184197
);
185198
});
186199

packages/hosts/mcp/src/server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ type SharedMcpServerConfig = {
9090
readonly mode: "native";
9191
};
9292
readonly browserApprovalStore?: BrowserApprovalStore;
93+
/**
94+
* Browser URL used by generative UI MCP contributions when the connected
95+
* client cannot mount MCP Apps resources directly.
96+
*/
97+
readonly renderUiFallbackUrl?: (code: string) => string;
9398
/**
9499
* Executor plugins whose host-protocol MCP contributions should be mounted.
95100
* Core SDK treats the field as opaque; this host interprets `plugin.mcp`.
@@ -672,6 +677,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>(
672677
debugLog,
673678
runToolEffect,
674679
executeCodeFromApp,
680+
renderUiFallbackUrl: config.renderUiFallbackUrl,
675681
resumeExecution,
676682
parseJsonContent,
677683
})

0 commit comments

Comments
 (0)