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