The Backend: Serving Resources

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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"
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>"

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"]
        }
    }

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
        )

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

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))
mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource

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,
        )

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(),
        ),
    ]

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)

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.
See forum comments
Download course materials from Github
Previous: Introduction to ChatGPT Apps Next: The Frontend: The OpenAI Bridge