json-render-mcp · git:20260923.561e6ec · 2026-09-23 · sha256 05ecb90ef52d6404

json-render-mcp git:20260923.561e6ecA

Immutable. This exact content never changes and is served at /api/v1/blob/05ecb90ef52d6404.

---
name: json-render-mcp
description: Builds interactive json-render panels with PMX Canvas native tools or standalone MCP Apps. Use for stateful forms, repeated content, and AX actions in PMX Canvas, or MCP-hosted UIs in Claude, ChatGPT, Cursor, and VS Code.
---

# @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.

In `pmx-canvas`, prefer the native `canvas_render { action: "add-json-render" }` and
`canvas_render { action: "add-graph" }` composite actions first. They store validated specs
directly in canvas node state and render them through the local `pmx-canvas` viewer route without
needing a separate sidecar server.

## PMX Canvas workflow (json-render 0.21)

1. Read `canvas://schema` (or use `canvas_render { action: "describe-schema" }`) for
   the installed component catalog. PMX includes shadcn components and its own charts;
   do not assume every component or action from an upstream example is registered.
2. Build a complete `{ root, elements, state }` spec. Validate with
   `canvas_render { action: "validate", type: "json-render", spec }`.
3. Create it with `canvas_render { action: "add-json-render", title, spec }`.
   For incremental generation, use `action: "stream-json-render"`, reuse the returned
   `nodeId` for subsequent patches, and set `done: true` on the final call.
4. Inspect the panel and exercise its inputs. Schema acceptance does not prove that
   an action reached the server or that a form edit was persisted.

### State, forms, and repeated content

- Put initial values in spec-level `state`, alongside `root` and `elements`. PMX seeds
  its viewer from this object; do not put `initialState` on a component.
- Use JSON Pointer paths: `value: { "$bindState": "/form/title" }` for `Input`,
  `Textarea`, or `Select`; `checked: { "$bindState": "/form/urgent" }` for `Checkbox`
  or `Switch`. Supply the catalog's required `label` and `name` props. `$state` reads
  a value only; `$bindState` also writes edits. `props.statePath` is not input binding.
- `repeat`, `watch`, `visible`, `on`, and `slots` are element fields, not props.
  `repeat: { "statePath": "/projects", "key": "id" }` repeats the container's children.
  Within those children, a nested container can use
  `repeat: { "statePath": { "$item": "tasks" }, "key": "id" }` to iterate the current
  project's tasks. Do not use `"/tasks"` (a root path) for that nested array.
  `$item` and `$index` refer to the innermost repeat; use
  `checked: { "$bindItem": "done" }` to edit that task's `done` field.
- Card body content uses `children: ["bodyId"]`. Named regions use
  `slots: { "header": ["headingId"], "footer": ["actionsId"] }`. Every referenced ID
  must exist in `elements`; do not put body content in `slots.default`. A header slot
  replaces the Card's title and description props; without it, those props work as before.
- Watchers skip mount: `watch: { "/form/title": { "action": "setState", "params":
  { "statePath": "/dirty", "value": true } } }` runs after the value changes, not to
  initialize `/dirty`. Seed initial and derived defaults explicitly. Avoid watchers
  that write back to their own watched paths and create loops.

### Actions and persistence boundaries

- Bind buttons with `on.press`, for example an action named `ax.work.create` or
  `ax.steer`, with its AX payload in `params`. PMX forwards only registered handlers;
  invented action names cannot execute arbitrary tools, shell commands, or requests.
  The server still validates the payload and the node's AX capabilities.
- AX handlers are **fire-and-forget** postMessages. Their `onSuccess` callbacks mean
  local dispatch completed, **not server acknowledgment**. Do not clear a draft or
  show “Saved” on that basis. Confirm via reflected AX state under reserved `/ax`
  (where applicable), or query the server's AX state/timeline. Do not use `/ax` for drafts.
- Built-in `validateForm` writes `{ valid, errors }` to `/formValidation` (or
  `params.statePath`) and returns without running `onSuccess`. Do not chain submission
  through that callback, or assume a following action in an array is validation-gated.
  Use an explicit validation step and state-based gating; server-side validation is
  still required for any submitted data.
- Form edits, `setState`, and `$bindItem` mutate **viewer-local state**, not the stored
  spec. Iframe reloads, including spec/stream updates, reset it from the latest spec's
  initial state. Finish streaming before collecting input; persist important changes
  through an explicit supported server interaction. Stored canvas nodes survive
  reloads, but unsent local drafts do not.

### Example: editable task draft

Pass this object to `canvas_render`. Editing either field sets a local dirty flag;
the button requests a real AX work item without pretending the request was saved.

```json
{
  "action": "add-json-render",
  "title": "Task draft",
  "spec": {
    "root": "card",
    "state": { "form": { "title": "Review release notes", "detail": "Check the upgrade caveats." }, "dirty": false },
    "elements": {
      "card": {
        "type": "Card",
        "props": { "title": "Task draft" },
        "children": ["fields"],
        "slots": { "footer": ["request"] }
      },
      "fields": {
        "type": "Stack",
        "props": { "direction": "vertical", "gap": "md" },
        "children": ["title", "detail"],
        "watch": {
          "/form/title": { "action": "setState", "params": { "statePath": "/dirty", "value": true } },
          "/form/detail": { "action": "setState", "params": { "statePath": "/dirty", "value": true } }
        }
      },
      "title": {
        "type": "Input",
        "props": { "label": "Title", "name": "title", "value": { "$bindState": "/form/title" } }
      },
      "detail": {
        "type": "Textarea",
        "props": { "label": "Details", "name": "detail", "value": { "$bindState": "/form/detail" } }
      },
      "request": {
        "type": "Button",
        "props": { "label": "Request task", "variant": "primary" },
        "on": {
          "press": {
            "action": "ax.work.create",
            "params": { "title": { "$state": "/form/title" }, "detail": { "$state": "/form/detail" } }
          }
        }
      }
    }
  }
}
```

The draft remains visible after dispatch. Verify that the work item appears in AX
state before reporting success. Repeated clicks can create duplicate work items.

## Standalone MCP App quick start

Use this path only when building a separate MCP Apps server rather than a native
PMX panel. PMX's `canvas_render` path needs none of the server, bundler, or client
configuration below.

### 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
```