Skip to content

Add generative UI shell for interactive React components#263

Open
RhysSullivan wants to merge 9 commits into
mainfrom
claude/generative-ui-mcp-apps-BG4vy
Open

Add generative UI shell for interactive React components#263
RhysSullivan wants to merge 9 commits into
mainfrom
claude/generative-ui-mcp-apps-BG4vy

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Apr 16, 2026

Copy link
Copy Markdown
Owner

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

  • Compiles JSX with Sucrase and evaluates in a scoped context with React, hooks, shadcn/ui components, Recharts, and Lucide icons
  • Direct typeof checks for App/Component/Main — no fragile regex export detection
  • Listens to both ontoolinput (fires on page reload) and ontoolresult for rendering
  • Non-JSX results render as a formatted JSON data view
  • Configurable maxHeight (default 800px) via const config = { maxHeight: N }
  • Error boundary with surfaced error messages instead of silent failures

Tools Proxy

  • tRPC-style recursive proxy: tools.github.issues.create() maps to execute-action call
  • Unwraps kernel result envelope { status, result, logs } to return raw data
  • run() escape hatch for multi-step tool composition

Server

  • execute tool registered with _meta.ui for MCP Apps — JSX detected via isReactCode heuristic (capitalized tags, className=, onClick={, fragments)
  • execute-action auto-approves elicitations — UI-initiated actions do not pause for approval since user already consented
  • Capability-based execute-action visibility

Styling and Theming

  • @tailwindcss/browser runtime bundled inline — any Tailwind class works at runtime
  • Fixed @source path for shadcn component scanning
  • Removed height 100% constraint so auto-resize reports real content height
  • Dark mode via prefers-color-scheme + host theme context

Tool Description

  • Generative UI section documents available components, hooks, dark mode theming, and config options
  • Instructs models to use dark variants or theme variables for dark mode compatibility

Test Plan

  • JSX code renders interactive React components in ChatGPT and Claude
  • Non-JSX code executes normally and returns JSON
  • useQuery fetches data via tools proxy (verified with Vercel domains API)
  • UI-initiated mutations auto-approve (verified with domain auto-renew toggle)
  • Page reload restores previously rendered UIs via ontoolinput
  • Dark/light theme applies correctly
  • Iframe auto-resizes with configurable maxHeight

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@pkg-pr-new

pkg-pr-new Bot commented Apr 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@263

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@263

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@263

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@263

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@263

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@263

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@263

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@263

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@263

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@263

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@263

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@263

executor

npm i https://pkg.pr.new/executor@263

commit: 89d1859

@RhysSullivan

Copy link
Copy Markdown
Owner Author

@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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 13, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 89d1859 Jun 29 2026, 09:32 PM

@RhysSullivan RhysSullivan force-pushed the claude/generative-ui-mcp-apps-BG4vy branch 4 times, most recently from 577e713 to 6ed04a9 Compare May 20, 2026 05:37
@RhysSullivan RhysSullivan force-pushed the claude/generative-ui-mcp-apps-BG4vy branch from 7ccfc42 to 3ed5a27 Compare May 20, 2026 22:14
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.
@github-actions

Copy link
Copy Markdown
Contributor

Cloudflare preview

Console https://executor-preview-pr-263.executor-e2e.workers.dev
MCP https://executor-preview-pr-263.executor-e2e.workers.dev/mcp
Deployed commit 89d1859

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-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a generative UI shell that allows LLM-generated React components to render interactively inside an MCP App iframe. It wires a new dynamic-ui plugin into the MCP host layer, adds a two-iframe sandboxing architecture (outer shell + inner renderer with strict CSP), a tRPC-style tools proxy bridged via postMessage, and server-side heuristic validation of submitted JSX before it reaches the iframe.

  • Shell runtime: Sucrase compiles JSX in the inner sandboxed iframe; component-runtime.ts evaluates it with a new Function scope that provides React hooks, shadcn/ui components, Recharts, Lucide, and TanStack Query. Network primitives are blocked at both the JS scope and CSP layers.
  • Tools proxy & bridge: proxy.ts maps dotted property accesses to execute-action calls; shell-app.tsx validates path segments and routes postMessage channels between the two iframes using per-render UUID tokens.
  • Server gating: render-ui is registered as an MCP App tool with capability-based visibility; execute-action and execute-action-resume are hidden from clients without MCP Apps support; a PostHog feature flag gates the entire plugin in the cloud deployment.

Confidence Score: 3/5

The 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.

Security Review

  • Sensitive data logged unconditionally (packages/plugins/dynamic-ui/src/shell/proxy.ts lines 53, 94-101, 124): every execute-action invocation and its full structured result are written to console.log with no debug flag. Browser extensions that monkey-patch console.log can capture these payloads, which may include API tokens, private records, or PII returned by tool calls.
  • Inner iframe CSP uses unsafe-eval: required for the new Function component evaluator and intentional by design, but any CSP bypass in the iframe would permit arbitrary code execution in that sandbox.
  • No credential leakage across trust boundaries, no SQL/XSS injection vectors in the server-side path, and no secrets observed in the changed files.

Important Files Changed

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Comment on lines +398 to +447
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(" ");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +429 to +430
const maxHeight = typeof config.maxHeight === "number" ? config.maxHeight : 800;
const rendererHeight = renderer ? Math.min(renderer.height, maxHeight) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +89 to +126
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant