diff --git a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md index b497105e..fde2c7c1 100644 --- a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md +++ b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md @@ -1,11 +1,11 @@ --- name: Create MCP App -description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs. +description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs that work across both Claude and ChatGPT. --- # Create MCP App -Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content. +Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop and ChatGPT. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content. ## Core Concept: Tool + Resource @@ -16,7 +16,7 @@ Every MCP App requires two parts linked together: 3. **Link** - The tool's `_meta.ui.resourceUri` references the resource ``` -Host calls tool → Server returns result → Host renders resource UI → UI receives result +Host calls tool -> Server returns result -> Host renders resource UI -> UI receives result ``` ## Quick Start Decision Tree @@ -302,6 +302,249 @@ async function toggleFullscreen() { See `examples/shadertoy-server/` for complete implementation. +## ChatGPT Compliance + +ChatGPT enforces additional metadata requirements beyond what Claude needs. If you are building an MCP App that must work in ChatGPT (or both Claude and ChatGPT), apply everything in this section. + +Reference: https://developers.openai.com/apps-sdk/build/mcp-server/ + +### Tool Annotations (Required) + +Every tool registered with `registerAppTool` must include an `annotations` object describing its impact. ChatGPT uses these hints to decide how to gate tool invocations. + +```typescript +registerAppTool( + server, + "my-tool", + { + title: "My Tool", + description: "Does something useful", + inputSchema: { query: z.string() }, + annotations: { + readOnlyHint: true, // true if the tool only reads data (search, lookup) + destructiveHint: false, // true if the tool deletes or modifies data + openWorldHint: false, // false if the tool targets a bounded set of resources + }, + _meta: { ui: { resourceUri } }, + }, + async ({ query }) => { /* handler */ } +); +``` + +Choose values that accurately describe the tool's behavior: +- A weather lookup: `readOnlyHint: true, destructiveHint: false, openWorldHint: false` +- A file deletion tool: `readOnlyHint: false, destructiveHint: true, openWorldHint: false` +- A web search tool: `readOnlyHint: true, destructiveHint: false, openWorldHint: true` + +Claude ignores these annotations, so including them is safe for cross-host apps. + +### `structuredContent` in Tool Responses (Required) + +ChatGPT expects tool results to use the `structuredContent` field for data that both the model and the widget consume. The `content` text array serves as a narrative fallback for the model. An optional `_meta` sibling carries widget-only data that is never sent to the model. + +```typescript +return { + // Model + widget: concise JSON the widget renders and the model reasons about + structuredContent: { results: data }, + + // Model only: text narration for non-UI hosts or model context + content: [ + { type: "text", text: "Found 5 results for your query." }, + ], + + // Widget only (optional): large or sensitive data the model should not see + _meta: { rawPayload: largeObject }, +}; +``` + +**Claude compatibility:** Claude delivers `content` to the widget via `ontoolresult` but may not pass `structuredContent`. Write the widget's result parser to check `structuredContent` first, then fall back to parsing JSON from `content[0].text`: + +```typescript +function parseResult(result: CallToolResult) { + // ChatGPT path: structuredContent is present + const structured = result.structuredContent as Record | undefined; + if (structured?.data) { + return { data: structured.data }; + } + + // Claude path: data embedded as JSON in content text + const text = result.content?.find((c) => c.type === "text"); + if (text && "text" in text) { + const parsed = JSON.parse(text.text); + if (parsed.data) return { data: parsed.data }; + } + + return { error: "No data in response" }; +} +``` + +### Widget CSP (Required for Submission) + +The resource contents must include a `_meta.ui.csp` object declaring the widget's Content Security Policy. ChatGPT sandboxes widgets in an iframe and enforces this CSP. Without it, the ChatGPT template configuration will show: *"Widget CSP is not set for this template."* + +```typescript +_meta: { + ui: { + csp: { + // Domains the widget may fetch() or XMLHttpRequest to + connectDomains: ["https://api.example.com"], + + // Domains the widget may load images, fonts, or scripts from + resourceDomains: ["https://cdn.example.com"], + + // Domains the widget may embed in sub-iframes (avoid if possible -- + // declaring frameDomains triggers heightened security review) + frameDomains: [], + }, + }, +}, +``` + +If the widget makes no external requests (e.g. it only uses `app.callServerTool()` through the MCP bridge), pass empty arrays: + +```typescript +csp: { + connectDomains: [], + resourceDomains: [], +}, +``` + +Claude ignores this metadata, so including it is safe for cross-host apps. + +### Widget Domain (Required for Submission) + +The resource contents must include a `_meta.ui.domain` with a unique HTTPS URL. ChatGPT renders the widget at `.web-sandbox.oaiusercontent.com`. Without it, the ChatGPT template configuration will show: *"Widget domain is not set for this template."* + +```typescript +_meta: { + ui: { + domain: "https://my-weather-app.example.com", + csp: { /* ... */ }, + }, +}, +``` + +Replace the placeholder with your actual production domain before submitting. + +Claude ignores this metadata, so including it is safe for cross-host apps. + +### Complete Resource Registration (ChatGPT-Compatible) + +Putting CSP and domain together, a ChatGPT-compatible resource registration looks like this: + +```typescript +registerAppResource( + server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + domain: "https://my-app.example.com", + csp: { + connectDomains: [], + resourceDomains: [], + }, + }, + }, + }, + ], + }; + }, +); +``` + +Compare with a Claude-only resource registration, which needs none of the `_meta.ui` fields: + +```typescript +registerAppResource( + server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, +); +``` + +### Transport Requirements + +ChatGPT can only connect to MCP servers over **Streamable HTTP** with **HTTPS** in production. It cannot use stdio. + +- For local development, use HTTP and tunnel with a tunnelling service (e.g. ngrok, Cloudflare Tunnel) for HTTPS. +- For production, deploy behind HTTPS (Cloudflare Workers, Fly.io, AWS, Vercel, etc.). +- Claude supports both stdio (Claude Desktop) and Streamable HTTP (claude.ai). + +### ChatGPT-Specific Widget APIs (`window.openai`) + +ChatGPT exposes optional host APIs on `window.openai` inside the widget iframe: + +- `uploadFile` / `getFileDownloadUrl` -- image and file handling +- `requestModal` -- host-owned modal overlays +- `requestCheckout` -- Instant Checkout (when enabled) + +These are ChatGPT-only and not part of the MCP Apps standard. Use them for enhanced UX but keep the core bridge on `app.callServerTool()` / `ontoolresult` for portability. + +### File Parameter Inputs (ChatGPT Extension) + +For tools that accept user-uploaded files, ChatGPT requires a specific input schema shape and a `_meta.openai/fileParams` declaration: + +```typescript +registerAppTool( + server, + "analyze-image", + { + title: "Analyze Image", + description: "Analyze an uploaded image", + inputSchema: { + imageFile: z.object({ + download_url: z.string(), + file_id: z.string(), + }), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + _meta: { + ui: { resourceUri }, + "openai/fileParams": ["imageFile"], + }, + }, + async ({ imageFile }) => { /* handler */ } +); +``` + +Files are objects with `download_url` and `file_id` fields only. Nested file structures are not supported. This is a ChatGPT-specific extension and will be ignored by Claude. + +### ChatGPT Compliance Checklist + +Use this checklist when preparing an MCP App for ChatGPT submission: + +- [ ] **Tool annotations** -- every tool has `annotations: { readOnlyHint, destructiveHint, openWorldHint }` +- [ ] **`structuredContent`** -- tool handlers return `structuredContent` alongside `content` +- [ ] **Widget CSP** -- resource contents include `_meta.ui.csp` with `connectDomains` and `resourceDomains` +- [ ] **Widget domain** -- resource contents include `_meta.ui.domain` with a unique HTTPS URL +- [ ] **HTTPS transport** -- server is accessible over HTTPS (use a tunnelling service for local dev) +- [ ] **Widget parser** -- client-side result parsing checks `structuredContent` first, falls back to `content` text +- [ ] **No secrets in responses** -- `structuredContent`, `content`, and `_meta` must not contain API keys or tokens +- [ ] **File params** (if applicable) -- file inputs use `z.object({ download_url, file_id })` with `_meta["openai/fileParams"]` + ## Common Mistakes to Avoid 1. **Handlers after connect()** - Register ALL handlers BEFORE calling `app.connect()` @@ -312,6 +555,10 @@ See `examples/shadertoy-server/` for complete implementation. 6. **No text fallback** - Always provide `content` array for non-UI hosts 7. **Hardcoded styles** - Use host CSS variables for theme integration 8. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during generation +9. **Missing tool annotations** - ChatGPT requires `annotations` on every tool; omitting them blocks submission +10. **Missing CSP / domain on resource** - ChatGPT requires `_meta.ui.csp` and `_meta.ui.domain` on resource contents; omitting them shows configuration errors +11. **Only using `content` for data** - ChatGPT reads `structuredContent`; embedding JSON in `content` text alone means ChatGPT cannot deliver structured data to the widget +12. **Stdio-only transport** - ChatGPT cannot use stdio; always support Streamable HTTP ## Testing @@ -340,3 +587,12 @@ Send debug logs to the host application (rather than just the iframe's dev conso await app.sendLog({ level: "info", data: "Debug message" }); await app.sendLog({ level: "error", data: { error: err.message } }); ``` + +### Testing with ChatGPT + +1. Build the project: `npm run build` +2. Start the server: `npm run serve` +3. Expose via a tunnelling service (e.g. `ngrok http 3001` or Cloudflare Tunnel) +4. In ChatGPT, add the MCP server URL (the tunnel's HTTPS URL + `/mcp`) +5. Verify the template configuration shows no errors for CSP or domain +6. Test tool invocation and confirm the widget renders with data from `structuredContent`