* mcp

* fix lint
This commit is contained in:
Chris Tate
2026-03-06 19:41:27 -06:00
committed by GitHub
parent 5b929dffa6
commit 1cc87310c9
32 changed files with 2056 additions and 11 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
"@json-render/jotai",
"@json-render/vue",
"@json-render/xstate",
"@json-render/image"
"@json-render/image",
"@json-render/mcp"
]
],
"linked": [],
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"json-render": {
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"servers": {
"json-render": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
}
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
},
},
});
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,12 @@
export type {
CreateMcpAppOptions,
McpToolOptions,
RegisterToolOptions,
RegisterResourceOptions,
} from "./types.js";
export {
createMcpApp,
registerJsonRenderTool,
registerJsonRenderResource,
} from "./server.js";
+158
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
```