Skip to content

Commit 1243be8

Browse files
authored
Merge pull request #141 from openai/widget-state-sharing-example
Add shopping-cart example
2 parents 31f00df + 6119251 commit 1243be8

File tree

8 files changed

+855
-3
lines changed

8 files changed

+855
-3
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The MCP servers in this demo highlight how each tool can light up widgets by com
3333

3434
- `src/` – Source for each widget example.
3535
- `assets/` – Generated HTML, JS, and CSS bundles after running the build step.
36+
- `shopping_cart_python/` – Python MCP server that demonstrates how `_meta["widgetSessionId"]` keeps `widgetState` in sync across turns for a shopping-cart widget.
3637
- `pizzaz_server_node/` – MCP server implemented with the official TypeScript SDK.
3738
- `pizzaz_server_python/` – Python MCP server that returns the Pizzaz widgets.
3839
- `solar-system_server_python/` – Python MCP server for the 3D solar system widget.
@@ -104,6 +105,7 @@ The repository ships several demo MCP servers that highlight different widget bu
104105
- **Pizzaz (Node & Python)** – pizza-inspired collection of tools and components
105106
- **Solar system (Python)** – 3D solar system viewer
106107
- **Kitchen sink lite (Node & Python)** – minimal widget + server pairing that demonstrates tool output, widget state, `callTool`, and host helpers
108+
- **Shopping cart (Python)** – simple shopping cart widget that demonstrates how to use `widgetSessionId` to keep state between tool calls
107109

108110
### Pizzaz Node server
109111

@@ -145,6 +147,22 @@ pip install -r kitchen_sink_server_python/requirements.txt
145147
uvicorn kitchen_sink_server_python.main:app --port 8000
146148
```
147149

150+
### Shopping cart Python server
151+
152+
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.
153+
154+
```bash
155+
python -m venv .venv
156+
source .venv/bin/activate
157+
pip install -r shopping_cart_python/requirements.txt
158+
uvicorn shopping_cart_python.main:app --port 8000
159+
```
160+
161+
> [!NOTE]
162+
> In production you should persist the cart server-side (see [shopping_cart_python/README.md](shopping_cart_python/README.md)), but this demo shows the mechanics of keeping state through `widgetSessionId`.
163+
164+
---
165+
148166
You can reuse the same virtual environment for all Python servers—install the dependencies once and run whichever entry point you need.
149167

150168
## Testing in ChatGPT

build-all.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const targets: string[] = [
2323
"pizzaz-albums",
2424
"pizzaz-shop",
2525
"kitchen-sink-lite",
26+
"shopping-cart",
2627
];
2728
const builtNames: string[] = [];
2829

shopping_cart_python/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Shopping cart MCP server (Python)
2+
3+
This example shows how to thread state across conversation turns by pairing `_meta["widgetSessionId"]` with `window.openai.widgetState`. The Python server ships a simple `add_to_cart` tool as an example, plus a widget that stays in sync even when the user adjusts quantities in the UI between turns.
4+
5+
## Installation
6+
7+
Use the same dependencies as the other FastMCP Python examples:
8+
9+
```bash
10+
python -m venv .venv
11+
source .venv/bin/activate
12+
pip install -r shopping_cart_python/requirements.txt
13+
```
14+
15+
## Run the server
16+
17+
In one shell, serve the static assets from the repo root:
18+
19+
```bash
20+
pnpm run serve
21+
```
22+
23+
In another shell, start the shopping-cart MCP server (from the repo root):
24+
25+
```bash
26+
python shopping_cart_python/main.py
27+
# or
28+
python -m uvicorn shopping_cart_python.main:app --host 0.0.0.0 --port 8000
29+
```
30+
31+
The server exposes `GET /mcp` for SSE and `POST /mcp/messages?sessionId=...` for follow-up messages, mirroring the other FastMCP examples.
32+
33+
## How the state flow works
34+
35+
- Every `call_tool` response sets `_meta["widgetSessionId"]` to the cart identifier and returns a `structuredContent` payload containing the new cart items.
36+
- 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.
37+
- 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.
38+
39+
## Recommended production pattern
40+
41+
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:
42+
43+
- 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"]`.
44+
- 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.
45+
- 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.
46+
47+
A lightweight in-memory store works for local testing; swap in a persistent datastore when you move beyond the demo.
48+
49+
## Example demo flow
50+
51+
- Ask "Add 2 eggs to my cart" => you will be prompted to add the eggs to the cart, and this will be the initial cart state
52+
- Say "Now add milk" => the milk will be added to the existing cart
53+
- Add 2 avocados from the UI => the widget state will change
54+
- Say "Now add 3 tomatoes" => the tomatoes will be added to the existing cart
55+
56+
You should have the following cart state:
57+
58+
- N eggs
59+
- 1 milk
60+
- 2 avocados
61+
- 3 tomatoes

