
Mcp Visual Output
- 64 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
mcp-visual-output is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- mcp-visual-output
- AI & Agent Building
- AI-coding skill
Mcp Visual Output by the numbers
- 64 all-time installs (skills.sh)
- Ranked #6,110 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill mcp-visual-outputAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
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-patterns for 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-catalog for 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.mdQuick 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
1. Define a catalog -- typed component schemas using defineCatalog() + Zod 2. Register with MCP -- createMcpApp() for new servers or registerJsonRenderTool() for existing ones 3. AI generates specs -- the model produces a JSON spec conforming to the catalog 4. 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
import { 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
import { 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.
Client-Side Iframe App
The iframe app receives specs from the MCP host and renders them:
import { 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:
import { 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:
{
"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
1. Returning raw HTML strings from MCP tools instead of json-render specs (breaks type safety, no streaming) 2. Deeply nested component trees that cannot stream progressively (keep flat) 3. Using script-src 'unsafe-inline' in CSP declarations (security risk, unnecessary) 4. Waiting for the full spec before rendering (defeats progressive rendering) 5. Defining 20+ component types in a single catalog (increases prompt token cost) 6. Missing html bundle in createMcpApp() 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
Component Recipes for MCP Visual Output
OrchestKit-specific recipes for common dashboard use cases. Each recipe shows the catalog definition, a sample spec, and integration notes.
Recipe 1: Eval Results Dashboard
Display skill evaluation results with pass rates, scores, and per-skill breakdowns.
Catalog
import { schema } from '@json-render/react/schema'
const evalCatalog = 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,
},
DataTable: {
props: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.string())),
sortable: z.boolean().optional(),
}),
children: false,
},
StatusBadge: {
props: z.object({
label: z.string(),
status: z.enum(['success', 'warning', 'error', 'info', 'pending']),
}),
children: false,
},
},
})Sample Spec
{
"root": "eval-dashboard",
"elements": {
"eval-dashboard": {
"type": "Card",
"props": { "title": "Eval Results -- v7.21.1" },
"children": ["run-status", "summary", "skill-results"]
},
"run-status": {
"type": "StatusBadge",
"props": { "label": "Eval Run #42", "status": "success" }
},
"summary": {
"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" },
{ "label": "Regressions", "value": "2", "color": "yellow" }
]
}
},
"skill-results": {
"type": "DataTable",
"props": {
"columns": [
{ "key": "skill", "label": "Skill" },
{ "key": "score", "label": "Score" },
{ "key": "status", "label": "Status" },
{ "key": "delta", "label": "vs Previous" }
],
"rows": [
{ "skill": "implement", "score": "9.1", "status": "pass", "delta": "+0.3" },
{ "skill": "verify", "score": "8.7", "status": "pass", "delta": "+0.1" },
{ "skill": "commit", "score": "7.2", "status": "pass", "delta": "-0.5" }
],
"sortable": true
}
}
}
}Recipe 2: Hook Pipeline Visualization
Show the status of hook execution across global, agent-scoped, and skill-scoped hooks.
Sample Spec
{
"root": "hook-pipeline",
"elements": {
"hook-pipeline": {
"type": "Stack",
"props": { "gap": "md" },
"children": ["global-hooks", "agent-hooks", "skill-hooks"]
},
"global-hooks": {
"type": "Card",
"props": { "title": "Global Hooks (37)" },
"children": ["global-stats", "global-table"]
},
"global-stats": {
"type": "StatGrid",
"props": {
"items": [
{ "label": "Active", "value": "35", "color": "green" },
{ "label": "Disabled", "value": "2", "color": "yellow" }
]
}
},
"global-table": {
"type": "DataTable",
"props": {
"columns": [
{ "key": "hook", "label": "Hook" },
{ "key": "event", "label": "Event" },
{ "key": "status", "label": "Status" },
{ "key": "lastRun", "label": "Last Run" }
],
"rows": [
{ "hook": "pre-commit-quality", "event": "PreToolUse", "status": "active", "lastRun": "2m ago" },
{ "hook": "commit-nudge", "event": "PostToolUse", "status": "active", "lastRun": "5m ago" }
]
}
},
"agent-hooks": {
"type": "Card",
"props": { "title": "Agent-Scoped Hooks (47)" },
"children": ["agent-badge"]
},
"agent-badge": {
"type": "StatusBadge",
"props": { "label": "All agent hooks healthy", "status": "success" }
},
"skill-hooks": {
"type": "Card",
"props": { "title": "Skill-Scoped Hooks (22)" },
"children": ["skill-badge"]
},
"skill-badge": {
"type": "StatusBadge",
"props": { "label": "All skill hooks healthy", "status": "success" }
}
}
}Recipe 3: Test Coverage Dashboard
Show test suite results with coverage metrics and failing test details.
Sample Spec
{
"root": "coverage-dashboard",
"elements": {
"coverage-dashboard": {
"type": "Card",
"props": { "title": "Test Coverage Report" },
"children": ["coverage-stats", "suite-results"]
},
"coverage-stats": {
"type": "StatGrid",
"props": {
"items": [
{ "label": "Line Coverage", "value": "94.2%", "color": "green" },
{ "label": "Branch Coverage", "value": "87.1%", "color": "green" },
{ "label": "Tests Passed", "value": "847/850", "color": "green" },
{ "label": "Duration", "value": "3m 12s", "trend": "down" }
]
}
},
"suite-results": {
"type": "DataTable",
"props": {
"columns": [
{ "key": "suite", "label": "Suite" },
{ "key": "tests", "label": "Tests" },
{ "key": "passed", "label": "Passed" },
{ "key": "coverage", "label": "Coverage" }
],
"rows": [
{ "suite": "unit", "tests": "620", "passed": "620", "coverage": "96%" },
{ "suite": "integration", "tests": "180", "passed": "178", "coverage": "89%" },
{ "suite": "e2e", "tests": "50", "passed": "49", "coverage": "82%" }
]
}
}
}
}Recipe 4: Dependency Graph Summary
Show project dependency health at a glance.
Sample Spec
{
"root": "deps",
"elements": {
"deps": {
"type": "Card",
"props": { "title": "Dependency Health" },
"children": ["dep-stats", "outdated"]
},
"dep-stats": {
"type": "StatGrid",
"props": {
"items": [
{ "label": "Total Deps", "value": "142" },
{ "label": "Up to Date", "value": "128", "color": "green" },
{ "label": "Minor Behind", "value": "11", "color": "yellow" },
{ "label": "Major Behind", "value": "3", "color": "red" }
]
}
},
"outdated": {
"type": "DataTable",
"props": {
"columns": [
{ "key": "package", "label": "Package" },
{ "key": "current", "label": "Current" },
{ "key": "latest", "label": "Latest" },
{ "key": "type", "label": "Update Type" }
],
"rows": [
{ "package": "react", "current": "18.2.0", "latest": "19.1.0", "type": "major" },
{ "package": "typescript", "current": "5.3.0", "latest": "5.7.0", "type": "minor" }
]
}
}
}
}Guidelines for New Recipes
1. Start with a StatGrid summary at the top -- users want the headline numbers first 2. Follow with a DataTable for drillable details 3. Use StatusBadge for overall health indicators 4. Keep specs under 30 elements total for readability and token efficiency 5. Name elements after their content domain (e.g., eval-stats, hook-table), not their component type (e.g., grid1, table2)
@json-render/mcp API Reference
Full API for integrating json-render visual output with MCP servers.
Server-Side API
createMcpApp(config)
Creates a new MCP server with json-render visual output built in. Async — returns McpServer directly.
import { createMcpApp } from '@json-render/mcp'
const server = await createMcpApp({
name: string, // required: MCP server name
version: string, // required: MCP server version
catalog: CatalogDefinition, // required: component schemas (defineCatalog output)
html: string, // required: bundled iframe app as HTML string
tool: {
name: string, // required: name of the render tool
description: string, // required: description shown to the AI
},
csp?: CspConfig, // CSP domain declarations
})Returns: McpServer — connect a transport directly, no .start() method.
registerJsonRenderTool(server, config)
Adds a json-render tool to an existing MCP server.
import { registerJsonRenderTool } from '@json-render/mcp'
registerJsonRenderTool(server, {
catalog: CatalogDefinition, // required
name: string, // required: tool name
title: string, // required: display title
description: string, // required: description shown to the AI
resourceUri: string, // required: URI for the UI resource
html: string, // required: bundled iframe app as HTML string
csp?: CspConfig, // CSP domain declarations
})Returns: void. Mutates the server by registering a new tool and UI resource.
CspConfig
interface CspConfig {
connectDomains?: string[] // fetch/XHR/WebSocket origins
resourceDomains?: string[] // script/image/style/font CDN origins
frameDomains?: string[] // nested iframe origins
}Client-Side API (Iframe App)
useJsonRenderApp(options?)
React hook for the iframe app. Receives specs from the MCP host via postMessage.
import { useJsonRenderApp } from '@json-render/mcp/app'
const { spec, loading, streaming, error } = useJsonRenderApp({
progressive?: boolean, // enable incremental spec updates (default: false)
onSpec?: (spec) => void, // callback when spec updates
onError?: (err) => void, // callback on parse/validation errors
})Returns:
spec: JsonRenderSpec | null-- current spec (null before first data)loading: boolean-- true before any spec data arrivesstreaming: boolean-- true while the AI is still generatingerror: Error | null-- set if spec parsing or validation fails
Renderer Component
import { Renderer } from '@json-render/react'
<Renderer
spec={spec} // the json-render spec
registry={registry} // component registry (maps type names to React components)
fallback?: ReactNode // rendered for unknown component types
onAction?: (action) => void // callback for component actions (clicks, selections)
/>defineCatalog(schema, options)
Defines a type-safe component catalog using Zod schemas.
import { defineCatalog } from '@json-render/core'
import { schema } from '@json-render/react/schema'
import { z } from 'zod'
const catalog = defineCatalog(schema, {
components: {
ComponentName: {
props: z.object({ ... }), // Zod schema for component props
children: boolean | z.ZodType, // false = no children, true = any, or typed
},
},
})Host Configuration
Claude Desktop
{
"mcpServers": {
"my-dashboard": {
"command": "node",
"args": ["./dist/server.js"],
"env": {}
}
}
}Cursor
{
"mcp": {
"servers": {
"my-dashboard": {
"command": "node",
"args": ["./dist/server.js"]
}
}
}
}Streamable HTTP (Remote)
import { createMcpApp } from '@json-render/mcp'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
const server = await createMcpApp({
name: 'dashboard',
version: '1.0.0',
catalog,
html: bundledHtml,
tool: { name: 'render', description: 'Render dashboard' },
})
// For remote deployment, connect Streamable HTTP transport directly
const transport = new StreamableHTTPServerTransport({ port: 3001, path: '/mcp' })
await server.connect(transport)Spec Format
The json-render spec is a flat element map with a root pointer:
interface JsonRenderSpec {
root: string // key of the root element
elements: Record<string, Element> // flat map of all elements
}
interface Element {
type: string // component type from catalog
props?: Record<string, unknown> // component props (validated against catalog)
children?: string[] // keys of child elements
}<!-- SYNCED from vercel-labs/json-render (skills/mcp/SKILL.md) --> <!-- Hash: fdc45b80ea851e518ba1ce37cbc6bdfff8627512caca55265f1f85b8639c662d --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@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)
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)
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 serverregisterJsonRenderTool(server, options)- register a json-render tool on an existing serverregisterJsonRenderResource(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:
// 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)
{
"mcpServers": {
"my-app": {
"command": "npx",
"args": ["tsx", "server.ts", "--stdio"]
}
}
}Claude Desktop
{
"mcpServers": {
"my-app": {
"command": "npx",
"args": ["tsx", "/path/to/server.ts", "--stdio"]
}
}
}Dependencies
# 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<!-- /SYNCED — OrchestKit-local notes below survive the next sync -->
Auth status visibility (CC 2.1.132+)
When a json-render-backed MCP server is loaded as a claude.ai connector, CC 2.1.132 distinguishes auth-required from broken in /mcp:
$ claude /mcp
my-app needs auth ← OAuth not completed for this connector
my-app connected · tools fetch failed ← handshake OK, tools/list failed (retried once)
my-app failed ← genuinely brokenImplications when shipping an MCP App:
- Return
401(not a generic 500) for unauthenticated requests so CC surfacesneeds authinstead offailed. Generic-500 used to render the same way pre-2.1.132 but no longer does. - Don't lazy-throw inside
tools/list. CC 2.1.132 retries it once; persistent failure shows asconnected · tools fetch failed, which is a worse user experience than failing the initial connect cleanly. - Headless
-pcallers no longer retry non-transient 4xx — an MCP App that issues401will fail fast in CI/scripted invocations as intended.
See configure/references/mcp-config.md (## CC 2.1.132 changes) for the matching CLI-side status semantics.
Rule Categories
1. Setup (mcp-app) -- HIGH -- 1 rule
Server-side setup for visual output. Wrong setup means tools return plain text instead of interactive UIs.
mcp-app-setup.md-- createMcpApp() vs registerJsonRenderTool(), catalog registration, html bundling
2. Security (sandbox) -- HIGH -- 1 rule
CSP declarations and iframe sandboxing. Missing CSP blocks all external network access; overly permissive CSP creates security holes.
sandbox-csp.md-- CSP domain declarations, iframe sandbox attributes, visibility controls
3. Rendering (streaming / dashboard) -- MEDIUM -- 2 rules
Progressive rendering and dashboard layout patterns. Affects perceived latency and component reusability.
streaming-output.md-- JSON Patch streaming, progressive rendering, partial spec updatesdashboard-patterns.md-- Stat grids, status badges, data tables, flat layout patterns
[Rule Name]
[Brief description -- 1-2 sentences.]
Incorrect:
// Bad patternCorrect:
// Good patternKey rules:
- [Rule 1]
- [Rule 2]
- [Rule 3]
Reference: [link]
Dashboard Patterns
Most MCP visual dashboards can be built with 3-5 component types: StatGrid, StatusBadge, DataTable, Card, and Stack. Keeping the catalog small reduces prompt tokens and makes AI-generated specs more reliable.
Incorrect -- overly complex component tree:
{
"root": "app",
"elements": {
"app": { "type": "ThemeProvider", "children": ["router"] },
"router": { "type": "Router", "children": ["layout"] },
"layout": { "type": "DashboardLayout", "children": ["sidebar", "main"] },
"sidebar": { "type": "Sidebar", "children": ["nav", "filters"] },
"nav": { "type": "Navigation", "props": { "items": [] } },
"filters": { "type": "FilterPanel", "children": ["dateRange", "category"] },
"dateRange": { "type": "DateRangePicker", "props": {} },
"category": { "type": "Select", "props": {} },
"main": { "type": "MainContent", "children": ["header", "body"] },
"header": { "type": "PageHeader", "props": {} },
"body": { "type": "ScrollArea", "children": ["grid"] },
"grid": { "type": "ResponsiveGrid", "children": ["card1"] },
"card1": { "type": "MetricCard", "props": {} }
}
}Correct -- flat layout with standard dashboard components:
{
"root": "dashboard",
"elements": {
"dashboard": {
"type": "Card",
"props": { "title": "System Overview" },
"children": ["metrics", "services", "logs"]
},
"metrics": {
"type": "StatGrid",
"props": {
"items": [
{ "label": "Uptime", "value": "99.9%", "color": "green" },
{ "label": "Requests/s", "value": "1,247", "trend": "up" },
{ "label": "Error Rate", "value": "0.3%", "color": "yellow" },
{ "label": "P95 Latency", "value": "142ms", "trend": "down" }
]
}
},
"services": {
"type": "DataTable",
"props": {
"columns": [
{ "key": "name", "label": "Service" },
{ "key": "status", "label": "Status" },
{ "key": "version", "label": "Version" }
],
"rows": [
{ "name": "api-gateway", "status": "healthy", "version": "2.4.1" },
{ "name": "auth-service", "status": "healthy", "version": "1.8.0" },
{ "name": "worker", "status": "degraded", "version": "3.1.2" }
]
}
},
"logs": {
"type": "StatusBadge",
"props": { "label": "Last deploy", "status": "success" }
}
}
}Pattern: Multi-Section Dashboard
Use a Stack as root with multiple Cards for sections:
{
"root": "layout",
"elements": {
"layout": {
"type": "Stack",
"props": { "gap": "md" },
"children": ["overview", "details"]
},
"overview": {
"type": "Card",
"props": { "title": "Overview" },
"children": ["summary-stats"]
},
"summary-stats": {
"type": "StatGrid",
"props": { "items": [] }
},
"details": {
"type": "Card",
"props": { "title": "Details" },
"children": ["detail-table"]
},
"detail-table": {
"type": "DataTable",
"props": { "columns": [], "rows": [] }
}
}
}Pattern: Status Dashboard
Combine StatusBadge with StatGrid for operational views. Root Card with a StatusBadge for overall health + StatGrid for key metrics:
{
"root": "status",
"elements": {
"status": { "type": "Card", "props": { "title": "Pipeline Status" }, "children": ["badge", "metrics"] },
"badge": { "type": "StatusBadge", "props": { "label": "CI Pipeline", "status": "success" } },
"metrics": { "type": "StatGrid", "props": { "items": [
{ "label": "Tests Passed", "value": "847/850", "color": "green" },
{ "label": "Build Time", "value": "3m 12s", "trend": "down" },
{ "label": "Coverage", "value": "94.2%", "trend": "up" }
]}}
}
}Key rules:
- Limit catalogs to 3-5 component types -- StatGrid, StatusBadge, DataTable, Card, and Stack cover most dashboards
- Keep element trees at 2-3 levels deep (root -> section -> component)
- Use Card as a sectioning container with a title prop
- Use Stack for vertical layouts with gap control
- StatGrid for multiple metrics at a glance (4-8 items works best visually)
- DataTable for structured data (paginate at 20 rows for readability)
- StatusBadge for single-status indicators (success/warning/error/info/pending)
- Name elements descriptively (e.g.,
eval-stats,service-table) -- the AI reads these names to understand structure
Reference: json-render spec
MCP App Setup
Two entry points: createMcpApp() wraps a new MCP server with visual output built in. registerJsonRenderTool() adds visual output to an existing server. Both require a catalog (component schemas) and an html bundle (the iframe app).
Incorrect -- returning raw HTML from an MCP tool:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
const server = new McpServer({ name: 'dashboard', version: '1.0.0' })
// BAD: raw HTML string -- no type safety, no catalog validation,
// no streaming, client may not render HTML at all
server.tool('show-dashboard', {}, async () => ({
content: [{
type: 'text',
text: '<div class="grid"><div class="stat">94 skills</div></div>',
}],
}))Correct -- createMcpApp() for a new server:
import { createMcpApp } from '@json-render/mcp'
import { buildAppHtml } from '@json-render/mcp/app'
import { dashboardCatalog } from './catalog'
// Generate the iframe HTML from the bundled JS/CSS (docs-prescribed generator).
const bundledHtml = buildAppHtml({ entry: './app.tsx' })
// Creates McpServer + registers the json-render tool automatically
const server = await createMcpApp({
name: 'dashboard-server', // required: MCP server name
version: '1.0.0', // required: MCP server version
catalog: dashboardCatalog, // Zod-typed component schemas
html: bundledHtml, // pre-built iframe app as a single HTML string
tool: {
name: 'render', // required: name of the render tool
description: 'Render dashboard', // required: description shown to the AI
},
})
// server is a McpServer — connect a transport directly, no .start()Correct -- registerJsonRenderTool() for an existing server:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { registerJsonRenderTool } from '@json-render/mcp'
import { buildAppHtml } from '@json-render/mcp/app'
import { dashboardCatalog } 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' })
// Your existing tools remain unchanged
server.tool('search', { query: z.string() }, async ({ query }) => ({
content: [{ type: 'text', text: results }],
}))
// Add visual output alongside existing tools
registerJsonRenderTool(server, {
catalog: dashboardCatalog,
name: 'render', // required: tool name
title: 'Render interactive dashboard', // required: display title
description: 'Render interactive dashboard', // required: description shown to the AI
resourceUri: 'json-render://dashboard', // required: URI for the UI resource
html: bundledHtml,
})Key rules:
- Always provide both
catalogandhtml-- the catalog defines what the AI can generate, the html renders it createMcpApp()is async and returnsMcpServerdirectly — there is no.start()method; connect a transport after awaitingcreateMcpApp()requiresname,version, andtool: { name, description }— all are required in 0.19registerJsonRenderTool()requirescatalog,name,title,description, andresourceUri— all required in 0.19- The html bundle must be a self-contained single-file app (all JS/CSS inlined) because it loads inside a sandboxed iframe with no external script access by default
- Use
createMcpApp()when building a server whose primary purpose is visual output - Use
registerJsonRenderTool()when adding visual output to a server that already has text-based tools - The registered tool accepts a json-render spec as input and returns the rendered iframe as a UI resource
- Never return raw HTML strings from MCP tools -- use the catalog/spec pattern for type safety and streaming support
Reference: @json-render/mcp README
Sandbox & CSP
MCP visual output renders inside sandboxed iframes. The host (Claude Desktop, Cursor, ChatGPT) enforces a Content Security Policy. By default, iframes have no external network access -- you must declare exactly which domains are needed.
Incorrect -- overly permissive CSP:
import { createMcpApp } from '@json-render/mcp'
const server = await createMcpApp({
name: 'dashboard',
version: '1.0.0',
catalog,
html: bundledHtml,
tool: { name: 'render', description: 'Render dashboard' },
csp: {
// BAD: wildcard allows any domain -- data exfiltration risk
connectDomains: ['*'],
// BAD: unsafe-inline allows injected scripts to execute
scriptSrc: ["'unsafe-inline'", "'unsafe-eval'"],
// BAD: no resource domain restrictions
resourceDomains: ['*'],
},
})Incorrect -- no CSP at all (default blocks everything):
const server = await createMcpApp({
name: 'dashboard',
version: '1.0.0',
catalog,
html: bundledHtml,
tool: { name: 'render', description: 'Render dashboard' },
// No csp config -- iframe cannot fetch any external resources.
// Images, fonts, API calls all fail silently.
})Correct -- minimal CSP with only required domains:
import { createMcpApp } from '@json-render/mcp'
const server = await createMcpApp({
name: 'dashboard',
version: '1.0.0',
catalog,
html: bundledHtml,
tool: { name: 'render', description: 'Render dashboard' },
csp: {
// Only the API your dashboard actually calls
connectDomains: ['https://api.example.com'],
// Only the CDN you load fonts/icons from
resourceDomains: ['https://cdn.jsdelivr.net'],
// Only if you embed external iframes (e.g., video)
frameDomains: ['https://www.youtube.com'],
},
})Correct -- registerJsonRenderTool with CSP on existing server:
registerJsonRenderTool(server, {
catalog,
name: 'render',
title: 'Render dashboard',
description: 'Render interactive dashboard',
resourceUri: 'json-render://dashboard',
html: bundledHtml,
csp: {
connectDomains: ['https://api.internal.com'],
// No resourceDomains needed if all assets are inlined in html bundle
// No frameDomains needed if no nested iframes
},
})Key rules:
- Default CSP is
connect-src 'none'-- the iframe cannot make any network requests unless you declare domains - Declare only the specific domains your dashboard needs, never use wildcards
- Never add
'unsafe-inline'or'unsafe-eval'to script-src -- the bundled html app should have all scripts inlined at build time, which the sandbox allows by default connectDomainscontrols fetch/XHR/WebSocket originsresourceDomainscontrols script, image, style, and font origins from CDNsframeDomainscontrols nested iframe origins (only needed for embedded content like videos)- If your dashboard is fully self-contained (no external API calls, all assets inlined), you do not need any CSP declarations
- The host controls the sandbox attribute on the iframe -- your MCP server cannot override sandbox permissions
Reference: MDN Content-Security-Policy
Streaming Output
The AI generates json-render specs token by token. Progressive rendering shows components as they complete instead of waiting for the entire spec. The @json-render/mcp library handles this via JSON Patch -- partial updates applied to the spec as the AI streams.
Incorrect -- waiting for full spec before rendering:
// Client-side iframe app
import { useJsonRenderApp } from '@json-render/mcp/app'
import { Renderer } from '@json-render/react'
function App() {
const { spec, loading } = useJsonRenderApp()
// BAD: shows nothing until the entire spec is complete
if (loading || !spec?.elements) return <div>Loading...</div>
// Only renders after AI is completely done generating
return <Renderer spec={spec} registry={registry} />
}Correct -- progressive rendering as elements complete:
import { useJsonRenderApp } from '@json-render/mcp/app'
import { Renderer } from '@json-render/react'
import { registry } from './registry'
function App() {
const { spec, loading, streaming } = useJsonRenderApp({
progressive: true, // enable incremental spec updates
})
// Render whatever is available, even partial specs
return (
<div>
{streaming && <StreamingIndicator />}
{spec && <Renderer spec={spec} registry={registry} />}
{!spec && loading && <Skeleton />}
</div>
)
}Correct -- server-side: flat specs stream better than deep trees:
// BAD: deeply nested tree -- inner components blocked until parents complete
const deepSpec = {
root: 'page',
elements: {
page: {
type: 'Layout', children: ['section1'],
},
section1: {
type: 'Section', children: ['subsection'],
},
subsection: {
type: 'Card', children: ['content'],
},
content: {
type: 'StatGrid', // not renderable until 3 ancestors finish
props: { items: [...] },
},
},
}
// GOOD: flat layout -- each component renderable as soon as it appears
const flatSpec = {
root: 'dashboard',
elements: {
dashboard: {
type: 'Stack', children: ['stats', 'table', 'status'],
},
stats: {
type: 'StatGrid', // renders immediately when streamed
props: { items: [...] },
},
table: {
type: 'DataTable', // renders as soon as stats is done
props: { columns: [...], rows: [...] },
},
status: {
type: 'StatusBadge', // renders independently
props: { label: 'Pipeline', status: 'success' },
},
},
}Key rules:
- Always set
progressive: trueinuseJsonRenderApp()to enable incremental rendering - Design specs with flat element trees (2-3 levels max) so components can render as they arrive
- Show a streaming indicator while the AI is still generating, but render available components immediately
- The json-render spec uses a flat element map (not nested JSX), which naturally supports progressive updates -- each element is independently addressable
- Keep individual element props small -- large arrays (100+ row tables) delay that element's first render
- For large datasets, paginate at the spec level (show first 20 rows, add a "load more" action)
Reference: JSON Patch RFC 6902
{
"skill": "mcp-visual-output",
"version": "1.0.0",
"testCases": [
{
"id": "add-visual-output-to-existing-mcp-server",
"rule": "mcp-app-setup",
"query": "I have an existing MCP server built with McpServer that returns plain text from its tools. How do I add visual output so the tools can return interactive dashboards?",
"expectedBehavior": [
"Claude uses registerJsonRenderTool() to add visual output to the existing server",
"Claude defines a catalog with defineCatalog() and Zod schemas for component types",
"Claude provides a bundled HTML string for the iframe app",
"Claude does NOT suggest replacing the entire server with createMcpApp()",
"Claude does NOT return raw HTML strings from MCP tools"
]
},
{
"id": "create-eval-results-dashboard",
"rule": "dashboard-patterns",
"query": "Create a dashboard that shows eval results for our 94 skills -- I need pass rates, scores, and a table of per-skill results displayed in an MCP tool response.",
"expectedBehavior": [
"Claude generates a json-render spec with StatGrid for summary metrics",
"Claude includes a DataTable with columns for skill name, score, and status",
"Claude uses a flat element tree with 2-3 levels maximum",
"Claude keeps the component count to 3-5 types",
"Claude wraps sections in Card components with descriptive titles"
]
},
{
"id": "configure-csp-for-mcp-visual-output",
"rule": "sandbox-csp",
"query": "My MCP dashboard needs to fetch data from an external API at api.example.com and load fonts from Google Fonts CDN. How do I configure the CSP for the sandboxed iframe?",
"expectedBehavior": [
"Claude declares connectDomains with the specific API origin",
"Claude declares resourceDomains for the Google Fonts CDN",
"Claude does NOT use wildcard domains or unsafe-inline",
"Claude explains that default CSP blocks all external access",
"Claude passes CSP config to createMcpApp() or registerJsonRenderTool()"
]
},
{
"id": "stream-visual-output-progressively",
"rule": "streaming-output",
"query": "My MCP dashboard takes 10 seconds to render because it waits for the AI to finish generating the entire spec. How do I make it render progressively as components are generated?",
"expectedBehavior": [
"Claude enables progressive: true in useJsonRenderApp() options",
"Claude renders available components immediately without waiting for full spec",
"Claude shows a streaming indicator while generation is in progress",
"Claude recommends flat element trees for better streaming performance",
"Claude does NOT suggest waiting for the complete spec before rendering"
]
},
{
"id": "negative-plain-rest-api",
"rule": "",
"query": "Build a REST API with Express.js that returns JSON responses for a todo list application with CRUD endpoints.",
"expectedBehavior": [
"Claude does NOT invoke the mcp-visual-output skill",
"Claude builds a standard REST API with JSON responses",
"Claude does not use createMcpApp, registerJsonRenderTool, or json-render specs"
]
},
{
"id": "negative-mcp-server-without-ui",
"rule": "",
"query": "Build an MCP server that exposes a tool for searching documents. The tool should return plain text results.",
"expectedBehavior": [
"Claude does NOT invoke the mcp-visual-output skill",
"Claude builds a standard MCP server with text-based tool responses",
"Claude uses mcp-patterns skill instead for basic MCP server setup"
]
}
]
}