Compare commits

...
Author SHA1 Message Date
Chris Tate b2020fad6f cleanup 2026-03-09 02:06:22 -05:00
Chris Tate 5b51204960 vue 2026-03-09 01:34:12 -05:00
Chris Tate 3418d42a43 svelte/vue clients for mcp 2026-03-09 01:18:31 -05:00
59 changed files with 1564 additions and 93 deletions
+11 -3
View File
@@ -1,8 +1,16 @@
{
"mcpServers": {
"json-render": {
"json-render-react": {
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
"args": ["tsx", "examples/mcp-react/server.ts", "--stdio"]
},
"json-render-vue": {
"command": "npx",
"args": ["tsx", "examples/mcp-vue/server.ts", "--stdio"]
},
"json-render-svelte": {
"command": "npx",
"args": ["tsx", "examples/mcp-svelte/server.ts", "--stdio"]
}
}
}
}
+1
View File
@@ -4,6 +4,7 @@
node_modules
.pnp
.pnp.js
.pnpm-store/
# Local env files
.env*
+1 -1
View File
@@ -3,7 +3,7 @@
"json-render": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
"args": ["tsx", "examples/mcp-react/server.ts", "--stdio"]
}
}
}
+1
View File
@@ -486,6 +486,7 @@ pnpm dev
- Chat Example: run `pnpm dev` in `examples/chat`
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- MCP Vue Example: run `pnpm build && pnpm start:stdio` in `examples/mcp-vue`
- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
+68 -9
View File
@@ -11,13 +11,20 @@ MCP Apps integration for json-render. Serve json-render UIs as interactive [MCP
npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk
```
For the iframe-side React UI, also install:
For the iframe-side UI, install the renderer for your framework:
```bash
# React
npm install @json-render/react react react-dom
# Vue
npm install @json-render/vue vue
# Svelte
npm install @json-render/svelte svelte
```
See the [MCP example](https://github.com/vercel-labs/json-render/tree/main/examples/mcp) for a full working example.
See the [MCP React example](https://github.com/vercel-labs/json-render/tree/main/examples/mcp-react) for a full working example.
## Overview
@@ -117,16 +124,22 @@ registerJsonRenderResource(server, {
});
```
## Client API (`@json-render/mcp/app`)
## Client API
These exports run inside the sandboxed iframe rendered by the MCP host.
These exports run inside the sandboxed iframe rendered by the MCP host. Framework-specific adapters are available at:
### useJsonRenderApp
- `@json-render/mcp/app/react` -- React hook
- `@json-render/mcp/app/vue` -- Vue composable
- `@json-render/mcp/app/svelte` -- Svelte stores
The React hook is also re-exported from `@json-render/mcp/app` for backward compatibility.
### React -- useJsonRenderApp (`@json-render/mcp/app/react`)
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 { useJsonRenderApp } from "@json-render/mcp/app/react";
import { JSONUIProvider, Renderer } from "@json-render/react";
function McpAppView({ registry }) {
@@ -146,7 +159,51 @@ function McpAppView({ registry }) {
}
```
#### UseJsonRenderAppReturn
### Vue -- useJsonRenderApp (`@json-render/mcp/app/vue`)
Vue composable with the same API shape. Values are Vue refs and computed properties.
```vue
<script setup>
import { useJsonRenderApp } from "@json-render/mcp/app/vue";
import { Renderer } from "@json-render/vue";
const { spec, loading, error } = useJsonRenderApp({ name: "my-app" });
</script>
<template>
<div v-if="error">Error: {{ error.message }}</div>
<div v-else-if="!spec">Waiting...</div>
<Renderer v-else :spec="spec" :registry="registry" :loading="loading" />
</template>
```
### Svelte -- createJsonRenderApp (`@json-render/mcp/app/svelte`)
Creates a json-render MCP App client using Svelte stores. Call `destroy()` in `onDestroy` to clean up.
```svelte
<script>
import { createJsonRenderApp } from "@json-render/mcp/app/svelte";
import { Renderer } from "@json-render/svelte";
import { onDestroy } from "svelte";
const { spec, loading, error, destroy } = createJsonRenderApp({ name: "my-app" });
onDestroy(destroy);
</script>
{#if $error}
<div>Error: {$error.message}</div>
{:else if !$spec}
<div>Waiting...</div>
{:else}
<Renderer spec={$spec} {registry} loading={$loading} />
{/if}
```
### Return values
All three adapters return the same logical state:
<table>
<thead>
@@ -195,9 +252,11 @@ function McpAppView({ registry }) {
</tbody>
</table>
### buildAppHtml
In React, values are plain primitives. In Vue, they are `Ref`/`ShallowRef`/`ComputedRef`. In Svelte, they are readable stores (use `$` prefix to subscribe). The Svelte adapter also returns a `destroy` function for cleanup.
Generate a self-contained HTML page from bundled JavaScript and CSS.
### buildAppHtml (`@json-render/mcp/app`)
Generate a self-contained HTML page from bundled JavaScript and CSS. Framework-agnostic.
```typescript
import { buildAppHtml } from "@json-render/mcp/app";
+1 -1
View File
@@ -98,7 +98,7 @@ export const docsNavigation: NavSection[] = [
},
{
title: "MCP App",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp-react",
external: true,
},
],
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -20,7 +20,7 @@ Add to `.cursor/mcp.json`:
"mcpServers": {
"json-render": {
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
"args": ["tsx", "examples/mcp-react/server.ts", "--stdio"]
}
}
}
@@ -35,7 +35,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
"mcpServers": {
"json-render": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/examples/mcp/server.ts", "--stdio"]
"args": ["tsx", "/absolute/path/to/examples/mcp-react/server.ts", "--stdio"]
}
}
}
@@ -1,5 +1,5 @@
{
"name": "example-mcp",
"name": "example-mcp-react",
"version": "0.1.1",
"type": "module",
"private": true,
@@ -1,7 +1,7 @@
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 { useJsonRenderApp } from "@json-render/mcp/app/react";
import { catalog } from "./catalog";
import { useState, useEffect } from "react";
+13
View File
@@ -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 (Svelte)</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "example-mcp-svelte",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"build": "vite build",
"start": "tsx server.ts --stdio"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/mcp": "workspace:*",
"@json-render/svelte": "workspace:*",
"@modelcontextprotocol/ext-apps": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"svelte": "^5.49.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
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 main() {
const html = loadHtml();
const server = await createMcpApp({
name: "json-render Svelte Example",
version: "1.0.0",
catalog,
html,
});
await server.connect(new StdioServerTransport());
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import { createJsonRenderApp } from "@json-render/mcp/app/svelte";
import { JsonUIProvider, Renderer } from "@json-render/svelte";
import { onDestroy } from "svelte";
import { registry } from "./registry";
const { spec, loading, connecting, error, destroy } = createJsonRenderApp({
name: "json-render-mcp-svelte",
version: "1.0.0",
});
onDestroy(destroy);
</script>
{#if $error}
<div class="p-4 text-destructive font-mono text-sm">
{$error.message}
</div>
{:else if !$spec}
<div class="p-4 text-muted-foreground text-sm">
{$connecting ? "Connecting to host..." : "Waiting for UI spec..."}
</div>
{:else}
<JsonUIProvider initialState={$spec.state ?? {}}>
<Renderer spec={$spec} {registry} loading={$loading} />
</JsonUIProvider>
{/if}
+54
View File
@@ -0,0 +1,54 @@
import { schema } from "@json-render/svelte/schema";
import { z } from "zod";
export const catalog = schema.createCatalog({
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
},
actions: {},
});
@@ -0,0 +1,18 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
label: string;
color?: string;
}> {}
let { props }: Props = $props();
</script>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium bg-secondary text-secondary-foreground border border-border"
style:background-color={props.color ? `${props.color}20` : undefined}
style:color={props.color ?? undefined}
style:border-color={props.color ? `${props.color}40` : undefined}>
{props.label}
</span>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
label: string;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}> {}
let { props, emit }: Props = $props();
const variantClasses: Record<string, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
danger: "bg-destructive/10 text-destructive hover:bg-destructive/20 border border-destructive/20",
};
let variant = $derived(props.variant ?? "primary");
</script>
<button
disabled={props.disabled}
onclick={() => emit("press")}
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors {variantClasses[variant] ?? variantClasses.primary} {props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}">
{props.label}
</button>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
title?: string;
subtitle?: string;
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
</script>
<div class="rounded-xl border border-border bg-card text-card-foreground p-5 shadow-sm">
{#if props.title}
<h2 class="text-base font-semibold text-card-foreground mb-1">
{props.title}
</h2>
{/if}
{#if props.subtitle}
<p class="text-sm text-muted-foreground mb-3">
{props.subtitle}
</p>
{/if}
{#if children}
{@render children()}
{/if}
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
gap?: number;
padding?: number;
direction?: "vertical" | "horizontal";
align?: "start" | "center" | "end";
}> {
children?: Snippet;
}
let { props, children }: Props = $props();
let horizontal = $derived(props.direction === "horizontal");
</script>
<div
class="flex"
style:flex-direction={horizontal ? "row" : "column"}
style:gap="{props.gap ?? 0}px"
style:padding="{props.padding ?? 0}px"
style:align-items={props.align === "start"
? "flex-start"
: props.align === "end"
? "flex-end"
: horizontal
? "center"
: "stretch"}>
{#if children}
{@render children()}
{/if}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { BaseComponentProps } from "@json-render/svelte";
interface Props extends BaseComponentProps<{
content: string;
size?: "sm" | "md" | "lg" | "xl";
weight?: "normal" | "medium" | "bold";
color?: string;
}> {}
let { props }: Props = $props();
const sizeClasses: Record<string, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
xl: "text-2xl",
};
const weightClasses: Record<string, string> = {
normal: "font-normal",
medium: "font-medium",
bold: "font-bold",
};
</script>
<span
class="{sizeClasses[props.size ?? 'md'] ?? 'text-sm'} {weightClasses[props.weight ?? 'normal'] ?? 'font-normal'} text-foreground"
style:color={props.color ?? undefined}>
{String(props.content ?? "")}
</span>
+98
View File
@@ -0,0 +1,98 @@
@import "tailwindcss";
@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-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);
--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)));
--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, #app {
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;
}
+12
View File
@@ -0,0 +1,12 @@
import "./globals.css";
import { mount } from "svelte";
import App from "./App.svelte";
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.setAttribute(
"data-theme",
prefersDark ? "dark" : "light",
);
document.documentElement.style.colorScheme = prefersDark ? "dark" : "light";
mount(App, { target: document.getElementById("app")! });
+18
View File
@@ -0,0 +1,18 @@
import { type Components, defineRegistry } from "@json-render/svelte";
import { catalog } from "./catalog";
import Stack from "./components/Stack.svelte";
import Card from "./components/Card.svelte";
import Text from "./components/Text.svelte";
import Button from "./components/Button.svelte";
import Badge from "./components/Badge.svelte";
const components: Components<typeof catalog> = {
Stack,
Card,
Text,
Button,
Badge,
};
export const { registry } = defineRegistry(catalog, { components });
+1
View File
@@ -0,0 +1 @@
export default {};
+16
View File
@@ -0,0 +1,16 @@
{
"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
},
"include": ["src", "server.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [svelte(), viteSingleFile()],
build: {
outDir: "dist",
emptyOutDir: false,
rollupOptions: {
input: "index.html",
},
},
});
+13
View File
@@ -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 (Vue)</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "example-mcp-vue",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"build": "vite build",
"start": "tsx server.ts",
"start:stdio": "tsx server.ts --stdio"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/mcp": "workspace:*",
"@json-render/vue": "workspace:*",
"@modelcontextprotocol/ext-apps": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"vue": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@vitejs/plugin-vue": "^6.0.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
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 main() {
const html = loadHtml();
const server = await createMcpApp({
name: "json-render Vue Example",
version: "1.0.0",
catalog,
html,
});
await server.connect(new StdioServerTransport());
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useJsonRenderApp } from "@json-render/mcp/app/vue";
import { Renderer, defineRegistry, JSONUIProvider } from "@json-render/vue";
import { catalog } from "./catalog";
import { components } from "./registry";
const { registry } = defineRegistry(catalog, { components });
const { spec, loading, connecting, error } = useJsonRenderApp({
name: "json-render-mcp-vue",
version: "1.0.0",
});
</script>
<template>
<div
v-if="error"
class="p-4 font-mono text-sm text-destructive"
>
{{ error.message }}
</div>
<div
v-else-if="!spec"
class="p-4 text-sm text-muted-foreground"
>
{{ connecting ? "Connecting to host..." : "Waiting for UI spec..." }}
</div>
<JSONUIProvider v-else :registry="registry" :initialState="spec.state ?? {}">
<Renderer :spec="spec" :registry="registry" :loading="loading" />
</JSONUIProvider>
</template>
+56
View File
@@ -0,0 +1,56 @@
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = schema.createCatalog({
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
},
actions: {},
});
export type AppCatalog = typeof catalog;
+98
View File
@@ -0,0 +1,98 @@
@import "tailwindcss";
@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-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);
--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)));
--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, #app {
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;
}
+13
View File
@@ -0,0 +1,13 @@
import "./globals.css";
import { createApp } from "vue";
import App from "./App.vue";
// Fallback theme before MCP host provides context
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.setAttribute(
"data-theme",
prefersDark ? "dark" : "light",
);
document.documentElement.style.colorScheme = prefersDark ? "dark" : "light";
createApp(App).mount("#app");
+116
View File
@@ -0,0 +1,116 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => {
const horizontal = props.direction === "horizontal";
return h(
"div",
{
class: "flex",
style: {
flexDirection: horizontal ? "row" : "column",
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
alignItems: props.align ?? (horizontal ? "center" : "stretch"),
},
},
children,
);
},
Card: ({ props, children }) =>
h(
"div",
{
class:
"rounded-xl border border-border bg-card text-card-foreground p-5 shadow-sm",
},
[
props.title &&
h(
"h2",
{
class: "text-base font-semibold text-card-foreground mb-1",
},
props.title,
),
props.subtitle &&
h(
"p",
{
class: "text-sm text-muted-foreground mb-3",
},
props.subtitle,
),
children,
],
),
Text: ({ props }) => {
const sizeClasses: Record<string, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
xl: "text-2xl",
};
const weightClasses: Record<string, string> = {
normal: "font-normal",
medium: "font-medium",
bold: "font-bold",
};
return h(
"span",
{
class: [
sizeClasses[props.size ?? "md"] ?? "text-sm",
weightClasses[props.weight ?? "normal"] ?? "font-normal",
"text-foreground",
].join(" "),
style: props.color ? { color: props.color } : undefined,
},
String(props.content ?? ""),
);
},
Button: ({ props, emit }) => {
const variant = props.variant ?? "primary";
const variantClasses: Record<string, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
danger:
"bg-destructive/10 text-destructive hover:bg-destructive/20 border border-destructive/20",
};
return h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
class: [
"inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors",
variantClasses[variant] ?? variantClasses.primary,
props.disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
].join(" "),
},
props.label,
);
},
Badge: ({ props }) =>
h(
"span",
{
class:
"inline-flex items-center rounded-full px-3 py-1 text-xs font-medium bg-secondary text-secondary-foreground border border-border",
style: props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined,
},
props.label,
),
};
+17
View File
@@ -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": "preserve"
},
"include": ["src", "server.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { viteSingleFile } from "vite-plugin-singlefile";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [vue(), tailwindcss(), viteSingleFile()],
build: {
outDir: "dist",
emptyOutDir: false,
rollupOptions: {
input: "index.html",
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+58 -6
View File
@@ -46,10 +46,12 @@ await server.connect(new StdioServerTransport());
### 3. Build the UI (iframe)
Create a React app that uses `useJsonRenderApp` from `@json-render/mcp/app`:
Create an app that connects to the MCP host and renders specs. Framework-specific adapters are available for React, Vue, and Svelte.
#### React
```tsx
import { useJsonRenderApp } from "@json-render/mcp/app";
import { useJsonRenderApp } from "@json-render/mcp/app/react";
import { JSONUIProvider, Renderer } from "@json-render/react";
function McpAppView({ registry }) {
@@ -66,6 +68,44 @@ function McpAppView({ registry }) {
}
```
#### Vue
```vue
<script setup>
import { useJsonRenderApp } from "@json-render/mcp/app/vue";
import { Renderer } from "@json-render/vue";
const { spec, loading, error } = useJsonRenderApp();
</script>
<template>
<div v-if="error">Error: {{ error.message }}</div>
<div v-else-if="!spec">Waiting for spec...</div>
<Renderer v-else :spec="spec" :registry="registry" :loading="loading" />
</template>
```
#### Svelte
```svelte
<script>
import { createJsonRenderApp } from "@json-render/mcp/app/svelte";
import { Renderer } from "@json-render/svelte";
import { onDestroy } from "svelte";
const { spec, loading, error, destroy } = createJsonRenderApp();
onDestroy(destroy);
</script>
{#if $error}
<div>Error: {$error.message}</div>
{:else if !$spec}
<div>Waiting for spec...</div>
{:else}
<Renderer spec={$spec} {registry} loading={$loading} />
{/if}
```
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
@@ -107,17 +147,29 @@ Register a json-render tool on an existing `McpServer`.
Register a json-render UI resource on an existing `McpServer`.
### Client Side (`@json-render/mcp/app`)
### Client Side
#### `useJsonRenderApp(options?)`
#### `buildAppHtml(options)` — `@json-render/mcp/app`
Generate a self-contained HTML string from bundled JS/CSS for use as a UI resource.
#### `useJsonRenderApp(options?)` — `@json-render/mcp/app/react`
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)`
#### `useJsonRenderApp(options?)` — `@json-render/mcp/app/vue`
Generate a self-contained HTML string from bundled JS/CSS for use as a UI resource.
Vue composable with the same API shape. Values are Vue refs/computed properties.
Returns `{ spec, loading, connected, connecting, error, app, callServerTool }`.
#### `createJsonRenderApp(options?)` — `@json-render/mcp/app/svelte`
Creates a json-render MCP App client using Svelte stores. Values are readable stores.
Returns `{ spec, loading, connected, connecting, error, app, callServerTool, destroy }`.
## Client Support
+27 -2
View File
@@ -42,6 +42,21 @@
"types": "./dist/app.d.ts",
"import": "./dist/app.mjs",
"require": "./dist/app.js"
},
"./app/react": {
"types": "./dist/app/react.d.ts",
"import": "./dist/app/react.mjs",
"require": "./dist/app/react.js"
},
"./app/vue": {
"types": "./dist/app/vue.d.ts",
"import": "./dist/app/vue.mjs",
"require": "./dist/app/vue.js"
},
"./app/svelte": {
"types": "./dist/app/svelte.d.ts",
"import": "./dist/app/svelte.mjs",
"require": "./dist/app/svelte.js"
}
},
"files": [
@@ -60,12 +75,16 @@
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"svelte": "^5.0.0",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"vue": "^3.5.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"svelte": "^5.0.0",
"vue": "^3.5.0"
},
"peerDependenciesMeta": {
"react": {
@@ -73,6 +92,12 @@
},
"react-dom": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
}
+12 -4
View File
@@ -5,11 +5,18 @@
* 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.
* framework-specific hooks / helpers to render them.
*
* Framework-specific adapters are available at:
* - `@json-render/mcp/app/react` — React hook
* - `@json-render/mcp/app/vue` — Vue composable
* - `@json-render/mcp/app/svelte` — Svelte stores
*
* The React hook is also re-exported here for backward compatibility.
*
* @example
* ```tsx
* import { useJsonRenderApp } from "@json-render/mcp/app";
* import { useJsonRenderApp } from "@json-render/mcp/app/react";
* import { Renderer } from "@json-render/react";
*
* function McpAppView({ registry }) {
@@ -21,10 +28,11 @@
* @packageDocumentation
*/
export { useJsonRenderApp } from "./use-json-render-app.js";
export { useJsonRenderApp } from "./app/react.js";
export type {
UseJsonRenderAppOptions,
UseJsonRenderAppReturn,
} from "./use-json-render-app.js";
} from "./app/react.js";
export { buildAppHtml } from "./build-app-html.js";
export type { BuildAppHtmlOptions } from "./build-app-html.js";
export type { JsonRenderAppOptions, JsonRenderAppState } from "./app/shared.js";
@@ -1,16 +1,16 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Spec } from "@json-render/core";
import { App } from "@modelcontextprotocol/ext-apps";
import {
parseSpecFromToolResult,
type JsonRenderAppOptions,
type ToolResultContent,
} from "./shared.js";
/**
* 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;
}
export type UseJsonRenderAppOptions = JsonRenderAppOptions;
/**
* Return value of `useJsonRenderApp`.
@@ -38,29 +38,6 @@ export interface UseJsonRenderAppReturn {
) => 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.
@@ -92,8 +69,6 @@ export function useJsonRenderApp(
}
};
// Let the App class handle transport creation internally,
// matching the official MCP Apps quickstart pattern.
app
.connect()
.then(() => {
+61
View File
@@ -0,0 +1,61 @@
import type { Spec } from "@json-render/core";
import type { App } from "@modelcontextprotocol/ext-apps";
/**
* Options for creating a json-render MCP App client.
*/
export interface JsonRenderAppOptions {
/** App name shown during initialization. Defaults to `"json-render"`. */
name?: string;
/** App version. Defaults to `"1.0.0"`. */
version?: string;
}
/**
* State returned by the json-render MCP App client across all frameworks.
*/
export interface JsonRenderAppState {
/** 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>;
}
export interface ToolResultContent {
type: string;
text?: string;
}
export 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;
}
}
+129
View File
@@ -0,0 +1,129 @@
import { writable, derived, type Readable, type Writable } from "svelte/store";
import type { Spec } from "@json-render/core";
import { App } from "@modelcontextprotocol/ext-apps";
import {
parseSpecFromToolResult,
type JsonRenderAppOptions,
type ToolResultContent,
} from "./shared.js";
/**
* Options for `createJsonRenderApp`.
*/
export type CreateJsonRenderAppOptions = JsonRenderAppOptions;
/**
* Return value of `createJsonRenderApp`.
*/
export interface CreateJsonRenderAppReturn {
/** The current json-render spec (null until the first tool result). */
spec: Readable<Spec | null>;
/** Whether the app is still connecting to the host. */
connecting: Readable<boolean>;
/** Whether the app is connected to the host. */
connected: Readable<boolean>;
/** Connection error, if any. */
error: Readable<Error | null>;
/** Whether the spec is still being received / parsed. */
loading: Readable<boolean>;
/** The underlying MCP App instance. */
app: Readable<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>;
/** Clean up the MCP App connection. Call in `onDestroy`. */
destroy: () => void;
}
/**
* Create a json-render MCP App client using Svelte stores.
*
* Connects to the MCP host, listens for tool results, and maintains
* the current json-render spec as a readable store.
*
* Call `destroy()` in your component's `onDestroy` to clean up.
*
* @example
* ```svelte
* <script>
* import { createJsonRenderApp } from "@json-render/mcp/app/svelte";
* import { onDestroy } from "svelte";
*
* const { spec, loading, destroy } = createJsonRenderApp();
* onDestroy(destroy);
* </script>
* ```
*/
export function createJsonRenderApp(
options: CreateJsonRenderAppOptions = {},
): CreateJsonRenderAppReturn {
const { name = "json-render", version = "1.0.0" } = options;
const spec: Writable<Spec | null> = writable(null);
const loading: Writable<boolean> = writable(true);
const connected: Writable<boolean> = writable(false);
const error: Writable<Error | null> = writable(null);
const appStore: Writable<App | null> = writable(null);
const connecting: Readable<boolean> = derived(
[connected, error],
([$connected, $error]) => !$connected && !$error,
);
const app = new App({ name, version });
appStore.set(app);
app.ontoolresult = (result: { content?: ToolResultContent[] }) => {
const parsed = parseSpecFromToolResult(result);
if (parsed) {
spec.set(parsed);
loading.set(false);
}
};
app
.connect()
.then(() => {
connected.set(true);
})
.catch((err: unknown) => {
error.set(err instanceof Error ? err : new Error(String(err)));
});
async function callServerTool(
toolName: string,
args: Record<string, unknown> = {},
): Promise<void> {
loading.set(true);
try {
const result = await app.callServerTool({
name: toolName,
arguments: args,
});
const parsed = parseSpecFromToolResult(result);
if (parsed) spec.set(parsed);
} finally {
loading.set(false);
}
}
function destroy(): void {
app.close().catch(() => {});
}
return {
spec,
connecting,
connected,
error,
loading,
app: appStore,
callServerTool,
destroy,
};
}
+124
View File
@@ -0,0 +1,124 @@
import {
shallowRef,
ref,
computed,
onMounted,
onUnmounted,
type ShallowRef,
type Ref,
type ComputedRef,
} from "vue";
import type { Spec } from "@json-render/core";
import { App } from "@modelcontextprotocol/ext-apps";
import {
parseSpecFromToolResult,
type JsonRenderAppOptions,
type ToolResultContent,
} from "./shared.js";
/**
* Options for the `useJsonRenderApp` composable.
*/
export type UseJsonRenderAppOptions = JsonRenderAppOptions;
/**
* Return value of `useJsonRenderApp`.
*/
export interface UseJsonRenderAppReturn {
/** The current json-render spec (null until the first tool result). */
spec: ShallowRef<Spec | null>;
/** Whether the app is still connecting to the host. */
connecting: ComputedRef<boolean>;
/** Whether the app is connected to the host. */
connected: Ref<boolean>;
/** Connection error, if any. */
error: Ref<Error | null>;
/** Whether the spec is still being received / parsed. */
loading: Ref<boolean>;
/** The underlying MCP App instance. */
app: ShallowRef<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>;
}
/**
* Vue composable that connects to the MCP host, listens for tool results,
* and maintains the current json-render spec.
*
* Must be called inside a component's `setup` function or `<script setup>`.
*/
export function useJsonRenderApp(
options: UseJsonRenderAppOptions = {},
): UseJsonRenderAppReturn {
const { name = "json-render", version = "1.0.0" } = options;
const spec = shallowRef<Spec | null>(null);
const loading = ref(true);
const connected = ref(false);
const error = ref<Error | null>(null);
const appRef = shallowRef<App | null>(null);
const connecting = computed(() => !connected.value && !error.value);
let appInstance: App | null = null;
onMounted(() => {
const app = new App({ name, version });
appInstance = app;
appRef.value = app;
app.ontoolresult = (result: { content?: ToolResultContent[] }) => {
const parsed = parseSpecFromToolResult(result);
if (parsed) {
spec.value = parsed;
loading.value = false;
}
};
app
.connect()
.then(() => {
connected.value = true;
})
.catch((err: unknown) => {
error.value = err instanceof Error ? err : new Error(String(err));
});
});
onUnmounted(() => {
appInstance?.close().catch(() => {});
});
async function callServerTool(
toolName: string,
args: Record<string, unknown> = {},
): Promise<void> {
if (!appInstance) return;
loading.value = true;
try {
const result = await appInstance.callServerTool({
name: toolName,
arguments: args,
});
const parsed = parseSpecFromToolResult(result);
if (parsed) spec.value = parsed;
} finally {
loading.value = false;
}
}
return {
spec,
connecting,
connected,
error,
loading,
app: appRef,
callServerTool,
};
}
+10 -1
View File
@@ -1,7 +1,13 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/app.ts"],
entry: [
"src/index.ts",
"src/app.ts",
"src/app/react.ts",
"src/app/vue.ts",
"src/app/svelte.ts",
],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
@@ -9,6 +15,9 @@ export default defineConfig({
external: [
"react",
"react-dom",
"vue",
"svelte",
"svelte/store",
"@json-render/core",
"@json-render/react",
"@modelcontextprotocol/sdk",
+107 -13
View File
@@ -520,7 +520,7 @@ importers:
specifier: ^5.7.2
version: 5.9.3
examples/mcp:
examples/mcp-react:
dependencies:
'@json-render/core':
specifier: workspace:*
@@ -578,6 +578,98 @@ importers:
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/mcp-svelte:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../../packages/core
'@json-render/mcp':
specifier: workspace:*
version: link:../../packages/mcp
'@json-render/svelte':
specifier: workspace:*
version: link:../../packages/svelte
'@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)
svelte:
specifier: ^5.49.2
version: 5.53.5
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.4
version: 6.2.4(svelte@5.53.5)(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))
'@tailwindcss/vite':
specifier: ^4.2.1
version: 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))
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
vite:
specifier: ^7.3.1
version: 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)
vite-plugin-singlefile:
specifier: ^2.3.0
version: 2.3.0(rollup@4.55.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))
examples/mcp-vue:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../../packages/core
'@json-render/mcp':
specifier: workspace:*
version: link:../../packages/mcp
'@json-render/vue':
specifier: workspace:*
version: link:../../packages/vue
'@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)
vue:
specifier: ^3.5.0
version: 3.5.29(typescript@5.9.3)
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 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))
'@vitejs/plugin-vue':
specifier: ^6.0.4
version: 6.0.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))(vue@3.5.29(typescript@5.9.3))
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
vite:
specifier: ^7.3.1
version: 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)
vite-plugin-singlefile:
specifier: ^2.3.0
version: 2.3.0(rollup@4.55.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))
examples/no-ai:
dependencies:
'@json-render/core':
@@ -1341,12 +1433,18 @@ importers:
'@types/react':
specifier: 19.2.3
version: 19.2.3
svelte:
specifier: ^5.0.0
version: 5.53.5
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
vue:
specifier: ^3.5.0
version: 3.5.29(typescript@5.9.3)
packages/react:
dependencies:
@@ -3732,16 +3830,6 @@ packages:
react-dom:
optional: true
'@modelcontextprotocol/sdk@1.26.0':
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@modelcontextprotocol/sdk@1.27.1':
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
engines: {node: '>=18'}
@@ -15035,7 +15123,7 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.12.0)
ajv: 8.17.1
@@ -25465,7 +25553,7 @@ snapshots:
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@dotenvx/dotenvx': 1.52.0
'@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76)
'@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.1
commander: 14.0.2
@@ -26670,6 +26758,12 @@ snapshots:
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-plugin-singlefile@2.3.0(rollup@4.55.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:
micromatch: 4.0.8
rollup: 4.55.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)
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
+64 -12
View File
@@ -34,10 +34,18 @@ const server = createMcpApp({
await server.connect(new StdioServerTransport());
```
### Client (React, inside iframe)
### Client (inside iframe)
Framework-specific adapters connect to the MCP host and maintain the current spec:
- `@json-render/mcp/app/react` -- `useJsonRenderApp()` React hook
- `@json-render/mcp/app/vue` -- `useJsonRenderApp()` Vue composable
- `@json-render/mcp/app/svelte` -- `createJsonRenderApp()` Svelte stores
#### React
```tsx
import { useJsonRenderApp } from "@json-render/mcp/app";
import { useJsonRenderApp } from "@json-render/mcp/app/react";
import { JSONUIProvider, Renderer } from "@json-render/react";
function McpAppView({ registry }) {
@@ -52,12 +60,50 @@ function McpAppView({ registry }) {
}
```
#### Vue
```vue
<script setup>
import { useJsonRenderApp } from "@json-render/mcp/app/vue";
import { Renderer } from "@json-render/vue";
const { spec, loading, error } = useJsonRenderApp();
</script>
<template>
<div v-if="error">Error: {{ error.message }}</div>
<div v-else-if="!spec">Waiting...</div>
<Renderer v-else :spec="spec" :registry="registry" :loading="loading" />
</template>
```
#### Svelte
```svelte
<script>
import { createJsonRenderApp } from "@json-render/mcp/app/svelte";
import { Renderer } from "@json-render/svelte";
import { onDestroy } from "svelte";
const { spec, loading, error, destroy } = createJsonRenderApp();
onDestroy(destroy);
</script>
{#if $error}
<div>Error: {$error.message}</div>
{:else if !$spec}
<div>Waiting...</div>
{:else}
<Renderer spec={$spec} {registry} loading={$loading} />
{/if}
```
## 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
3. The HTML resource is a Vite-bundled single-file app (React, Vue, or Svelte) with json-render renderers
4. Inside the iframe, the framework adapter connects to the host via `postMessage` and renders specs
## Server API
@@ -65,17 +111,21 @@ function McpAppView({ registry }) {
- `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`)
## Client API
- `useJsonRenderApp(options?)` - React hook, returns `{ spec, loading, connected, error, callServerTool }`
- `buildAppHtml(options)` - generate HTML from bundled JS/CSS
- `buildAppHtml(options)` (`@json-render/mcp/app`) - generate HTML from bundled JS/CSS
- `useJsonRenderApp(options?)` (`@json-render/mcp/app/react`) - React hook
- `useJsonRenderApp(options?)` (`@json-render/mcp/app/vue`) - Vue composable
- `createJsonRenderApp(options?)` (`@json-render/mcp/app/svelte`) - Svelte stores + `destroy()`
All adapters return `{ spec, loading, connected, connecting, error, app, callServerTool }`.
## Building the iframe HTML
Bundle the React app into a single self-contained HTML file using Vite + `vite-plugin-singlefile`:
Bundle your app into a single self-contained HTML file using Vite + `vite-plugin-singlefile`. Add the appropriate framework plugin:
```typescript
// vite.config.ts
// vite.config.ts (React example)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
@@ -120,9 +170,11 @@ export default defineConfig({
# Server
npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk
# Client (iframe)
npm install @json-render/react @json-render/shadcn react react-dom
# Client (iframe) -- pick your framework
npm install @json-render/react react react-dom # React
npm install @json-render/vue vue # Vue
npm install @json-render/svelte svelte # Svelte
# Build tools
npm install -D vite @vitejs/plugin-react vite-plugin-singlefile
npm install -D vite vite-plugin-singlefile
```