diff --git a/README.md b/README.md index b01d659..257366c 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. @@ -104,6 +105,7 @@ The repository ships several demo MCP servers that highlight different widget bu - **Pizzaz (Node & Python)** – pizza-inspired collection of tools and components - **Solar system (Python)** – 3D solar system viewer - **Kitchen sink lite (Node & Python)** – minimal widget + server pairing that demonstrates tool output, widget state, `callTool`, and host helpers +- **Shopping cart (Python)** – simple shopping cart widget that demonstrates how to use `widgetSessionId` to keep state between tool calls ### Pizzaz Node server @@ -145,6 +147,22 @@ pip install -r kitchen_sink_server_python/requirements.txt uvicorn kitchen_sink_server_python.main:app --port 8000 ``` +### 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 shopping_cart_python/requirements.txt +uvicorn shopping_cart_python.main:app --port 8000 +``` + +> [!NOTE] +> 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`. + +--- + You can reuse the same virtual environment for all Python servers—install the dependencies once and run whichever entry point you need. ## Testing in ChatGPT diff --git a/build-all.mts b/build-all.mts index 182ea01..68fc5ea 100644 --- a/build-all.mts +++ b/build-all.mts @@ -23,6 +23,7 @@ const targets: string[] = [ "pizzaz-albums", "pizzaz-shop", "kitchen-sink-lite", + "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..6bc940c --- /dev/null +++ b/shopping_cart_python/README.md @@ -0,0 +1,61 @@ +# Shopping cart MCP server (Python) + +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. + +## 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 (from the repo root): + +```bash +python shopping_cart_python/main.py +# or +python -m uvicorn shopping_cart_python.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. + +## Example demo flow + +- 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 +- Say "Now add milk" => the milk will be added to the existing cart +- Add 2 avocados from the UI => the widget state will change +- Say "Now add 3 tomatoes" => the tomatoes will be added to the existing cart + +You should have the following cart state: + +- N eggs +- 1 milk +- 2 avocados +- 3 tomatoes diff --git a/shopping_cart_python/main.py b/shopping_cart_python/main.py new file mode 100644 index 0000000..adcd824 --- /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(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/icons.tsx b/src/shopping-cart/icons.tsx new file mode 100644 index 0000000..3c10dfb --- /dev/null +++ b/src/shopping-cart/icons.tsx @@ -0,0 +1,145 @@ +import type { SVGProps } from "react"; + +export function EggIcon(props: SVGProps) { + return ( + + ); +} + +export function BreadIcon(props: SVGProps) { + return ( + + ); +} + +export function TomatoIcon(props: SVGProps) { + return ( + + ); +} + +export function AvocadoIcon(props: SVGProps) { + return ( + + ); +} + +export function JarIcon(props: SVGProps) { + return ( + + ); +} diff --git a/src/shopping-cart/index.tsx b/src/shopping-cart/index.tsx new file mode 100644 index 0000000..b73799f --- /dev/null +++ b/src/shopping-cart/index.tsx @@ -0,0 +1,402 @@ +import { useEffect, useMemo, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { useOpenAiGlobal } from "../use-openai-global"; +import { useWidgetState } from "../use-widget-state"; +import { AvocadoIcon, BreadIcon, EggIcon, JarIcon, TomatoIcon } from "./icons"; + +type CartItem = { + name: string; + quantity: number; + [key: string]: unknown; +}; + +type CartWidgetState = { + cartId?: string; + items?: CartItem[]; + [key: string]: unknown; +}; + +const createDefaultCartState = (): CartWidgetState => ({ + items: [], +}); + +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 }: { label: string; value: unknown }) { + const pretty = usePrettyJson(value); + + return ( +
+
+

+ {label} +

