
Vercel の json-render で Claude の MCP Apps UI を作ってみた
はじめに
MCP Apps(Model Context Protocol Apps)は、AI チャットクライアント内にインタラクティブな UI を直接表示できる仕組みです。しかし実際に開発するとなると、HTML/CSS/JS をゼロから書いてシングルファイルにバンドルし、MCP サーバーに登録する……というステップが必要で、地味に手間がかかります。
そこで登場するのが json-render です。Vercel Labs が公開した Generative UI(生成的 UI)フレームワークで、「AI が自然言語から UI を生成し、あらかじめ定義したコンポーネントカタログの範囲内でレンダリングする」というコンセプトのライブラリです。Generative UI とは、LLM がテキストだけでなくリッチな UI コンポーネントを動的に生成・表示するアプローチのことです。
json-render には @json-render/mcp パッケージが用意されており、MCP Apps との統合がとてもスムーズにできます。今回はこの MCP サンプルを実際にビルドして Claude Desktop で動かすところまで試してみました。
対象読者: MCP Apps の基本は知っていて、UI 開発をもっと効率化したいエンジニア
json-render とは
json-render は、AI が生成する UI を「JSON スペック」として表現し、事前に定義したコンポーネントカタログの範囲内でレンダリングするフレームワークです。Generative UI(生成的 UI)という概念を Vercel が提唱しており、そのリファレンス実装にあたります。
特徴を3つに絞ると以下の通りです。
| 特徴 | 説明 |
|---|---|
| ガードレール付き | AI はカタログに定義されたコンポーネントしか使えない。意図しない HTML の注入を防止 |
| クロスプラットフォーム | 同じカタログ定義で React, Vue, Svelte, Solid, React Native に対応 |
| プログレッシブレンダリング | LLM のストリーミング出力に合わせて UI を段階的に描画 |
対応するレンダラーは非常に豊富です。Web フレームワーク(React / Vue / Svelte / Solid)だけでなく、React Native(モバイル)、React Three Fiber(3D)、Remotion(動画)、React PDF、React Email まで揃っています。
3ステップのアーキテクチャ
json-render は以下の3ステップで動作します。
1. カタログ定義 ──── どのコンポーネントが使えるか(Zodスキーマで定義)
↓
2. JSON スペック生成 ── LLM がカタログに沿って UI 仕様を JSON で出力
↓
3. レンダリング ──── Renderer がスペックをコンポーネントツリーに変換して描画
json-render × MCP Apps の仕組み
json-render の @json-render/mcp パッケージは、上記の3ステップを MCP Apps のアーキテクチャにきれいに乗せています。
┌──────────────┐ ①カタログをプロンプトに変換 ┌───────────────┐
│ json-render │ ──────────────────────────► │ LLM │
│ カタログ定義 │ │ (Claude等) │
└──────────────┘ └───────┬───────┘
│ ②JSONスペックを生成
▼
┌──────────────┐ ③ render-ui ツール結果 ┌───────────────┐
│ MCP Apps │ ◄──── postMessage ────────── │ Host │
│ iframe内のUI │ │ (Claude Desktop) │
└──────────────┘ └───────────────┘
│
▼ ④ Renderer がスペックを shadcn/ui コンポーネントに変換
┌──────────────────┐
│ インタラクティブUI │
│ (テーブル, カード等) │
└──────────────────┘
ポイントは createMcpApp() という関数です。カタログと HTML を渡すだけで、MCP サーバーのセットアップが完了します。
import { createMcpApp } from "@json-render/mcp";
const server = await createMcpApp({
name: "json-render Example",
version: "1.0.0",
catalog, // コンポーネントカタログ
html, // Vite でバンドルしたシングル HTML
});
内部では以下が自動的に行われます。
catalog.prompt()でカタログ定義を LLM 向けのプロンプト文字列に変換render-uiという名前の MCP ツールを登録(description にカタログプロンプトを埋め込み)ui://render-ui/view.htmlというリソースを登録(バンドル済み HTML を返す)
つまり、LLM はツールの description からどんなコンポーネントが使えるかを理解し、JSON スペックを生成してツール結果として返します。ホストがそれを iframe 内の UI に渡し、json-render の Renderer がリッチな UI に変換する、という流れです。
実際にセットアップして動かしてみる
前提条件
- Node.js >= 20
- pnpm(monorepo のため)
- Claude Desktop または Cursor 等の MCP Apps 対応クライアント
リポジトリのクローンとビルド
json-render は monorepo 構成なので、まず全体をクローンして必要なパッケージをビルドします。
git clone https://github.com/vercel-labs/json-render.git
cd json-render
pnpm install
MCP サンプルは examples/mcp/ にあります。依存する内部パッケージを順番にビルドする必要がありました。
# 依存パッケージを順にビルド
pnpm --filter @json-render/core build
pnpm --filter @internal/react-state build
pnpm --filter @json-render/react build
pnpm --filter @json-render/shadcn build
pnpm --filter @json-render/mcp build
# MCP サンプルをビルド
pnpm --filter example-mcp build
ビルドが成功すると、examples/mcp/dist/index.html に約983KBのシングル HTML ファイルが生成されます。Vite の vite-plugin-singlefile プラグインにより、CSS と JS がすべてインライン化された一枚の HTML になっています。
$ ls -la examples/mcp/dist/index.html
-rw-r--r-- 1 user user 983067 Mar 18 17:38 dist/index.html
Claude Desktop での設定
claude_desktop_config.json に以下を追加します。
{
"mcpServers": {
"json-render": {
"command": "npx",
"args": ["tsx", "/Users/yourname/json-render/examples/mcp/server.ts", "--stdio"]
}
}
}
/Users/yourname/json-render/ の部分は、実際にリポジトリをクローンしたパスに置き換えてください。
Cursor を使う場合は .cursor/mcp.json に同様の形式で追加します。
HTTP トランスポートで起動したい場合は --stdio を外して起動するだけです。
cd examples/mcp
pnpm start
# MCP server listening on http://localhost:3001/mcp

