Skip to content
Open
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
262 changes: 259 additions & 3 deletions plugins/mcp-apps/skills/create-mcp-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 },
Comment on lines +343 to +356
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structuredContent example and the cross-host parsing guidance are inconsistent: the example returns structuredContent: { results: data } and a human sentence in content[0].text, but the later widget parser expects JSON in content[0].text and reads a data field. Align the field names and update the example content block(s) to match the documented fallback parsing approach (or adjust the parser guidance to match the example).

Copilot uses AI. Check for mistakes.
};
```

**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<string, unknown> | 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 };
Comment on lines +366 to +374
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the parseResult example, JSON.parse(text.text) can throw (especially given the earlier example content text is not JSON). Wrap the parse in a try/catch (or validate the string before parsing) so a non-JSON text block doesn’t crash the widget, and keep the parsed field name consistent with the structuredContent shape you recommend.

Suggested change
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 };
if (structured?.results) {
return { results: structured.results };
}
// Claude path: data embedded as JSON in content text
const text = result.content?.find((c) => c.type === "text");
if (text && "text" in text) {
try {
const parsed = JSON.parse(text.text as string) as Record<string, unknown>;
if (parsed && typeof parsed === "object" && "results" in parsed) {
return { results: (parsed as { results: unknown }).results };
}
} catch {
// Ignore JSON parse errors and fall through to the error return below.
}

Copilot uses AI. Check for mistakes.
}

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 `<domain>.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<ReadResourceResult> => {
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<ReadResourceResult> => {
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()`
Expand All @@ -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

Expand Down Expand Up @@ -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`
Loading