mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 03:54:41 +08:00
@@ -17,7 +17,8 @@
|
||||
"@json-render/jotai",
|
||||
"@json-render/vue",
|
||||
"@json-render/xstate",
|
||||
"@json-render/image"
|
||||
"@json-render/image",
|
||||
"@json-render/mcp"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"json-render": {
|
||||
"command": "npx",
|
||||
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"servers": {
|
||||
"json-render": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
CreateMcpAppOptions,
|
||||
McpToolOptions,
|
||||
RegisterToolOptions,
|
||||
RegisterResourceOptions,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
createMcpApp,
|
||||
registerJsonRenderTool,
|
||||
registerJsonRenderResource,
|
||||
} from "./server.js";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Generated
+247
-4
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user