+
+
+        {pretty}
+      
+
+ ); +} + +const suggestedItems = [ + { + name: "Eggs", + description: "Breakfast basics", + Icon: EggIcon, + }, + { + name: "Bread", + description: "Fresh and toasty", + Icon: BreadIcon, + }, + { + name: "Tomatoes", + description: "Juicy and bright", + Icon: TomatoIcon, + }, + { + name: "Avocados", + description: "Perfectly ripe", + Icon: AvocadoIcon, + }, +]; + +const iconMatchers = [ + { keywords: ["egg", "eggs"], Icon: EggIcon }, + { keywords: ["bread"], Icon: BreadIcon }, + { keywords: ["tomato", "tomatoes"], Icon: TomatoIcon }, + { keywords: ["avocado", "avocados"], Icon: AvocadoIcon }, +]; + +function App() { + const toolOutput = useOpenAiGlobal("toolOutput"); + const toolResponseMetadata = useOpenAiGlobal("toolResponseMetadata"); + const widgetState = useOpenAiGlobal("widgetState"); + const [cartState, setCartState] = useWidgetState( + createDefaultCartState + ); + const cartItems = Array.isArray(cartState?.items) ? cartState.items : []; + const animationStyles = ` + @keyframes fadeUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + `; + + function addItem(name: string) { + if (!name) { + return; + } + + setCartState((prevState) => { + const baseState: CartWidgetState = prevState ?? {}; + const items = Array.isArray(baseState.items) + ? baseState.items.map((item) => ({ ...item })) + : []; + const idx = items.findIndex((item) => item.name === name); + + if (idx === -1) { + items.push({ name, quantity: 1 }); + } else { + const current = items[idx]; + items[idx] = { + ...current, + quantity: (current.quantity ?? 0) + 1, + }; + } + + return { ...baseState, 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 tool event has actually changed. + const serializedToolOutput = (() => { + try { + return JSON.stringify({ toolOutput, toolResponseMetadata }); + } 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 ?? cartState ?? createDefaultCartState(); + const baseItems = Array.isArray(baseState.items) ? baseState.items : []; + const incomingCartId = + typeof (toolOutput as { cartId?: unknown } | null)?.cartId === "string" + ? ((toolOutput as { cartId?: string }).cartId ?? undefined) + : undefined; + + 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, + cartId: baseState.cartId ?? incomingCartId, + items: nextItems, + }; + + // Update cartState with the new state that includes the new items + // Updating cartState automatically updates window.openai.widgetState. + setCartState(nextState); + }, [toolOutput, toolResponseMetadata]); + + function getIconForItem(name: string) { + const words = name + .toLowerCase() + .replace(/[^a-z]/g, " ") + .split(/\s+/) + .filter(Boolean); + for (const entry of iconMatchers) { + if (entry.keywords.some((keyword) => words.includes(keyword))) { + return entry.Icon; + } + } + return JarIcon; + } + + const itemCards = cartItems.length ? ( +
+ {cartItems.map((item) => ( +
+
+
+ {(() => { + const Icon = getIconForItem(item.name); + return ; + })()} +
+
+

{item.name}

+

+ Qty {item.quantity ?? 0} +

+
+
+
+ + +
+
+ ))} +
+ ) : ( +
+ Your cart is empty. Add a few items to get started. +
+ ); + + return ( +
+ +
+
+

+ Simple cart +

+

+ Pick a few essentials +

+

+ Update your cart through the chat or tap to add a suggestion or + adjust quantities. +

+
+ +
+
+
+

+ Suggested items +

+
+
+ {suggestedItems.map(({ name, description, Icon }, index) => ( +
+
+
+ +
+
+

+ {name} +

+

{description}

+
+
+ +
+ ))} +
+
+ +
+
+

+ Cart +

+ + {cartItems.length} items + +
+ {itemCards} + +
+
+ +
+
+

+ Widget state & output +

+ Debug view +
+
+ + +
+
+
+
+ ); +} + +const rootElement = document.getElementById("shopping-cart-root"); +if (!rootElement) { + throw new Error("Missing shopping-cart-root element"); +} + +createRoot(rootElement).render(); diff --git a/src/use-widget-state.ts b/src/use-widget-state.ts index 9ef4afa..c8638ac 100644 --- a/src/use-widget-state.ts +++ b/src/use-widget-state.ts @@ -32,14 +32,14 @@ export function useWidgetState( _setWidgetState((prevState) => { const newState = typeof state === "function" ? state(prevState) : state; - if (newState != null) { - window.openai.setWidgetState(newState); + if (newState != null && typeof window !== "undefined") { + void window.openai?.setWidgetState?.(newState); } return newState; }); }, - [window.openai.setWidgetState] + [] ); return [widgetState, setWidgetState] as const;