From baa6a085226f4bf36e28a4ef25bf3695f976079c Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Mon, 10 Nov 2025 14:24:09 -0500 Subject: [PATCH 01/10] Add ecommerce app for SA/SE bootcamp --- build-all.mts | 1 + ecommerce_server_python/README.md | 41 + ecommerce_server_python/main.py | 416 ++++++ ecommerce_server_python/requirements.txt | 4 + ecommerce_server_python/sample_data.json | 131 ++ pizzaz_server_python/main.py | 94 +- src/ecommerce/index.tsx | 21 + src/pizzaz-shop/app.tsx | 1464 ++++++++++++++++++++++ src/pizzaz-shop/index.tsx | 1457 +-------------------- src/types.ts | 35 + src/use-widget-props.ts | 17 +- 11 files changed, 2216 insertions(+), 1465 deletions(-) create mode 100644 ecommerce_server_python/README.md create mode 100644 ecommerce_server_python/main.py create mode 100644 ecommerce_server_python/requirements.txt create mode 100644 ecommerce_server_python/sample_data.json create mode 100644 src/ecommerce/index.tsx create mode 100644 src/pizzaz-shop/app.tsx diff --git a/build-all.mts b/build-all.mts index 045b4af..cfcbd45 100644 --- a/build-all.mts +++ b/build-all.mts @@ -17,6 +17,7 @@ const GLOBAL_CSS_LIST = [path.resolve("src/index.css")]; const targets: string[] = [ "todo", "solar-system", + "ecommerce", "pizzaz", "pizzaz-carousel", "pizzaz-list", diff --git a/ecommerce_server_python/README.md b/ecommerce_server_python/README.md new file mode 100644 index 0000000..023d64c --- /dev/null +++ b/ecommerce_server_python/README.md @@ -0,0 +1,41 @@ +# Ecommerce MCP server (Python) + +This server exposes a single tool that renders the ecommerce widget hydrated by +sample catalog data. The tool performs a simple text search over +`sample_data.json` and returns matching products as `cartItems`, allowing the +widget to display the results without any hard-coded inventory. + +## Prerequisites + +- Python 3.10+ +- A virtual environment (recommended) + +## Installation + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Running the server + +```bash +python main.py +``` + +The server listens on `http://127.0.0.1:8000` and exposes the standard MCP +endpoints: + +- `GET /mcp` for the SSE stream +- `POST /mcp/messages?sessionId=...` for follow-ups + +The `ecommerce-search` tool uses the `searchTerm` argument to filter products +by name, description, tags, or highlights and returns structured content with +`cartItems` so the ecommerce widget can hydrate itself. + +## Customization + +- Update `sample_data.json` with your own products. +- Adjust the search logic in `main.py` to match your catalog rules. +- Rebuild the widget assets (`pnpm run build`) if you change the UI. diff --git a/ecommerce_server_python/main.py b/ecommerce_server_python/main.py new file mode 100644 index 0000000..a87288a --- /dev/null +++ b/ecommerce_server_python/main.py @@ -0,0 +1,416 @@ +"""Pizzaz demo MCP server implemented with the Python FastMCP helper. + +The server mirrors the Node example in this repository and exposes +widget-backed tools that render the Pizzaz UI bundle. Each handler returns the +HTML shell via an MCP resource and echoes the selected topping as structured +content so the ChatGPT client can hydrate the widget. The module also wires the +handlers into an HTTP/SSE stack so you can run the server with uvicorn on port +8000, matching the Node transport behavior.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from functools import lru_cache +import json +from pathlib import Path +from typing import Any, Dict, List + +import mcp.types as types +from mcp.server.fastmcp import FastMCP + + +@dataclass(frozen=True) +class PizzazWidget: + identifier: str + title: str + template_uri: str + invoking: str + invoked: str + html: str + response_text: str + + +ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" +ECOMMERCE_SAMPLE_DATA_PATH = ( + Path(__file__).resolve().parent.parent + / "ecommerce_server_python" + / "sample_data.json" +) + + +@lru_cache(maxsize=None) +def _load_widget_html(component_name: str) -> str: + html_path = ASSETS_DIR / f"{component_name}.html" + if html_path.exists(): + return html_path.read_text(encoding="utf8") + + fallback_candidates = sorted(ASSETS_DIR.glob(f"{component_name}-*.html")) + if fallback_candidates: + return fallback_candidates[-1].read_text(encoding="utf8") + + raise FileNotFoundError( + f'Widget HTML for "{component_name}" not found in {ASSETS_DIR}. ' + "Run `pnpm run build` to generate the assets before starting the server." + ) + + +@lru_cache(maxsize=1) +def _load_ecommerce_cart_items() -> List[Dict[str, Any]]: + if not ECOMMERCE_SAMPLE_DATA_PATH.exists(): + return [] + + try: + raw = json.loads(ECOMMERCE_SAMPLE_DATA_PATH.read_text(encoding="utf8")) + except json.JSONDecodeError: + return [] + + items: List[Dict[str, Any]] = [] + for entry in raw.get("products", []): + if isinstance(entry, dict): + items.append(entry) + + return items + + +def _product_matches_search(item: Dict[str, Any], search_term: str) -> bool: + """Return True if the product matches the provided search term.""" + term = search_term.strip().lower() + if not term: + return True + + def _contains_text(value: Any) -> bool: + return isinstance(value, str) and term in value.lower() + + searchable_fields = ( + "name", + "description", + "shortDescription", + "detailSummary", + ) + + for field in searchable_fields: + if _contains_text(item.get(field)): + return True + + tags = item.get("tags") + if isinstance(tags, list): + for tag in tags: + if _contains_text(tag): + return True + + highlights = item.get("highlights") + if isinstance(highlights, list): + for highlight in highlights: + if _contains_text(highlight): + return True + + return False + + +ECOMMERCE_WIDGET = PizzazWidget( + identifier="pizzaz-ecommerce", + title="Show Ecommerce Catalog", + template_uri="ui://widget/ecommerce.html", + invoking="Loading the ecommerce catalog", + invoked="Ecommerce catalog ready", + html=_load_widget_html("ecommerce"), + response_text="Rendered the ecommerce catalog!", +) + + +MIME_TYPE = "text/html+skybridge" + +SEARCH_TOOL_NAME = ECOMMERCE_WIDGET.identifier +INCREMENT_TOOL_NAME = "increment_item" + +SEARCH_TOOL_SCHEMA: Dict[str, Any] = { + "type": "object", + "title": "Product search terms", + "properties": { + "searchTerm": { + "type": "string", + "description": "Free-text keywords to filter products by name, description, tags, or highlights.", + }, + }, + "required": [], + "additionalProperties": False, +} + +INCREMENT_TOOL_SCHEMA: Dict[str, Any] = { + "type": "object", + "title": "Increment cart item", + "properties": { + "productId": { + "type": "string", + "description": "Product ID from the catalog to increment.", + }, + "incrementBy": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "How many units to add to the product quantity (defaults to 1).", + }, + }, + "required": ["productId"], + "additionalProperties": False, +} + + +mcp = FastMCP( + name="pizzaz-python", + stateless_http=True, +) + + +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, + } + + +def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: + return { + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + } + + +@mcp._mcp_server.list_tools() +async def _list_tools() -> List[types.Tool]: + return [ + types.Tool( + name=SEARCH_TOOL_NAME, + title=ECOMMERCE_WIDGET.title, + description="Search the ecommerce catalog using free-text keywords.", + inputSchema=SEARCH_TOOL_SCHEMA, + _meta=_tool_meta(ECOMMERCE_WIDGET), + # To disable the approval prompt for the tools + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ), + types.Tool( + name=INCREMENT_TOOL_NAME, + title="Increment Cart Item", + description="Increase the quantity of an item already in the cart.", + inputSchema=INCREMENT_TOOL_SCHEMA, + _meta=_tool_meta(ECOMMERCE_WIDGET), + # To disable the approval prompt for the tools + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ), + ] + + +@mcp._mcp_server.list_resources() +async def _list_resources() -> List[types.Resource]: + return [ + types.Resource( + name=ECOMMERCE_WIDGET.title, + title=ECOMMERCE_WIDGET.title, + uri=ECOMMERCE_WIDGET.template_uri, + description=_resource_description(ECOMMERCE_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(ECOMMERCE_WIDGET), + ) + ] + + +@mcp._mcp_server.list_resource_templates() +async def _list_resource_templates() -> List[types.ResourceTemplate]: + return [ + types.ResourceTemplate( + name=ECOMMERCE_WIDGET.title, + title=ECOMMERCE_WIDGET.title, + uriTemplate=ECOMMERCE_WIDGET.template_uri, + description=_resource_description(ECOMMERCE_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(ECOMMERCE_WIDGET), + ) + ] + + +async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: + requested_uri = str(req.params.uri) + if requested_uri != ECOMMERCE_WIDGET.template_uri: + return types.ServerResult( + types.ReadResourceResult( + contents=[], + _meta={"error": f"Unknown resource: {req.params.uri}"}, + ) + ) + + contents = [ + types.TextResourceContents( + uri=ECOMMERCE_WIDGET.template_uri, + mimeType=MIME_TYPE, + text=ECOMMERCE_WIDGET.html, + _meta=_tool_meta(ECOMMERCE_WIDGET), + ) + ] + + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + +async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: + tool_name = req.params.name + if tool_name not in {SEARCH_TOOL_NAME, INCREMENT_TOOL_NAME}: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Unknown tool: {req.params.name}", + ) + ], + isError=True, + ) + ) + + arguments = req.params.arguments or {} + meta = _tool_invocation_meta(ECOMMERCE_WIDGET) + cart_items = [deepcopy(item) for item in _load_ecommerce_cart_items()] + + if tool_name == SEARCH_TOOL_NAME: + search_term = str(arguments.get("searchTerm", "")).strip() + filtered_items = cart_items + if search_term: + filtered_items = [ + item + for item in cart_items + if _product_matches_search(item, search_term) + ] + structured_content: Dict[str, Any] = { + "cartItems": filtered_items, + "searchTerm": search_term, + } + response_text = ECOMMERCE_WIDGET.response_text + else: + product_id = str(arguments.get("productId", "")).strip() + if not product_id: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="productId is required to increment a cart item.", + ) + ], + isError=True, + ) + ) + + increment_raw = arguments.get("incrementBy", 1) + try: + increment_by = int(increment_raw) + except (TypeError, ValueError): + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="incrementBy must be an integer.", + ) + ], + isError=True, + ) + ) + + if increment_by < 1: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="incrementBy must be at least 1.", + ) + ], + isError=True, + ) + ) + + product = next( + (item for item in cart_items if item.get("id") == product_id), + None, + ) + if product is None: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Product '{product_id}' was not found in the cart.", + ) + ], + isError=True, + ) + ) + + current_quantity_raw = product.get("quantity", 0) + try: + current_quantity = int(current_quantity_raw) + except (TypeError, ValueError): + current_quantity = 0 + product["quantity"] = current_quantity + increment_by + + structured_content = { + "cartItems": cart_items, + "searchTerm": "", + } + product_name = product.get("name", product_id) + response_text = ( + f"Incremented {product_name} by {increment_by}. Updated cart ready." + ) + + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=response_text, + ) + ], + structuredContent=structured_content, + _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) diff --git a/ecommerce_server_python/requirements.txt b/ecommerce_server_python/requirements.txt new file mode 100644 index 0000000..0af5ea9 --- /dev/null +++ b/ecommerce_server_python/requirements.txt @@ -0,0 +1,4 @@ +fastmcp>=0.1.0 +mcp>=0.1.0 +pydantic>=2.6.0 +uvicorn>=0.30.0 diff --git a/ecommerce_server_python/sample_data.json b/ecommerce_server_python/sample_data.json new file mode 100644 index 0000000..dd5d5c1 --- /dev/null +++ b/ecommerce_server_python/sample_data.json @@ -0,0 +1,131 @@ +{ + "products": [ + { + "id": "marys-chicken", + "name": "Mary's Chicken", + "price": 19.48, + "description": "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", + "shortDescription": "Organic chicken breasts", + "detailSummary": "4 lbs • $3.99/lb", + "nutritionFacts": [ + { "label": "Protein", "value": "8g" }, + { "label": "Fat", "value": "9g" }, + { "label": "Sugar", "value": "12g" }, + { "label": "Calories", "value": "160" } + ], + "highlights": [ + "No antibiotics or added hormones.", + "Air chilled and never frozen for peak flavor.", + "Raised in the USA on a vegetarian diet." + ], + "tags": ["size"], + "quantity": 2, + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png" + }, + { + "id": "avocados", + "name": "Avocados", + "price": 1, + "description": "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", + "shortDescription": "Creamy Hass avocados", + "detailSummary": "3 ct • $1.00/ea", + "nutritionFacts": [ + { "label": "Fiber", "value": "7g" }, + { "label": "Fat", "value": "15g" }, + { "label": "Potassium", "value": "485mg" }, + { "label": "Calories", "value": "160" } + ], + "highlights": [ + "Perfectly ripe and ready for slicing.", + "Rich in healthy fats and naturally creamy." + ], + "tags": ["vegan"], + "quantity": 2, + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png" + }, + { + "id": "hojicha-pizza", + "name": "Hojicha Pizza", + "price": 15.5, + "description": "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", + "shortDescription": "Smoky hojicha sauce & honey", + "detailSummary": "12\" pie • Serves 2", + "nutritionFacts": [ + { "label": "Protein", "value": "14g" }, + { "label": "Fat", "value": "18g" }, + { "label": "Sugar", "value": "9g" }, + { "label": "Calories", "value": "320" } + ], + "highlights": [ + "Smoky roasted hojicha glaze with honey drizzle.", + "Stone-fired crust with a delicate char." + ], + "tags": ["vegetarian", "size", "spicy"], + "quantity": 2, + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png" + }, + { + "id": "chicken-pizza", + "name": "Chicken Pizza", + "price": 7, + "description": "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", + "shortDescription": "Roasted chicken & pesto", + "detailSummary": "10\" personal • Serves 1", + "nutritionFacts": [ + { "label": "Protein", "value": "20g" }, + { "label": "Fat", "value": "11g" }, + { "label": "Carbs", "value": "36g" }, + { "label": "Calories", "value": "290" } + ], + "highlights": [ + "Roasted chicken with caramelized onions.", + "Fresh basil pesto and mozzarella." + ], + "tags": ["size"], + "quantity": 1, + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png" + }, + { + "id": "matcha-pizza", + "name": "Matcha Pizza", + "price": 5, + "description": "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", + "shortDescription": "Velvety matcha cream", + "detailSummary": "8\" dessert • Serves 2", + "nutritionFacts": [ + { "label": "Protein", "value": "6g" }, + { "label": "Fat", "value": "10g" }, + { "label": "Sugar", "value": "14g" }, + { "label": "Calories", "value": "240" } + ], + "highlights": [ + "Stone-baked crust with delicate crunch.", + "Matcha mascarpone with white chocolate drizzle." + ], + "tags": ["vegetarian"], + "quantity": 1, + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png" + }, + { + "id": "pesto-pizza", + "name": "Pesto Pizza", + "price": 12.5, + "description": "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", + "shortDescription": "Basil pesto & tomatoes", + "detailSummary": "12\" pie • Serves 2", + "nutritionFacts": [ + { "label": "Protein", "value": "16g" }, + { "label": "Fat", "value": "14g" }, + { "label": "Carbs", "value": "28g" }, + { "label": "Calories", "value": "310" } + ], + "highlights": [ + "House-made pesto with sweet basil and pine nuts.", + "Roasted cherry tomatoes for a pop of acidity." + ], + "tags": ["vegetarian", "size"], + "quantity": 1, + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png" + } + ] +} diff --git a/pizzaz_server_python/main.py b/pizzaz_server_python/main.py index 74177a6..b841562 100644 --- a/pizzaz_server_python/main.py +++ b/pizzaz_server_python/main.py @@ -12,6 +12,7 @@ from copy import deepcopy from dataclasses import dataclass from functools import lru_cache +import json from pathlib import Path from typing import Any, Dict, List @@ -32,6 +33,11 @@ class PizzazWidget: ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" +ECOMMERCE_SAMPLE_DATA_PATH = ( + Path(__file__).resolve().parent.parent + / "ecommerce_server_python" + / "sample_data.json" +) @lru_cache(maxsize=None) @@ -50,6 +56,24 @@ def _load_widget_html(component_name: str) -> str: ) +@lru_cache(maxsize=1) +def _load_ecommerce_cart_items() -> List[Dict[str, Any]]: + if not ECOMMERCE_SAMPLE_DATA_PATH.exists(): + return [] + + try: + raw = json.loads(ECOMMERCE_SAMPLE_DATA_PATH.read_text(encoding="utf8")) + except json.JSONDecodeError: + return [] + + items: List[Dict[str, Any]] = [] + for entry in raw.get("products", []): + if isinstance(entry, dict): + items.append(entry) + + return items + + widgets: List[PizzazWidget] = [ PizzazWidget( identifier="pizza-map", @@ -96,6 +120,15 @@ def _load_widget_html(component_name: str) -> str: html=_load_widget_html("pizzaz-shop"), response_text="Rendered the Pizzaz shop!", ), + PizzazWidget( + identifier="pizzaz-ecommerce", + title="Show Ecommerce Catalog", + template_uri="ui://widget/ecommerce.html", + invoking="Loading the ecommerce catalog", + invoked="Ecommerce catalog ready", + html=_load_widget_html("ecommerce"), + response_text="Rendered the ecommerce catalog!", + ), ] @@ -164,22 +197,35 @@ def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: @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), - # To disable the approval prompt for the tools - annotations={ - "destructiveHint": False, - "openWorldHint": False, - "readOnlyHint": True, - }, + tools: List[types.Tool] = [] + for widget in widgets: + input_schema: Dict[str, Any] + if widget.identifier == "pizzaz-ecommerce": + input_schema = { + "type": "object", + "properties": {}, + "additionalProperties": False, + } + else: + input_schema = deepcopy(TOOL_INPUT_SCHEMA) + + tools.append( + types.Tool( + name=widget.identifier, + title=widget.title, + description=widget.title, + inputSchema=input_schema, + _meta=_tool_meta(widget), + # To disable the approval prompt for the tools + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ) ) - for widget in widgets - ] + + return tools @mcp._mcp_server.list_resources() @@ -249,6 +295,24 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: ) ) + if widget.identifier == "pizzaz-ecommerce": + meta = _tool_invocation_meta(widget) + cart_items = [deepcopy(item) for item in _load_ecommerce_cart_items()] + structured_content: Dict[str, Any] = {"cartItems": cart_items, "searchTerm": ""} + + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=widget.response_text, + ) + ], + structuredContent=structured_content, + _meta=meta, + ) + ) + arguments = req.params.arguments or {} try: payload = PizzaInput.model_validate(arguments) diff --git a/src/ecommerce/index.tsx b/src/ecommerce/index.tsx new file mode 100644 index 0000000..29c260e --- /dev/null +++ b/src/ecommerce/index.tsx @@ -0,0 +1,21 @@ +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App, type CartItem, type PizzazShopAppProps } from "../pizzaz-shop/app"; + +export type EcommerceAppProps = Omit & { + defaultCartItems?: CartItem[]; +}; + +const container = document.getElementById("ecommerce-root"); + +if (!container) { + throw new Error("Missing root element: ecommerce-root"); +} + +createRoot(container).render( + + + +); + +export default App; diff --git a/src/pizzaz-shop/app.tsx b/src/pizzaz-shop/app.tsx new file mode 100644 index 0000000..cca02ce --- /dev/null +++ b/src/pizzaz-shop/app.tsx @@ -0,0 +1,1464 @@ +import clsx from "clsx"; +import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; +import { Minus, Plus, ShoppingCart } from "lucide-react"; +import { + type MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useDisplayMode } from "../use-display-mode"; +import { useMaxHeight } from "../use-max-height"; +import { useOpenAiGlobal } from "../use-openai-global"; +import { useWidgetProps } from "../use-widget-props"; +import { useWidgetState } from "../use-widget-state"; + +type NutritionFact = { + label: string; + value: string; +}; + +export type CartItem = { + id: string; + name: string; + price: number; + description: string; + shortDescription?: string; + detailSummary?: string; + nutritionFacts?: NutritionFact[]; + highlights?: string[]; + tags?: string[]; + quantity: number; + image: string; +}; + +export type PizzazCartWidgetState = { + state?: "checkout" | null; + cartItems?: CartItem[]; + selectedCartItemId?: string | null; +}; + +export type PizzazCartWidgetProps = { + cartItems?: CartItem[]; + widgetState?: Partial | null; +}; + +const SERVICE_FEE = 3; +const DELIVERY_FEE = 2.99; +const TAX_FEE = 3.4; +const CONTINUE_TO_PAYMENT_EVENT = "pizzaz-shop:continue-to-payment"; + +const FILTERS: Array<{ + id: "all" | "vegetarian" | "vegan" | "size" | "spicy"; + label: string; + tag?: string; +}> = [ + { id: "all", label: "All" }, + { id: "vegetarian", label: "Vegetarian", tag: "vegetarian" }, + { id: "vegan", label: "Vegan", tag: "vegan" }, + { id: "size", label: "Size", tag: "size" }, + { id: "spicy", label: "Spicy", tag: "spicy" }, +]; + +const INITIAL_CART_ITEMS: CartItem[] = [ + { + id: "marys-chicken", + name: "Mary's Chicken", + price: 19.48, + description: + "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", + shortDescription: "Organic chicken breasts", + detailSummary: "4 lbs • $3.99/lb", + nutritionFacts: [ + { label: "Protein", value: "8g" }, + { label: "Fat", value: "9g" }, + { label: "Sugar", value: "12g" }, + { label: "Calories", value: "160" }, + ], + highlights: [ + "No antibiotics or added hormones.", + "Air chilled and never frozen for peak flavor.", + "Raised in the USA on a vegetarian diet.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png", + tags: ["size"], + }, + { + id: "avocados", + name: "Avocados", + price: 1, + description: + "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", + shortDescription: "Creamy Hass avocados", + detailSummary: "3 ct • $1.00/ea", + nutritionFacts: [ + { label: "Fiber", value: "7g" }, + { label: "Fat", value: "15g" }, + { label: "Potassium", value: "485mg" }, + { label: "Calories", value: "160" }, + ], + highlights: [ + "Perfectly ripe and ready for slicing.", + "Rich in healthy fats and naturally creamy.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png", + tags: ["vegan"], + }, + { + id: "hojicha-pizza", + name: "Hojicha Pizza", + price: 15.5, + description: + "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", + shortDescription: "Smoky hojicha sauce & honey", + detailSummary: '12" pie • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "14g" }, + { label: "Fat", value: "18g" }, + { label: "Sugar", value: "9g" }, + { label: "Calories", value: "320" }, + ], + highlights: [ + "Smoky roasted hojicha glaze with honey drizzle.", + "Stone-fired crust with a delicate char.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png", + tags: ["vegetarian", "size", "spicy"], + }, + { + id: "chicken-pizza", + name: "Chicken Pizza", + price: 7, + description: + "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", + shortDescription: "Roasted chicken & pesto", + detailSummary: '10" personal • Serves 1', + nutritionFacts: [ + { label: "Protein", value: "20g" }, + { label: "Fat", value: "11g" }, + { label: "Carbs", value: "36g" }, + { label: "Calories", value: "290" }, + ], + highlights: [ + "Roasted chicken with caramelized onions.", + "Fresh basil pesto and mozzarella.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png", + tags: ["size"], + }, + { + id: "matcha-pizza", + name: "Matcha Pizza", + price: 5, + description: + "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", + shortDescription: "Velvety matcha cream", + detailSummary: '8" dessert • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "6g" }, + { label: "Fat", value: "10g" }, + { label: "Sugar", value: "14g" }, + { label: "Calories", value: "240" }, + ], + highlights: [ + "Stone-baked crust with delicate crunch.", + "Matcha mascarpone with white chocolate drizzle.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + tags: ["vegetarian"], + }, + { + id: "pesto-pizza", + name: "Pesto Pizza", + price: 12.5, + description: + "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", + shortDescription: "Basil pesto & tomatoes", + detailSummary: '12" pie • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "16g" }, + { label: "Fat", value: "14g" }, + { label: "Carbs", value: "28g" }, + { label: "Calories", value: "310" }, + ], + highlights: [ + "House-made pesto with sweet basil and pine nuts.", + "Roasted cherry tomatoes for a pop of acidity.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + tags: ["vegetarian", "size"], + }, +]; + +const cloneCartItem = (item: CartItem): CartItem => ({ + ...item, + nutritionFacts: item.nutritionFacts?.map((fact) => ({ ...fact })), + highlights: item.highlights ? [...item.highlights] : undefined, + tags: item.tags ? [...item.tags] : undefined, +}); + +const createDefaultCartItems = (): CartItem[] => + INITIAL_CART_ITEMS.map((item) => cloneCartItem(item)); + +export type PizzazShopAppProps = { + defaultCartItems?: CartItem[]; +}; + +const nutritionFactsEqual = ( + a?: NutritionFact[], + b?: NutritionFact[] +): boolean => { + if (!a?.length && !b?.length) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + return a.every((fact, index) => { + const other = b[index]; + if (!other) { + return false; + } + return fact.label === other.label && fact.value === other.value; + }); +}; + +const highlightsEqual = (a?: string[], b?: string[]): boolean => { + if (!a?.length && !b?.length) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + return a.every((highlight, index) => highlight === b[index]); +}; + +const cartItemsEqual = (a: CartItem[], b: CartItem[]): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!right) { + return false; + } + if ( + left.id !== right.id || + left.quantity !== right.quantity || + left.name !== right.name || + left.price !== right.price || + left.description !== right.description || + left.shortDescription !== right.shortDescription || + left.detailSummary !== right.detailSummary || + !nutritionFactsEqual(left.nutritionFacts, right.nutritionFacts) || + !highlightsEqual(left.highlights, right.highlights) || + !highlightsEqual(left.tags, right.tags) || + left.image !== right.image + ) { + return false; + } + } + return true; +}; + +type SelectedCartItemPanelProps = { + item: CartItem; + onAdjustQuantity: (id: string, delta: number) => void; +}; + +function SelectedCartItemPanel({ + item, + onAdjustQuantity, +}: SelectedCartItemPanelProps) { + const nutritionFacts = Array.isArray(item.nutritionFacts) + ? item.nutritionFacts + : []; + const highlights = Array.isArray(item.highlights) ? item.highlights : []; + + const hasNutritionFacts = nutritionFacts.length > 0; + const hasHighlights = highlights.length > 0; + + return ( +
+
+
+ {item.name} +
+
+
+ +
+
+
+

+ ${item.price.toFixed(2)} +

+

{item.name}

+
+
+ + + {item.quantity} + + +
+
+ +

{item.description}

+ + {item.detailSummary ? ( +

{item.detailSummary}

+ ) : null} + + {hasNutritionFacts ? ( +
+ {nutritionFacts.map((fact) => ( +
+

{fact.value}

+

{fact.label}

+
+ ))} +
+ ) : null} + + {hasHighlights ? ( +
+ {highlights.map((highlight, index) => ( +

{highlight}

+ ))} +
+ ) : null} +
+
+ ); +} + +type CheckoutDetailsPanelProps = { + shouldShowCheckoutOnly: boolean; + subtotal: number; + total: number; + onContinueToPayment?: () => void; +}; + +function CheckoutDetailsPanel({ + shouldShowCheckoutOnly, + subtotal, + total, + onContinueToPayment, +}: CheckoutDetailsPanelProps) { + return ( + <> + {!shouldShowCheckoutOnly && ( +
+

Checkout details

+
+ )} + +
+
+

Delivery address

+
+
+

+ 1234 Main St, San Francisco, CA +

+

+ Leave at door - Delivery instructions +

+
+ +
+
+
+

Fast

+

+ 50 min - 2 hr 10 min +

+
+ Free +
+
+
+

Priority

+

35 min

+
+ Free +
+
+
+ +
+
+

Delivery tip

+

100% goes to the shopper

+
+
+ + + + +
+
+ +
+
+ Subtotal + ${subtotal.toFixed(2)} +
+
+ Total + + ${total.toFixed(2)} + +
+

+ +
+ + ); +} + +export function App({ + defaultCartItems: providedDefaultCartItems, +}: PizzazShopAppProps = {}) { + const maxHeight = useMaxHeight() ?? undefined; + const displayMode = useDisplayMode(); + const isFullscreen = displayMode === "fullscreen"; + const widgetProps = useWidgetProps(() => ({})); + const baseCartItems = useMemo(() => { + if (Array.isArray(providedDefaultCartItems)) { + return providedDefaultCartItems.map((item) => cloneCartItem(item)); + } + return createDefaultCartItems(); + }, [providedDefaultCartItems]); + const computeDefaultWidgetState = useCallback( + (): PizzazCartWidgetState => ({ + state: null, + cartItems: baseCartItems.map((item) => cloneCartItem(item)), + selectedCartItemId: null, + }), + [baseCartItems] + ); + const [widgetState, setWidgetState] = useWidgetState( + computeDefaultWidgetState + ); + const navigate = useNavigate(); + const location = useLocation(); + const isCheckoutRoute = useMemo(() => { + const pathname = location?.pathname ?? ""; + if (!pathname) { + return false; + } + + return pathname === "/checkout" || pathname.endsWith("/checkout"); + }, [location?.pathname]); + + const cartGridRef = useRef(null); + const [gridColumnCount, setGridColumnCount] = useState(1); + + const mergeWithDefaultItems = useCallback( + (items: CartItem[]): CartItem[] => { + const existingIds = new Set(items.map((item) => item.id)); + const merged = items.map((item) => { + const defaultItem = baseCartItems.find( + (candidate) => candidate.id === item.id + ); + + if (!defaultItem) { + return cloneCartItem(item); + } + + const enriched: CartItem = { + ...cloneCartItem(defaultItem), + ...item, + tags: item.tags ? [...item.tags] : defaultItem.tags, + nutritionFacts: + item.nutritionFacts ?? + defaultItem.nutritionFacts?.map((fact) => ({ ...fact })), + highlights: + item.highlights != null + ? [...item.highlights] + : defaultItem.highlights + ? [...defaultItem.highlights] + : undefined, + }; + + return cloneCartItem(enriched); + }); + + baseCartItems.forEach((defaultItem) => { + if (!existingIds.has(defaultItem.id)) { + merged.push(cloneCartItem(defaultItem)); + } + }); + + return merged; + }, + [baseCartItems] + ); + + const resolvedCartItems = useMemo(() => { + if (Array.isArray(widgetState?.cartItems) && widgetState.cartItems.length) { + return mergeWithDefaultItems(widgetState.cartItems); + } + + if ( + Array.isArray(widgetProps?.widgetState?.cartItems) && + widgetProps.widgetState.cartItems.length + ) { + return mergeWithDefaultItems(widgetProps.widgetState.cartItems); + } + + if (Array.isArray(widgetProps?.cartItems) && widgetProps.cartItems.length) { + return mergeWithDefaultItems(widgetProps.cartItems); + } + + return mergeWithDefaultItems(baseCartItems); + }, [ + baseCartItems, + mergeWithDefaultItems, + widgetProps?.cartItems, + widgetProps?.widgetState?.cartItems, + widgetState, + ]); + + const [cartItems, setCartItems] = useState(resolvedCartItems); + + useEffect(() => { + setCartItems((previous) => + cartItemsEqual(previous, resolvedCartItems) ? previous : resolvedCartItems + ); + }, [resolvedCartItems]); + + const resolvedSelectedCartItemId = + widgetState?.selectedCartItemId ?? + widgetProps?.widgetState?.selectedCartItemId ?? + null; + + const [selectedCartItemId, setSelectedCartItemId] = useState( + resolvedSelectedCartItemId + ); + + useEffect(() => { + setSelectedCartItemId((prev) => + prev === resolvedSelectedCartItemId ? prev : resolvedSelectedCartItemId + ); + }, [resolvedSelectedCartItemId]); + + const view = useOpenAiGlobal("view"); + const viewParams = view?.params; + const isModalView = view?.mode === "modal"; + const checkoutFromState = + (widgetState?.state ?? widgetProps?.widgetState?.state) === "checkout"; + const modalParams = + viewParams && typeof viewParams === "object" + ? (viewParams as { + state?: unknown; + cartItems?: unknown; + subtotal?: unknown; + total?: unknown; + totalItems?: unknown; + }) + : null; + + const modalState = + modalParams && typeof modalParams.state === "string" + ? (modalParams.state as string) + : null; + + const isCartModalView = isModalView && modalState === "cart"; + const shouldShowCheckoutOnly = + isCheckoutRoute || (isModalView && !isCartModalView); + const wasModalViewRef = useRef(isModalView); + + useEffect(() => { + if (!viewParams || typeof viewParams !== "object") { + return; + } + + const paramsWithSelection = viewParams as { + selectedCartItemId?: unknown; + }; + + const selectedIdFromParams = paramsWithSelection.selectedCartItemId; + + if ( + typeof selectedIdFromParams === "string" && + selectedIdFromParams !== selectedCartItemId + ) { + setSelectedCartItemId(selectedIdFromParams); + return; + } + + if (selectedIdFromParams === null && selectedCartItemId !== null) { + setSelectedCartItemId(null); + } + }, [selectedCartItemId, viewParams]); + + const [hoveredCartItemId, setHoveredCartItemId] = useState( + null + ); + const [activeFilters, setActiveFilters] = useState([]); + + const updateWidgetState = useCallback( + (partial: Partial) => { + setWidgetState((previous) => ({ + ...computeDefaultWidgetState(), + ...(previous ?? {}), + ...partial, + })); + }, + [computeDefaultWidgetState, setWidgetState] + ); + + useEffect(() => { + if (!Array.isArray(widgetState?.cartItems)) { + return; + } + + const merged = mergeWithDefaultItems(widgetState.cartItems); + + if (!cartItemsEqual(widgetState.cartItems, merged)) { + updateWidgetState({ cartItems: merged }); + } + }, [mergeWithDefaultItems, updateWidgetState, widgetState?.cartItems]); + + useEffect(() => { + if (wasModalViewRef.current && !isModalView && checkoutFromState) { + updateWidgetState({ state: null }); + } + + wasModalViewRef.current = isModalView; + }, [checkoutFromState, isModalView, updateWidgetState]); + + const adjustQuantity = useCallback( + (id: string, delta: number) => { + setCartItems((previousItems) => { + const updatedItems = previousItems.map((item) => + item.id === id + ? { ...item, quantity: Math.max(0, item.quantity + delta) } + : item + ); + + if (!cartItemsEqual(previousItems, updatedItems)) { + updateWidgetState({ cartItems: updatedItems }); + } + + return updatedItems; + }); + }, + [updateWidgetState] + ); + + useEffect(() => { + if (!shouldShowCheckoutOnly) { + return; + } + + setHoveredCartItemId(null); + }, [shouldShowCheckoutOnly]); + + const manualCheckoutTriggerRef = useRef(false); + + const requestModalWithAnchor = useCallback( + ({ + title, + params, + anchorElement, + }: { + title: string; + params: Record; + anchorElement?: HTMLElement | null; + }) => { + if (isModalView) { + return; + } + + const anchorRect = anchorElement?.getBoundingClientRect(); + const anchor = + anchorRect == null + ? undefined + : { + top: anchorRect.top, + left: anchorRect.left, + width: anchorRect.width, + height: anchorRect.height, + }; + + void (async () => { + try { + await window?.openai?.requestModal?.({ + title, + params, + ...(anchor ? { anchor } : {}), + }); + } catch (error) { + console.error("Failed to open checkout modal", error); + } + })(); + }, + [isModalView] + ); + + const openCheckoutModal = useCallback( + (anchorElement?: HTMLElement | null) => { + requestModalWithAnchor({ + title: "Checkout", + params: { state: "checkout" }, + anchorElement, + }); + }, + [requestModalWithAnchor] + ); + + const openCartItemModal = useCallback( + ({ + selectedId, + selectedName, + anchorElement, + }: { + selectedId: string; + selectedName: string | null; + anchorElement?: HTMLElement | null; + }) => { + requestModalWithAnchor({ + title: selectedName ?? selectedId, + params: { state: "checkout", selectedCartItemId: selectedId }, + anchorElement, + }); + }, + [requestModalWithAnchor] + ); + + const handleCartItemSelect = useCallback( + (id: string, anchorElement?: HTMLElement | null) => { + const itemName = cartItems.find((item) => item.id === id)?.name ?? null; + manualCheckoutTriggerRef.current = true; + setSelectedCartItemId(id); + updateWidgetState({ selectedCartItemId: id, state: "checkout" }); + openCartItemModal({ + selectedId: id, + selectedName: itemName, + anchorElement, + }); + }, + [cartItems, openCartItemModal, updateWidgetState] + ); + + const subtotal = useMemo( + () => + cartItems.reduce( + (total, item) => total + item.price * Math.max(0, item.quantity), + 0 + ), + [cartItems] + ); + + const total = subtotal + SERVICE_FEE + DELIVERY_FEE + TAX_FEE; + + const totalItems = useMemo( + () => + cartItems.reduce((total, item) => total + Math.max(0, item.quantity), 0), + [cartItems] + ); + + const visibleCartItems = useMemo(() => { + if (!activeFilters.length) { + return cartItems; + } + + return cartItems.filter((item) => { + const tags = item.tags ?? []; + + return activeFilters.every((filterId) => { + const filterMeta = FILTERS.find((filter) => filter.id === filterId); + if (!filterMeta?.tag) { + return true; + } + return tags.includes(filterMeta.tag); + }); + }); + }, [activeFilters, cartItems]); + + const updateItemColumnPlacement = useCallback(() => { + const gridNode = cartGridRef.current; + + const width = gridNode?.offsetWidth ?? 0; + + let baseColumnCount = 1; + if (width >= 768) { + baseColumnCount = 3; + } else if (width >= 640) { + baseColumnCount = 2; + } + + const columnCount = isFullscreen + ? Math.max(baseColumnCount, 3) + : baseColumnCount; + + if (gridNode) { + gridNode.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; + } + + setGridColumnCount(columnCount); + }, [isFullscreen]); + + const handleFilterToggle = useCallback( + (id: string) => { + setActiveFilters((previous) => { + if (id === "all") { + return []; + } + + const isActive = previous.includes(id); + if (isActive) { + return []; + } + + return [id]; + }); + + requestAnimationFrame(() => { + updateItemColumnPlacement(); + }); + }, + [updateItemColumnPlacement] + ); + + useEffect(() => { + const node = cartGridRef.current; + + if (!node) { + return; + } + + const observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + requestAnimationFrame(updateItemColumnPlacement); + }) + : null; + + observer?.observe(node); + window.addEventListener("resize", updateItemColumnPlacement); + + return () => { + observer?.disconnect(); + window.removeEventListener("resize", updateItemColumnPlacement); + }; + }, [updateItemColumnPlacement]); + + const openCartModal = useCallback( + (anchorElement?: HTMLElement | null) => { + if (isModalView || shouldShowCheckoutOnly) { + return; + } + + requestModalWithAnchor({ + title: "Cart", + params: { + state: "cart", + cartItems, + subtotal, + total, + totalItems, + }, + anchorElement, + }); + }, + [ + cartItems, + isModalView, + requestModalWithAnchor, + shouldShowCheckoutOnly, + subtotal, + total, + totalItems, + ] + ); + + type CartSummaryItem = { + id: string; + name: string; + price: number; + quantity: number; + image?: string; + }; + + const cartSummaryItems: CartSummaryItem[] = useMemo(() => { + if (!isCartModalView) { + return []; + } + + const items = Array.isArray(modalParams?.cartItems) + ? modalParams?.cartItems + : null; + + if (!items) { + return cartItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + quantity: Math.max(0, item.quantity), + image: item.image, + })); + } + + const sanitized = items + .map((raw, index) => { + if (!raw || typeof raw !== "object") { + return null; + } + const candidate = raw as Record; + const id = + typeof candidate.id === "string" ? candidate.id : `cart-${index}`; + const name = + typeof candidate.name === "string" ? candidate.name : "Item"; + const priceValue = Number(candidate.price); + const quantityValue = Number(candidate.quantity); + const price = Number.isFinite(priceValue) ? priceValue : 0; + const quantity = Number.isFinite(quantityValue) + ? Math.max(0, quantityValue) + : 0; + const image = + typeof candidate.image === "string" ? candidate.image : undefined; + + return { + id, + name, + price, + quantity, + image, + } as CartSummaryItem; + }) + .filter(Boolean) as CartSummaryItem[]; + + if (sanitized.length === 0) { + return cartItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + quantity: Math.max(0, item.quantity), + image: item.image, + })); + } + + return sanitized; + }, [cartItems, isCartModalView, modalParams?.cartItems]); + + const cartSummarySubtotal = useMemo(() => { + if (!isCartModalView) { + return subtotal; + } + + const candidate = Number(modalParams?.subtotal); + return Number.isFinite(candidate) ? candidate : subtotal; + }, [isCartModalView, modalParams?.subtotal, subtotal]); + + const cartSummaryTotal = useMemo(() => { + if (!isCartModalView) { + return total; + } + + const candidate = Number(modalParams?.total); + return Number.isFinite(candidate) ? candidate : total; + }, [isCartModalView, modalParams?.total, total]); + + const cartSummaryTotalItems = useMemo(() => { + if (!isCartModalView) { + return totalItems; + } + + const candidate = Number(modalParams?.totalItems); + return Number.isFinite(candidate) ? candidate : totalItems; + }, [isCartModalView, modalParams?.totalItems, totalItems]); + + const handleContinueToPayment = useCallback( + (event?: ReactMouseEvent) => { + const anchorElement = event?.currentTarget ?? null; + + if (typeof window !== "undefined") { + const detail = { + subtotal: isCartModalView ? cartSummarySubtotal : subtotal, + total: isCartModalView ? cartSummaryTotal : total, + totalItems: isCartModalView ? cartSummaryTotalItems : totalItems, + }; + + try { + window.dispatchEvent( + new CustomEvent(CONTINUE_TO_PAYMENT_EVENT, { detail }) + ); + } catch (error) { + console.error("Failed to dispatch checkout navigation event", error); + } + } + + if (isCartModalView) { + return; + } + + manualCheckoutTriggerRef.current = true; + updateWidgetState({ state: "checkout" }); + const shouldNavigateToCheckout = isCartModalView || !isCheckoutRoute; + + if (shouldNavigateToCheckout) { + navigate("/checkout"); + return; + } + + openCheckoutModal(anchorElement); + }, + [ + cartSummarySubtotal, + cartSummaryTotal, + cartSummaryTotalItems, + isCartModalView, + isCheckoutRoute, + navigate, + openCheckoutModal, + subtotal, + total, + totalItems, + updateWidgetState, + ] + ); + + const handleSeeAll = useCallback(async () => { + if (typeof window === "undefined") { + return; + } + + try { + await window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); + } catch (error) { + console.error("Failed to request fullscreen display mode", error); + } + }, []); + + useLayoutEffect(() => { + const raf = requestAnimationFrame(updateItemColumnPlacement); + + return () => { + cancelAnimationFrame(raf); + }; + }, [updateItemColumnPlacement, visibleCartItems]); + + const selectedCartItem = useMemo(() => { + if (selectedCartItemId == null) { + return null; + } + return cartItems.find((item) => item.id === selectedCartItemId) ?? null; + }, [cartItems, selectedCartItemId]); + + const selectedCartItemName = selectedCartItem?.name ?? null; + const shouldShowSelectedCartItemPanel = + selectedCartItem != null && !isFullscreen; + + useEffect(() => { + if (isCheckoutRoute) { + return; + } + + if (!checkoutFromState) { + return; + } + + if (manualCheckoutTriggerRef.current) { + manualCheckoutTriggerRef.current = false; + return; + } + + if (selectedCartItemId) { + openCartItemModal({ + selectedId: selectedCartItemId, + selectedName: selectedCartItemName, + }); + return; + } + + openCheckoutModal(); + }, [ + isCheckoutRoute, + checkoutFromState, + openCartItemModal, + openCheckoutModal, + selectedCartItemId, + selectedCartItemName, + ]); + + const cartPanel = ( +
+ {!shouldShowCheckoutOnly && ( +
+ {!isFullscreen ? ( +
+ +
+ ) : ( +
Results
+ )} + +
+ )} + + +
+ + {visibleCartItems.map((item, index) => { + const isHovered = hoveredCartItemId === item.id; + const shortDescription = + item.shortDescription ?? item.description.split(".")[0]; + const columnCount = Math.max(gridColumnCount, 1); + const rowStartIndex = + Math.floor(index / columnCount) * columnCount; + const itemsRemaining = visibleCartItems.length - rowStartIndex; + const rowSize = Math.min(columnCount, itemsRemaining); + const positionInRow = index - rowStartIndex; + + const isSingle = rowSize === 1; + const isLeft = positionInRow === 0; + const isRight = positionInRow === rowSize - 1; + + return ( + + handleCartItemSelect( + item.id, + event.currentTarget as HTMLElement + ) + } + onMouseEnter={() => setHoveredCartItemId(item.id)} + onMouseLeave={() => setHoveredCartItemId(null)} + className={clsx( + "group mb-4 flex cursor-pointer flex-col overflow-hidden border border-transparent bg-white transition-colors", + isHovered && "border-[#0f766e]" + )} + > +
+ {item.name} + +
+
+
+
+

+ {item.name} +

+

+ ${item.price.toFixed(2)} +

+
+ {shortDescription ? ( +

+ {shortDescription} +

+ ) : null} +
+
+ + + {item.quantity} + + +
+
+
+ + ); + })} + +
+ +
+ ); + + if (isCartModalView && !isCheckoutRoute) { + return ( +
+
+ {cartSummaryItems.length ? ( + cartSummaryItems.map((item) => ( +
+
+ {item.image ? ( + {item.name} + ) : null} +
+
+
+
+

+ {item.name} +

+

+ ${item.price.toFixed(2)} • Qty{" "} + {Math.max(0, item.quantity)} +

+
+ + ${(item.price * Math.max(0, item.quantity)).toFixed(2)} + +
+
+ )) + ) : ( +

+ Your cart is empty. +

+ )} +
+ +
+
+ Subtotal + ${cartSummarySubtotal.toFixed(2)} +
+
+ Total + ${cartSummaryTotal.toFixed(2)} +
+
+ +
+ ); + } + + const checkoutPanel = ( +
+ {shouldShowSelectedCartItemPanel ? ( + + ) : ( + + )} +
+ ); + + return ( +
+
+ {shouldShowCheckoutOnly ? ( + checkoutPanel + ) : isFullscreen ? ( +
+
{cartPanel}
+
{checkoutPanel}
+
+ ) : ( + cartPanel + )} + {!isFullscreen && !shouldShowCheckoutOnly && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/pizzaz-shop/index.tsx b/src/pizzaz-shop/index.tsx index 3fcc4cc..cbd252a 100644 --- a/src/pizzaz-shop/index.tsx +++ b/src/pizzaz-shop/index.tsx @@ -1,1458 +1,19 @@ -import clsx from "clsx"; -import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; -import { Minus, Plus, ShoppingCart } from "lucide-react"; -import { - type MouseEvent as ReactMouseEvent, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; import { createRoot } from "react-dom/client"; -import { BrowserRouter, useLocation, useNavigate } from "react-router-dom"; -import { useDisplayMode } from "../use-display-mode"; -import { useMaxHeight } from "../use-max-height"; -import { useOpenAiGlobal } from "../use-openai-global"; -import { useWidgetProps } from "../use-widget-props"; -import { useWidgetState } from "../use-widget-state"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./app"; -type NutritionFact = { - label: string; - value: string; -}; +export * from "./app"; -type CartItem = { - id: string; - name: string; - price: number; - description: string; - shortDescription?: string; - detailSummary?: string; - nutritionFacts?: NutritionFact[]; - highlights?: string[]; - tags?: string[]; - quantity: number; - image: string; -}; +const container = document.getElementById("pizzaz-shop-root"); -type PizzazCartWidgetState = { - state?: "checkout" | null; - cartItems?: CartItem[]; - selectedCartItemId?: string | null; -}; - -type PizzazCartWidgetProps = { - cartItems?: CartItem[]; - widgetState?: Partial | null; -}; - -const SERVICE_FEE = 3; -const DELIVERY_FEE = 2.99; -const TAX_FEE = 3.4; -const CONTINUE_TO_PAYMENT_EVENT = "pizzaz-shop:continue-to-payment"; - -const FILTERS: Array<{ - id: "all" | "vegetarian" | "vegan" | "size" | "spicy"; - label: string; - tag?: string; -}> = [ - { id: "all", label: "All" }, - { id: "vegetarian", label: "Vegetarian", tag: "vegetarian" }, - { id: "vegan", label: "Vegan", tag: "vegan" }, - { id: "size", label: "Size", tag: "size" }, - { id: "spicy", label: "Spicy", tag: "spicy" }, -]; - -const INITIAL_CART_ITEMS: CartItem[] = [ - { - id: "marys-chicken", - name: "Mary's Chicken", - price: 19.48, - description: - "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", - shortDescription: "Organic chicken breasts", - detailSummary: "4 lbs • $3.99/lb", - nutritionFacts: [ - { label: "Protein", value: "8g" }, - { label: "Fat", value: "9g" }, - { label: "Sugar", value: "12g" }, - { label: "Calories", value: "160" }, - ], - highlights: [ - "No antibiotics or added hormones.", - "Air chilled and never frozen for peak flavor.", - "Raised in the USA on a vegetarian diet.", - ], - quantity: 2, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png", - tags: ["size"], - }, - { - id: "avocados", - name: "Avocados", - price: 1, - description: - "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", - shortDescription: "Creamy Hass avocados", - detailSummary: "3 ct • $1.00/ea", - nutritionFacts: [ - { label: "Fiber", value: "7g" }, - { label: "Fat", value: "15g" }, - { label: "Potassium", value: "485mg" }, - { label: "Calories", value: "160" }, - ], - highlights: [ - "Perfectly ripe and ready for slicing.", - "Rich in healthy fats and naturally creamy.", - ], - quantity: 2, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png", - tags: ["vegan"], - }, - { - id: "hojicha-pizza", - name: "Hojicha Pizza", - price: 15.5, - description: - "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", - shortDescription: "Smoky hojicha sauce & honey", - detailSummary: '12" pie • Serves 2', - nutritionFacts: [ - { label: "Protein", value: "14g" }, - { label: "Fat", value: "18g" }, - { label: "Sugar", value: "9g" }, - { label: "Calories", value: "320" }, - ], - highlights: [ - "Smoky roasted hojicha glaze with honey drizzle.", - "Stone-fired crust with a delicate char.", - ], - quantity: 2, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png", - tags: ["vegetarian", "size", "spicy"], - }, - { - id: "chicken-pizza", - name: "Chicken Pizza", - price: 7, - description: - "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", - shortDescription: "Roasted chicken & pesto", - detailSummary: '10" personal • Serves 1', - nutritionFacts: [ - { label: "Protein", value: "20g" }, - { label: "Fat", value: "11g" }, - { label: "Carbs", value: "36g" }, - { label: "Calories", value: "290" }, - ], - highlights: [ - "Roasted chicken with caramelized onions.", - "Fresh basil pesto and mozzarella.", - ], - quantity: 1, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png", - tags: ["size"], - }, - { - id: "matcha-pizza", - name: "Matcha Pizza", - price: 5, - description: - "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", - shortDescription: "Velvety matcha cream", - detailSummary: '8" dessert • Serves 2', - nutritionFacts: [ - { label: "Protein", value: "6g" }, - { label: "Fat", value: "10g" }, - { label: "Sugar", value: "14g" }, - { label: "Calories", value: "240" }, - ], - highlights: [ - "Stone-baked crust with delicate crunch.", - "Matcha mascarpone with white chocolate drizzle.", - ], - quantity: 1, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", - tags: ["vegetarian"], - }, - { - id: "pesto-pizza", - name: "Pesto Pizza", - price: 12.5, - description: - "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", - shortDescription: "Basil pesto & tomatoes", - detailSummary: '12" pie • Serves 2', - nutritionFacts: [ - { label: "Protein", value: "16g" }, - { label: "Fat", value: "14g" }, - { label: "Carbs", value: "28g" }, - { label: "Calories", value: "310" }, - ], - highlights: [ - "House-made pesto with sweet basil and pine nuts.", - "Roasted cherry tomatoes for a pop of acidity.", - ], - quantity: 1, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", - tags: ["vegetarian", "size"], - }, -]; - -const cloneCartItem = (item: CartItem): CartItem => ({ - ...item, - nutritionFacts: item.nutritionFacts?.map((fact) => ({ ...fact })), - highlights: item.highlights ? [...item.highlights] : undefined, - tags: item.tags ? [...item.tags] : undefined, -}); - -const createDefaultCartItems = (): CartItem[] => - INITIAL_CART_ITEMS.map((item) => cloneCartItem(item)); - -const createDefaultWidgetState = (): PizzazCartWidgetState => ({ - state: null, - cartItems: createDefaultCartItems(), - selectedCartItemId: null, -}); - -const nutritionFactsEqual = ( - a?: NutritionFact[], - b?: NutritionFact[] -): boolean => { - if (!a?.length && !b?.length) { - return true; - } - if (!a || !b || a.length !== b.length) { - return false; - } - return a.every((fact, index) => { - const other = b[index]; - if (!other) { - return false; - } - return fact.label === other.label && fact.value === other.value; - }); -}; - -const highlightsEqual = (a?: string[], b?: string[]): boolean => { - if (!a?.length && !b?.length) { - return true; - } - if (!a || !b || a.length !== b.length) { - return false; - } - return a.every((highlight, index) => highlight === b[index]); -}; - -const cartItemsEqual = (a: CartItem[], b: CartItem[]): boolean => { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i += 1) { - const left = a[i]; - const right = b[i]; - if (!right) { - return false; - } - if ( - left.id !== right.id || - left.quantity !== right.quantity || - left.name !== right.name || - left.price !== right.price || - left.description !== right.description || - left.shortDescription !== right.shortDescription || - left.detailSummary !== right.detailSummary || - !nutritionFactsEqual(left.nutritionFacts, right.nutritionFacts) || - !highlightsEqual(left.highlights, right.highlights) || - !highlightsEqual(left.tags, right.tags) || - left.image !== right.image - ) { - return false; - } - } - return true; -}; - -type SelectedCartItemPanelProps = { - item: CartItem; - onAdjustQuantity: (id: string, delta: number) => void; -}; - -function SelectedCartItemPanel({ - item, - onAdjustQuantity, -}: SelectedCartItemPanelProps) { - const nutritionFacts = Array.isArray(item.nutritionFacts) - ? item.nutritionFacts - : []; - const highlights = Array.isArray(item.highlights) ? item.highlights : []; - - const hasNutritionFacts = nutritionFacts.length > 0; - const hasHighlights = highlights.length > 0; - - return ( -
-
-
- {item.name} -
-
-
- -
-
-
-

- ${item.price.toFixed(2)} -

-

{item.name}

-
-
- - - {item.quantity} - - -
-
- -

{item.description}

- - {item.detailSummary ? ( -

{item.detailSummary}

- ) : null} - - {hasNutritionFacts ? ( -
- {nutritionFacts.map((fact) => ( -
-

{fact.value}

-

{fact.label}

-
- ))} -
- ) : null} - - {hasHighlights ? ( -
- {highlights.map((highlight, index) => ( -

{highlight}

- ))} -
- ) : null} -
-
- ); -} - -type CheckoutDetailsPanelProps = { - shouldShowCheckoutOnly: boolean; - subtotal: number; - total: number; - onContinueToPayment?: () => void; -}; - -function CheckoutDetailsPanel({ - shouldShowCheckoutOnly, - subtotal, - total, - onContinueToPayment, -}: CheckoutDetailsPanelProps) { - return ( - <> - {!shouldShowCheckoutOnly && ( -
-

Checkout details

-
- )} - -
-
-

Delivery address

-
-
-

- 1234 Main St, San Francisco, CA -

-

- Leave at door - Delivery instructions -

-
- -
-
-
-

Fast

-

- 50 min - 2 hr 10 min -

-
- Free -
-
-
-

Priority

-

35 min

-
- Free -
-
-
- -
-
-

Delivery tip

-

100% goes to the shopper

-
-
- - - - -
-
- -
-
- Subtotal - ${subtotal.toFixed(2)} -
-
- Total - - ${total.toFixed(2)} - -
-

- -
- - ); -} - -function App() { - const maxHeight = useMaxHeight() ?? undefined; - const displayMode = useDisplayMode(); - const isFullscreen = displayMode === "fullscreen"; - const widgetProps = useWidgetProps(() => ({})); - const [widgetState, setWidgetState] = useWidgetState( - createDefaultWidgetState - ); - const navigate = useNavigate(); - const location = useLocation(); - const isCheckoutRoute = useMemo(() => { - const pathname = location?.pathname ?? ""; - if (!pathname) { - return false; - } - - return pathname === "/checkout" || pathname.endsWith("/checkout"); - }, [location?.pathname]); - - const defaultCartItems = useMemo(() => createDefaultCartItems(), []); - const cartGridRef = useRef(null); - const [gridColumnCount, setGridColumnCount] = useState(1); - - const mergeWithDefaultItems = useCallback( - (items: CartItem[]): CartItem[] => { - const existingIds = new Set(items.map((item) => item.id)); - const merged = items.map((item) => { - const defaultItem = defaultCartItems.find( - (candidate) => candidate.id === item.id - ); - - if (!defaultItem) { - return cloneCartItem(item); - } - - const enriched: CartItem = { - ...cloneCartItem(defaultItem), - ...item, - tags: item.tags ? [...item.tags] : defaultItem.tags, - nutritionFacts: - item.nutritionFacts ?? - defaultItem.nutritionFacts?.map((fact) => ({ ...fact })), - highlights: - item.highlights != null - ? [...item.highlights] - : defaultItem.highlights - ? [...defaultItem.highlights] - : undefined, - }; - - return cloneCartItem(enriched); - }); - - defaultCartItems.forEach((defaultItem) => { - if (!existingIds.has(defaultItem.id)) { - merged.push(cloneCartItem(defaultItem)); - } - }); - - return merged; - }, - [defaultCartItems] - ); - - const resolvedCartItems = useMemo(() => { - if (Array.isArray(widgetState?.cartItems) && widgetState.cartItems.length) { - return mergeWithDefaultItems(widgetState.cartItems); - } - - if ( - Array.isArray(widgetProps?.widgetState?.cartItems) && - widgetProps.widgetState.cartItems.length - ) { - return mergeWithDefaultItems(widgetProps.widgetState.cartItems); - } - - if (Array.isArray(widgetProps?.cartItems) && widgetProps.cartItems.length) { - return mergeWithDefaultItems(widgetProps.cartItems); - } - - return mergeWithDefaultItems(defaultCartItems); - }, [ - defaultCartItems, - mergeWithDefaultItems, - widgetProps?.cartItems, - widgetProps?.widgetState?.cartItems, - widgetState, - ]); - - const [cartItems, setCartItems] = useState(resolvedCartItems); - - useEffect(() => { - setCartItems((previous) => - cartItemsEqual(previous, resolvedCartItems) ? previous : resolvedCartItems - ); - }, [resolvedCartItems]); - - const resolvedSelectedCartItemId = - widgetState?.selectedCartItemId ?? - widgetProps?.widgetState?.selectedCartItemId ?? - null; - - const [selectedCartItemId, setSelectedCartItemId] = useState( - resolvedSelectedCartItemId - ); - - useEffect(() => { - setSelectedCartItemId((prev) => - prev === resolvedSelectedCartItemId ? prev : resolvedSelectedCartItemId - ); - }, [resolvedSelectedCartItemId]); - - const view = useOpenAiGlobal("view"); - const viewParams = view?.params; - const isModalView = view?.mode === "modal"; - const checkoutFromState = - (widgetState?.state ?? widgetProps?.widgetState?.state) === "checkout"; - const modalParams = - viewParams && typeof viewParams === "object" - ? (viewParams as { - state?: unknown; - cartItems?: unknown; - subtotal?: unknown; - total?: unknown; - totalItems?: unknown; - }) - : null; - - const modalState = - modalParams && typeof modalParams.state === "string" - ? (modalParams.state as string) - : null; - - const isCartModalView = isModalView && modalState === "cart"; - const shouldShowCheckoutOnly = - isCheckoutRoute || (isModalView && !isCartModalView); - const wasModalViewRef = useRef(isModalView); - - useEffect(() => { - if (!viewParams || typeof viewParams !== "object") { - return; - } - - const paramsWithSelection = viewParams as { - selectedCartItemId?: unknown; - }; - - const selectedIdFromParams = paramsWithSelection.selectedCartItemId; - - if ( - typeof selectedIdFromParams === "string" && - selectedIdFromParams !== selectedCartItemId - ) { - setSelectedCartItemId(selectedIdFromParams); - return; - } - - if (selectedIdFromParams === null && selectedCartItemId !== null) { - setSelectedCartItemId(null); - } - }, [selectedCartItemId, viewParams]); - - const [hoveredCartItemId, setHoveredCartItemId] = useState( - null - ); - const [activeFilters, setActiveFilters] = useState([]); - - const updateWidgetState = useCallback( - (partial: Partial) => { - setWidgetState((previous) => ({ - ...createDefaultWidgetState(), - ...(previous ?? {}), - ...partial, - })); - }, - [setWidgetState] - ); - - useEffect(() => { - if (!Array.isArray(widgetState?.cartItems)) { - return; - } - - const merged = mergeWithDefaultItems(widgetState.cartItems); - - if (!cartItemsEqual(widgetState.cartItems, merged)) { - updateWidgetState({ cartItems: merged }); - } - }, [mergeWithDefaultItems, updateWidgetState, widgetState?.cartItems]); - - useEffect(() => { - if (wasModalViewRef.current && !isModalView && checkoutFromState) { - updateWidgetState({ state: null }); - } - - wasModalViewRef.current = isModalView; - }, [checkoutFromState, isModalView, updateWidgetState]); - - const adjustQuantity = useCallback( - (id: string, delta: number) => { - setCartItems((previousItems) => { - const updatedItems = previousItems.map((item) => - item.id === id - ? { ...item, quantity: Math.max(0, item.quantity + delta) } - : item - ); - - if (!cartItemsEqual(previousItems, updatedItems)) { - updateWidgetState({ cartItems: updatedItems }); - } - - return updatedItems; - }); - }, - [updateWidgetState] - ); - - useEffect(() => { - if (!shouldShowCheckoutOnly) { - return; - } - - setHoveredCartItemId(null); - }, [shouldShowCheckoutOnly]); - - const manualCheckoutTriggerRef = useRef(false); - - const requestModalWithAnchor = useCallback( - ({ - title, - params, - anchorElement, - }: { - title: string; - params: Record; - anchorElement?: HTMLElement | null; - }) => { - if (isModalView) { - return; - } - - const anchorRect = anchorElement?.getBoundingClientRect(); - const anchor = - anchorRect == null - ? undefined - : { - top: anchorRect.top, - left: anchorRect.left, - width: anchorRect.width, - height: anchorRect.height, - }; - - void (async () => { - try { - await window?.openai?.requestModal?.({ - title, - params, - ...(anchor ? { anchor } : {}), - }); - } catch (error) { - console.error("Failed to open checkout modal", error); - } - })(); - }, - [isModalView] - ); - - const openCheckoutModal = useCallback( - (anchorElement?: HTMLElement | null) => { - requestModalWithAnchor({ - title: "Checkout", - params: { state: "checkout" }, - anchorElement, - }); - }, - [requestModalWithAnchor] - ); - - const openCartItemModal = useCallback( - ({ - selectedId, - selectedName, - anchorElement, - }: { - selectedId: string; - selectedName: string | null; - anchorElement?: HTMLElement | null; - }) => { - requestModalWithAnchor({ - title: selectedName ?? selectedId, - params: { state: "checkout", selectedCartItemId: selectedId }, - anchorElement, - }); - }, - [requestModalWithAnchor] - ); - - const handleCartItemSelect = useCallback( - (id: string, anchorElement?: HTMLElement | null) => { - const itemName = cartItems.find((item) => item.id === id)?.name ?? null; - manualCheckoutTriggerRef.current = true; - setSelectedCartItemId(id); - updateWidgetState({ selectedCartItemId: id, state: "checkout" }); - openCartItemModal({ - selectedId: id, - selectedName: itemName, - anchorElement, - }); - }, - [cartItems, openCartItemModal, updateWidgetState] - ); - - const subtotal = useMemo( - () => - cartItems.reduce( - (total, item) => total + item.price * Math.max(0, item.quantity), - 0 - ), - [cartItems] - ); - - const total = subtotal + SERVICE_FEE + DELIVERY_FEE + TAX_FEE; - - const totalItems = useMemo( - () => - cartItems.reduce((total, item) => total + Math.max(0, item.quantity), 0), - [cartItems] - ); - - const visibleCartItems = useMemo(() => { - if (!activeFilters.length) { - return cartItems; - } - - return cartItems.filter((item) => { - const tags = item.tags ?? []; - - return activeFilters.every((filterId) => { - const filterMeta = FILTERS.find((filter) => filter.id === filterId); - if (!filterMeta?.tag) { - return true; - } - return tags.includes(filterMeta.tag); - }); - }); - }, [activeFilters, cartItems]); - - const updateItemColumnPlacement = useCallback(() => { - const gridNode = cartGridRef.current; - - const width = gridNode?.offsetWidth ?? 0; - - let baseColumnCount = 1; - if (width >= 768) { - baseColumnCount = 3; - } else if (width >= 640) { - baseColumnCount = 2; - } - - const columnCount = isFullscreen - ? Math.max(baseColumnCount, 3) - : baseColumnCount; - - if (gridNode) { - gridNode.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; - } - - setGridColumnCount(columnCount); - }, [isFullscreen]); - - const handleFilterToggle = useCallback( - (id: string) => { - setActiveFilters((previous) => { - if (id === "all") { - return []; - } - - const isActive = previous.includes(id); - if (isActive) { - return []; - } - - return [id]; - }); - - requestAnimationFrame(() => { - updateItemColumnPlacement(); - }); - }, - [updateItemColumnPlacement] - ); - - useEffect(() => { - const node = cartGridRef.current; - - if (!node) { - return; - } - - const observer = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(() => { - requestAnimationFrame(updateItemColumnPlacement); - }) - : null; - - observer?.observe(node); - window.addEventListener("resize", updateItemColumnPlacement); - - return () => { - observer?.disconnect(); - window.removeEventListener("resize", updateItemColumnPlacement); - }; - }, [updateItemColumnPlacement]); - - const openCartModal = useCallback( - (anchorElement?: HTMLElement | null) => { - if (isModalView || shouldShowCheckoutOnly) { - return; - } - - requestModalWithAnchor({ - title: "Cart", - params: { - state: "cart", - cartItems, - subtotal, - total, - totalItems, - }, - anchorElement, - }); - }, - [ - cartItems, - isModalView, - requestModalWithAnchor, - shouldShowCheckoutOnly, - subtotal, - total, - totalItems, - ] - ); - - type CartSummaryItem = { - id: string; - name: string; - price: number; - quantity: number; - image?: string; - }; - - const cartSummaryItems: CartSummaryItem[] = useMemo(() => { - if (!isCartModalView) { - return []; - } - - const items = Array.isArray(modalParams?.cartItems) - ? modalParams?.cartItems - : null; - - if (!items) { - return cartItems.map((item) => ({ - id: item.id, - name: item.name, - price: item.price, - quantity: Math.max(0, item.quantity), - image: item.image, - })); - } - - const sanitized = items - .map((raw, index) => { - if (!raw || typeof raw !== "object") { - return null; - } - const candidate = raw as Record; - const id = - typeof candidate.id === "string" ? candidate.id : `cart-${index}`; - const name = - typeof candidate.name === "string" ? candidate.name : "Item"; - const priceValue = Number(candidate.price); - const quantityValue = Number(candidate.quantity); - const price = Number.isFinite(priceValue) ? priceValue : 0; - const quantity = Number.isFinite(quantityValue) - ? Math.max(0, quantityValue) - : 0; - const image = - typeof candidate.image === "string" ? candidate.image : undefined; - - return { - id, - name, - price, - quantity, - image, - } as CartSummaryItem; - }) - .filter(Boolean) as CartSummaryItem[]; - - if (sanitized.length === 0) { - return cartItems.map((item) => ({ - id: item.id, - name: item.name, - price: item.price, - quantity: Math.max(0, item.quantity), - image: item.image, - })); - } - - return sanitized; - }, [cartItems, isCartModalView, modalParams?.cartItems]); - - const cartSummarySubtotal = useMemo(() => { - if (!isCartModalView) { - return subtotal; - } - - const candidate = Number(modalParams?.subtotal); - return Number.isFinite(candidate) ? candidate : subtotal; - }, [isCartModalView, modalParams?.subtotal, subtotal]); - - const cartSummaryTotal = useMemo(() => { - if (!isCartModalView) { - return total; - } - - const candidate = Number(modalParams?.total); - return Number.isFinite(candidate) ? candidate : total; - }, [isCartModalView, modalParams?.total, total]); - - const cartSummaryTotalItems = useMemo(() => { - if (!isCartModalView) { - return totalItems; - } - - const candidate = Number(modalParams?.totalItems); - return Number.isFinite(candidate) ? candidate : totalItems; - }, [isCartModalView, modalParams?.totalItems, totalItems]); - - const handleContinueToPayment = useCallback( - (event?: ReactMouseEvent) => { - const anchorElement = event?.currentTarget ?? null; - - if (typeof window !== "undefined") { - const detail = { - subtotal: isCartModalView ? cartSummarySubtotal : subtotal, - total: isCartModalView ? cartSummaryTotal : total, - totalItems: isCartModalView ? cartSummaryTotalItems : totalItems, - }; - - try { - window.dispatchEvent( - new CustomEvent(CONTINUE_TO_PAYMENT_EVENT, { detail }) - ); - } catch (error) { - console.error("Failed to dispatch checkout navigation event", error); - } - } - - if (isCartModalView) { - return; - } - - manualCheckoutTriggerRef.current = true; - updateWidgetState({ state: "checkout" }); - const shouldNavigateToCheckout = isCartModalView || !isCheckoutRoute; - - if (shouldNavigateToCheckout) { - navigate("/checkout"); - return; - } - - openCheckoutModal(anchorElement); - }, - [ - cartSummarySubtotal, - cartSummaryTotal, - cartSummaryTotalItems, - isCartModalView, - isCheckoutRoute, - navigate, - openCheckoutModal, - subtotal, - total, - totalItems, - updateWidgetState, - ] - ); - - const handleSeeAll = useCallback(async () => { - if (typeof window === "undefined") { - return; - } - - try { - await window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); - } catch (error) { - console.error("Failed to request fullscreen display mode", error); - } - }, []); - - useLayoutEffect(() => { - const raf = requestAnimationFrame(updateItemColumnPlacement); - - return () => { - cancelAnimationFrame(raf); - }; - }, [updateItemColumnPlacement, visibleCartItems]); - - const selectedCartItem = useMemo(() => { - if (selectedCartItemId == null) { - return null; - } - return cartItems.find((item) => item.id === selectedCartItemId) ?? null; - }, [cartItems, selectedCartItemId]); - - const selectedCartItemName = selectedCartItem?.name ?? null; - const shouldShowSelectedCartItemPanel = - selectedCartItem != null && !isFullscreen; - - useEffect(() => { - if (isCheckoutRoute) { - return; - } - - if (!checkoutFromState) { - return; - } - - if (manualCheckoutTriggerRef.current) { - manualCheckoutTriggerRef.current = false; - return; - } - - if (selectedCartItemId) { - openCartItemModal({ - selectedId: selectedCartItemId, - selectedName: selectedCartItemName, - }); - return; - } - - openCheckoutModal(); - }, [ - isCheckoutRoute, - checkoutFromState, - openCartItemModal, - openCheckoutModal, - selectedCartItemId, - selectedCartItemName, - ]); - - const cartPanel = ( -
- {!shouldShowCheckoutOnly && ( -
- {!isFullscreen ? ( -
- -
- ) : ( -
Results
- )} - -
- )} - - -
- - {visibleCartItems.map((item, index) => { - const isHovered = hoveredCartItemId === item.id; - const shortDescription = - item.shortDescription ?? item.description.split(".")[0]; - const columnCount = Math.max(gridColumnCount, 1); - const rowStartIndex = - Math.floor(index / columnCount) * columnCount; - const itemsRemaining = visibleCartItems.length - rowStartIndex; - const rowSize = Math.min(columnCount, itemsRemaining); - const positionInRow = index - rowStartIndex; - - const isSingle = rowSize === 1; - const isLeft = positionInRow === 0; - const isRight = positionInRow === rowSize - 1; - - return ( - - handleCartItemSelect( - item.id, - event.currentTarget as HTMLElement - ) - } - onMouseEnter={() => setHoveredCartItemId(item.id)} - onMouseLeave={() => setHoveredCartItemId(null)} - className={clsx( - "group mb-4 flex cursor-pointer flex-col overflow-hidden border border-transparent bg-white transition-colors", - isHovered && "border-[#0f766e]" - )} - > -
- {item.name} - -
-
-
-
-

- {item.name} -

-

- ${item.price.toFixed(2)} -

-
- {shortDescription ? ( -

- {shortDescription} -

- ) : null} -
-
- - - {item.quantity} - - -
-
-
- - ); - })} - -
- -
- ); - - if (isCartModalView && !isCheckoutRoute) { - return ( -
-
- {cartSummaryItems.length ? ( - cartSummaryItems.map((item) => ( -
-
- {item.image ? ( - {item.name} - ) : null} -
-
-
-
-

- {item.name} -

-

- ${item.price.toFixed(2)} • Qty{" "} - {Math.max(0, item.quantity)} -

-
- - ${(item.price * Math.max(0, item.quantity)).toFixed(2)} - -
-
- )) - ) : ( -

- Your cart is empty. -

- )} -
- -
-
- Subtotal - ${cartSummarySubtotal.toFixed(2)} -
-
- Total - ${cartSummaryTotal.toFixed(2)} -
-
- -
- ); - } - - const checkoutPanel = ( -
- {shouldShowSelectedCartItemPanel ? ( - - ) : ( - - )} -
- ); - - return ( -
-
- {shouldShowCheckoutOnly ? ( - checkoutPanel - ) : isFullscreen ? ( -
-
{cartPanel}
-
{checkoutPanel}
-
- ) : ( - cartPanel - )} - {!isFullscreen && !shouldShowCheckoutOnly && ( -
- -
- )} -
-
- ); +if (!container) { + throw new Error("Missing root element: pizzaz-shop-root"); } -createRoot(document.getElementById("pizzaz-shop-root")!).render( +createRoot(container).render( ); + +export default App; diff --git a/src/types.ts b/src/types.ts index 765164e..150ef08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,9 +18,11 @@ export type OpenAiGlobals< // state toolInput: ToolInput; toolOutput: ToolOutput | null; + toolResponse: ToolResponseEnvelope | null; toolResponseMetadata: ToolResponseMetadata | null; widgetState: WidgetState | null; setWidgetState: (state: WidgetState) => Promise; + view: ViewDescriptor | null; }; // currently copied from types.ts in chatgpt/web-sandbox. @@ -32,6 +34,7 @@ type API = { // Layout controls requestDisplayMode: RequestDisplayMode; + requestModal?: RequestModal; }; export type UnknownObject = Record; @@ -69,10 +72,42 @@ export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{ mode: DisplayMode; }>; +export type ViewDescriptor = { + mode: string; + params?: UnknownObject; +}; + +export type ModalAnchor = { + top: number; + left: number; + width: number; + height: number; +}; + +export type RequestModalArgs = { + title: string; + params?: UnknownObject; + anchor?: ModalAnchor; +}; + +export type RequestModal = (args: RequestModalArgs) => Promise; + export type CallToolResponse = { result: string; }; +export type ToolResponseEnvelope< + ToolOutput = UnknownObject, + ToolResponseMetadata = UnknownObject +> = { + toolName?: string; + status?: "success" | "error" | string; + content?: UnknownObject; + structuredContent?: ToolOutput | null; + metadata?: ToolResponseMetadata | null; + error?: UnknownObject | null; +}; + /** Calling APIs */ export type CallTool = ( name: string, diff --git a/src/use-widget-props.ts b/src/use-widget-props.ts index cef4762..ba305a6 100644 --- a/src/use-widget-props.ts +++ b/src/use-widget-props.ts @@ -1,14 +1,27 @@ import { useOpenAiGlobal } from "./use-openai-global"; +import type { ToolResponseEnvelope } from "./types"; export function useWidgetProps>( defaultState?: T | (() => T) ): T { - const props = useOpenAiGlobal("toolOutput") as T; + const toolResponse = useOpenAiGlobal("toolResponse") as + | ToolResponseEnvelope + | null; + const structuredContent = + typeof toolResponse?.structuredContent === "object" && + toolResponse.structuredContent !== null + ? (toolResponse.structuredContent as T) + : null; + + const props = (structuredContent ?? + (useOpenAiGlobal("toolOutput") as T | null)) as T | null; const fallback = typeof defaultState === "function" ? (defaultState as () => T | null)() : defaultState ?? null; - return props ?? fallback; + const resolved = props ?? fallback; + + return resolved ?? ({} as T); } From 60883d9f5b849da42060cae5112601f086fb0763 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Wed, 12 Nov 2025 17:19:39 -0500 Subject: [PATCH 02/10] Update --- ecommerce_server_python/main.py | 211 +++++++++++++++++++++++++++++++- 1 file changed, 205 insertions(+), 6 deletions(-) diff --git a/ecommerce_server_python/main.py b/ecommerce_server_python/main.py index a87288a..f123c41 100644 --- a/ecommerce_server_python/main.py +++ b/ecommerce_server_python/main.py @@ -9,15 +9,42 @@ from __future__ import annotations +import json +import os from copy import deepcopy from dataclasses import dataclass from functools import lru_cache -import json from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, Iterable, List +from urllib.parse import urlparse import mcp.types as types +from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.fastmcp import FastMCP +from mcp.shared.auth import ProtectedResourceMetadata +from pydantic import AnyHttpUrl +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + + +class SimpleTokenVerifier(TokenVerifier): + """Development helper that blindly accepts any token.""" + + def __init__(self, required_scopes: Iterable[str]): + self.required_scopes: list[str] = list(required_scopes) + + async def verify_token( + self, token: str + ) -> ( + AccessToken | None + ): # TODO: Do not use in production—replace with a real verifier. + return AccessToken( + token=token or "dev_token", + client_id="dev_client", + subject="dev", + scopes=self.required_scopes or [], + claims={"debug": True}, + ) @dataclass(frozen=True) @@ -156,25 +183,82 @@ def _contains_text(value: Any) -> bool: "additionalProperties": False, } +DEFAULT_AUTH_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" +DEFAULT_RESOURCE_SERVER_URL = "http://localhost:8000/mcp" + +# Public URLs that describe this resource server plus the authorization server. +AUTHORIZATION_SERVER_URL = AnyHttpUrl( + os.environ.get("AUTHORIZATION_SERVER_URL", DEFAULT_AUTH_SERVER_URL) +) +RESOURCE_SERVER_URL = "https://5fb2bf13c559.ngrok-free.app" +RESOURCE_SCOPES = ["cart.write"] + +_parsed_resource_url = urlparse(str(RESOURCE_SERVER_URL)) +_resource_path = ( + _parsed_resource_url.path if _parsed_resource_url.path not in ("", "/") else "" +) +PROTECTED_RESOURCE_METADATA_PATH = ( + f"/.well-known/oauth-protected-resource{_resource_path}" +) +PROTECTED_RESOURCE_METADATA_URL = f"{_parsed_resource_url.scheme}://{_parsed_resource_url.netloc}{PROTECTED_RESOURCE_METADATA_PATH}" + +print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) +PROTECTED_RESOURCE_METADATA = ProtectedResourceMetadata( + resource=RESOURCE_SERVER_URL, + authorization_servers=[AUTHORIZATION_SERVER_URL], + scopes_supported=RESOURCE_SCOPES, +) + +# Tool-level securitySchemes inform ChatGPT when OAuth is required for a call. +PUBLIC_TOOL_SECURITY_SCHEMES = [{"type": "noauth"}] +INCREMENT_TOOL_SECURITY_SCHEMES = [ + { + "type": "oauth2", + "scopes": ["cart.write"], + } +] + mcp = FastMCP( name="pizzaz-python", stateless_http=True, + # # Token verifier for authentication + # token_verifier=SimpleTokenVerifier(required_scopes=["cart.write"]), + # # Auth settings for RFC 9728 Protected Resource Metadata + # auth=AuthSettings( + # issuer_url=AnyHttpUrl("https://dev-65wmmp5d56ev40iy.us.auth0.com/"), # Authorization Server URL + # resource_server_url=AnyHttpUrl("https://5fb2bf13c559.ngrok-free.app"), # This server's URL + # required_scopes=["cart.write"], + # ), ) +@mcp.custom_route(PROTECTED_RESOURCE_METADATA_PATH, methods=["GET", "OPTIONS"]) +async def protected_resource_metadata(request: Request) -> Response: + """Expose RFC 9728 metadata so clients can find the Auth0 authorization server.""" + if request.method == "OPTIONS": + return Response(status_code=204) + return JSONResponse(PROTECTED_RESOURCE_METADATA.model_dump(mode="json")) + + def _resource_description(widget: PizzazWidget) -> str: return f"{widget.title} widget markup" -def _tool_meta(widget: PizzazWidget) -> Dict[str, Any]: - return { +def _tool_meta( + widget: PizzazWidget, + security_schemes: List[Dict[str, Any]] | None = None, +) -> Dict[str, Any]: + meta = { "openai/outputTemplate": widget.template_uri, "openai/toolInvocation/invoking": widget.invoking, "openai/toolInvocation/invoked": widget.invoked, "openai/widgetAccessible": True, "openai/resultCanProduceWidget": True, } + if security_schemes is not None: + meta["securitySchemes"] = deepcopy(security_schemes) + return meta def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: @@ -184,15 +268,124 @@ def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: } +def _resource_metadata_url() -> str | None: + auth_config = getattr(mcp.settings, "auth", None) + if auth_config and auth_config.resource_server_url: + parsed = urlparse(str(auth_config.resource_server_url)) + resource_path = parsed.path if parsed.path not in ("", "/") else "" + return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}" + print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) + return PROTECTED_RESOURCE_METADATA_URL + + +def _build_www_authenticate_value(error: str, description: str) -> str: + safe_error = error.replace('"', r"\"") + safe_description = description.replace('"', r"\"") + parts = [ + f'error="{safe_error}"', + f'error_description="{safe_description}"', + ] + resource_metadata = _resource_metadata_url() + if resource_metadata: + parts.append(f'resource_metadata="{resource_metadata}"') + return f"Bearer {', '.join(parts)}" + + +def _oauth_error_result( + user_message: str, + *, + error: str = "invalid_request", + description: str | None = None, +) -> types.ServerResult: + description_text = description or user_message + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=user_message, + ) + ], + _meta={ + "mcp/www_authenticate": [ + _build_www_authenticate_value(error, description_text) + ] + }, + isError=True, + ) + ) + + +def _get_bearer_token_from_request() -> str | None: + try: + request_context = mcp._mcp_server.request_context + except LookupError: + return None + + request = getattr(request_context, "request", None) + if request is None: + return None + + header_value: Any = None + headers = getattr(request, "headers", None) + if headers is not None: + try: + header_value = headers.get("authorization") + if header_value is None: + header_value = headers.get("Authorization") + except Exception: + header_value = None + + if header_value is None: + # Attempt to read from ASGI scope headers if available + scope = getattr(request, "scope", None) + scope_headers = scope.get("headers") if isinstance(scope, dict) else None + if scope_headers: + for key, value in scope_headers: + decoded_key = ( + key.decode("latin-1") + if isinstance(key, (bytes, bytearray)) + else str(key) + ).lower() + if decoded_key == "authorization": + header_value = ( + value.decode("latin-1") + if isinstance(value, (bytes, bytearray)) + else str(value) + ) + break + + if header_value is None and isinstance(request, dict): + # Fall back to dictionary-like request contexts + raw_value = request.get("authorization") or request.get("Authorization") + header_value = raw_value + + if header_value is None: + return None + + if isinstance(header_value, (bytes, bytearray)): + header_value = header_value.decode("latin-1") + + header_value = header_value.strip() + if not header_value.lower().startswith("bearer "): + return None + + token = header_value[7:].strip() + return token or None + + @mcp._mcp_server.list_tools() async def _list_tools() -> List[types.Tool]: + public_tool_meta = _tool_meta(ECOMMERCE_WIDGET, PUBLIC_TOOL_SECURITY_SCHEMES) + increment_tool_meta = _tool_meta(ECOMMERCE_WIDGET, INCREMENT_TOOL_SECURITY_SCHEMES) return [ types.Tool( name=SEARCH_TOOL_NAME, title=ECOMMERCE_WIDGET.title, description="Search the ecommerce catalog using free-text keywords.", inputSchema=SEARCH_TOOL_SCHEMA, - _meta=_tool_meta(ECOMMERCE_WIDGET), + _meta=public_tool_meta, + securitySchemes=list(PUBLIC_TOOL_SECURITY_SCHEMES), # To disable the approval prompt for the tools annotations={ "destructiveHint": False, @@ -205,7 +398,8 @@ async def _list_tools() -> List[types.Tool]: title="Increment Cart Item", description="Increase the quantity of an item already in the cart.", inputSchema=INCREMENT_TOOL_SCHEMA, - _meta=_tool_meta(ECOMMERCE_WIDGET), + _meta=increment_tool_meta, + securitySchemes=list(INCREMENT_TOOL_SECURITY_SCHEMES), # To disable the approval prompt for the tools annotations={ "destructiveHint": False, @@ -300,6 +494,11 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: } response_text = ECOMMERCE_WIDGET.response_text else: + if not _get_bearer_token_from_request(): + return _oauth_error_result( + "Authentication required: no access token provided.", + description="No access token was provided", + ) product_id = str(arguments.get("productId", "")).strip() if not product_id: return types.ServerResult( From 3377e2e122365340364135de9c1979c35c5cbc38 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Tue, 18 Nov 2025 14:08:34 -0500 Subject: [PATCH 03/10] Update for mixed auth --- ecommerce_server_python/main.py | 86 +- ecommerce_server_python/sample_data.json | 126 +- .../sample_mixed_auth_server.ts | 74 + src/ecommerce/index.tsx | 8 +- src/pizzaz-shop/app.tsx | 1542 ++--------------- src/use-widget-props.ts | 2 +- 6 files changed, 263 insertions(+), 1575 deletions(-) create mode 100644 ecommerce_server_python/sample_mixed_auth_server.ts diff --git a/ecommerce_server_python/main.py b/ecommerce_server_python/main.py index f123c41..2aaba94 100644 --- a/ecommerce_server_python/main.py +++ b/ecommerce_server_python/main.py @@ -64,6 +64,10 @@ class PizzazWidget: / "ecommerce_server_python" / "sample_data.json" ) +MATCHA_PIZZA_IMAGE = "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png" +DEFAULT_CART_ITEMS: List[Dict[str, Any]] = [ + {"id": "matcha-pizza", "image": MATCHA_PIZZA_IMAGE, "quantity": 1} +] @lru_cache(maxsize=None) @@ -85,54 +89,53 @@ def _load_widget_html(component_name: str) -> str: @lru_cache(maxsize=1) def _load_ecommerce_cart_items() -> List[Dict[str, Any]]: if not ECOMMERCE_SAMPLE_DATA_PATH.exists(): - return [] + return [deepcopy(item) for item in DEFAULT_CART_ITEMS] try: raw = json.loads(ECOMMERCE_SAMPLE_DATA_PATH.read_text(encoding="utf8")) except json.JSONDecodeError: - return [] + return [deepcopy(item) for item in DEFAULT_CART_ITEMS] items: List[Dict[str, Any]] = [] for entry in raw.get("products", []): - if isinstance(entry, dict): - items.append(entry) + if not isinstance(entry, dict): + continue + sanitized = _sanitize_cart_item(entry) + if sanitized: + items.append(sanitized) - return items + return items or [deepcopy(item) for item in DEFAULT_CART_ITEMS] -def _product_matches_search(item: Dict[str, Any], search_term: str) -> bool: - """Return True if the product matches the provided search term.""" - term = search_term.strip().lower() - if not term: - return True +def _sanitize_cart_item(entry: Dict[str, Any]) -> Dict[str, Any] | None: + identifier = str(entry.get("id", "")).strip() + if not identifier: + return None - def _contains_text(value: Any) -> bool: - return isinstance(value, str) and term in value.lower() + image_candidate = str(entry.get("image", "")).strip() + image = image_candidate or MATCHA_PIZZA_IMAGE - searchable_fields = ( - "name", - "description", - "shortDescription", - "detailSummary", - ) + quantity_raw = entry.get("quantity", 0) + try: + quantity = int(quantity_raw) + except (TypeError, ValueError): + quantity = 0 - for field in searchable_fields: - if _contains_text(item.get(field)): - return True + return { + "id": identifier, + "image": image, + "quantity": max(0, quantity), + } - tags = item.get("tags") - if isinstance(tags, list): - for tag in tags: - if _contains_text(tag): - return True - highlights = item.get("highlights") - if isinstance(highlights, list): - for highlight in highlights: - if _contains_text(highlight): - return True +def _product_matches_search(item: Dict[str, Any], search_term: str) -> bool: + """Return True if the product matches the provided search term.""" + term = search_term.strip().lower() + if not term: + return True - return False + identifier = str(item.get("id", "")).lower() + return term in identifier ECOMMERCE_WIDGET = PizzazWidget( @@ -157,7 +160,7 @@ def _contains_text(value: Any) -> bool: "properties": { "searchTerm": { "type": "string", - "description": "Free-text keywords to filter products by name, description, tags, or highlights.", + "description": "Optional text to match against the product ID (only the Matcha Pizza item is available).", }, }, "required": [], @@ -222,14 +225,6 @@ def _contains_text(value: Any) -> bool: mcp = FastMCP( name="pizzaz-python", stateless_http=True, - # # Token verifier for authentication - # token_verifier=SimpleTokenVerifier(required_scopes=["cart.write"]), - # # Auth settings for RFC 9728 Protected Resource Metadata - # auth=AuthSettings( - # issuer_url=AnyHttpUrl("https://dev-65wmmp5d56ev40iy.us.auth0.com/"), # Authorization Server URL - # resource_server_url=AnyHttpUrl("https://5fb2bf13c559.ngrok-free.app"), # This server's URL - # required_scopes=["cart.write"], - # ), ) @@ -265,6 +260,7 @@ def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: return { "openai/toolInvocation/invoking": widget.invoking, "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetSessionId": "ren-test-session-id", } @@ -491,6 +487,7 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: structured_content: Dict[str, Any] = { "cartItems": filtered_items, "searchTerm": search_term, + "toolCallName": SEARCH_TOOL_NAME, } response_text = ECOMMERCE_WIDGET.response_text else: @@ -566,11 +563,8 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: current_quantity = 0 product["quantity"] = current_quantity + increment_by - structured_content = { - "cartItems": cart_items, - "searchTerm": "", - } - product_name = product.get("name", product_id) + structured_content = {"toolCallName": INCREMENT_TOOL_NAME} + product_name = product.get("id", product_id) response_text = ( f"Incremented {product_name} by {increment_by}. Updated cart ready." ) diff --git a/ecommerce_server_python/sample_data.json b/ecommerce_server_python/sample_data.json index dd5d5c1..b1c8698 100644 --- a/ecommerce_server_python/sample_data.json +++ b/ecommerce_server_python/sample_data.json @@ -1,131 +1,9 @@ { "products": [ - { - "id": "marys-chicken", - "name": "Mary's Chicken", - "price": 19.48, - "description": "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", - "shortDescription": "Organic chicken breasts", - "detailSummary": "4 lbs • $3.99/lb", - "nutritionFacts": [ - { "label": "Protein", "value": "8g" }, - { "label": "Fat", "value": "9g" }, - { "label": "Sugar", "value": "12g" }, - { "label": "Calories", "value": "160" } - ], - "highlights": [ - "No antibiotics or added hormones.", - "Air chilled and never frozen for peak flavor.", - "Raised in the USA on a vegetarian diet." - ], - "tags": ["size"], - "quantity": 2, - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png" - }, - { - "id": "avocados", - "name": "Avocados", - "price": 1, - "description": "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", - "shortDescription": "Creamy Hass avocados", - "detailSummary": "3 ct • $1.00/ea", - "nutritionFacts": [ - { "label": "Fiber", "value": "7g" }, - { "label": "Fat", "value": "15g" }, - { "label": "Potassium", "value": "485mg" }, - { "label": "Calories", "value": "160" } - ], - "highlights": [ - "Perfectly ripe and ready for slicing.", - "Rich in healthy fats and naturally creamy." - ], - "tags": ["vegan"], - "quantity": 2, - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png" - }, - { - "id": "hojicha-pizza", - "name": "Hojicha Pizza", - "price": 15.5, - "description": "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", - "shortDescription": "Smoky hojicha sauce & honey", - "detailSummary": "12\" pie • Serves 2", - "nutritionFacts": [ - { "label": "Protein", "value": "14g" }, - { "label": "Fat", "value": "18g" }, - { "label": "Sugar", "value": "9g" }, - { "label": "Calories", "value": "320" } - ], - "highlights": [ - "Smoky roasted hojicha glaze with honey drizzle.", - "Stone-fired crust with a delicate char." - ], - "tags": ["vegetarian", "size", "spicy"], - "quantity": 2, - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png" - }, - { - "id": "chicken-pizza", - "name": "Chicken Pizza", - "price": 7, - "description": "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", - "shortDescription": "Roasted chicken & pesto", - "detailSummary": "10\" personal • Serves 1", - "nutritionFacts": [ - { "label": "Protein", "value": "20g" }, - { "label": "Fat", "value": "11g" }, - { "label": "Carbs", "value": "36g" }, - { "label": "Calories", "value": "290" } - ], - "highlights": [ - "Roasted chicken with caramelized onions.", - "Fresh basil pesto and mozzarella." - ], - "tags": ["size"], - "quantity": 1, - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png" - }, { "id": "matcha-pizza", - "name": "Matcha Pizza", - "price": 5, - "description": "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", - "shortDescription": "Velvety matcha cream", - "detailSummary": "8\" dessert • Serves 2", - "nutritionFacts": [ - { "label": "Protein", "value": "6g" }, - { "label": "Fat", "value": "10g" }, - { "label": "Sugar", "value": "14g" }, - { "label": "Calories", "value": "240" } - ], - "highlights": [ - "Stone-baked crust with delicate crunch.", - "Matcha mascarpone with white chocolate drizzle." - ], - "tags": ["vegetarian"], - "quantity": 1, - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png" - }, - { - "id": "pesto-pizza", - "name": "Pesto Pizza", - "price": 12.5, - "description": "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", - "shortDescription": "Basil pesto & tomatoes", - "detailSummary": "12\" pie • Serves 2", - "nutritionFacts": [ - { "label": "Protein", "value": "16g" }, - { "label": "Fat", "value": "14g" }, - { "label": "Carbs", "value": "28g" }, - { "label": "Calories", "value": "310" } - ], - "highlights": [ - "House-made pesto with sweet basil and pine nuts.", - "Roasted cherry tomatoes for a pop of acidity." - ], - "tags": ["vegetarian", "size"], - "quantity": 1, - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png" + "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + "quantity": 1 } ] } diff --git a/ecommerce_server_python/sample_mixed_auth_server.ts b/ecommerce_server_python/sample_mixed_auth_server.ts new file mode 100644 index 0000000..a79cbe3 --- /dev/null +++ b/ecommerce_server_python/sample_mixed_auth_server.ts @@ -0,0 +1,74 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const server = new McpServer( + { + name: "Mixed Auth", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +server.registerTool( + "public_echo", + { + description: "Echoes the provided text. Works with or without auth.", + inputSchema: { text: z.string() }, + _meta: { + securitySchemes: [{ type: "noauth" }, { type: "oauth2", scopes: [] }], + }, + }, + async ({ text }, extra) => { + const user = extra.authInfo?.extra?.email ?? ""; + return { + content: [{ type: "text", text: JSON.stringify({ user, echoed: text }) }], + }; + } +); + +server.registerTool( + "increment_item", + { + description: "Ren edited thrice: increments an item (requires OAuth2).", + inputSchema: {}, + _meta: { + securitySchemes: [{ type: "oauth2", scopes: ["secret:read"] }], + }, + }, + async (_args, extra) => { + const user = extra.authInfo?.extra?.email; + + // Unauthenticated / missing token → auth challenge + if (!user) { + const wwwAuthenticate = + 'Bearer error="invalid_request" error_description="No access token was provided" resource_metadata="https://tinymcp.dev/api/mixed-auth-b778ed/mcp"'; + + return { + // MCP tool result + content: [ + { + type: "text", + text: "Authentication required: no access token provided.", + }, + ], + _meta: { + // One or more RFC 9278-style WWW-Authenticate values + "mcp/www_authenticate": [wwwAuthenticate], + }, + // Marks this as an error ToolResponse per the spec + isError: true, + }; + } + + // Authenticated success path + return { + content: [ + { type: "text", text: JSON.stringify({ item: "hunter2", user }) }, + ], + }; + } +); diff --git a/src/ecommerce/index.tsx b/src/ecommerce/index.tsx index 29c260e..ecb3d4e 100644 --- a/src/ecommerce/index.tsx +++ b/src/ecommerce/index.tsx @@ -1,10 +1,8 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; -import { App, type CartItem, type PizzazShopAppProps } from "../pizzaz-shop/app"; +import { App, type PizzazShopAppProps } from "../pizzaz-shop/app"; -export type EcommerceAppProps = Omit & { - defaultCartItems?: CartItem[]; -}; +export type EcommerceAppProps = PizzazShopAppProps; const container = document.getElementById("ecommerce-root"); @@ -14,7 +12,7 @@ if (!container) { createRoot(container).render( - + ); diff --git a/src/pizzaz-shop/app.tsx b/src/pizzaz-shop/app.tsx index cca02ce..aec14c1 100644 --- a/src/pizzaz-shop/app.tsx +++ b/src/pizzaz-shop/app.tsx @@ -1,45 +1,21 @@ -import clsx from "clsx"; -import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; -import { Minus, Plus, ShoppingCart } from "lucide-react"; -import { - type MouseEvent as ReactMouseEvent, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Minus, Plus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useDisplayMode } from "../use-display-mode"; import { useMaxHeight } from "../use-max-height"; -import { useOpenAiGlobal } from "../use-openai-global"; import { useWidgetProps } from "../use-widget-props"; import { useWidgetState } from "../use-widget-state"; - -type NutritionFact = { - label: string; - value: string; -}; +import { useOpenAiGlobal } from "../use-openai-global"; export type CartItem = { id: string; - name: string; - price: number; - description: string; - shortDescription?: string; - detailSummary?: string; - nutritionFacts?: NutritionFact[]; - highlights?: string[]; - tags?: string[]; - quantity: number; image: string; + quantity: number; }; +type CartItemWidgetStateEntry = Pick; + export type PizzazCartWidgetState = { - state?: "checkout" | null; - cartItems?: CartItem[]; - selectedCartItemId?: string | null; + cartItems?: CartItemWidgetStateEntry[]; }; export type PizzazCartWidgetProps = { @@ -47,1418 +23,186 @@ export type PizzazCartWidgetProps = { widgetState?: Partial | null; }; -const SERVICE_FEE = 3; -const DELIVERY_FEE = 2.99; -const TAX_FEE = 3.4; -const CONTINUE_TO_PAYMENT_EVENT = "pizzaz-shop:continue-to-payment"; +export type PizzazShopAppProps = Record; -const FILTERS: Array<{ - id: "all" | "vegetarian" | "vegan" | "size" | "spicy"; - label: string; - tag?: string; -}> = [ - { id: "all", label: "All" }, - { id: "vegetarian", label: "Vegetarian", tag: "vegetarian" }, - { id: "vegan", label: "Vegan", tag: "vegan" }, - { id: "size", label: "Size", tag: "size" }, - { id: "spicy", label: "Spicy", tag: "spicy" }, -]; - -const INITIAL_CART_ITEMS: CartItem[] = [ - { - id: "marys-chicken", - name: "Mary's Chicken", - price: 19.48, - description: - "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", - shortDescription: "Organic chicken breasts", - detailSummary: "4 lbs • $3.99/lb", - nutritionFacts: [ - { label: "Protein", value: "8g" }, - { label: "Fat", value: "9g" }, - { label: "Sugar", value: "12g" }, - { label: "Calories", value: "160" }, - ], - highlights: [ - "No antibiotics or added hormones.", - "Air chilled and never frozen for peak flavor.", - "Raised in the USA on a vegetarian diet.", - ], - quantity: 2, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png", - tags: ["size"], - }, - { - id: "avocados", - name: "Avocados", - price: 1, - description: - "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", - shortDescription: "Creamy Hass avocados", - detailSummary: "3 ct • $1.00/ea", - nutritionFacts: [ - { label: "Fiber", value: "7g" }, - { label: "Fat", value: "15g" }, - { label: "Potassium", value: "485mg" }, - { label: "Calories", value: "160" }, - ], - highlights: [ - "Perfectly ripe and ready for slicing.", - "Rich in healthy fats and naturally creamy.", - ], - quantity: 2, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png", - tags: ["vegan"], - }, - { - id: "hojicha-pizza", - name: "Hojicha Pizza", - price: 15.5, - description: - "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", - shortDescription: "Smoky hojicha sauce & honey", - detailSummary: '12" pie • Serves 2', - nutritionFacts: [ - { label: "Protein", value: "14g" }, - { label: "Fat", value: "18g" }, - { label: "Sugar", value: "9g" }, - { label: "Calories", value: "320" }, - ], - highlights: [ - "Smoky roasted hojicha glaze with honey drizzle.", - "Stone-fired crust with a delicate char.", - ], - quantity: 2, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png", - tags: ["vegetarian", "size", "spicy"], - }, - { - id: "chicken-pizza", - name: "Chicken Pizza", - price: 7, - description: - "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", - shortDescription: "Roasted chicken & pesto", - detailSummary: '10" personal • Serves 1', - nutritionFacts: [ - { label: "Protein", value: "20g" }, - { label: "Fat", value: "11g" }, - { label: "Carbs", value: "36g" }, - { label: "Calories", value: "290" }, - ], - highlights: [ - "Roasted chicken with caramelized onions.", - "Fresh basil pesto and mozzarella.", - ], - quantity: 1, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png", - tags: ["size"], - }, - { - id: "matcha-pizza", - name: "Matcha Pizza", - price: 5, - description: - "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", - shortDescription: "Velvety matcha cream", - detailSummary: '8" dessert • Serves 2', - nutritionFacts: [ - { label: "Protein", value: "6g" }, - { label: "Fat", value: "10g" }, - { label: "Sugar", value: "14g" }, - { label: "Calories", value: "240" }, - ], - highlights: [ - "Stone-baked crust with delicate crunch.", - "Matcha mascarpone with white chocolate drizzle.", - ], - quantity: 1, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", - tags: ["vegetarian"], - }, - { - id: "pesto-pizza", - name: "Pesto Pizza", - price: 12.5, - description: - "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", - shortDescription: "Basil pesto & tomatoes", - detailSummary: '12" pie • Serves 2', - nutritionFacts: [ - { label: "Protein", value: "16g" }, - { label: "Fat", value: "14g" }, - { label: "Carbs", value: "28g" }, - { label: "Calories", value: "310" }, - ], - highlights: [ - "House-made pesto with sweet basil and pine nuts.", - "Roasted cherry tomatoes for a pop of acidity.", - ], - quantity: 1, - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", - tags: ["vegetarian", "size"], - }, -]; - -const cloneCartItem = (item: CartItem): CartItem => ({ - ...item, - nutritionFacts: item.nutritionFacts?.map((fact) => ({ ...fact })), - highlights: item.highlights ? [...item.highlights] : undefined, - tags: item.tags ? [...item.tags] : undefined, -}); - -const createDefaultCartItems = (): CartItem[] => - INITIAL_CART_ITEMS.map((item) => cloneCartItem(item)); - -export type PizzazShopAppProps = { - defaultCartItems?: CartItem[]; +const MATCHA_PIZZA: CartItem = { + id: "matcha-pizza", + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + quantity: 1, }; -const nutritionFactsEqual = ( - a?: NutritionFact[], - b?: NutritionFact[] -): boolean => { - if (!a?.length && !b?.length) { - return true; - } - if (!a || !b || a.length !== b.length) { - return false; - } - return a.every((fact, index) => { - const other = b[index]; - if (!other) { - return false; - } - return fact.label === other.label && fact.value === other.value; - }); -}; +const MATCHA_LABEL = "Matcha Pizza"; -const highlightsEqual = (a?: string[], b?: string[]): boolean => { - if (!a?.length && !b?.length) { - return true; +const buildWidgetState = (quantity: number): PizzazCartWidgetState => { + if (quantity <= 0) { + return { cartItems: [] }; } - if (!a || !b || a.length !== b.length) { - return false; - } - return a.every((highlight, index) => highlight === b[index]); + return { + cartItems: [ + { + id: MATCHA_PIZZA.id, + quantity, + }, + ], + }; }; -const cartItemsEqual = (a: CartItem[], b: CartItem[]): boolean => { - if (a.length !== b.length) { - return false; +const toQuantity = (value: unknown): number | null => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return null; } - for (let i = 0; i < a.length; i += 1) { - const left = a[i]; - const right = b[i]; - if (!right) { - return false; - } - if ( - left.id !== right.id || - left.quantity !== right.quantity || - left.name !== right.name || - left.price !== right.price || - left.description !== right.description || - left.shortDescription !== right.shortDescription || - left.detailSummary !== right.detailSummary || - !nutritionFactsEqual(left.nutritionFacts, right.nutritionFacts) || - !highlightsEqual(left.highlights, right.highlights) || - !highlightsEqual(left.tags, right.tags) || - left.image !== right.image - ) { - return false; - } - } - return true; + return Math.max(0, Math.round(parsed)); }; -type SelectedCartItemPanelProps = { - item: CartItem; - onAdjustQuantity: (id: string, delta: number) => void; -}; - -function SelectedCartItemPanel({ - item, - onAdjustQuantity, -}: SelectedCartItemPanelProps) { - const nutritionFacts = Array.isArray(item.nutritionFacts) - ? item.nutritionFacts - : []; - const highlights = Array.isArray(item.highlights) ? item.highlights : []; - - const hasNutritionFacts = nutritionFacts.length > 0; - const hasHighlights = highlights.length > 0; - - return ( -
-
-
- {item.name} -
-
-
- -
-
-
-

- ${item.price.toFixed(2)} -

-

{item.name}

-
-
- - - {item.quantity} - - -
-
- -

{item.description}

- - {item.detailSummary ? ( -

{item.detailSummary}

- ) : null} - - {hasNutritionFacts ? ( -
- {nutritionFacts.map((fact) => ( -
-

{fact.value}

-

{fact.label}

-
- ))} -
- ) : null} - - {hasHighlights ? ( -
- {highlights.map((highlight, index) => ( -

{highlight}

- ))} -
- ) : null} -
-
- ); -} - -type CheckoutDetailsPanelProps = { - shouldShowCheckoutOnly: boolean; - subtotal: number; - total: number; - onContinueToPayment?: () => void; -}; - -function CheckoutDetailsPanel({ - shouldShowCheckoutOnly, - subtotal, - total, - onContinueToPayment, -}: CheckoutDetailsPanelProps) { - return ( - <> - {!shouldShowCheckoutOnly && ( -
-

Checkout details

-
- )} - -
-
-

Delivery address

-
-
-

- 1234 Main St, San Francisco, CA -

-

- Leave at door - Delivery instructions -

-
- -
-
-
-

Fast

-

- 50 min - 2 hr 10 min -

-
- Free -
-
-
-

Priority

-

35 min

-
- Free -
-
-
- -
-
-

Delivery tip

-

100% goes to the shopper

-
-
- - - - -
-
- -
-
- Subtotal - ${subtotal.toFixed(2)} -
-
- Total - - ${total.toFixed(2)} - -
-

- -
- - ); -} - -export function App({ - defaultCartItems: providedDefaultCartItems, -}: PizzazShopAppProps = {}) { +export function App({}: PizzazShopAppProps = {}) { const maxHeight = useMaxHeight() ?? undefined; const displayMode = useDisplayMode(); const isFullscreen = displayMode === "fullscreen"; const widgetProps = useWidgetProps(() => ({})); - const baseCartItems = useMemo(() => { - if (Array.isArray(providedDefaultCartItems)) { - return providedDefaultCartItems.map((item) => cloneCartItem(item)); - } - return createDefaultCartItems(); - }, [providedDefaultCartItems]); - const computeDefaultWidgetState = useCallback( - (): PizzazCartWidgetState => ({ - state: null, - cartItems: baseCartItems.map((item) => cloneCartItem(item)), - selectedCartItemId: null, - }), - [baseCartItems] + const toolOutput = useOpenAiGlobal("toolOutput") as Record | null; + const [widgetState, setWidgetState] = useWidgetState(() => + buildWidgetState(MATCHA_PIZZA.quantity) ); - const [widgetState, setWidgetState] = useWidgetState( - computeDefaultWidgetState - ); - const navigate = useNavigate(); - const location = useLocation(); - const isCheckoutRoute = useMemo(() => { - const pathname = location?.pathname ?? ""; - if (!pathname) { - return false; - } - - return pathname === "/checkout" || pathname.endsWith("/checkout"); - }, [location?.pathname]); - - const cartGridRef = useRef(null); - const [gridColumnCount, setGridColumnCount] = useState(1); - - const mergeWithDefaultItems = useCallback( - (items: CartItem[]): CartItem[] => { - const existingIds = new Set(items.map((item) => item.id)); - const merged = items.map((item) => { - const defaultItem = baseCartItems.find( - (candidate) => candidate.id === item.id - ); - - if (!defaultItem) { - return cloneCartItem(item); - } - - const enriched: CartItem = { - ...cloneCartItem(defaultItem), - ...item, - tags: item.tags ? [...item.tags] : defaultItem.tags, - nutritionFacts: - item.nutritionFacts ?? - defaultItem.nutritionFacts?.map((fact) => ({ ...fact })), - highlights: - item.highlights != null - ? [...item.highlights] - : defaultItem.highlights - ? [...defaultItem.highlights] - : undefined, - }; - - return cloneCartItem(enriched); - }); - - baseCartItems.forEach((defaultItem) => { - if (!existingIds.has(defaultItem.id)) { - merged.push(cloneCartItem(defaultItem)); - } - }); - - return merged; - }, - [baseCartItems] - ); - - const resolvedCartItems = useMemo(() => { - if (Array.isArray(widgetState?.cartItems) && widgetState.cartItems.length) { - return mergeWithDefaultItems(widgetState.cartItems); - } - - if ( - Array.isArray(widgetProps?.widgetState?.cartItems) && - widgetProps.widgetState.cartItems.length - ) { - return mergeWithDefaultItems(widgetProps.widgetState.cartItems); + const toolCartItem = useMemo(() => { + if (!toolOutput || typeof toolOutput !== "object") { + return null; } - - if (Array.isArray(widgetProps?.cartItems) && widgetProps.cartItems.length) { - return mergeWithDefaultItems(widgetProps.cartItems); + const entries = (toolOutput as { cartItems?: unknown }).cartItems; + if (!Array.isArray(entries)) { + return null; } - - return mergeWithDefaultItems(baseCartItems); - }, [ - baseCartItems, - mergeWithDefaultItems, - widgetProps?.cartItems, - widgetProps?.widgetState?.cartItems, - widgetState, - ]); - - const [cartItems, setCartItems] = useState(resolvedCartItems); - - useEffect(() => { - setCartItems((previous) => - cartItemsEqual(previous, resolvedCartItems) ? previous : resolvedCartItems - ); - }, [resolvedCartItems]); - - const resolvedSelectedCartItemId = - widgetState?.selectedCartItemId ?? - widgetProps?.widgetState?.selectedCartItemId ?? - null; - - const [selectedCartItemId, setSelectedCartItemId] = useState( - resolvedSelectedCartItemId + const first = entries[0]; + return first && typeof first === "object" ? (first as CartItem) : null; + }, [toolOutput]); + const propsCartItem = Array.isArray(widgetProps?.cartItems) + ? (widgetProps.cartItems[0] as CartItem | undefined) ?? null + : null; + const externalQuantity = + toQuantity(propsCartItem?.quantity) ?? toQuantity(toolCartItem?.quantity); + const widgetEntry = widgetState?.cartItems?.[0]; + const widgetQuantity = widgetEntry ? toQuantity(widgetEntry.quantity) : null; + const [quantity, setQuantity] = useState( + widgetQuantity ?? externalQuantity ?? MATCHA_PIZZA.quantity + ); + const widgetStateJson = useMemo( + () => JSON.stringify(widgetState ?? null, null, 2), + [widgetState] ); - useEffect(() => { - setSelectedCartItemId((prev) => - prev === resolvedSelectedCartItemId ? prev : resolvedSelectedCartItemId - ); - }, [resolvedSelectedCartItemId]); - - const view = useOpenAiGlobal("view"); - const viewParams = view?.params; - const isModalView = view?.mode === "modal"; - const checkoutFromState = - (widgetState?.state ?? widgetProps?.widgetState?.state) === "checkout"; - const modalParams = - viewParams && typeof viewParams === "object" - ? (viewParams as { - state?: unknown; - cartItems?: unknown; - subtotal?: unknown; - total?: unknown; - totalItems?: unknown; - }) - : null; - - const modalState = - modalParams && typeof modalParams.state === "string" - ? (modalParams.state as string) - : null; - - const isCartModalView = isModalView && modalState === "cart"; - const shouldShowCheckoutOnly = - isCheckoutRoute || (isModalView && !isCartModalView); - const wasModalViewRef = useRef(isModalView); + console.log("widgetState", widgetState); + }, [widgetState]); useEffect(() => { - if (!viewParams || typeof viewParams !== "object") { - return; - } - - const paramsWithSelection = viewParams as { - selectedCartItemId?: unknown; - }; - - const selectedIdFromParams = paramsWithSelection.selectedCartItemId; - - if ( - typeof selectedIdFromParams === "string" && - selectedIdFromParams !== selectedCartItemId - ) { - setSelectedCartItemId(selectedIdFromParams); + if (widgetQuantity == null) { return; } - - if (selectedIdFromParams === null && selectedCartItemId !== null) { - setSelectedCartItemId(null); - } - }, [selectedCartItemId, viewParams]); - - const [hoveredCartItemId, setHoveredCartItemId] = useState( - null - ); - const [activeFilters, setActiveFilters] = useState([]); - - const updateWidgetState = useCallback( - (partial: Partial) => { - setWidgetState((previous) => ({ - ...computeDefaultWidgetState(), - ...(previous ?? {}), - ...partial, - })); - }, - [computeDefaultWidgetState, setWidgetState] - ); + setQuantity((previous) => + previous === widgetQuantity ? previous : widgetQuantity + ); + }, [widgetQuantity]); useEffect(() => { - if (!Array.isArray(widgetState?.cartItems)) { + if (widgetQuantity != null || externalQuantity == null) { return; } - - const merged = mergeWithDefaultItems(widgetState.cartItems); - - if (!cartItemsEqual(widgetState.cartItems, merged)) { - updateWidgetState({ cartItems: merged }); - } - }, [mergeWithDefaultItems, updateWidgetState, widgetState?.cartItems]); - - useEffect(() => { - if (wasModalViewRef.current && !isModalView && checkoutFromState) { - updateWidgetState({ state: null }); - } - - wasModalViewRef.current = isModalView; - }, [checkoutFromState, isModalView, updateWidgetState]); + setWidgetState(buildWidgetState(externalQuantity)); + }, [externalQuantity, setWidgetState, widgetQuantity]); const adjustQuantity = useCallback( - (id: string, delta: number) => { - setCartItems((previousItems) => { - const updatedItems = previousItems.map((item) => - item.id === id - ? { ...item, quantity: Math.max(0, item.quantity + delta) } - : item - ); - - if (!cartItemsEqual(previousItems, updatedItems)) { - updateWidgetState({ cartItems: updatedItems }); - } - - return updatedItems; - }); - }, - [updateWidgetState] - ); - - useEffect(() => { - if (!shouldShowCheckoutOnly) { - return; - } - - setHoveredCartItemId(null); - }, [shouldShowCheckoutOnly]); - - const manualCheckoutTriggerRef = useRef(false); - - const requestModalWithAnchor = useCallback( - ({ - title, - params, - anchorElement, - }: { - title: string; - params: Record; - anchorElement?: HTMLElement | null; - }) => { - if (isModalView) { + (delta: number) => { + if (!Number.isFinite(delta) || delta === 0) { return; } - - const anchorRect = anchorElement?.getBoundingClientRect(); - const anchor = - anchorRect == null - ? undefined - : { - top: anchorRect.top, - left: anchorRect.left, - width: anchorRect.width, - height: anchorRect.height, - }; - - void (async () => { - try { - await window?.openai?.requestModal?.({ - title, - params, - ...(anchor ? { anchor } : {}), - }); - } catch (error) { - console.error("Failed to open checkout modal", error); - } - })(); - }, - [isModalView] - ); - - const openCheckoutModal = useCallback( - (anchorElement?: HTMLElement | null) => { - requestModalWithAnchor({ - title: "Checkout", - params: { state: "checkout" }, - anchorElement, - }); - }, - [requestModalWithAnchor] - ); - - const openCartItemModal = useCallback( - ({ - selectedId, - selectedName, - anchorElement, - }: { - selectedId: string; - selectedName: string | null; - anchorElement?: HTMLElement | null; - }) => { - requestModalWithAnchor({ - title: selectedName ?? selectedId, - params: { state: "checkout", selectedCartItemId: selectedId }, - anchorElement, - }); - }, - [requestModalWithAnchor] - ); - - const handleCartItemSelect = useCallback( - (id: string, anchorElement?: HTMLElement | null) => { - const itemName = cartItems.find((item) => item.id === id)?.name ?? null; - manualCheckoutTriggerRef.current = true; - setSelectedCartItemId(id); - updateWidgetState({ selectedCartItemId: id, state: "checkout" }); - openCartItemModal({ - selectedId: id, - selectedName: itemName, - anchorElement, - }); - }, - [cartItems, openCartItemModal, updateWidgetState] - ); - - const subtotal = useMemo( - () => - cartItems.reduce( - (total, item) => total + item.price * Math.max(0, item.quantity), - 0 - ), - [cartItems] - ); - - const total = subtotal + SERVICE_FEE + DELIVERY_FEE + TAX_FEE; - - const totalItems = useMemo( - () => - cartItems.reduce((total, item) => total + Math.max(0, item.quantity), 0), - [cartItems] - ); - - const visibleCartItems = useMemo(() => { - if (!activeFilters.length) { - return cartItems; - } - - return cartItems.filter((item) => { - const tags = item.tags ?? []; - - return activeFilters.every((filterId) => { - const filterMeta = FILTERS.find((filter) => filter.id === filterId); - if (!filterMeta?.tag) { - return true; + setQuantity((previous) => { + const next = Math.max(0, previous + delta); + if (next === previous) { + return previous; } - return tags.includes(filterMeta.tag); + setWidgetState(buildWidgetState(next)); + return next; }); - }); - }, [activeFilters, cartItems]); - - const updateItemColumnPlacement = useCallback(() => { - const gridNode = cartGridRef.current; - - const width = gridNode?.offsetWidth ?? 0; - - let baseColumnCount = 1; - if (width >= 768) { - baseColumnCount = 3; - } else if (width >= 640) { - baseColumnCount = 2; - } - - const columnCount = isFullscreen - ? Math.max(baseColumnCount, 3) - : baseColumnCount; - - if (gridNode) { - gridNode.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; - } - - setGridColumnCount(columnCount); - }, [isFullscreen]); - - const handleFilterToggle = useCallback( - (id: string) => { - setActiveFilters((previous) => { - if (id === "all") { - return []; - } - - const isActive = previous.includes(id); - if (isActive) { - return []; - } - - return [id]; - }); - - requestAnimationFrame(() => { - updateItemColumnPlacement(); - }); - }, - [updateItemColumnPlacement] - ); - - useEffect(() => { - const node = cartGridRef.current; - - if (!node) { - return; - } - - const observer = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(() => { - requestAnimationFrame(updateItemColumnPlacement); - }) - : null; - - observer?.observe(node); - window.addEventListener("resize", updateItemColumnPlacement); - - return () => { - observer?.disconnect(); - window.removeEventListener("resize", updateItemColumnPlacement); - }; - }, [updateItemColumnPlacement]); - - const openCartModal = useCallback( - (anchorElement?: HTMLElement | null) => { - if (isModalView || shouldShowCheckoutOnly) { - return; - } - - requestModalWithAnchor({ - title: "Cart", - params: { - state: "cart", - cartItems, - subtotal, - total, - totalItems, - }, - anchorElement, - }); - }, - [ - cartItems, - isModalView, - requestModalWithAnchor, - shouldShowCheckoutOnly, - subtotal, - total, - totalItems, - ] - ); - - type CartSummaryItem = { - id: string; - name: string; - price: number; - quantity: number; - image?: string; - }; - - const cartSummaryItems: CartSummaryItem[] = useMemo(() => { - if (!isCartModalView) { - return []; - } - - const items = Array.isArray(modalParams?.cartItems) - ? modalParams?.cartItems - : null; - - if (!items) { - return cartItems.map((item) => ({ - id: item.id, - name: item.name, - price: item.price, - quantity: Math.max(0, item.quantity), - image: item.image, - })); - } - - const sanitized = items - .map((raw, index) => { - if (!raw || typeof raw !== "object") { - return null; - } - const candidate = raw as Record; - const id = - typeof candidate.id === "string" ? candidate.id : `cart-${index}`; - const name = - typeof candidate.name === "string" ? candidate.name : "Item"; - const priceValue = Number(candidate.price); - const quantityValue = Number(candidate.quantity); - const price = Number.isFinite(priceValue) ? priceValue : 0; - const quantity = Number.isFinite(quantityValue) - ? Math.max(0, quantityValue) - : 0; - const image = - typeof candidate.image === "string" ? candidate.image : undefined; - - return { - id, - name, - price, - quantity, - image, - } as CartSummaryItem; - }) - .filter(Boolean) as CartSummaryItem[]; - - if (sanitized.length === 0) { - return cartItems.map((item) => ({ - id: item.id, - name: item.name, - price: item.price, - quantity: Math.max(0, item.quantity), - image: item.image, - })); - } - - return sanitized; - }, [cartItems, isCartModalView, modalParams?.cartItems]); - - const cartSummarySubtotal = useMemo(() => { - if (!isCartModalView) { - return subtotal; - } - - const candidate = Number(modalParams?.subtotal); - return Number.isFinite(candidate) ? candidate : subtotal; - }, [isCartModalView, modalParams?.subtotal, subtotal]); - - const cartSummaryTotal = useMemo(() => { - if (!isCartModalView) { - return total; - } - - const candidate = Number(modalParams?.total); - return Number.isFinite(candidate) ? candidate : total; - }, [isCartModalView, modalParams?.total, total]); - - const cartSummaryTotalItems = useMemo(() => { - if (!isCartModalView) { - return totalItems; - } - - const candidate = Number(modalParams?.totalItems); - return Number.isFinite(candidate) ? candidate : totalItems; - }, [isCartModalView, modalParams?.totalItems, totalItems]); - - const handleContinueToPayment = useCallback( - (event?: ReactMouseEvent) => { - const anchorElement = event?.currentTarget ?? null; - - if (typeof window !== "undefined") { - const detail = { - subtotal: isCartModalView ? cartSummarySubtotal : subtotal, - total: isCartModalView ? cartSummaryTotal : total, - totalItems: isCartModalView ? cartSummaryTotalItems : totalItems, - }; - - try { - window.dispatchEvent( - new CustomEvent(CONTINUE_TO_PAYMENT_EVENT, { detail }) - ); - } catch (error) { - console.error("Failed to dispatch checkout navigation event", error); - } - } - - if (isCartModalView) { - return; - } - - manualCheckoutTriggerRef.current = true; - updateWidgetState({ state: "checkout" }); - const shouldNavigateToCheckout = isCartModalView || !isCheckoutRoute; - - if (shouldNavigateToCheckout) { - navigate("/checkout"); - return; - } - - openCheckoutModal(anchorElement); }, - [ - cartSummarySubtotal, - cartSummaryTotal, - cartSummaryTotalItems, - isCartModalView, - isCheckoutRoute, - navigate, - openCheckoutModal, - subtotal, - total, - totalItems, - updateWidgetState, - ] - ); - - const handleSeeAll = useCallback(async () => { - if (typeof window === "undefined") { - return; - } - - try { - await window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); - } catch (error) { - console.error("Failed to request fullscreen display mode", error); - } - }, []); - - useLayoutEffect(() => { - const raf = requestAnimationFrame(updateItemColumnPlacement); - - return () => { - cancelAnimationFrame(raf); - }; - }, [updateItemColumnPlacement, visibleCartItems]); + [setWidgetState] + ); + + const image = + (propsCartItem && + typeof propsCartItem.image === "string" && + propsCartItem.image.trim() + ? propsCartItem.image + : null) ?? + (toolCartItem && + typeof toolCartItem.image === "string" && + toolCartItem.image.trim() + ? toolCartItem.image + : null) ?? + MATCHA_PIZZA.image; - const selectedCartItem = useMemo(() => { - if (selectedCartItemId == null) { - return null; - } - return cartItems.find((item) => item.id === selectedCartItemId) ?? null; - }, [cartItems, selectedCartItemId]); - - const selectedCartItemName = selectedCartItem?.name ?? null; - const shouldShowSelectedCartItemPanel = - selectedCartItem != null && !isFullscreen; - - useEffect(() => { - if (isCheckoutRoute) { - return; - } - - if (!checkoutFromState) { - return; - } - - if (manualCheckoutTriggerRef.current) { - manualCheckoutTriggerRef.current = false; - return; - } - - if (selectedCartItemId) { - openCartItemModal({ - selectedId: selectedCartItemId, - selectedName: selectedCartItemName, - }); - return; - } - - openCheckoutModal(); - }, [ - isCheckoutRoute, - checkoutFromState, - openCartItemModal, - openCheckoutModal, - selectedCartItemId, - selectedCartItemName, - ]); - - const cartPanel = ( -
- {!shouldShowCheckoutOnly && ( -
- {!isFullscreen ? ( -
- + return ( +
+
+
+ {MATCHA_LABEL} +
+
+

+ {MATCHA_LABEL} +

+

+ {MATCHA_PIZZA.id} +

- ) : ( -
Results
- )} - -
- )} - - -
- - {visibleCartItems.map((item, index) => { - const isHovered = hoveredCartItemId === item.id; - const shortDescription = - item.shortDescription ?? item.description.split(".")[0]; - const columnCount = Math.max(gridColumnCount, 1); - const rowStartIndex = - Math.floor(index / columnCount) * columnCount; - const itemsRemaining = visibleCartItems.length - rowStartIndex; - const rowSize = Math.min(columnCount, itemsRemaining); - const positionInRow = index - rowStartIndex; - - const isSingle = rowSize === 1; - const isLeft = positionInRow === 0; - const isRight = positionInRow === rowSize - 1; - - return ( - - handleCartItemSelect( - item.id, - event.currentTarget as HTMLElement - ) - } - onMouseEnter={() => setHoveredCartItemId(item.id)} - onMouseLeave={() => setHoveredCartItemId(null)} - className={clsx( - "group mb-4 flex cursor-pointer flex-col overflow-hidden border border-transparent bg-white transition-colors", - isHovered && "border-[#0f766e]" - )} + + {quantity} + + - - {item.quantity} - - -
-
-
- - ); - })} - -
- - - ); - - if (isCartModalView && !isCheckoutRoute) { - return ( -
-
- {cartSummaryItems.length ? ( - cartSummaryItems.map((item) => ( -
-
- {item.image ? ( - {item.name} - ) : null} -
-
-
-
-

- {item.name} -

-

- ${item.price.toFixed(2)} • Qty{" "} - {Math.max(0, item.quantity)} -

-
- - ${(item.price * Math.max(0, item.quantity)).toFixed(2)} - -
+ +
- )) - ) : ( -

- Your cart is empty. -

- )} -
- -
-
- Subtotal - ${cartSummarySubtotal.toFixed(2)} -
-
- Total - ${cartSummaryTotal.toFixed(2)} +
-
- + +
+

+ Widget state +

+
+            {widgetStateJson}
+          
+
- ); - } - - const checkoutPanel = ( -
- {shouldShowSelectedCartItemPanel ? ( - - ) : ( - - )} -
- ); - - return ( -
-
- {shouldShowCheckoutOnly ? ( - checkoutPanel - ) : isFullscreen ? ( -
-
{cartPanel}
-
{checkoutPanel}
-
- ) : ( - cartPanel - )} - {!isFullscreen && !shouldShowCheckoutOnly && ( -
- -
- )} -
); } diff --git a/src/use-widget-props.ts b/src/use-widget-props.ts index ba305a6..a865eb9 100644 --- a/src/use-widget-props.ts +++ b/src/use-widget-props.ts @@ -4,7 +4,7 @@ import type { ToolResponseEnvelope } from "./types"; export function useWidgetProps>( defaultState?: T | (() => T) ): T { - const toolResponse = useOpenAiGlobal("toolResponse") as + const toolResponse = useOpenAiGlobal("toolOutput") as | ToolResponseEnvelope | null; const structuredContent = From 55b10720fccb1fecce0e2170848fbdb14bf3de0f Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Mon, 8 Dec 2025 13:30:12 -0500 Subject: [PATCH 04/10] Add mixed auth example --- .../README.md | 0 authenticated_server_python/main.py | 395 ++++++++++++ .../requirements.txt | 0 ecommerce_server_python/main.py | 609 ------------------ ecommerce_server_python/sample_data.json | 9 - .../sample_mixed_auth_server.ts | 74 --- 6 files changed, 395 insertions(+), 692 deletions(-) rename {ecommerce_server_python => authenticated_server_python}/README.md (100%) create mode 100644 authenticated_server_python/main.py rename {ecommerce_server_python => authenticated_server_python}/requirements.txt (100%) delete mode 100644 ecommerce_server_python/main.py delete mode 100644 ecommerce_server_python/sample_data.json delete mode 100644 ecommerce_server_python/sample_mixed_auth_server.ts diff --git a/ecommerce_server_python/README.md b/authenticated_server_python/README.md similarity index 100% rename from ecommerce_server_python/README.md rename to authenticated_server_python/README.md diff --git a/authenticated_server_python/main.py b/authenticated_server_python/main.py new file mode 100644 index 0000000..e88f7c6 --- /dev/null +++ b/authenticated_server_python/main.py @@ -0,0 +1,395 @@ +"""MCP server for an authenticated app implemented with the Python FastMCP helper.""" + +from __future__ import annotations + +import os +from copy import deepcopy +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, List +from urllib.parse import urlparse + +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.shared.auth import ProtectedResourceMetadata +from pydantic import AnyHttpUrl +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + + +@dataclass(frozen=True) +class PizzazWidget: + identifier: str + title: str + template_uri: str + invoking: str + invoked: str + html: str + response_text: str + + +ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" + + +@lru_cache(maxsize=None) +def _load_widget_html(component_name: str) -> str: + html_path = ASSETS_DIR / f"{component_name}.html" + if html_path.exists(): + return html_path.read_text(encoding="utf8") + + fallback_candidates = sorted(ASSETS_DIR.glob(f"{component_name}-*.html")) + if fallback_candidates: + return fallback_candidates[-1].read_text(encoding="utf8") + + raise FileNotFoundError( + f'Widget HTML for "{component_name}" not found in {ASSETS_DIR}. ' + "Run `pnpm run build` to generate the assets before starting the server." + ) + + +CAROUSEL_WIDGET = 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=_load_widget_html("pizzaz-carousel"), + response_text="Rendered a pizza carousel!", +) + + +MIME_TYPE = "text/html+skybridge" + +SEARCH_TOOL_NAME = CAROUSEL_WIDGET.identifier + +SEARCH_TOOL_SCHEMA: Dict[str, Any] = { + "type": "object", + "title": "Search terms", + "properties": { + "searchTerm": { + "type": "string", + "description": "Optional text to echo back in the response.", + }, + }, + "required": [], + "additionalProperties": False, +} + +DEFAULT_AUTH_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" +DEFAULT_RESOURCE_SERVER_URL = "http://localhost:8000/mcp" + +# Public URLs that describe this resource server plus the authorization server. +AUTHORIZATION_SERVER_URL = AnyHttpUrl( + os.environ.get("AUTHORIZATION_SERVER_URL", DEFAULT_AUTH_SERVER_URL) +) +RESOURCE_SERVER_URL = "https://945c890ee720.ngrok-free.app" +RESOURCE_SCOPES = [] + +_parsed_resource_url = urlparse(str(RESOURCE_SERVER_URL)) +_resource_path = ( + _parsed_resource_url.path if _parsed_resource_url.path not in ("", "/") else "" +) +PROTECTED_RESOURCE_METADATA_PATH = ( + f"/.well-known/oauth-protected-resource{_resource_path}" +) +PROTECTED_RESOURCE_METADATA_URL = f"{_parsed_resource_url.scheme}://{_parsed_resource_url.netloc}{PROTECTED_RESOURCE_METADATA_PATH}" + +print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) +PROTECTED_RESOURCE_METADATA = ProtectedResourceMetadata( + resource=RESOURCE_SERVER_URL, + authorization_servers=[AUTHORIZATION_SERVER_URL], + scopes_supported=RESOURCE_SCOPES, +) + +# Tool-level securitySchemes inform ChatGPT when OAuth is required for a call. +MIXED_TOOL_SECURITY_SCHEMES = [ + {"type": "noauth"}, + { + "type": "oauth2", + "scopes": RESOURCE_SCOPES, + }, +] + + +mcp = FastMCP( + name="pizzaz-python", + stateless_http=True, +) + + +def _resource_metadata_url() -> str | None: + auth_config = getattr(mcp.settings, "auth", None) + if auth_config and auth_config.resource_server_url: + parsed = urlparse(str(auth_config.resource_server_url)) + resource_path = parsed.path if parsed.path not in ("", "/") else "" + return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}" + print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) + return PROTECTED_RESOURCE_METADATA_URL + + +def _build_www_authenticate_value(error: str, description: str) -> str: + safe_error = error.replace('"', r"\"") + safe_description = description.replace('"', r"\"") + parts = [ + f'error="{safe_error}"', + f'error_description="{safe_description}"', + ] + resource_metadata = _resource_metadata_url() + if resource_metadata: + parts.append(f'resource_metadata="{resource_metadata}"') + return f"Bearer {', '.join(parts)}" + + +def _oauth_error_result( + user_message: str, + *, + error: str = "invalid_request", + description: str | None = None, +) -> types.ServerResult: + description_text = description or user_message + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=user_message, + ) + ], + _meta={ + "mcp/www_authenticate": [ + _build_www_authenticate_value(error, description_text) + ] + }, + isError=True, + ) + ) + + +def _get_bearer_token_from_request() -> str | None: + try: + request_context = mcp._mcp_server.request_context + except LookupError: + return None + + request = getattr(request_context, "request", None) + if request is None: + return None + + header_value: Any = None + headers = getattr(request, "headers", None) + if headers is not None: + try: + header_value = headers.get("authorization") + if header_value is None: + header_value = headers.get("Authorization") + except Exception: + header_value = None + + if header_value is None: + # Attempt to read from ASGI scope headers if available + scope = getattr(request, "scope", None) + scope_headers = scope.get("headers") if isinstance(scope, dict) else None + if scope_headers: + for key, value in scope_headers: + decoded_key = ( + key.decode("latin-1") + if isinstance(key, (bytes, bytearray)) + else str(key) + ).lower() + if decoded_key == "authorization": + header_value = ( + value.decode("latin-1") + if isinstance(value, (bytes, bytearray)) + else str(value) + ) + break + + if header_value is None and isinstance(request, dict): + # Fall back to dictionary-like request contexts + raw_value = request.get("authorization") or request.get("Authorization") + header_value = raw_value + + if header_value is None: + return None + + if isinstance(header_value, (bytes, bytearray)): + header_value = header_value.decode("latin-1") + + header_value = header_value.strip() + if not header_value.lower().startswith("bearer "): + return None + + token = header_value[7:].strip() + return token or None + + +@mcp.custom_route(PROTECTED_RESOURCE_METADATA_PATH, methods=["GET", "OPTIONS"]) +async def protected_resource_metadata(request: Request) -> Response: + """Expose RFC 9728 metadata so clients can find the Auth0 authorization server.""" + if request.method == "OPTIONS": + return Response(status_code=204) + return JSONResponse(PROTECTED_RESOURCE_METADATA.model_dump(mode="json")) + + +def _resource_description(widget: PizzazWidget) -> str: + return f"{widget.title} widget markup" + + +def _tool_meta( + widget: PizzazWidget, + security_schemes: List[Dict[str, Any]] | None = None, +) -> Dict[str, Any]: + meta = { + "openai/outputTemplate": widget.template_uri, + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetAccessible": True, + "openai/resultCanProduceWidget": True, + } + if security_schemes is not None: + meta["securitySchemes"] = deepcopy(security_schemes) + return meta + + +def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: + return { + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetSessionId": "ren-test-session-id", + } + + +def _tool_error(message: str) -> types.ServerResult: + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=message)], + isError=True, + ) + ) + + +@mcp._mcp_server.list_tools() +async def _list_tools() -> List[types.Tool]: + tool_meta = _tool_meta(CAROUSEL_WIDGET, MIXED_TOOL_SECURITY_SCHEMES) + return [ + types.Tool( + name=SEARCH_TOOL_NAME, + title=CAROUSEL_WIDGET.title, + description="Echo the provided search terms.", + inputSchema=SEARCH_TOOL_SCHEMA, + _meta=tool_meta, + securitySchemes=list(MIXED_TOOL_SECURITY_SCHEMES), + # To disable the approval prompt for the tools + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ), + ] + + +@mcp._mcp_server.list_resources() +async def _list_resources() -> List[types.Resource]: + return [ + types.Resource( + name=CAROUSEL_WIDGET.title, + title=CAROUSEL_WIDGET.title, + uri=CAROUSEL_WIDGET.template_uri, + description=_resource_description(CAROUSEL_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(CAROUSEL_WIDGET), + ) + ] + + +@mcp._mcp_server.list_resource_templates() +async def _list_resource_templates() -> List[types.ResourceTemplate]: + return [ + types.ResourceTemplate( + name=CAROUSEL_WIDGET.title, + title=CAROUSEL_WIDGET.title, + uriTemplate=CAROUSEL_WIDGET.template_uri, + description=_resource_description(CAROUSEL_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(CAROUSEL_WIDGET), + ) + ] + + +async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: + requested_uri = str(req.params.uri) + if requested_uri != CAROUSEL_WIDGET.template_uri: + return types.ServerResult( + types.ReadResourceResult( + contents=[], + _meta={"error": f"Unknown resource: {req.params.uri}"}, + ) + ) + + contents = [ + types.TextResourceContents( + uri=CAROUSEL_WIDGET.template_uri, + mimeType=MIME_TYPE, + text=CAROUSEL_WIDGET.html, + _meta=_tool_meta(CAROUSEL_WIDGET), + ) + ] + + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + +async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: + tool_name = req.params.name + if tool_name != SEARCH_TOOL_NAME: + return _tool_error(f"Unknown tool: {req.params.name}") + + arguments = req.params.arguments or {} + meta = _tool_invocation_meta(CAROUSEL_WIDGET) + topping = str(arguments.get("searchTerm", "")).strip() + + if not _get_bearer_token_from_request(): + return _oauth_error_result( + "Authentication required: no access token provided.", + description="No access token was provided", + ) + + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="Rendered a pizza carousel!", + ) + ], + 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) diff --git a/ecommerce_server_python/requirements.txt b/authenticated_server_python/requirements.txt similarity index 100% rename from ecommerce_server_python/requirements.txt rename to authenticated_server_python/requirements.txt diff --git a/ecommerce_server_python/main.py b/ecommerce_server_python/main.py deleted file mode 100644 index 2aaba94..0000000 --- a/ecommerce_server_python/main.py +++ /dev/null @@ -1,609 +0,0 @@ -"""Pizzaz demo MCP server implemented with the Python FastMCP helper. - -The server mirrors the Node example in this repository and exposes -widget-backed tools that render the Pizzaz UI bundle. Each handler returns the -HTML shell via an MCP resource and echoes the selected topping as structured -content so the ChatGPT client can hydrate the widget. The module also wires the -handlers into an HTTP/SSE stack so you can run the server with uvicorn on port -8000, matching the Node transport behavior.""" - -from __future__ import annotations - -import json -import os -from copy import deepcopy -from dataclasses import dataclass -from functools import lru_cache -from pathlib import Path -from typing import Any, Dict, Iterable, List -from urllib.parse import urlparse - -import mcp.types as types -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.fastmcp import FastMCP -from mcp.shared.auth import ProtectedResourceMetadata -from pydantic import AnyHttpUrl -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - - -class SimpleTokenVerifier(TokenVerifier): - """Development helper that blindly accepts any token.""" - - def __init__(self, required_scopes: Iterable[str]): - self.required_scopes: list[str] = list(required_scopes) - - async def verify_token( - self, token: str - ) -> ( - AccessToken | None - ): # TODO: Do not use in production—replace with a real verifier. - return AccessToken( - token=token or "dev_token", - client_id="dev_client", - subject="dev", - scopes=self.required_scopes or [], - claims={"debug": True}, - ) - - -@dataclass(frozen=True) -class PizzazWidget: - identifier: str - title: str - template_uri: str - invoking: str - invoked: str - html: str - response_text: str - - -ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" -ECOMMERCE_SAMPLE_DATA_PATH = ( - Path(__file__).resolve().parent.parent - / "ecommerce_server_python" - / "sample_data.json" -) -MATCHA_PIZZA_IMAGE = "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png" -DEFAULT_CART_ITEMS: List[Dict[str, Any]] = [ - {"id": "matcha-pizza", "image": MATCHA_PIZZA_IMAGE, "quantity": 1} -] - - -@lru_cache(maxsize=None) -def _load_widget_html(component_name: str) -> str: - html_path = ASSETS_DIR / f"{component_name}.html" - if html_path.exists(): - return html_path.read_text(encoding="utf8") - - fallback_candidates = sorted(ASSETS_DIR.glob(f"{component_name}-*.html")) - if fallback_candidates: - return fallback_candidates[-1].read_text(encoding="utf8") - - raise FileNotFoundError( - f'Widget HTML for "{component_name}" not found in {ASSETS_DIR}. ' - "Run `pnpm run build` to generate the assets before starting the server." - ) - - -@lru_cache(maxsize=1) -def _load_ecommerce_cart_items() -> List[Dict[str, Any]]: - if not ECOMMERCE_SAMPLE_DATA_PATH.exists(): - return [deepcopy(item) for item in DEFAULT_CART_ITEMS] - - try: - raw = json.loads(ECOMMERCE_SAMPLE_DATA_PATH.read_text(encoding="utf8")) - except json.JSONDecodeError: - return [deepcopy(item) for item in DEFAULT_CART_ITEMS] - - items: List[Dict[str, Any]] = [] - for entry in raw.get("products", []): - if not isinstance(entry, dict): - continue - sanitized = _sanitize_cart_item(entry) - if sanitized: - items.append(sanitized) - - return items or [deepcopy(item) for item in DEFAULT_CART_ITEMS] - - -def _sanitize_cart_item(entry: Dict[str, Any]) -> Dict[str, Any] | None: - identifier = str(entry.get("id", "")).strip() - if not identifier: - return None - - image_candidate = str(entry.get("image", "")).strip() - image = image_candidate or MATCHA_PIZZA_IMAGE - - quantity_raw = entry.get("quantity", 0) - try: - quantity = int(quantity_raw) - except (TypeError, ValueError): - quantity = 0 - - return { - "id": identifier, - "image": image, - "quantity": max(0, quantity), - } - - -def _product_matches_search(item: Dict[str, Any], search_term: str) -> bool: - """Return True if the product matches the provided search term.""" - term = search_term.strip().lower() - if not term: - return True - - identifier = str(item.get("id", "")).lower() - return term in identifier - - -ECOMMERCE_WIDGET = PizzazWidget( - identifier="pizzaz-ecommerce", - title="Show Ecommerce Catalog", - template_uri="ui://widget/ecommerce.html", - invoking="Loading the ecommerce catalog", - invoked="Ecommerce catalog ready", - html=_load_widget_html("ecommerce"), - response_text="Rendered the ecommerce catalog!", -) - - -MIME_TYPE = "text/html+skybridge" - -SEARCH_TOOL_NAME = ECOMMERCE_WIDGET.identifier -INCREMENT_TOOL_NAME = "increment_item" - -SEARCH_TOOL_SCHEMA: Dict[str, Any] = { - "type": "object", - "title": "Product search terms", - "properties": { - "searchTerm": { - "type": "string", - "description": "Optional text to match against the product ID (only the Matcha Pizza item is available).", - }, - }, - "required": [], - "additionalProperties": False, -} - -INCREMENT_TOOL_SCHEMA: Dict[str, Any] = { - "type": "object", - "title": "Increment cart item", - "properties": { - "productId": { - "type": "string", - "description": "Product ID from the catalog to increment.", - }, - "incrementBy": { - "type": "integer", - "minimum": 1, - "default": 1, - "description": "How many units to add to the product quantity (defaults to 1).", - }, - }, - "required": ["productId"], - "additionalProperties": False, -} - -DEFAULT_AUTH_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" -DEFAULT_RESOURCE_SERVER_URL = "http://localhost:8000/mcp" - -# Public URLs that describe this resource server plus the authorization server. -AUTHORIZATION_SERVER_URL = AnyHttpUrl( - os.environ.get("AUTHORIZATION_SERVER_URL", DEFAULT_AUTH_SERVER_URL) -) -RESOURCE_SERVER_URL = "https://5fb2bf13c559.ngrok-free.app" -RESOURCE_SCOPES = ["cart.write"] - -_parsed_resource_url = urlparse(str(RESOURCE_SERVER_URL)) -_resource_path = ( - _parsed_resource_url.path if _parsed_resource_url.path not in ("", "/") else "" -) -PROTECTED_RESOURCE_METADATA_PATH = ( - f"/.well-known/oauth-protected-resource{_resource_path}" -) -PROTECTED_RESOURCE_METADATA_URL = f"{_parsed_resource_url.scheme}://{_parsed_resource_url.netloc}{PROTECTED_RESOURCE_METADATA_PATH}" - -print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) -PROTECTED_RESOURCE_METADATA = ProtectedResourceMetadata( - resource=RESOURCE_SERVER_URL, - authorization_servers=[AUTHORIZATION_SERVER_URL], - scopes_supported=RESOURCE_SCOPES, -) - -# Tool-level securitySchemes inform ChatGPT when OAuth is required for a call. -PUBLIC_TOOL_SECURITY_SCHEMES = [{"type": "noauth"}] -INCREMENT_TOOL_SECURITY_SCHEMES = [ - { - "type": "oauth2", - "scopes": ["cart.write"], - } -] - - -mcp = FastMCP( - name="pizzaz-python", - stateless_http=True, -) - - -@mcp.custom_route(PROTECTED_RESOURCE_METADATA_PATH, methods=["GET", "OPTIONS"]) -async def protected_resource_metadata(request: Request) -> Response: - """Expose RFC 9728 metadata so clients can find the Auth0 authorization server.""" - if request.method == "OPTIONS": - return Response(status_code=204) - return JSONResponse(PROTECTED_RESOURCE_METADATA.model_dump(mode="json")) - - -def _resource_description(widget: PizzazWidget) -> str: - return f"{widget.title} widget markup" - - -def _tool_meta( - widget: PizzazWidget, - security_schemes: List[Dict[str, Any]] | None = None, -) -> Dict[str, Any]: - meta = { - "openai/outputTemplate": widget.template_uri, - "openai/toolInvocation/invoking": widget.invoking, - "openai/toolInvocation/invoked": widget.invoked, - "openai/widgetAccessible": True, - "openai/resultCanProduceWidget": True, - } - if security_schemes is not None: - meta["securitySchemes"] = deepcopy(security_schemes) - return meta - - -def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: - return { - "openai/toolInvocation/invoking": widget.invoking, - "openai/toolInvocation/invoked": widget.invoked, - "openai/widgetSessionId": "ren-test-session-id", - } - - -def _resource_metadata_url() -> str | None: - auth_config = getattr(mcp.settings, "auth", None) - if auth_config and auth_config.resource_server_url: - parsed = urlparse(str(auth_config.resource_server_url)) - resource_path = parsed.path if parsed.path not in ("", "/") else "" - return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}" - print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) - return PROTECTED_RESOURCE_METADATA_URL - - -def _build_www_authenticate_value(error: str, description: str) -> str: - safe_error = error.replace('"', r"\"") - safe_description = description.replace('"', r"\"") - parts = [ - f'error="{safe_error}"', - f'error_description="{safe_description}"', - ] - resource_metadata = _resource_metadata_url() - if resource_metadata: - parts.append(f'resource_metadata="{resource_metadata}"') - return f"Bearer {', '.join(parts)}" - - -def _oauth_error_result( - user_message: str, - *, - error: str = "invalid_request", - description: str | None = None, -) -> types.ServerResult: - description_text = description or user_message - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=user_message, - ) - ], - _meta={ - "mcp/www_authenticate": [ - _build_www_authenticate_value(error, description_text) - ] - }, - isError=True, - ) - ) - - -def _get_bearer_token_from_request() -> str | None: - try: - request_context = mcp._mcp_server.request_context - except LookupError: - return None - - request = getattr(request_context, "request", None) - if request is None: - return None - - header_value: Any = None - headers = getattr(request, "headers", None) - if headers is not None: - try: - header_value = headers.get("authorization") - if header_value is None: - header_value = headers.get("Authorization") - except Exception: - header_value = None - - if header_value is None: - # Attempt to read from ASGI scope headers if available - scope = getattr(request, "scope", None) - scope_headers = scope.get("headers") if isinstance(scope, dict) else None - if scope_headers: - for key, value in scope_headers: - decoded_key = ( - key.decode("latin-1") - if isinstance(key, (bytes, bytearray)) - else str(key) - ).lower() - if decoded_key == "authorization": - header_value = ( - value.decode("latin-1") - if isinstance(value, (bytes, bytearray)) - else str(value) - ) - break - - if header_value is None and isinstance(request, dict): - # Fall back to dictionary-like request contexts - raw_value = request.get("authorization") or request.get("Authorization") - header_value = raw_value - - if header_value is None: - return None - - if isinstance(header_value, (bytes, bytearray)): - header_value = header_value.decode("latin-1") - - header_value = header_value.strip() - if not header_value.lower().startswith("bearer "): - return None - - token = header_value[7:].strip() - return token or None - - -@mcp._mcp_server.list_tools() -async def _list_tools() -> List[types.Tool]: - public_tool_meta = _tool_meta(ECOMMERCE_WIDGET, PUBLIC_TOOL_SECURITY_SCHEMES) - increment_tool_meta = _tool_meta(ECOMMERCE_WIDGET, INCREMENT_TOOL_SECURITY_SCHEMES) - return [ - types.Tool( - name=SEARCH_TOOL_NAME, - title=ECOMMERCE_WIDGET.title, - description="Search the ecommerce catalog using free-text keywords.", - inputSchema=SEARCH_TOOL_SCHEMA, - _meta=public_tool_meta, - securitySchemes=list(PUBLIC_TOOL_SECURITY_SCHEMES), - # To disable the approval prompt for the tools - annotations={ - "destructiveHint": False, - "openWorldHint": False, - "readOnlyHint": True, - }, - ), - types.Tool( - name=INCREMENT_TOOL_NAME, - title="Increment Cart Item", - description="Increase the quantity of an item already in the cart.", - inputSchema=INCREMENT_TOOL_SCHEMA, - _meta=increment_tool_meta, - securitySchemes=list(INCREMENT_TOOL_SECURITY_SCHEMES), - # To disable the approval prompt for the tools - annotations={ - "destructiveHint": False, - "openWorldHint": False, - "readOnlyHint": True, - }, - ), - ] - - -@mcp._mcp_server.list_resources() -async def _list_resources() -> List[types.Resource]: - return [ - types.Resource( - name=ECOMMERCE_WIDGET.title, - title=ECOMMERCE_WIDGET.title, - uri=ECOMMERCE_WIDGET.template_uri, - description=_resource_description(ECOMMERCE_WIDGET), - mimeType=MIME_TYPE, - _meta=_tool_meta(ECOMMERCE_WIDGET), - ) - ] - - -@mcp._mcp_server.list_resource_templates() -async def _list_resource_templates() -> List[types.ResourceTemplate]: - return [ - types.ResourceTemplate( - name=ECOMMERCE_WIDGET.title, - title=ECOMMERCE_WIDGET.title, - uriTemplate=ECOMMERCE_WIDGET.template_uri, - description=_resource_description(ECOMMERCE_WIDGET), - mimeType=MIME_TYPE, - _meta=_tool_meta(ECOMMERCE_WIDGET), - ) - ] - - -async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: - requested_uri = str(req.params.uri) - if requested_uri != ECOMMERCE_WIDGET.template_uri: - return types.ServerResult( - types.ReadResourceResult( - contents=[], - _meta={"error": f"Unknown resource: {req.params.uri}"}, - ) - ) - - contents = [ - types.TextResourceContents( - uri=ECOMMERCE_WIDGET.template_uri, - mimeType=MIME_TYPE, - text=ECOMMERCE_WIDGET.html, - _meta=_tool_meta(ECOMMERCE_WIDGET), - ) - ] - - return types.ServerResult(types.ReadResourceResult(contents=contents)) - - -async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: - tool_name = req.params.name - if tool_name not in {SEARCH_TOOL_NAME, INCREMENT_TOOL_NAME}: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Unknown tool: {req.params.name}", - ) - ], - isError=True, - ) - ) - - arguments = req.params.arguments or {} - meta = _tool_invocation_meta(ECOMMERCE_WIDGET) - cart_items = [deepcopy(item) for item in _load_ecommerce_cart_items()] - - if tool_name == SEARCH_TOOL_NAME: - search_term = str(arguments.get("searchTerm", "")).strip() - filtered_items = cart_items - if search_term: - filtered_items = [ - item - for item in cart_items - if _product_matches_search(item, search_term) - ] - structured_content: Dict[str, Any] = { - "cartItems": filtered_items, - "searchTerm": search_term, - "toolCallName": SEARCH_TOOL_NAME, - } - response_text = ECOMMERCE_WIDGET.response_text - else: - if not _get_bearer_token_from_request(): - return _oauth_error_result( - "Authentication required: no access token provided.", - description="No access token was provided", - ) - product_id = str(arguments.get("productId", "")).strip() - if not product_id: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text="productId is required to increment a cart item.", - ) - ], - isError=True, - ) - ) - - increment_raw = arguments.get("incrementBy", 1) - try: - increment_by = int(increment_raw) - except (TypeError, ValueError): - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text="incrementBy must be an integer.", - ) - ], - isError=True, - ) - ) - - if increment_by < 1: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text="incrementBy must be at least 1.", - ) - ], - isError=True, - ) - ) - - product = next( - (item for item in cart_items if item.get("id") == product_id), - None, - ) - if product is None: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Product '{product_id}' was not found in the cart.", - ) - ], - isError=True, - ) - ) - - current_quantity_raw = product.get("quantity", 0) - try: - current_quantity = int(current_quantity_raw) - except (TypeError, ValueError): - current_quantity = 0 - product["quantity"] = current_quantity + increment_by - - structured_content = {"toolCallName": INCREMENT_TOOL_NAME} - product_name = product.get("id", product_id) - response_text = ( - f"Incremented {product_name} by {increment_by}. Updated cart ready." - ) - - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=response_text, - ) - ], - structuredContent=structured_content, - _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) diff --git a/ecommerce_server_python/sample_data.json b/ecommerce_server_python/sample_data.json deleted file mode 100644 index b1c8698..0000000 --- a/ecommerce_server_python/sample_data.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "products": [ - { - "id": "matcha-pizza", - "image": "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", - "quantity": 1 - } - ] -} diff --git a/ecommerce_server_python/sample_mixed_auth_server.ts b/ecommerce_server_python/sample_mixed_auth_server.ts deleted file mode 100644 index a79cbe3..0000000 --- a/ecommerce_server_python/sample_mixed_auth_server.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -const server = new McpServer( - { - name: "Mixed Auth", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } -); - -server.registerTool( - "public_echo", - { - description: "Echoes the provided text. Works with or without auth.", - inputSchema: { text: z.string() }, - _meta: { - securitySchemes: [{ type: "noauth" }, { type: "oauth2", scopes: [] }], - }, - }, - async ({ text }, extra) => { - const user = extra.authInfo?.extra?.email ?? ""; - return { - content: [{ type: "text", text: JSON.stringify({ user, echoed: text }) }], - }; - } -); - -server.registerTool( - "increment_item", - { - description: "Ren edited thrice: increments an item (requires OAuth2).", - inputSchema: {}, - _meta: { - securitySchemes: [{ type: "oauth2", scopes: ["secret:read"] }], - }, - }, - async (_args, extra) => { - const user = extra.authInfo?.extra?.email; - - // Unauthenticated / missing token → auth challenge - if (!user) { - const wwwAuthenticate = - 'Bearer error="invalid_request" error_description="No access token was provided" resource_metadata="https://tinymcp.dev/api/mixed-auth-b778ed/mcp"'; - - return { - // MCP tool result - content: [ - { - type: "text", - text: "Authentication required: no access token provided.", - }, - ], - _meta: { - // One or more RFC 9278-style WWW-Authenticate values - "mcp/www_authenticate": [wwwAuthenticate], - }, - // Marks this as an error ToolResponse per the spec - isError: true, - }; - } - - // Authenticated success path - return { - content: [ - { type: "text", text: JSON.stringify({ item: "hunter2", user }) }, - ], - }; - } -); From 6e0c1284f61532a904f9aea4f5bf9a0602c52eca Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Mon, 8 Dec 2025 14:15:55 -0500 Subject: [PATCH 05/10] Remove changes to other apps --- src/ecommerce/index.tsx | 19 - src/pizzaz-shop/app.tsx | 208 ------ src/pizzaz-shop/index.tsx | 1457 ++++++++++++++++++++++++++++++++++++- src/types.ts | 35 - src/use-widget-props.ts | 17 +- 5 files changed, 1450 insertions(+), 286 deletions(-) delete mode 100644 src/ecommerce/index.tsx delete mode 100644 src/pizzaz-shop/app.tsx diff --git a/src/ecommerce/index.tsx b/src/ecommerce/index.tsx deleted file mode 100644 index ecb3d4e..0000000 --- a/src/ecommerce/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; -import { App, type PizzazShopAppProps } from "../pizzaz-shop/app"; - -export type EcommerceAppProps = PizzazShopAppProps; - -const container = document.getElementById("ecommerce-root"); - -if (!container) { - throw new Error("Missing root element: ecommerce-root"); -} - -createRoot(container).render( - - - -); - -export default App; diff --git a/src/pizzaz-shop/app.tsx b/src/pizzaz-shop/app.tsx deleted file mode 100644 index aec14c1..0000000 --- a/src/pizzaz-shop/app.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { Minus, Plus } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useDisplayMode } from "../use-display-mode"; -import { useMaxHeight } from "../use-max-height"; -import { useWidgetProps } from "../use-widget-props"; -import { useWidgetState } from "../use-widget-state"; -import { useOpenAiGlobal } from "../use-openai-global"; - -export type CartItem = { - id: string; - image: string; - quantity: number; -}; - -type CartItemWidgetStateEntry = Pick; - -export type PizzazCartWidgetState = { - cartItems?: CartItemWidgetStateEntry[]; -}; - -export type PizzazCartWidgetProps = { - cartItems?: CartItem[]; - widgetState?: Partial | null; -}; - -export type PizzazShopAppProps = Record; - -const MATCHA_PIZZA: CartItem = { - id: "matcha-pizza", - image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", - quantity: 1, -}; - -const MATCHA_LABEL = "Matcha Pizza"; - -const buildWidgetState = (quantity: number): PizzazCartWidgetState => { - if (quantity <= 0) { - return { cartItems: [] }; - } - return { - cartItems: [ - { - id: MATCHA_PIZZA.id, - quantity, - }, - ], - }; -}; - -const toQuantity = (value: unknown): number | null => { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return null; - } - return Math.max(0, Math.round(parsed)); -}; - -export function App({}: PizzazShopAppProps = {}) { - const maxHeight = useMaxHeight() ?? undefined; - const displayMode = useDisplayMode(); - const isFullscreen = displayMode === "fullscreen"; - const widgetProps = useWidgetProps(() => ({})); - const toolOutput = useOpenAiGlobal("toolOutput") as Record | null; - const [widgetState, setWidgetState] = useWidgetState(() => - buildWidgetState(MATCHA_PIZZA.quantity) - ); - const toolCartItem = useMemo(() => { - if (!toolOutput || typeof toolOutput !== "object") { - return null; - } - const entries = (toolOutput as { cartItems?: unknown }).cartItems; - if (!Array.isArray(entries)) { - return null; - } - const first = entries[0]; - return first && typeof first === "object" ? (first as CartItem) : null; - }, [toolOutput]); - const propsCartItem = Array.isArray(widgetProps?.cartItems) - ? (widgetProps.cartItems[0] as CartItem | undefined) ?? null - : null; - const externalQuantity = - toQuantity(propsCartItem?.quantity) ?? toQuantity(toolCartItem?.quantity); - const widgetEntry = widgetState?.cartItems?.[0]; - const widgetQuantity = widgetEntry ? toQuantity(widgetEntry.quantity) : null; - const [quantity, setQuantity] = useState( - widgetQuantity ?? externalQuantity ?? MATCHA_PIZZA.quantity - ); - const widgetStateJson = useMemo( - () => JSON.stringify(widgetState ?? null, null, 2), - [widgetState] - ); - useEffect(() => { - console.log("widgetState", widgetState); - }, [widgetState]); - - useEffect(() => { - if (widgetQuantity == null) { - return; - } - setQuantity((previous) => - previous === widgetQuantity ? previous : widgetQuantity - ); - }, [widgetQuantity]); - - useEffect(() => { - if (widgetQuantity != null || externalQuantity == null) { - return; - } - setWidgetState(buildWidgetState(externalQuantity)); - }, [externalQuantity, setWidgetState, widgetQuantity]); - - const adjustQuantity = useCallback( - (delta: number) => { - if (!Number.isFinite(delta) || delta === 0) { - return; - } - setQuantity((previous) => { - const next = Math.max(0, previous + delta); - if (next === previous) { - return previous; - } - setWidgetState(buildWidgetState(next)); - return next; - }); - }, - [setWidgetState] - ); - - const image = - (propsCartItem && - typeof propsCartItem.image === "string" && - propsCartItem.image.trim() - ? propsCartItem.image - : null) ?? - (toolCartItem && - typeof toolCartItem.image === "string" && - toolCartItem.image.trim() - ? toolCartItem.image - : null) ?? - MATCHA_PIZZA.image; - - return ( -
-
-
- {MATCHA_LABEL} -
-
-

- {MATCHA_LABEL} -

-

- {MATCHA_PIZZA.id} -

-
-
-

- The matcha dessert pie is the only product in this demo. Use the - buttons to adjust the quantity or call the increment tool from your - MCP client. -

-
- - - {quantity} - - -
-
-
-
-
-

- Widget state -

-
-            {widgetStateJson}
-          
-
-
-
- ); -} diff --git a/src/pizzaz-shop/index.tsx b/src/pizzaz-shop/index.tsx index cbd252a..3fcc4cc 100644 --- a/src/pizzaz-shop/index.tsx +++ b/src/pizzaz-shop/index.tsx @@ -1,19 +1,1458 @@ +import clsx from "clsx"; +import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; +import { Minus, Plus, ShoppingCart } from "lucide-react"; +import { + type MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; -import { App } from "./app"; +import { BrowserRouter, useLocation, useNavigate } from "react-router-dom"; +import { useDisplayMode } from "../use-display-mode"; +import { useMaxHeight } from "../use-max-height"; +import { useOpenAiGlobal } from "../use-openai-global"; +import { useWidgetProps } from "../use-widget-props"; +import { useWidgetState } from "../use-widget-state"; -export * from "./app"; +type NutritionFact = { + label: string; + value: string; +}; -const container = document.getElementById("pizzaz-shop-root"); +type CartItem = { + id: string; + name: string; + price: number; + description: string; + shortDescription?: string; + detailSummary?: string; + nutritionFacts?: NutritionFact[]; + highlights?: string[]; + tags?: string[]; + quantity: number; + image: string; +}; -if (!container) { - throw new Error("Missing root element: pizzaz-shop-root"); +type PizzazCartWidgetState = { + state?: "checkout" | null; + cartItems?: CartItem[]; + selectedCartItemId?: string | null; +}; + +type PizzazCartWidgetProps = { + cartItems?: CartItem[]; + widgetState?: Partial | null; +}; + +const SERVICE_FEE = 3; +const DELIVERY_FEE = 2.99; +const TAX_FEE = 3.4; +const CONTINUE_TO_PAYMENT_EVENT = "pizzaz-shop:continue-to-payment"; + +const FILTERS: Array<{ + id: "all" | "vegetarian" | "vegan" | "size" | "spicy"; + label: string; + tag?: string; +}> = [ + { id: "all", label: "All" }, + { id: "vegetarian", label: "Vegetarian", tag: "vegetarian" }, + { id: "vegan", label: "Vegan", tag: "vegan" }, + { id: "size", label: "Size", tag: "size" }, + { id: "spicy", label: "Spicy", tag: "spicy" }, +]; + +const INITIAL_CART_ITEMS: CartItem[] = [ + { + id: "marys-chicken", + name: "Mary's Chicken", + price: 19.48, + description: + "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", + shortDescription: "Organic chicken breasts", + detailSummary: "4 lbs • $3.99/lb", + nutritionFacts: [ + { label: "Protein", value: "8g" }, + { label: "Fat", value: "9g" }, + { label: "Sugar", value: "12g" }, + { label: "Calories", value: "160" }, + ], + highlights: [ + "No antibiotics or added hormones.", + "Air chilled and never frozen for peak flavor.", + "Raised in the USA on a vegetarian diet.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png", + tags: ["size"], + }, + { + id: "avocados", + name: "Avocados", + price: 1, + description: + "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", + shortDescription: "Creamy Hass avocados", + detailSummary: "3 ct • $1.00/ea", + nutritionFacts: [ + { label: "Fiber", value: "7g" }, + { label: "Fat", value: "15g" }, + { label: "Potassium", value: "485mg" }, + { label: "Calories", value: "160" }, + ], + highlights: [ + "Perfectly ripe and ready for slicing.", + "Rich in healthy fats and naturally creamy.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png", + tags: ["vegan"], + }, + { + id: "hojicha-pizza", + name: "Hojicha Pizza", + price: 15.5, + description: + "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", + shortDescription: "Smoky hojicha sauce & honey", + detailSummary: '12" pie • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "14g" }, + { label: "Fat", value: "18g" }, + { label: "Sugar", value: "9g" }, + { label: "Calories", value: "320" }, + ], + highlights: [ + "Smoky roasted hojicha glaze with honey drizzle.", + "Stone-fired crust with a delicate char.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png", + tags: ["vegetarian", "size", "spicy"], + }, + { + id: "chicken-pizza", + name: "Chicken Pizza", + price: 7, + description: + "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", + shortDescription: "Roasted chicken & pesto", + detailSummary: '10" personal • Serves 1', + nutritionFacts: [ + { label: "Protein", value: "20g" }, + { label: "Fat", value: "11g" }, + { label: "Carbs", value: "36g" }, + { label: "Calories", value: "290" }, + ], + highlights: [ + "Roasted chicken with caramelized onions.", + "Fresh basil pesto and mozzarella.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png", + tags: ["size"], + }, + { + id: "matcha-pizza", + name: "Matcha Pizza", + price: 5, + description: + "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", + shortDescription: "Velvety matcha cream", + detailSummary: '8" dessert • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "6g" }, + { label: "Fat", value: "10g" }, + { label: "Sugar", value: "14g" }, + { label: "Calories", value: "240" }, + ], + highlights: [ + "Stone-baked crust with delicate crunch.", + "Matcha mascarpone with white chocolate drizzle.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + tags: ["vegetarian"], + }, + { + id: "pesto-pizza", + name: "Pesto Pizza", + price: 12.5, + description: + "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", + shortDescription: "Basil pesto & tomatoes", + detailSummary: '12" pie • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "16g" }, + { label: "Fat", value: "14g" }, + { label: "Carbs", value: "28g" }, + { label: "Calories", value: "310" }, + ], + highlights: [ + "House-made pesto with sweet basil and pine nuts.", + "Roasted cherry tomatoes for a pop of acidity.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + tags: ["vegetarian", "size"], + }, +]; + +const cloneCartItem = (item: CartItem): CartItem => ({ + ...item, + nutritionFacts: item.nutritionFacts?.map((fact) => ({ ...fact })), + highlights: item.highlights ? [...item.highlights] : undefined, + tags: item.tags ? [...item.tags] : undefined, +}); + +const createDefaultCartItems = (): CartItem[] => + INITIAL_CART_ITEMS.map((item) => cloneCartItem(item)); + +const createDefaultWidgetState = (): PizzazCartWidgetState => ({ + state: null, + cartItems: createDefaultCartItems(), + selectedCartItemId: null, +}); + +const nutritionFactsEqual = ( + a?: NutritionFact[], + b?: NutritionFact[] +): boolean => { + if (!a?.length && !b?.length) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + return a.every((fact, index) => { + const other = b[index]; + if (!other) { + return false; + } + return fact.label === other.label && fact.value === other.value; + }); +}; + +const highlightsEqual = (a?: string[], b?: string[]): boolean => { + if (!a?.length && !b?.length) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + return a.every((highlight, index) => highlight === b[index]); +}; + +const cartItemsEqual = (a: CartItem[], b: CartItem[]): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!right) { + return false; + } + if ( + left.id !== right.id || + left.quantity !== right.quantity || + left.name !== right.name || + left.price !== right.price || + left.description !== right.description || + left.shortDescription !== right.shortDescription || + left.detailSummary !== right.detailSummary || + !nutritionFactsEqual(left.nutritionFacts, right.nutritionFacts) || + !highlightsEqual(left.highlights, right.highlights) || + !highlightsEqual(left.tags, right.tags) || + left.image !== right.image + ) { + return false; + } + } + return true; +}; + +type SelectedCartItemPanelProps = { + item: CartItem; + onAdjustQuantity: (id: string, delta: number) => void; +}; + +function SelectedCartItemPanel({ + item, + onAdjustQuantity, +}: SelectedCartItemPanelProps) { + const nutritionFacts = Array.isArray(item.nutritionFacts) + ? item.nutritionFacts + : []; + const highlights = Array.isArray(item.highlights) ? item.highlights : []; + + const hasNutritionFacts = nutritionFacts.length > 0; + const hasHighlights = highlights.length > 0; + + return ( +
+
+
+ {item.name} +
+
+
+ +
+
+
+

+ ${item.price.toFixed(2)} +

+

{item.name}

+
+
+ + + {item.quantity} + + +
+
+ +

{item.description}

+ + {item.detailSummary ? ( +

{item.detailSummary}

+ ) : null} + + {hasNutritionFacts ? ( +
+ {nutritionFacts.map((fact) => ( +
+

{fact.value}

+

{fact.label}

+
+ ))} +
+ ) : null} + + {hasHighlights ? ( +
+ {highlights.map((highlight, index) => ( +

{highlight}

+ ))} +
+ ) : null} +
+
+ ); +} + +type CheckoutDetailsPanelProps = { + shouldShowCheckoutOnly: boolean; + subtotal: number; + total: number; + onContinueToPayment?: () => void; +}; + +function CheckoutDetailsPanel({ + shouldShowCheckoutOnly, + subtotal, + total, + onContinueToPayment, +}: CheckoutDetailsPanelProps) { + return ( + <> + {!shouldShowCheckoutOnly && ( +
+

Checkout details

+
+ )} + +
+
+

Delivery address

+
+
+

+ 1234 Main St, San Francisco, CA +

+

+ Leave at door - Delivery instructions +

+
+ +
+
+
+

Fast

+

+ 50 min - 2 hr 10 min +

+
+ Free +
+
+
+

Priority

+

35 min

+
+ Free +
+
+
+ +
+
+

Delivery tip

+

100% goes to the shopper

+
+
+ + + + +
+
+ +
+
+ Subtotal + ${subtotal.toFixed(2)} +
+
+ Total + + ${total.toFixed(2)} + +
+

+ +
+ + ); +} + +function App() { + const maxHeight = useMaxHeight() ?? undefined; + const displayMode = useDisplayMode(); + const isFullscreen = displayMode === "fullscreen"; + const widgetProps = useWidgetProps(() => ({})); + const [widgetState, setWidgetState] = useWidgetState( + createDefaultWidgetState + ); + const navigate = useNavigate(); + const location = useLocation(); + const isCheckoutRoute = useMemo(() => { + const pathname = location?.pathname ?? ""; + if (!pathname) { + return false; + } + + return pathname === "/checkout" || pathname.endsWith("/checkout"); + }, [location?.pathname]); + + const defaultCartItems = useMemo(() => createDefaultCartItems(), []); + const cartGridRef = useRef(null); + const [gridColumnCount, setGridColumnCount] = useState(1); + + const mergeWithDefaultItems = useCallback( + (items: CartItem[]): CartItem[] => { + const existingIds = new Set(items.map((item) => item.id)); + const merged = items.map((item) => { + const defaultItem = defaultCartItems.find( + (candidate) => candidate.id === item.id + ); + + if (!defaultItem) { + return cloneCartItem(item); + } + + const enriched: CartItem = { + ...cloneCartItem(defaultItem), + ...item, + tags: item.tags ? [...item.tags] : defaultItem.tags, + nutritionFacts: + item.nutritionFacts ?? + defaultItem.nutritionFacts?.map((fact) => ({ ...fact })), + highlights: + item.highlights != null + ? [...item.highlights] + : defaultItem.highlights + ? [...defaultItem.highlights] + : undefined, + }; + + return cloneCartItem(enriched); + }); + + defaultCartItems.forEach((defaultItem) => { + if (!existingIds.has(defaultItem.id)) { + merged.push(cloneCartItem(defaultItem)); + } + }); + + return merged; + }, + [defaultCartItems] + ); + + const resolvedCartItems = useMemo(() => { + if (Array.isArray(widgetState?.cartItems) && widgetState.cartItems.length) { + return mergeWithDefaultItems(widgetState.cartItems); + } + + if ( + Array.isArray(widgetProps?.widgetState?.cartItems) && + widgetProps.widgetState.cartItems.length + ) { + return mergeWithDefaultItems(widgetProps.widgetState.cartItems); + } + + if (Array.isArray(widgetProps?.cartItems) && widgetProps.cartItems.length) { + return mergeWithDefaultItems(widgetProps.cartItems); + } + + return mergeWithDefaultItems(defaultCartItems); + }, [ + defaultCartItems, + mergeWithDefaultItems, + widgetProps?.cartItems, + widgetProps?.widgetState?.cartItems, + widgetState, + ]); + + const [cartItems, setCartItems] = useState(resolvedCartItems); + + useEffect(() => { + setCartItems((previous) => + cartItemsEqual(previous, resolvedCartItems) ? previous : resolvedCartItems + ); + }, [resolvedCartItems]); + + const resolvedSelectedCartItemId = + widgetState?.selectedCartItemId ?? + widgetProps?.widgetState?.selectedCartItemId ?? + null; + + const [selectedCartItemId, setSelectedCartItemId] = useState( + resolvedSelectedCartItemId + ); + + useEffect(() => { + setSelectedCartItemId((prev) => + prev === resolvedSelectedCartItemId ? prev : resolvedSelectedCartItemId + ); + }, [resolvedSelectedCartItemId]); + + const view = useOpenAiGlobal("view"); + const viewParams = view?.params; + const isModalView = view?.mode === "modal"; + const checkoutFromState = + (widgetState?.state ?? widgetProps?.widgetState?.state) === "checkout"; + const modalParams = + viewParams && typeof viewParams === "object" + ? (viewParams as { + state?: unknown; + cartItems?: unknown; + subtotal?: unknown; + total?: unknown; + totalItems?: unknown; + }) + : null; + + const modalState = + modalParams && typeof modalParams.state === "string" + ? (modalParams.state as string) + : null; + + const isCartModalView = isModalView && modalState === "cart"; + const shouldShowCheckoutOnly = + isCheckoutRoute || (isModalView && !isCartModalView); + const wasModalViewRef = useRef(isModalView); + + useEffect(() => { + if (!viewParams || typeof viewParams !== "object") { + return; + } + + const paramsWithSelection = viewParams as { + selectedCartItemId?: unknown; + }; + + const selectedIdFromParams = paramsWithSelection.selectedCartItemId; + + if ( + typeof selectedIdFromParams === "string" && + selectedIdFromParams !== selectedCartItemId + ) { + setSelectedCartItemId(selectedIdFromParams); + return; + } + + if (selectedIdFromParams === null && selectedCartItemId !== null) { + setSelectedCartItemId(null); + } + }, [selectedCartItemId, viewParams]); + + const [hoveredCartItemId, setHoveredCartItemId] = useState( + null + ); + const [activeFilters, setActiveFilters] = useState([]); + + const updateWidgetState = useCallback( + (partial: Partial) => { + setWidgetState((previous) => ({ + ...createDefaultWidgetState(), + ...(previous ?? {}), + ...partial, + })); + }, + [setWidgetState] + ); + + useEffect(() => { + if (!Array.isArray(widgetState?.cartItems)) { + return; + } + + const merged = mergeWithDefaultItems(widgetState.cartItems); + + if (!cartItemsEqual(widgetState.cartItems, merged)) { + updateWidgetState({ cartItems: merged }); + } + }, [mergeWithDefaultItems, updateWidgetState, widgetState?.cartItems]); + + useEffect(() => { + if (wasModalViewRef.current && !isModalView && checkoutFromState) { + updateWidgetState({ state: null }); + } + + wasModalViewRef.current = isModalView; + }, [checkoutFromState, isModalView, updateWidgetState]); + + const adjustQuantity = useCallback( + (id: string, delta: number) => { + setCartItems((previousItems) => { + const updatedItems = previousItems.map((item) => + item.id === id + ? { ...item, quantity: Math.max(0, item.quantity + delta) } + : item + ); + + if (!cartItemsEqual(previousItems, updatedItems)) { + updateWidgetState({ cartItems: updatedItems }); + } + + return updatedItems; + }); + }, + [updateWidgetState] + ); + + useEffect(() => { + if (!shouldShowCheckoutOnly) { + return; + } + + setHoveredCartItemId(null); + }, [shouldShowCheckoutOnly]); + + const manualCheckoutTriggerRef = useRef(false); + + const requestModalWithAnchor = useCallback( + ({ + title, + params, + anchorElement, + }: { + title: string; + params: Record; + anchorElement?: HTMLElement | null; + }) => { + if (isModalView) { + return; + } + + const anchorRect = anchorElement?.getBoundingClientRect(); + const anchor = + anchorRect == null + ? undefined + : { + top: anchorRect.top, + left: anchorRect.left, + width: anchorRect.width, + height: anchorRect.height, + }; + + void (async () => { + try { + await window?.openai?.requestModal?.({ + title, + params, + ...(anchor ? { anchor } : {}), + }); + } catch (error) { + console.error("Failed to open checkout modal", error); + } + })(); + }, + [isModalView] + ); + + const openCheckoutModal = useCallback( + (anchorElement?: HTMLElement | null) => { + requestModalWithAnchor({ + title: "Checkout", + params: { state: "checkout" }, + anchorElement, + }); + }, + [requestModalWithAnchor] + ); + + const openCartItemModal = useCallback( + ({ + selectedId, + selectedName, + anchorElement, + }: { + selectedId: string; + selectedName: string | null; + anchorElement?: HTMLElement | null; + }) => { + requestModalWithAnchor({ + title: selectedName ?? selectedId, + params: { state: "checkout", selectedCartItemId: selectedId }, + anchorElement, + }); + }, + [requestModalWithAnchor] + ); + + const handleCartItemSelect = useCallback( + (id: string, anchorElement?: HTMLElement | null) => { + const itemName = cartItems.find((item) => item.id === id)?.name ?? null; + manualCheckoutTriggerRef.current = true; + setSelectedCartItemId(id); + updateWidgetState({ selectedCartItemId: id, state: "checkout" }); + openCartItemModal({ + selectedId: id, + selectedName: itemName, + anchorElement, + }); + }, + [cartItems, openCartItemModal, updateWidgetState] + ); + + const subtotal = useMemo( + () => + cartItems.reduce( + (total, item) => total + item.price * Math.max(0, item.quantity), + 0 + ), + [cartItems] + ); + + const total = subtotal + SERVICE_FEE + DELIVERY_FEE + TAX_FEE; + + const totalItems = useMemo( + () => + cartItems.reduce((total, item) => total + Math.max(0, item.quantity), 0), + [cartItems] + ); + + const visibleCartItems = useMemo(() => { + if (!activeFilters.length) { + return cartItems; + } + + return cartItems.filter((item) => { + const tags = item.tags ?? []; + + return activeFilters.every((filterId) => { + const filterMeta = FILTERS.find((filter) => filter.id === filterId); + if (!filterMeta?.tag) { + return true; + } + return tags.includes(filterMeta.tag); + }); + }); + }, [activeFilters, cartItems]); + + const updateItemColumnPlacement = useCallback(() => { + const gridNode = cartGridRef.current; + + const width = gridNode?.offsetWidth ?? 0; + + let baseColumnCount = 1; + if (width >= 768) { + baseColumnCount = 3; + } else if (width >= 640) { + baseColumnCount = 2; + } + + const columnCount = isFullscreen + ? Math.max(baseColumnCount, 3) + : baseColumnCount; + + if (gridNode) { + gridNode.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; + } + + setGridColumnCount(columnCount); + }, [isFullscreen]); + + const handleFilterToggle = useCallback( + (id: string) => { + setActiveFilters((previous) => { + if (id === "all") { + return []; + } + + const isActive = previous.includes(id); + if (isActive) { + return []; + } + + return [id]; + }); + + requestAnimationFrame(() => { + updateItemColumnPlacement(); + }); + }, + [updateItemColumnPlacement] + ); + + useEffect(() => { + const node = cartGridRef.current; + + if (!node) { + return; + } + + const observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + requestAnimationFrame(updateItemColumnPlacement); + }) + : null; + + observer?.observe(node); + window.addEventListener("resize", updateItemColumnPlacement); + + return () => { + observer?.disconnect(); + window.removeEventListener("resize", updateItemColumnPlacement); + }; + }, [updateItemColumnPlacement]); + + const openCartModal = useCallback( + (anchorElement?: HTMLElement | null) => { + if (isModalView || shouldShowCheckoutOnly) { + return; + } + + requestModalWithAnchor({ + title: "Cart", + params: { + state: "cart", + cartItems, + subtotal, + total, + totalItems, + }, + anchorElement, + }); + }, + [ + cartItems, + isModalView, + requestModalWithAnchor, + shouldShowCheckoutOnly, + subtotal, + total, + totalItems, + ] + ); + + type CartSummaryItem = { + id: string; + name: string; + price: number; + quantity: number; + image?: string; + }; + + const cartSummaryItems: CartSummaryItem[] = useMemo(() => { + if (!isCartModalView) { + return []; + } + + const items = Array.isArray(modalParams?.cartItems) + ? modalParams?.cartItems + : null; + + if (!items) { + return cartItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + quantity: Math.max(0, item.quantity), + image: item.image, + })); + } + + const sanitized = items + .map((raw, index) => { + if (!raw || typeof raw !== "object") { + return null; + } + const candidate = raw as Record; + const id = + typeof candidate.id === "string" ? candidate.id : `cart-${index}`; + const name = + typeof candidate.name === "string" ? candidate.name : "Item"; + const priceValue = Number(candidate.price); + const quantityValue = Number(candidate.quantity); + const price = Number.isFinite(priceValue) ? priceValue : 0; + const quantity = Number.isFinite(quantityValue) + ? Math.max(0, quantityValue) + : 0; + const image = + typeof candidate.image === "string" ? candidate.image : undefined; + + return { + id, + name, + price, + quantity, + image, + } as CartSummaryItem; + }) + .filter(Boolean) as CartSummaryItem[]; + + if (sanitized.length === 0) { + return cartItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + quantity: Math.max(0, item.quantity), + image: item.image, + })); + } + + return sanitized; + }, [cartItems, isCartModalView, modalParams?.cartItems]); + + const cartSummarySubtotal = useMemo(() => { + if (!isCartModalView) { + return subtotal; + } + + const candidate = Number(modalParams?.subtotal); + return Number.isFinite(candidate) ? candidate : subtotal; + }, [isCartModalView, modalParams?.subtotal, subtotal]); + + const cartSummaryTotal = useMemo(() => { + if (!isCartModalView) { + return total; + } + + const candidate = Number(modalParams?.total); + return Number.isFinite(candidate) ? candidate : total; + }, [isCartModalView, modalParams?.total, total]); + + const cartSummaryTotalItems = useMemo(() => { + if (!isCartModalView) { + return totalItems; + } + + const candidate = Number(modalParams?.totalItems); + return Number.isFinite(candidate) ? candidate : totalItems; + }, [isCartModalView, modalParams?.totalItems, totalItems]); + + const handleContinueToPayment = useCallback( + (event?: ReactMouseEvent) => { + const anchorElement = event?.currentTarget ?? null; + + if (typeof window !== "undefined") { + const detail = { + subtotal: isCartModalView ? cartSummarySubtotal : subtotal, + total: isCartModalView ? cartSummaryTotal : total, + totalItems: isCartModalView ? cartSummaryTotalItems : totalItems, + }; + + try { + window.dispatchEvent( + new CustomEvent(CONTINUE_TO_PAYMENT_EVENT, { detail }) + ); + } catch (error) { + console.error("Failed to dispatch checkout navigation event", error); + } + } + + if (isCartModalView) { + return; + } + + manualCheckoutTriggerRef.current = true; + updateWidgetState({ state: "checkout" }); + const shouldNavigateToCheckout = isCartModalView || !isCheckoutRoute; + + if (shouldNavigateToCheckout) { + navigate("/checkout"); + return; + } + + openCheckoutModal(anchorElement); + }, + [ + cartSummarySubtotal, + cartSummaryTotal, + cartSummaryTotalItems, + isCartModalView, + isCheckoutRoute, + navigate, + openCheckoutModal, + subtotal, + total, + totalItems, + updateWidgetState, + ] + ); + + const handleSeeAll = useCallback(async () => { + if (typeof window === "undefined") { + return; + } + + try { + await window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); + } catch (error) { + console.error("Failed to request fullscreen display mode", error); + } + }, []); + + useLayoutEffect(() => { + const raf = requestAnimationFrame(updateItemColumnPlacement); + + return () => { + cancelAnimationFrame(raf); + }; + }, [updateItemColumnPlacement, visibleCartItems]); + + const selectedCartItem = useMemo(() => { + if (selectedCartItemId == null) { + return null; + } + return cartItems.find((item) => item.id === selectedCartItemId) ?? null; + }, [cartItems, selectedCartItemId]); + + const selectedCartItemName = selectedCartItem?.name ?? null; + const shouldShowSelectedCartItemPanel = + selectedCartItem != null && !isFullscreen; + + useEffect(() => { + if (isCheckoutRoute) { + return; + } + + if (!checkoutFromState) { + return; + } + + if (manualCheckoutTriggerRef.current) { + manualCheckoutTriggerRef.current = false; + return; + } + + if (selectedCartItemId) { + openCartItemModal({ + selectedId: selectedCartItemId, + selectedName: selectedCartItemName, + }); + return; + } + + openCheckoutModal(); + }, [ + isCheckoutRoute, + checkoutFromState, + openCartItemModal, + openCheckoutModal, + selectedCartItemId, + selectedCartItemName, + ]); + + const cartPanel = ( +
+ {!shouldShowCheckoutOnly && ( +
+ {!isFullscreen ? ( +
+ +
+ ) : ( +
Results
+ )} + +
+ )} + + +
+ + {visibleCartItems.map((item, index) => { + const isHovered = hoveredCartItemId === item.id; + const shortDescription = + item.shortDescription ?? item.description.split(".")[0]; + const columnCount = Math.max(gridColumnCount, 1); + const rowStartIndex = + Math.floor(index / columnCount) * columnCount; + const itemsRemaining = visibleCartItems.length - rowStartIndex; + const rowSize = Math.min(columnCount, itemsRemaining); + const positionInRow = index - rowStartIndex; + + const isSingle = rowSize === 1; + const isLeft = positionInRow === 0; + const isRight = positionInRow === rowSize - 1; + + return ( + + handleCartItemSelect( + item.id, + event.currentTarget as HTMLElement + ) + } + onMouseEnter={() => setHoveredCartItemId(item.id)} + onMouseLeave={() => setHoveredCartItemId(null)} + className={clsx( + "group mb-4 flex cursor-pointer flex-col overflow-hidden border border-transparent bg-white transition-colors", + isHovered && "border-[#0f766e]" + )} + > +
+ {item.name} + +
+
+
+
+

+ {item.name} +

+

+ ${item.price.toFixed(2)} +

+
+ {shortDescription ? ( +

+ {shortDescription} +

+ ) : null} +
+
+ + + {item.quantity} + + +
+
+
+ + ); + })} + +
+ +
+ ); + + if (isCartModalView && !isCheckoutRoute) { + return ( +
+
+ {cartSummaryItems.length ? ( + cartSummaryItems.map((item) => ( +
+
+ {item.image ? ( + {item.name} + ) : null} +
+
+
+
+

+ {item.name} +

+

+ ${item.price.toFixed(2)} • Qty{" "} + {Math.max(0, item.quantity)} +

+
+ + ${(item.price * Math.max(0, item.quantity)).toFixed(2)} + +
+
+ )) + ) : ( +

+ Your cart is empty. +

+ )} +
+ +
+
+ Subtotal + ${cartSummarySubtotal.toFixed(2)} +
+
+ Total + ${cartSummaryTotal.toFixed(2)} +
+
+ +
+ ); + } + + const checkoutPanel = ( +
+ {shouldShowSelectedCartItemPanel ? ( + + ) : ( + + )} +
+ ); + + return ( +
+
+ {shouldShowCheckoutOnly ? ( + checkoutPanel + ) : isFullscreen ? ( +
+
{cartPanel}
+
{checkoutPanel}
+
+ ) : ( + cartPanel + )} + {!isFullscreen && !shouldShowCheckoutOnly && ( +
+ +
+ )} +
+
+ ); } -createRoot(container).render( +createRoot(document.getElementById("pizzaz-shop-root")!).render( ); - -export default App; diff --git a/src/types.ts b/src/types.ts index 150ef08..765164e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,11 +18,9 @@ export type OpenAiGlobals< // state toolInput: ToolInput; toolOutput: ToolOutput | null; - toolResponse: ToolResponseEnvelope | null; toolResponseMetadata: ToolResponseMetadata | null; widgetState: WidgetState | null; setWidgetState: (state: WidgetState) => Promise; - view: ViewDescriptor | null; }; // currently copied from types.ts in chatgpt/web-sandbox. @@ -34,7 +32,6 @@ type API = { // Layout controls requestDisplayMode: RequestDisplayMode; - requestModal?: RequestModal; }; export type UnknownObject = Record; @@ -72,42 +69,10 @@ export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{ mode: DisplayMode; }>; -export type ViewDescriptor = { - mode: string; - params?: UnknownObject; -}; - -export type ModalAnchor = { - top: number; - left: number; - width: number; - height: number; -}; - -export type RequestModalArgs = { - title: string; - params?: UnknownObject; - anchor?: ModalAnchor; -}; - -export type RequestModal = (args: RequestModalArgs) => Promise; - export type CallToolResponse = { result: string; }; -export type ToolResponseEnvelope< - ToolOutput = UnknownObject, - ToolResponseMetadata = UnknownObject -> = { - toolName?: string; - status?: "success" | "error" | string; - content?: UnknownObject; - structuredContent?: ToolOutput | null; - metadata?: ToolResponseMetadata | null; - error?: UnknownObject | null; -}; - /** Calling APIs */ export type CallTool = ( name: string, diff --git a/src/use-widget-props.ts b/src/use-widget-props.ts index a865eb9..cef4762 100644 --- a/src/use-widget-props.ts +++ b/src/use-widget-props.ts @@ -1,27 +1,14 @@ import { useOpenAiGlobal } from "./use-openai-global"; -import type { ToolResponseEnvelope } from "./types"; export function useWidgetProps>( defaultState?: T | (() => T) ): T { - const toolResponse = useOpenAiGlobal("toolOutput") as - | ToolResponseEnvelope - | null; - const structuredContent = - typeof toolResponse?.structuredContent === "object" && - toolResponse.structuredContent !== null - ? (toolResponse.structuredContent as T) - : null; - - const props = (structuredContent ?? - (useOpenAiGlobal("toolOutput") as T | null)) as T | null; + const props = useOpenAiGlobal("toolOutput") as T; const fallback = typeof defaultState === "function" ? (defaultState as () => T | null)() : defaultState ?? null; - const resolved = props ?? fallback; - - return resolved ?? ({} as T); + return props ?? fallback; } From fb03dc4344cfd8fffb434812177d45cab3858ef8 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Mon, 8 Dec 2025 14:18:18 -0500 Subject: [PATCH 06/10] Update --- pizzaz_server_python/main.py | 94 ++++++------------------------------ 1 file changed, 15 insertions(+), 79 deletions(-) diff --git a/pizzaz_server_python/main.py b/pizzaz_server_python/main.py index b841562..74177a6 100644 --- a/pizzaz_server_python/main.py +++ b/pizzaz_server_python/main.py @@ -12,7 +12,6 @@ from copy import deepcopy from dataclasses import dataclass from functools import lru_cache -import json from pathlib import Path from typing import Any, Dict, List @@ -33,11 +32,6 @@ class PizzazWidget: ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" -ECOMMERCE_SAMPLE_DATA_PATH = ( - Path(__file__).resolve().parent.parent - / "ecommerce_server_python" - / "sample_data.json" -) @lru_cache(maxsize=None) @@ -56,24 +50,6 @@ def _load_widget_html(component_name: str) -> str: ) -@lru_cache(maxsize=1) -def _load_ecommerce_cart_items() -> List[Dict[str, Any]]: - if not ECOMMERCE_SAMPLE_DATA_PATH.exists(): - return [] - - try: - raw = json.loads(ECOMMERCE_SAMPLE_DATA_PATH.read_text(encoding="utf8")) - except json.JSONDecodeError: - return [] - - items: List[Dict[str, Any]] = [] - for entry in raw.get("products", []): - if isinstance(entry, dict): - items.append(entry) - - return items - - widgets: List[PizzazWidget] = [ PizzazWidget( identifier="pizza-map", @@ -120,15 +96,6 @@ def _load_ecommerce_cart_items() -> List[Dict[str, Any]]: html=_load_widget_html("pizzaz-shop"), response_text="Rendered the Pizzaz shop!", ), - PizzazWidget( - identifier="pizzaz-ecommerce", - title="Show Ecommerce Catalog", - template_uri="ui://widget/ecommerce.html", - invoking="Loading the ecommerce catalog", - invoked="Ecommerce catalog ready", - html=_load_widget_html("ecommerce"), - response_text="Rendered the ecommerce catalog!", - ), ] @@ -197,35 +164,22 @@ def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: @mcp._mcp_server.list_tools() async def _list_tools() -> List[types.Tool]: - tools: List[types.Tool] = [] - for widget in widgets: - input_schema: Dict[str, Any] - if widget.identifier == "pizzaz-ecommerce": - input_schema = { - "type": "object", - "properties": {}, - "additionalProperties": False, - } - else: - input_schema = deepcopy(TOOL_INPUT_SCHEMA) - - tools.append( - types.Tool( - name=widget.identifier, - title=widget.title, - description=widget.title, - inputSchema=input_schema, - _meta=_tool_meta(widget), - # To disable the approval prompt for the tools - annotations={ - "destructiveHint": False, - "openWorldHint": False, - "readOnlyHint": True, - }, - ) + return [ + types.Tool( + name=widget.identifier, + title=widget.title, + description=widget.title, + inputSchema=deepcopy(TOOL_INPUT_SCHEMA), + _meta=_tool_meta(widget), + # To disable the approval prompt for the tools + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, ) - - return tools + for widget in widgets + ] @mcp._mcp_server.list_resources() @@ -295,24 +249,6 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: ) ) - if widget.identifier == "pizzaz-ecommerce": - meta = _tool_invocation_meta(widget) - cart_items = [deepcopy(item) for item in _load_ecommerce_cart_items()] - structured_content: Dict[str, Any] = {"cartItems": cart_items, "searchTerm": ""} - - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=widget.response_text, - ) - ], - structuredContent=structured_content, - _meta=meta, - ) - ) - arguments = req.params.arguments or {} try: payload = PizzaInput.model_validate(arguments) From 1baabe92267747b34f958e44d0f070151b29fb75 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Mon, 8 Dec 2025 14:31:22 -0500 Subject: [PATCH 07/10] Remove build-all.mts --- build-all.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/build-all.mts b/build-all.mts index cfcbd45..045b4af 100644 --- a/build-all.mts +++ b/build-all.mts @@ -17,7 +17,6 @@ const GLOBAL_CSS_LIST = [path.resolve("src/index.css")]; const targets: string[] = [ "todo", "solar-system", - "ecommerce", "pizzaz", "pizzaz-carousel", "pizzaz-list", From 9b574dd1cecb3a3c942ffa6cc39cb7429e5ed5e2 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Tue, 9 Dec 2025 10:58:40 -0500 Subject: [PATCH 08/10] Update README --- README.md | 11 ++++++++++ authenticated_server_python/README.md | 29 ++++++++++++++++++--------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3e39aee..5658eed 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The MCP servers in this demo highlight how each tool can light up widgets by com - `pizzaz_server_python/` – Python MCP server that returns the Pizzaz widgets. - `solar-system_server_python/` – Python MCP server for the 3D solar system widget. - `build-all.mts` – Vite build orchestrator that produces hashed bundles for every widget entrypoint. +- `authenticated_server_python/` – Python MCP server that demonstrates authenticated tool calls. ## Prerequisites @@ -80,6 +81,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 +- **Authenticated (Python)** – pizza carousel tool that requires OAuth ### Pizzaz Node server @@ -97,6 +99,15 @@ pip install -r pizzaz_server_python/requirements.txt uvicorn pizzaz_server_python.main:app --port 8000 ``` +### Authenticated Python server + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r authenticated_server_python/requirements.txt +python authenticated_server_python/main.py +``` + ### Solar system Python server ```bash diff --git a/authenticated_server_python/README.md b/authenticated_server_python/README.md index 023d64c..ec27a6a 100644 --- a/authenticated_server_python/README.md +++ b/authenticated_server_python/README.md @@ -1,9 +1,16 @@ -# Ecommerce MCP server (Python) +# Authenticated MCP server (Python) -This server exposes a single tool that renders the ecommerce widget hydrated by -sample catalog data. The tool performs a simple text search over -`sample_data.json` and returns matching products as `cartItems`, allowing the -widget to display the results without any hard-coded inventory. +This example shows how to build an authenticated app with the OpenAI Apps SDK. +It demonstrates triggering the ChatGPT authentication UI by responding with MCP +authorization metadata and follows the same OAuth flow described in the MCP +authorization spec: https://modelcontextprotocol.io/docs/tutorials/security/authorization#the-authorization-flow:-step-by-step. +The Apps SDK auth guide covers how the UI is triggered: https://developers.openai.com/apps-sdk/build/auth#triggering-authentication-ui. + +The server exposes a single pizza-carousel tool that requires a bearer token. +If a request is missing a token, the server returns an `mcp/www_authenticate` +hint (backed by `WWW-Authenticate`) plus `/.well-known/oauth-protected-resource` +metadata so ChatGPT knows which authorization server to use. With a valid token, +the tool returns the pizza carousel widget markup. ## Prerequisites @@ -30,12 +37,14 @@ endpoints: - `GET /mcp` for the SSE stream - `POST /mcp/messages?sessionId=...` for follow-ups -The `ecommerce-search` tool uses the `searchTerm` argument to filter products -by name, description, tags, or highlights and returns structured content with -`cartItems` so the ecommerce widget can hydrate itself. +The pizza carousel tool echoes the optional `searchTerm` argument as a topping +and returns structured content plus widget markup. Unauthenticated calls return +the MCP auth hint so the Apps SDK can start the OAuth flow. ## Customization -- Update `sample_data.json` with your own products. -- Adjust the search logic in `main.py` to match your catalog rules. +- Update `AUTHORIZATION_SERVER_URL` (and the resource URL in `main.py`) to point + to your OAuth provider. +- Adjust the `WWW-Authenticate` construction or scopes to match your security + model. - Rebuild the widget assets (`pnpm run build`) if you change the UI. From 5a9933aa632fc70ea3520f6b5033afc98cf12b4a Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Thu, 11 Dec 2025 15:11:13 -0500 Subject: [PATCH 09/10] Update --- authenticated_server_python/.env.example | 2 + authenticated_server_python/README.md | 42 ++++-- authenticated_server_python/main.py | 174 +++++++++++++++++++---- 3 files changed, 183 insertions(+), 35 deletions(-) create mode 100644 authenticated_server_python/.env.example diff --git a/authenticated_server_python/.env.example b/authenticated_server_python/.env.example new file mode 100644 index 0000000..48c90b4 --- /dev/null +++ b/authenticated_server_python/.env.example @@ -0,0 +1,2 @@ +AUTHORIZATION_SERVER_URL=https://dev-65wmmp5d56ev40iy.us.auth0.com/ +RESOURCE_SERVER_URL=https://945c890ee720.ngrok-free.app/mcp diff --git a/authenticated_server_python/README.md b/authenticated_server_python/README.md index ec27a6a..a90caa5 100644 --- a/authenticated_server_python/README.md +++ b/authenticated_server_python/README.md @@ -6,11 +6,36 @@ authorization metadata and follows the same OAuth flow described in the MCP authorization spec: https://modelcontextprotocol.io/docs/tutorials/security/authorization#the-authorization-flow:-step-by-step. The Apps SDK auth guide covers how the UI is triggered: https://developers.openai.com/apps-sdk/build/auth#triggering-authentication-ui. -The server exposes a single pizza-carousel tool that requires a bearer token. -If a request is missing a token, the server returns an `mcp/www_authenticate` -hint (backed by `WWW-Authenticate`) plus `/.well-known/oauth-protected-resource` -metadata so ChatGPT knows which authorization server to use. With a valid token, -the tool returns the pizza carousel widget markup. +The server exposes two OAuth-protected tools: the `pizza-carousel` widget and +`see_past_orders` (returns a `pizzaz-list` widget with past-order data). If a +request is missing a token, the server returns an `mcp/www_authenticate` hint +(backed by `WWW-Authenticate`) plus `/.well-known/oauth-protected-resource` +metadata so ChatGPT knows which authorization server to use. With a valid +token, the tools return widget markup or structured results. + +## Configuring the authorization server (AUth0) + +> The scaffold expects OAuth 2.1 bearer tokens issued by Auth0. Substitute your own IdP if you prefer, but keep the same environment variable names. + +1. **Create an API** + - Auth0 Dashboard → *Applications* → *APIs* → *Create API* + - Name it (e.g., `mcp-python-server`) + - Identifier → `https://your-domain.example.com/mcp` (add this to your `JWT_AUDIENCES` environment variable) + - (JWT) Profile → Auth0 + +2. **Enable a default audience for your tenant** (per [this community post](https://community.auth0.com/t/rfc-8707-implementation-audience-vs-resource/188990/4)) so that Auth0 issues an unencrypted RS256 JWT. + - Tenant settings > Default Audience > Add the API identifier you created in step 1. + +3. **Enable Dynamic Client Registration** + - Go to Dashboard > Settings > Advanced and enable the [OIDC Dynamic Application Registration](https://auth0.com/docs/get-started/applications/dynamic-client-registration?tenant=openai-mcpkit-trial%40prod-us-5&locale=en-us). + +4. **Add a social connection to the tenant** for example Google oauth2 to provide a social login mechanism for uers. + - Authentication > Social > google-oauth2 > Advanced > Promote Connection to Domain Level + +5. **Update your environment variables** + - `AUTH0_ISSUER`: your tenant domain (e.g., `https://dev-your-tenant.us.auth0.com/`) + - `JWT_AUDIENCES`: API identifider created in step 1 (e.g. `https://your-domain.example.com/mcp`) + ## Prerequisites @@ -28,14 +53,11 @@ pip install -r requirements.txt ## Running the server ```bash -python main.py +uvicorn main:app --host 0.0.0.0 --port 8000 ``` The server listens on `http://127.0.0.1:8000` and exposes the standard MCP -endpoints: - -- `GET /mcp` for the SSE stream -- `POST /mcp/messages?sessionId=...` for follow-ups +endpoint at `GET /mcp`. The pizza carousel tool echoes the optional `searchTerm` argument as a topping and returns structured content plus widget markup. Unauthenticated calls return diff --git a/authenticated_server_python/main.py b/authenticated_server_python/main.py index e88f7c6..5c1af27 100644 --- a/authenticated_server_python/main.py +++ b/authenticated_server_python/main.py @@ -50,7 +50,7 @@ def _load_widget_html(component_name: str) -> str: CAROUSEL_WIDGET = PizzazWidget( identifier="pizza-carousel", - title="Show Pizza Carousel", + title="Show pizza spots", template_uri="ui://widget/pizza-carousel.html", invoking="Carousel some spots", invoked="Served a fresh carousel", @@ -58,10 +58,21 @@ def _load_widget_html(component_name: str) -> str: response_text="Rendered a pizza carousel!", ) +PAST_ORDERS_WIDGET = PizzazWidget( + identifier="pizzaz-list", + title="Past orders", + template_uri="ui://widget/pizzaz-list.html", + invoking="Fetching your recent orders", + invoked="Served recent orders", + html=_load_widget_html("pizzaz-list"), + response_text="Rendered past orders list!", +) + MIME_TYPE = "text/html+skybridge" SEARCH_TOOL_NAME = CAROUSEL_WIDGET.identifier +PAST_ORDERS_TOOL_NAME = "see_past_orders" SEARCH_TOOL_SCHEMA: Dict[str, Any] = { "type": "object", @@ -76,6 +87,48 @@ def _load_widget_html(component_name: str) -> str: "additionalProperties": False, } +PAST_ORDERS_TOOL_SCHEMA: Dict[str, Any] = { + "type": "object", + "title": "Past orders", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "description": "Optional max number of past orders to return.", + } + }, + "required": [], + "additionalProperties": False, +} + +PAST_ORDERS_DATA = [ + { + "orderId": "pz-4931", + "items": ["Classic Margherita", "Garlic knots"], + "status": "delivered", + "total": "$18.50", + }, + { + "orderId": "pz-4810", + "items": ["Pepperoni Feast"], + "status": "delivered", + "total": "$15.00", + }, + { + "orderId": "pz-4799", + "items": ["Veggie Garden", "Caesar salad"], + "status": "refunded", + "total": "$22.40", + }, + { + "orderId": "pz-4750", + "items": ["Spicy Hawaiian"], + "status": "delivered", + "total": "$17.25", + }, +] + DEFAULT_AUTH_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" DEFAULT_RESOURCE_SERVER_URL = "http://localhost:8000/mcp" @@ -83,7 +136,10 @@ def _load_widget_html(component_name: str) -> str: AUTHORIZATION_SERVER_URL = AnyHttpUrl( os.environ.get("AUTHORIZATION_SERVER_URL", DEFAULT_AUTH_SERVER_URL) ) -RESOURCE_SERVER_URL = "https://945c890ee720.ngrok-free.app" +RESOURCE_SERVER_URL = os.environ.get("RESOURCE_SERVER_URL", DEFAULT_RESOURCE_SERVER_URL) + +print("AUTHORIZATION_SERVER_URL", AUTHORIZATION_SERVER_URL) +print("RESOURCE_SERVER_URL", RESOURCE_SERVER_URL) RESOURCE_SCOPES = [] _parsed_resource_url = urlparse(str(RESOURCE_SERVER_URL)) @@ -111,9 +167,16 @@ def _load_widget_html(component_name: str) -> str: }, ] +OAUTH_ONLY_SECURITY_SCHEMES = [ + { + "type": "oauth2", + "scopes": RESOURCE_SCOPES, + } +] + mcp = FastMCP( - name="pizzaz-python", + name="authenticated-server-python", stateless_http=True, ) @@ -272,6 +335,7 @@ def _tool_error(message: str) -> types.ServerResult: @mcp._mcp_server.list_tools() async def _list_tools() -> List[types.Tool]: tool_meta = _tool_meta(CAROUSEL_WIDGET, MIXED_TOOL_SECURITY_SCHEMES) + past_orders_meta = _tool_meta(PAST_ORDERS_WIDGET, OAUTH_ONLY_SECURITY_SCHEMES) return [ types.Tool( name=SEARCH_TOOL_NAME, @@ -287,6 +351,19 @@ async def _list_tools() -> List[types.Tool]: "readOnlyHint": True, }, ), + types.Tool( + name=PAST_ORDERS_TOOL_NAME, + title="See past orders", + description="Return a list of past pizza orders (OAuth required).", + inputSchema=PAST_ORDERS_TOOL_SCHEMA, + _meta=past_orders_meta, + securitySchemes=list(OAUTH_ONLY_SECURITY_SCHEMES), + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ), ] @@ -300,7 +377,15 @@ async def _list_resources() -> List[types.Resource]: description=_resource_description(CAROUSEL_WIDGET), mimeType=MIME_TYPE, _meta=_tool_meta(CAROUSEL_WIDGET), - ) + ), + types.Resource( + name=PAST_ORDERS_WIDGET.title, + title=PAST_ORDERS_WIDGET.title, + uri=PAST_ORDERS_WIDGET.template_uri, + description=_resource_description(PAST_ORDERS_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(PAST_ORDERS_WIDGET), + ), ] @@ -314,13 +399,24 @@ async def _list_resource_templates() -> List[types.ResourceTemplate]: description=_resource_description(CAROUSEL_WIDGET), mimeType=MIME_TYPE, _meta=_tool_meta(CAROUSEL_WIDGET), - ) + ), + types.ResourceTemplate( + name=PAST_ORDERS_WIDGET.title, + title=PAST_ORDERS_WIDGET.title, + uriTemplate=PAST_ORDERS_WIDGET.template_uri, + description=_resource_description(PAST_ORDERS_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(PAST_ORDERS_WIDGET), + ), ] async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: requested_uri = str(req.params.uri) - if requested_uri != CAROUSEL_WIDGET.template_uri: + if requested_uri not in { + CAROUSEL_WIDGET.template_uri, + PAST_ORDERS_WIDGET.template_uri, + }: return types.ServerResult( types.ReadResourceResult( contents=[], @@ -328,12 +424,18 @@ async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerR ) ) + widget = ( + CAROUSEL_WIDGET + if requested_uri == CAROUSEL_WIDGET.template_uri + else PAST_ORDERS_WIDGET + ) + contents = [ types.TextResourceContents( - uri=CAROUSEL_WIDGET.template_uri, + uri=widget.template_uri, mimeType=MIME_TYPE, - text=CAROUSEL_WIDGET.html, - _meta=_tool_meta(CAROUSEL_WIDGET), + text=widget.html, + _meta=_tool_meta(widget), ) ] @@ -342,31 +444,53 @@ async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerR async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: tool_name = req.params.name - if tool_name != SEARCH_TOOL_NAME: - return _tool_error(f"Unknown tool: {req.params.name}") arguments = req.params.arguments or {} - meta = _tool_invocation_meta(CAROUSEL_WIDGET) - topping = str(arguments.get("searchTerm", "")).strip() - if not _get_bearer_token_from_request(): return _oauth_error_result( "Authentication required: no access token provided.", description="No access token was provided", ) - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text="Rendered a pizza carousel!", - ) - ], - structuredContent={"pizzaTopping": topping}, - _meta=meta, + if tool_name == SEARCH_TOOL_NAME: + meta = _tool_invocation_meta(CAROUSEL_WIDGET) + topping = str(arguments.get("searchTerm", "")).strip() + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="Rendered a pizza carousel!", + ) + ], + structuredContent={"pizzaTopping": topping}, + _meta=meta, + ) ) - ) + + if tool_name == PAST_ORDERS_TOOL_NAME: + meta = _tool_invocation_meta(PAST_ORDERS_WIDGET) + limit = arguments.get("limit") + try: + parsed_limit = int(limit) if limit is not None else len(PAST_ORDERS_DATA) + except Exception: + parsed_limit = len(PAST_ORDERS_DATA) + parsed_limit = max(1, min(parsed_limit, len(PAST_ORDERS_DATA))) + orders = PAST_ORDERS_DATA[:parsed_limit] + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=PAST_ORDERS_WIDGET.response_text, + ) + ], + structuredContent={"orders": orders}, + _meta=meta, + ) + ) + + return _tool_error(f"Unknown tool: {req.params.name}") mcp._mcp_server.request_handlers[types.CallToolRequest] = _call_tool_request From f5a1cdd1ccb232219366060cadc86cbb64e9f8fb Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Thu, 11 Dec 2025 16:18:21 -0500 Subject: [PATCH 10/10] Fix --- authenticated_server_python/.env.example | 2 -- authenticated_server_python/main.py | 26 ++++++++---------------- 2 files changed, 9 insertions(+), 19 deletions(-) delete mode 100644 authenticated_server_python/.env.example diff --git a/authenticated_server_python/.env.example b/authenticated_server_python/.env.example deleted file mode 100644 index 48c90b4..0000000 --- a/authenticated_server_python/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -AUTHORIZATION_SERVER_URL=https://dev-65wmmp5d56ev40iy.us.auth0.com/ -RESOURCE_SERVER_URL=https://945c890ee720.ngrok-free.app/mcp diff --git a/authenticated_server_python/main.py b/authenticated_server_python/main.py index 5c1af27..1172f67 100644 --- a/authenticated_server_python/main.py +++ b/authenticated_server_python/main.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from copy import deepcopy from dataclasses import dataclass from functools import lru_cache @@ -13,7 +12,6 @@ import mcp.types as types from mcp.server.fastmcp import FastMCP from mcp.shared.auth import ProtectedResourceMetadata -from pydantic import AnyHttpUrl from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -129,14 +127,8 @@ def _load_widget_html(component_name: str) -> str: }, ] -DEFAULT_AUTH_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" -DEFAULT_RESOURCE_SERVER_URL = "http://localhost:8000/mcp" - -# Public URLs that describe this resource server plus the authorization server. -AUTHORIZATION_SERVER_URL = AnyHttpUrl( - os.environ.get("AUTHORIZATION_SERVER_URL", DEFAULT_AUTH_SERVER_URL) -) -RESOURCE_SERVER_URL = os.environ.get("RESOURCE_SERVER_URL", DEFAULT_RESOURCE_SERVER_URL) +AUTHORIZATION_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" +RESOURCE_SERVER_URL = "https://945c890ee720.ngrok-free.app/mcp" print("AUTHORIZATION_SERVER_URL", AUTHORIZATION_SERVER_URL) print("RESOURCE_SERVER_URL", RESOURCE_SERVER_URL) @@ -195,8 +187,7 @@ def _build_www_authenticate_value(error: str, description: str) -> str: safe_error = error.replace('"', r"\"") safe_description = description.replace('"', r"\"") parts = [ - f'error="{safe_error}"', - f'error_description="{safe_description}"', + f'error="{safe_error}"error_description="{safe_description}"', ] resource_metadata = _resource_metadata_url() if resource_metadata: @@ -446,11 +437,6 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: tool_name = req.params.name arguments = req.params.arguments or {} - if not _get_bearer_token_from_request(): - return _oauth_error_result( - "Authentication required: no access token provided.", - description="No access token was provided", - ) if tool_name == SEARCH_TOOL_NAME: meta = _tool_invocation_meta(CAROUSEL_WIDGET) @@ -469,6 +455,12 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: ) if tool_name == PAST_ORDERS_TOOL_NAME: + if not _get_bearer_token_from_request(): + return _oauth_error_result( + "Authentication required: no access token provided.", + description="No access token was provided", + ) + meta = _tool_invocation_meta(PAST_ORDERS_WIDGET) limit = arguments.get("limit") try: