From c30a97c671e59762e82dcaa0b692fcbc5aca2b40 Mon Sep 17 00:00:00 2001 From: Ahnaf An Nafee Date: Mon, 12 Oct 2026 01:51:56 -0400 Subject: [PATCH 1/2] fix(mcp): load skill documents on Windows loadSkill guards against path traversal by checking that the resolved path starts with the skills directory followed by "/". On Windows, path.resolve returns backslash-separated paths, so every skill name failed the check and all skill resources and prompts threw "Invalid skill name". Compare against path.sep instead, and cover loading and traversal rejection in tests. --- apps/mcp/src/skills.ts | 4 ++-- apps/mcp/tests/skills.test.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/mcp/src/skills.ts b/apps/mcp/src/skills.ts index e578d8a..12d1e96 100644 --- a/apps/mcp/src/skills.ts +++ b/apps/mcp/src/skills.ts @@ -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)); @@ -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"); diff --git a/apps/mcp/tests/skills.test.ts b/apps/mcp/tests/skills.test.ts index 57a35e1..df55a0b 100644 --- a/apps/mcp/tests/skills.test.ts +++ b/apps/mcp/tests/skills.test.ts @@ -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", () => { @@ -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"); + }); +}); From 40c410e848d430262ec9784fa0c3321c6b1ebe57 Mon Sep 17 00:00:00 2001 From: Ahnaf An Nafee Date: Mon, 12 Oct 2026 01:51:56 -0400 Subject: [PATCH 2/2] feat(mcp): render assemble_document inline as an MCP App assemble_document returned the assembled page as plain text, so MCP Apps hosts such as Claude Desktop showed raw HTML instead of rendering it. - Register a ui://open-generative-ui/document.html view (text/html;profile=mcp-app) and link the tool to it through _meta.ui.resourceUri, plus the legacy ui/resourceUri key. - The view applies the design system and the host theme, runs the fragment's scripts in order, and maps sendPrompt and openLink to the MCP Apps bridge. It loads the pinned ext-apps app-with-deps bundle from jsDelivr, which is already on the CDN allowlist, and asks hosts for no border. - Clients that advertise the io.modelcontextprotocol/ui extension get a one-line result instead of the roughly 12 KB document, which keeps it out of the model's context. Other clients still receive the full document. - Bump @modelcontextprotocol/sdk to ^1.32.1, whose ClientCapabilities schema keeps the extensions field. - Share one CDN allowlist between the iframe CSP and the MCP Apps CSP. --- apps/mcp/README.md | 2 + apps/mcp/package.json | 2 +- apps/mcp/src/renderer.ts | 84 ++++++++++++++++++++++++++++++++--- apps/mcp/src/server.ts | 57 +++++++++++++++++++++--- apps/mcp/tests/server.test.ts | 72 ++++++++++++++++++++++++++++++ docs/mcp-integration.md | 1 + pnpm-lock.yaml | 58 +++++++++++++++++++++++- 7 files changed, 261 insertions(+), 15 deletions(-) create mode 100644 apps/mcp/tests/server.test.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index c4a6c43..d3346f3 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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 { diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 5dd31a3..6d10310 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -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" diff --git a/apps/mcp/src/renderer.ts b/apps/mcp/src/renderer.ts index 8e1400b..81327fa 100644 --- a/apps/mcp/src/renderer.ts +++ b/apps/mcp/src/renderer.ts @@ -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 }, '*'); @@ -37,11 +52,7 @@ export function assembleDocument(html: string): string { `; } + +// 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 = ` + + + + + + + + +
+ + +`; diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index a2caf8a..ab210d3 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -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({ @@ -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 @@ -115,10 +149,19 @@ export function createMcpServer(): McpServer { "Self-contained HTML fragment with inline