Skip to content

Commit 7cd43c7

Browse files
committed
Update
1 parent 2724141 commit 7cd43c7

File tree

6 files changed

+606
-1389
lines changed

6 files changed

+606
-1389
lines changed

docs/IMG_0629.png

503 KB
Loading

docs/IMG_0630.jpg

54.8 KB
Loading

docs/request-modal.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# `window.openai.requestModal`
2+
3+
`window.openai.requestModal` lets a widget ask the ChatGPT host to reopen the experience inside a modal. The host re-mounts the widget in `view.mode === "modal"` and passes back the `params` you supply so you can render a focused flow (for example, checkout).
4+
5+
```ts
6+
type ModalAnchor = { top: number; left: number; width: number; height: number };
7+
8+
await window.openai.requestModal({
9+
title: "Checkout",
10+
params: { state: "checkout" }, // returned as window.openai.view.params in the modal
11+
heightHint: 720, // optional: give the host a preferred height in px
12+
anchor: anchorRect, // optional: helps the host animate from the clicked element
13+
});
14+
```
15+
16+
## Usage patterns
17+
18+
- Call from a user gesture and guard for environments where the API is missing.
19+
- Keep the payload small and serializable; the host echoes it back as `window.openai.view.params`.
20+
- In the modal view, use the echoed params plus any persisted widget state (via `setWidgetState`) to rebuild the UI.
21+
22+
### 1) Basic invocation (inline view)
23+
24+
```ts
25+
async function openCheckout() {
26+
try {
27+
await window.openai?.requestModal?.({
28+
title: "Checkout",
29+
params: { state: "checkout" },
30+
});
31+
} catch (error) {
32+
console.error("Unable to open checkout modal", error);
33+
}
34+
}
35+
```
36+
37+
### 2) Anchor the modal to the click target
38+
39+
This mirrors the pizza-shop helper in `src/pizzaz-shop/index.tsx`: measure the trigger element so the host can animate the modal from that spot.
40+
41+
```ts
42+
function openItemModal(event: React.MouseEvent<HTMLButtonElement>, itemId: string) {
43+
const rect = event.currentTarget.getBoundingClientRect();
44+
45+
void window.openai?.requestModal?.({
46+
title: "Item details",
47+
params: { state: "checkout", selectedCartItemId: itemId },
48+
anchor: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
49+
});
50+
}
51+
```
52+
53+
### 3) Render using the modal params
54+
55+
When the host reopens the widget in a modal, read `window.openai.view` to hydrate your view.
56+
57+
```ts
58+
const view = window.openai?.view ?? { mode: "inline", params: null };
59+
const isModalView = view.mode === "modal";
60+
const modalParams = (view.params ?? {}) as { state?: string; selectedCartItemId?: string };
61+
62+
useEffect(() => {
63+
if (!isModalView) return;
64+
65+
if (modalParams.state === "checkout") {
66+
setActiveView("checkout");
67+
setSelectedCartItemId(modalParams.selectedCartItemId ?? null);
68+
}
69+
}, [isModalView, modalParams.state, modalParams.selectedCartItemId]);
70+
```
71+
72+
## Tips
73+
74+
- Fall back gracefully (`window.openai?.requestModal`) so the widget still works in hosts that have not rolled out modals.
75+
- Pair modal params with persistent widget state via `window.openai.setWidgetState` to keep carts or selections in sync between inline and modal views.
76+
- Use `heightHint` to prevent scroll-clipped layouts; the host may clamp the value.
77+
- Avoid passing large data blobs—send identifiers and refetch using your existing tool calls instead.
78+
79+
## State management (inline ↔ modal)
80+
81+
The host remounts your widget when the modal opens, so treat state as reconstructable:
82+
83+
- **Persist shared state** with `window.openai.setWidgetState` (e.g., cart contents, selections). The host restores `window.openai.widgetState` on every mount.
84+
- **Use modal params for intent**, not full data. Pass `{ state: "checkout", selectedCartItemId }`, then re-derive the UI from `widgetState`.
85+
- **Detect view changes** via `window.openai.view.mode` to branch behavior (e.g., hide inline-only controls when `mode === "modal"`).
86+
87+
### Example: keep cart and selection in sync
88+
89+
```ts
90+
// Inline view: ask for modal and persist selection
91+
async function openCartItemModal(itemId: string, anchor?: DOMRect) {
92+
await window.openai.setWidgetState((prev) => ({
93+
...(prev ?? {}),
94+
selectedCartItemId: itemId,
95+
}));
96+
97+
await window.openai.requestModal({
98+
title: "Checkout",
99+
params: { state: "checkout", selectedCartItemId: itemId },
100+
anchor: anchor && {
101+
top: anchor.top,
102+
left: anchor.left,
103+
width: anchor.width,
104+
height: anchor.height,
105+
},
106+
});
107+
}
108+
109+
// Modal view: rebuild from persisted state + params
110+
const view = window.openai.view;
111+
const isModalView = view.mode === "modal";
112+
const modalParams = (view.params ?? {}) as { state?: string; selectedCartItemId?: string };
113+
const widgetState = window.openai.widgetState as {
114+
cartItems?: Array<{ id: string; quantity: number }>;
115+
selectedCartItemId?: string | null;
116+
} | null;
117+
118+
const selectedId = modalParams.selectedCartItemId ?? widgetState?.selectedCartItemId ?? null;
119+
const cartItems = widgetState?.cartItems ?? [];
120+
121+
useEffect(() => {
122+
if (!isModalView) return;
123+
124+
setSelectedCartItemId(selectedId);
125+
setVisibleView(modalParams.state === "checkout" ? "checkout" : "cart");
126+
}, [isModalView, selectedId, modalParams.state]);
127+
```
128+
129+
### Sync tool output into widget state
130+
131+
Many samples (including `src/pizzaz-shop/index.tsx`) keep tool responses and widget state aligned. If a tool returns the canonical cart, mirror it into `setWidgetState` so both inline and modal mounts see the same data:
132+
133+
```ts
134+
useEffect(() => {
135+
if (!toolOutput || !window.openai?.setWidgetState) return;
136+
137+
// Only sync when the content actually changes
138+
const toolCart = toolOutput.cartItems;
139+
if (!Array.isArray(toolCart)) return;
140+
141+
void window.openai.setWidgetState((prev) => ({
142+
...(prev ?? {}),
143+
cartItems: toolCart,
144+
}));
145+
}, [toolOutput]);
146+
```
147+
148+
### Degrade gracefully
149+
150+
- Wrap calls with optional chaining (`window.openai?.requestModal`) so the widget still works where modals are unsupported.
151+
- If the modal API is missing, continue to use inline flows; rely on `widgetState` to avoid losing user progress.

ecommerce_server_python/main.py

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

0 commit comments

Comments
 (0)