diff --git a/README.md b/README.md index eeec0a8..992b77c 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/build-all.mts b/build-all.mts index 045b4af..04f88f9 100644 --- a/build-all.mts +++ b/build-all.mts @@ -22,6 +22,7 @@ const targets: string[] = [ "pizzaz-list", "pizzaz-albums", "pizzaz-shop", + "shopping-cart", ]; const builtNames: string[] = []; diff --git a/shopping_cart_python/README.md b/shopping_cart_python/README.md new file mode 100644 index 0000000..555eebc --- /dev/null +++ b/shopping_cart_python/README.md @@ -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. diff --git a/shopping_cart_python/main.py b/shopping_cart_python/main.py new file mode 100644 index 0000000..7aa2e63 --- /dev/null +++ b/shopping_cart_python/main.py @@ -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) diff --git a/shopping_cart_python/requirements.txt b/shopping_cart_python/requirements.txt new file mode 100644 index 0000000..5deee2d --- /dev/null +++ b/shopping_cart_python/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115.0 +mcp[fastapi]>=0.1.0 +uvicorn>=0.30.0 diff --git a/src/shopping-cart/index.tsx b/src/shopping-cart/index.tsx new file mode 100644 index 0000000..2c5a770 --- /dev/null +++ b/src/shopping-cart/index.tsx @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { useOpenAiGlobal } from "../use-openai-global"; +import { useWidgetState } from "../use-widget-state"; + +type JsonPanelProps = { + label: string; + value: unknown; +}; + +type CartItem = { + name: string; + quantity: number; + [key: string]: unknown; +}; + +type CartWidgetState = { + cartId?: string; + items?: CartItem[]; + [key: string]: unknown; +}; + +function usePrettyJson(value: unknown): string { + return useMemo(() => { + if (value === undefined || value === null) { + return "null"; + } + + try { + return JSON.stringify(value, null, 2); + } catch (error) { + return `<>`; + } + }, [value]); +} + +function JsonPanel({ label, value }: JsonPanelProps) { + const pretty = usePrettyJson(value); + + return ( +
+
+

+ {label} +

+
+
+        {pretty}
+      
+
+ ); +} + +const createDefaultCartState = (): CartWidgetState => ({ + items: [], +}); + +function App() { + const toolInput = useOpenAiGlobal("toolInput"); + const toolOutput = useOpenAiGlobal("toolOutput"); + const widgetState = useOpenAiGlobal("widgetState"); + const [cartState, setCartState] = useWidgetState(createDefaultCartState); + const cartItems = Array.isArray(cartState?.items) ? cartState.items : []; + + function adjustQuantity(name: string, delta: number) { + if (!name || delta === 0) { + return; + } + + console.log("adjustQuantity", { name, delta }); + setCartState((prevState) => { + const baseState: CartWidgetState = prevState ?? {}; + const items = Array.isArray(baseState.items) + ? baseState.items.map((item) => ({ ...item })) + : []; + console.log("adjustQuantity:prev", baseState); + + const idx = items.findIndex((item) => item.name === name); + if (idx === -1) { + console.log("adjustQuantity:missing", name); + return baseState; + } + + const current = items[idx]; + const nextQuantity = Math.max(0, (current.quantity ?? 0) + delta); + if (nextQuantity === 0) { + items.splice(idx, 1); + } else { + items[idx] = { ...current, quantity: nextQuantity }; + } + + const nextState = { ...baseState, items }; + console.log("adjustQuantity:next", nextState); + return nextState; + }); + } + + const lastToolOutputRef = useRef("__tool_output_unset__"); + + useEffect(() => { + + // Merge deltas (toolOutput) into the latest widgetState without + // and then update cartState. Runs whenever toolOutput changes. + if (toolOutput == null) { + return; + } + + // changes to cartState triggered from UI will also trigger another global update event, so we need to check if the toolOutput has actually changed. + const serializedToolOutput = (() => { + try { + return JSON.stringify(toolOutput); + } catch (error) { + console.warn("Unable to serialize toolOutput", error); + return "__tool_output_error__"; + } + })(); + + if (serializedToolOutput === lastToolOutputRef.current) { + console.log("useEffect skipped (toolOutput is actually unchanged)"); + return; + } + lastToolOutputRef.current = serializedToolOutput; + + // Get the items that the user wants to add to the cart from toolOutput + const incomingItems = Array.isArray( + (toolOutput as { items?: unknown } | null)?.items + ) + ? ((toolOutput as { items?: CartItem[] }).items ?? []) + : []; + + // Since we set `widgetSessionId` on the tool response, when the tool response returns + // widgetState should contain the state from the previous turn of conversation + // treat widgetState as the definitive local state, and add the new items + const baseState = widgetState ?? createDefaultCartState(); + const baseItems = Array.isArray(baseState.items) ? baseState.items : []; + + const itemsByName = new Map(); + for (const item of baseItems) { + if (item?.name) { + itemsByName.set(item.name, item); + } + } + // Add in the new items to create newState + for (const item of incomingItems) { + if (item?.name) { + itemsByName.set(item.name, { ...itemsByName.get(item.name), ...item }); + } + } + + const nextItems = Array.from(itemsByName.values()); + const nextState = { ...baseState, items: nextItems }; + + // Update cartState with the new state that includes the new items + // Updating cartState automatically updates window.openai.widgetState. + setCartState(nextState); + + }, [toolOutput]); + + const panels: JsonPanelProps[] = [ + { label: "window.openai.toolInput", value: toolInput }, + { label: "window.openai.toolOutput", value: toolOutput }, + { label: "window.openai.widgetState", value: cartState }, + ]; + + const itemCards = cartItems.length ? ( +
+ {cartItems.map((item) => ( +
+
+

{item.name}

+

+ Quantity: {item.quantity} +

+
+
+ + +
+
+ ))} +
+ ) : ( +

+ The cart is empty. Tool calls that return widget state will populate this + section. +

+ ); + + return ( +
+
+
+
+

+ Cart Items +

+
+ {itemCards} +
+ {panels.map((panel) => ( + + ))} +
+
+ ); +} + +const rootElement = document.getElementById("shopping-cart-root"); +if (!rootElement) { + throw new Error("Missing shopping-cart-root element"); +} + +createRoot(rootElement).render();