Browse Source

mcp (#184)

* mcp

* fix lint
Chris Tate 4 tháng trước cách đây
mục cha
commit
1cc87310c9

+ 2 - 1
.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": [],

+ 8 - 0
.cursor/mcp.json

@@ -0,0 +1,8 @@
+{
+  "mcpServers": {
+    "json-render": {
+      "command": "npx",
+      "args": ["tsx", "examples/mcp/server.ts", "--stdio"]
+    }
+  }
+}

+ 9 - 0
.vscode/mcp.json

@@ -0,0 +1,9 @@
+{
+  "servers": {
+    "json-render": {
+      "type": "stdio",
+      "command": "npx",
+      "args": ["tsx", "examples/mcp/server.ts", "--stdio"]
+    }
+  }
+}

+ 1 - 0
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
 

+ 247 - 0
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
+
+<table>
+  <thead>
+    <tr>
+      <th>Option</th>
+      <th>Type</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>name</code></td>
+      <td><code>string</code></td>
+      <td>Server name shown in client UIs</td>
+    </tr>
+    <tr>
+      <td><code>version</code></td>
+      <td><code>string</code></td>
+      <td>Server version</td>
+    </tr>
+    <tr>
+      <td><code>catalog</code></td>
+      <td><code>Catalog</code></td>
+      <td>json-render catalog defining available components</td>
+    </tr>
+    <tr>
+      <td><code>html</code></td>
+      <td><code>string</code></td>
+      <td>Self-contained HTML for the iframe UI</td>
+    </tr>
+    <tr>
+      <td><code>tool</code></td>
+      <td><code>McpToolOptions</code></td>
+      <td>Optional tool name/title/description overrides</td>
+    </tr>
+  </tbody>
+</table>
+
+### 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 <div>Error: {error.message}</div>;
+  if (!spec) return <div>Waiting...</div>;
+
+  return (
+    <JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
+      <Renderer spec={spec} registry={registry} loading={loading} />
+    </JSONUIProvider>
+  );
+}
+```
+
+#### UseJsonRenderAppReturn
+
+<table>
+  <thead>
+    <tr>
+      <th>Field</th>
+      <th>Type</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>spec</code></td>
+      <td><code>{'Spec | null'}</code></td>
+      <td>Current json-render spec</td>
+    </tr>
+    <tr>
+      <td><code>loading</code></td>
+      <td><code>boolean</code></td>
+      <td>Whether the spec is still being received</td>
+    </tr>
+    <tr>
+      <td><code>connected</code></td>
+      <td><code>boolean</code></td>
+      <td>Whether connected to the host</td>
+    </tr>
+    <tr>
+      <td><code>connecting</code></td>
+      <td><code>boolean</code></td>
+      <td>Whether currently connecting</td>
+    </tr>
+    <tr>
+      <td><code>error</code></td>
+      <td><code>{'Error | null'}</code></td>
+      <td>Connection error, if any</td>
+    </tr>
+    <tr>
+      <td><code>app</code></td>
+      <td><code>{'App | null'}</code></td>
+      <td>The underlying MCP App instance</td>
+    </tr>
+    <tr>
+      <td><code>callServerTool</code></td>
+      <td><code>{'(name, args?) => Promise<void>'}</code></td>
+      <td>Call an MCP server tool and update spec from result</td>
+    </tr>
+  </tbody>
+</table>
+
+### 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.

+ 1 - 1
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.
 

+ 6 - 0
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" },
     ],
   },
 ];

+ 1 - 0
apps/web/lib/page-titles.ts

@@ -50,6 +50,7 @@ export const PAGE_TITLES: Record<string, string> = {
   "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",
 };
 
 /**

+ 63 - 0
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

+ 13 - 0
examples/mcp/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; img-src * data: blob:; font-src * data:; connect-src *; media-src *;" />
+  <title>json-render MCP App</title>
+</head>
+<body>
+  <div id="root"></div>
+  <script type="module" src="/src/main.tsx"></script>
+</body>
+</html>

+ 34 - 0
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"
+  }
+}

+ 86 - 0
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);
+  });
+}

+ 10 - 0
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: {},
+});

+ 106 - 0
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;
+}

+ 269 - 0
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<string, unknown> }) => {
+      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 (
+        <div
+          style={{
+            width: size,
+            height: size,
+            borderRadius: "50%",
+            overflow: "hidden",
+            backgroundColor: "var(--color-background-tertiary, #27272a)",
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            flexShrink: 0,
+          }}
+        >
+          {showImg ? (
+            <img
+              src={src}
+              alt={name}
+              referrerPolicy="no-referrer"
+              crossOrigin="anonymous"
+              width={size}
+              height={size}
+              onError={onError}
+              style={{ objectFit: "cover", width: size, height: size }}
+            />
+          ) : (
+            <span
+              style={{
+                fontSize: size * 0.4,
+                color: "var(--color-text-secondary, #a1a1aa)",
+              }}
+            >
+              {initials}
+            </span>
+          )}
+        </div>
+      );
+    },
+  },
+});
+
+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 (
+        <div
+          style={{
+            padding: 16,
+            color: "#dc2626",
+            fontFamily: "monospace",
+            fontSize: 13,
+          }}
+        >
+          Error: {this.state.error.message}
+        </div>
+      );
+    }
+    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<string, string> };
+}) {
+  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<Spec | null>(null);
+  const [error, setError] = useState<string | null>(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<string, unknown> | undefined;
+      if (!data || typeof data !== "object") return;
+      const method = data.method as string | undefined;
+      const params = data.params as Record<string, unknown> | undefined;
+
+      // Cursor sends the spec via tool-input
+      if (method === "ui/notifications/tool-input" && params?.arguments) {
+        const args = params.arguments as Record<string, unknown>;
+        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<typeof applyHostContext>[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<typeof applyHostContext>[0]);
+
+        // Fallback: if host didn't provide theme, detect via media query
+        if (!ctx || !(ctx as Record<string, unknown>).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 (
+      <div
+        style={{
+          padding: 16,
+          color: "#dc2626",
+          fontFamily: "monospace",
+          fontSize: 13,
+        }}
+      >
+        {error}
+      </div>
+    );
+  }
+
+  if (!spec) {
+    return (
+      <div
+        style={{
+          padding: 16,
+          color: "#6b7280",
+          fontFamily: "sans-serif",
+          fontSize: 14,
+        }}
+      >
+        Loading...
+      </div>
+    );
+  }
+
+  return (
+    <JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
+      <div className="w-full">
+        <Renderer spec={spec} registry={registry} />
+      </div>
+    </JSONUIProvider>
+  );
+}
+
+createRoot(document.getElementById("root")!).render(
+  <ErrorBoundary>
+    <McpAppView />
+  </ErrorBoundary>,
+);

+ 89 - 0
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<string[]>(["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 (
+      <div style={debugStyle}>
+        <div style={{ color: "#ef4444", fontWeight: "bold" }}>
+          Connection error: {error.message}
+        </div>
+        <div style={{ marginTop: 8 }}>{logs.join("\n")}</div>
+      </div>
+    );
+  }
+
+  if (!spec) {
+    return (
+      <div style={debugStyle}>
+        <div>
+          {connecting
+            ? "Connecting to host..."
+            : loading
+              ? "Waiting for UI spec..."
+              : "No spec received."}
+        </div>
+        <div style={{ marginTop: 8 }}>{logs.join("\n")}</div>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
+        <Renderer spec={spec} registry={registry} loading={loading} />
+      </JSONUIProvider>
+    </div>
+  );
+}

+ 17 - 0
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"]
+}

+ 15 - 0
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",
+    },
+  },
+});

+ 2 - 3
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,

+ 124 - 0
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 <div>Error: {error.message}</div>;
+  if (!spec) return <div>Waiting for spec...</div>;
+
+  return (
+    <JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
+      <Renderer spec={spec} registry={registry} loading={loading} />
+    </JSONUIProvider>
+  );
+}
+```
+
+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.

