The one thing that makes the widget render
A ChatGPT app is an MCP server with a UI layer, and that UI layer is MCP Apps, the first official MCP extension, with support shipped in ChatGPT, Claude, Goose, and Visual Studio Code (OpenAI Apps SDK, MCP Apps blog). OpenAI's docs organize distribution under plugins, where a plugin bundles skills, an MCP server, and optional UI; the app you build here is the MCP server and UI part (plugin architecture). The single mistake that breaks more first apps than anything else is serving the widget HTML from your own /widget URL and pointing the tool at that URL. ChatGPT will call your tool, get text back, and render nothing.
The widget is not a web page ChatGPT fetches over HTTP. It's an MCP resource with a ui:// URI, served over the same MCP connection as your tools. The tool announces which resource is its UI through _meta, and the URI in _meta has to be byte-for-byte the URI you registered the resource under. Match the two strings and the widget renders; mismatch them (or hand ChatGPT an https:// URL instead) and you get a silent blank. Per OpenAI's troubleshooting docs, the tool's UI metadata has to point at a registered HTML resource whose URI matches exactly, or the component won't load.
So a minimal app is exactly three pieces on one MCP server:
- A resource at
ui://greeting/widget.htmlwhose content is your HTML. - A tool whose
_metapoints back at that exact URI. - A Streamable HTTP endpoint at
/mcpthat carries both.
Everything below builds that, then connects it to ChatGPT.
A note on the Python path
The Apps SDK is built on MCP, and OpenAI ships official examples in both TypeScript and Python. On the TypeScript side there's a dedicated helper package, @modelcontextprotocol/ext-apps, whose registerAppTool / registerAppResource functions wire the UI metadata for you (MCP Apps build guide). There's no equivalent helper package for Python, but you don't need one: the in-SDK FastMCP lets you attach the UI metadata right on the @mcp.tool() and @mcp.resource() decorators through a meta= argument, and FastMCP copies that straight into the tool's and resource's _meta when it lists them (python-sdk FastMCP.list_tools, v1.29.0). If you'd rather stay in TypeScript, the sibling TypeScript quickstart covers the exact same app with those helpers.
OpenAI's own Python example, pizzaz_server_python, predates that meta= argument, so it reaches under FastMCP to the low-level list_resources and ReadResourceRequest handlers instead. That's useful context if you're reading their code, but for a fresh server the decorators are the shorter path, and that's what this guide uses.
Two things to get right
Two details decide whether the widget renders at all in Python. Get these right up front and the rest is wiring.
- The
_metahas to live on the tool, and you set it with the decorator'smeta=argument.@mcp.tool()takes ameta={...}dict, and FastMCP copies it into the tool's_metawhen ChatGPT lists your tools. That's where the widget binding goes:ui.resourceUriis the standard MCP Apps key, andopenai/outputTemplateis ChatGPT's compatibility alias for it, so setting both keeps the app portable to other MCP Apps hosts (OpenAI reference). Skip themeta=and the tool ships with no widget binding at all, which is why "just add a bare decorator" apps render nothing. - The widget's URI string is a contract. The URI you give the resource and the one in the tool's
_metabinding have to be identical. A trailing slash or awidget.htmvswidget.htmltypo is enough to fall back silently to text. Define it once as a constant and reuse it in both places.
One more thing worth knowing up front: there are two projects called FastMCP. This guide uses the one bundled with the official MCP SDK, imported as from mcp.server.fastmcp import FastMCP. The separate pip install fastmcp package is a different, third-party project (currently 3.4.5), unaffected by anything below. For an Apps SDK server that mirrors OpenAI's example, you want the in-SDK one.
Heads-up on versions: the official SDK's mcp 2.0.0 shipped stable on 2026-07-28 and renamed the in-SDK class from FastMCP to MCPServer (module mcp.server.fastmcp → mcp.server.mcpserver); the old import path is removed, not deprecated, so a plain pip install mcp today gives you MCPServer. The Apps SDK wiring in this guide — the meta= decorator argument and streamable_http_app() — hasn't been re-verified against that release, so the install step below pins mcp<2 (final 1.x: 1.29.0) to keep the FastMCP spelling and the exact snippets used here working. (python-sdk v2 migration)
Prerequisites
- Python 3.10 or higher and the official MCP Python SDK, pinned below
2.0so you get theFastMCPspelling this guide uses (pip install "mcp<2") (modelcontextprotocol/python-sdk). - A way to expose
localhostover HTTPS, since ChatGPT connectors need a public URL. This guide usesngrok;cloudflaredworks too. - ChatGPT with Developer mode available (Settings, then Security and login); availability can depend on your account and workspace policy (OpenAI connect and test). Publishing to everyone comes later, through the plugin directory's submission review (OpenAI submission).
Install
$python -m venv venv$source venv/bin/activate # Windows: venv\Scripts\activate$pip install "mcp<2" uvicorn
The MCP SDK brings the in-SDK FastMCP and the mcp.types classes you'll build tools and resources from. uvicorn runs the ASGI app that FastMCP hands you. The <2 pin keeps you on the FastMCP spelling used throughout this guide — mcp 2.0.0 (current, 2026-07-28) renamed it to MCPServer (see the note above).
The widget HTML
The widget runs in a sandboxed iframe. Inside it, ChatGPT exposes a window.openai object: window.openai.toolOutput holds the structuredContent your tool returned, and an openai:set_globals event fires whenever the host pushes new globals in (OpenAI build/chatgpt-ui).
Because the iframe runs under a deny-by-default CSP, the reliable pattern for a quickstart is self-contained HTML with no external script loads: render with plain DOM, not a CDN import (OpenAI troubleshooting). Once you outgrow this, bundle a real component with a tool like Vite's single-file plugin and serve the single output file as the resource text (MCP Apps build guide).
Create widget.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Greeting</title>
<style>
:root { color-scheme: light dark; }
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
.card { border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
border-radius: 12px; padding: 16px; }
.greeting { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.meta { opacity: 0.6; font-size: 13px; margin: 0; }
</style>
</head>
<body>
<div class="card">
<p class="greeting" id="greeting">Loading…</p>
<p class="meta" id="meta"></p>
</div>
<script type="module">
// window.openai.toolOutput holds the tool's structuredContent.
// It may be undefined on first paint, so also listen for set_globals.
function render(output) {
if (!output) return;
document.getElementById("greeting").textContent = output.greeting;
document.getElementById("meta").textContent =
"Generated " + new Date(output.timestamp).toLocaleTimeString();
}
render(window.openai?.toolOutput);
window.addEventListener("openai:set_globals", (event) => {
render(event.detail?.globals?.toolOutput);
});
</script>
</body>
</html>The render(window.openai?.toolOutput) call plus the openai:set_globals listener is the documented vanilla-JS read pattern. toolOutput can be undefined on the first paint, and the event fires once the host has the data ready (OpenAI build/chatgpt-ui). This HTML is identical whether your server is Python or TypeScript; only the server code below differs.
The server
This is the whole app: one FastMCP server, one resource registered at a ui:// URI, one tool whose _meta points back at that same URI, exposed over Streamable HTTP. Both registrations are plain FastMCP decorators; the meta= argument on each is where the widget binding lives.
Create server.py:
from datetime import datetime, timezone
from pathlib import Path
from mcp.server.fastmcp import FastMCP
# The MCP Apps standard MIME type. ChatGPT still accepts the older
# "text/html+skybridge" alias, which OpenAI's own Python example uses.
MIME_TYPE = "text/html;profile=mcp-app"
# This exact string is the contract: the resource is registered under it,
# and the tool's _meta points at it. They must match byte-for-byte.
WIDGET_URI = "ui://greeting/widget.html"
WIDGET_HTML = (Path(__file__).parent / "widget.html").read_text(encoding="utf-8")
mcp = FastMCP("greeting-app")
# 1. Register the widget as a ui:// MCP resource. The function body returns
# the HTML; the mime_type marks it as a widget. (@mcp.resource also takes a
# meta= dict if you want to attach resource-level hints.)
@mcp.resource(WIDGET_URI, name="Greeting widget", mime_type=MIME_TYPE)
def greeting_widget() -> str:
return WIDGET_HTML
# 2. Register the tool, bound to the resource through meta=. The dict return,
# annotated dict[str, str], becomes structuredContent (window.openai.toolOutput).
@mcp.tool(
name="greet_user",
title="Greet user",
meta={
# Cross-host MCP Apps standard key:
"ui": {"resourceUri": WIDGET_URI},
# ChatGPT compatibility alias + status strings:
"openai/outputTemplate": WIDGET_URI,
"openai/toolInvocation/invoking": "Creating greeting…",
"openai/toolInvocation/invoked": "Greeting ready.",
},
)
def greet_user(name: str) -> dict[str, str]:
"""Render a personalized greeting card. Use this when the user
asks to be greeted or welcomed by name."""
clean = name.strip() or "World"
return {
"greeting": f"Hello, {clean}!",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# 3. Streamable HTTP ASGI app at /mcp.
app = mcp.streamable_http_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run("server:app", host="0.0.0.0", port=8000)Three things in this file do the real work, and they map to the three pieces from the top:
@mcp.resource(WIDGET_URI, mime_type=MIME_TYPE)serves the HTML as aui://resource. The function returns the HTML string, and FastMCP hands ChatGPT exactly that text with the MIME type you set, so the widget arrives over MCP rather than a side HTTP fetch.@mcp.tool(meta={...})registers the tool and its widget binding in one place. Themeta=dict lands in the tool's_meta:ui.resourceUriis the cross-host MCP Apps binding to the resource,openai/outputTemplateis ChatGPT's compatibility alias for the same thing, and theopenai/toolInvocationkeys give ChatGPT the status text to show while the tool runs. The-> dict[str, str]return type is what tells FastMCP to send the dict back asstructuredContent, which becomeswindow.openai.toolOutputin the widget.mcp.streamable_http_app()returns the ASGI app that carries both over Streamable HTTP at/mcp. This is the current in-SDK FastMCP method for the Streamable HTTP app; the oldersse_app()served the deprecated two-endpoint SSE transport.
If you read OpenAI's pizzaz_server_python, you'll see it do the same job the long way: it builds types.Tool objects by hand in a list_tools handler and returns resource text from a ReadResourceRequest handler, because it predates the meta= decorator argument. Same _meta, same result, just more wiring.
There is no app.get("/widget/...") route here, no static HTML file served over HTTP, and no ngrok URL stuffed into outputTemplate: the widget travels over MCP as a resource.
Run it:
$python server.py# Uvicorn running on http://0.0.0.0:8000
The MCP endpoint is http://localhost:8000/mcp.
Connect it to ChatGPT
ChatGPT connectors need a public HTTPS URL, so tunnel your local server:
$ngrok http 8000
Copy the HTTPS forwarding URL (for example https://abc123.ngrok.app). Your MCP endpoint is that URL plus /mcp. You do not edit any outputTemplate. The ui:// URI stays internal; only the connector URL changes.
Developer mode is where you register a local server (OpenAI connect and test). Turn it on, then add the server:
- ChatGPT, then Settings, then Security and login, then turn on Developer mode. Availability here can depend on your account and workspace policy.
- Go to chatgpt.com/plugins, select the plus button, give it a user-facing name and description, and set the MCP server URL to
https://abc123.ngrok.app/mcp,/mcppath included. - Create the connection and review the tools it discovered.
- Start a new conversation, add the connection from the tools menu, and ask: "Greet me as Alex with the greeting app."
ChatGPT shows "Creating greeting…", calls greet_user with { "name": "Alex" }, fetches the ui://greeting/widget.html resource, and renders the card inline: "Hello, Alex!" with a timestamp.
When the widget still won't render
These are the documented failure modes, in the order they actually bite (OpenAI troubleshooting).
Tool runs, but you get text instead of a card
The binding is broken. Two strings have to be identical: the URI you gave the resource and the one in the tool's _meta binding (ui.resourceUri / openai/outputTemplate). This is exactly why WIDGET_URI is one constant used in both places. The other common version of this bug is a bare @mcp.tool() with no meta= at all: the tool ships with no widget binding, so ChatGPT has nothing to render and falls back to text.
Card renders empty
window.openai.toolOutput is undefined when your script reads it on first paint. Make sure you both call render(window.openai?.toolOutput) and subscribe to openai:set_globals; the data often arrives via the event, not the initial global. Also confirm your tool actually returned a structuredContent object, since that's what becomes toolOutput.
Console shows CSP errors
The iframe is deny-by-default. A <script src="https://esm.sh/..."> or a remote stylesheet gets blocked, and your widget stays blank. Inline everything, as the HTML above does, or bundle to a single file and serve that file's contents as the resource text.
ChatGPT answers from general knowledge instead of calling the tool
This is a metadata problem, not a wiring one. Give the tool a specific title and a description in "Use this when…" form so the model knows when it applies. greet_user with "Render a personalized greeting card. Use this when…" is discoverable; process with "Processes input" is not. With the decorator, the description comes straight from the function's docstring, so write that docstring for the model.
Where to go next
You now have the minimum viable ChatGPT app: a tool, a ui:// resource, and the _meta link between them. From here the same pieces scale up. Return richer structuredContent, swap the inline DOM for a bundled widget, and use window.openai.callTool(name, args) to let the widget drive the server (OpenAI reference). The wiring you just built doesn't change; only the contents of the three registrations do. If you want the same app with less boilerplate, the TypeScript quickstart uses the @modelcontextprotocol/ext-apps helpers to collapse the resource and tool registration into two function calls.
Once real ChatGPT users are invoking the app, the useful questions turn operational: which tools actually get called, what arguments come through, and where a call fails before the widget ever renders. That production visibility is what AgentCat adds to an MCP server.
Related Guides
OpenAI Apps SDK TypeScript Quickstart
Build a working ChatGPT app in TypeScript: an MCP server whose tool is bound to a ui:// resource so the widget actually renders inline. Uses McpServer, the MCP Apps extension helpers, and Streamable HTTP, verified against current docs.
Building an MCP server in Python using FastMCP
Build an MCP server in Python with FastMCP, with a clear answer to the question that trips everyone up: the in-SDK MCPServer (called FastMCP before the 2026-07-28 rename) versus the standalone fastmcp package, and which one to install.
Building a Streamable HTTP MCP server
Build a remote MCP server on the Streamable HTTP transport with the official TypeScript SDK, in the shape the 2026-07-28 spec revision requires: one POST-only endpoint, the headers that bite people, OAuth, and scaling out now that sessions are gone.