Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ Then start the HTTP server with `pnpm dev`.

Wraps an HTML fragment with the complete OpenGenerativeUI design system.

In [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) hosts such as Claude Desktop, the tool is linked to the `ui://open-generative-ui/document.html` view, so the fragment renders inline in the conversation. The view applies the design system and the host's theme, runs the fragment's scripts in order, and maps `sendPrompt(text)` and `openLink(url)` to the host. The tool result is then a one-line confirmation, which keeps the document out of the model's context. Clients that do not advertise the `io.modelcontextprotocol/ui` extension receive the complete document as shown below.

**Input:**
```typescript
{
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1",
"@modelcontextprotocol/sdk": "^1.25.3",
"@modelcontextprotocol/sdk": "^1.32.1",
"@repo/design-system": "workspace:*",
"hono": "^4",
"zod": "^3.23"
Expand Down
84 changes: 79 additions & 5 deletions apps/mcp/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@

import { THEME_CSS, SVG_CLASSES_CSS, FORM_STYLES_CSS } from "@repo/design-system";

// Origins generated UI may load scripts and assets from, in both rendering paths.
export const CDN_ORIGINS = [
"https://cdnjs.cloudflare.com",
"https://esm.sh",
"https://cdn.jsdelivr.net",
"https://unpkg.com",
];

export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";

// ponytail: the view loads the MCP Apps SDK from jsDelivr when it opens, so rendering
// needs network access; inline app-with-deps.js into the view if offline hosts matter.
const EXT_APPS_SDK_URL =
"https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps@2.0.3/dist/src/app-with-deps.js";

const BRIDGE_JS = `
window.sendPrompt = function(text) {
window.parent.postMessage({ type: 'send-prompt', text: text }, '*');
Expand Down Expand Up @@ -37,11 +52,7 @@ export function assembleDocument(html: string): string {
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'unsafe-inline' 'unsafe-eval'
https://cdnjs.cloudflare.com
https://esm.sh
https://cdn.jsdelivr.net
https://unpkg.com;
script-src 'unsafe-inline' 'unsafe-eval' ${CDN_ORIGINS.join(" ")};
style-src 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
Expand All @@ -63,3 +74,66 @@ export function assembleDocument(html: string): string {
</body>
</html>`;
}

// MCP Apps view for assemble_document. The host renders it in a sandboxed iframe
// and delivers the tool arguments, so the fragment arrives as args.html.
export const MCP_APP_VIEW_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<style>
${THEME_CSS}
${SVG_CLASSES_CSS}
${FORM_STYLES_CSS}
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
</style>
</head>
<body>
<div id="content"></div>
<script type="module">
import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "${EXT_APPS_SDK_URL}";

const app = new App({ name: "open-generative-ui", version: "0.1.0" });
const content = document.getElementById("content");

window.sendPrompt = (text) =>
app.sendMessage({ role: "user", content: [{ type: "text", text: String(text) }] });
window.openLink = (url) => app.openLink({ url: String(url) });
document.addEventListener("click", (e) => {
const a = e.target.closest && e.target.closest("a[href^='http']");
if (a) { e.preventDefault(); window.openLink(a.href); }
});

// innerHTML never runs scripts, so each one is recreated in document order.
// External classic scripts are awaited so later inline code can use their globals.
async function render(html) {
content.innerHTML = html;
for (const old of content.querySelectorAll("script")) {
const script = document.createElement("script");
for (const { name, value } of old.attributes) script.setAttribute(name, value);
script.textContent = old.textContent;
const loaded = script.src && script.type !== "module"
? new Promise((done) => { script.onload = script.onerror = done; })
: null;
old.replaceWith(script);
if (loaded) await loaded;
}
}

function applyHostContext(ctx) {
if (ctx?.theme) applyDocumentTheme(ctx.theme);
if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
if (ctx?.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
}

app.ontoolinput = ({ arguments: args }) => {
if (typeof args?.html === "string") render(args.html);
};
app.onhostcontextchanged = applyHostContext;
await app.connect();
applyHostContext(app.getHostContext());
</script>
</body>
</html>`;
57 changes: 50 additions & 7 deletions apps/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import {
} from "@modelcontextprotocol/sdk/server/mcp.js";
import * as z from "zod";
import { listSkills, loadSkill } from "./skills.js";
import { assembleDocument } from "./renderer.js";
import {
assembleDocument,
CDN_ORIGINS,
MCP_APP_MIME_TYPE,
MCP_APP_VIEW_HTML,
} from "./renderer.js";

const VIEW_URI = "ui://open-generative-ui/document.html";
const UI_EXTENSION = "io.modelcontextprotocol/ui";

export function createMcpServer(): McpServer {
const server = new McpServer({
Expand Down Expand Up @@ -97,13 +105,39 @@ export function createMcpServer(): McpServer {
}),
);

// Tool: assemble complete HTML document with design system
// MCP Apps view: hosts with the ui extension render assemble_document inline
server.registerResource(
"document-view",
VIEW_URI,
{
description: "Renders assemble_document fragments inline in MCP Apps hosts",
mimeType: MCP_APP_MIME_TYPE,
},
async () => ({
contents: [
{
uri: VIEW_URI,
mimeType: MCP_APP_MIME_TYPE,
text: MCP_APP_VIEW_HTML,
_meta: {
ui: {
prefersBorder: false,
csp: { resourceDomains: CDN_ORIGINS, connectDomains: CDN_ORIGINS },
},
},
},
],
}),
);

// Tool: render (MCP Apps hosts) or assemble (other clients) a design-system document
server.registerTool(
"assemble_document",
{
description:
"Wraps HTML with Open Intelligent UI theme CSS, SVG classes, form styles, and bridge JS. " +
"Returns a complete iframe-ready HTML document.",
"Renders an HTML fragment for the user with Open Intelligent UI theme CSS variables, " +
"SVG c-* classes, form styles, and sendPrompt(text) / openLink(url) globals. " +
"MCP Apps hosts show it inline; other clients get the complete iframe-ready HTML document.",
inputSchema: {
title: z.string().describe("Short title for the visualization"),
description: z
Expand All @@ -115,10 +149,19 @@ export function createMcpServer(): McpServer {
"Self-contained HTML fragment with inline <style> and <script>",
),
},
_meta: { ui: { resourceUri: VIEW_URI }, "ui/resourceUri": VIEW_URI },
},
async ({ title, html }) => {
// The view already shows the fragment, so UI hosts get a one-liner instead of
// echoing ~12 KB of design-system CSS into the model's context on every call.
const ui = server.server.getClientCapabilities()?.extensions?.[UI_EXTENSION] as
| { mimeTypes?: string[] }
| undefined;
const text = ui?.mimeTypes?.includes(MCP_APP_MIME_TYPE)
? `Rendered "${title}" inline for the user.`
: assembleDocument(html);
return { content: [{ type: "text", text }] };
},
async ({ html }) => ({
content: [{ type: "text", text: assembleDocument(html) }],
}),
);

return server;
Expand Down
4 changes: 2 additions & 2 deletions apps/mcp/src/skills.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, readdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { resolve, dirname, sep } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand All @@ -15,7 +15,7 @@ export function listSkills(): string[] {

export function loadSkill(name: string): string {
const resolved = resolve(SKILLS_DIR, `${name}.txt`);
if (!resolved.startsWith(resolve(SKILLS_DIR) + "/")) {
if (!resolved.startsWith(resolve(SKILLS_DIR) + sep)) {
throw new Error(`Invalid skill name: ${name}`);
}
return readFileSync(resolved, "utf-8");
Expand Down
72 changes: 72 additions & 0 deletions apps/mcp/tests/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
import { createMcpServer } from "../src/server";

const VIEW_URI = "ui://open-generative-ui/document.html";
const MCP_APP_MIME = "text/html;profile=mcp-app";
const UI_HOST: ClientCapabilities = {
extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [MCP_APP_MIME] } },
};
const ARGS = { title: "Demo", description: "A demo", html: "<p id='x'>hi</p>" };

async function connect(capabilities: ClientCapabilities = {}) {
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await createMcpServer().connect(serverSide);
const client = new Client({ name: "test", version: "0.0.0" }, { capabilities });
await client.connect(clientSide);
return client;
}

async function callText(capabilities?: ClientCapabilities) {
const client = await connect(capabilities);
const result = await client.callTool({ name: "assemble_document", arguments: ARGS });
return (result.content as { type: string; text: string }[])[0].text;
}

describe("assemble_document as an MCP App", () => {
it("links the tool to its ui:// view, including the legacy key", async () => {
const { tools } = await (await connect()).listTools();
const tool = tools.find((t) => t.name === "assemble_document");
expect(tool?._meta).toMatchObject({
ui: { resourceUri: VIEW_URI },
"ui/resourceUri": VIEW_URI,
});
});

it("serves the view with the design system, bridge, and CDN allowlist", async () => {
const { contents } = await (await connect()).readResource({ uri: VIEW_URI });
const view = contents[0] as { mimeType?: string; text?: string; _meta?: unknown };
expect(view.mimeType).toBe(MCP_APP_MIME);
expect(view.text).toContain("--color-background-primary");
expect(view.text).toContain("window.sendPrompt");
expect(view.text).toContain("window.openLink");
expect(view.text).toMatch(/\.sr-only\s*\{[^}]*clip:/);
expect(view._meta).toMatchObject({
ui: {
prefersBorder: false,
csp: {
resourceDomains: expect.arrayContaining([
"https://cdnjs.cloudflare.com",
"https://esm.sh",
"https://cdn.jsdelivr.net",
"https://unpkg.com",
]),
},
},
});
});

it("keeps the document out of the model's context on MCP Apps hosts", async () => {
const text = await callText(UI_HOST);
expect(text).not.toContain("<!DOCTYPE html>");
expect(text).toContain("Demo");
});

it("returns the full document to hosts that cannot render MCP Apps", async () => {
const text = await callText();
expect(text).toContain("<!DOCTYPE html>");
expect(text).toContain(ARGS.html);
});
});
12 changes: 11 additions & 1 deletion apps/mcp/tests/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { listSkills } from "../src/skills";
import { listSkills, loadSkill } from "../src/skills";

describe("listSkills", () => {
it("returns an array containing the bundled skills", () => {
Expand All @@ -8,3 +8,13 @@ describe("listSkills", () => {
expect(skills).toContain("master-agent-playbook");
});
});

describe("loadSkill", () => {
it("loads a bundled skill by name on every platform", () => {
expect(loadSkill("master-agent-playbook").length).toBeGreaterThan(0);
});

it("rejects names that escape the skills directory", () => {
expect(() => loadSkill("../package")).toThrow("Invalid skill name");
});
});
1 change: 1 addition & 0 deletions docs/mcp-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The MCP server lives in `apps/mcp/`.
|-----|-------------|
| `skills://list` | JSON array of available skill names |
| `skills://{name}` | Full text of an individual skill document |
| `ui://open-generative-ui/document.html` | MCP Apps view that renders `assemble_document` output inline (Claude Desktop and other MCP Apps hosts) |

### Prompts

Expand Down
Loading