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 build and 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/invoking and invoked: 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_domains allows API calls (e.g., to ChatGPT); resource_domains permits 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_meta for flexibility, but often you’ll use the shared function. Per SDK, _meta is 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: name and title are for display; uri matches the tool metadata; description helps the model understand the resource (surfaced via openai/widgetDescription if 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 _meta for 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 like readOnlyHint: True for list_notes to 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 /dashboard endpoint 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

  1. ChatGPT queries list_tools and sees metadata like openai/outputTemplate.
  2. It invokes a tool, receiving structuredContent + _meta.
  3. It requests the resource via an MCP ReadResourceRequest.
  4. 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.

See forum comments
Download course materials from Github
Previous: Introduction to ChatGPT Apps Next: The Frontend: The OpenAI Bridge