From d2a79cce217a72ccbf3308b1ab86896c152e1f2d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:37:46 -0700 Subject: [PATCH 1/3] Add MCP-Apps shell package and first-party render-ui tools --- apps/cloud/package.json | 1 + apps/cloud/src/mcp/session-durable-object.ts | 5 + apps/host-cloudflare/package.json | 1 + .../src/mcp/session-durable-object.ts | 11 +- apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/mcp/session-store.ts | 3 + apps/local/package.json | 1 + apps/local/src/executor.ts | 7 +- apps/local/src/main.ts | 23 +- bun.lock | 51 ++ packages/core/api/src/server/mcp-build.ts | 40 +- packages/core/execution/src/index.ts | 10 +- .../core/execution/src/provided-globals.ts | 270 ++++++++ packages/core/execution/src/skills.ts | 89 ++- packages/hosts/mcp-apps-shell/.gitignore | 1 + packages/hosts/mcp-apps-shell/package.json | 74 +++ .../scripts/gen-provided-globals.ts | 23 + packages/hosts/mcp-apps-shell/src/index.ts | 11 + .../hosts/mcp-apps-shell/src/shell-html.ts | 49 ++ .../src/shell/component-runtime.test.ts | 53 ++ .../src/shell/component-runtime.ts | 185 ++++++ .../mcp-apps-shell/src/shell/components.ts | 277 ++++++++ .../mcp-apps-shell/src/shell/globals.css | 147 +++++ .../hosts/mcp-apps-shell/src/shell/hooks.ts | 114 ++++ .../src/shell/inner-renderer-source.d.ts | 4 + .../src/shell/inner-renderer.tsx | 309 +++++++++ .../mcp-apps-shell/src/shell/mcp-app.html | 12 + .../mcp-apps-shell/src/shell/mcp-app.tsx | 35 + .../src/shell/provided-globals.pin.test.ts | 22 + .../hosts/mcp-apps-shell/src/shell/proxy.ts | 154 +++++ .../mcp-apps-shell/src/shell/shell-app.tsx | 623 ++++++++++++++++++ packages/hosts/mcp-apps-shell/tsconfig.json | 15 + .../hosts/mcp-apps-shell/vite.config.shell.ts | 59 ++ .../hosts/mcp-apps-shell/vitest.config.ts | 12 + packages/hosts/mcp/package.json | 5 + packages/hosts/mcp/src/render-ui.ts | 96 +++ packages/hosts/mcp/src/tool-server.ts | 501 +++++++++++++- 37 files changed, 3274 insertions(+), 20 deletions(-) create mode 100644 packages/core/execution/src/provided-globals.ts create mode 100644 packages/hosts/mcp-apps-shell/.gitignore create mode 100644 packages/hosts/mcp-apps-shell/package.json create mode 100644 packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts create mode 100644 packages/hosts/mcp-apps-shell/src/index.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell-html.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/components.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/globals.css create mode 100644 packages/hosts/mcp-apps-shell/src/shell/hooks.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.html create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/proxy.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx create mode 100644 packages/hosts/mcp-apps-shell/tsconfig.json create mode 100644 packages/hosts/mcp-apps-shell/vite.config.shell.ts create mode 100644 packages/hosts/mcp-apps-shell/vitest.config.ts create mode 100644 packages/hosts/mcp/src/render-ui.ts diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 74276f6a6..258b12ab7 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -41,6 +41,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c2d249aef..b11ca8e76 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -25,6 +25,8 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; import { McpAgentSessionDOBase, type BuiltMcpServer, @@ -250,6 +252,9 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.currentParentSpan(), debug: env.EXECUTOR_MCP_DEBUG === "true", browserApprovalStore: self.browserApprovalStore, diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index d7159620d..55a9ff19b 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -23,6 +23,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 95b366b7e..194bfbbd2 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -5,6 +5,8 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, @@ -113,7 +115,7 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine } = yield* makeExecutionStack( + const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, @@ -124,8 +126,15 @@ export class McpSessionDO extends McpAgentSessionDOBase interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; + /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced + * so callers building user-facing links (MCP artifact deep links) use the + * same origin the executor itself was configured with. */ + readonly webBaseUrl: string; } class LocalExecutorTag extends Context.Service()( @@ -233,7 +237,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { ); } - return { executor, plugins }; + return { executor, plugins, webBaseUrl }; }), ); }; @@ -246,6 +250,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = return { executor: bundle.executor, plugins: bundle.plugins, + webBaseUrl: bundle.webBaseUrl, dispose: async () => { await Effect.runPromise(Effect.ignore(bundle.executor.close())); await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 750cd72f6..3daff99c5 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -1,6 +1,8 @@ import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { makeLocalApiHandler } from "./app"; import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; @@ -81,15 +83,24 @@ export const createServerHandlers = async (token: string): Promise { - if (resource.kind === "default") return { config: { engine } }; + if (resource.kind === "default") { + return { config: { engine, artifacts: executor.artifacts, ...appsConfig } }; + } const handle = await createExecutorHandle({ activeToolkitSlug: resource.slug, }); @@ -98,7 +109,11 @@ export const createServerHandlers = async (token: string): Promise=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 275185754..0374bd42f 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -7,6 +7,7 @@ import { type McpBuildServerOptions, } from "@executor-js/host-mcp/in-memory-session-store"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; import { ErrorCapture } from "../observability"; import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack"; @@ -37,13 +38,21 @@ export type McpExecutionStackLayer = Layer.Layer< * in the injected stack layer (libSQL vs D1, etc.). */ export const makeMcpBuildServer = - (executionStack: McpExecutionStackLayer): McpBuildServer => + (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => (principal: Principal, options?: McpBuildServerOptions) => - makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { - mcpResource: options?.resource, + Effect.gen(function* () { + const { engine, executor } = yield* makeExecutionStack( + principal.accountId, + principal.organizationId, + principal.organizationName, + { mcpResource: options?.resource }, + ).pipe(Effect.withSpan("mcp.execution_stack.build")); + // Read inside the provided boundary: `webBaseUrl` is a host seam, and + // hosts that can't know their public URL at boot leave it unset — in + // which case artifacts still persist but carry no deep link. + const hostConfig = yield* HostConfig; + return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; }).pipe( - Effect.withSpan("mcp.execution_stack.build"), - Effect.map(({ engine }) => engine), // Pin browser-handoff URLs to the principal's org slug when present; // absent slug leaves the service unprovided and the URL stays bare. principal.organizationSlug !== undefined @@ -51,14 +60,31 @@ export const makeMcpBuildServer = : (effect) => effect, Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), - Effect.flatMap((engine) => - createExecutorMcpServer({ engine, ...(options ?? {}) }).pipe( + Effect.flatMap(({ engine, executor, webBaseUrl }) => + createExecutorMcpServer({ + engine, + artifacts: executor.artifacts, + ...(hostOptions?.loadAppShellHtml + ? { loadAppShellHtml: hostOptions.loadAppShellHtml } + : {}), + ...(webBaseUrl ? { artifactUrl: artifactUrlFor(webBaseUrl) } : {}), + ...(options ?? {}), + }).pipe( Effect.withSpan("mcp.server.create"), Effect.map((mcpServer) => ({ mcpServer, engine })), ), ), ); +/** Per-host (not per-session) MCP wiring. Kept separate from + * `McpBuildServerOptions`, which the session store fills in per request. */ +export interface McpBuildHostOptions { + /** Serves the MCP-Apps shell resource. Hosts that can render generative UI + * pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; omitting it + * leaves the ui-bearing tools unregistered. */ + readonly loadAppShellHtml?: () => Promise; +} + /** * The standard console `McpErrorReporter` seam: route an orchestration defect * the MCP envelope would otherwise swallow into a 500 through the host's diff --git a/packages/core/execution/src/index.ts b/packages/core/execution/src/index.ts index 5a384c36b..dc9113fc7 100644 --- a/packages/core/execution/src/index.ts +++ b/packages/core/execution/src/index.ts @@ -12,7 +12,15 @@ export { } from "./engine"; export { buildExecuteDescription, INTEGRATION_INVENTORY_HEADER } from "./description"; -export { EXECUTE_SKILL, SKILLS, findSkill, renderSkillsIndex, type Skill } from "./skills"; +export { + EXECUTE_SKILL, + RENDER_UI_SKILL, + SKILLS, + findSkill, + renderSkillsIndex, + type Skill, +} from "./skills"; +export { PROVIDED_GLOBAL_NAMES } from "./provided-globals"; export { ExecutionToolError } from "./errors"; export { defaultToolDiscoveryProvider, diff --git a/packages/core/execution/src/provided-globals.ts b/packages/core/execution/src/provided-globals.ts new file mode 100644 index 000000000..e4225fadd --- /dev/null +++ b/packages/core/execution/src/provided-globals.ts @@ -0,0 +1,270 @@ +/** + * Every name model-generated `render-ui` code may reference without declaring + * it — React hooks, TanStack Query, the `tools` proxy, and every shadcn, + * Recharts and Lucide export the shell binds into scope. + * + * GENERATED from the shell's real evaluation scope + * (`@executor-js/mcp-apps-shell` `PROVIDED_SCOPE_NAMES`). Do not hand-edit: + * `provided-globals.pin.test.ts` in the shell package fails if this drifts from + * what the iframe actually binds. Regenerate by running that package's + * `bun run gen:provided-globals`. + * + * It lives here rather than in the shell package because the MCP host must + * validate generated code without importing React. + */ + +export const PROVIDED_GLOBAL_NAMES: ReadonlySet = new Set([ + "React", + "useState", + "useEffect", + "useRef", + "useCallback", + "useMemo", + "useContext", + "Fragment", + "createContext", + "module", + "exports", + "require", + "fetch", + "XMLHttpRequest", + "WebSocket", + "EventSource", + "Worker", + "SharedWorker", + "useQuery", + "useMutation", + "useQueryClient", + "queryOptions", + "mutationOptions", + "skipToken", + "tools", + "run", + "Accordion", + "AccordionContent", + "AccordionItem", + "AccordionTrigger", + "Activity", + "Alert", + "AlertCircle", + "AlertDescription", + "AlertTitle", + "AlertTriangle", + "Area", + "AreaChart", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowUpRight", + "AtSign", + "Avatar", + "AvatarFallback", + "AvatarImage", + "Badge", + "Bar", + "BarChart", + "BarChart3", + "Battery", + "Bookmark", + "Box", + "Brush", + "Button", + "Calendar", + "Card", + "CardAction", + "CardContent", + "CardDescription", + "CardFooter", + "CardHeader", + "CardTitle", + "CartesianGrid", + "Cell", + "ChartContainer", + "ChartLegend", + "ChartLegendContent", + "ChartStyle", + "ChartTooltip", + "ChartTooltipContent", + "Check", + "Checkbox", + "ChevronDown", + "ChevronLeft", + "ChevronRight", + "ChevronUp", + "ChevronsUpDown", + "Circle", + "Clock", + "CloudRain", + "Code", + "ComposedChart", + "Copy", + "Cpu", + "Database", + "Dialog", + "DialogClose", + "DialogContent", + "DialogDescription", + "DialogFooter", + "DialogHeader", + "DialogTitle", + "DialogTrigger", + "Download", + "DropdownMenu", + "DropdownMenuCheckboxItem", + "DropdownMenuContent", + "DropdownMenuGroup", + "DropdownMenuItem", + "DropdownMenuLabel", + "DropdownMenuRadioGroup", + "DropdownMenuRadioItem", + "DropdownMenuSeparator", + "DropdownMenuSub", + "DropdownMenuSubContent", + "DropdownMenuSubTrigger", + "DropdownMenuTrigger", + "Edit", + "ExternalLink", + "Eye", + "EyeOff", + "File", + "FileText", + "Filter", + "Folder", + "FolderOpen", + "Funnel", + "FunnelChart", + "GitBranch", + "GitCommit", + "GitPullRequest", + "Globe", + "Grip", + "GripVertical", + "Hash", + "Heart", + "Hexagon", + "Home", + "Image", + "Info", + "Input", + "Key", + "Label", + "Legend", + "Line", + "LineChart", + "Link", + "Loader2", + "Lock", + "Mail", + "MapPin", + "Menu", + "MessageSquare", + "Mic", + "Minus", + "Moon", + "MoreHorizontal", + "MoreVertical", + "Package", + "Paperclip", + "Pause", + "Phone", + "Pie", + "PieChart", + "PieChartIcon", + "Play", + "Plus", + "PolarAngleAxis", + "PolarGrid", + "PolarRadiusAxis", + "Popover", + "PopoverAnchor", + "PopoverContent", + "PopoverTrigger", + "Progress", + "Radar", + "RadarChart", + "RadialBar", + "RadialBarChart", + "RadioGroup", + "RadioGroupItem", + "ReferenceArea", + "ReferenceLine", + "RefreshCw", + "ResponsiveContainer", + "RotateCcw", + "Scatter", + "ScatterChart", + "ScrollArea", + "ScrollBar", + "Search", + "Select", + "SelectContent", + "SelectGroup", + "SelectItem", + "SelectLabel", + "SelectTrigger", + "SelectValue", + "Send", + "Separator", + "Server", + "Settings", + "Sheet", + "SheetClose", + "SheetContent", + "SheetDescription", + "SheetFooter", + "SheetHeader", + "SheetTitle", + "SheetTrigger", + "Shield", + "Skeleton", + "Slider", + "SortAsc", + "SortDesc", + "Square", + "Star", + "Sun", + "Switch", + "Table", + "TableBody", + "TableCaption", + "TableCell", + "TableFooter", + "TableHead", + "TableHeader", + "TableRow", + "Tabs", + "TabsContent", + "TabsList", + "TabsTrigger", + "Tag", + "Terminal", + "Textarea", + "Thermometer", + "Toggle", + "ToggleGroup", + "ToggleGroupItem", + "Tooltip", + "TooltipContent", + "TooltipProvider", + "TooltipTrigger", + "Trash2", + "Treemap", + "TrendingDown", + "TrendingUp", + "Triangle", + "Unlock", + "Upload", + "User", + "Users", + "Video", + "Volume2", + "VolumeX", + "Wifi", + "WifiOff", + "X", + "XAxis", + "YAxis", + "Zap", + "cn", +]); diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 849562da9..527acf135 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -70,8 +70,95 @@ export const EXECUTE_SKILL: Skill = { body: EXECUTE_SKILL_BODY, }; +// The `render-ui` how-to. Same reasoning as `execute`: the discovery-vs-render +// protocol, the TanStack rules and the component inventory are a page of prose +// that only matters once a model decides to build a UI, so the tool description +// stays short and points here. +const SHADCN_COMPONENTS = + "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"; + +const RECHARTS_COMPONENTS = + "BarChart, Bar, LineChart, Line, AreaChart, Area, PieChart, Pie, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Legend, ChartContainer, ChartTooltip, ChartTooltipContent"; + +const LUCIDE_ICONS = + "Plus, Minus, Check, X, Search, Loader2, AlertCircle, ExternalLink, Copy, Trash2, Edit, Settings, User, Globe, Star, TrendingUp, Activity, Database, Shield, Package, and more"; + +const RENDER_UI_SKILL_BODY = [ + "# render-ui", + "", + "Render an interactive React UI component as an MCP app, and save it as an artifact.", + "", + "Every successful render is persisted under the `title` you supply, so the user can", + "reopen it later and you can find it again with `list-artifacts` / `show-artifact`.", + "Give it a short human-readable name (`Active users dashboard`), and a `description`", + "describing what it shows — that description is what a later request like", + '"show me my active users dashboard" is matched against.', + "", + "## Workflow", + "", + "1. If you need to understand tool names, query syntax, required arguments, response shapes, IDs, or mutation inputs, first use the regular `execute` tool to inspect them.", + "2. Then call `render-ui` with a component named `App` in the `code` parameter.", + "3. Recreate every read from the discovery step inside `App` with `useQuery(tools...queryOptions(args))` so the UI stays live.", + "4. Use `useMutation(tools...mutationOptions({ onSuccess }))` for user-triggered writes or actions.", + "5. Return only the component code.", + "", + "## Using Execute For Discovery", + "", + "- `execute` is for exploration: list datasets, inspect schemas, test a query, fetch one small sample row, or learn the exact mutation input shape.", + "- `render-ui` is for the final interactive surface. Do not paste discovery results into JSX as literal rows, cards, summaries, metrics, or chart series.", + "- After discovering an API call with `execute`, put the same call in TanStack Query options inside the generated component.", + "- Example discovery: call `execute` with `return await tools.axiom_mcp.querydataset({ ... })` to confirm columns, then call `render-ui` with `useQuery(tools.axiom_mcp.querydataset.queryOptions({ ... }))`.", + "- Use discovered result shapes exactly. If a sample or schema returns `{ renew, expiresAt }`, read `data?.renew`, not `data?.domain?.renew`.", + "- Keep discovery small. Use limits, narrow time ranges, or schema/list tools when possible.", + "", + "## TanStack Query State", + "", + "- The component is already wrapped in a `QueryClientProvider`; do not create your own.", + "- Use `const queryClient = useQueryClient()` when a mutation changes data shown by a query.", + "- For simple writes, invalidate with `queryClient.invalidateQueries(tools...queryFilter(args))` in `onSuccess` or `onSettled`.", + "- For toggles and switches, pass the new checked value into `mutate`: `onCheckedChange={(checked) => mutation.mutate({ body: { enabled: checked } })}`.", + "- For optimistic UI, use `onMutate` to `cancelQueries`, snapshot `getQueryData`, and `setQueryData`; return the snapshot, restore it in `onError`, and invalidate in `onSettled`.", + "- Tool proxy helpers are TanStack-native: `.queryOptions(args, options)`, `.mutationOptions(options)`, `.queryKey(args)`, `.queryFilter(args, filters)`, `.pathKey()`, `.pathFilter(filters)`, and `.mutationKey()`.", + "", + "## What Is Already In Scope", + "", + "**Write no imports.** Everything below is bound before your code runs:", + "", + "- React: `useState`, `useEffect`, `useRef`, `useCallback`, `useMemo`, `useContext`, `createContext`, `Fragment`.", + "- TanStack Query v5: `useQuery`, `useMutation`, `useQueryClient`, `queryOptions`, `mutationOptions`, `skipToken`.", + "- The tool proxy `tools` and the multi-step escape hatch `run(code)`.", + `- shadcn/ui components available by name: ${SHADCN_COMPONENTS}`, + `- Recharts components available by name: ${RECHARTS_COMPONENTS}`, + `- Lucide icons available by name: ${LUCIDE_ICONS}`, + "- The class-name helper `cn`.", + "", + "## Rules", + "", + "- Use this tool instead of `execute` whenever the output should be an interactive UI.", + "- Export a component named `App`. A top-level `const config = { maxHeight }` sets the frame height.", + "- Do not call API tools first and paste returned data into JSX.", + "- Do not embed tool response rows, API results, summaries, dashboard data, or copied query output as literals. Fetch them with `useQuery` so the UI stays live; only hardcode display constants like labels, colors, tab names, and chart configuration.", + "- Always render the loading and error states from `useQuery` / `useMutation`; do not replace them with hardcoded fallback data.", + "- Do not redeclare or destructure provided globals. `const { useState } = React` and `const Card = ...` are rejected by the server before the UI reaches the iframe — use them directly.", + "- `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource` and workers are blocked inside the frame. Every read and write goes through `tools`.", + "- Give `title` a short human-readable name and `description` enough detail that you can find the artifact again later.", + "", + "## Retrieving Saved Artifacts", + "", + "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.", + "- `show-artifact({ id })` re-renders one. Match the user's phrasing against the titles and descriptions from `list-artifacts` rather than guessing an id.", + "- Clients that cannot display MCP apps get a link to the artifact in the web app instead; pass that URL on to the user verbatim.", +].join("\n"); + +export const RENDER_UI_SKILL: Skill = { + name: "render-ui", + summary: + "How to write a React component for the render-ui tool: discover data with execute, keep it live with TanStack Query, and what is already in scope.", + body: RENDER_UI_SKILL_BODY, +}; + /** The full skill catalog. Hand-curated; keep it small. */ -export const SKILLS: readonly Skill[] = [EXECUTE_SKILL]; +export const SKILLS: readonly Skill[] = [EXECUTE_SKILL, RENDER_UI_SKILL]; /** Look up a skill by its exact name. */ export const findSkill = (name: string): Skill | undefined => diff --git a/packages/hosts/mcp-apps-shell/.gitignore b/packages/hosts/mcp-apps-shell/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/hosts/mcp-apps-shell/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json new file mode 100644 index 000000000..ac2d8453d --- /dev/null +++ b/packages/hosts/mcp-apps-shell/package.json @@ -0,0 +1,74 @@ +{ + "name": "@executor-js/mcp-apps-shell", + "version": "1.4.4", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./shell-html": { + "types": "./src/shell-html.ts", + "default": "./src/shell-html.ts" + }, + "./shell/components": { + "types": "./src/shell/components.ts", + "default": "./src/shell/components.ts" + }, + "./shell/shell-app": { + "types": "./src/shell/shell-app.tsx", + "default": "./src/shell/shell-app.tsx" + }, + "./shell/proxy": { + "types": "./src/shell/proxy.ts", + "default": "./src/shell/proxy.ts" + }, + "./shell/component-runtime": { + "types": "./src/shell/component-runtime.ts", + "default": "./src/shell/component-runtime.ts" + }, + "./globals.css": "./src/shell/globals.css" + }, + "scripts": { + "build:shell": "vite build --config vite.config.shell.ts", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/react": "workspace:*", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.12.1", + "@tanstack/react-query": "^5.99.0", + "esbuild": "^0.27.7", + "sucrase": "^3.35.1" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@tailwindcss/browser": "^4.2.2", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "lucide-react": "^1.7.0", + "playwright-core": "^1.59.1", + "react": "catalog:", + "react-dom": "catalog:", + "recharts": "3.8.0", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "^2.3.2", + "vitest": "catalog:", + "zod": "4.3.6" + } +} diff --git a/packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts b/packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts new file mode 100644 index 000000000..43b25c011 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts @@ -0,0 +1,23 @@ +import { PROVIDED_SCOPE_NAMES } from "../src/shell/component-runtime"; +const header = `/** + * Every name model-generated \`render-ui\` code may reference without declaring + * it — React hooks, TanStack Query, the \`tools\` proxy, and every shadcn, + * Recharts and Lucide export the shell binds into scope. + * + * GENERATED from the shell's real evaluation scope + * (\`@executor-js/mcp-apps-shell\` \`PROVIDED_SCOPE_NAMES\`). Do not hand-edit: + * \`provided-globals.pin.test.ts\` in the shell package fails if this drifts from + * what the iframe actually binds. Regenerate by running that package's + * \`bun run gen:provided-globals\`. + * + * It lives here rather than in the shell package because the MCP host must + * validate generated code without importing React. + */ + +export const PROVIDED_GLOBAL_NAMES: ReadonlySet = new Set([ +`; +const body = PROVIDED_SCOPE_NAMES.map((n) => ` ${JSON.stringify(n)},`).join("\n"); +await Bun.write( + new URL("../../../core/execution/src/provided-globals.ts", import.meta.url).pathname, + `${header}${body}\n]);\n`, +); diff --git a/packages/hosts/mcp-apps-shell/src/index.ts b/packages/hosts/mcp-apps-shell/src/index.ts new file mode 100644 index 000000000..e73575819 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/index.ts @@ -0,0 +1,11 @@ +/** + * The MCP-Apps shell: the trusted iframe host that renders model-written JSX. + * + * The shell reaches MCP clients as a single self-contained HTML document (the + * `ui://executor/shell.html` resource). Hosts read it with + * `loadMcpAppsShellHtml` and hand it to the MCP host through the + * `loadAppShellHtml` config seam — the MCP host never imports this package + * directly, so React/Recharts/Tailwind stay out of the Workers graph. + */ + +export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html"; diff --git a/packages/hosts/mcp-apps-shell/src/shell-html.ts b/packages/hosts/mcp-apps-shell/src/shell-html.ts new file mode 100644 index 000000000..04c21f4a0 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell-html.ts @@ -0,0 +1,49 @@ +let shellHtmlCache: string | undefined; + +/** + * The self-contained shell HTML served as the MCP-Apps `ui://` resource. + * + * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather + * than the MCP host package importing it directly: the shell drags React, + * Recharts and Tailwind into whatever graph imports it, and the MCP host also + * runs on Workers where none of that belongs. + */ +export const loadMcpAppsShellHtml = async (): Promise => { + if (shellHtmlCache) return shellHtmlCache; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional prebuilt shell asset is loaded from local filesystem when present + try { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const candidates = [ + // `bun build --compile` can't bundle this runtime `fs.readFile`, so the + // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the + // executable. We find it via `process.execPath`, the same colocation + // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. + path.join(path.dirname(process.execPath), "mcp-app.html"), + // Dev / package-resolved (`bun run`, vitest): the package's own dist. + path.join(import.meta.dirname, "../dist/mcp-app.html"), + path.join(import.meta.dirname, "../../dist/mcp-app.html"), + ]; + + for (const candidate of candidates) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before falling back + try { + shellHtmlCache = await fs.readFile(candidate, "utf-8"); + return shellHtmlCache; + } catch { + // Try the next candidate path. + } + } + } catch { + // Fall through to the development fallback below. + } + + shellHtmlCache = MCP_APPS_SHELL_NOT_BUILT_HTML; + return shellHtmlCache; +}; + +/** What the loader serves when no built shell is on disk. Exported so tests can + * assert a host is serving the real thing rather than this placeholder. */ +export const MCP_APPS_SHELL_NOT_BUILT_HTML = + "

Shell not built. Run: bun run --cwd packages/hosts/mcp-apps-shell build:shell

"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts new file mode 100644 index 000000000..56c96fc58 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compileJsx, evaluateComponent } from "./component-runtime"; + +describe("generated UI component runtime", () => { + it("accepts export default function components", () => { + const compiled = compileJsx(` + const config = { maxHeight: 500 }; + + export default function App() { + return ok; + } + `); + + const result = evaluateComponent(compiled, {}, () => Promise.resolve(null)); + + expect(compiled).not.toContain("export default"); + expect(result).not.toHaveProperty("error"); + if ("error" in result) return; + expect(typeof result.component).toBe("function"); + expect(result.config).toEqual({ maxHeight: 500 }); + }); + + it("accepts anonymous default exports", () => { + const compiled = compileJsx(` + export default function() { + return ok; + } + `); + + const result = evaluateComponent(compiled, {}, () => Promise.resolve(null)); + + expect(result).not.toHaveProperty("error"); + if ("error" in result) return; + expect(typeof result.component).toBe("function"); + }); + + it("shadows direct fetch attempts with a clear error", () => { + const compiled = compileJsx(` + fetch("https://example.com"); + function App() { + return ; + } + `); + + const result = evaluateComponent(compiled, {}, () => Promise.resolve(null)); + + expect(result).toEqual({ + error: + "Evaluation error: fetch is disabled in generated UI. Use tools.* via useQuery/useMutation.", + }); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts new file mode 100644 index 000000000..37d85dd0a --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts @@ -0,0 +1,185 @@ +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, + useContext, + Fragment, + createContext, +} from "react"; +import { transform } from "sucrase"; + +import { + mutationOptions, + queryOptions, + skipToken, + useMutation, + useQuery, + useQueryClient, +} from "./hooks"; +import * as Components from "./components"; +import * as QueryHooks from "./hooks"; + +export type EvaluatedComponent = + | { component: React.ComponentType; config: Record } + | { error: string }; + +const createGeneratedCodeRequire = + () => + (specifier: string): unknown => { + if (specifier === "react") return React; + if (specifier === "@tanstack/react-query") return QueryHooks; + if (specifier === "recharts" || specifier === "lucide-react" || specifier === "./components") { + return Components; + } + throw new Error( + `Generated UI cannot import "${specifier}". Everything needed is already in scope; remove the import.`, + ); + }; + +const blockedNetworkPrimitive = (name: string) => + function blockedNetworkPrimitive() { + throw new Error(`${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`); + }; + +/** Compile JSX source to plain JS using Sucrase. */ +export function compileJsx(code: string): string { + const result = transform(code, { + transforms: ["jsx", "typescript", "imports"], + jsxRuntime: "classic", + production: true, + }); + return result.code; +} + +/** + * Evaluate compiled JS in a scoped context providing React, hooks, + * components, tools proxy, useQuery/useMutation, and Lucide icons. + */ +export function evaluateComponent( + compiled: string, + tools: Record, + run: (code: string) => Promise, +): EvaluatedComponent { + const module = { exports: {} as Record | React.ComponentType }; + const exports = module.exports; + + const scope: Record = buildGeneratedCodeScope({ module, exports, tools, run }); + + const scopeKeys = Object.keys(scope); + const scopeValues = scopeKeys.map((k) => scope[k]); + + // Execute the compiled code and look for a component + optional config. + // We check well-known names and common module export shapes so generated + // code stays resilient to `export default function App()` and friends. + const wrappedCode = ` + "use strict"; + ${compiled} + var __moduleExports = module && module.exports; + var __defaultExport = + __moduleExports && typeof __moduleExports === "object" && "default" in __moduleExports + ? __moduleExports.default + : exports && exports.default; + var __comp = null; + if (typeof App === "function") __comp = App; + else if (typeof Component === "function") __comp = Component; + else if (typeof Main === "function") __comp = Main; + else if (typeof __defaultExport === "function") __comp = __defaultExport; + else if (typeof __moduleExports === "function") __comp = __moduleExports; + else if (exports && typeof exports.App === "function") __comp = exports.App; + else if (exports && typeof exports.Component === "function") __comp = exports.Component; + else if (exports && typeof exports.Main === "function") __comp = exports.Main; + var __cfg = typeof config === "object" && config !== null ? config : {}; + return { component: __comp, config: __cfg }; + `; + + try { + // eslint-disable-next-line no-new-func + const factory = new Function(...scopeKeys, wrappedCode); + const result = factory(...scopeValues) as { + component: React.ComponentType | null; + config: Record; + }; + if (!result.component) { + return { error: "No component found. Export a function named App." }; + } + return { component: result.component, config: result.config }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[executor-shell] Failed to evaluate component:", err); + return { error: `Evaluation error: ${msg}` }; + } +} + +/** + * The single source of truth for what generated code may reference without + * declaring it. `evaluateComponent` passes these as the `new Function` + * parameter list, so a name here IS in scope in the iframe and a name missing + * here is NOT — which is why `render-ui`'s redeclaration validator is pinned + * against `PROVIDED_SCOPE_NAMES` by test rather than hand-maintained twice. + */ +function buildGeneratedCodeScope(bindings: { + module: unknown; + exports: unknown; + tools: Record; + run: (code: string) => Promise; +}): Record { + const { module, exports, tools, run } = bindings; + return { + // React core + React, + useState, + useEffect, + useRef, + useCallback, + useMemo, + useContext, + Fragment, + createContext, + + // Common module globals for resilient generated code evaluation. + module, + exports, + require: createGeneratedCodeRequire(), + + // Defense in depth for direct network attempts. The inner iframe CSP is + // the browser boundary; these shadows produce clearer runtime errors. + fetch: blockedNetworkPrimitive("fetch"), + XMLHttpRequest: blockedNetworkPrimitive("XMLHttpRequest"), + WebSocket: blockedNetworkPrimitive("WebSocket"), + EventSource: blockedNetworkPrimitive("EventSource"), + Worker: blockedNetworkPrimitive("Worker"), + SharedWorker: blockedNetworkPrimitive("SharedWorker"), + + // Data fetching + useQuery, + useMutation, + useQueryClient, + queryOptions, + mutationOptions, + skipToken, + + // Tools proxy + escape hatch + tools, + run, + + // All UI components, icons, chart primitives + ...Components, + }; +} + +/** + * Every name generated code may use without declaring it, derived from the + * real evaluation scope rather than re-listed by hand. `render-ui`'s + * redeclaration guard consumes this, so adding a component to `components.ts` + * automatically protects it from being shadowed. + */ +export const PROVIDED_SCOPE_NAMES: readonly string[] = Object.keys( + buildGeneratedCodeScope({ + module: { exports: {} }, + exports: {}, + tools: {}, + run: () => Promise.resolve(undefined), + }), +); diff --git a/packages/hosts/mcp-apps-shell/src/shell/components.ts b/packages/hosts/mcp-apps-shell/src/shell/components.ts new file mode 100644 index 000000000..d1d4fb192 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/components.ts @@ -0,0 +1,277 @@ +/** + * Barrel export of all UI components available to model-generated React code. + * These are re-exports of the existing shadcn components from @executor-js/react, + * plus Recharts primitives and Lucide icons. + */ + +// --------------------------------------------------------------------------- +// shadcn/ui components +// --------------------------------------------------------------------------- + +// Layout +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardAction, + CardContent, + CardFooter, +} from "@executor-js/react/components/card"; +export { Separator } from "@executor-js/react/components/separator"; +export { Tabs, TabsList, TabsTrigger, TabsContent } from "@executor-js/react/components/tabs"; +export { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@executor-js/react/components/accordion"; +export { ScrollArea, ScrollBar } from "@executor-js/react/components/scroll-area"; + +// Overlay +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} from "@executor-js/react/components/dialog"; +export { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetClose, +} from "@executor-js/react/components/sheet"; +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, +} from "@executor-js/react/components/popover"; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, +} from "@executor-js/react/components/tooltip"; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from "@executor-js/react/components/dropdown-menu"; + +// Input +export { Button } from "@executor-js/react/components/button"; +export { Input } from "@executor-js/react/components/input"; +export { Textarea } from "@executor-js/react/components/textarea"; +export { Label } from "@executor-js/react/components/label"; +export { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, +} from "@executor-js/react/components/select"; +export { Checkbox } from "@executor-js/react/components/checkbox"; +export { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; +export { Switch } from "@executor-js/react/components/switch"; +export { Slider } from "@executor-js/react/components/slider"; +export { Toggle } from "@executor-js/react/components/toggle"; +export { ToggleGroup, ToggleGroupItem } from "@executor-js/react/components/toggle-group"; + +// Data display +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableRow, + TableHead, + TableCell, + TableCaption, +} from "@executor-js/react/components/table"; +export { Badge } from "@executor-js/react/components/badge"; +export { Avatar, AvatarImage, AvatarFallback } from "@executor-js/react/components/avatar"; +export { Progress } from "@executor-js/react/components/progress"; +export { Skeleton } from "@executor-js/react/components/skeleton"; + +// Feedback +export { Alert, AlertTitle, AlertDescription } from "@executor-js/react/components/alert"; + +// Charts (shadcn wrappers around Recharts) +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} from "@executor-js/react/components/chart"; + +// --------------------------------------------------------------------------- +// Recharts primitives (exposed directly for model use) +// --------------------------------------------------------------------------- + +export { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + RadarChart, + Radar, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + RadialBarChart, + RadialBar, + ScatterChart, + Scatter, + ComposedChart, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Legend, + ReferenceLine, + ReferenceArea, + Brush, + Funnel, + FunnelChart, + Treemap, +} from "recharts"; + +// --------------------------------------------------------------------------- +// Lucide icons (common subset) +// --------------------------------------------------------------------------- + +export { + Plus, + Minus, + Check, + X, + ChevronDown, + ChevronUp, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + Search, + Loader2, + AlertCircle, + AlertTriangle, + Info, + ExternalLink, + Copy, + Trash2, + Edit, + Settings, + User, + Users, + Mail, + Calendar, + Clock, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUpRight, + Download, + Upload, + File, + FileText, + Folder, + FolderOpen, + Image, + Link, + Globe, + Home, + Star, + Heart, + Eye, + EyeOff, + Lock, + Unlock, + RefreshCw, + RotateCcw, + Filter, + SortAsc, + SortDesc, + MoreHorizontal, + MoreVertical, + Menu, + Grip, + GripVertical, + Code, + Terminal, + Database, + Server, + Cpu, + Zap, + Activity, + TrendingUp, + TrendingDown, + BarChart3, + PieChart as PieChartIcon, + GitBranch, + GitCommit, + GitPullRequest, + MessageSquare, + Send, + Bookmark, + Tag, + Hash, + AtSign, + Paperclip, + MapPin, + Phone, + Video, + Mic, + Volume2, + VolumeX, + Play, + Pause, + Square, + Circle, + Triangle, + Hexagon, + Box, + Package, + Shield, + Key, + Wifi, + WifiOff, + Battery, + Sun, + Moon, + CloudRain, + Thermometer, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +export { cn } from "@executor-js/react/lib/utils"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/globals.css b/packages/hosts/mcp-apps-shell/src/shell/globals.css new file mode 100644 index 000000000..9fb13129a --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/globals.css @@ -0,0 +1,147 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@source "./**/*.{ts,tsx}"; +@source "../../../../react/src/components/**/*.{ts,tsx}"; + +@theme inline { + --font-sans: ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, monospace; + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); +} + +/* ——— Light theme ——— */ +:root { + color-scheme: light dark; + --radius: 0.625rem; + + --background: oklch(0.985 0.002 260); + --foreground: oklch(0.145 0.005 260); + --card: oklch(0.99 0.001 260); + --card-foreground: oklch(0.145 0.005 260); + --popover: oklch(0.99 0.001 260); + --popover-foreground: oklch(0.145 0.005 260); + --primary: oklch(0.55 0.17 175); + --primary-foreground: oklch(0.98 0.005 175); + --secondary: oklch(0.94 0.005 260); + --secondary-foreground: oklch(0.32 0.008 260); + --muted: oklch(0.955 0.004 260); + --muted-foreground: oklch(0.5 0.01 250); + --accent: oklch(0.94 0.006 260); + --accent-foreground: oklch(0.145 0.005 260); + --destructive: oklch(0.55 0.22 25); + --border: oklch(0.88 0.006 260); + --input: oklch(0.88 0.006 260); + --ring: oklch(0.55 0.17 175); + + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); +} + +/* ——— Dark theme ——— */ +@media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.115 0.005 260); + --foreground: oklch(0.9 0.008 250); + --card: oklch(0.145 0.005 260); + --card-foreground: oklch(0.9 0.008 250); + --popover: oklch(0.145 0.005 260); + --popover-foreground: oklch(0.9 0.008 250); + --primary: oklch(0.72 0.15 175); + --primary-foreground: oklch(0.12 0.01 175); + --secondary: oklch(0.195 0.007 260); + --secondary-foreground: oklch(0.78 0.008 250); + --muted: oklch(0.175 0.005 260); + --muted-foreground: oklch(0.65 0.01 250); + --accent: oklch(0.195 0.008 260); + --accent-foreground: oklch(0.9 0.008 250); + --destructive: oklch(0.62 0.22 25); + --border: oklch(0.28 0.007 260); + --input: oklch(0.195 0.007 260); + --ring: oklch(0.72 0.15 175); + + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + } +} + +.dark { + --background: oklch(0.115 0.005 260); + --foreground: oklch(0.9 0.008 250); + --card: oklch(0.145 0.005 260); + --card-foreground: oklch(0.9 0.008 250); + --popover: oklch(0.145 0.005 260); + --popover-foreground: oklch(0.9 0.008 250); + --primary: oklch(0.72 0.15 175); + --primary-foreground: oklch(0.12 0.01 175); + --secondary: oklch(0.195 0.007 260); + --secondary-foreground: oklch(0.78 0.008 250); + --muted: oklch(0.175 0.005 260); + --muted-foreground: oklch(0.65 0.01 250); + --accent: oklch(0.195 0.008 260); + --accent-foreground: oklch(0.9 0.008 250); + --destructive: oklch(0.62 0.22 25); + --border: oklch(0.28 0.007 260); + --input: oklch(0.195 0.007 260); + --ring: oklch(0.72 0.15 175); + + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); +} + +@layer base { + * { + @apply border-border; + } + + html, + body, + #root { + min-height: 100%; + margin: 0; + padding: 0; + } + + body { + @apply bg-background font-sans text-foreground antialiased; + } +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/hooks.ts b/packages/hosts/mcp-apps-shell/src/shell/hooks.ts new file mode 100644 index 000000000..873a64e9f --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/hooks.ts @@ -0,0 +1,114 @@ +import { useRef } from "react"; +import { + QueryClient, + QueryClientProvider, + mutationOptions, + queryOptions, + skipToken, + useMutation as useTanStackMutation, + useQuery as useTanStackQuery, + useQueryClient as useTanStackQueryClient, + type QueryClient as QueryClientType, + type UseMutationOptions, + type UseMutationResult, + type UseQueryOptions, + type UseQueryResult, +} from "@tanstack/react-query"; + +export { QueryClient, QueryClientProvider, mutationOptions, queryOptions, skipToken }; + +const invalidationScopes: Array>> = []; + +const trackInvalidation = (promise: Promise) => { + for (const scope of invalidationScopes) { + scope.push(promise); + } + return promise; +}; + +const trackMutationCallback = async (callback: () => T | Promise): Promise => { + const scope: Array> = []; + invalidationScopes.push(scope); + try { + const result = await callback(); + await Promise.allSettled(scope); + return result; + } finally { + invalidationScopes.pop(); + } +}; + +const wrapQueryClient = (client: QueryClientType): QueryClientType => + new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) as unknown; + if (prop === "invalidateQueries" && typeof value === "function") { + return (...args: unknown[]) => + trackInvalidation( + (value as (...args: unknown[]) => Promise).apply(target, args), + ); + } + return typeof value === "function" ? value.bind(target) : value; + }, + }) as QueryClientType; + +export const useQueryClient = (queryClient?: QueryClientType): QueryClientType => { + const client = useTanStackQueryClient(queryClient); + const wrappedRef = useRef<{ client: QueryClientType; wrapped: QueryClientType } | null>(null); + + if (wrappedRef.current?.client !== client) { + wrappedRef.current = { client, wrapped: wrapQueryClient(client) }; + } + + return wrappedRef.current.wrapped; +}; + +const wrapMutationOptions = ( + options: UseMutationOptions, +): UseMutationOptions => ({ + ...options, + onSuccess: options.onSuccess + ? (...args: Parameters>) => + trackMutationCallback(() => options.onSuccess?.(...args)) + : undefined, + onError: options.onError + ? (...args: Parameters>) => + trackMutationCallback(() => options.onError?.(...args)) + : undefined, + onSettled: options.onSettled + ? (...args: Parameters>) => + trackMutationCallback(() => options.onSettled?.(...args)) + : undefined, +}); + +export function useQuery< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends readonly unknown[] = readonly unknown[], +>( + options: UseQueryOptions, + queryClient?: QueryClientType, +): UseQueryResult; +export function useQuery< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends readonly unknown[] = readonly unknown[], +>( + options: UseQueryOptions, + queryClient?: QueryClientType, +): UseQueryResult { + return useTanStackQuery(options, queryClient); +} + +export function useMutation( + options: UseMutationOptions, + queryClient?: QueryClientType, +): UseMutationResult; +export function useMutation( + options: UseMutationOptions, + queryClient?: QueryClientType, +): UseMutationResult { + return useTanStackMutation(wrapMutationOptions(options), queryClient); +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts new file mode 100644 index 000000000..8dce7ba81 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:executor-inner-renderer" { + const source: string; + export default source; +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx new file mode 100644 index 000000000..37c5c3a15 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx @@ -0,0 +1,309 @@ +import React, { type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { + QueryClient, + QueryClientProvider, + mutationOptions, + queryOptions, + skipToken, + type MutationKey, + type QueryFilters, + type QueryKey, + type UseMutationOptions, + type UseQueryOptions, +} from "@tanstack/react-query"; + +import { compileJsx, evaluateComponent } from "./component-runtime"; +import * as Components from "./components"; + +type ParentRequestPayload = + | { type: "executor.toolCall"; path: string[]; args: unknown[] } + | { type: "executor.run"; code: string }; + +type ParentResponse = { + type: "executor.response"; + requestId: number; + token: string; + ok: boolean; + value?: unknown; + error?: string; +}; + +type RenderMessage = { + type: "executor.render"; + token: string; + code: string; + theme?: unknown; +}; + +type ThemeMessage = { + type: "executor.theme"; + token: string; + theme?: unknown; +}; + +type InboundMessage = ParentResponse | RenderMessage | ThemeMessage; + +const token = document.querySelector( + "meta[name='executor-render-token']", +)?.content; + +if (!token) { + throw new Error("Missing renderer token."); +} + +const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + } +>(); + +let nextRequestId = 0; +let root: ReturnType | null = null; + +const blockedNetwork = (name: string) => () => { + throw new Error(`${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`); +}; + +Object.assign(globalThis, { + fetch: blockedNetwork("fetch"), + XMLHttpRequest: blockedNetwork("XMLHttpRequest"), + WebSocket: blockedNetwork("WebSocket"), + EventSource: blockedNetwork("EventSource"), + Worker: blockedNetwork("Worker"), + SharedWorker: blockedNetwork("SharedWorker"), +}); + +const sendParent = (message: Record) => { + window.parent.postMessage({ ...message, token }, "*"); +}; + +const requestParent = (message: ParentRequestPayload): Promise => { + const requestId = ++nextRequestId; + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }); + sendParent({ ...message, requestId }); + }); +}; + +function makeQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + mutations: { + retry: false, + }, + }, + }); +} + +let queryClient: QueryClient = makeQueryClient(); + +const toolQueryKey = (path: readonly string[], input?: unknown): QueryKey => [ + "executor-tool", + path, + { input, type: "query" }, +]; + +const toolPathKey = (path: readonly string[]): QueryKey => ["executor-tool", path]; + +const toolMutationKey = (path: readonly string[]): MutationKey => [ + "executor-tool", + path, + { type: "mutation" }, +]; + +const queryFilter = ( + path: readonly string[], + input?: unknown, + filters?: Omit, +): QueryFilters => ({ + ...filters, + queryKey: toolQueryKey(path, input), +}); + +const pathFilter = ( + path: readonly string[], + filters?: Omit, +): QueryFilters => ({ + ...filters, + queryKey: toolPathKey(path), +}); + +const createToolsProxy = (): Record => { + const nest = (path: string[]): unknown => + new Proxy(function () {}, { + get(_target, key: string | symbol) { + if (key === "then" || key === "toJSON" || key === Symbol.toPrimitive) return undefined; + if (typeof key !== "string") return undefined; + if (key === "queryOptions") { + return (input?: unknown, options?: Omit) => + queryOptions({ + ...options, + queryKey: toolQueryKey(path, input === skipToken ? undefined : input), + queryFn: + input === skipToken + ? skipToken + : () => requestParent({ type: "executor.toolCall", path, args: [input ?? {}] }), + }); + } + if (key === "queryKey") { + return (input?: unknown) => toolQueryKey(path, input); + } + if (key === "queryFilter") { + return (input?: unknown, filters?: Omit) => + queryFilter(path, input, filters); + } + if (key === "pathKey") { + return () => toolPathKey(path); + } + if (key === "pathFilter") { + return (filters?: Omit) => pathFilter(path, filters); + } + if (key === "mutationOptions") { + return (options?: Omit) => + mutationOptions({ + ...options, + mutationKey: toolMutationKey(path), + mutationFn: (input?: unknown) => + requestParent({ type: "executor.toolCall", path, args: [input ?? {}] }), + }); + } + if (key === "mutationKey") { + return () => toolMutationKey(path); + } + return nest([...path, key]); + }, + apply(_target, _thisArg, args: unknown[]) { + return requestParent({ type: "executor.toolCall", path, args }); + }, + }); + + return nest([]) as Record; +}; + +const run = (code: string): Promise => requestParent({ type: "executor.run", code }); + +const applyTheme = (theme: unknown) => { + if (theme === "dark" || theme === "light") { + document.documentElement.classList.toggle("dark", theme === "dark"); + } +}; + +const renderNode = (node: ReactNode) => { + const mount = document.getElementById("root"); + if (!mount) return; + root ??= createRoot(mount); + root.render({node}); +}; + +const renderError = (title: string, message: string) => { + renderNode( +
+ + + {title} + + {message} + + +
, + ); + sendParent({ type: "executor.renderer.error", message }); +}; + +class ErrorBoundary extends React.Component<{ children: ReactNode }, { error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override render() { + if (this.state.error) { + return ( + + + Runtime Error + + {this.state.error.message} + {this.state.error.stack && ( +
{this.state.error.stack}
+ )} +
+
+ ); + } + return this.props.children; + } +} + +const renderGeneratedCode = (code: string) => { + try { + const compiled = compileJsx(code); + const evalResult = evaluateComponent(compiled, createToolsProxy(), run); + + if ("error" in evalResult) { + renderError("Error", evalResult.error); + return; + } + + sendParent({ type: "executor.renderer.config", config: evalResult.config }); + const Component = evalResult.component; + queryClient = makeQueryClient(); + renderNode( + + + + + , + ); + } catch (err) { + renderError("Compilation Error", err instanceof Error ? err.message : String(err)); + } +}; + +window.addEventListener("message", (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object" || data.token !== token) return; + + if (data.type === "executor.response") { + const entry = pending.get(data.requestId); + if (!entry) return; + pending.delete(data.requestId); + if (data.ok) { + entry.resolve(data.value); + } else { + entry.reject(new Error(data.error ?? "Renderer request failed")); + } + return; + } + + if (data.type === "executor.theme") { + applyTheme(data.theme); + return; + } + + if (data.type === "executor.render") { + applyTheme(data.theme); + renderGeneratedCode(data.code); + } +}); + +const resizeObserver = new ResizeObserver(([entry]) => { + sendParent({ + type: "executor.renderer.size", + height: Math.ceil(entry.contentRect.height), + }); +}); + +resizeObserver.observe(document.body); +sendParent({ type: "executor.renderer.ready" }); diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html new file mode 100644 index 000000000..7d347f683 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + Executor Shell + + +
+ + + diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx new file mode 100644 index 000000000..da9d82fc9 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import { McpAppsShell } from "./shell-app"; + +function ConnectedShellApp() { + const { app, error: connectionError } = useApp({ + appInfo: { name: "Executor Shell", version: "1.0.0" }, + capabilities: {}, + }); + + if (connectionError) { + return ( +
+
Connection error: {connectionError.message}
+
+ ); + } + + if (!app) { + return ( +
+
Connecting
+
+ ); + } + + return ; +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts new file mode 100644 index 000000000..33fdc0141 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; + +import { PROVIDED_SCOPE_NAMES } from "./component-runtime"; + +// The `render-ui` tool rejects code that redeclares a name the shell already +// binds. That guard lives in the MCP host (which must not import React), so the +// name list is generated into the execution package by +// `scripts/gen-provided-globals.ts`. This test is the pin: add a component to +// `components.ts` without regenerating and it fails here rather than silently +// letting a model shadow a real binding at render time. +describe("provided globals", () => { + it("matches the scope the iframe actually binds", () => { + expect([...PROVIDED_GLOBAL_NAMES].sort()).toEqual([...PROVIDED_SCOPE_NAMES].sort()); + }); + + it("covers the names the shell is built around", () => { + for (const name of ["React", "useState", "useQuery", "tools", "run", "Card", "cn"]) { + expect(PROVIDED_SCOPE_NAMES).toContain(name); + } + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts new file mode 100644 index 000000000..794759aa6 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts @@ -0,0 +1,154 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export type ToolCallHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; +}; + +export type TrustedInteraction = { + executionId: string; + interaction: { + kind?: unknown; + message?: unknown; + url?: unknown; + requestedSchema?: unknown; + }; +}; + +export type TrustedInteractionResponse = { + action: "accept" | "decline" | "cancel"; + content?: Record; +}; + +export type RequestTrustedInteraction = ( + interaction: TrustedInteraction, +) => Promise; + +/** + * Creates a tRPC-style recursive proxy that maps dotted tool paths + * to execute-action calls through the MCP Apps bridge. + * + * Usage: tools.github.issues.create({ title: "Bug" }) + * becomes: app.callServerTool("execute-action", { code: "return await tools.github.issues.create({\"title\":\"Bug\"})" }) + */ +export function createToolsProxy( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, +): Record { + function nest(path: string[]): unknown { + return new Proxy(function () {}, { + get(_target, key: string) { + if (key === "then" || key === "toJSON" || key === (Symbol.toPrimitive as unknown)) { + return undefined; + } + return nest([...path, key]); + }, + apply(_target, _thisArg, args: unknown[]) { + const toolPath = path.join("."); + const serializedArgs = args.length > 0 ? JSON.stringify(args[0]) : "{}"; + const code = `return await tools.${toolPath}(${serializedArgs})`; + + return app + .callServerTool({ + name: "execute-action", + arguments: { code }, + }) + .then((r) => resolveToolResult(app, r, requestTrustedInteraction)); + }, + }); + } + + return nest([]) as Record; +} + +/** + * Creates the `run()` escape hatch for multi-step tool composition. + * + * Usage: const result = await run(` + * const me = await tools.github.users.me() + * return await tools.github.issues.create({ assignee: me.login, ... }) + * `) + */ +export function createRunFn( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, +): (code: string) => Promise { + return (code: string) => + app + .callServerTool({ + name: "execute-action", + arguments: { code }, + }) + .then((r) => resolveToolResult(app, r, requestTrustedInteraction)); +} + +async function resolveToolResult( + app: ToolCallHost, + result: CallToolResult, + requestTrustedInteraction: RequestTrustedInteraction, +): Promise { + 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 | 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); + } + + return unwrapResult(structured) ?? parseTextContent(result); +} + +function parseTrustedInteraction( + structured: Record | undefined, +): TrustedInteraction | null { + if (!structured || structured.status !== "waiting_for_interaction") return null; + if (typeof structured.executionId !== "string") return null; + const interaction = + typeof structured.interaction === "object" && + structured.interaction !== null && + !Array.isArray(structured.interaction) + ? (structured.interaction as TrustedInteraction["interaction"]) + : {}; + return { executionId: structured.executionId, interaction }; +} + +/** + * Unwrap execution result. The kernel wraps results as + * `{ status: "completed", result: , logs: [...] }`. + * Return just the inner result value. + */ +function unwrapResult(structured: Record | undefined | null): unknown { + if ( + structured && + typeof structured === "object" && + "status" in structured && + "result" in structured + ) { + return structured.result; + } + return structured; +} + +function parseTextContent(r: { content?: Array<{ type: string; text?: string }> }): unknown { + const text = r.content?.find((c) => c.type === "text")?.text; + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx new file mode 100644 index 000000000..470f90d28 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -0,0 +1,623 @@ +import "./globals.css"; +import "@tailwindcss/browser"; + +import React, { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { useElicitationApproval } from "@executor-js/react/components/elicitation-approval"; + +import { + createToolsProxy, + createRunFn, + type ToolCallHost, + type TrustedInteraction, + type TrustedInteractionResponse, +} from "./proxy"; +import * as Components from "./components"; +import innerRendererScript from "virtual:executor-inner-renderer"; + +type PendingInteraction = TrustedInteraction & { + resolve: (response: TrustedInteractionResponse) => void; +}; + +export type McpAppsShellHost = ToolCallHost & { + readonly getHostContext: () => McpUiHostContext | undefined; + readonly openLink: (params: { url: string }) => Promise; + ontoolinput?: (params: { arguments?: Record }) => void; + ontoolresult?: (result: CallToolResult) => void; + onerror?: (err: Error) => void; + onhostcontextchanged?: (ctx: McpUiHostContext) => void; +}; + +type RendererState = { + token: string; + code: string; + srcDoc: string; + config: Record; + height: number; +}; + +type RendererRequest = + | { + type: "executor.toolCall"; + requestId: number; + token: string; + path: unknown; + args: unknown; + } + | { type: "executor.run"; requestId: number; token: string; code: unknown } + | { type: "executor.renderer.ready"; token: string } + | { type: "executor.renderer.config"; token: string; config: unknown } + | { type: "executor.renderer.size"; token: string; height: unknown } + | { type: "executor.renderer.error"; token: string; message: unknown }; + +// --------------------------------------------------------------------------- +// Theme application from MCP Apps host context +// --------------------------------------------------------------------------- + +function applyTheme(ctx: McpUiHostContext) { + if (ctx.theme) { + document.documentElement.classList.toggle("dark", ctx.theme === "dark"); + } +} + +const createRendererToken = (): string => { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `renderer_${Date.now()}_${Math.random().toString(36).slice(2)}`; +}; + +const escapeInlineHtml = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +const escapeStyleContent = (value: string): string => value.replace(/<\/style/gi, "<\\/style"); + +const escapeScriptContent = (value: string): string => value.replace(/<\/script/gi, "<\\/script"); + +const collectShellCss = (): string => + Array.from(document.styleSheets) + .map((sheet) => { + try { + return Array.from(sheet.cssRules) + .map((rule) => rule.cssText) + .join("\n"); + } catch { + return ""; + } + }) + .filter((css) => css.length > 0) + .join("\n"); + +const buildRendererSrcDoc = (token: string): string => { + const css = collectShellCss(); + return ` + + + + + + + + +
+ + +`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; + +const toolPathToCode = (path: unknown, args: unknown): string => { + if (!Array.isArray(path) || path.length === 0) { + throw new Error("Invalid tool path."); + } + const parts = path.map((part) => { + if (typeof part !== "string" || !TOOL_PATH_SEGMENT.test(part)) { + throw new Error("Invalid tool path."); + } + return part; + }); + const argList = Array.isArray(args) ? args : []; + const serializedArgs = JSON.stringify(argList[0] ?? {}); + return `return await tools.${parts.join(".")}(${serializedArgs})`; +}; + +// --------------------------------------------------------------------------- +// Shell App — connects to MCP host, receives code, renders components +// --------------------------------------------------------------------------- + +export function McpAppsShell({ + app, + initialCode, +}: { + app: McpAppsShellHost; + initialCode?: string | undefined; +}) { + const [component, setComponent] = useState(null); + const [renderer, setRenderer] = useState(null); + const [error, setError] = useState(null); + const [hostContext, setHostContext] = useState(); + const [pendingInteraction, setPendingInteraction] = useState(null); + const toolsRef = useRef>({}); + const runRef = useRef<(code: string) => Promise>(() => Promise.resolve(null)); + const pendingInteractionRef = useRef(null); + const rendererFrameRef = useRef(null); + const rendererRef = useRef(null); + + useEffect(() => { + rendererRef.current = renderer; + }, [renderer]); + + const requestTrustedInteraction = useCallback( + (interaction: TrustedInteraction): Promise => + new Promise((resolve) => { + if (pendingInteractionRef.current) { + resolve({ action: "cancel" }); + return; + } + + const pending = { ...interaction, resolve }; + pendingInteractionRef.current = pending; + setPendingInteraction(pending); + }), + [], + ); + + const completeTrustedInteraction = useCallback((response: TrustedInteractionResponse) => { + const pending = pendingInteractionRef.current; + pendingInteractionRef.current = null; + setPendingInteraction(null); + pending?.resolve(response); + }, []); + + const postToRenderer = useCallback((message: Record) => { + const current = rendererRef.current; + const target = rendererFrameRef.current?.contentWindow; + if (!current || !target) return; + target.postMessage({ ...message, token: current.token }, "*"); + }, []); + + useEffect(() => { + const handleRendererMessage = (event: MessageEvent) => { + const current = rendererRef.current; + if (!current || event.source !== rendererFrameRef.current?.contentWindow) return; + const data = event.data; + if (!isRecord(data) || data.token !== current.token) return; + const source = event.source; + if (!source || typeof source.postMessage !== "function") return; + const respond = (requestId: number, ok: boolean, value?: unknown, error?: string) => { + source.postMessage( + { + type: "executor.response", + requestId, + token: current.token, + ok, + value, + error, + }, + "*", + ); + }; + + if (data.type === "executor.renderer.ready") { + postToRenderer({ + type: "executor.render", + code: current.code, + theme: hostContext?.theme, + }); + return; + } + + if (data.type === "executor.renderer.config") { + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, config: isRecord(data.config) ? data.config : {} } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.size") { + const height = typeof data.height === "number" ? Math.ceil(data.height) : current.height; + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, height: Math.max(120, Math.min(4000, height)) } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.error") { + if (typeof data.message === "string") { + console.error("[executor-shell] Renderer error:", data.message); + } + return; + } + + if (data.type === "executor.run") { + if (typeof data.code !== "string") { + respond(data.requestId, false, undefined, "Invalid run payload."); + return; + } + runRef + .current(data.code) + .then((value) => respond(data.requestId, true, value)) + .catch((err: unknown) => + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ), + ); + return; + } + + if (data.type === "executor.toolCall") { + let code: string; + try { + code = toolPathToCode(data.path, data.args); + } catch (err) { + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ); + return; + } + runRef + .current(code) + .then((value) => respond(data.requestId, true, value)) + .catch((err: unknown) => + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ), + ); + } + }; + + window.addEventListener("message", handleRendererMessage); + return () => window.removeEventListener("message", handleRendererMessage); + }, [hostContext?.theme, postToRenderer]); + + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.theme", theme: hostContext?.theme }); + } + }, [hostContext?.theme, postToRenderer, renderer]); + + /** Render a JSX code string in the sandboxed inner iframe. */ + const renderCode = useCallback((code: string) => { + try { + const token = createRendererToken(); + const nextRenderer = { + token, + code, + srcDoc: buildRendererSrcDoc(token), + config: {}, + height: 240, + }; + rendererRef.current = nextRenderer; + setRenderer(nextRenderer); + setComponent(null); + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`Compilation error: ${msg}`); + setComponent(null); + rendererRef.current = null; + setRenderer(null); + } + }, []); + + useEffect(() => { + toolsRef.current = createToolsProxy(app, requestTrustedInteraction); + runRef.current = createRunFn(app, requestTrustedInteraction); + + // Handle tool input — fires on init (including page reload) with + // the tool arguments. For generative UI the arguments contain { code }. + app.ontoolinput = (params: { arguments?: Record }) => { + const code = params.arguments?.code; + if (code && typeof code === "string") { + renderCode(code); + } + }; + + app.ontoolresult = (result: CallToolResult) => { + const structured = result.structuredContent as Record | undefined; + const code = structured?.code; + + if (code && typeof code === "string") { + renderCode(code); + return; + } + + // Not a generative UI result — render a data view + const DataView = () => { + const text = result.content?.find((c) => c.type === "text")?.text; + const isError = (result as { isError?: boolean }).isError; + const data = structured as Record | undefined; + + return ( + + + {isError ? ( + + + Error + + {text ?? "Unknown error"} + + + ) : ( +
+                  {data ? JSON.stringify(data, null, 2) : (text ?? "(no result)")}
+                
+ )} +
+
+ ); + }; + setComponent(() => DataView); + rendererRef.current = null; + setRenderer(null); + setError(null); + }; + + app.onerror = (err) => { + console.error("[executor-shell] App error:", err); + }; + + app.onhostcontextchanged = (ctx: McpUiHostContext) => { + setHostContext((prev) => ({ ...prev, ...ctx })); + applyTheme(ctx); + }; + + (app as { onteardown?: () => Promise> }).onteardown = async () => { + return {}; + }; + }, [app, renderCode, requestTrustedInteraction]); + + // Apply initial host context + useEffect(() => { + const ctx = app.getHostContext(); + if (ctx) { + setHostContext(ctx); + applyTheme(ctx); + } + }, [app]); + + useEffect(() => { + if (initialCode) renderCode(initialCode); + }, [initialCode, renderCode]); + + if (error) { + return ( +
+ + + Error + + {error} + + +
+ ); + } + + if (!component && !renderer) { + return ( +
+ +
+ ); + } + + const Component = component; + const config = renderer?.config ?? {}; + const maxHeight = typeof config.maxHeight === "number" ? config.maxHeight : 800; + const rendererHeight = renderer ? Math.min(renderer.height, maxHeight) : undefined; + + return ( + +
+ {renderer ? ( + + + +`; + +const startShellServer = async (): Promise => { + const server = await createViteServer({ + configFile: resolve(packageRoot, "vite.config.shell.ts"), + clearScreen: false, + logLevel: "error", + server: { + host: "127.0.0.1", + port: 0, + }, + }); + + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) { + throw new Error("Vite did not report a local shell URL."); + } + + return { + url: url.replace(/\/$/, ""), + close: () => server.close(), + }; +}; + +const readBody = (request: IncomingMessage): Promise => + new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.from(chunk)); + }); + request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8"))); + request.on("error", rejectBody); + }); + +const startOpenApiServer = (): Promise => + new Promise((resolveServer, rejectServer) => { + let baseUrl = ""; + let domainAutoRenew = false; + let nextDomainGetDelayMs = 0; + const postRequests: string[] = []; + + const server: Server = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/openapi.json") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Inventory", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/items": { + get: { + operationId: "listItems", + responses: { + "200": { + description: "Inventory items", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + }, + }, + }, + post: { + operationId: "createItem", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateItem" }, + }, + }, + }, + responses: { + "200": { + description: "Created item", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreatedItem" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}": { + get: { + operationId: "getDomain", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}/auto-renew": { + post: { + operationId: "updateDomainAutoRenew", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutoRenewInput" }, + }, + }, + }, + responses: { + "200": { + description: "Updated domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AutoRenewInput: { + type: "object", + required: ["autoRenew"], + properties: { + autoRenew: { type: "boolean" }, + }, + }, + Domain: { + type: "object", + required: ["domain", "renew"], + properties: { + domain: { type: "string" }, + renew: { type: "boolean" }, + }, + }, + Item: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + }, + }, + CreateItem: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }, + CreatedItem: { + type: "object", + required: ["id", "name", "created"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + created: { type: "boolean" }, + }, + }, + }, + }, + }), + ); + return; + } + + if (request.method === "GET" && request.url === "/items") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([{ id: 1, name: "Seed Widget" }])); + return; + } + + if (request.method === "GET" && request.url?.startsWith("/domains/")) { + const domain = decodeURIComponent(request.url.slice("/domains/".length)); + if (!domain.includes("/")) { + const delayMs = nextDomainGetDelayMs; + nextDomainGetDelayMs = 0; + if (delayMs > 0) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); + } + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ domain, renew: domainAutoRenew })); + return; + } + } + + if (request.method === "POST" && request.url === "/items") { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { name?: unknown }; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + id: 2, + name: typeof parsed.name === "string" ? parsed.name : "Unnamed", + created: true, + }), + ); + return; + } + + if ( + request.method === "POST" && + request.url?.startsWith("/domains/") && + request.url.endsWith("/auto-renew") + ) { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { autoRenew?: unknown }; + domainAutoRenew = parsed.autoRenew === true; + nextDomainGetDelayMs = 300; + const domainPath = request.url.slice( + "/domains/".length, + request.url.length - "/auto-renew".length, + ); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + domain: decodeURIComponent(domainPath), + renew: domainAutoRenew, + }), + ); + return; + } + + response.statusCode = 404; + response.end("not found"); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve OpenAPI server address.")); + return; + } + + const { port } = address as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + resolveServer({ + specUrl: `${baseUrl}/openapi.json`, + postRequests, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const makePausedResult = ( + id: string, + request: ReturnType, +): ExecutionResult => ({ + status: "paused", + execution: { id, elicitationContext: { address: formToolId, args: {}, request } }, +}); + +/** + * These tests drive the shell directly over postMessage rather than through + * `render-ui`, so persistence is out of scope here — but the ui tools only + * register when an artifacts port is present, and `execute-action` is what the + * shell actually calls. A trivial in-memory port is enough to bring them up. + */ +const makeInMemoryArtifacts = () => { + const rows = new Map(); + let seq = 0; + return { + list: (): Effect.Effect => Effect.sync(() => [...rows.values()]), + get: (id: string): Effect.Effect => + Effect.suspend(() => { + const row = rows.get(id); + return row ? Effect.succeed(row) : Effect.fail(new Error(`no artifact ${id}`)); + }), + save: (input: { + readonly title: string; + readonly description?: string | null; + readonly code: string; + }): Effect.Effect => + Effect.sync(() => { + seq += 1; + const artifact = { + id: ArtifactId.make(`art_${seq}`), + owner: "user" as const, + title: input.title, + description: input.description ?? null, + code: input.code, + createdAt: new Date(seq * 1000), + updatedAt: new Date(seq * 1000), + }; + rows.set(artifact.id, artifact); + return artifact; + }), + }; +}; + +const startMcpHarnessForEngine = async ( + engine: ExecutionEngine, +): Promise => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + engine, + loadAppShellHtml: loadMcpAppsShellHtml, + artifacts: makeInMemoryArtifacts(), + }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "browser-harness", version: "1.0.0" }, + { capabilities: appsWithoutElicitationCapabilities }, + ); + + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + + return { + callTool: async (params) => { + if (!params.name) { + throw new Error("Missing MCP tool name."); + } + return client.callTool({ + name: params.name, + arguments: params.arguments ?? {}, + }); + }, + close: async () => { + await clientTransport.close(); + await serverTransport.close(); + }, + }; +}; + +const startMcpHarness = async (openApi: OpenApiServer): Promise => { + const executor = await Effect.runPromise( + Effect.gen(function* () { + const built = yield* createExecutor( + makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }), + ); + // Tools only exist per connection, so register the integration and open + // one org `main` connection — that is what makes + // `tools.inventory.org.main.*` resolvable from generated code. + yield* built["inventory-test"].seed(); + yield* built.connections.create({ + owner: "org", + name: INVENTORY_CONNECTION, + integration: INVENTORY_SLUG, + template: INVENTORY_TEMPLATE, + value: "token", + }); + return built; + }), + ); + + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor({ + timeoutMs: 5_000, + memoryLimitBytes: 32 * 1024 * 1024, + }), + }); + + return startMcpHarnessForEngine(engine); +}; + +const startSchemaElicitationMcpHarness = (): Promise => + startMcpHarnessForEngine({ + getDescription: Effect.succeed("schema elicitation test executor"), + execute: () => Effect.succeed({ result: null }), + executeWithPause: () => + Effect.succeed( + makePausedResult( + "schema_exec", + FormElicitation.make({ + message: "Provide approval details", + requestedSchema: { + type: "object", + required: ["name", "count", "priority", "tags"], + properties: { + name: { + type: "string", + title: "Display name", + minLength: 2, + description: "Human-readable name to submit.", + }, + count: { + type: "integer", + title: "Count", + minimum: 1, + default: 2, + }, + priority: { + type: "string", + title: "Priority", + enum: ["low", "high"], + enumNames: ["Low", "High"], + }, + notify: { + type: "boolean", + title: "Notify", + default: false, + }, + tags: { + type: "array", + title: "Tags", + minItems: 1, + items: { + enum: ["alpha", "beta"], + enumNames: ["Alpha", "Beta"], + }, + }, + }, + }, + }), + ), + ), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + resume: (_executionId, response) => + Effect.succeed({ + status: "completed", + result: { result: response.content ?? {} }, + }), + }); + +const startHostServer = (shellUrl: string, mcp: McpHarness): Promise => + new Promise((resolveServer, rejectServer) => { + const html = createHostHtml(shellUrl); + const server: Server = createServer(async (request, response) => { + if (request.method === "POST" && request.url === "/tools/call") { + try { + const body = await readBody(request); + const params = JSON.parse(body) as HostToolCall; + const result = await mcp.callTool(params); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(result)); + } catch (err) { + response.statusCode = 500; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + content: [ + { + type: "text", + text: err instanceof Error ? err.message : String(err), + }, + ], + isError: true, + }), + ); + } + return; + } + + if (request.method !== "GET" || request.url !== "/") { + response.statusCode = 404; + response.end("not found"); + return; + } + + response.statusCode = 200; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(html); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve host server address.")); + return; + } + + const { port } = address as AddressInfo; + resolveServer({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const waitForValue = async ( + page: Page, + read: () => T | undefined, + label: string, +): Promise => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const value = read(); + if (value !== undefined) return value; + await page.waitForTimeout(50); + } + throw new Error(`Timed out waiting for ${label}.`); +}; + +const waitForShellFrame = (page: Page): Promise => + waitForValue( + page, + () => page.frames().find((frame) => frame.url().includes("/mcp-app.html")), + "MCP app shell iframe", + ); + +const waitForInnerFrame = async (page: Page, shellFrame: Frame): Promise => { + const locator = shellFrame.locator('iframe[title="Generated UI"]'); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const handle = await locator.elementHandle(); + const frame = await handle?.contentFrame(); + await handle?.dispose(); + if (frame) return frame; + await page.waitForTimeout(50); + } + throw new Error("Timed out waiting for generated UI iframe."); +}; + +const waitForHostInitialized = (page: Page) => + page.waitForFunction(() => + Boolean((window as unknown as BrowserHostWindow).__mcpHostState.initialized), + ); + +const getHostState = (page: Page): Promise => + page.evaluate(() => (window as unknown as BrowserHostWindow).__mcpHostState); + +const openHarness = async (browser: Browser, hostUrl: string) => { + const page = await browser.newPage(); + await page.goto(hostUrl, { waitUntil: "domcontentloaded" }); + const shellFrame = await waitForShellFrame(page); + await waitForHostInitialized(page); + await shellFrame.locator('[data-testid="shell-loading-state"]').waitFor({ timeout: 10_000 }); + return { page, shellFrame }; +}; + +const renderGeneratedUi = async (page: Page, shellFrame: Frame, code: string): Promise => { + await page.evaluate( + (value) => (window as unknown as BrowserHostWindow).__sendGeneratedUi(value), + code, + ); + await shellFrame.locator('iframe[title="Generated UI"]').waitFor({ timeout: 10_000 }); + return waitForInnerFrame(page, shellFrame); +}; + +describe("MCP app generated UI browser isolation", () => { + let openApiServer: OpenApiServer | undefined; + let mcpHarness: McpHarness | undefined; + let shellServer: ShellServer | undefined; + let hostServer: HostServer | undefined; + let browser: Browser | undefined; + + beforeAll(async () => { + openApiServer = await startOpenApiServer(); + mcpHarness = await startMcpHarness(openApiServer); + shellServer = await startShellServer(); + hostServer = await startHostServer(shellServer.url, mcpHarness); + browser = await chromium.launch({ + executablePath: chromeExecutablePath, + headless: process.env.PLAYWRIGHT_HEADLESS !== "0", + slowMo: process.env.PLAYWRIGHT_SLOWMO ? Number(process.env.PLAYWRIGHT_SLOWMO) : undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + }, 60_000); + + afterAll(async () => { + await browser?.close(); + await hostServer?.close(); + await shellServer?.close(); + await mcpHarness?.close(); + await openApiServer?.close(); + }, 30_000); + + it("runs generated UI in a sandboxed browser iframe and proxies live tool calls", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedDataCode); + await innerFrame.waitForFunction( + () => document.querySelector("#status")?.textContent === "Widget", + undefined, + { timeout: 10_000 }, + ); + + const rendererAttributes = await shellFrame + .locator('iframe[title="Generated UI"]') + .evaluate((element) => ({ + sandbox: element.getAttribute("sandbox"), + srcDoc: element.getAttribute("srcdoc") ?? "", + })); + + expect(rendererAttributes.sandbox).toBe("allow-scripts"); + expect(rendererAttributes.srcDoc).toContain('meta name="executor-render-token"'); + expect(rendererAttributes.srcDoc).toContain("default-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("connect-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("form-action 'none'"); + expect(rendererAttributes.srcDoc).toContain("frame-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("worker-src 'none'"); + + const parentAccess = await innerFrame.evaluate(() => { + try { + void window.parent.document.body; + return "allowed"; + } catch (err) { + return err instanceof DOMException ? err.name : String(err); + } + }); + expect(parentAccess).toBe("SecurityError"); + + const blockedText = (await innerFrame.locator("#blocked").textContent()) ?? ""; + for (const name of networkPrimitives) { + expect(blockedText).toContain( + `${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`, + ); + } + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.org.main.listItems({})", + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("blocks popup, form, and nested-frame escape attempts from generated UI", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedEscapeAttemptCode); + await innerFrame.locator("#escape-results").waitFor({ timeout: 10_000 }); + + const escapeText = (await innerFrame.locator("#escape-results").textContent()) ?? ""; + expect(escapeText).toContain("popup:true"); + expect(escapeText).toContain("form:"); + expect(escapeText).toContain("iframe:"); + + const pages = browser.contexts().flatMap((context) => context.pages()); + expect(pages).toHaveLength(1); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toHaveLength(0); + } finally { + await page.close(); + } + }, 30_000); + + it("rejects spoofed renderer messages unless the iframe window and token match", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + window.dispatchEvent( + new MessageEvent("message", { + source: window, + data: { type: "executor.run", requestId: 1, token, code: "return 42" }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { type: "executor.run", requestId: 2, token: "wrong", code: "return 42" }, + }), + ); + }); + + await page.waitForTimeout(100); + expect((await getHostState(page)).toolCalls).toHaveLength(0); + + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { type: "executor.run", requestId: 3, token, code: "return 42" }, + }), + ); + }); + + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.length === 1, + ); + expect((await getHostState(page)).toolCalls[0]).toEqual( + expect.objectContaining({ + name: "execute-action", + arguments: { code: "return 42" }, + }), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("handles elicitations in the trusted shell instead of the generated iframe", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").waitFor({ timeout: 10_000 }); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(await innerFrame.locator("text=Approve action").count()).toBe(0); + expect(await shellFrame.locator('[data-testid="trusted-interaction-fields"]').count()).toBe( + 0, + ); + expect(await shellFrame.locator("text=Response content").count()).toBe(0); + expect(openApiServer.postRequests).toHaveLength(0); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#approval-status")?.textContent === "Approved Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toEqual([JSON.stringify({ name: "Approved Widget" })]); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: "accept", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("resumes declined and canceled approvals without performing the mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + + for (const action of ["Decline", "Cancel"] as const) { + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const initialPostCount = openApiServer.postRequests.length; + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + await shellFrame.getByRole("button", { name: action }).click({ timeout: 10_000 }); + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.resumeCalls.length === 1, + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: action === "Decline" ? "decline" : "cancel", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + } + }, 30_000); + + it("blocks generated UI mutations that run on mount until trusted approval", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAutoMutationCode); + await innerFrame.locator("#auto-mutation-status").waitFor({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostStateBeforeApproval = await getHostState(page); + expect(hostStateBeforeApproval.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.org.main.createItem({"body":{"name":"Mount Widget"}})', + }, + }), + ]), + ); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-mutation-status")?.textContent === "Mount Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ name: "Mount Widget" })); + } finally { + await page.close(); + } + }, 30_000); + + it("updates live query state after an approved TanStack Query mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTanstackQueryCode); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#auto-renew-toggle").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "true", + undefined, + { timeout: 10_000 }, + ); + await innerFrame.waitForFunction( + () => + document.querySelector("#auto-renew-success")?.textContent === + "Auto-renew enabled successfully", + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ autoRenew: true })); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.org.main.getDomain({"domain":"openexecutor.com"})', + }, + }), + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.org.main.updateDomainAutoRenew({"domain":"openexecutor.com","body":{"autoRenew":true}})', + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("renders form elicitations as typed approval fields", async () => { + if (!browser || !shellServer) { + throw new Error("Browser harness did not start."); + } + + const schemaMcpHarness = await startSchemaElicitationMcpHarness(); + const schemaHostServer = await startHostServer(shellServer.url, schemaMcpHarness); + const { page, shellFrame } = await openHarness(browser, schemaHostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await shellFrame.locator("text=Provide approval details").waitFor({ timeout: 10_000 }); + + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Count").fill("3"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Notify").click(); + await shellFrame.getByLabel("Beta").click(); + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => + document.querySelector("#schema-status")?.textContent === + JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: "schema_exec", + action: "accept", + content: JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + }, + }), + ]); + } finally { + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 30_000); + + it("keeps trusted approval controls visible in a short host iframe", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + await page.evaluate(() => { + const appFrame = document.getElementById("app") as HTMLIFrameElement | null; + if (!appFrame) throw new Error("Missing app iframe."); + appFrame.style.height = "180px"; + }); + await shellFrame.waitForFunction(() => document.documentElement.clientHeight <= 180); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + const metrics = await shellFrame + .locator('[data-testid="trusted-interaction-modal"]') + .evaluate((modal) => { + const card = modal.querySelector('[data-testid="trusted-interaction-card"]'); + const body = modal.querySelector('[data-testid="trusted-interaction-body"]'); + const footer = modal.querySelector( + '[data-testid="trusted-interaction-footer"]', + ); + if (!card || !body || !footer) throw new Error("Missing trusted modal element."); + + const cardRect = card.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + bodyOverflowY: getComputedStyle(body).overflowY, + cardBottom: cardRect.bottom, + cardTop: cardRect.top, + footerBottom: footerRect.bottom, + footerTop: footerRect.top, + viewportHeight: document.documentElement.clientHeight, + }; + }); + + expect(metrics.bodyOverflowY).toBe("auto"); + expect(metrics.cardTop).toBeGreaterThanOrEqual(0); + expect(metrics.cardBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + expect(metrics.footerTop).toBeGreaterThanOrEqual(0); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + } finally { + await page.close(); + } + }, 30_000); +}); diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts new file mode 100644 index 000000000..06007a24e --- /dev/null +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; +import type * as Cause from "effect/Cause"; + +import { ArtifactId, FormElicitation, type Artifact, type ArtifactSummary } from "@executor-js/sdk"; +import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; + +import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./render-ui"; +import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +class TestArtifactError extends Data.TaggedError("TestArtifactError")<{ + readonly message: string; +}> {} + +const makeStubEngine = (overrides: { + executeWithPause?: ExecutionEngine["executeWithPause"]; + resume?: ExecutionEngine["resume"]; +}): ExecutionEngine => ({ + execute: () => Effect.succeed({ result: "default" }), + executeWithPause: + overrides.executeWithPause ?? + (() => Effect.succeed({ status: "completed", result: { result: "default" } })), + resume: overrides.resume ?? (() => Effect.succeed(null)), + isExecutionSettled: undefined, + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), +}); + +/** + * An in-memory stand-in for `executor.artifacts` with the same observable + * behavior the real owner-scoped store has: save mints an id (or overwrites in + * place), get fails when nothing matches, list is newest-first without code. + */ +const makeArtifactStore = () => { + const rows = new Map(); + let seq = 0; + const calls: Array<{ title: string; description: string | null; code: string }> = []; + return { + calls, + rows, + port: { + list: (): Effect.Effect => + Effect.sync(() => + [...rows.values()] + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .map(({ code: _code, ...summary }) => summary), + ), + get: (id: string): Effect.Effect => + Effect.suspend(() => { + const row = rows.get(id); + return row + ? Effect.succeed(row) + : Effect.fail(new TestArtifactError({ message: `no artifact ${id}` })); + }), + save: (input: { + readonly id?: string; + readonly title: string; + readonly description?: string | null; + readonly code: string; + }): Effect.Effect => + Effect.suspend(() => { + calls.push({ + title: input.title, + description: input.description ?? null, + code: input.code, + }); + const existing = input.id === undefined ? undefined : rows.get(input.id); + if (input.id !== undefined && !existing) { + return Effect.fail(new TestArtifactError({ message: `no artifact ${input.id}` })); + } + seq += 1; + const artifact: Artifact = { + id: ArtifactId.make(existing?.id ?? `art_${seq}`), + owner: "user", + title: input.title, + description: input.description ?? null, + code: input.code, + createdAt: existing?.createdAt ?? new Date(seq * 1000), + updatedAt: new Date(seq * 1000), + }; + rows.set(artifact.id, artifact); + return Effect.succeed(artifact); + }), + }, + }; +}; + +const withClient = async ( + engine: ExecutionEngine, + capabilities: ClientCapabilities, + fn: (client: Client) => Promise, + config?: Partial>, +) => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + engine, + loadAppShellHtml: () => Promise.resolve(SHELL_HTML), + ...config, + } as ExecutorMcpServerConfig), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions + try { + await fn(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +const SHELL_HTML = "
"; + +// What a client that renders MCP Apps advertises at `initialize`. +const APPS_CAPS = { + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, +} as unknown as ClientCapabilities; + +const NO_APPS_CAPS: ClientCapabilities = {}; + +const structuredOf = (result: Awaited>): Record => + (result.structuredContent ?? {}) as Record; + +const textOf = (result: Awaited>): string => + (result.content as Array<{ type: string; text: string }>)[0].text; + +const toolNames = async (client: Client): Promise => + (await client.listTools()).tools.map((tool) => tool.name); + +const COUNTER_CODE = "function App() { return
hi
; }"; + +const makePausedResult = (id: string, message: string): ExecutionResult => ({ + status: "paused", + execution: { + id, + elicitationContext: { + request: FormElicitation.make({ message, requestedSchema: {} }), + }, + } as ExecutionResult extends { status: "paused"; execution: infer P } ? P : never, +}); + +// --------------------------------------------------------------------------- +// Capability-gated visibility +// --------------------------------------------------------------------------- + +describe("MCP host — artifact tool visibility", () => { + it("hides the app-only tools from clients that cannot render MCP Apps", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const names = await toolNames(client); + // The model-facing three are always advertised — they degrade to a + // deep link rather than disappearing. + expect(names).toContain("render-ui"); + expect(names).toContain("list-artifacts"); + expect(names).toContain("show-artifact"); + // `execute-action` is only callable from inside a rendered app. + expect(names).not.toContain("execute-action"); + expect(names).not.toContain("execute-action-resume"); + }, + { artifacts: store.port }, + ); + }); + + it("exposes the app-only tools to clients that advertise the shell mime type", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const names = await toolNames(client); + expect(names).toContain("render-ui"); + expect(names).toContain("execute-action"); + expect(names).toContain("execute-action-resume"); + }, + { artifacts: store.port }, + ); + }); + + it("registers no ui tools at all when no shell loader is configured", async () => { + const store = makeArtifactStore(); + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ engine: makeStubEngine({}), artifacts: store.port }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names).toContain("execute"); + expect(names).not.toContain("render-ui"); + expect(names).not.toContain("list-artifacts"); + await clientTransport.close(); + await serverTransport.close(); + }); + + it("serves the shell as an MCP-Apps resource with a zero-domain CSP", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI }); + const [content] = read.contents; + expect(content.mimeType).toBe(RESOURCE_MIME_TYPE); + expect(content.text).toBe(SHELL_HTML); + // The shell may open no network connection of its own; everything + // routes back over the MCP bridge. + expect(content._meta).toMatchObject({ + ui: { csp: { connectDomains: [], resourceDomains: [] } }, + }); + }, + { artifacts: store.port }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// render-ui delivery +// --------------------------------------------------------------------------- + +describe("MCP host — render-ui", () => { + it("returns the code inline and persists it when the client renders apps", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "render-ui", + arguments: { + code: COUNTER_CODE, + title: "Active users dashboard", + description: "Daily active users over time", + }, + }); + expect(structuredOf(result)).toEqual({ code: COUNTER_CODE, artifactId: "art_1" }); + expect(result.isError).toBeFalsy(); + expect(store.calls).toEqual([ + { + title: "Active users dashboard", + description: "Daily active users over time", + code: COUNTER_CODE, + }, + ]); + }, + { artifacts: store.port, artifactUrl: artifactUrlFor("https://executor.test") }, + ); + }); + + it("returns a deep link and still persists when the client cannot render apps", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "render-ui", + arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, + }); + expect(structuredOf(result)).toEqual({ + status: "fallback_url", + url: "https://executor.test/artifacts/art_1", + artifactId: "art_1", + }); + // The model needs to be told to hand the URL over. + expect(textOf(result)).toContain("https://executor.test/artifacts/art_1"); + // Persistence is what makes the fallback possible at all. + expect(store.calls).toHaveLength(1); + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); + }, + { artifacts: store.port, artifactUrl: artifactUrlFor("https://executor.test") }, + ); + }); + + it("still persists and reports the id when no web UI is configured", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "render-ui", + arguments: { code: COUNTER_CODE, title: "Orphan dashboard" }, + }); + expect(structuredOf(result)).toEqual({ + status: "fallback_unavailable", + reason: "mcp_apps_unsupported", + artifactId: "art_1", + }); + expect(store.rows.get("art_1")?.title).toBe("Orphan dashboard"); + }, + { artifacts: store.port }, + ); + }); + + it("rejects redeclared provided globals before the code reaches the iframe", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const destructured = await client.callTool({ + name: "render-ui", + arguments: { + code: "const { useState } = React; function App(){ return null; }", + title: "Bad", + }, + }); + expect(destructured.isError).toBe(true); + expect(textOf(destructured)).toContain("Do not destructure React"); + + const shadowed = await client.callTool({ + name: "render-ui", + arguments: { code: "const Card = 1; function App(){ return null; }", title: "Bad" }, + }); + expect(shadowed.isError).toBe(true); + expect(textOf(shadowed)).toContain('Provided global "Card"'); + + // Nothing rejected is ever persisted. + expect(store.calls).toHaveLength(0); + }, + { artifacts: store.port }, + ); + }); + + it("allows display constants that the dropped data heuristic used to reject", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + // The donor branch rejected this on the variable name alone. Legit + // chart configuration is not a hardcoded live-data snapshot. + const result = await client.callTool({ + name: "render-ui", + arguments: { + code: "const series = [{ key: 'a', color: '#111' }, { key: 'b', color: '#222' }]; function App(){ return null; }", + title: "Chart", + }, + }); + expect(result.isError).toBeFalsy(); + expect(store.calls).toHaveLength(1); + }, + { artifacts: store.port }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Retrieval +// --------------------------------------------------------------------------- + +describe("MCP host — artifact retrieval", () => { + it("round-trips: render-ui saves, list-artifacts finds it, show-artifact returns the code", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await client.callTool({ + name: "render-ui", + arguments: { + code: COUNTER_CODE, + title: "Active users dashboard", + description: "DAU over time", + }, + }); + + const listed = await client.callTool({ name: "list-artifacts", arguments: {} }); + expect(structuredOf(listed)).toMatchObject({ + artifacts: [ + { id: "art_1", title: "Active users dashboard", description: "DAU over time" }, + ], + }); + // The text form is what a model without structured-output support reads. + expect(textOf(listed)).toContain("Active users dashboard"); + + const shown = await client.callTool({ + name: "show-artifact", + arguments: { id: "art_1" }, + }); + expect(structuredOf(shown)).toEqual({ code: COUNTER_CODE, artifactId: "art_1" }); + }, + { artifacts: store.port }, + ); + }); + + it("delivers a saved artifact as a deep link to clients without apps support", async () => { + const store = makeArtifactStore(); + await Effect.runPromise( + store.port.save({ title: "Saved earlier", description: null, code: COUNTER_CODE }), + ); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const shown = await client.callTool({ name: "show-artifact", arguments: { id: "art_1" } }); + expect(structuredOf(shown)).toEqual({ + status: "fallback_url", + url: "https://executor.test/artifacts/art_1", + artifactId: "art_1", + }); + }, + { artifacts: store.port, artifactUrl: artifactUrlFor("https://executor.test") }, + ); + }); + + it("reports a miss as an error result rather than failing the tool call", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const shown = await client.callTool({ + name: "show-artifact", + arguments: { id: "art_nope" }, + }); + expect(shown.isError).toBe(true); + expect(structuredOf(shown)).toMatchObject({ error: "artifact_not_found", id: "art_nope" }); + // The model is told how to recover. + expect(textOf(shown)).toContain("list-artifacts"); + }, + { artifacts: store.port }, + ); + }); + + it("lists nothing, without erroring, before anything is saved", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const listed = await client.callTool({ name: "list-artifacts", arguments: {} }); + expect(listed.isError).toBeFalsy(); + expect(structuredOf(listed)).toEqual({ artifacts: [] }); + expect(textOf(listed)).toContain("No saved artifacts"); + }, + { artifacts: store.port }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// execute-action — shell-owned approval +// --------------------------------------------------------------------------- + +describe("MCP host — execute-action", () => { + // The pin that matters: the shell renders the approval modal itself, in its + // trusted outer frame. A browser approval URL would be unusable from inside a + // widget, so `execute-action` must return the resolvable pause payload even + // when the session's elicitation mode is `browser`. + it("pauses for shell-owned approval instead of a browser approval URL", async () => { + const store = makeArtifactStore(); + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(makePausedResult("exec_app", "Approve UI action?")), + resume: (executionId, response) => + Effect.succeed( + executionId === "exec_app" + ? { status: "completed", result: { result: `action:${response.action}` } } + : null, + ), + }); + + await withClient( + engine, + APPS_CAPS, + async (client) => { + const paused = await client.callTool({ + name: "execute-action", + arguments: { code: "return await tools.github.issues.create({})" }, + }); + expect(paused.structuredContent).toMatchObject({ + status: "waiting_for_interaction", + executionId: "exec_app", + interaction: { message: "Approve UI action?" }, + }); + expect(textOf(paused)).not.toContain("executor.test/resume"); + + const resumed = await client.callTool({ + name: "execute-action-resume", + arguments: { executionId: "exec_app", action: "accept", content: "{}" }, + }); + expect(resumed.content).toEqual([{ type: "text", text: "action:accept" }]); + }, + { + artifacts: store.port, + elicitationMode: { + mode: "browser", + approvalUrl: (executionId) => `https://executor.test/resume/${executionId}`, + }, + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Deep-link shape +// --------------------------------------------------------------------------- + +describe("artifactUrlFor", () => { + it("builds /artifacts/:id against the host's origin", () => { + expect(artifactUrlFor("https://executor.sh")("art_123")).toBe( + "https://executor.sh/artifacts/art_123", + ); + }); + + it("ignores any path on the configured base and escapes the id", () => { + expect(artifactUrlFor("http://localhost:4788/")("art/../x")).toBe( + "http://localhost:4788/artifacts/art%2F..%2Fx", + ); + }); +}); From be86f246cf285caf20b45e942cd7e54f1acee11c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:57:41 -0700 Subject: [PATCH 3/3] Ship the MCP-Apps shell in binaries and revive the sunpeak e2e harness --- .oxlintrc.jsonc | 25 ++++++ apps/cli/src/build.ts | 31 +++++++ e2e/mcp-apps/.gitignore | 5 ++ e2e/mcp-apps/README.md | 67 ++++++++++++++ e2e/mcp-apps/package.json | 17 ++++ e2e/mcp-apps/playwright.config.ts | 28 ++++++ e2e/mcp-apps/scripts/check-sunpeak.mjs | 45 ++++++++++ e2e/mcp-apps/tests/render-ui.spec.ts | 87 +++++++++++++++++++ packages/hosts/mcp-apps-shell/CHANGELOG.md | 1 + .../src/shell-resource.smoke.test.ts | 1 + .../hosts/mcp/src/artifacts-tools.test.ts | 9 +- 11 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 e2e/mcp-apps/.gitignore create mode 100644 e2e/mcp-apps/README.md create mode 100644 e2e/mcp-apps/package.json create mode 100644 e2e/mcp-apps/playwright.config.ts create mode 100644 e2e/mcp-apps/scripts/check-sunpeak.mjs create mode 100644 e2e/mcp-apps/tests/render-ui.spec.ts create mode 100644 packages/hosts/mcp-apps-shell/CHANGELOG.md diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 9c7d8caf1..da06c5351 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -119,6 +119,31 @@ "executor/no-try-catch-or-throw": "off", }, }, + { + // boundary: the MCP-Apps shell is browser code, not Effect domain code. + // It runs inside a sandboxed iframe and talks to the host over + // postMessage and the MCP bridge — both of which are throw/Promise APIs + // with no Effect runtime present. `new Function` evaluation of generated + // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire + // all surface untyped JS errors that must be caught and rendered rather + // than modelled. Scoped to the iframe payload plus its build config; the + // package's Effect-facing loader (`src/shell-html.ts`) is deliberately + // NOT listed and uses inline suppressions instead. + "files": [ + "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}", + "packages/hosts/mcp-apps-shell/vite.config.shell.ts", + ], + "rules": { + "executor/no-double-cast": "off", + "executor/no-error-constructor": "off", + "executor/no-instanceof-error": "off", + "executor/no-json-parse": "off", + "executor/no-promise-catch": "off", + "executor/no-promise-reject": "off", + "executor/no-try-catch-or-throw": "off", + "executor/no-unknown-error-message": "off", + }, + }, { "files": ["packages/kernel/core/src/code-recovery.ts"], "rules": { diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 0f61ec225..2643f8749 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { return wasmPath; }; +// --------------------------------------------------------------------------- +// MCP-Apps shell (mcp-app.html) +// +// The MCP host serves the shell as the `ui://executor/shell.html` resource, and +// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime +// `fs.readFile` is invisible to `bun build --compile`, so without this the +// packaged binary would serve the "Shell not built" placeholder and every +// generated UI would render blank. Build it if missing and copy it next to the +// executable, where the loader finds it via `process.execPath` — the same +// colocation trick used for `libsql.node` / `emscripten-module.wasm`. +// --------------------------------------------------------------------------- + +const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); +const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); + +const ensureMcpAppsShell = async (): Promise => { + if (!existsSync(mcpAppsShellHtml)) { + console.log("Building MCP-Apps shell (mcp-app.html)..."); + await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); + } + if (!existsSync(mcpAppsShellHtml)) { + throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); + } + return mcpAppsShellHtml; +}; + const resolveOnePasswordCoreWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); const sdkPkg = req.resolve("@1password/sdk/package.json"); @@ -398,6 +424,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); const quickJsWasmPath = resolveQuickJsWasmPath(); + const mcpAppsShellPath = await ensureMcpAppsShell(); const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); const workerBundlerDistPath = resolveWorkerBundlerDistPath(); const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); @@ -423,6 +450,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { // Copy QuickJS WASM next to binary — loaded at runtime by the server await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); + // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, + // read from execDir at runtime by @executor-js/mcp-apps-shell. + await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); + // Copy 1Password's sdk-core WASM next to the binary. The patched // sdk-core loader falls back to this sibling file when bun --compile // bakes a build-machine node_modules path into __dirname. diff --git a/e2e/mcp-apps/.gitignore b/e2e/mcp-apps/.gitignore new file mode 100644 index 000000000..cb5522839 --- /dev/null +++ b/e2e/mcp-apps/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +test-results/ +playwright-report/ +.exdata/ diff --git a/e2e/mcp-apps/README.md b/e2e/mcp-apps/README.md new file mode 100644 index 000000000..a81ca3576 --- /dev/null +++ b/e2e/mcp-apps/README.md @@ -0,0 +1,67 @@ +# MCP Apps tests (sunpeak) + +Render and test executor's generative-UI MCP Apps (the `render-ui` tool from the +`mcp-apps-shell` plugin) in a **real MCP-Apps host**, headlessly, in CI, with **no +VM and no Claude/ChatGPT account**. + +[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude +and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts +the returned `ui://` resource in a sandboxed iframe with the host bridge, and +hands the test a frame-scoped handle to the rendered component. Every test runs +against both host simulations. This is the lightweight successor to the earlier +MCPJam Playwright harness (which we drove by hand via fragile UI selectors). + +## Run + +```bash +cd e2e/mcp-apps +npm install # also patches sunpeak (see below) and is isolated from the bun workspace +npm test # builds the shell, starts sunpeak + `executor mcp`, runs the specs +``` + +`playwright.config.ts` starts `executor mcp` over **stdio** (from source, so the +shell resource is served), so there is no daemon or HTTP token to manage. Specs +live in `tests/`; `npm run test:headed` and `npm run report` help when debugging. + +A spec is tiny: + +```ts +import { test, expect } from "sunpeak/test"; +test("widget mounts", async ({ inspector }) => { + const result = await inspector.renderTool("render-ui", { code: APP_SRC }); + const app = result.app().frameLocator("iframe"); // see "extra iframe" below + await expect(app.locator('button:has-text("Increment")')).toBeVisible(); +}); +``` + +## Two interop notes (both handled here) + +1. **sunpeak doesn't advertise the MCP-Apps UI _client_ capability.** Its + inspector connects with `new Client({ name, version })` and never declares + `capabilities.extensions["io.modelcontextprotocol/ui"]`. executor's + `render-ui` (correctly, per the MCP-Apps spec) only mounts the widget inline + when the host advertises it can render `text/html;profile=mcp-app`; otherwise + it returns its browser **fallback URL** and nothing renders. `scripts/patch-sunpeak.mjs` + (a postinstall) adds that capability to sunpeak's client. This is + upstream-worthy: sunpeak should advertise it itself. + +2. **executor's shell nests one extra iframe.** The shell mounts the generated + component in a nested `srcdoc` iframe (`shell-app` -> `inner-renderer`, the + double-iframe sandbox), one level below sunpeak's `result.app()`. So tests use + `result.app().frameLocator("iframe")` to reach the component. + +## Why stdio + from source + +`render-ui`'s shell resource (`ui://executor/shell-tanstack-query.html`) is +`packages/hosts/mcp-apps-shell/dist/mcp-app.html`. `pretest` builds it. Running +`executor mcp` from source serves it directly. (The compiled binary now ships +the shell too — see `apps/cli/src/build.ts` — but source is the cheaper path +for a test.) + +## Scope + +sunpeak is a host _simulation_: it covers the protocol contract, the rendered +UI, and tool/theme/display-mode behavior. It does not catch real-host quirks +(OAuth, production CSP). For the shell component in isolation there is also the +faster in-repo unit test at +`packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts`. diff --git a/e2e/mcp-apps/package.json b/e2e/mcp-apps/package.json new file mode 100644 index 000000000..d38201777 --- /dev/null +++ b/e2e/mcp-apps/package.json @@ -0,0 +1,17 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "private": true, + "description": "Render + test executor's generative-UI MCP Apps in a real MCP-Apps host (sunpeak), headless, no VM.", + "type": "module", + "scripts": { + "postinstall": "node scripts/check-sunpeak.mjs", + "pretest": "bun run --cwd ../../packages/hosts/mcp-apps-shell build:shell", + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.56" + } +} diff --git a/e2e/mcp-apps/playwright.config.ts b/e2e/mcp-apps/playwright.config.ts new file mode 100644 index 000000000..fa6b97480 --- /dev/null +++ b/e2e/mcp-apps/playwright.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { defineConfig } from "sunpeak/test/config"; + +// sunpeak runs `sunpeak inspect` (a real MCP-Apps host that mounts ui:// +// resources in a sandboxed iframe) as the Playwright web server, connects it to +// the MCP server below, and gives tests a frame-scoped handle to the rendered +// app. We point it at executor over stdio (`executor mcp`) so the whole thing is +// self-contained: no daemon to manage, no HTTP token. +// +// Run the daemon FROM SOURCE (bun apps/cli/bin/executor.ts) so the shell +// resource is served from packages/hosts/mcp-apps-shell/dist/mcp-app.html +// (`pretest` builds it). The compiled binary also ships the shell, but source +// is the cheapest path for a test. +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); + +export default defineConfig({ + testDir: "tests", + server: { + command: "bun", + args: [resolve(repo, "apps/cli/bin/executor.ts"), "mcp"], + env: { + EXECUTOR_DATA_DIR: resolve(here, ".exdata"), + }, + cwd: repo, + }, +}); diff --git a/e2e/mcp-apps/scripts/check-sunpeak.mjs b/e2e/mcp-apps/scripts/check-sunpeak.mjs new file mode 100644 index 000000000..5b4283e1d --- /dev/null +++ b/e2e/mcp-apps/scripts/check-sunpeak.mjs @@ -0,0 +1,45 @@ +// Verify sunpeak's inspector advertises the MCP-Apps UI *client* capability to +// the upstream MCP server. +// +// executor gates inline UI rendering on that advertisement (per the MCP-Apps +// spec: a server mounts inline only when the host says it can render +// `text/html;profile=mcp-app`). A host that doesn't advertise it gets the +// deep-link fallback instead of the widget — which would make this whole suite +// pass while testing the wrong delivery path. +// +// sunpeak used to construct its Client with no capabilities at all and we +// patched the shipped file. As of 0.20.69 it declares the capability itself, so +// this is now a CHECK rather than a patch. It stays fatal: if a future sunpeak +// drops the capability, the harness must fail loudly at install time rather +// than silently degrade. +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const target = resolve(here, "../node_modules/sunpeak/bin/commands/inspect.mjs"); + +if (!existsSync(target)) { + console.error(`[check-sunpeak] ${target} not found. Is sunpeak installed?`); + process.exit(1); +} + +const src = readFileSync(target, "utf8"); +const EXTENSION_ID = "io.modelcontextprotocol/ui"; +const MIME_TYPE = "text/html;profile=mcp-app"; + +if (!src.includes(EXTENSION_ID) || !src.includes(MIME_TYPE)) { + console.error( + [ + "[check-sunpeak] the inspector no longer advertises MCP-Apps client support.", + ` expected both ${EXTENSION_ID} and ${MIME_TYPE}`, + ` in: ${target}`, + "Without it executor returns its deep-link fallback and these tests would pass", + "while testing nothing. Pin a sunpeak version that advertises the capability,", + "or restore a patch here that injects it into the Client constructor.", + ].join("\n"), + ); + process.exit(1); +} + +console.log("[check-sunpeak] inspector advertises MCP-Apps UI client capability"); diff --git a/e2e/mcp-apps/tests/render-ui.spec.ts b/e2e/mcp-apps/tests/render-ui.spec.ts new file mode 100644 index 000000000..33c4c75c2 --- /dev/null +++ b/e2e/mcp-apps/tests/render-ui.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "sunpeak/test"; + +// executor's render-ui shell mounts the generated component in a *nested* srcdoc +// iframe (shell-app -> inner-renderer, the MCP-Apps double-iframe sandbox), one +// level below sunpeak's own `result.app()`. Descend that extra level. +const appBody = (result: { app: () => ReturnType["app"]> } | any) => + result.app().frameLocator("iframe"); + +const COUNTER = `function App() { + const [count, setCount] = useState(0); + return ( +
+

MCP App counter

+ {count} + +
+ ); +}`; + +// sunpeak runs every test against both the Claude and ChatGPT host simulations. + +test("render-ui mounts an interactive React widget", async ({ inspector }) => { + const result = await inspector.renderTool("render-ui", { + code: COUNTER, + title: "MCP App counter", + }); + const app = appBody(result); + + await expect(app.locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); + const inc = app.locator('button:has-text("Increment")'); + await expect(inc).toBeVisible(); + + // state is live: clicking updates the rendered output + await inc.click(); + await expect(app.locator("text=1")).toBeVisible(); +}); + +test("render-ui renders in dark theme", async ({ inspector }) => { + const result = await inspector.renderTool( + "render-ui", + { code: COUNTER, title: "MCP App counter" }, + { theme: "dark" }, + ); + await expect(appBody(result).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); + +// The whole point of patching sunpeak to advertise MCP-Apps support is that +// executor returns the inline widget rather than its deep-link fallback. Assert +// that directly, so a future sunpeak change that drops the capability fails +// here instead of quietly testing the wrong delivery path. +// Protocol-level assertions go through the `mcp` fixture: `inspector` models +// what a *host* shows the user and does not surface the raw tool result. +const structuredOf = (result: { structuredContent?: unknown }): Record => + (result.structuredContent ?? {}) as Record; + +test("render-ui returns an inline widget, not the deep-link fallback", async ({ mcp }) => { + const result = await mcp.callTool("render-ui", { + code: COUNTER, + title: "MCP App counter", + description: "A counter used by the MCP Apps e2e harness", + }); + const structured = structuredOf(result); + // The whole point of the inspector advertising MCP-Apps support: executor + // must hand back the component, not a URL to go look at it elsewhere. + expect(structured.status).not.toBe("fallback_url"); + expect(structured.code).toContain("MCP App counter"); + // Every successful render is persisted, so the artifact can be reopened. + expect(structured.artifactId).toBeTruthy(); +}); + +test("a rendered artifact is listed and can be shown again", async ({ inspector, mcp }) => { + const rendered = await mcp.callTool("render-ui", { + code: COUNTER, + title: "Retrievable counter", + description: "Saved so list-artifacts can find it", + }); + const artifactId = structuredOf(rendered).artifactId as string; + expect(artifactId).toBeTruthy(); + + const listed = await mcp.callTool("list-artifacts", {}); + const artifacts = structuredOf(listed).artifacts as Array<{ id: string }> | undefined; + expect(artifacts?.some((artifact) => artifact.id === artifactId)).toBe(true); + + // …and the stored source really does render, through the same shell. + const shown = await inspector.renderTool("show-artifact", { id: artifactId }); + await expect(appBody(shown).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); diff --git a/packages/hosts/mcp-apps-shell/CHANGELOG.md b/packages/hosts/mcp-apps-shell/CHANGELOG.md new file mode 100644 index 000000000..33433e4c6 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/mcp-apps-shell diff --git a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts index 09d7f7e7e..2c4e94639 100644 --- a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts @@ -28,6 +28,7 @@ const stubEngine: ExecutionEngine = { getDescription: Effect.succeed("smoke"), }; +// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field const APPS_CAPS = { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, } as unknown as ClientCapabilities; diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts index 06007a24e..11fc5835f 100644 --- a/packages/hosts/mcp/src/artifacts-tools.test.ts +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -123,7 +123,10 @@ const withClient = async ( const SHELL_HTML = "
"; -// What a client that renders MCP Apps advertises at `initialize`. +// What a client that renders MCP Apps advertises at `initialize`. The SDK's +// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which +// is exactly why ext-apps ships `getUiCapability` to read it. +// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field const APPS_CAPS = { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, } as unknown as ClientCapabilities; @@ -217,7 +220,9 @@ describe("MCP host — artifact tool visibility", () => { const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI }); const [content] = read.contents; expect(content.mimeType).toBe(RESOURCE_MIME_TYPE); - expect(content.text).toBe(SHELL_HTML); + // Served as text, never as a blob. + expect("text" in content).toBe(true); + expect("text" in content ? content.text : "").toBe(SHELL_HTML); // The shell may open no network connection of its own; everything // routes back over the MCP bridge. expect(content._meta).toMatchObject({