From 1cc87310c90d65ff2c93a97b58acd91d231c2e0f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 6 Mar 2026 19:41:27 -0600 Subject: [PATCH] mcp (#184) * mcp * fix lint --- .changeset/config.json | 3 +- .cursor/mcp.json | 8 + .vscode/mcp.json | 9 + README.md | 1 + apps/web/app/(main)/docs/api/mcp/page.mdx | 247 ++++++++++++++++ apps/web/app/api/docs-chat/route.ts | 2 +- apps/web/lib/docs-navigation.ts | 6 + apps/web/lib/page-titles.ts | 1 + examples/mcp/README.md | 63 ++++ examples/mcp/index.html | 13 + examples/mcp/package.json | 34 +++ examples/mcp/server.ts | 86 ++++++ examples/mcp/src/catalog.ts | 10 + examples/mcp/src/globals.css | 106 +++++++ examples/mcp/src/main.tsx | 269 ++++++++++++++++++ examples/mcp/src/mcp-app-view.tsx | 89 ++++++ examples/mcp/tsconfig.json | 17 ++ examples/mcp/vite.config.ts | 15 + .../svelte-chat/src/lib/render/registry.ts | 5 +- packages/mcp/README.md | 124 ++++++++ packages/mcp/package.json | 78 +++++ packages/mcp/src/app.ts | 30 ++ packages/mcp/src/build-app-html.ts | 71 +++++ packages/mcp/src/index.ts | 12 + packages/mcp/src/server.ts | 158 ++++++++++ packages/mcp/src/types.ts | 62 ++++ packages/mcp/src/use-json-render-app.ts | 139 +++++++++ packages/mcp/tsconfig.json | 9 + packages/mcp/tsup.config.ts | 17 ++ packages/svelte/src/JsonUIProvider.svelte | 4 +- pnpm-lock.yaml | 251 +++++++++++++++- skills/json-render-mcp/SKILL.md | 128 +++++++++ 32 files changed, 2056 insertions(+), 11 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .vscode/mcp.json create mode 100644 apps/web/app/(main)/docs/api/mcp/page.mdx create mode 100644 examples/mcp/README.md create mode 100644 examples/mcp/index.html create mode 100644 examples/mcp/package.json create mode 100644 examples/mcp/server.ts create mode 100644 examples/mcp/src/catalog.ts create mode 100644 examples/mcp/src/globals.css create mode 100644 examples/mcp/src/main.tsx create mode 100644 examples/mcp/src/mcp-app-view.tsx create mode 100644 examples/mcp/tsconfig.json create mode 100644 examples/mcp/vite.config.ts create mode 100644 packages/mcp/README.md create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/app.ts create mode 100644 packages/mcp/src/build-app-html.ts create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/src/types.ts create mode 100644 packages/mcp/src/use-json-render-app.ts create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/tsup.config.ts create mode 100644 skills/json-render-mcp/SKILL.md diff --git a/.changeset/config.json b/.changeset/config.json index e63cf2a..31bbfd8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -17,7 +17,8 @@ "@json-render/jotai", "@json-render/vue", "@json-render/xstate", - "@json-render/image" + "@json-render/image", + "@json-render/mcp" ] ], "linked": [], diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..5f211d4 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "json-render": { + "command": "npx", + "args": ["tsx", "examples/mcp/server.ts", "--stdio"] + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..b2876a0 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "json-render": { + "type": "stdio", + "command": "npx", + "args": ["tsx", "examples/mcp/server.ts", "--stdio"] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index cd95f01..80cd28c 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ function Dashboard({ spec }) { | `@json-render/zustand` | Zustand adapter for `StateStore` | | `@json-render/jotai` | Jotai adapter for `StateStore` | | `@json-render/xstate` | XState Store (atom) adapter for `StateStore` | +| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code | ## Renderers diff --git a/apps/web/app/(main)/docs/api/mcp/page.mdx b/apps/web/app/(main)/docs/api/mcp/page.mdx new file mode 100644 index 0000000..2bb4948 --- /dev/null +++ b/apps/web/app/(main)/docs/api/mcp/page.mdx @@ -0,0 +1,247 @@ +import { pageMetadata } from "@/lib/page-metadata" +export const metadata = pageMetadata("docs/api/mcp") + +# @json-render/mcp + +MCP Apps integration for json-render. Serve json-render UIs as interactive [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. + +## Install + +```bash +npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk +``` + +For the iframe-side React UI, also install: + +```bash +npm install @json-render/react react react-dom +``` + +See the [MCP example](https://github.com/vercel-labs/json-render/tree/main/examples/mcp) for a full working example. + +## Overview + +MCP Apps let MCP servers return interactive HTML UIs that render directly inside chat conversations. `@json-render/mcp` bridges json-render catalogs with the MCP Apps protocol: + +1. Your **catalog** defines which components and actions the AI can use +2. The **MCP server** exposes the catalog as a tool with the spec schema +3. The **bundled HTML** renders json-render specs inside the host's sandboxed iframe +4. The AI generates a spec, the host renders it, and users interact with the live UI + +## Server API + +### createMcpApp + +Create a fully-configured MCP server. This is the main entry point. + +```typescript +import { createMcpApp } from "@json-render/mcp"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import fs from "node:fs"; + +const server = createMcpApp({ + name: "My Dashboard", + version: "1.0.0", + catalog: myCatalog, + html: fs.readFileSync("dist/index.html", "utf-8"), +}); + +await server.connect(new StdioServerTransport()); +``` + +#### CreateMcpAppOptions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDescription
namestringServer name shown in client UIs
versionstringServer version
catalogCatalogjson-render catalog defining available components
htmlstringSelf-contained HTML for the iframe UI
toolMcpToolOptionsOptional tool name/title/description overrides
+ +### registerJsonRenderTool + +Register a json-render tool on an existing `McpServer`. Use this when you need to add json-render to a server that has other tools. + +```typescript +import { registerJsonRenderTool } from "@json-render/mcp"; + +registerJsonRenderTool(server, { + catalog, + name: "render-ui", + title: "Render UI", + description: "Render an interactive UI", + resourceUri: "ui://render-ui/view.html", +}); +``` + +### registerJsonRenderResource + +Register the UI resource that serves the bundled HTML. + +```typescript +import { registerJsonRenderResource } from "@json-render/mcp"; + +registerJsonRenderResource(server, { + resourceUri: "ui://render-ui/view.html", + html: bundledHtml, +}); +``` + +## Client API (`@json-render/mcp/app`) + +These exports run inside the sandboxed iframe rendered by the MCP host. + +### useJsonRenderApp + +React hook that connects to the MCP host, listens for tool results, and maintains the current json-render spec. + +```tsx +import { useJsonRenderApp } from "@json-render/mcp/app"; +import { JSONUIProvider, Renderer } from "@json-render/react"; + +function McpAppView({ registry }) { + const { spec, loading, connected, error } = useJsonRenderApp({ + name: "my-app", + version: "1.0.0", + }); + + if (error) return
Error: {error.message}
; + if (!spec) return
Waiting...
; + + return ( + + + + ); +} +``` + +#### UseJsonRenderAppReturn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
spec{'Spec | null'}Current json-render spec
loadingbooleanWhether the spec is still being received
connectedbooleanWhether connected to the host
connectingbooleanWhether currently connecting
error{'Error | null'}Connection error, if any
app{'App | null'}The underlying MCP App instance
callServerTool{'(name, args?) => Promise'}Call an MCP server tool and update spec from result
+ +### buildAppHtml + +Generate a self-contained HTML page from bundled JavaScript and CSS. + +```typescript +import { buildAppHtml } from "@json-render/mcp/app"; +import fs from "node:fs"; + +const html = buildAppHtml({ + title: "Dashboard", + js: fs.readFileSync("dist/app.js", "utf-8"), + css: fs.readFileSync("dist/app.css", "utf-8"), +}); +``` + +## Client Configuration + +### Cursor + +Add to `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "json-render": { + "command": "npx", + "args": ["tsx", "path/to/server.ts", "--stdio"] + } + } +} +``` + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "json-render": { + "command": "npx", + "args": ["tsx", "/absolute/path/to/server.ts", "--stdio"] + } + } +} +``` + +## Supported Clients + +MCP Apps are supported by Claude (web and desktop), ChatGPT, VS Code (GitHub Copilot), Cursor, Goose, and Postman. diff --git a/apps/web/app/api/docs-chat/route.ts b/apps/web/app/api/docs-chat/route.ts index 4924287..6156e8e 100644 --- a/apps/web/app/api/docs-chat/route.ts +++ b/apps/web/app/api/docs-chat/route.ts @@ -16,7 +16,7 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render GitHub repository: https://github.com/vercel-labs/json-render Documentation: https://json-render.dev/docs -npm packages: @json-render/core, @json-render/react, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen +npm packages: @json-render/core, @json-render/react, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory. diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index cb700b2..ff261ed 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -95,6 +95,11 @@ export const docsNavigation: NavSection[] = [ href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers", external: true, }, + { + title: "MCP App", + href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp", + external: true, + }, ], }, { @@ -128,6 +133,7 @@ export const docsNavigation: NavSection[] = [ { title: "@json-render/vue", href: "/docs/api/vue" }, { title: "@json-render/svelte", href: "/docs/api/svelte" }, { title: "@json-render/codegen", href: "/docs/api/codegen" }, + { title: "@json-render/mcp", href: "/docs/api/mcp" }, ], }, ]; diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 2dd3513..f08cba2 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -50,6 +50,7 @@ export const PAGE_TITLES: Record = { "docs/api/image": "@json-render/image API", "docs/api/remotion": "@json-render/remotion API", "docs/api/shadcn": "@json-render/shadcn API", + "docs/api/mcp": "@json-render/mcp API", }; /** diff --git a/examples/mcp/README.md b/examples/mcp/README.md new file mode 100644 index 0000000..8f63736 --- /dev/null +++ b/examples/mcp/README.md @@ -0,0 +1,63 @@ +# MCP App Example + +A json-render MCP App that serves interactive shadcn/ui-based UIs directly inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. + +## Setup + +```bash +pnpm install +pnpm build +``` + +## Usage + +### With Cursor + +Add to `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "json-render": { + "command": "npx", + "args": ["tsx", "examples/mcp/server.ts", "--stdio"] + } + } +} +``` + +### With Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "json-render": { + "command": "npx", + "args": ["tsx", "/absolute/path/to/examples/mcp/server.ts", "--stdio"] + } + } +} +``` + +### HTTP Transport + +```bash +pnpm start +# Server listens on http://localhost:3001/mcp +``` + +### Stdio Transport + +```bash +pnpm start:stdio +``` + +## How It Works + +1. The Vite build bundles the React app (with shadcn components and `useJsonRenderApp` hook) into a single self-contained HTML file +2. The MCP server registers a `render-ui` tool with the catalog prompt as its description +3. When the LLM calls the tool, it generates a json-render spec constrained to the catalog +4. The host renders the bundled HTML in a sandboxed iframe +5. The iframe receives the spec via the MCP Apps protocol and renders it with json-render diff --git a/examples/mcp/index.html b/examples/mcp/index.html new file mode 100644 index 0000000..4f10465 --- /dev/null +++ b/examples/mcp/index.html @@ -0,0 +1,13 @@ + + + + + + + json-render MCP App + + +
+ + + diff --git a/examples/mcp/package.json b/examples/mcp/package.json new file mode 100644 index 0000000..1196188 --- /dev/null +++ b/examples/mcp/package.json @@ -0,0 +1,34 @@ +{ + "name": "example-mcp", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "build": "vite build", + "start": "tsx server.ts", + "start:stdio": "tsx server.ts --stdio", + "dev": "tsx watch server.ts" + }, + "dependencies": { + "@json-render/core": "workspace:*", + "@json-render/mcp": "workspace:*", + "@json-render/react": "workspace:*", + "@json-render/shadcn": "workspace:*", + "@modelcontextprotocol/ext-apps": "^1.2.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "react": "19.2.3", + "react-dom": "19.2.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/react": "19.2.3", + "@types/react-dom": "19.2.3", + "@tailwindcss/vite": "^4.2.1", + "@vitejs/plugin-react": "^5.1.4", + "tailwindcss": "^4.2.1", + "tsx": "^4.21.0", + "typescript": "^5.7.2", + "vite": "^6.3.5", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/mcp/server.ts b/examples/mcp/server.ts new file mode 100644 index 0000000..772ceec --- /dev/null +++ b/examples/mcp/server.ts @@ -0,0 +1,86 @@ +import { createMcpApp } from "@json-render/mcp"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { catalog } from "./src/catalog.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function loadHtml(): string { + const htmlPath = path.join(__dirname, "dist", "index.html"); + if (!fs.existsSync(htmlPath)) { + throw new Error( + `Built HTML not found at ${htmlPath}. Run 'pnpm build' first.`, + ); + } + return fs.readFileSync(htmlPath, "utf-8"); +} + +async function startStdio() { + const html = loadHtml(); + const server = await createMcpApp({ + name: "json-render Example", + version: "1.0.0", + catalog, + html, + }); + await server.connect(new StdioServerTransport()); +} + +async function startHttp() { + const html = loadHtml(); + const port = parseInt(process.env.PORT ?? "3001", 10); + + const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); + + expressApp.all("/mcp", async (req, res) => { + const server = await createMcpApp({ + name: "json-render Example", + version: "1.0.0", + catalog, + html, + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + expressApp.listen(port, () => { + console.log(`MCP server listening on http://localhost:${port}/mcp`); + }); +} + +if (process.argv.includes("--stdio")) { + startStdio().catch((e) => { + console.error(e); + process.exit(1); + }); +} else { + startHttp().catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/examples/mcp/src/catalog.ts b/examples/mcp/src/catalog.ts new file mode 100644 index 0000000..3ae4eb6 --- /dev/null +++ b/examples/mcp/src/catalog.ts @@ -0,0 +1,10 @@ +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/react/schema"; +import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; + +export const catalog = defineCatalog(schema, { + components: { + ...shadcnComponentDefinitions, + }, + actions: {}, +}); diff --git a/examples/mcp/src/globals.css b/examples/mcp/src/globals.css new file mode 100644 index 0000000..8b322d6 --- /dev/null +++ b/examples/mcp/src/globals.css @@ -0,0 +1,106 @@ +@import "tailwindcss"; +@source "../../../packages/shadcn/src"; +@source "../../../packages/react/src"; + +@custom-variant dark (&:is([data-theme="dark"] *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-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-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); +} + +[data-theme="dark"] { + --background: var(--color-background-secondary, var(--vscode-editor-background, oklch(0.145 0 0))); + --foreground: var(--color-text-primary, var(--vscode-foreground, oklch(0.985 0 0))); + --card: var(--color-background-primary, var(--vscode-sideBar-background, oklch(0.205 0 0))); + --card-foreground: var(--color-text-primary, var(--vscode-foreground, oklch(0.985 0 0))); + --popover: var(--color-background-primary, var(--vscode-sideBar-background, oklch(0.205 0 0))); + --popover-foreground: var(--color-text-primary, var(--vscode-foreground, oklch(0.985 0 0))); + --primary: var(--color-text-primary, var(--vscode-foreground, oklch(0.922 0 0))); + --primary-foreground: var(--color-background-primary, var(--vscode-sideBar-background, oklch(0.205 0 0))); + --secondary: var(--color-background-tertiary, var(--vscode-activityBar-background, oklch(0.269 0 0))); + --secondary-foreground: var(--color-text-primary, var(--vscode-foreground, oklch(0.985 0 0))); + --muted: var(--color-background-tertiary, var(--vscode-activityBar-background, oklch(0.269 0 0))); + --muted-foreground: var(--color-text-secondary, var(--vscode-descriptionForeground, oklch(0.708 0 0))); + --accent: var(--color-background-tertiary, var(--vscode-activityBar-background, oklch(0.269 0 0))); + --accent-foreground: var(--color-text-primary, var(--vscode-foreground, oklch(0.985 0 0))); + --destructive: var(--color-text-danger, var(--vscode-errorForeground, oklch(0.704 0.191 22.216))); + --border: var(--color-border-primary, var(--vscode-widget-border, oklch(1 0 0 / 10%))); + --input: var(--color-border-secondary, var(--vscode-editorWidget-border, oklch(1 0 0 / 15%))); + --ring: var(--color-ring-primary, var(--vscode-focusBorder, oklch(0.556 0 0))); +} + +* { + box-sizing: border-box; + border-color: var(--color-border); +} + +html, body, #root { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +body { + background-color: var(--color-background); + color: var(--color-foreground); + font-family: var(--vscode-font-family, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply text-foreground; + background-color: var(--color-background) !important; + } +} + +button { + cursor: pointer; +} diff --git a/examples/mcp/src/main.tsx b/examples/mcp/src/main.tsx new file mode 100644 index 0000000..f24b59b --- /dev/null +++ b/examples/mcp/src/main.tsx @@ -0,0 +1,269 @@ +import "./globals.css"; +import { + Component, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; +import { createRoot } from "react-dom/client"; +import { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react"; +import { shadcnComponents } from "@json-render/shadcn"; +import type { Spec } from "@json-render/core"; +import { catalog } from "./catalog"; + +const { registry } = defineRegistry(catalog, { + components: { + ...shadcnComponents, + Avatar: ({ props }: { props: Record }) => { + const name = (props.name as string) || (props.alt as string) || "?"; + const src = props.src as string | undefined; + const initials = name + .split(" ") + .map((n) => n[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + const size = props.size === "lg" ? 48 : props.size === "sm" ? 32 : 40; + const [imgFailed, setImgFailed] = useState(false); + const onError = useCallback(() => setImgFailed(true), []); + const showImg = src && !imgFailed; + + return ( +
+ {showImg ? ( + {name} + ) : ( + + {initials} + + )} +
+ ); + }, + }, +}); + +class ErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return ( +
+ Error: {this.state.error.message} +
+ ); + } + return this.props.children; + } +} + +function forceFullWidth(spec: Spec): Spec { + if (!spec.elements) return spec; + const elements = { ...spec.elements }; + for (const [key, el] of Object.entries(elements)) { + if (el.type === "Card" && el.props) { + elements[key] = { + ...el, + props: { ...el.props, maxWidth: "full", centered: false }, + }; + } + } + return { ...spec, elements }; +} + +function parseSpec( + result: { content?: Array<{ type: string; text?: string }> } | undefined, +): Spec | null { + const text = result?.content?.find((c) => c.type === "text")?.text; + if (!text) return null; + try { + return forceFullWidth(JSON.parse(text) as Spec); + } catch { + return null; + } +} + +function applyHostContext(ctx: { + theme?: string; + styles?: { variables?: Record }; +}) { + if (ctx.theme) { + document.documentElement.setAttribute("data-theme", ctx.theme); + document.documentElement.style.colorScheme = ctx.theme; + } + if (ctx.styles?.variables) { + const root = document.documentElement; + for (const [key, value] of Object.entries(ctx.styles.variables)) { + root.style.setProperty(key, value); + } + } +} + +function McpAppView() { + const [spec, setSpec] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let specReceived = false; + + function handleSpec(s: Spec) { + if (specReceived) return; + specReceived = true; + setSpec(s); + } + + function onMessage(event: MessageEvent) { + if (specReceived) return; + const data = event.data as Record | undefined; + if (!data || typeof data !== "object") return; + const method = data.method as string | undefined; + const params = data.params as Record | undefined; + + // Cursor sends the spec via tool-input + if (method === "ui/notifications/tool-input" && params?.arguments) { + const args = params.arguments as Record; + const rawSpec = args.spec; + if ( + rawSpec && + typeof rawSpec === "object" && + "root" in rawSpec && + "elements" in rawSpec + ) { + handleSpec(forceFullWidth(rawSpec as Spec)); + } + } + } + window.addEventListener("message", onMessage); + + const app = new McpApp({ name: "json-render", version: "1.0.0" }); + + app.ontoolresult = (result) => { + const parsed = parseSpec( + result as { content?: Array<{ type: string; text?: string }> }, + ); + if (parsed) handleSpec(parsed); + }; + + app.onhostcontextchanged = (ctx) => + applyHostContext(ctx as Parameters[0]); + + app.onerror = (err: unknown) => { + setError(err instanceof Error ? err.message : String(err)); + }; + + app + .connect() + .then(() => { + const ctx = app.getHostContext?.(); + if (ctx) + applyHostContext(ctx as Parameters[0]); + + // Fallback: if host didn't provide theme, detect via media query + if (!ctx || !(ctx as Record).theme) { + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + document.documentElement.setAttribute( + "data-theme", + prefersDark ? "dark" : "light", + ); + document.documentElement.style.colorScheme = prefersDark + ? "dark" + : "light"; + } + }) + .catch((err: unknown) => + setError(err instanceof Error ? err.message : String(err)), + ); + + return () => { + window.removeEventListener("message", onMessage); + app.close().catch(() => {}); + }; + }, []); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!spec) { + return ( +
+ Loading... +
+ ); + } + + return ( + +
+ +
+
+ ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/mcp/src/mcp-app-view.tsx b/examples/mcp/src/mcp-app-view.tsx new file mode 100644 index 0000000..7e853da --- /dev/null +++ b/examples/mcp/src/mcp-app-view.tsx @@ -0,0 +1,89 @@ +import { JSONUIProvider, Renderer } from "@json-render/react"; +import { shadcnComponents } from "@json-render/shadcn"; +import { defineRegistry } from "@json-render/react"; +import { useJsonRenderApp } from "@json-render/mcp/app"; +import { catalog } from "./catalog"; +import { useState, useEffect } from "react"; + +const { registry } = defineRegistry(catalog, { + components: { + ...shadcnComponents, + }, +}); + +const debugStyle = { + padding: 12, + fontFamily: "monospace", + fontSize: 12, + color: "#000", + background: "#fffbe6", + border: "1px solid #faad14", + borderRadius: 4, + margin: 8, + whiteSpace: "pre-wrap" as const, + wordBreak: "break-all" as const, +}; + +export function McpAppView() { + const [logs, setLogs] = useState(["App mounted"]); + + const addLog = (msg: string) => { + setLogs((prev) => [ + ...prev, + `${new Date().toISOString().slice(11, 23)} ${msg}`, + ]); + }; + + const { spec, loading, connected, connecting, error } = useJsonRenderApp({ + name: "json-render-mcp-example", + version: "1.0.0", + }); + + useEffect(() => { + addLog( + `State: connecting=${connecting} connected=${connected} error=${error?.message ?? "none"} loading=${loading} spec=${spec ? "yes" : "null"}`, + ); + }, [connecting, connected, error, loading, spec]); + + useEffect(() => { + if (spec) { + addLog( + `Spec received: root=${spec.root}, elements=${Object.keys(spec.elements ?? {}).join(",")}`, + ); + } + }, [spec]); + + if (error) { + return ( +
+
+ Connection error: {error.message} +
+
{logs.join("\n")}
+
+ ); + } + + if (!spec) { + return ( +
+
+ {connecting + ? "Connecting to host..." + : loading + ? "Waiting for UI spec..." + : "No spec received."} +
+
{logs.join("\n")}
+
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/examples/mcp/tsconfig.json b/examples/mcp/tsconfig.json new file mode 100644 index 0000000..458d416 --- /dev/null +++ b/examples/mcp/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "jsx": "react-jsx" + }, + "include": ["src", "server.ts"] +} diff --git a/examples/mcp/vite.config.ts b/examples/mcp/vite.config.ts new file mode 100644 index 0000000..185077f --- /dev/null +++ b/examples/mcp/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss(), viteSingleFile()], + build: { + outDir: "dist", + emptyOutDir: false, + rollupOptions: { + input: "index.html", + }, + }, +}); diff --git a/examples/svelte-chat/src/lib/render/registry.ts b/examples/svelte-chat/src/lib/render/registry.ts index 7802bb1..1209ad3 100644 --- a/examples/svelte-chat/src/lib/render/registry.ts +++ b/examples/svelte-chat/src/lib/render/registry.ts @@ -1,7 +1,6 @@ -import { defineRegistry, type ComponentRegistry } from "@json-render/svelte"; +import { defineRegistry } from "@json-render/svelte"; import { explorerCatalog } from "./catalog"; -// Import render components import StackComponent from "./components/Stack.svelte"; import CardComponent from "./components/Card.svelte"; import GridComponent from "./components/Grid.svelte"; @@ -28,7 +27,7 @@ import SelectInputComponent from "./components/SelectInput.svelte"; import TextInputComponent from "./components/TextInput.svelte"; import ButtonComponent from "./components/Button.svelte"; -const components: ComponentRegistry = { +const components = { Stack: StackComponent, Card: CardComponent, Grid: GridComponent, diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..c2b79d3 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,124 @@ +# @json-render/mcp + +MCP Apps integration for [json-render](https://github.com/vercel-labs/json-render). Serve json-render UIs as interactive MCP Apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. + +## What are MCP Apps? + +[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that lets MCP servers return interactive HTML UIs rendered directly inside chat conversations. Instead of text-only tool responses, users get full interactive interfaces -- dashboards, forms, data visualizations -- embedded inline. + +## Installation + +```bash +npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk +``` + +## Quick Start + +### 1. Define your catalog + +```ts +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/react/schema"; +import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; + +const catalog = defineCatalog(schema, { + components: { ...shadcnComponentDefinitions }, + actions: {}, +}); +``` + +### 2. Create the MCP server + +```ts +import { createMcpApp } from "@json-render/mcp"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import fs from "node:fs"; + +const server = createMcpApp({ + name: "My Dashboard", + version: "1.0.0", + catalog, + html: fs.readFileSync("dist/index.html", "utf-8"), +}); + +await server.connect(new StdioServerTransport()); +``` + +### 3. Build the UI (iframe) + +Create a React app that uses `useJsonRenderApp` from `@json-render/mcp/app`: + +```tsx +import { useJsonRenderApp } from "@json-render/mcp/app"; +import { JSONUIProvider, Renderer } from "@json-render/react"; + +function McpAppView({ registry }) { + const { spec, loading, connected, error } = useJsonRenderApp(); + + if (error) return
Error: {error.message}
; + if (!spec) return
Waiting for spec...
; + + return ( + + + + ); +} +``` + +Bundle with Vite + `vite-plugin-singlefile` into a single HTML file, then pass it to `createMcpApp` as the `html` option. + +### 4. Connect to a client + +Add to `.cursor/mcp.json` or Claude Desktop config: + +```json +{ + "mcpServers": { + "my-app": { + "command": "node", + "args": ["./server.js", "--stdio"] + } + } +} +``` + +## API Reference + +### Server Side (main export) + +#### `createMcpApp(options)` + +Creates a fully-configured `McpServer` with a json-render tool and UI resource. + +| Option | Type | Description | +|--------|------|-------------| +| `name` | `string` | Server name shown in client UIs | +| `version` | `string` | Server version | +| `catalog` | `Catalog` | json-render catalog defining available components | +| `html` | `string` | Bundled HTML for the iframe UI | +| `tool` | `McpToolOptions` | Optional tool name/title/description overrides | + +#### `registerJsonRenderTool(server, options)` + +Register a json-render tool on an existing `McpServer`. + +#### `registerJsonRenderResource(server, options)` + +Register a json-render UI resource on an existing `McpServer`. + +### Client Side (`@json-render/mcp/app`) + +#### `useJsonRenderApp(options?)` + +React hook for the iframe-side app. Connects to the MCP host, receives tool results, and maintains the current json-render spec. + +Returns `{ spec, loading, connected, connecting, error, app, callServerTool }`. + +#### `buildAppHtml(options)` + +Generate a self-contained HTML string from bundled JS/CSS for use as a UI resource. + +## Client Support + +MCP Apps are supported by Claude, ChatGPT, VS Code (Copilot), Cursor, Goose, and Postman. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..07909a8 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,78 @@ +{ + "name": "@json-render/mcp", + "version": "0.11.0", + "license": "Apache-2.0", + "description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.", + "keywords": [ + "json", + "ui", + "mcp", + "model-context-protocol", + "mcp-apps", + "ai", + "generative-ui", + "llm", + "renderer", + "claude", + "chatgpt", + "cursor" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/json-render.git", + "directory": "packages/mcp" + }, + "homepage": "https://github.com/vercel-labs/json-render#readme", + "bugs": { + "url": "https://github.com/vercel-labs/json-render/issues" + }, + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./app": { + "types": "./dist/app.d.ts", + "import": "./dist/app.mjs", + "require": "./dist/app.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@json-render/core": "workspace:*", + "@modelcontextprotocol/ext-apps": "^1.2.0", + "@modelcontextprotocol/sdk": "^1.27.1" + }, + "devDependencies": { + "@internal/typescript-config": "workspace:*", + "@types/react": "19.2.3", + "tsup": "^8.0.2", + "typescript": "^5.4.5" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } +} diff --git a/packages/mcp/src/app.ts b/packages/mcp/src/app.ts new file mode 100644 index 0000000..c00b034 --- /dev/null +++ b/packages/mcp/src/app.ts @@ -0,0 +1,30 @@ +/** + * Client-side (iframe) utilities for rendering json-render specs + * inside an MCP App view. + * + * This module is intended to run **inside the sandboxed iframe** that + * MCP hosts render. It connects to the host via the MCP Apps protocol, + * receives tool results containing json-render specs, and provides + * React hooks / helpers to render them. + * + * @example + * ```tsx + * import { useJsonRenderApp } from "@json-render/mcp/app"; + * import { Renderer } from "@json-render/react"; + * + * function McpAppView({ registry }) { + * const { spec, loading } = useJsonRenderApp(); + * return ; + * } + * ``` + * + * @packageDocumentation + */ + +export { useJsonRenderApp } from "./use-json-render-app.js"; +export type { + UseJsonRenderAppOptions, + UseJsonRenderAppReturn, +} from "./use-json-render-app.js"; +export { buildAppHtml } from "./build-app-html.js"; +export type { BuildAppHtmlOptions } from "./build-app-html.js"; diff --git a/packages/mcp/src/build-app-html.ts b/packages/mcp/src/build-app-html.ts new file mode 100644 index 0000000..71a87fb --- /dev/null +++ b/packages/mcp/src/build-app-html.ts @@ -0,0 +1,71 @@ +/** + * Options for `buildAppHtml`. + */ +export interface BuildAppHtmlOptions { + /** Title for the HTML page. Defaults to `"json-render"`. */ + title?: string; + /** + * Inline CSS to inject into the page ` + + +
+ + +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..9388373 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,12 @@ +export type { + CreateMcpAppOptions, + McpToolOptions, + RegisterToolOptions, + RegisterResourceOptions, +} from "./types.js"; + +export { + createMcpApp, + registerJsonRenderTool, + registerJsonRenderResource, +} from "./server.js"; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 0000000..5730001 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,158 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CreateMcpAppOptions, + RegisterToolOptions, + RegisterResourceOptions, +} from "./types.js"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +/** + * Dynamically import the ext-apps server helpers to avoid CJS/ESM type + * mismatches at compile time while still getting the proper runtime + * `_meta.ui` normalization that hosts require. + */ +async function getExtApps() { + const mod = await import("@modelcontextprotocol/ext-apps/server"); + return mod; +} + +/** + * Register a json-render tool on an existing MCP server. + * + * Uses `registerAppTool` from `@modelcontextprotocol/ext-apps/server` + * so that MCP Apps-capable hosts (Claude, VS Code, Cursor, ChatGPT) + * see the `_meta.ui.resourceUri` in the tool listing and know to + * fetch and render the `ui://` resource as an interactive iframe. + * + * The tool accepts a json-render spec as input and returns it as text + * content, which the iframe receives via `ontoolresult`. + */ +export async function registerJsonRenderTool( + server: McpServer, + options: RegisterToolOptions, +): Promise { + const { catalog, name, title, description, resourceUri } = options; + const { registerAppTool } = await getExtApps(); + + const specZodSchema = catalog.zodSchema(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registerAppTool as any)( + server, + name, + { + title, + description, + inputSchema: { spec: specZodSchema }, + _meta: { ui: { resourceUri } }, + }, + async (args: { spec?: unknown }) => { + const spec = args.spec; + const validation = catalog.validate(spec); + const validSpec = validation.success ? validation.data : spec; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(validSpec), + }, + ], + }; + }, + ); +} + +/** + * Register a json-render UI resource on an existing MCP server. + * + * The resource serves the self-contained HTML page that renders + * json-render specs received from tool results. + */ +export async function registerJsonRenderResource( + server: McpServer, + options: RegisterResourceOptions, +): Promise { + const { resourceUri, html } = options; + const { registerAppResource, RESOURCE_MIME_TYPE: mimeType } = + await getExtApps(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registerAppResource as any)( + server, + resourceUri, + resourceUri, + { mimeType }, + async () => ({ + contents: [ + { + uri: resourceUri, + mimeType, + text: html, + _meta: { + ui: { + csp: { + resourceDomains: ["https:"], + connectDomains: ["https:"], + }, + }, + }, + }, + ], + }), + ); +} + +/** + * Create a fully-configured MCP server that serves a json-render catalog + * as an MCP App. + * + * This is the main entry point for most users. It creates an `McpServer`, + * registers the render tool and UI resource, and returns the server + * ready for transport connection. + * + * @example + * ```ts + * import { createMcpApp } from "@json-render/mcp"; + * + * const server = createMcpApp({ + * name: "My Dashboard", + * version: "1.0.0", + * catalog: myCatalog, + * html: myBundledHtml, + * }); + * + * // Connect via stdio + * import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + * await server.connect(new StdioServerTransport()); + * ``` + */ +export async function createMcpApp( + options: CreateMcpAppOptions, +): Promise { + const { name, version, catalog, html, tool } = options; + + const toolName = tool?.name ?? "render-ui"; + const toolTitle = tool?.title ?? "Render UI"; + const resourceUri = `ui://${toolName}/view.html`; + + const catalogPrompt = catalog.prompt(); + const toolDescription = + tool?.description ?? + `Render an interactive UI. The spec argument must be a json-render spec conforming to the catalog.\n\n${catalogPrompt}`; + + const server = new McpServer({ name, version }); + + await registerJsonRenderTool(server, { + catalog, + name: toolName, + title: toolTitle, + description: toolDescription, + resourceUri, + }); + + await registerJsonRenderResource(server, { resourceUri, html }); + + return server; +} diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 0000000..38bc3e5 --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,62 @@ +import type { Catalog } from "@json-render/core"; + +/** + * Options for creating an MCP App server backed by a json-render catalog. + */ +export interface CreateMcpAppOptions { + /** Display name for the MCP server shown in client UIs. */ + name: string; + /** Semantic version of the MCP server. */ + version: string; + /** The json-render catalog defining available components and actions. */ + catalog: Catalog; + /** + * Pre-built HTML string for the UI resource. + * Generate this with `buildAppHtml` from `@json-render/mcp/app` or + * provide your own self-contained HTML page. + */ + html: string; + /** + * Optional tool configuration overrides. + */ + tool?: McpToolOptions; +} + +/** + * Options for configuring the MCP tool that renders json-render specs. + */ +export interface McpToolOptions { + /** Tool name exposed to the LLM. Defaults to `"render-ui"`. */ + name?: string; + /** Human-readable title. Defaults to `"Render UI"`. */ + title?: string; + /** Tool description shown to the LLM. When omitted, a description is + * auto-generated from the catalog prompt. */ + description?: string; +} + +/** + * Options for registering the MCP App tool. + */ +export interface RegisterToolOptions { + /** The json-render catalog. */ + catalog: Catalog; + /** Tool name. */ + name: string; + /** Tool title. */ + title: string; + /** Tool description. */ + description: string; + /** The `ui://` resource URI this tool's view is served from. */ + resourceUri: string; +} + +/** + * Options for registering the MCP App UI resource. + */ +export interface RegisterResourceOptions { + /** The `ui://` resource URI. */ + resourceUri: string; + /** Self-contained HTML string for the view. */ + html: string; +} diff --git a/packages/mcp/src/use-json-render-app.ts b/packages/mcp/src/use-json-render-app.ts new file mode 100644 index 0000000..c85a929 --- /dev/null +++ b/packages/mcp/src/use-json-render-app.ts @@ -0,0 +1,139 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import type { Spec } from "@json-render/core"; +import { App } from "@modelcontextprotocol/ext-apps"; + +/** + * Options for the `useJsonRenderApp` hook. + */ +export interface UseJsonRenderAppOptions { + /** App name shown during initialization. Defaults to `"json-render"`. */ + name?: string; + /** App version. Defaults to `"1.0.0"`. */ + version?: string; +} + +/** + * Return value of `useJsonRenderApp`. + */ +export interface UseJsonRenderAppReturn { + /** The current json-render spec (null until the first tool result). */ + spec: Spec | null; + /** Whether the app is still connecting to the host. */ + connecting: boolean; + /** Whether the app is connected to the host. */ + connected: boolean; + /** Connection error, if any. */ + error: Error | null; + /** Whether the spec is still being received / parsed. */ + loading: boolean; + /** The underlying MCP App instance. */ + app: App | null; + /** + * Call a tool on the MCP server and update the spec from the result. + * Useful for refresh / drill-down interactions. + */ + callServerTool: ( + name: string, + args?: Record, + ) => Promise; +} + +interface ToolResultContent { + type: string; + text?: string; +} + +function parseSpecFromToolResult(result: { + content?: ToolResultContent[]; +}): Spec | null { + const textContent = result.content?.find( + (c: ToolResultContent) => c.type === "text", + ); + if (!textContent?.text) return null; + try { + const parsed = JSON.parse(textContent.text); + if (parsed && typeof parsed === "object" && "spec" in parsed) { + return parsed.spec as Spec; + } + return parsed as Spec; + } catch { + return null; + } +} + +/** + * React hook that connects to the MCP host, listens for tool results, + * and maintains the current json-render spec. + * + * Follows the official MCP Apps pattern: create an `App` instance, + * register the `ontoolresult` handler, then call `app.connect()` + * which internally creates a PostMessageTransport to the host. + */ +export function useJsonRenderApp( + options: UseJsonRenderAppOptions = {}, +): UseJsonRenderAppReturn { + const { name = "json-render", version = "1.0.0" } = options; + + const [spec, setSpec] = useState(null); + const [loading, setLoading] = useState(true); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + const appRef = useRef(null); + + useEffect(() => { + const app = new App({ name, version }); + appRef.current = app; + + app.ontoolresult = (result: { content?: ToolResultContent[] }) => { + const parsed = parseSpecFromToolResult(result); + if (parsed) { + setSpec(parsed); + setLoading(false); + } + }; + + // Let the App class handle transport creation internally, + // matching the official MCP Apps quickstart pattern. + app + .connect() + .then(() => { + setConnected(true); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err : new Error(String(err))); + }); + + return () => { + app.close().catch(() => {}); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const callServerTool = useCallback( + async (toolName: string, args: Record = {}) => { + if (!appRef.current) return; + setLoading(true); + try { + const result = await appRef.current.callServerTool({ + name: toolName, + arguments: args, + }); + const parsed = parseSpecFromToolResult(result); + if (parsed) setSpec(parsed); + } finally { + setLoading(false); + } + }, + [], + ); + + return { + spec, + connecting: !connected && !error, + connected, + error, + loading, + app: appRef.current, + callServerTool, + }; +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..6c6204c --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@internal/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts new file mode 100644 index 0000000..ad5ce41 --- /dev/null +++ b/packages/mcp/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts", "src/app.ts"], + format: ["cjs", "esm"], + dts: true, + sourcemap: true, + clean: true, + external: [ + "react", + "react-dom", + "@json-render/core", + "@json-render/react", + "@modelcontextprotocol/sdk", + "@modelcontextprotocol/ext-apps", + ], +}); diff --git a/packages/svelte/src/JsonUIProvider.svelte b/packages/svelte/src/JsonUIProvider.svelte index 32cc619..8162e94 100644 --- a/packages/svelte/src/JsonUIProvider.svelte +++ b/packages/svelte/src/JsonUIProvider.svelte @@ -4,8 +4,8 @@ * Props for JSONUIProvider */ export interface JSONUIProviderProps { - /** Component registry */ - registry: ComponentRegistry; + /** Component registry (passed through for convenience; not used internally) */ + registry?: ComponentRegistry; /** * External store (controlled mode). When provided, `initialState` and * `onStateChange` are ignored. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c08a7ef..95405ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -520,6 +520,64 @@ importers: specifier: ^5.7.2 version: 5.9.3 + examples/mcp: + dependencies: + '@json-render/core': + specifier: workspace:* + version: link:../../packages/core + '@json-render/mcp': + specifier: workspace:* + version: link:../../packages/mcp + '@json-render/react': + specifier: workspace:* + version: link:../../packages/react + '@json-render/shadcn': + specifier: workspace:* + version: link:../../packages/shadcn + '@modelcontextprotocol/ext-apps': + specifier: ^1.2.0 + version: 1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/react': + specifier: 19.2.3 + version: 19.2.3 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.3) + '@vitejs/plugin-react': + specifier: ^5.1.4 + version: 5.1.4(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-plugin-singlefile: + specifier: ^2.3.0 + version: 2.3.0(rollup@4.55.1)(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + examples/no-ai: dependencies: '@json-render/core': @@ -621,7 +679,7 @@ importers: version: 0.575.0(react@19.2.4) next: specifier: 16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -755,7 +813,7 @@ importers: version: 0.575.0(react@19.2.4) next: specifier: 16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1259,6 +1317,37 @@ importers: specifier: ^5.4.5 version: 5.9.3 + packages/mcp: + dependencies: + '@json-render/core': + specifier: workspace:* + version: link:../core + '@modelcontextprotocol/ext-apps': + specifier: ^1.2.0 + version: 1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + devDependencies: + '@internal/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/react': + specifier: 19.2.3 + version: 19.2.3 + tsup: + specifier: ^8.0.2 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.4.5 + version: 5.9.3 + packages/react: dependencies: '@json-render/core': @@ -3630,6 +3719,19 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/ext-apps@1.2.0': + resolution: {integrity: sha512-ijUQJX/FmNq8PWgOLzph/BAfy84sUZxoIRuHzr+F37wYtWjhdl8pliBJybapYolppY+XJ8oqjFZmTOuMqxwbWQ==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.24.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -3640,6 +3742,16 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@mongodb-js/zstd@7.0.0': resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} engines: {node: '>= 20.19.0'} @@ -12106,6 +12218,53 @@ packages: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} + vite-plugin-singlefile@2.3.0: + resolution: {integrity: sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.44.1 + vite: ^5.4.11 || ^6.0.0 || ^7.0.0 + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14860,6 +15019,22 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/ext-apps@1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@modelcontextprotocol/ext-apps@1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.0) @@ -14882,6 +15057,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.0) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.0 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@mongodb-js/zstd@7.0.0': dependencies: node-addon-api: 8.5.0 @@ -17744,7 +17941,7 @@ snapshots: '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))': dependencies: '@types/jest': 28.1.8 - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5) eslint: 8.57.1 eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -17977,6 +18174,13 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tailwindcss/vite@4.2.1(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.1 @@ -18263,7 +18467,7 @@ snapshots: '@types/node': 22.19.6 optional: true - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)': + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5) @@ -18497,6 +18701,18 @@ snapshots: '@visual-json/core': 0.1.1 react: 19.2.3 + '@vitejs/plugin-react@5.1.4(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 @@ -26448,6 +26664,29 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + vite-plugin-singlefile@2.3.0(rollup@4.55.1)(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + micromatch: 4.0.8 + rollup: 4.55.1 + vite: 6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.6 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -26903,6 +27142,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.22.3: {} zod@3.25.76: {} diff --git a/skills/json-render-mcp/SKILL.md b/skills/json-render-mcp/SKILL.md new file mode 100644 index 0000000..680eae9 --- /dev/null +++ b/skills/json-render-mcp/SKILL.md @@ -0,0 +1,128 @@ +--- +name: json-render-mcp +description: MCP Apps integration for json-render. Use when building MCP servers that render interactive UIs in Claude, ChatGPT, Cursor, or VS Code, or when integrating json-render with the Model Context Protocol. +--- + +# @json-render/mcp + +MCP Apps integration that serves json-render UIs as interactive MCP Apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. + +## Quick Start + +### Server (Node.js) + +```typescript +import { createMcpApp } from "@json-render/mcp"; +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/react/schema"; +import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import fs from "node:fs"; + +const catalog = defineCatalog(schema, { + components: { ...shadcnComponentDefinitions }, + actions: {}, +}); + +const server = createMcpApp({ + name: "My App", + version: "1.0.0", + catalog, + html: fs.readFileSync("dist/index.html", "utf-8"), +}); + +await server.connect(new StdioServerTransport()); +``` + +### Client (React, inside iframe) + +```tsx +import { useJsonRenderApp } from "@json-render/mcp/app"; +import { JSONUIProvider, Renderer } from "@json-render/react"; + +function McpAppView({ registry }) { + const { spec, loading, error } = useJsonRenderApp(); + if (error) return
Error: {error.message}
; + if (!spec) return
Waiting...
; + return ( + + + + ); +} +``` + +## Architecture + +1. `createMcpApp()` creates an `McpServer` that registers a `render-ui` tool and a `ui://` HTML resource +2. The tool description includes the catalog prompt so the LLM knows how to generate valid specs +3. The HTML resource is a Vite-bundled single-file React app with json-render renderers +4. Inside the iframe, `useJsonRenderApp()` connects to the host via `postMessage` and renders specs + +## Server API + +- `createMcpApp(options)` - main entry, creates a full MCP server +- `registerJsonRenderTool(server, options)` - register a json-render tool on an existing server +- `registerJsonRenderResource(server, options)` - register the UI resource + +## Client API (`@json-render/mcp/app`) + +- `useJsonRenderApp(options?)` - React hook, returns `{ spec, loading, connected, error, callServerTool }` +- `buildAppHtml(options)` - generate HTML from bundled JS/CSS + +## Building the iframe HTML + +Bundle the React app into a single self-contained HTML file using Vite + `vite-plugin-singlefile`: + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { outDir: "dist" }, +}); +``` + +## Client Configuration + +### Cursor (`.cursor/mcp.json`) + +```json +{ + "mcpServers": { + "my-app": { + "command": "npx", + "args": ["tsx", "server.ts", "--stdio"] + } + } +} +``` + +### Claude Desktop + +```json +{ + "mcpServers": { + "my-app": { + "command": "npx", + "args": ["tsx", "/path/to/server.ts", "--stdio"] + } + } +} +``` + +## Dependencies + +```bash +# Server +npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk + +# Client (iframe) +npm install @json-render/react @json-render/shadcn react react-dom + +# Build tools +npm install -D vite @vitejs/plugin-react vite-plugin-singlefile +```