Skip to main content

Overview

FastApps is a Python framework that eliminates the complexity of building Apps in ChatGPT. It handles all the MCP protocol boilerplate, auto-discovery, and build configuration so you can focus on writing your widget logic and UI.

The Problem

OpenAI’s Apps SDK lets you build apps for ChatGPT. But the manual setup is extensive:

Raw Apps SDK (The problem)

from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Dict, List

import mcp.types as types
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, ConfigDict, Field, ValidationError


@dataclass(frozen=True)
class PizzazWidget:
    identifier: str
    title: str
    template_uri: str
    invoking: str
    invoked: str
    html: str
    response_text: str


widgets: List[PizzazWidget] = [
    PizzazWidget(
        identifier="pizza-map",
        title="Show Pizza Map",
        template_uri="ui://widget/pizza-map.html",
        invoking="Hand-tossing a map",
        invoked="Served a fresh map",
        html=(
            "<div id=\"pizzaz-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-0038.css\">\n"
            "<script type=\"module\" src=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-0038.js\"></script>"
        ),
        response_text="Rendered a pizza map!",
    ),
    PizzazWidget(
        identifier="pizza-carousel",
        title="Show Pizza Carousel",
        template_uri="ui://widget/pizza-carousel.html",
        invoking="Carousel some spots",
        invoked="Served a fresh carousel",
        html=(
            "<div id=\"pizzaz-carousel-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-carousel-0038.css\">\n"
            "<script type=\"module\" src=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-carousel-0038.js\"></script>"
        ),
        response_text="Rendered a pizza carousel!",
    ),
    PizzazWidget(
        identifier="pizza-albums",
        title="Show Pizza Album",
        template_uri="ui://widget/pizza-albums.html",
        invoking="Hand-tossing an album",
        invoked="Served a fresh album",
        html=(
            "<div id=\"pizzaz-albums-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-albums-0038.css\">\n"
            "<script type=\"module\" src=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-albums-0038.js\"></script>"
        ),
        response_text="Rendered a pizza album!",
    ),
    PizzazWidget(
        identifier="pizza-list",
        title="Show Pizza List",
        template_uri="ui://widget/pizza-list.html",
        invoking="Hand-tossing a list",
        invoked="Served a fresh list",
        html=(
            "<div id=\"pizzaz-list-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-list-0038.css\">\n"
            "<script type=\"module\" src=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-list-0038.js\"></script>"
        ),
        response_text="Rendered a pizza list!",
    ),
    PizzazWidget(
        identifier="pizza-video",
        title="Show Pizza Video",
        template_uri="ui://widget/pizza-video.html",
        invoking="Hand-tossing a video",
        invoked="Served a fresh video",
        html=(
            "<div id=\"pizzaz-video-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-video-0038.css\">\n"
            "<script type=\"module\" src=\"https://persistent.oaistatic.com/"
            "ecosystem-built-assets/pizzaz-video-0038.js\"></script>"
        ),
        response_text="Rendered a pizza video!",
    ),
]


MIME_TYPE = "text/html+skybridge"


WIDGETS_BY_ID: Dict[str, PizzazWidget] = {widget.identifier: widget for widget in widgets}
WIDGETS_BY_URI: Dict[str, PizzazWidget] = {widget.template_uri: widget for widget in widgets}


class PizzaInput(BaseModel):
    """Schema for pizza tools."""

    pizza_topping: str = Field(
        ...,
        alias="pizzaTopping",
        description="Topping to mention when rendering the widget.",
    )

    model_config = ConfigDict(populate_by_name=True, extra="forbid")


mcp = FastMCP(
    name="pizzaz-python",
    stateless_http=True,
)


TOOL_INPUT_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "pizzaTopping": {
            "type": "string",
            "description": "Topping to mention when rendering the widget.",
        }
    },
    "required": ["pizzaTopping"],
    "additionalProperties": False,
}


def _resource_description(widget: PizzazWidget) -> str:
    return f"{widget.title} widget markup"


def _tool_meta(widget: PizzazWidget) -> Dict[str, Any]:
    return {
        "openai/outputTemplate": widget.template_uri,
        "openai/toolInvocation/invoking": widget.invoking,
        "openai/toolInvocation/invoked": widget.invoked,
        "openai/widgetAccessible": True,
        "openai/resultCanProduceWidget": True,
        "annotations": {
          "destructiveHint": False,
          "openWorldHint": False,
          "readOnlyHint": True,
        }
    }


def _embedded_widget_resource(widget: PizzazWidget) -> types.EmbeddedResource:
    return types.EmbeddedResource(
        type="resource",
        resource=types.TextResourceContents(
            uri=widget.template_uri,
            mimeType=MIME_TYPE,
            text=widget.html,
            title=widget.title,
        ),
    )


@mcp._mcp_server.list_tools()
async def _list_tools() -> List[types.Tool]:
    return [
        types.Tool(
            name=widget.identifier,
            title=widget.title,
            description=widget.title,
            inputSchema=deepcopy(TOOL_INPUT_SCHEMA),
            _meta=_tool_meta(widget),
        )
        for widget in widgets
    ]


@mcp._mcp_server.list_resources()
async def _list_resources() -> List[types.Resource]:
    return [
        types.Resource(
            name=widget.title,
            title=widget.title,
            uri=widget.template_uri,
            description=_resource_description(widget),
            mimeType=MIME_TYPE,
            _meta=_tool_meta(widget),
        )
        for widget in widgets
    ]


@mcp._mcp_server.list_resource_templates()
async def _list_resource_templates() -> List[types.ResourceTemplate]:
    return [
        types.ResourceTemplate(
            name=widget.title,
            title=widget.title,
            uriTemplate=widget.template_uri,
            description=_resource_description(widget),
            mimeType=MIME_TYPE,
            _meta=_tool_meta(widget),
        )
        for widget in widgets
    ]


