The Backend: Serving Resources
All source code for the backend is written in 05-building-a-chatgpt-app/note-taking-app/backend/server.py in the module’s materials repo. Navigate to the file in your editor of choice. The backend is a standard FastMCP server with extensions specific to the OpenAI Apps SDK. The primary goal is to serve the widget HTML as an MCP resource and configure tools with metadata that instructs ChatGPT to render the UI instead of just text responses.
Before diving in, note that this project ships with a prebuilt dist/index.html, so you can run the backend immediately. You only need to run npm run build in the frontend directory if you change React code and want to regenerate the bundle. If you encounter issues, check that your Python environment is set up with FastMCP installed via uv sync.
Defining the Widget URI and Loading HTML
You start by defining a unique URI for the widget and a function to load its HTML content. The URI uses the “ui://” scheme, a custom protocol in MCP for identifying resources like widgets.
WIDGET_URI = "ui://widget/notes.html"
MIME_TYPE = "text/html+skybridge"
- Why “ui://”? This scheme signals to ChatGPT that the resource is a UI component, distinct from standard HTTP URIs.
- MIME_TYPE: The “text/html+skybridge” type indicates an HTML widget compatible with the OpenAI bridge (window.openai). Without this, ChatGPT might treat it as plain text.
The _load_widget_html function reads the compiled React app from disk:
def _load_widget_html() -> str:
"""Load the React notes widget HTML."""
try:
current_dir = Path(__file__).resolve().parent
react_build_path = current_dir / ".." / "frontend" / "dist" / "index.html"
if react_build_path.exists():
return react_build_path.read_text(encoding="utf-8")
return "<h1>Error: Widget not found. Please run 'npm run build' in the frontend directory.</h1>"
except Exception as e:
return f"<h1>Error loading widget: {str(e)}</h1>"
- How it works: It loads the bundled HTML, which includes inlined JS and CSS from Vite. This ensures the widget is self-contained and loads quickly in ChatGPT’s iframe.
-
Tip: If you modify the React code, re-run
npm run buildand restart the server to update the HTML. For production, consider minification or caching.
Configuring Tool Metadata with _tool_meta
The Apps SDK relies on metadata in tool descriptors to connect tool outputs to UI rendering. This is defined in _tool_meta, which you attach to each tool.
def _tool_meta() -> Dict[str, Any]:
return {
"openai/outputTemplate": WIDGET_URI,
"openai/toolInvocation/invoking": "Loading notes...",
"openai/toolInvocation/invoked": "Notes loaded",
"openai/widgetAccessible": True,
"openai/widgetPrefersBorder": True,
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": {
"connect_domains": ["https://chatgpt.com"],
"resource_domains": ["https://*.oaistatic.com"]
}
}
Let’s break this down based on the OpenAI Apps SDK reference:
-
openai/outputTemplate: Specifies the URI of the HTML resource to render when the tool completes. Without this, ChatGPT falls back to text-only output. -
openai/toolInvocation/invokingandinvoked: User-facing status messages (≤64 characters each) shown during and after tool execution. Customize these for better UX—e.g., to reflect loading times for database queries. -
openai/widgetAccessible: Enables the widget to call other MCP tools via the client bridge (window.openai.callTool). Set to True for interactive apps like yours. -
openai/widgetPrefersBorder: Hints to ChatGPT to render the widget in a bordered card for visual separation. -
openai/widgetDomain: The origin for hosting the widget. Defaults to a sandbox, but for submitted apps, use a unique domain per app. -
openai/widgetCSP: Content Security Policy allowlists to prevent XSS.connect_domainsallows API calls (e.g., to ChatGPT);resource_domainspermits asset loading (e.g., OpenAI static files). If your widget needs external resources (like custom fonts), add domains here and test for CSP violations in the browser console.
These fields ensure secure, integrated rendering. For more on CSP, see the SDK’s security guidelines—always start with minimal domains and expand as needed.
Tools That Return UI Data
Tools like list_notes and create_note are decorated with @mcp.tool(). The key is their return type: a CallToolResult with fields optimized for both text fallback and UI.
@mcp.tool()
def list_notes() -> types.CallToolResult:
"""List all notes metadata (id, title)."""
try:
structured_notes = _fetch_note_summaries()
message = f"Found {len(structured_notes)} notes" if structured_notes else "No notes found"
result = types.CallToolResult(
content=[types.TextContent(
type="text",
text=message
)],
structuredContent={"notes": structured_notes, "count": len(structured_notes)},
_meta={"openai/outputTemplate": WIDGET_URI, "openai/widgetAccessible": True}
)
return result
except Exception as e:
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Error retrieving notes: {str(e)}")],
isError=True
)
-
content: An array of TextContent for human-readable fallback (e.g., if UI rendering fails or for non-visual clients). -
structuredContent: JSON data injected into the widget via window.openai.toolOutput. Keep it concise—the model and widget read it verbatim. Here, you send note summaries to avoid overloading with full content. -
_meta: Attaches rendering hints. This overrides the full_tool_metafor flexibility, but often you’ll use the shared function. Per SDK,_metais hidden from the model but delivered to the widget.
For create_note, follow a similar pattern: mutate the database, then return updated structuredContent to refresh the UI.
- Why structuredContent? It allows the widget to render dynamically without direct backend access, promoting separation of concerns.
- Optimization Tip: For large datasets, paginate in structuredContent and add UI controls for loading more.
Listing the Widget Resource
Use the @mcp._mcp_server.list_resources() decorator to advertise available resources. ChatGPT queries this to discover widgets.
@mcp._mcp_server.list_resources()
async def _list_resources() -> List[types.Resource]:
resources = [
types.Resource(
name="Notes Widget",
title="AI Notes Dashboard",
uri=WIDGET_URI,
description="Interactive notes management widget built with React",
mimeType=MIME_TYPE,
_meta={
"openai/widgetPrefersBorder": True,
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": {
"connect_domains": ["https://chatgpt.com"],
"resource_domains": ["https://*.oaistatic.com"]
}
},
)
]
return resources
-
Fields Breakdown:
nameandtitleare for display;urimatches the tool metadata;descriptionhelps the model understand the resource (surfaced viaopenai/widgetDescriptionif set). -
_meta: Mirrors tool metadata for consistency. This ensures CSP and preferences apply when ChatGPT requests the resource.
Serving the Widget HTML
ChatGPT fetches resources via MCP ReadResourceRequest messages (not a Python function). Register a handler to serve the HTML:
async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult:
if str(req.params.uri) != WIDGET_URI:
return types.ServerResult(
types.ReadResourceResult(
contents=[],
_meta={"error": f"Unknown resource: {req.params.uri}"},
)
)
html_content = _load_widget_html()
contents = [
types.TextResourceContents(
uri=WIDGET_URI,
mimeType=MIME_TYPE,
text=html_content,
_meta={
"openai/widgetPrefersBorder": True,
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": {
"connect_domains": ["https://chatgpt.com"],
"resource_domains": ["https://*.oaistatic.com"]
}
},
)
]
return types.ServerResult(types.ReadResourceResult(contents=contents))
Register the handler with the MCP server:
mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource
-
Handler Logic: Check the URI, load HTML, and wrap in TextResourceContents. Include
_metafor runtime hints. - Error Handling: Return a meaningful _meta error to aid debugging—check server logs if ChatGPT reports “Unknown resource.”
- Why Async? MCP uses async for scalability; this allows handling concurrent requests efficiently.
The open_dashboard Tool
This is a lightweight “trigger” tool to open the widget without data processing:
@mcp.tool()
async def open_dashboard() -> types.CallToolResult:
"""Open the interactive notes dashboard widget."""
try:
notes = database.execute_query("SELECT id, title, content, created_at FROM notes")
notes_count = len(notes)
meta = _tool_meta()
meta["openai/toolInvocation/invoking"] = "Opening dashboard"
meta["openai/toolInvocation/invoked"] = "Opened dashboard"
return types.CallToolResult(
content=[
types.TextContent(
type="text",
text=(
"Your Notes Dashboard is available."
),
)
],
_meta=meta,
)
except Exception as e:
error_msg = f"Error opening dashboard: {str(e)}"
return types.CallToolResult(
content=[types.TextContent(type="text", text=error_msg)],
isError=True,
)
- Purpose: It returns minimal content but uses metadata to force widget rendering. Ideal for entry points like “Open app.”
- Customization: Override invocation messages for context-specific feedback.
Listing Tools for ChatGPT
The @mcp._mcp_server.list_tools() decorator provides the tool catalog:
@mcp._mcp_server.list_tools()
async def _list_tools() -> List[types.Tool]:
return [
types.Tool(
name="create_note",
title="Create Note",
description="Create a new note with a title and content.",
inputSchema={
"type": "object",
"properties": {
"title": {"type": "string"},
"content": {"type": "string"},
},
"required": ["title", "content"],
"additionalProperties": False,
},
_meta=_tool_meta(),
),
types.Tool(
name="list_notes",
title="List Notes",
description="List all notes.",
inputSchema={
"type": "object",
"properties": {},
"additionalProperties": False,
},
_meta=_tool_meta(),
),
types.Tool(
name="open_dashboard",
title="Open Notes Dashboard",
description="Open the interactive notes dashboard widget.",
inputSchema={
"type": "object",
"properties": {},
"additionalProperties": False,
},
_meta=_tool_meta(),
),
]
-
Attaching Metadata: Each tool gets
_tool_meta()for UI triggering. Per SDK, you can add hints likereadOnlyHint: Trueforlist_notesto inform the model it’s non-destructive. - Schema Best Practices: Keep inputSchema strict to validate user inputs; this prevents errors in tool calls.
Why Include Starlette?
FastMCP provides the core MCP server, but you wrap it in Starlette for additional HTTP routes:
app = mcp.streamable_http_app()
dashboard_routes = [
Route("/dashboard", dashboard_page),
]
for route in dashboard_routes:
app.routes.append(route)
-
Benefit: This adds a
/dashboardendpoint for browser-based testing (e.g., http://localhost:8000/dashboard previews the widget). MCP handles /mcp paths separately. - When to Use: Great for local dev; in production, secure with auth.
Putting It All Together
- ChatGPT queries
list_toolsand sees metadata likeopenai/outputTemplate. - It invokes a tool, receiving
structuredContent+_meta. - It requests the resource via an MCP
ReadResourceRequest. - The server serves HTML, and ChatGPT renders it in an iframe, injecting data.
This setup transforms your MCP server into a UI-capable backend. Next, you’ll build the frontend to consume this data.