Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The MCP servers in this demo highlight how each tool can light up widgets by com

- `src/` – Source for each widget example.
- `assets/` – Generated HTML, JS, and CSS bundles after running the build step.
- `shopping_cart_python/` – Python MCP server that demonstrates how `_meta["widgetSessionId"]` keeps `widgetState` in sync across turns for a shopping-cart widget.
- `pizzaz_server_node/` – MCP server implemented with the official TypeScript SDK.
- `pizzaz_server_python/` – Python MCP server that returns the Pizzaz widgets.
- `solar-system_server_python/` – Python MCP server for the 3D solar system widget.
Expand Down Expand Up @@ -118,6 +119,19 @@ uvicorn solar-system_server_python.main:app --port 8000

You can reuse the same virtual environment for all Python servers—install the dependencies once and run whichever entry point you need.

### Shopping cart Python server

Use this example to learn how `_meta["widgetSessionId"]` can carry `widgetState` between tool calls so the model and widget share the same shopping cart. The widget merges tool responses with prior `widgetState`, and UI actions (like incrementing quantities) feed back into that shared state so the assistant always sees the latest cart.

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r pizzaz_server_python/requirements.txt
python shopping_cart_python/main.py
```

In production you should persist the cart server-side (see `shopping_cart_python/README.md`), but this demo shows the mechanics of threading cart state through `widgetSessionId`.

## Testing in ChatGPT

To add these apps to ChatGPT, enable [developer mode](https://platform.openai.com/docs/guides/developer-mode), and add your apps in Settings > Connectors.
Expand Down
1 change: 1 addition & 0 deletions build-all.mts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const targets: string[] = [
"pizzaz-list",
"pizzaz-albums",
"pizzaz-shop",
"shopping-cart",
];
const builtNames: string[] = [];

Expand Down
51 changes: 51 additions & 0 deletions shopping_cart_python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Shopping cart MCP server (Python)

This example shows how to thread shopping-cart state across conversation turns by pairing `_meta["widgetSessionId"]` with `window.openai.widgetState`. The Python server ships a simple `add_to_cart` tool plus a widget that stays in sync even when the user adjusts quantities in the UI between turns.

## Prerequisites

- Node.js 18+ with the repo dependencies installed (`pnpm install`)
- A built and served asset bundle (`pnpm run build` then `pnpm run serve` from the repo root)
- Python 3.10+ and a virtual environment (recommended)

## Installation

Use the same dependencies as the other FastMCP Python examples:

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r shopping_cart_python/requirements.txt
```

## Run the server

In one shell, serve the static assets from the repo root:

```bash
pnpm run serve
```

In another shell, start the shopping-cart MCP server:

```bash
uvicorn main:app --host 0.0.0.0 --port 8000
```

The server exposes `GET /mcp` for SSE and `POST /mcp/messages?sessionId=...` for follow-up messages, mirroring the other FastMCP examples.

## How the state flow works

- Every `call_tool` response sets `_meta["widgetSessionId"]` to the cart identifier and returns a `structuredContent` payload containing the new cart items.
- The widget reads `window.openai.widgetState`, merges in the latest `toolOutput.items`, and writes the combined snapshot back to `window.openai.widgetState`. UI interactions (increment/decrement) also update that shared state so the next turn sees the changes.
- Because the host keeps `widgetState` keyed by `widgetSessionId`, subsequent tool calls for the same session automatically receive the prior cart state, letting the model and UI stay aligned without extra plumbing.

## Recommended production pattern

This demo leans on `window.openai.widgetState` to illustrate the mechanics. In production, keep the cart in your MCP server (or a backing datastore) instead of relying on client-side state:

- On each `add_to_cart` (or similar) tool call, load the cart from your datastore using the session/cart ID, apply the incoming items, persist the new snapshot, and return it along with `_meta["widgetSessionId"]`.
- From the widget, treat the datastore as the source of truth: every UX interaction (like incrementing quantities) should invoke your backend—either via another MCP tool call or a direct HTTP request—to mutate and re-read the cart.
- Continue setting `_meta["widgetSessionId"]` so the host and widget stay locked to the same cart across turns, while the datastore ensures durability and multi-device correctness.

A lightweight in-memory store works for local testing; swap in a persistent datastore when you move beyond the demo.
222 changes: 222 additions & 0 deletions shopping_cart_python/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""Simple ecommerce MCP server exposing the shopping cart widget."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, List
from uuid import uuid4

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

