Upstream version reference: @json-render/mcp 0.21.0 (2026-09-30).
MCP Visual Output
Upgrade plain MCP tool responses to interactive dashboards rendered inside AI conversations. Built on @json-render/mcp, which bridges the json-render spec system with MCP's tool/resource model -- the AI generates a typed JSON spec, and a sandboxed iframe renders it as an interactive UI.
Building an MCP server from scratch? Use
ork:mcp-patternsfor server setup, transport, and security. This skill focuses on the visual output layer after your server is running.Need the full component catalog? See
ork:json-render-catalogfor all available components, props, and composition patterns.
Decision Tree -- Which File to Read
What are you doing? | +-- Setting up visual output for the first time | +-- New MCP server -----------> rules/mcp-app-setup.md | +-- Existing MCP server ------> rules/mcp-app-setup.md (registerJsonRenderTool section) | +-- Configuring security / sandbox | +-- CSP declarations ----------> rules/sandbox-csp.md | +-- Iframe permissions --------> rules/sandbox-csp.md | +-- Rendering strategy | +-- Progressive streaming -----> rules/streaming-output.md | +-- Dashboard layouts ----------> rules/dashboard-patterns.md | +-- API reference | +-- Server-side API -----------> references/mcp-integration.md | +-- Component recipes ----------> references/component-recipes.md
Quick Reference
| Category | Rule | Impact | Key Pattern |
|---|---|---|---|
| Setup | mcp-app-setup.md | HIGH | createMcpApp() and registerJsonRenderTool() |
| Security | sandbox-csp.md | HIGH | CSP declarations, iframe sandboxing |
| Rendering | streaming-output.md | MEDIUM | Progressive rendering via JSON Patch |
| Patterns | dashboard-patterns.md | MEDIUM | Stat grids, status badges, data tables |
Total: 4 rules across 3 categories
How It Works
- Define a catalog -- typed component schemas using
defineCatalog()+ Zod - Register with MCP --
createMcpApp()for new servers orregisterJsonRenderTool()for existing ones - AI generates specs -- the model produces a JSON spec conforming to the catalog
- Iframe renders it -- a bundled React app inside a sandboxed iframe renders the spec with
useJsonRenderApp()+<Renderer />
The AI never writes HTML or CSS. It produces a structured JSON spec that references catalog components by type. The iframe app renders those components using a pre-built registry.
Quick Start -- New MCP Server
typescriptimport { createMcpApp } from '@json-render/mcp' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { buildAppHtml } from '@json-render/mcp/app' import { catalog } from './catalog' // Generate the iframe HTML from the bundled JS/CSS (docs-prescribed generator). const bundledHtml = buildAppHtml({ entry: './app.tsx' }) // 1. Create the MCP app (async; returns an McpServer, no .start()/.close()). // name + version are required; tool config nests under `tool` // (default tool name is 'render-ui'). There is no top-level `csp`. const server = await createMcpApp({ name: 'my-app', version: '1.0.0', catalog, // component schemas the AI can use html: bundledHtml, // pre-built iframe app (single HTML file) tool: { name: 'render-dashboard', description: 'Render an interactive dashboard from a json-render spec', }, }) // 2. Connect a transport -- stdio, Streamable HTTP, or any MCP transport await server.connect(new StdioServerTransport())
Quick Start -- Enhance Existing Server with Visual Output
typescriptimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { registerJsonRenderTool, registerJsonRenderResource } from '@json-render/mcp' import { buildAppHtml } from '@json-render/mcp/app' import { catalog } from './catalog' const server = new McpServer({ name: 'my-server', version: '1.0.0' }) // Generate the iframe HTML from the bundled JS/CSS (docs-prescribed generator). const bundledHtml = buildAppHtml({ entry: './app.tsx' }) const resourceUri = 'ui://my-server/dashboard' // Register the render tool (lets the model return specs). // name, title, description, and resourceUri are all required. registerJsonRenderTool(server, { catalog, name: 'render-dashboard', title: 'Render Dashboard', description: 'Render an interactive dashboard from a json-render spec', resourceUri, }) // Serve the bundled HTML iframe app as a resource (new in 0.15). // resourceUri must match the tool's resourceUri. registerJsonRenderResource(server, { resourceUri, html: bundledHtml })
registerJsonRenderResource() was added in 0.15 to separate tool registration from UI resource serving — useful when the host caches the bundled HTML (clients: Claude, ChatGPT, Cursor, VS Code Copilot, Goose, Postman). Transports: stdio and Streamable HTTP (Express) both supported. This skill is verified against @json-render/mcp 0.20.0, which shipped zero code change: every published file is byte-identical to 0.19.0 apart from the version and its pinned @json-render/core dependency. New capability in that release therefore belongs to core and react, not to this package. What reaches an MCP-rendered iframe indirectly is named slots and nested repeats, plus a fix so named onSuccess/onError chained actions actually receive their configured params.
Smallest Proof: One Tool, UI Resource Plus Text Fallback
scripts/glyph-card-server.mjs is a dependency-free MCP server (stdio, or --http <port> for Streamable HTTP) with one tool, render_glyph_card. The tool lists _meta.ui.resourceUri (plus the legacy ui/resourceUri and the ChatGPT alias openai/outputTemplate), serves a ui:// resource as text/html;profile=mcp-app, and returns a glyph text card in content with the same rows in structuredContent. Hosts without UI use the text; MCP Apps hosts draw the card. One caveat: Claude Code passes structuredContent JSON to the model when it is present, so the text card is not what the model sees there. The HTTP mode refuses any non-local Host or Origin with 403 unless you name one host with --allow-host <host>, so a tunnel serves only that name. Unit tests: tests/unit/test-mcp-glyph-card.mjs.
Client-Side Iframe App
The iframe app receives specs from the MCP host and renders them:
typescriptimport { useJsonRenderApp } from '@json-render/mcp/app' import { Renderer } from '@json-render/react' import { registry } from './registry' function App() { const { spec, loading } = useJsonRenderApp() if (loading) return <Skeleton /> return <Renderer spec={spec} registry={registry} /> }
Catalog Definition
Catalogs define what components the AI can use. Each component has typed props via Zod:
typescriptimport { defineCatalog } from '@json-render/core' import { schema } from '@json-render/react/schema' import { z } from 'zod' export const dashboardCatalog = defineCatalog(schema, { components: { StatGrid: { props: z.object({ items: z.array(z.object({ label: z.string(), value: z.string(), trend: z.enum(['up', 'down', 'flat']).optional(), color: z.enum(['green', 'red', 'yellow', 'blue']).optional(), })), }), children: false, }, StatusBadge: { props: z.object({ label: z.string(), status: z.enum(['success', 'warning', 'error', 'info', 'pending']), }), children: false, }, DataTable: { props: z.object({ columns: z.array(z.object({ key: z.string(), label: z.string() })), rows: z.array(z.record(z.string())), }), children: false, }, }, })
Example: Eval Results Dashboard
The AI generates a spec like this -- flat element map, no nesting beyond 2 levels:
json{ "root": "dashboard", "elements": { "dashboard": { "type": "Card", "props": { "title": "Eval Results -- v7.21.1" }, "children": ["stats", "table"] }, "stats": { "type": "StatGrid", "props": { "items": [ { "label": "Skills Evaluated", "value": "94", "trend": "flat" }, { "label": "Pass Rate", "value": "97.8%", "trend": "up", "color": "green" }, { "label": "Avg Score", "value": "8.2/10", "trend": "up" } ] } }, "table": { "type": "DataTable", "props": { "columns": [ { "key": "skill", "label": "Skill" }, { "key": "score", "label": "Score" }, { "key": "status", "label": "Status" } ], "rows": [ { "skill": "implement", "score": "9.1", "status": "pass" }, { "skill": "verify", "score": "8.7", "status": "pass" } ] } } } }
Key Decisions
| Decision | Recommendation |
|---|---|
| New vs existing server | createMcpApp() for new; registerJsonRenderTool() to add to existing |
| CSP policy | Minimal -- only declare domains you actually need |
| Streaming | Always enable progressive rendering; never wait for full spec |
| Dashboard depth | Keep element trees flat (2-3 levels max) for streamability |
| Component count | 3-5 component types per catalog covers most dashboards |
| Visual vs text | Use visual output for multi-metric views; plain text for single values |
CC 2.1.113 fixed MCP concurrent-call timeout handling — hanging tool calls now error cleanly instead of blocking the queue. Parallel tool invocation from dashboards is safer; no workarounds needed.
When to Use Visual Output vs Plain Text
| Scenario | Use Visual Output | Use Plain Text |
|---|---|---|
| Multiple metrics at a glance | Yes -- StatGrid | No |
| Tabular data (5+ rows) | Yes -- DataTable | No |
| Status of multiple systems | Yes -- StatusBadge grid | No |
| Single value answer | No | Yes |
| Error message | No | Yes |
| File content / code | No | Yes |
Common Mistakes
- Returning raw HTML strings from MCP tools instead of json-render specs (breaks type safety, no streaming)
- Deeply nested component trees that cannot stream progressively (keep flat)
- Using
script-src 'unsafe-inline'in CSP declarations (security risk, unnecessary) - Waiting for the full spec before rendering (defeats progressive rendering)
- Defining 20+ component types in a single catalog (increases prompt token cost)
- Missing
htmlbundle increateMcpApp()config (iframe has nothing to render)
Related Skills
ork:mcp-patterns-- MCP server building, transport, securityork:json-render-catalog-- Full component catalog and composition patternsork:multi-surface-render-- Rendering across Claude, Cursor, ChatGPT, webork:ai-ui-generation-- GenUI patterns for AI-generated interfaces


