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.
Comment on lines +311 to +339
Copy link
Member

Choose a reason for hiding this comment

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

Just to confirm, ChatGPT requires these explicit annotations for all MCP tools (regardless of true/false)?

If that is the case, it might be more effective to simply add them to all of our examples. That way the agent should pick up on the pattern without having to call it out in the skill.

Another possibility (not necessarily better) would be to enforce their presence in the type signature of registerAppTool.


### `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`:
Comment on lines +341 to +360
Copy link
Member

Choose a reason for hiding this comment

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

I would prefer to find a unified approach that works on all platforms (and update all of our examples to use that).

Is the difference that ChatGPT always shows structuredContent to the model, whereas Claude does not? (I know that was the case for OpenAI Apps SDK, but I didn't realize they were doing the same for MCP Apps.)


```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: [],
},
},
},
```
Comment on lines +381 to +401
Copy link
Member

Choose a reason for hiding this comment

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

This sounds like a bug. The spec explicitly allows "empty or omitted":

interface McpUiResourceCsp {
/**
* Origins for network requests (fetch/XHR/WebSocket)
*
* - Empty or omitted = no external connections (secure default)
* - Maps to CSP `connect-src` directive
*
* @example
* ["https://api.weather.com", "wss://realtime.service.com"]
*/
connectDomains?: string[],
/**
* Origins for static resources (images, scripts, stylesheets, fonts, media)
*
* - Empty or omitted = no external resources (secure default)
* - Wildcard subdomains supported: `https://*.example.com`
* - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives
*
* @example
* ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
*/
resourceDomains?: string[],
/**
* Origins for nested iframes
*
* - Empty or omitted = no nested iframes allowed (`frame-src 'none'`)
* - Maps to CSP `frame-src` directive
*
* @example
* ["https://www.youtube.com", "https://player.vimeo.com"]
*/
frameDomains?: string[],


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: { /* ... */ },
},
},
```
Comment on lines +414 to +425
Copy link
Member

Choose a reason for hiding this comment

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

Is domain: "https://my-weather-app.example.com" correct? I was under the impression that it would be something like domain: "my-weather-app-example-com" for ChatGPT, per:

/**
* Dedicated origin for view
*
* Optional domain for the view's sandbox origin. Useful when views need
* stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists.
*
* **Host-dependent:** The format and validation rules for this field are
* determined by each host. Servers MUST consult host-specific documentation
* for the expected domain format. Common patterns include:
* - Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`)
* - URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`)
*
* If omitted, Host uses default sandbox origin (typically per-conversation).
*
* @example
* "a904794854a047f6.claudemcpcontent.com"
* @example
* "www-example-com.oaiusercontent.com"
*/
domain?: string,

In either case, I think we could add this to our example in the patterns guide, and that way we can omit it from the skill itself.


Replace the placeholder with your actual production domain before submitting.

Claude ignores this metadata, so including it is safe for cross-host apps.
Copy link
Member

Choose a reason for hiding this comment

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

Unless I'm misunderstanding, I think this is not true (per the referenced example).


### 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.
Comment on lines +492 to +533
Copy link
Member

Choose a reason for hiding this comment

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

I do not think we should include this in any of our documentation.

If there is a single URL (or small number of URLs) that serve documentation for these extensions, we could possibly point to those, but only if the documentation presents the extensions as progressive enhancements (i.e., provides explicit guidance to not rely on those extensions).


### 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"]`
Comment on lines +535 to +546
Copy link
Member

Choose a reason for hiding this comment

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

I do not think we should position this as a "ChatGPT Compliance" checklist. To whatever extent a checklist is necessary (which it may not be, given other changes), it should be presented as a generic checklist.

The checklist is also duplicative with the "Common Mistakes to Avoid" section below. We should go with one or the other.


## 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