TOOL_NAME = "add_to_cart"
WIDGET_TEMPLATE_URI = "ui://widget/shopping-cart.html"
WIDGET_TITLE = "Start shopping cart"
WIDGET_INVOKING = "Preparing shopping cart"
WIDGET_INVOKED = "Shopping cart ready"
MIME_TYPE = "text/html+skybridge"
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"


def _load_widget_html() -> str:
html_path = ASSETS_DIR / "shopping-cart.html"
if html_path.exists():
return html_path.read_text(encoding="utf8")

fallback = sorted(ASSETS_DIR.glob("shopping-cart-*.html"))
if fallback:
return fallback[-1].read_text(encoding="utf8")

raise FileNotFoundError(
f'Widget HTML for "shopping-cart" not found in {ASSETS_DIR}. '
"Run `pnpm run build` to generate the assets before starting the server."
)


SHOPPING_CART_HTML = _load_widget_html()


class CartItem(BaseModel):
"""Represents an item being added to a cart."""

name: str = Field(..., description="Name of the item to show in the cart.")
quantity: int = Field(
default=1,
ge=1,
description="How many units to add to the cart (must be positive).",
)

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


class AddToCartInput(BaseModel):
"""Payload for the add_to_cart tool."""

items: List[CartItem] = Field(
...,
description="List of items to add to the active cart.",
)
cart_id: str | None = Field(
default=None,
alias="cartId",
description="Existing cart identifier. Leave blank to start a new cart.",
)

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


TOOL_INPUT_SCHEMA = AddToCartInput.model_json_schema(by_alias=True)

carts: Dict[str, List[Dict[str, Any]]] = {}

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


def _serialize_item(item: CartItem) -> Dict[str, Any]:
"""Return a JSON serializable dict including any custom fields."""
return item.model_dump(by_alias=True)


def _get_or_create_cart(cart_id: str | None) -> str:
if cart_id and cart_id in carts:
return cart_id

new_id = cart_id or uuid4().hex
carts.setdefault(new_id, [])
return new_id


def _widget_meta() -> Dict[str, Any]:
return {
"openai/outputTemplate": WIDGET_TEMPLATE_URI,
"openai/toolInvocation/invoking": WIDGET_INVOKING,
"openai/toolInvocation/invoked": WIDGET_INVOKED,
"openai/widgetAccessible": True,
}


@mcp._mcp_server.list_tools()
async def _list_tools() -> List[types.Tool]:
return [
types.Tool(
name=TOOL_NAME,
title="Add items to cart",
description="Adds the provided items to the active cart and returns its state.",
inputSchema=TOOL_INPUT_SCHEMA,
_meta=_widget_meta(),
)
]


@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="Markup for the shopping cart widget.",
mimeType=MIME_TYPE,
_meta=_widget_meta(),
)
]


async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult:
if str(req.params.uri) != WIDGET_TEMPLATE_URI:
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=SHOPPING_CART_HTML,
_meta=_widget_meta(),
)
]
return types.ServerResult(types.ReadResourceResult(contents=contents))


async def _handle_call_tool(req: types.CallToolRequest) -> types.ServerResult:
if req.params.name != TOOL_NAME:
return types.ServerResult(
types.CallToolResult(
content=[
types.TextContent(
type="text",
text=f"Unknown tool: {req.params.name}",
)
],
isError=True,
)
)

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

cart_id = _get_or_create_cart(payload.cart_id)
# cart_items = carts[cart_id]
cart_items = []
for item in payload.items:
cart_items.append(_serialize_item(item))

structured_content = {
"cartId": cart_id,
"items": [dict(item) for item in cart_items],
}
meta = _widget_meta()
meta["openai/widgetSessionId"] = cart_id

message = f"Cart {cart_id} now has {len(cart_items)} item(s)."
return types.ServerResult(
types.CallToolResult(
content=[types.TextContent(type="text", text=message)],
structuredContent=structured_content,
_meta=meta,
)
)


mcp._mcp_server.request_handlers[types.CallToolRequest] = _handle_call_tool
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)
3 changes: 3 additions & 0 deletions shopping_cart_python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fastapi>=0.115.0
mcp[fastapi]>=0.1.0
uvicorn>=0.30.0
Loading