async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult:
    widget = WIDGETS_BY_URI.get(str(req.params.uri))
    if widget is None:
        return types.ServerResult(
            types.ReadResourceResult(
                contents=[],
                _meta={"error": f"Unknown resource: {req.params.uri}"},
            )
        )

    contents = [
        types.TextResourceContents(
            uri=widget.template_uri,
            mimeType=MIME_TYPE,
            text=widget.html,
            _meta=_tool_meta(widget),
        )
    ]

    return types.ServerResult(types.ReadResourceResult(contents=contents))


async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult:
    widget = WIDGETS_BY_ID.get(req.params.name)
    if widget is None:
        return types.ServerResult(
            types.CallToolResult(
                content=[
                    types.TextContent(
                        type="text",
                        text=f"Unknown tool: {req.params.name}",
                    )
                ],
                isError=True,
            )
        )

    arguments = req.params.arguments or {}
    try:
        payload = PizzaInput.model_validate(arguments)
    except ValidationError as exc:
        return types.ServerResult(
            types.CallToolResult(
                content=[
                    types.TextContent(
                        type="text",
                        text=f"Input validation error: {exc.errors()}",
                    )
                ],
                isError=True,
            )
        )

    topping = payload.pizza_topping
    widget_resource = _embedded_widget_resource(widget)
    meta: Dict[str, Any] = {
        "openai.com/widget": widget_resource.model_dump(mode="json"),
        "openai/outputTemplate": widget.template_uri,
        "openai/toolInvocation/invoking": widget.invoking,
        "openai/toolInvocation/invoked": widget.invoked,
        "openai/widgetAccessible": True,
        "openai/resultCanProduceWidget": True,
    }

    return types.ServerResult(
        types.CallToolResult(
            content=[
                types.TextContent(
                    type="text",
                    text=widget.response_text,
                )
            ],
            structuredContent={"pizzaTopping": topping},
            _meta=meta,
        )
    )


mcp._mcp_server.request_handlers[types.CallToolRequest] = _call_tool_request
mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource


app = mcp.streamable_http_app()

try:
    from starlette.middleware.cors import CORSMiddleware

    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_methods=["*"],
        allow_headers=["*"],
        allow_credentials=False,
    )
except Exception:
    pass


if __name__ == "__main__":
    import uvicorn

    uvicorn.run("main:app", host="0.0.0.0", port=8000)
That’s a lot of boilerplate for every widget you build.

The Solution

With FastApps, you write just 2 files:

MCP Tool (widget registeration)

from fastapps import BaseWidget, ConfigDict
from pydantic import BaseModel
from typing import Dict, Any


class MyWidgetInput(BaseModel):
    model_config = ConfigDict(populate_by_name=True)


class MyWidgetTool(BaseWidget):
    identifier = "my_widget"
    title = "My Widget"
    input_schema = MyWidgetInput
    invoking = "Loading widget..."
    invoked = "Widget ready!"
    
    widget_csp = {
        "connect_domains": [],
        "resource_domains": []
    }
    
    async def execute(self, input_data: MyWidgetInput) -> Dict[str, Any]:
        return {
            "message": "Welcome to FastApps"
        }

React Component (UI of the widget)

import React from 'react';
import { useWidgetProps } from 'fastapps';

export default function MyWidget() {
  const props = useWidgetProps();
  return <h1>{props.message}</h1>;
}
That’s it! FastApps automatically handles all the boilerplate:
  • ✅ MCP server setup and protocol implementation
  • ✅ Tool and resource registration with proper mime types
  • ✅ Metadata wiring and CSP configuration
  • ✅ Asset building with Vite and component mounting
  • ✅ Auto-discovery from your project structure

Architecture

┌─────────────────────────────────────────┐
│           ChatGPT Interface             │
└─────────────────┬───────────────────────┘
                  │ MCP Protocol

┌─────────────────────────────────────────┐
│         FastApps Framework                │
├─────────────────────────────────────────┤
│  Python Backend (Your Tool)             │
│  ├── Input validation (Pydantic)        │
│  ├── Business logic                     │
│  └── Data preparation                   │
└─────────────────┬───────────────────────┘
                  │ Props

┌─────────────────────────────────────────┐
│  React Frontend (Your Component)        │
│  ├── useWidgetProps() - Get data        │
│  ├── useWidgetState() - Manage state    │
│  └── Render UI                          │
└─────────────────────────────────────────┘

How It Works

Structure

Every widget has two parts:
  • MCP Tool - Backend logic with Pydantic validation
  • React Component - Frontend UI with type-safe props

Auto-Discovery

Drop a file in server/tools/ and FastApps automatically:
  1. Discovers all BaseWidget subclasses
  2. Registers them as MCP tools with proper metadata
  3. Creates HTML resources and wires everything together
  4. Links to React components in widgets/ folder

Workflow

fastapps create mywidget     # It create the files of tool + component
# Edit server/tools/mywidget_tool.py
# Edit widgets/mywidget/index.jsx
npm run build && python server/main.py

Comparison

AspectRaw Apps SDKFastApps
SetupManual server config, protocol handlersAuto-configured
RegistrationManual tools + resources + metadataAuto-discovered
Asset BundlingCustom build scriptsBuilt-in Vite
Files per Widget5+ files2 files
Boilerplate Lines~150+ lines~0 lines
Convention over Configuration - Zero config files, predictable structure, clear validation errors

Requirements

  • Python 3.11+
  • Node.js 18+

“You should write your widget logic and UI, not MCP boilerplate.”
Ready to get started?Quick Start Guide
I