Add generative UI shell for interactive React components#263
Add generative UI shell for interactive React components#263RhysSullivan wants to merge 9 commits into
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | 89d1859 | Commit Preview URL Branch Preview URL |
Jun 29 2026, 09:33 PM |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
|
@claude you can keep yourself as a coauthor on this PR because you did a good job but i better not see that shit again |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | 89d1859 | Jun 29 2026, 09:32 PM |
577e713 to
6ed04a9
Compare
7ccfc42 to
3ed5a27
Compare
Reconcile the generative-UI MCP feature with main's unified provider architecture: - host-mcp: serving root stays dependency-light; the plugin-contribution vocabulary and createExecutorMcpServer move to the /tool-server subpath. McpToolResult widened to ContentBlock[] to match main's ToolFile output. - cloud: feature-flag gate + dynamic-ui plugin filtering moved into the new session-durable-object buildMcpServer; feature-flags relocated to src root. Generated-UI fallback served by a bare (unauthenticated) plugin route. - local: plugin filtering wired through the renamed src/ layout and the daemon-bridged CLI (the in-CLI stdio server is gone on main). - dynamic-ui: zod pinned to 4.3.6 to dedupe with host-mcp; browser test updated to ToolAddress / ElicitationContext.address.
Cloudflare preview
Sign-in is Cloudflare Access (one-time PIN to an allowed email). The preview has its own database and encryption key; it is destroyed when this PR closes. |
Greptile SummaryThis PR introduces a generative UI shell that allows LLM-generated React components to render interactively inside an MCP App iframe. It wires a new
Confidence Score: 3/5The shell runtime and sandboxing architecture are well-structured, but the tools proxy emits unconditional console.log calls for every tool invocation and response before this code reaches production users. The debug logging in proxy.ts dumps serialized tool arguments and full structured results to the browser console on every single tool call, with no flag to gate it off. Tools routed through this proxy can return API tokens, private records, or PII, all of which would be available to any browser extension that hooks console.log. This needs to be addressed before the shell is exposed to real users. packages/plugins/dynamic-ui/src/shell/proxy.ts deserves the closest look before merge.
|
| Filename | Overview |
|---|---|
| packages/plugins/dynamic-ui/src/shell/proxy.ts | tRPC-style tool proxy with unconditional debug console.log calls on every invocation and result, leaking sensitive data in production; also missing recursion depth cap in resolveToolResult |
| packages/plugins/dynamic-ui/src/mcp.ts | MCP contribution wiring for render-ui/execute-action tools; validation regexes can be bypassed via JS block comments but isolation is preserved by the iframe sandbox |
| packages/plugins/dynamic-ui/src/shell/shell-app.tsx | Main shell React component; iframe height is clamped but maxHeight from untrusted iframe config is not, could make the container invisible or unbounded |
| packages/plugins/dynamic-ui/src/shell/component-runtime.ts | Sucrase JSX compilation and Function-based component evaluation with well-scoped context; network primitives are blocked by design |
| packages/plugins/dynamic-ui/src/shell/inner-renderer.tsx | Inner iframe renderer: receives code via postMessage, compiles and renders it in a QueryClientProvider, sends size/config/error events back to parent |
| packages/hosts/mcp/src/tool-server.ts | MCP server host extended with plugins/renderUiFallbackUrl config; executeCodeFromApp delegates to the engine with correct elicitation mode handling |
| packages/hosts/mcp/src/tool-server.test.ts | Well-structured integration tests covering capability-gated tool visibility, fallback URL behavior, hardcoded data rejection, and globals redeclaration rejection |
| apps/cloud/src/mcp/session-durable-object.ts | Adds PostHog feature flag evaluation for generated UI with safe fallback to disabled on error; correctly gates plugin registration |
Reviews (1): Last reviewed commit: "Merge origin/main into generative UI she..." | Re-trigger Greptile
| const serializedArgs = args.length > 0 ? JSON.stringify(args[0]) : "{}"; | ||
| const code = `return await tools.${toolPath}(${serializedArgs})`; | ||
|
|
||
| console.log("[executor-proxy] calling:", code); |
There was a problem hiding this comment.
Unconditional debug logs leak tool invocations and results
console.log is called on every tool invocation ("[executor-proxy] calling:" with the full serialized args) and on every response ("[executor-proxy] raw result:" and "[executor-proxy] unwrapped:" with the full structured content). In a production MCP App shell, these logs appear unconditionally in the browser console where browser extensions with console.log monkey-patching can capture them. Tool calls through the proxy can carry sensitive values (auth tokens, private API responses, PII), so these should be gated behind a debug flag or removed before shipping.
| const REACT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{[^{}]*\}\s*=\s*React\b/s; | ||
|
|
||
| const OBJECT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=/gs; | ||
|
|
||
| const PROVIDED_GLOBAL_DECLARATION = | ||
| /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b|\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\bclass\s+([A-Za-z_$][\w$]*)\b/g; | ||
|
|
||
| const firstDefined = (...values: Array<string | undefined>): string | undefined => | ||
| values.find((value): value is string => value !== undefined); | ||
|
|
||
| const localDestructuredName = (part: string): string | undefined => { | ||
| const binding = part | ||
| .replace(/^\s*\.\.\./, "") | ||
| .split("=")[0] | ||
| ?.trim(); | ||
| const alias = binding?.match(/:\s*([A-Za-z_$][\w$]*)\s*$/)?.[1]; | ||
| return alias ?? binding?.match(/^([A-Za-z_$][\w$]*)\b/)?.[1]; | ||
| }; | ||
|
|
||
| export const validateRenderUiCode = (code: string): string | null => { | ||
| if (REACT_DESTRUCTURING_DECLARATION.test(code)) { | ||
| return [ | ||
| "Do not destructure React in render-ui.", | ||
| "Hooks such as useState are already in scope; use useState(...) directly or React.useState(...).", | ||
| ].join(" "); | ||
| } | ||
|
|
||
| for (const match of code.matchAll(OBJECT_DESTRUCTURING_DECLARATION)) { | ||
| const names = match[1]?.split(",").flatMap((part) => { | ||
| const name = localDestructuredName(part); | ||
| return name ? [name] : []; | ||
| }); | ||
| const providedName = names?.find((name) => PROVIDED_GLOBAL_NAMES.has(name)); | ||
| if (providedName) { | ||
| return [ | ||
| `Provided global "${providedName}" is already in scope and cannot be redeclared.`, | ||
| "Remove the destructuring declaration and use the provided global directly.", | ||
| ].join(" "); | ||
| } | ||
| } | ||
|
|
||
| for (const match of code.matchAll(PROVIDED_GLOBAL_DECLARATION)) { | ||
| const name = firstDefined(match[1], match[2], match[3]); | ||
| if (name && PROVIDED_GLOBAL_NAMES.has(name)) { | ||
| return [ | ||
| `Provided global "${name}" is already in scope and cannot be redeclared.`, | ||
| "Remove the local declaration and use the provided global directly.", | ||
| ].join(" "); | ||
| } | ||
| } |
There was a problem hiding this comment.
Validation regexes bypassed by JS block comments
PROVIDED_GLOBAL_DECLARATION and REACT_DESTRUCTURING_DECLARATION operate on raw source before Sucrase compilation. Sucrase strips block comments during compilation, so const /* bypass */ Card = () => null passes validation (regex requires \s+ between const and the identifier; /*...*/ is not \s) but compiles to const Card = () => null, shadowing the provided Card global inside the new Function scope. The same technique works for const { /* skip */ useState } = React. The validation is documented as a best-effort heuristic so this does not break isolation, but it means the "server rejects redeclarations" guarantee can be circumvented by crafted LLM output and could cause confusing runtime failures.
| const maxHeight = typeof config.maxHeight === "number" ? config.maxHeight : 800; | ||
| const rendererHeight = renderer ? Math.min(renderer.height, maxHeight) : undefined; |
There was a problem hiding this comment.
maxHeight from the inner iframe is unclamped
config.maxHeight is received directly from the renderer iframe via a postMessage (the generated component can set const config = { maxHeight: N }). The iframe height is clamped to Math.max(120, Math.min(4000, height)), but maxHeight used for the outer container style is used as-is. A value of 0 makes the entire shell invisible; a value like 1e9 sets a comically oversized container. Adding a sensible clamp (e.g., Math.max(120, Math.min(4000, maxHeight))) would keep this consistent with the height clamping already in place.
| async function resolveToolResult( | ||
| app: ToolCallHost, | ||
| result: CallToolResult, | ||
| requestTrustedInteraction: RequestTrustedInteraction, | ||
| ): Promise<unknown> { | ||
| console.log( | ||
| "[executor-proxy] raw result:", | ||
| JSON.stringify({ | ||
| isError: result.isError, | ||
| structuredContent: result.structuredContent, | ||
| text: result.content?.find((c) => c.type === "text")?.text, | ||
| }), | ||
| ); | ||
|
|
||
| if (result.isError) { | ||
| const msg = result.content?.find((c) => c.type === "text")?.text ?? "Tool call failed"; | ||
| throw new Error(msg); | ||
| } | ||
|
|
||
| const structured = result.structuredContent as Record<string, unknown> | undefined; | ||
| const pending = parseTrustedInteraction(structured); | ||
| if (pending) { | ||
| const response = await requestTrustedInteraction(pending); | ||
| const resumed = await app.callServerTool({ | ||
| name: "execute-action-resume", | ||
| arguments: { | ||
| executionId: pending.executionId, | ||
| action: response.action, | ||
| content: JSON.stringify(response.content ?? {}), | ||
| }, | ||
| }); | ||
| return resolveToolResult(app, resumed, requestTrustedInteraction); | ||
| } | ||
|
|
||
| const unwrapped = unwrapResult(structured) ?? parseTextContent(result); | ||
| console.log("[executor-proxy] unwrapped:", JSON.stringify(unwrapped)); | ||
| return unwrapped; | ||
| } |
There was a problem hiding this comment.
resolveToolResult recurses without a depth cap
When a tool call returns waiting_for_interaction, resolveToolResult calls resume and then calls itself again on the result. If the server returns waiting_for_interaction a second time after receiving a cancel action, the function recurses indefinitely until the stack overflows. In shell-app.tsx, requestTrustedInteraction cancels immediately when a modal is already visible, so the resumed result should be completed. However, there is no explicit guard at the proxy layer — a misbehaving or buggy server-side resume could still produce unbounded recursion. Adding a simple depth counter (e.g., cap at 10) would make this path resilient.
Summary
Adds a generative UI shell that enables the Executor MCP server to render interactive React components generated by LLMs. The shell runs in an iframe via MCP Apps, receives JSX code, compiles and evaluates it in a sandboxed context, and provides access to tools and data-fetching hooks.
Key Changes
Shell Runtime
Tools Proxy
Server
Styling and Theming
Tool Description
Test Plan