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
+
+
+
+
+ | 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 |
+ Self-contained HTML for the iframe UI |
+
+
+ tool |
+ McpToolOptions |
+ Optional 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
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+ spec |
+ {'Spec | null'} |
+ Current json-render spec |
+
+
+ loading |
+ boolean |
+ Whether the spec is still being received |
+
+
+ connected |
+ boolean |
+ Whether connected to the host |
+
+
+ connecting |
+ boolean |
+ Whether 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 ? (
+

+ ) : (
+
+ {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 `
+
+
+
+
+
+