shopping_cart_python/main.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Simple ecommerce MCP server exposing the shopping cart widget."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any, Dict, List
7+
from uuid import uuid4
8+
9+
import mcp.types as types
10+
from mcp.server.fastmcp import FastMCP
11+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
12+
13+
TOOL_NAME = "add_to_cart"
14+
WIDGET_TEMPLATE_URI = "ui://widget/shopping-cart.html"
15+
WIDGET_TITLE = "Start shopping cart"
16+
WIDGET_INVOKING = "Preparing shopping cart"
17+
WIDGET_INVOKED = "Shopping cart ready"
18+
MIME_TYPE = "text/html+skybridge"
19+
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
20+
21+
22+
def _load_widget_html() -> str:
23+
html_path = ASSETS_DIR / "shopping-cart.html"
24+
if html_path.exists():
25+
return html_path.read_text(encoding="utf8")
26+
27+
fallback = sorted(ASSETS_DIR.glob("shopping-cart-*.html"))
28+
if fallback:
29+
return fallback[-1].read_text(encoding="utf8")
30+
31+
raise FileNotFoundError(
32+
f'Widget HTML for "shopping-cart" not found in {ASSETS_DIR}. '
33+
"Run `pnpm run build` to generate the assets before starting the server."
34+
)
35+
36+
37+
SHOPPING_CART_HTML = _load_widget_html()
38+
39+
40+
class CartItem(BaseModel):
41+
"""Represents an item being added to a cart."""
42+
43+
name: str = Field(..., description="Name of the item to show in the cart.")
44+
quantity: int = Field(
45+
default=1,
46+
ge=1,
47+
description="How many units to add to the cart (must be positive).",
48+
)
49+
50+
model_config = ConfigDict(populate_by_name=True, extra="allow")
51+
52+
53+
class AddToCartInput(BaseModel):
54+
"""Payload for the add_to_cart tool."""
55+
56+
items: List[CartItem] = Field(
57+
...,
58+
description="List of items to add to the active cart.",
59+
)
60+
cart_id: str | None = Field(
61+
default=None,
62+
alias="cartId",
63+
description="Existing cart identifier. Leave blank to start a new cart.",
64+
)
65+
66+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
67+
68+
69+
TOOL_INPUT_SCHEMA = AddToCartInput.model_json_schema(by_alias=True)
70+
71+
carts: Dict[str, List[Dict[str, Any]]] = {}
72+
73+
mcp = FastMCP(
74+
name="ecommerce-python",
75+
stateless_http=True,
76+
)
77+
78+
79+
def _serialize_item(item: CartItem) -> Dict[str, Any]:
80+
"""Return a JSON serializable dict including any custom fields."""
81+
return item.model_dump(by_alias=True)
82+
83+
84+
def _get_or_create_cart(cart_id: str | None) -> str:
85+
if cart_id and cart_id in carts:
86+
return cart_id
87+
88+
new_id = cart_id or uuid4().hex
89+
carts.setdefault(new_id, [])
90+
return new_id
91+
92+
93+
def _widget_meta() -> Dict[str, Any]:
94+
return {
95+
"openai/outputTemplate": WIDGET_TEMPLATE_URI,
96+
"openai/toolInvocation/invoking": WIDGET_INVOKING,
97+
"openai/toolInvocation/invoked": WIDGET_INVOKED,
98+
"openai/widgetAccessible": True,
99+
}
100+
101+
102+
@mcp._mcp_server.list_tools()
103+
async def _list_tools() -> List[types.Tool]:
104+
return [
105+
types.Tool(
106+
name=TOOL_NAME,
107+
title="Add items to cart",
108+
description="Adds the provided items to the active cart and returns its state.",
109+
inputSchema=TOOL_INPUT_SCHEMA,
110+
_meta=_widget_meta(),
111+
)
112+
]
113+
114+
115+
@mcp._mcp_server.list_resources()
116+
async def _list_resources() -> List[types.Resource]:
117+
return [
118+
types.Resource(
119+
name=WIDGET_TITLE,
120+
title=WIDGET_TITLE,
121+
uri=WIDGET_TEMPLATE_URI,
122+
description="Markup for the shopping cart widget.",
123+
mimeType=MIME_TYPE,
124+
_meta=_widget_meta(),
125+
)
126+
]
127+
128+
129+
async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult:
130+
if str(req.params.uri) != WIDGET_TEMPLATE_URI:
131+
return types.ServerResult(
132+
types.ReadResourceResult(
133+
contents=[],
134+
_meta={"error": f"Unknown resource: {req.params.uri}"},
135+
)
136+
)
137+
138+
contents = [
139+
types.TextResourceContents(
140+
uri=WIDGET_TEMPLATE_URI,
141+
mimeType=MIME_TYPE,
142+
text=SHOPPING_CART_HTML,
143+
_meta=_widget_meta(),
144+
)
145+
]
146+
return types.ServerResult(types.ReadResourceResult(contents=contents))
147+
148+
149+
async def _handle_call_tool(req: types.CallToolRequest) -> types.ServerResult:
150+
if req.params.name != TOOL_NAME:
151+
return types.ServerResult(
152+
types.CallToolResult(
153+
content=[
154+
types.TextContent(
155+
type="text",
156+
text=f"Unknown tool: {req.params.name}",
157+
)
158+
],
159+
isError=True,
160+
)
161+
)
162+
163+
try:
164+
payload = AddToCartInput.model_validate(req.params.arguments or {})
165+
except ValidationError as exc:
166+
return types.ServerResult(
167+
types.CallToolResult(
168+
content=[
169+
types.TextContent(
170+
type="text", text=f"Invalid input: {exc.errors()}"
171+
)
172+
],
173+
isError=True,
174+
)
175+
)
176+
177+
cart_id = _get_or_create_cart(payload.cart_id)
178+
# cart_items = carts[cart_id]
179+
cart_items = []
180+
for item in payload.items:
181+
cart_items.append(_serialize_item(item))
182+
183+
structured_content = {
184+
"cartId": cart_id,
185+
"items": [dict(item) for item in cart_items],
186+
}
187+
meta = _widget_meta()
188+
meta["openai/widgetSessionId"] = cart_id
189+
190+
message = f"Cart {cart_id} now has {len(cart_items)} item(s)."
191+
return types.ServerResult(
192+
types.CallToolResult(
193+
content=[types.TextContent(type="text", text=message)],
194+
structuredContent=structured_content,
195+
_meta=meta,
196+
)
197+
)
198+
199+
200+
mcp._mcp_server.request_handlers[types.CallToolRequest] = _handle_call_tool
201+
mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource
202+
203+
app = mcp.streamable_http_app()
204+
205+
try:
206+
from starlette.middleware.cors import CORSMiddleware
207+
208+
app.add_middleware(
209+
CORSMiddleware,
210+
allow_origins=["*"],
211+
allow_methods=["*"],
212+
allow_headers=["*"],
213+
allow_credentials=False,
214+
)
215+
except Exception:
216+
pass
217+
218+
219+
if __name__ == "__main__":
220+
import uvicorn
221+
222+
uvicorn.run(app, host="0.0.0.0", port=8000)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
fastapi>=0.115.0
2+
mcp[fastapi]>=0.1.0
3+
uvicorn>=0.30.0

0 commit comments

Comments
 (0)