diff --git a/.cursor/mcp.json b/.cursor/mcp.json
index 5f211d4f..87da2a23 100644
--- a/.cursor/mcp.json
+++ b/.cursor/mcp.json
@@ -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"]
}
}
-}
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index cdd528c4..32979a7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
node_modules
.pnp
.pnp.js
+.pnpm-store/
# Local env files
.env*
diff --git a/.vscode/mcp.json b/.vscode/mcp.json
index b2876a02..98831672 100644
--- a/.vscode/mcp.json
+++ b/.vscode/mcp.json
@@ -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"]
}
}
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 91b9a006..404f917d 100644
--- a/README.md
+++ b/README.md
@@ -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`
diff --git a/apps/web/app/(main)/docs/api/mcp/page.mdx b/apps/web/app/(main)/docs/api/mcp/page.mdx
index 2bb49487..d9cfecc5 100644
--- a/apps/web/app/(main)/docs/api/mcp/page.mdx
+++ b/apps/web/app/(main)/docs/api/mcp/page.mdx
@@ -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. Framework-specific adapters are available at:
-These exports run inside the sandboxed iframe rendered by the MCP host.
+- `@json-render/mcp/app/react` -- React hook
+- `@json-render/mcp/app/vue` -- Vue composable
+- `@json-render/mcp/app/svelte` -- Svelte stores
-### useJsonRenderApp
+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
+
+
+
+ Error: {{ error.message }}
+ Waiting...
+
+
+```
+
+### 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
+
+
+{#if $error}
+
Error: {$error.message}
+{:else if !$spec}
+ Waiting...
+{:else}
+
+{/if}
+```
+
+### Return values
+
+All three adapters return the same logical state:
@@ -195,9 +252,11 @@ function McpAppView({ registry }) {
-### 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.
+
+### buildAppHtml (`@json-render/mcp/app`)
-Generate a self-contained HTML page from bundled JavaScript and CSS.
+Generate a self-contained HTML page from bundled JavaScript and CSS. Framework-agnostic.
```typescript
import { buildAppHtml } from "@json-render/mcp/app";
diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts
index c5d77395..f15f6ead 100644
--- a/apps/web/lib/docs-navigation.ts
+++ b/apps/web/lib/docs-navigation.ts
@@ -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,
},
],
diff --git a/examples/chat/next-env.d.ts b/examples/chat/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/examples/chat/next-env.d.ts
+++ b/examples/chat/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/examples/dashboard/next-env.d.ts b/examples/dashboard/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/examples/dashboard/next-env.d.ts
+++ b/examples/dashboard/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/examples/mcp/CHANGELOG.md b/examples/mcp-react/CHANGELOG.md
similarity index 100%
rename from examples/mcp/CHANGELOG.md
rename to examples/mcp-react/CHANGELOG.md
diff --git a/examples/mcp/README.md b/examples/mcp-react/README.md
similarity index 88%
rename from examples/mcp/README.md
rename to examples/mcp-react/README.md
index 8f637362..4cb8d1c1 100644
--- a/examples/mcp/README.md
+++ b/examples/mcp-react/README.md
@@ -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"]
}
}
}
diff --git a/examples/mcp/index.html b/examples/mcp-react/index.html
similarity index 100%
rename from examples/mcp/index.html
rename to examples/mcp-react/index.html
diff --git a/examples/mcp/package.json b/examples/mcp-react/package.json
similarity index 96%
rename from examples/mcp/package.json
rename to examples/mcp-react/package.json
index 310100d4..adace60f 100644
--- a/examples/mcp/package.json
+++ b/examples/mcp-react/package.json
@@ -1,5 +1,5 @@
{
- "name": "example-mcp",
+ "name": "example-mcp-react",
"version": "0.1.1",
"type": "module",
"private": true,
diff --git a/examples/mcp/server.ts b/examples/mcp-react/server.ts
similarity index 100%
rename from examples/mcp/server.ts
rename to examples/mcp-react/server.ts
diff --git a/examples/mcp/src/catalog.ts b/examples/mcp-react/src/catalog.ts
similarity index 100%
rename from examples/mcp/src/catalog.ts
rename to examples/mcp-react/src/catalog.ts
diff --git a/examples/mcp/src/globals.css b/examples/mcp-react/src/globals.css
similarity index 100%
rename from examples/mcp/src/globals.css
rename to examples/mcp-react/src/globals.css
diff --git a/examples/mcp/src/main.tsx b/examples/mcp-react/src/main.tsx
similarity index 100%
rename from examples/mcp/src/main.tsx
rename to examples/mcp-react/src/main.tsx
diff --git a/examples/mcp/src/mcp-app-view.tsx b/examples/mcp-react/src/mcp-app-view.tsx
similarity index 97%
rename from examples/mcp/src/mcp-app-view.tsx
rename to examples/mcp-react/src/mcp-app-view.tsx
index 7e853da7..7f170314 100644
--- a/examples/mcp/src/mcp-app-view.tsx
+++ b/examples/mcp-react/src/mcp-app-view.tsx
@@ -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";
diff --git a/examples/mcp/tsconfig.json b/examples/mcp-react/tsconfig.json
similarity index 100%
rename from examples/mcp/tsconfig.json
rename to examples/mcp-react/tsconfig.json
diff --git a/examples/mcp/vite.config.ts b/examples/mcp-react/vite.config.ts
similarity index 100%
rename from examples/mcp/vite.config.ts
rename to examples/mcp-react/vite.config.ts
diff --git a/examples/mcp-svelte/index.html b/examples/mcp-svelte/index.html
new file mode 100644
index 00000000..6dc94b78
--- /dev/null
+++ b/examples/mcp-svelte/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ json-render MCP App (Svelte)
+
+
+
+
+
+
diff --git a/examples/mcp-svelte/package.json b/examples/mcp-svelte/package.json
new file mode 100644
index 00000000..5296fa82
--- /dev/null
+++ b/examples/mcp-svelte/package.json
@@ -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"
+ }
+}
diff --git a/examples/mcp-svelte/server.ts b/examples/mcp-svelte/server.ts
new file mode 100644
index 00000000..4bbb2536
--- /dev/null
+++ b/examples/mcp-svelte/server.ts
@@ -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);
+});
diff --git a/examples/mcp-svelte/src/App.svelte b/examples/mcp-svelte/src/App.svelte
new file mode 100644
index 00000000..f918d30d
--- /dev/null
+++ b/examples/mcp-svelte/src/App.svelte
@@ -0,0 +1,26 @@
+
+
+{#if $error}
+
+ {$error.message}
+
+{:else if !$spec}
+
+ {$connecting ? "Connecting to host..." : "Waiting for UI spec..."}
+
+{:else}
+
+
+
+{/if}
diff --git a/examples/mcp-svelte/src/catalog.ts b/examples/mcp-svelte/src/catalog.ts
new file mode 100644
index 00000000..51d641d2
--- /dev/null
+++ b/examples/mcp-svelte/src/catalog.ts
@@ -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: {},
+});
diff --git a/examples/mcp-svelte/src/components/Badge.svelte b/examples/mcp-svelte/src/components/Badge.svelte
new file mode 100644
index 00000000..b3a28ee5
--- /dev/null
+++ b/examples/mcp-svelte/src/components/Badge.svelte
@@ -0,0 +1,18 @@
+
+
+
+ {props.label}
+
diff --git a/examples/mcp-svelte/src/components/Button.svelte b/examples/mcp-svelte/src/components/Button.svelte
new file mode 100644
index 00000000..492f0068
--- /dev/null
+++ b/examples/mcp-svelte/src/components/Button.svelte
@@ -0,0 +1,26 @@
+
+
+ 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}
+
diff --git a/examples/mcp-svelte/src/components/Card.svelte b/examples/mcp-svelte/src/components/Card.svelte
new file mode 100644
index 00000000..edd949f7
--- /dev/null
+++ b/examples/mcp-svelte/src/components/Card.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {#if props.title}
+
+ {props.title}
+
+ {/if}
+ {#if props.subtitle}
+
+ {props.subtitle}
+
+ {/if}
+ {#if children}
+ {@render children()}
+ {/if}
+
diff --git a/examples/mcp-svelte/src/components/Stack.svelte b/examples/mcp-svelte/src/components/Stack.svelte
new file mode 100644
index 00000000..05049226
--- /dev/null
+++ b/examples/mcp-svelte/src/components/Stack.svelte
@@ -0,0 +1,33 @@
+
+
+
+ {#if children}
+ {@render children()}
+ {/if}
+
diff --git a/examples/mcp-svelte/src/components/Text.svelte b/examples/mcp-svelte/src/components/Text.svelte
new file mode 100644
index 00000000..06c72b19
--- /dev/null
+++ b/examples/mcp-svelte/src/components/Text.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {String(props.content ?? "")}
+
diff --git a/examples/mcp-svelte/src/globals.css b/examples/mcp-svelte/src/globals.css
new file mode 100644
index 00000000..1162f970
--- /dev/null
+++ b/examples/mcp-svelte/src/globals.css
@@ -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;
+}
diff --git a/examples/mcp-svelte/src/main.ts b/examples/mcp-svelte/src/main.ts
new file mode 100644
index 00000000..4b13ec06
--- /dev/null
+++ b/examples/mcp-svelte/src/main.ts
@@ -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")! });
diff --git a/examples/mcp-svelte/src/registry.ts b/examples/mcp-svelte/src/registry.ts
new file mode 100644
index 00000000..f7086448
--- /dev/null
+++ b/examples/mcp-svelte/src/registry.ts
@@ -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 = {
+ Stack,
+ Card,
+ Text,
+ Button,
+ Badge,
+};
+
+export const { registry } = defineRegistry(catalog, { components });
diff --git a/examples/mcp-svelte/svelte.config.js b/examples/mcp-svelte/svelte.config.js
new file mode 100644
index 00000000..ff8b4c56
--- /dev/null
+++ b/examples/mcp-svelte/svelte.config.js
@@ -0,0 +1 @@
+export default {};
diff --git a/examples/mcp-svelte/tsconfig.json b/examples/mcp-svelte/tsconfig.json
new file mode 100644
index 00000000..5155a5e2
--- /dev/null
+++ b/examples/mcp-svelte/tsconfig.json
@@ -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"]
+}
diff --git a/examples/mcp-svelte/vite.config.ts b/examples/mcp-svelte/vite.config.ts
new file mode 100644
index 00000000..0ca0a97b
--- /dev/null
+++ b/examples/mcp-svelte/vite.config.ts
@@ -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",
+ },
+ },
+});
diff --git a/examples/mcp-vue/index.html b/examples/mcp-vue/index.html
new file mode 100644
index 00000000..5e254659
--- /dev/null
+++ b/examples/mcp-vue/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ json-render MCP App (Vue)
+
+
+
+
+
+
diff --git a/examples/mcp-vue/package.json b/examples/mcp-vue/package.json
new file mode 100644
index 00000000..76298814
--- /dev/null
+++ b/examples/mcp-vue/package.json
@@ -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"
+ }
+}
diff --git a/examples/mcp-vue/server.ts b/examples/mcp-vue/server.ts
new file mode 100644
index 00000000..72e512d9
--- /dev/null
+++ b/examples/mcp-vue/server.ts
@@ -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);
+});
diff --git a/examples/mcp-vue/src/App.vue b/examples/mcp-vue/src/App.vue
new file mode 100644
index 00000000..363023bc
--- /dev/null
+++ b/examples/mcp-vue/src/App.vue
@@ -0,0 +1,31 @@
+
+
+
+
+ {{ error.message }}
+
+
+ {{ connecting ? "Connecting to host..." : "Waiting for UI spec..." }}
+
+
+
+
+
diff --git a/examples/mcp-vue/src/catalog.ts b/examples/mcp-vue/src/catalog.ts
new file mode 100644
index 00000000..c61a7b54
--- /dev/null
+++ b/examples/mcp-vue/src/catalog.ts
@@ -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;
diff --git a/examples/mcp-vue/src/globals.css b/examples/mcp-vue/src/globals.css
new file mode 100644
index 00000000..1162f970
--- /dev/null
+++ b/examples/mcp-vue/src/globals.css
@@ -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;
+}
diff --git a/examples/mcp-vue/src/main.ts b/examples/mcp-vue/src/main.ts
new file mode 100644
index 00000000..b00b2fa5
--- /dev/null
+++ b/examples/mcp-vue/src/main.ts
@@ -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");
diff --git a/examples/mcp-vue/src/registry.ts b/examples/mcp-vue/src/registry.ts
new file mode 100644
index 00000000..9197aa4f
--- /dev/null
+++ b/examples/mcp-vue/src/registry.ts
@@ -0,0 +1,116 @@
+import { h } from "vue";
+import type { Components } from "@json-render/vue";
+import type { AppCatalog } from "./catalog";
+
+export const components: Components = {
+ 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 = {
+ sm: "text-xs",
+ md: "text-sm",
+ lg: "text-base",
+ xl: "text-2xl",
+ };
+ const weightClasses: Record = {
+ 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 = {
+ 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,
+ ),
+};
diff --git a/examples/mcp-vue/tsconfig.json b/examples/mcp-vue/tsconfig.json
new file mode 100644
index 00000000..596f0b3a
--- /dev/null
+++ b/examples/mcp-vue/tsconfig.json
@@ -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"]
+}
diff --git a/examples/mcp-vue/vite.config.ts b/examples/mcp-vue/vite.config.ts
new file mode 100644
index 00000000..9d8b97da
--- /dev/null
+++ b/examples/mcp-vue/vite.config.ts
@@ -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",
+ },
+ },
+});
diff --git a/examples/no-ai/next-env.d.ts b/examples/no-ai/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/examples/no-ai/next-env.d.ts
+++ b/examples/no-ai/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/examples/react-pdf/next-env.d.ts b/examples/react-pdf/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/examples/react-pdf/next-env.d.ts
+++ b/examples/react-pdf/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/examples/remotion/next-env.d.ts b/examples/remotion/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/examples/remotion/next-env.d.ts
+++ b/examples/remotion/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/examples/stripe-app/api/next-env.d.ts b/examples/stripe-app/api/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/examples/stripe-app/api/next-env.d.ts
+++ b/examples/stripe-app/api/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index c2b79d34..41d037e6 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -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
+
+
+
+ Error: {{ error.message }}
+ Waiting for spec...
+
+
+```
+
+#### Svelte
+
+```svelte
+
+
+{#if $error}
+ Error: {$error.message}
+{:else if !$spec}
+ Waiting for spec...
+{:else}
+
+{/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
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 2ff9eb89..3eb721e0 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -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
}
}
}
diff --git a/packages/mcp/src/app.ts b/packages/mcp/src/app.ts
index c00b0345..443701f0 100644
--- a/packages/mcp/src/app.ts
+++ b/packages/mcp/src/app.ts
@@ -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";
diff --git a/packages/mcp/src/use-json-render-app.ts b/packages/mcp/src/app/react.ts
similarity index 77%
rename from packages/mcp/src/use-json-render-app.ts
rename to packages/mcp/src/app/react.ts
index c85a9294..cb9e6650 100644
--- a/packages/mcp/src/use-json-render-app.ts
+++ b/packages/mcp/src/app/react.ts
@@ -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;
}
-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(() => {
diff --git a/packages/mcp/src/app/shared.ts b/packages/mcp/src/app/shared.ts
new file mode 100644
index 00000000..16035d48
--- /dev/null
+++ b/packages/mcp/src/app/shared.ts
@@ -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,
+ ) => Promise;
+}
+
+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;
+ }
+}
diff --git a/packages/mcp/src/app/svelte.ts b/packages/mcp/src/app/svelte.ts
new file mode 100644
index 00000000..85eee287
--- /dev/null
+++ b/packages/mcp/src/app/svelte.ts
@@ -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;
+ /** Whether the app is still connecting to the host. */
+ connecting: Readable;
+ /** Whether the app is connected to the host. */
+ connected: Readable;
+ /** Connection error, if any. */
+ error: Readable;
+ /** Whether the spec is still being received / parsed. */
+ loading: Readable;
+ /** The underlying MCP App instance. */
+ app: Readable;
+ /**
+ * 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,
+ ) => Promise;
+ /** 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
+ *
+ * ```
+ */
+export function createJsonRenderApp(
+ options: CreateJsonRenderAppOptions = {},
+): CreateJsonRenderAppReturn {
+ const { name = "json-render", version = "1.0.0" } = options;
+
+ const spec: Writable = writable(null);
+ const loading: Writable = writable(true);
+ const connected: Writable = writable(false);
+ const error: Writable = writable(null);
+ const appStore: Writable = writable(null);
+
+ const connecting: Readable = 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 = {},
+ ): Promise {
+ 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,
+ };
+}
diff --git a/packages/mcp/src/app/vue.ts b/packages/mcp/src/app/vue.ts
new file mode 100644
index 00000000..c4f2af85
--- /dev/null
+++ b/packages/mcp/src/app/vue.ts
@@ -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;
+ /** Whether the app is still connecting to the host. */
+ connecting: ComputedRef;
+ /** Whether the app is connected to the host. */
+ connected: Ref;
+ /** Connection error, if any. */
+ error: Ref;
+ /** Whether the spec is still being received / parsed. */
+ loading: Ref;
+ /** The underlying MCP App instance. */
+ app: ShallowRef;
+ /**
+ * 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,
+ ) => Promise;
+}
+
+/**
+ * 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 `
+
+
+ Error: {{ error.message }}
+ Waiting...
+
+
+```
+
+#### Svelte
+
+```svelte
+
+
+{#if $error}
+ Error: {$error.message}
+{:else if !$spec}
+ Waiting...
+{:else}
+
+{/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
+
+- `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()`
-- `useJsonRenderApp(options?)` - React hook, returns `{ spec, loading, connected, error, callServerTool }`
-- `buildAppHtml(options)` - generate HTML from bundled JS/CSS
+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
```