+ 78 - 0
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
+    }
+  }
+}

+ 30 - 0
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 <Renderer spec={spec} registry={registry} loading={loading} />;
+ * }
+ * ```
+ *
+ * @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";

+ 71 - 0
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 `<style>` tag.
+   * Use this to include Tailwind output or custom styles.
+   */
+  css?: string;
+  /**
+   * Bundled JavaScript for the app entry point.
+   * This should be the output of a bundler (Vite, esbuild, etc.)
+   * that bundles your React app, registry, and the
+   * `useJsonRenderApp` hook into a single script.
+   */
+  js: string;
+  /**
+   * Additional `<head>` content (meta tags, font links, etc.).
+   */
+  head?: string;
+}
+
+/**
+ * Build a self-contained HTML string for an MCP App UI resource.
+ *
+ * The resulting HTML is designed to be served as a `ui://` resource
+ * via `registerJsonRenderResource` or `createMcpApp`.
+ *
+ * Typically you'd use a bundler (Vite + `vite-plugin-singlefile`, or
+ * esbuild) to produce the `js` and `css` strings, then pass them here.
+ *
+ * @example
+ * ```ts
+ * import { buildAppHtml } from "@json-render/mcp/app";
+ * import { readFileSync } from "node:fs";
+ *
+ * const html = buildAppHtml({
+ *   title: "Dashboard",
+ *   js: readFileSync("dist/app.js", "utf-8"),
+ *   css: readFileSync("dist/app.css", "utf-8"),
+ * });
+ * ```
+ */
+export function buildAppHtml(options: BuildAppHtmlOptions): string {
+  const { title = "json-render", css = "", js, head = "" } = options;
+
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>${escapeHtml(title)}</title>
+  ${head}
+  <style>${css}</style>
+</head>
+<body>
+  <div id="root"></div>
+  <script type="module">${js}</script>
+</body>
+</html>`;
+}
+
+function escapeHtml(str: string): string {
+  return str
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;");
+}

+ 12 - 0
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";

+ 158 - 0
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<void> {
+  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<void> {
+  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<McpServer> {
+  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;
+}

+ 62 - 0
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;
+}

+ 139 - 0
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<string, unknown>,
+  ) => Promise<void>;
+}
+
+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<Spec | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [connected, setConnected] = useState(false);
+  const [error, setError] = useState<Error | null>(null);
+  const appRef = useRef<App | null>(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<string, unknown> = {}) => {
+      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,
+  };
+}

+ 9 - 0
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"]
+}

+ 17 - 0
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",
+  ],
+});

+ 2 - 2
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.

+ 247 - 4
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: {}

+ 128 - 0
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 <div>Error: {error.message}</div>;
+  if (!spec) return <div>Waiting...</div>;
+  return (
+    <JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
+      <Renderer spec={spec} registry={registry} loading={loading} />
+    </JSONUIProvider>
+  );
+}
+```
+
+## 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
+```