modified create-mcp-app skill to support chatGPT#440
modified create-mcp-app skill to support chatGPT#440mandpd wants to merge 1 commit intomodelcontextprotocol:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Updates the create-mcp-app skill documentation to cover MCP App requirements for ChatGPT in addition to Claude, so developers can build cross-host compatible MCP Apps without trial-and-error in ChatGPT’s template UI.
Changes:
- Expands the skill intro to explicitly target both Claude and ChatGPT.
- Adds a comprehensive “ChatGPT Compliance” section (tool annotations,
structuredContent, widget CSP/domain, transport notes, file params, checklist). - Extends “Common Mistakes” and “Testing” sections with ChatGPT-specific guidance.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 }; |
There was a problem hiding this comment.
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.
| 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. | |
| } |
| 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 }, |
There was a problem hiding this comment.
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).
jonathanhefner
left a comment
There was a problem hiding this comment.
In general, I lean more toward including some of this information in our docs (where necessary), and pointing the skill to those (a la #416).
I also strongly prefer to avoid vendor-specific guidance as much as possible. (And I wonder if some of these issues are transient, i.e., oversights that OpenAI will fix.)
If there are enough persistent issues, then it may be worth creating a dedicated "Troubleshooting" guide, where we can call out vendor-specific problems that occur during the testing phase.
| ### 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. |
There was a problem hiding this comment.
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 }, | ||
| }; | ||
| ``` | ||
|
|
||
| **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`: |
There was a problem hiding this comment.
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.)
| ### 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: [], | ||
| }, | ||
| }, | ||
| }, | ||
| ``` |
There was a problem hiding this comment.
This sounds like a bug. The spec explicitly allows "empty or omitted":
ext-apps/specification/2026-01-26/apps.mdx
Lines 114 to 145 in 0bbbfee
| ### 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: { /* ... */ }, | ||
| }, | ||
| }, | ||
| ``` |
There was a problem hiding this comment.
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:
ext-apps/specification/2026-01-26/apps.mdx
Lines 199 to 218 in 0bbbfee
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. |
There was a problem hiding this comment.
Unless I'm misunderstanding, I think this is not true (per the referenced example).
| ### 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. |
There was a problem hiding this comment.
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"]` |
There was a problem hiding this comment.
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.
Motivation and Context
The existing
create-mcp-appSKILL.md only covers building MCP Apps for Claude. ChatGPT enforces additional metadata requirements (tool annotations,structuredContentresponses,widget CSP, widget domain) that are not documented in the skill. Developers building cross-host MCP Apps have to discover these requirements through trial and error against the ChatGPT
template configuration UI.
This change adds a comprehensive "ChatGPT Compliance" section so the skill can guide developers through both hosts' requirements in a single reference.
How Has This Been Tested?
annotations,structuredContent,_meta.ui.csp, and_meta.ui.domainresolves ChatGPT's template configuration errors ("Widget CSP is not set", "Widgetdomain is not set")
structuredContent/contenttext parsing pattern works in both Claude (which deliverscontent) and ChatGPT (which deliversstructuredContent)Breaking Changes
None. All additions are purely documentation. Existing MCP Apps built with the original skill continue to work in Claude without modification.
Types of changes
Checklist
Additional context
Key additions to the skill:
readOnlyHint,destructiveHint,openWorldHintwith guidance on choosing values for different tool typesstructuredContentresponse pattern -- three-field response shape (structuredContent,content,_meta) with a cross-host parsing pattern that checksstructuredContentfirst and falls back to
contenttextregisterAppResourceexamples comparing Claude-only vs ChatGPT-compatible registrationopenai/fileParamsextension for user-uploaded filesReference: https://developers.openai.com/apps-sdk/build/mcp-server/