試しに UI を生成してもらう
Claude Desktop で「売上ダッシュボードを表示して」のように依頼すると、render-ui ツールが呼び出されます。LLM はカタログの description を参照して、使用可能な shadcn/ui コンポーネント(Card, Table, Badge, Chart 等)の組み合わせで JSON スペックを生成します。
生成される JSON スペックはこのような構造です。
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "売上ダッシュボード" },
"children": ["metric-1", "metric-2", "table-1"]
},
"metric-1": {
"type": "Metric",
"props": { "label": "今月の売上", "value": "¥12,500,000", "format": "currency" },
"children": []
},
"metric-2": {
"type": "Metric",
"props": { "label": "前月比", "value": "+15%", "format": "percent" },
"children": []
},
"table-1": {
"type": "Table",
"props": {
"columns": ["商品名", "売上", "数量"],
"rows": [
["商品A", "¥5,000,000", "500"],
["商品B", "¥4,200,000", "380"],
["商品C", "¥3,300,000", "290"]
]
},
"children": []
}
}
}
この JSON が iframe 内の Renderer に渡され、shadcn/ui の美しいコンポーネントとしてレンダリングされます。

コードの中身を読み解く
カタログ定義(catalog.ts)
カタログは非常にシンプルです。shadcn/ui の36コンポーネントをまとめてインポートしています。
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, // 36個の shadcn/ui コンポーネント定義
},
actions: {},
});
defineCatalog() は Zod スキーマベースでコンポーネントの props を定義する関数です。shadcnComponentDefinitions には Card, Table, Badge, Button, Alert, Dialog など36種類のコンポーネントが含まれており、それぞれの props スキーマと description が定義されています。
catalog.prompt() を呼ぶと、これらの定義がLLM が理解できるプロンプト文字列に変換されます。このプロンプトが render-ui ツールの description にそのまま埋め込まれるため、LLM は「どのコンポーネントがどんな props を持つか」を正確に把握できる仕組みです。
サーバー実装(server.ts)
サーバーは stdio と HTTP の両方をサポートしています。
import { createMcpApp } from "@json-render/mcp";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.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());
}
カタログとバンドル済み HTML を渡すだけで、MCP サーバーの登録(ツール定義、リソース登録)がすべて完了します。通常の MCP Apps 開発で必要な registerApp() や server.tool() を個別に書く必要がありません。
UI 側の実装(main.tsx)
iframe 内で動作する React アプリのエントリーポイントです。
import { useState, useEffect } from "react";
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, // shadcn/ui の実コンポーネント
},
});
function McpAppView() {
const [spec, setSpec] = useState<Spec | null>(null);
useEffect(() => {
const app = new McpApp({ name: "json-render", version: "1.0.0" });
// ツール結果(JSONスペック)を受け取ってパースする
app.ontoolresult = (result) => {
const parsed = parseSpec(result);
if (parsed) setSpec(parsed);
};
// ホストのテーマ(ダーク/ライト)に自動追従
app.onhostcontextchanged = (ctx) => applyHostContext(ctx);
app.connect();
}, []);
if (!spec) return <div>Loading...</div>;
return (
<JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
);
}
ここでのポイントは以下の3つです。
McpApp(ext-apps SDK) でホストとの postMessage 通信を確立ontoolresultコールバックで LLM が生成した JSON スペックを受け取りRendererにスペックと registry を渡して描画
さらに、onhostcontextchanged でホスト側のダークモード/ライトモード設定を取得し、CSS 変数を動的に切り替えている点も実用的です。
Vite のシングルファイル設定
vite.config.ts では vite-plugin-singlefile を使ってすべてを1つの HTML にバンドルしています。
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",
rollupOptions: {
input: "index.html",
},
},
});
MCP Apps では UI を ui:// リソースとして HTML 文字列で返す必要があるため、CSS / JS をすべてインライン化する viteSingleFile() が必須です。これは MCP Apps 開発でのベストプラクティスと言えます。
json-render を使うメリット・従来との比較
従来の MCP Apps UI 開発
HTML/CSS/JSを手書き → registerApp() でリソース登録 → ツール定義を手書き
- UI のデザインを1からコーディング
- ツールの description にUI の使い方を手動で記述
- LLM がどんな UI を生成するかは予測しづらい
json-render を使った場合
カタログ定義(Zod) → createMcpApp() 1行 → LLM が自動で UI を生成
- 36種の shadcn/ui コンポーネントがすぐ使える
- カタログ定義がそのままツールの description になるため、LLM は正確にコンポーネントを選択できる
- スキーマバリデーションにより、不正な JSON スペックはレンダリング前に弾かれる
- ダークモード対応、プログレッシブレンダリングもビルトイン
カタログのカスタマイズ
デフォルトでは shadcn/ui の全36コンポーネントがカタログに含まれますが、LLM のプロンプトが長くなりすぎる場合があります。必要なコンポーネントだけに絞ることで、トークン使用量を削減し、生成精度を上げることができます。
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
// 必要なコンポーネントだけ選択
const { Card, Table, Badge, Metric, Button } = shadcnComponentDefinitions;
export const catalog = defineCatalog(schema, {
components: { Card, Table, Badge, Metric, Button },
actions: {
export_report: { description: "Export dashboard to PDF" },
},
});
まとめ
json-render を使うと、MCP Apps の UI 開発が「コンポーネントのカタログを定義するだけ」に近い体験になります。
今回実際に試してみて感じたポイントをまとめます。
createMcpApp()1行でサーバーセットアップが完了 する手軽さは魅力的。通常の MCP Apps 開発で必要なregisterApp()+server.tool()の定型コードが不要- Zod ベースのカタログ定義 により、LLM が生成できる UI が型安全に制約される。「何が出てくるかわからない」不安がない
- shadcn/ui の36コンポーネント がプリビルトで使えるため、UI デザインのスキルがなくても美しいダッシュボードやフォームが生成される
- ダークモード対応、プログレッシブレンダリング がビルトインで、ホスト側の体験が洗練されている
- monorepo のビルドには多少手間がかかるが、npm パッケージとして利用すれば問題ない
「MCP Apps の UI をもっと手軽に、もっと美しく」したい方にはぴったりのライブラリです。React 以外のフレームワーク(Vue / Svelte / Solid)にも対応しているので、技術スタックに関わらず検討する価値があります。








