OpenAI Apps SDK Python Quickstart

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

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:

  1. A resource at ui://greeting/widget.html whose content is your HTML.
  2. A tool whose _meta points back at that exact URI.
  3. A Streamable HTTP endpoint at /mcp that 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 _meta has to live on the tool, and you set it with the decorator's meta= argument. @mcp.tool() takes a meta={...} dict, and FastMCP copies it into the tool's _meta when ChatGPT lists your tools. That's where the widget binding goes: ui.resourceUri is the standard MCP Apps key, and openai/outputTemplate is ChatGPT's compatibility alias for it, so setting both keeps the app portable to other MCP Apps hosts (OpenAI reference). Skip the meta= 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 _meta binding have to be identical. A trailing slash or a widget.htm vs widget.html typo 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.fastmcpmcp.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.0 so you get the FastMCP spelling this guide uses (pip install "mcp<2") (modelcontextprotocol/python-sdk).
  • A way to expose localhost over HTTPS, since ChatGPT connectors need a public URL. This guide uses ngrok; cloudflared works 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 a ui:// 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. The meta= dict lands in the tool's _meta: ui.resourceUri is the cross-host MCP Apps binding to the resource, openai/outputTemplate is ChatGPT's compatibility alias for the same thing, and the openai/toolInvocation keys 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 as structuredContent, which becomes window.openai.toolOutput in 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 older sse_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:

  1. ChatGPT, then Settings, then Security and login, then turn on Developer mode. Availability here can depend on your account and workspace policy.
  2. 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, /mcp path included.
  3. Create the connection and review the tools it discovered.
  4. 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.