The Quick Answer
Create a ChatGPT app by building an MCP server that returns structured data and a React component to display it. The MCP server defines tools, and each tool can reference an HTML template for its UI.
Install the Python MCP SDK and create a minimal server with FastAPI:
$pip install "mcp[cli]" fastapi uvicorn
Register a tool with its UI template and handler using the FastMCP module:
from mcp.server.fastmcp import FastMCP
from fastapi import FastAPI
mcp = FastMCP("hello-world-app")
app = FastAPI()
@mcp.tool()
def greet_user(name: str) -> dict:
"""Display a personalized greeting"""
return {
"content": [{"type": "text", "text": f"Greeting {name}"}],
"structuredContent": {"message": f"Hello, {name}!"},
"_meta": {
"openai/outputTemplate": "ui://widget/hello.html",
"openai/toolInvocation/invoking": "Creating greeting…",
"openai/toolInvocation/invoked": "Greeting ready."
}
}
Create hello.html
with a simple React component:
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script type="module">
import React from 'https://esm.sh/react@18';
import ReactDOM from 'https://esm.sh/react-dom@18/client';
const App = () => {
const data = window.openai?.toolOutput?.structuredContent;
return React.createElement('div', null, data?.message || 'Loading...');
};
ReactDOM.createRoot(document.getElementById('root')).render(
React.createElement(App)
);
</script>
</body>
</html>
The toolInvocation
strings provide status feedback to users. ChatGPT displays the invoking
message while your tool executes, then shows invoked
when complete. This gives users immediate feedback without requiring custom loading states in your component.
This architecture separates concerns: the MCP server handles logic and data, while the UI component focuses purely on presentation. By declaring the template in the tool descriptor, ChatGPT knows upfront which UI to render for each tool's output, enabling better optimization and preloading. Python's type hints provide automatic validation for tool parameters, making your server more robust without extra configuration.
Project Setup
Create a new directory for your ChatGPT app and set up the basic structure. Apps SDK projects typically separate the MCP server code from the UI components.
$mkdir hello-chatgpt-app$cd hello-chatgpt-app$mkdir components app$touch app/__init__.py
Initialize your Python project with a virtual environment:
$python -m venv venv$source venv/bin/activate # On Windows: venv\Scripts\activate
Install the required dependencies:
$pip install "mcp[cli]" fastapi uvicorn
Create a requirements.txt
to track dependencies:
$pip freeze > requirements.txt
The components
directory will hold your HTML templates for UI rendering. The MCP server code will live in app/server.py
and define the tools that ChatGPT can call. FastAPI provides the HTTP framework with built-in support for streaming responses needed for the Streamable HTTP transport.
This structure follows the Apps SDK pattern where server logic and UI presentation are cleanly separated. The server focuses on data and business logic, while components handle user interaction and display. Python's FastMCP module simplifies MCP server creation with decorator-based tool registration, making it faster to prototype than manually implementing the protocol.
Understanding the Architecture
Before diving into code, let's understand how ChatGPT apps work. Your application consists of three key pieces:
- MCP Server: Defines tools (actions ChatGPT can perform) and handles their execution
- UI Components: HTML files with React code that render tool results inline in ChatGPT
- HTTP Server: Serves both the MCP protocol endpoint and component files over HTTPS
For browser-based ChatGPT, you'll use Streamable HTTP for the MCP protocol. Streamable HTTP uses standard HTTP POST requests with chunked responses, allowing ChatGPT to connect to your server via a URL and receive incremental updates during long-running operations.
The data flow: ChatGPT → HTTP POST → MCP Server → Tool Handler → Returns structuredContent
via chunked response → ChatGPT fetches component HTML → Renders with window.openai.toolOutput
This clean separation means your server handles business logic while components focus purely on presentation. The _meta["openai/outputTemplate"]
field in your tool's return value links the two together. Python's type hints on your tool functions automatically generate JSON schemas for ChatGPT, eliminating the need to manually define input schemas like in TypeScript implementations.
Create the UI Component
UI components in ChatGPT apps are HTML files with embedded React code. They run in a sandboxed iframe and communicate with ChatGPT through the window.openai
API.
Create components/greeting.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Greeting Widget</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import React from 'https://esm.sh/react@18';
import ReactDOM from 'https://esm.sh/react-dom@18/client';
function GreetingCard() {
const data = window.openai?.toolOutput?.structuredContent;
if (!data) {
return React.createElement('div', null, 'Loading...');
}
return React.createElement('div', null,
React.createElement('p', null, data.greeting),
React.createElement('small', null, `Generated: ${new Date(data.timestamp).toLocaleString()}`)
);
}
ReactDOM.createRoot(document.getElementById('root')).render(
React.createElement(GreetingCard)
);
</script>
</body>
</html>
This component demonstrates the fundamental pattern: read from window.openai.toolOutput
to get the data your server returned in structuredContent
. The component is self-contained, using ESM.sh to load React directly from a CDN without requiring a build step.
ChatGPT injects the window.openai
object before your component loads, providing access to the tool output data. The toolInvocation
status strings you defined in _meta
handle loading states automatically, so your component can focus on displaying the data. More complex components can use window.openai.callTool()
to trigger server actions or window.openai.sendFollowUpMessage()
to continue the conversation. The component HTML remains identical whether your backend is Python or TypeScript—only the server implementation differs.
Create the HTTP Server
For browser-based ChatGPT, you need an HTTP server that handles both the MCP protocol and serves component files.
Create app/server.py
for the main application:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from mcp.server.fastmcp import FastMCP
from datetime import datetime
import os
# Create FastMCP instance and register tools
mcp = FastMCP("hello-world-app")
@mcp.tool()
def greet_user(name: str) -> dict:
"""Display a personalized greeting"""
return {
"content": [
{"type": "text", "text": f"Greeting {name}"}
],
"structuredContent": {
"greeting": f"Hello, {name}!",
"timestamp": datetime.now().isoformat(),
},
"_meta": {
"openai/outputTemplate": "ui://widget/greeting.html",
"openai/toolInvocation/invoking": "Creating greeting…",
"openai/toolInvocation/invoked": "Greeting ready.",
}
}
# Create FastAPI app
app = FastAPI()
# Enable CORS for ChatGPT
app.add_middleware(
CORSMiddleware,
allow_origins=["https://chatgpt.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount FastMCP's built-in Streamable HTTP server
app.mount("/mcp", mcp.get_asgi_app())
# Serve component files with Skybridge MIME type
@app.get("/widget/{component_name}")
async def serve_component(component_name: str):
component_path = os.path.join("components", component_name)
if not os.path.exists(component_path):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Component not found"}, status_code=404)
return FileResponse(
component_path,
media_type="text/html+skybridge"
)
# Health check endpoint
@app.get("/health")
def health_check():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
This unified server handles both MCP protocol requests and component serving. ChatGPT will connect to your /mcp
endpoint to discover and call tools, while component files are served from /widget/:name
.
The text/html+skybridge
MIME type is required for ChatGPT to properly recognize app UI components. These HTML files are called "Skybridge" templates in OpenAI's documentation. While plain text/html
might work during development, the correct MIME type is essential for production and will be validated by tools like fast-agent.
The CORS configuration is critical for security. Only allow https://chatgpt.com
to fetch your components and access your MCP endpoint. In production, you'll use HTTPS for your server and may need additional security measures like authentication tokens.
★ Insight ─────────────────────────────────────
Why MCP Moved from SSE to Streamable HTTP:
- Simpler architecture: SSE required two separate endpoints (GET for SSE stream, POST for client messages), creating artificial complexity. Streamable HTTP uses a single POST endpoint for bidirectional communication.
- Better infrastructure compatibility: SSE's long-lived connections don't work well with load balancers, proxies, and serverless environments. Streamable HTTP uses standard HTTP semantics that work everywhere.
- Improved reliability: With SSE, if the connection dropped mid-operation, state could be lost. Streamable HTTP's request/response model makes operations atomic and easier to retry.
─────────────────────────────────────────────────
For local development, use ngrok to expose your server:
$ngrok http 8000
Copy the HTTPS URL provided by ngrok—you'll need it to connect ChatGPT to your server.
Connect to ChatGPT
With your server running, connect it to ChatGPT using Developer Mode. This enables ChatGPT to discover your tools and render your components.
Start your development server:
$python app/server.py
Expose your local server via ngrok:
$ngrok http 8000
Copy the HTTPS URL (e.g., https://abc123.ngrok.io
). Update your tool registration in app/server.py
to use this URL for component templates:
"_meta": {
"openai/outputTemplate": "https://abc123.ngrok.io/widget/greeting.html"
}
Restart your server after updating the URL.
Enable Developer Mode in ChatGPT:
- Open ChatGPT settings (browser version at chatgpt.com)
- Navigate to Settings → Features → Developer mode
- Toggle Developer mode on
Create a new connector:
- In the ChatGPT interface, go to Connectors
- Click Create connector
- Enter your connector details:
- Name: Hello World App
- Connector URL:
https://abc123.ngrok.io/mcp
(your ngrok URL +/mcp
)
- Click Save
If successful, you'll see "greet_user" appear in the connector's tool list.
Test your app by starting a new conversation and asking: "Use the Hello World App to greet me as Alex"
ChatGPT will show "Creating greeting…" while the tool executes, then "Greeting ready." when complete. It will call your greet_user
tool with {"name": "Alex"}
and render your greeting component inline showing "Hello, Alex!" with a timestamp.
Common Issues
Understanding why things break helps you debug faster. Here are the three most common issues when building your first ChatGPT app and their root causes.
Component Won't Render
Symptom: ChatGPT calls your tool but shows text instead of your UI component.
This usually happens because ChatGPT can't fetch your component file. The component URL in _meta["openai/outputTemplate"]
must be publicly accessible and return valid HTML with proper CORS headers and the correct MIME type. Check that your serve_component
function sets media_type="text/html+skybridge"
and that your CORS middleware allows https://chatgpt.com
. Verify the URL is reachable by opening it in your browser—you should see the HTML file content.
In Python, ensure you're using FileResponse
from FastAPI with the correct media_type
parameter. A common mistake is forgetting to add CORS headers to the component endpoint, which causes the browser to block the request even though your server responds correctly.
Tool Not Being Called
Symptom: ChatGPT responds with general knowledge instead of calling your tool.
The model decides whether to use your tool based on the function name and docstring. Generic names like "process" or vague docstrings make it hard for ChatGPT to know when your tool is relevant. Python's docstring becomes the tool's description, so write it clearly:
@mcp.tool()
def greet_user(name: str) -> dict:
"""Display a personalized greeting card for the user""" # Specific
# vs
"""Processes input""" # Too vague
The function name and first line of the docstring are critical for tool selection. Write docstrings as if you're explaining to another developer when this tool should be used. Include keywords that match likely user queries. Python's type hints (like name: str
) automatically generate the input schema, so ensure they're accurate—incorrect type hints can cause validation errors when ChatGPT tries to call your tool.
Related Guides
OpenAI Apps SDK TypeScript Quickstart
Build your first ChatGPT app in TypeScript with this step-by-step guide covering MCP server setup and inline UI components.
Error handling in custom MCP servers
Implement robust error handling in MCP servers with proper error codes, logging, and recovery strategies.
Building a serverless MCP server
Deploy MCP servers on serverless platforms like AWS Lambda, Vercel, and Cloudflare Workers with StreamableHTTP.