From 6bde9bb81ab977da1cf0577fce43b59e9d17ecdc Mon Sep 17 00:00:00 2001 From: Obad94 Date: Thu, 16 Oct 2025 02:31:12 +0500 Subject: [PATCH] feat: add dynamic resource template support - align template URI handling across node and python demos - add pizzaz toppings and detail tools returning parameterized widgets - inline dev/CDN asset handling with template metadata - update readme and env samples for SSR resource templates Fixes: #47 --- .gitignore | 3 +- README.md | 92 +- build-all.mts | 48 +- package.json | 7 +- pizzaz_server_node/.env.example | 4 + pizzaz_server_node/README.md | 52 +- pizzaz_server_node/package.json | 1 + pizzaz_server_node/src/server.ts | 847 +++++++++++++-- pizzaz_server_python/.env.example | 5 + pizzaz_server_python/README.md | 46 +- pizzaz_server_python/main.py | 1057 ++++++++++++++++--- pizzaz_server_python/requirements.txt | 1 + pnpm-lock.yaml | 9 + scripts/run-python-server.mjs | 81 ++ solar-system_server_python/.env.example | 5 + solar-system_server_python/README.md | 55 +- solar-system_server_python/main.py | 187 +++- solar-system_server_python/requirements.txt | 1 + src/pizzaz-carousel/PlaceCard.jsx | 36 +- src/pizzaz-carousel/index.jsx | 31 +- src/pizzaz-list/index.jsx | 203 +++- src/pizzaz-video/index.css | 3 + src/pizzaz-video/index.jsx | 46 + src/pizzaz/Inspector.jsx | 2 +- src/pizzaz/Sidebar.jsx | 2 +- src/pizzaz/index.jsx | 22 +- src/pizzaz/markers.json | 230 +++- src/utils/price-range.js | 34 + 28 files changed, 2699 insertions(+), 411 deletions(-) create mode 100644 pizzaz_server_node/.env.example create mode 100644 pizzaz_server_python/.env.example create mode 100644 scripts/run-python-server.mjs create mode 100644 solar-system_server_python/.env.example create mode 100644 src/pizzaz-video/index.css create mode 100644 src/pizzaz-video/index.jsx create mode 100644 src/utils/price-range.js diff --git a/.gitignore b/.gitignore index bdd17b6..c26ce6b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,4 @@ yarn-error.log __pycache__/ *.py[cod] *.egg-info/ -.venv/ - +.venv/ \ No newline at end of file diff --git a/README.md b/README.md index cb01f13..9fd3918 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This repository showcases example UI components to be used with the Apps SDK, as well as example MCP servers that expose a collection of components as tools. It is meant to be used as a starting point and source of inspiration to build your own apps for ChatGPT. -## MCP + Apps SDK overview +## MCP + Apps SDK Overview The Model Context Protocol (MCP) is an open specification for connecting large language model clients to external tools, data, and user interfaces. An MCP server exposes tools that a model can call during a conversation and returns results according to the tool contracts. Those results can include extra metadata—such as inline HTML—that the Apps SDK uses to render rich UI components (widgets) alongside assistant messages. @@ -19,7 +19,7 @@ Because the protocol is transport agnostic, you can host the server over Server- The MCP servers in this demo highlight how each tool can light up widgets by combining structured payloads with `_meta.openai/outputTemplate` metadata returned from the MCP servers. -## Repository structure +## Repository Structure - `src/` – Source for each widget example. - `assets/` – Generated HTML, JS, and CSS bundles after running the build step. @@ -52,7 +52,7 @@ The components are bundled into standalone assets that the MCP servers serve as pnpm run build ``` -This command runs `build-all.mts`, producing versioned `.html`, `.js`, and `.css` files inside `assets/`. Each widget is wrapped with the CSS it needs so you can host the bundles directly or ship them with your own server. +This command runs `build-all.mts`, producing versioned `.html`, `.js`, and `.css` files inside `assets/`. Each widget is wrapped with the CSS it needs so you can host the bundles directly or ship them with your own server. If the local assets are missing at runtime, the Pizzaz MCP server automatically falls back to the CDN bundles (version `0038`). To iterate locally, you can also launch the Vite dev server: @@ -60,6 +60,14 @@ To iterate locally, you can also launch the Vite dev server: pnpm run dev ``` +The Vite config binds to `http://127.0.0.1:4444` by default. Need another host or port? Pass CLI overrides (for example, to expose on all interfaces at `4000`): + +```bash +pnpm run dev --host 0.0.0.0 --port 4000 +``` + +If you change the origin, update the MCP server `.env` (`DOMAIN=`) so widgets resolve correctly. + ## Serve the static assets If you want to preview the generated bundles without the MCP servers, start the static file server after running a build: @@ -68,6 +76,14 @@ If you want to preview the generated bundles without the MCP servers, start the pnpm run serve ``` +This static server also defaults to port `4444`. Override it when needed: + +```bash +pnpm run serve -p 4000 +``` + +Make sure the MCP server `DOMAIN` matches the port you choose. + The assets are exposed at [`http://localhost:4444`](http://localhost:4444) with CORS enabled so that local tooling (including MCP inspectors) can fetch them. ## Run the MCP servers @@ -79,31 +95,66 @@ The repository ships several demo MCP servers that highlight different widget bu Every tool response includes plain text content, structured JSON, and `_meta.openai/outputTemplate` metadata so the Apps SDK can hydrate the matching widget. +Each MCP server reads `ENVIRONMENT`, `DOMAIN`, and `PORT` from a `.env` file located in its own directory (`pizzaz_server_node/.env`, `pizzaz_server_python/.env`, `solar-system_server_python/.env`). Instead of exporting shell variables, create or update the `.env` file beside the server you're running. For example, inside `pizzaz_server_node/.env`: + +```env +# Development: consume Vite dev assets on http://localhost:5173 +ENVIRONMENT=local + +# Production-style: point to the static asset server started with `pnpm run serve` +# ENVIRONMENT=production +# DOMAIN=http://localhost:4444 + +# Port override (defaults to 8000 when omitted) +# PORT=8123 +``` + +- Use `ENVIRONMENT=local` while `pnpm run dev` is serving assets so widgets load without hash suffixes. +- Switch to `ENVIRONMENT=production` and set `DOMAIN` after running `pnpm run build` and `pnpm run serve` to reference the static bundles. +- Adjust `PORT` if you need the MCP endpoint on something other than `http://localhost:8000/mcp`. + ### Pizzaz Node server ```bash cd pizzaz_server_node +pnpm install pnpm start ``` ### Pizzaz Python server ```bash +cd pizzaz_server_python python -m venv .venv +# Windows PowerShell +.\.venv\Scripts\activate +# macOS/Linux source .venv/bin/activate -pip install -r pizzaz_server_python/requirements.txt -uvicorn pizzaz_server_python.main:app --port 8000 +pip install -r requirements.txt +python main.py ``` +Prefer invoking uvicorn directly? From the repository root you can run `uvicorn pizzaz_server_python.main:app --port 8000` once dependencies are installed. + +> Prefer pnpm scripts? After activating the virtual environment, return to the repository root (for example `cd ..`) and run `pnpm start:pizzaz-python`. + ### Solar system Python server ```bash +cd solar-system_server_python python -m venv .venv +# Windows PowerShell +.\.venv\Scripts\activate +# macOS/Linux source .venv/bin/activate -pip install -r solar-system_server_python/requirements.txt -uvicorn solar-system_server_python.main:app --port 8000 +pip install -r requirements.txt +python main.py ``` +Prefer invoking uvicorn directly? From the repository root you can run `uvicorn solar-system_server_python.main:app --port 8000` once dependencies are installed. + +> Similarly, once the virtual environment is active, head back to the repository root and run `pnpm start:solar-python` to use the wrapper script. + You can reuse the same virtual environment for all Python servers—install the dependencies once and run whichever entry point you need. ## Testing in ChatGPT @@ -112,15 +163,35 @@ To add these apps to ChatGPT, enable [developer mode](https://platform.openai.co To add your local server without deploying it, you can use a tool like [ngrok](https://ngrok.com/) to expose your local server to the internet. -For example, once your mcp servers are running, you can run: +For example, once your MCP servers are running, you can run: ```bash ngrok http 8000 ``` -You will get a public URL that you can use to add your local server to ChatGPT in Settings > Connectors. +Use the generated URL (for example `https://.ngrok-free.app/mcp`) when configuring ChatGPT. All of the demo servers listen on `http://localhost:8000/mcp` by default; adjust the port in the command above if you override it. + +### Hot-swap modes without reconnecting -For example: `https://.ngrok-free.app/mcp` +You can swap between CDN, static builds, and the Vite dev server without reconfiguring ChatGPT: + +1. Change the environment you care about (edit the relevant `.env`, run `pnpm run dev`, or rebuild assets and rerun the MCP server). +2. In ChatGPT, open **Settings → Apps & Connectors →** select your connected app → **Actions → Refresh app**. +3. Continue the conversation, no reconnects or page reloads are needed. + +When switching modes, avoid disconnecting the connector, deleting it, launching a brand-new tunnel, or refreshing the ChatGPT conversation tab. After you hit **Refresh app**, ChatGPT keeps the existing MCP base URL and simply pulls the latest widget HTML/CSS/JS strategy from your server. + +| Mode | What you change | Typical `.env` | +| --- | --- | --- | +| CDN (easiest) | Nothing beyond the MCP server | (leave `PORT`, `ENVIRONMENT` & `DOMAIN` unset) | +| Static serve (inline bundles) | `pnpm run build` (optionally `pnpm run serve` to inspect) | `ENVIRONMENT=production` / `PORT=8000` | +| Dev (Vite hot reload) | Run `pnpm run dev` and point your MCP server at it | `ENVIRONMENT=local` / `DOMAIN=http://127.0.0.1:4444` / `PORT=8000` | + +#### Working inside virtual machines + +For the smoothest loop, keep everything inside the same VM: run Vite or the static server, the MCP server, ngrok, and your ChatGPT browser session together so localhost resolves correctly. If your browser lives on the host machine while servers stay in the VM, either tunnel the frontend as well (for example, a second `ngrok http 4444` plus `DOMAIN=`), or expose the VM via an HTTPS-accessible IP and point `DOMAIN` there. + +Switch modes freely → **Actions → Refresh app** → keep building. Once you add a connector, you can use it in ChatGPT conversations. @@ -130,7 +201,6 @@ You can add your app to the conversation context by selecting it in the "More" o You can then invoke tools by asking something related. For example, for the Pizzaz app, you can ask "What are the best pizzas in town?". - ## Next steps - Customize the widget data: edit the handlers in `pizzaz_server_node/src`, `pizzaz_server_python/main.py`, or the solar system server to fetch data from your systems. diff --git a/build-all.mts b/build-all.mts index abd9832..d138edb 100644 --- a/build-all.mts +++ b/build-all.mts @@ -145,9 +145,11 @@ const outputs = fs const renamed = []; +const buildSalt = process.env.BUILD_SALT ?? new Date().toISOString(); + const h = crypto .createHash("sha256") - .update(pkg.version, "utf8") + .update(`${pkg.version}:${buildSalt}`, "utf8") .digest("hex") .slice(0, 4); @@ -172,25 +174,47 @@ for (const name of builtNames) { const cssPath = path.join(dir, `${name}-${h}.css`); const jsPath = path.join(dir, `${name}-${h}.js`); - const css = fs.existsSync(cssPath) - ? fs.readFileSync(cssPath, { encoding: "utf8" }) - : ""; - const js = fs.existsSync(jsPath) - ? fs.readFileSync(jsPath, { encoding: "utf8" }) - : ""; + const cssHref = fs.existsSync(cssPath) + ? `/${path.basename(cssPath)}?v=${h}` + : undefined; + const jsSrc = fs.existsSync(jsPath) + ? `/${path.basename(jsPath)}?v=${h}` + : undefined; - const cssBlock = css ? `\n \n` : ""; - const jsBlock = js ? `\n ` : ""; + const extraScript = name === "pizzaz-video" + ? "\n ` : "", + extraScript, "", "", - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); + fs.writeFileSync(htmlPath, html, { encoding: "utf8" }); console.log(`${htmlPath} (generated)`); + + const stableHtmlPath = path.join(dir, `${name}.html`); + fs.writeFileSync(stableHtmlPath, html, { encoding: "utf8" }); + console.log(`${stableHtmlPath} (generated)`); + + const cleanUrlDir = path.join(dir, name); + fs.mkdirSync(cleanUrlDir, { recursive: true }); + const cleanUrlIndexPath = path.join(cleanUrlDir, "index.html"); + const cleanHtml = html + .replace(`href="${cssHref ?? ""}"`, cssHref ? `href="${cssHref}"` : "") + .replace(`src="${jsSrc ?? ""}"`, jsSrc ? `src="${jsSrc}"` : ""); + + fs.writeFileSync(cleanUrlIndexPath, cleanHtml, { encoding: "utf8" }); + console.log(`${cleanUrlIndexPath} (generated)`); } diff --git a/package.json b/package.json index 3a92c3f..288dfca 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,15 @@ "main": "host/main.ts", "scripts": { "build": "tsx ./build-all.mts", - "serve": "serve -s ./assets -p 4444 --cors", + "serve": "serve -s ./assets --cors", "dev": "vite --config vite.config.mts", "tsc": "tsc -b", "tsc:app": "tsc -p tsconfig.app.json", "tsc:node": "tsc -p tsconfig.node.json", - "dev:host": "vite --config vite.host.config.mts" + "dev:host": "vite --config vite.host.config.mts", + "start:pizzaz-node": "pnpm -C pizzaz_server_node start", + "start:pizzaz-python": "node ./scripts/run-python-server.mjs pizzaz_server_python/main.py", + "start:solar-python": "node ./scripts/run-python-server.mjs solar-system_server_python/main.py" }, "keywords": [], "author": "", diff --git a/pizzaz_server_node/.env.example b/pizzaz_server_node/.env.example new file mode 100644 index 0000000..855e550 --- /dev/null +++ b/pizzaz_server_node/.env.example @@ -0,0 +1,4 @@ +## Pizzaz MCP (Node) environment variables +# ENVIRONMENT=local # Optional: 'local' or 'production' (default) +# DOMAIN=http://localhost:4444 # Override dev/serve origin (leave unset for CDN) +# PORT=8000 # Optional: change server port (default 8000) diff --git a/pizzaz_server_node/README.md b/pizzaz_server_node/README.md index 0f0f91d..d502a20 100644 --- a/pizzaz_server_node/README.md +++ b/pizzaz_server_node/README.md @@ -1,6 +1,6 @@ -# Pizzaz MCP server (Node) +# Pizzaz MCP Server (Node) -This directory contains a minimal Model Context Protocol (MCP) server implemented with the official TypeScript SDK. The server exposes the full suite of Pizzaz demo widgets so you can experiment with UI-bearing tools in ChatGPT developer mode. +This directory contains a minimal Model Context Protocol (MCP) server implemented with the official TypeScript SDK. The service exposes the five Pizzaz demo widgets and shares configuration with the rest of the workspace: it reads environment flags from a local `.env` file and automatically falls back to the published CDN bundles when local assets are unavailable. ## Prerequisites @@ -13,7 +13,7 @@ This directory contains a minimal Model Context Protocol (MCP) server implemente pnpm install ``` -If you prefer npm or yarn, adjust the command accordingly. +Adjust the command if you prefer npm or yarn. ## Run the server @@ -21,12 +21,46 @@ If you prefer npm or yarn, adjust the command accordingly. pnpm start ``` -The script bootstraps the server over SSE (Server-Sent Events), which makes it compatible with the MCP Inspector as well as ChatGPT connectors. Once running you can list the tools and invoke any of the pizza experiences. +This launches an HTTP MCP server on `http://localhost:8000/mcp` with two endpoints: -Each tool responds with: +- `GET /mcp` provides the SSE stream. +- `POST /mcp/messages?sessionId=...` accepts follow-up messages for active sessions. -- `content`: a short text confirmation that mirrors the original Pizzaz examples. -- `structuredContent`: a small JSON payload that echoes the topping argument, demonstrating how to ship data alongside widgets. -- `_meta.openai/outputTemplate`: metadata that binds the response to the matching Skybridge widget shell. +Configuration lives in `.env` within this directory (loaded automatically via `dotenv`). Update it before starting the server to control asset origins and ports. A typical file looks like: -Feel free to extend the handlers with real data sources, authentication, and persistence. +```env +# Use the Vite dev server started with `pnpm run dev` +ENVIRONMENT=local + +# After `pnpm run build && pnpm run serve`, point to the static bundles +# ENVIRONMENT=production +# DOMAIN=http://localhost:4444 + +# Change the default port (defaults to 8000) +# PORT=8123 +``` + +Key behaviors: + +- When `ENVIRONMENT=local`, widgets load from the Vite dev server (`pnpm run dev` from the repo root) without hashed filenames. +- When `ENVIRONMENT=production` and `DOMAIN` is set, widgets are served from your local static server (typically `pnpm run serve`). +- When `ENVIRONMENT` is omitted entirely—or neither local option provides assets—the server falls back to the CDN bundles (version `0038`). + +The script boots the server with an SSE transport, which makes it compatible with the MCP Inspector as well as ChatGPT connectors. Once running you can list the tools and invoke any of the pizza experiences. +- Each tool emits: + - `content`: confirmation text matching the requested action. + - `structuredContent`: JSON reflecting the requested topping. + - `_meta.openai/outputTemplate`: metadata binding the response to the Skybridge widget. + +### Hot-swap reminder + +After changing `.env`, rebuilding assets, or toggling between dev/static/CDN, open your ChatGPT connector (**Settings → Apps & Connectors → [your app] → Actions → Refresh app**). That keeps the same MCP URL, avoids new ngrok tunnels, and prompts ChatGPT to fetch the latest widget templates. See the root [README](../README.md#hot-swap-modes-without-reconnecting) for the mode cheat sheet and VM tips. + +## Next Steps + +Extend these handlers with real data sources, authentication, or localization, and customize the widget configuration under `src/` to align with your application. + +See main [README.md](../README.md) for: +- Testing in ChatGPT +- Architecture overview +- Advanced configuration diff --git a/pizzaz_server_node/package.json b/pizzaz_server_node/package.json index 4029db9..575d4fb 100644 --- a/pizzaz_server_node/package.json +++ b/pizzaz_server_node/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^0.5.0", + "dotenv": "^16.4.5", "zod": "^3.23.8" }, "devDependencies": { diff --git a/pizzaz_server_node/src/server.ts b/pizzaz_server_node/src/server.ts index cdef68f..035c62f 100644 --- a/pizzaz_server_node/src/server.ts +++ b/pizzaz_server_node/src/server.ts @@ -1,7 +1,11 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { URL } from "node:url"; +import { readFileSync, existsSync, readdirSync, Dirent } from "node:fs"; +import { resolve } from "node:path"; +import { URL, fileURLToPath } from "node:url"; +import crypto from "node:crypto"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import 'dotenv/config'; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { CallToolRequestSchema, @@ -19,95 +23,595 @@ import { type Tool } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import pkg from "../../package.json" with { type: "json" }; +import markers from "../../src/pizzaz/markers.json" with { type: "json" }; + +const CDN_BASE = "https://persistent.oaistatic.com/ecosystem-built-assets"; +const CDN_VERSION = "0038"; + +function getEnv(key: string): string | undefined { + const value = process.env[key]; + return value === undefined ? undefined : value; +} + +// Environment variables - only these three are supported +const ENVIRONMENT = (getEnv("ENVIRONMENT") ?? "").trim(); +const DOMAIN = (getEnv("DOMAIN") ?? "").trim() || undefined; +const PORT = (getEnv("PORT") ?? "").trim() || undefined; + +// Determine asset serving strategy based on ENVIRONMENT and DOMAIN +const environment = ENVIRONMENT.toLowerCase(); +const isLocalEnv = environment === "local" || environment === "dev" || environment === "development"; +const rawDevAssetOrigin = DOMAIN ?? (isLocalEnv ? "http://localhost:4444" : undefined); +const devAssetOrigin = rawDevAssetOrigin?.replace(/\/$/, ""); + +// When using the Vite dev server (`pnpm run dev`), assets are served without the hash suffix +const devAssetUseHash = !isLocalEnv; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const repoRoot = resolve(__dirname, "../../"); +const assetsDir = resolve(repoRoot, "assets"); + +function discoverAssetHash(dir: string): string | undefined { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") { + console.warn(`Failed to scan assets directory for hash: ${err.message}`); + } + return undefined; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + const match = entry.name.match(/^[a-z0-9-]+-([0-9a-f]{4})\.(?:js|css|html)$/); + if (match) { + return match[1]; + } + } + return undefined; +} + +const computedAssetHash = crypto + .createHash("sha256") + .update((pkg as { version: string }).version, "utf8") + .digest("hex") + .slice(0, 4); + +const assetHash = ( + process.env.ASSET_HASH?.trim().toLowerCase() || + discoverAssetHash(assetsDir) || + computedAssetHash +).toLowerCase(); + +// In dev with un-hashed assets, derive a version tag from the process start minute +const isDevUnhashed = Boolean(devAssetOrigin) && !devAssetUseHash; +const autoDevVersion = isDevUnhashed + ? `dev-${Math.floor(Date.now() / 60_000).toString(36)}` + : undefined; +const templateVersion = (autoDevVersion ?? assetHash).toLowerCase(); + +// Default pizza video (public-domain fallback that does not expire). +const DEFAULT_PIZZA_VIDEO_URL = + "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"; + +const videoScriptSnippet = ` +${extraScript} + `.trim(); +} + +function inlineWidgetHtml(assetName: string): string | undefined { + const cssPath = resolve(assetsDir, `${assetName}-${assetHash}.css`); + const jsPath = resolve(assetsDir, `${assetName}-${assetHash}.js`); + + // If either file is missing, silently skip inlining and allow CDN/dev fallback. + if (!existsSync(cssPath) || !existsSync(jsPath)) { + return undefined; + } + + try { + const css = readFileSync(cssPath, "utf8"); + const js = readFileSync(jsPath, "utf8"); + + const extraScript = assetName === "pizzaz-video" ? videoScriptSnippet : ""; + + return ` +
+ + +${extraScript} + `.trim(); + } catch (error) { + const err = error as NodeJS.ErrnoException; + // Only warn on unexpected read errors; ENOENT is already handled above. + if (err.code !== "ENOENT") { + console.warn( + `Failed to inline local assets for ${assetName}: ${err.message}. Falling back to CDN.`, + ); + } + return undefined; + } +} + +function cdnWidgetHtml(assetName: string): string { + const extraScript = assetName === "pizzaz-video" ? videoScriptSnippet : ""; + + return ` +
+ + +${extraScript} + `.trim(); +} + +function buildWidgetHtml(assetName: string): string { + const devHtml = devHostedWidgetHtml(assetName); + if (devHtml) { + return devHtml; + } + + if (!ENVIRONMENT) { + console.info(`No ENVIRONMENT set; falling back to CDN assets for ${assetName}`); + return cdnWidgetHtml(assetName); + } + + return inlineWidgetHtml(assetName) ?? cdnWidgetHtml(assetName); +} + +const TEMPLATE_PARAM_GLOBAL = "__PIZZAZ_TEMPLATE_PARAMS__"; + +function appendTemplateParamsScript(baseHtml: string, params: Record): string { + const keys = Object.keys(params); + if (keys.length === 0) { + return baseHtml; + } + + const serialized = JSON.stringify(params); + const paramScript = ` - `.trim(), - responseText: "Rendered a pizza map!" + responseText: "Rendered a pizza map!", + assetName: "pizzaz" }, { id: "pizza-carousel", title: "Show Pizza Carousel", - templateUri: "ui://widget/pizza-carousel.html", + templateUriBase: "ui://widget/pizza-carousel.html", invoking: "Carousel some spots", invoked: "Served a fresh carousel", - html: ` - - - - `.trim(), - responseText: "Rendered a pizza carousel!" + responseText: "Rendered a pizza carousel!", + assetName: "pizzaz-carousel" }, { id: "pizza-albums", title: "Show Pizza Album", - templateUri: "ui://widget/pizza-albums.html", + templateUriBase: "ui://widget/pizza-albums.html", invoking: "Hand-tossing an album", invoked: "Served a fresh album", - html: ` -
- - - `.trim(), - responseText: "Rendered a pizza album!" + responseText: "Rendered a pizza album!", + assetName: "pizzaz-albums" }, { id: "pizza-list", title: "Show Pizza List", - templateUri: "ui://widget/pizza-list.html", + templateUriBase: "ui://widget/pizza-list.html", invoking: "Hand-tossing a list", invoked: "Served a fresh list", - html: ` -
- - - `.trim(), - responseText: "Rendered a pizza list!" + responseText: "Rendered a pizza list!", + assetName: "pizzaz-list", + dynamicTemplate: { + uriTemplateBase: "ui://widget/pizza-list/{pizzaTopping}.html", + description: "Pizza list widget filtered by topping.", + parameters: [ + { + name: "pizzaTopping", + description: "Name of the topping to highlight in the list." + } + ] + } }, { id: "pizza-video", title: "Show Pizza Video", - templateUri: "ui://widget/pizza-video.html", + templateUriBase: "ui://widget/pizza-video.html", invoking: "Hand-tossing a video", invoked: "Served a fresh video", - html: ` -
- - - `.trim(), - responseText: "Rendered a pizza video!" + responseText: "Rendered a pizza video!", + assetName: "pizzaz-video" } ]; +const versionSuffix = templateVersion ? `?v=${templateVersion}` : ""; + +const resources: Resource[] = []; +const resourceTemplates: ResourceTemplate[] = []; +const resourceTemplateHandlers: ResourceTemplateHandler[] = []; + +const restaurants: PizzaPlace[] = ((markers as { places?: PizzaPlace[] }).places ?? []).map((place) => { + const toppingSet = new Set(); + + (place.toppings ?? []) + .map((topping) => topping.trim()) + .filter(Boolean) + .forEach((topping) => toppingSet.add(topping)); + + const menu = (place.menu ?? []).map((menuItem) => { + const sanitizedToppings = (menuItem.toppings ?? []) + .map((topping) => topping.trim()) + .filter(Boolean); + sanitizedToppings.forEach((topping) => toppingSet.add(topping)); + + return { + ...menuItem, + toppings: sanitizedToppings, + image: menuItem.image ?? place.thumbnail + } satisfies MenuItem; + }); + + const priceRange = computePriceRange(menu); + + return { + ...place, + toppings: Array.from(toppingSet), + menu, + priceRange + } satisfies PizzaPlace; +}); + +type MenuItemWithRestaurant = MenuItem & { restaurant: PizzaPlace }; + +const menuItems: MenuItemWithRestaurant[] = restaurants.flatMap((place) => + (place.menu ?? []).map((item) => ({ + ...item, + restaurant: place + })) +); + +const restaurantsById = new Map(); +const restaurantsByNormalizedName = new Map(); + +restaurants.forEach((place) => { + restaurantsById.set(place.id.toLowerCase(), place); + restaurantsByNormalizedName.set(place.name.toLowerCase(), place); +}); + +const menuItemsById = new Map(); +const menuItemsByNormalizedName = new Map(); + +menuItems.forEach((item) => { + menuItemsById.set(item.id.toLowerCase(), item); + menuItemsByNormalizedName.set(item.name.toLowerCase(), item); +}); + +const allToppings = Array.from( + new Set(menuItems.flatMap((item) => item.toppings ?? [])) +).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + +const toppingsByNormalized = new Map(); +allToppings.forEach((topping) => { + toppingsByNormalized.set(topping.toLowerCase(), topping); +}); + +const widgets: PizzazWidget[] = widgetConfigs.map((config) => { + const templateUri = `${config.templateUriBase}${versionSuffix}`; + const outputTemplateBase = config.dynamicTemplate?.uriTemplateBase ?? config.templateUriBase; + const outputTemplate = `${outputTemplateBase}${versionSuffix}`; + const baseHtml = buildWidgetHtml(config.assetName); + + const widget: PizzazWidget = { + id: config.id, + title: config.title, + templateUri, + outputTemplate, + invoking: config.invoking, + invoked: config.invoked, + responseText: config.responseText, + html: baseHtml, + templateParameters: config.dynamicTemplate?.parameters + }; + + const resourceDescription = `${config.title} widget markup`; + + resources.push({ + uri: templateUri, + name: config.title, + description: resourceDescription, + mimeType: "text/html+skybridge", + _meta: widgetMeta(widget, { outputTemplate: templateUri }) + }); + + resourceTemplates.push({ + uriTemplate: templateUri, + name: config.title, + description: resourceDescription, + mimeType: "text/html+skybridge", + _meta: widgetMeta(widget, { outputTemplate: templateUri }) + }); + + if (config.dynamicTemplate) { + const dynamicUriTemplate = `${config.dynamicTemplate.uriTemplateBase}${versionSuffix}`; + const compiled = compileUriTemplate(dynamicUriTemplate); + const render = config.dynamicTemplate.render + ? (params: Record) => config.dynamicTemplate!.render!(params, { baseHtml }) + : (params: Record) => appendTemplateParamsScript(baseHtml, params); + + const templatedResource: ResourceTemplate = { + uriTemplate: dynamicUriTemplate, + name: config.title, + description: config.dynamicTemplate.description ?? `${config.title} widget markup (templated)`, + mimeType: "text/html+skybridge", + _meta: widgetMeta(widget, { + outputTemplate: dynamicUriTemplate, + includeParameterSchema: true + }) + }; + + resourceTemplates.push(templatedResource); + resourceTemplateHandlers.push({ + widget, + template: templatedResource, + compiled, + render + }); + } + + return widget; +}); + const widgetsById = new Map(); const widgetsByUri = new Map(); @@ -124,37 +628,65 @@ const toolInputSchema = { description: "Topping to mention when rendering the widget." } }, - required: ["pizzaTopping"], + required: [], additionalProperties: false } as const; const toolInputParser = z.object({ - pizzaTopping: z.string() + pizzaTopping: z.string().optional() }); -const tools: Tool[] = widgets.map((widget) => ({ +const widgetTools: Tool[] = widgets.map((widget) => ({ name: widget.id, description: widget.title, inputSchema: toolInputSchema, title: widget.title, - _meta: widgetMeta(widget) + _meta: widgetMeta(widget, { includeParameterSchema: true }) })); -const resources: Resource[] = widgets.map((widget) => ({ - uri: widget.templateUri, - name: widget.title, - description: `${widget.title} widget markup`, - mimeType: "text/html+skybridge", - _meta: widgetMeta(widget) -})); +const availableToppingsTool: Tool = { + name: "list-pizza-toppings", + title: "List Available Pizza Toppings", + description: "Lists every pizza topping supported by the Pizzaz widgets.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false + }, + _meta: { + "openai/widgetAccessible": false, + "openai/resultCanProduceWidget": false + } +}; -const resourceTemplates: ResourceTemplate[] = widgets.map((widget) => ({ - uriTemplate: widget.templateUri, - name: widget.title, - description: `${widget.title} widget markup`, - mimeType: "text/html+skybridge", - _meta: widgetMeta(widget) -})); +const pizzaDetailToolInputSchema = { + type: "object", + properties: { + pizzaName: { + type: "string", + description: "Name or identifier of the pizza to describe." + } + }, + required: ["pizzaName"], + additionalProperties: false +} as const; + +const pizzaDetailInputParser = z.object({ + pizzaName: z.string() +}); + +const pizzaDetailTool: Tool = { + name: "describe-pizza-toppings", + title: "Describe Pizza Toppings", + description: "Lists the toppings for a specific pizza from the demo dataset.", + inputSchema: pizzaDetailToolInputSchema, + _meta: { + "openai/widgetAccessible": false, + "openai/resultCanProduceWidget": false + } +}; + +const tools: Tool[] = [...widgetTools, availableToppingsTool, pizzaDetailTool]; function createPizzazServer(): Server { const server = new Server( @@ -177,20 +709,64 @@ function createPizzazServer(): Server { server.setRequestHandler(ReadResourceRequestSchema, async (request: ReadResourceRequest) => { const widget = widgetsByUri.get(request.params.uri); - if (!widget) { - throw new Error(`Unknown resource: ${request.params.uri}`); + if (widget) { + return { + contents: [ + { + uri: widget.templateUri, + mimeType: "text/html+skybridge", + text: widget.html, + _meta: widgetMeta(widget, { + outputTemplate: widget.templateUri, + resolvedUri: widget.templateUri + }) + } + ] + }; } - return { - contents: [ - { - uri: widget.templateUri, - mimeType: "text/html+skybridge", - text: widget.html, - _meta: widgetMeta(widget) + for (const handler of resourceTemplateHandlers) { + const match = handler.compiled.regex.exec(request.params.uri); + const groups = match?.groups; + + if (!groups) { + continue; + } + + const params: Record = {}; + handler.compiled.parameterNames.forEach((name) => { + const value = groups[name]; + if (typeof value === "string" && value.length > 0) { + let decoded = value; + try { + decoded = decodeURIComponent(value); + } catch (error) { + console.warn(`Failed to decode template parameter ${name}: ${(error as Error).message}`); + } + params[name] = decoded; } - ] - }; + }); + + const html = handler.render(params); + + return { + contents: [ + { + uri: request.params.uri, + mimeType: "text/html+skybridge", + text: html, + _meta: widgetMeta(handler.widget, { + outputTemplate: handler.template.uriTemplate, + includeParameterSchema: true, + parameterValues: params, + resolvedUri: request.params.uri + }) + } + ] + }; + } + + throw new Error(`Unknown resource: ${request.params.uri}`); }); server.setRequestHandler(ListResourceTemplatesRequestSchema, async (_request: ListResourceTemplatesRequest) => ({ @@ -202,6 +778,106 @@ function createPizzazServer(): Server { })); server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + if (request.params.name === availableToppingsTool.name) { + const toppingsList = allToppings; + return { + content: [ + { + type: "text", + text: `Here are the toppings you can request: ${toppingsList + .map((topping) => `“${topping}”`) + .join(", ")}.` + } + ], + structuredContent: { + availableToppings: toppingsList + }, + _meta: { + "openai/widgetAccessible": false, + "openai/resultCanProduceWidget": false + } + }; + } + + if (request.params.name === pizzaDetailTool.name) { + const args = pizzaDetailInputParser.parse(request.params.arguments ?? {}); + const pizza = findPizza(args.pizzaName); + + if (!pizza) { + const suggestions = menuItems + .slice(0, 6) + .map((item) => `${item.name} (${item.restaurant.name})`) + .join(", "); + return { + content: [ + { + type: "text", + text: `I couldn’t find a pizza named “${args.pizzaName}”. Try one of these: ${suggestions}.` + } + ], + structuredContent: { + availablePizzas: menuItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + toppings: item.toppings ?? [], + restaurant: { + id: item.restaurant.id, + name: item.restaurant.name, + city: item.restaurant.city, + rating: item.restaurant.rating, + priceRange: item.restaurant.priceRange + } + })) + }, + _meta: { + "openai/widgetAccessible": false, + "openai/resultCanProduceWidget": false + } + }; + } + + const { restaurant, ...menuItem } = pizza; + const toppings = menuItem.toppings ?? []; + const toppingsText = toppings.length + ? toppings.map((topping) => `“${topping}”`).join(", ") + : "no recorded toppings"; + const priceFragment = menuItem.price ? ` It costs ${menuItem.price}.` : ""; + + return { + content: [ + { + type: "text", + text: `${menuItem.name} from ${restaurant.name} features ${toppingsText}.${priceFragment}` + } + ], + structuredContent: { + pizza: { + id: menuItem.id, + name: menuItem.name, + description: menuItem.description, + price: menuItem.price, + toppings, + image: menuItem.image + }, + restaurant: { + id: restaurant.id, + name: restaurant.name, + city: restaurant.city, + description: restaurant.description, + rating: restaurant.rating, + thumbnail: restaurant.thumbnail, + priceRange: restaurant.priceRange + }, + toppings + }, + _meta: { + "openai/widgetAccessible": false, + "openai/resultCanProduceWidget": false + } + }; + } + const widget = widgetsById.get(request.params.name); if (!widget) { @@ -209,18 +885,43 @@ function createPizzazServer(): Server { } const args = toolInputParser.parse(request.params.arguments ?? {}); + const rawTopping = args.pizzaTopping?.trim() ?? ""; + const matchedTopping = findTopping(rawTopping); + const hasRecognizedTopping = Boolean(matchedTopping); + + const parameterValues = hasRecognizedTopping && widget.templateParameters?.length + ? { + pizzaTopping: matchedTopping as string + } + : undefined; + + const resolvedOutputTemplate = hasRecognizedTopping + ? fillUriTemplate(widget.outputTemplate, parameterValues ?? {}) + : widget.templateUri; + + const metaOutputTemplate = hasRecognizedTopping ? widget.outputTemplate : widget.templateUri; return { content: [ { type: "text", - text: widget.responseText + text: hasRecognizedTopping + ? `${widget.responseText} Filtered by “${matchedTopping}”.` + : `${widget.responseText} Showing all pizzas.` } ], structuredContent: { - pizzaTopping: args.pizzaTopping + pizzaTopping: hasRecognizedTopping ? matchedTopping : null, + availableToppings: allToppings, + filterApplied: hasRecognizedTopping, + requestedTopping: rawTopping || null }, - _meta: widgetMeta(widget) + _meta: widgetMeta(widget, { + includeParameterSchema: true, + parameterValues, + resolvedUri: resolvedOutputTemplate, + outputTemplate: metaOutputTemplate + }) }; }); @@ -296,7 +997,7 @@ async function handlePostMessage( } } -const portEnv = Number(process.env.PORT ?? 8000); +const portEnv = Number(PORT ?? 8000); const port = Number.isFinite(portEnv) ? portEnv : 8000; const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { diff --git a/pizzaz_server_python/.env.example b/pizzaz_server_python/.env.example new file mode 100644 index 0000000..f458c2d --- /dev/null +++ b/pizzaz_server_python/.env.example @@ -0,0 +1,5 @@ +## Pizzaz MCP (Python) environment variables +# Note: All variables are optional. With nothing set, the servers default to CDN. +# ENVIRONMENT=local # Optional: 'local' or 'production' (default) +# DOMAIN=http://localhost:4444 # Override dev/serve origin (leave unset for CDN) +# PORT=8000 # Optional: change server port (default 8000) diff --git a/pizzaz_server_python/README.md b/pizzaz_server_python/README.md index 45f8d27..e7b634d 100644 --- a/pizzaz_server_python/README.md +++ b/pizzaz_server_python/README.md @@ -1,6 +1,6 @@ -# Pizzaz MCP server (Python) +# Pizzaz MCP Server (Python) -This directory packages a Python implementation of the Pizzaz demo server using the `FastMCP` helper from the official Model Context Protocol SDK. It mirrors the Node example and exposes each pizza widget as both a resource and a tool. +This directory packages a Python implementation of the Pizzaz demo server using the `FastMCP` helper from the official Model Context Protocol SDK. It mirrors the Node example and exposes each pizza widget as both a resource and a tool while sharing configuration through a local `.env` file and falling back to the published CDN bundles when needed. ## Prerequisites @@ -10,6 +10,12 @@ This directory packages a Python implementation of the Pizzaz demo server using ## Installation ```bash +# Windows +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt + +# Unix/Mac python -m venv .venv source .venv/bin/activate pip install -r requirements.txt @@ -22,7 +28,7 @@ pip install -r requirements.txt > other project, run `pip uninstall modelcontextprotocol` before reinstalling > the requirements. -## Run the server +## Run the Server ```bash python main.py @@ -33,7 +39,34 @@ This boots a FastAPI app with uvicorn on `http://127.0.0.1:8000` (equivalently ` - `GET /mcp` exposes the SSE stream. - `POST /mcp/messages?sessionId=...` accepts follow-up messages for an active session. -Cross-origin requests are allowed so you can drive the server from local tooling or the MCP Inspector. Each tool returns structured content that echoes the requested topping plus metadata that points to the correct Skybridge widget shell, matching the original Pizzaz documentation. +Cross-origin requests are allowed so you can drive the server from local tooling or the MCP Inspector. The process loads configuration from `.env` in this directory. Update it to control asset origin and port selection, for example: + +```env +# Use the Vite dev server started in the repo root with `pnpm run dev` +ENVIRONMENT=local + +# After `pnpm run build && pnpm run serve`, point to the static bundles +# ENVIRONMENT=production +# DOMAIN=http://localhost:4444 + +# Change the default port (defaults to 8000) +# PORT=8123 +``` + +- When `ENVIRONMENT=local`, widgets hydrate from the running Vite dev server without hashed filenames. +- When `ENVIRONMENT=production` alongside a `DOMAIN`, widgets load from your local static server. +- When `ENVIRONMENT` is omitted entirely, the server now defaults to the CDN assets (version `0038`) just like the Node implementation. +- Each tool response includes confirmation text, structured JSON echoing the requested topping, and `_meta.openai/outputTemplate` metadata for the Skybridge widget. + +Prefer a cross-platform launcher? After activating the environment you can run: + +```bash +pnpm start:pizzaz-python +``` + +## Hot-swap reminder + +Whenever you switch the server mode (dev/static/CDN), tweak `.env`, or rebuild assets, refresh your ChatGPT connector instead of deleting it: **Settings → Apps & Connectors → [your app] → Actions → Refresh app**. ChatGPT keeps the same MCP endpoint and reloads widget templates in place. The main [README](../README.md#hot-swap-modes-without-reconnecting) has a concise cheat sheet plus VM guidance. ## Next steps @@ -42,3 +75,8 @@ Use these handlers as a starting point when wiring in real data, authentication, 1. Register reusable UI resources that load static HTML bundles. 2. Associate tools with those widgets via `_meta.openai/outputTemplate`. 3. Ship structured JSON alongside human-readable confirmation text. + +See main [README.md](../README.md) for: +- Testing in ChatGPT +- Architecture overview +- Advanced configuration diff --git a/pizzaz_server_python/main.py b/pizzaz_server_python/main.py index 2b2a1f1..f7f2349 100644 --- a/pizzaz_server_python/main.py +++ b/pizzaz_server_python/main.py @@ -11,115 +11,637 @@ from copy import deepcopy from dataclasses import dataclass -from typing import Any, Dict, List +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Pattern, Tuple +import re + +import hashlib +from dotenv import load_dotenv +import json +import logging +import os +import time +from urllib.parse import quote, unquote import mcp.types as types from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, ConfigDict, Field, ValidationError +logger = logging.getLogger(__name__) + + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Load .env from this server directory if present, with OS env taking precedence +try: + load_dotenv(REPO_ROOT / "pizzaz_server_python" / ".env") +except Exception: + pass +ASSETS_DIR = REPO_ROOT / "assets" + +with (REPO_ROOT / "package.json").open("r", encoding="utf-8") as package_file: + _package_version = json.load(package_file)["version"] + +DEFAULT_ASSET_HASH = hashlib.sha256(_package_version.encode("utf-8")).hexdigest()[:4] + + +def _discover_asset_hash() -> str | None: + try: + candidates = sorted( + ASSETS_DIR.glob("*.js"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + except FileNotFoundError: + return None + except OSError as exc: # pragma: no cover + logger.warning("Failed to scan assets directory for hash: %s", exc) + return None + + pattern = re.compile(r"^[a-z0-9-]+-([0-9a-f]{4})\.js$") + for candidate in candidates: + match = pattern.match(candidate.name) + if match: + return match.group(1) + return None + + +def _get_env(key: str) -> str | None: + return os.environ.get(key) + + +# Environment variables - only these three are supported +ENVIRONMENT = (_get_env("ENVIRONMENT") or "").strip() +DOMAIN = (_get_env("DOMAIN") or "").strip() or None +PORT = (_get_env("PORT") or "").strip() or None + +# Internal constants +CDN_BASE = "https://persistent.oaistatic.com/ecosystem-built-assets" +CDN_VERSION = "0038" + +# Determine asset serving strategy based on ENVIRONMENT and DOMAIN +_environment = ENVIRONMENT.lower() +_is_env_local = _environment in {"local", "dev", "development"} + +if DOMAIN: + _dev_asset_origin = DOMAIN.rstrip("/") +elif _is_env_local: + _dev_asset_origin = "http://localhost:4444" +else: + _dev_asset_origin = None + +# When using the Vite dev server (`pnpm run dev`), assets are served without the hash suffix +_dev_asset_hashed = not _is_env_local + +asset_hash_override = (_get_env("ASSET_HASH") or "").strip().lower() +_asset_hash = asset_hash_override or (_discover_asset_hash() or DEFAULT_ASSET_HASH).lower() + +# In dev with un-hashed assets, derive a version tag from the process start minute +_is_dev_unhashed = bool(_dev_asset_origin) and (not _dev_asset_hashed) +_auto_dev_version = None +if _is_dev_unhashed: + # Auto-bump once per minute: dev- + _auto_dev_version = f"dev-{int(time.time() // 60):x}" + +_template_version = ( + _auto_dev_version + or _asset_hash +).lower() +_version_suffix = f"?v={_template_version}" if _template_version else "" + +# Default pizza video (public-domain fallback that does not expire). +DEFAULT_PIZZA_VIDEO_URL = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + +VIDEO_URL_SCRIPT = f"" + @dataclass(frozen=True) class PizzazWidget: identifier: str title: str template_uri: str + output_template: str invoking: str invoked: str - html: str + base_html: str response_text: str + template_parameters: List[Dict[str, str]] -widgets: List[PizzazWidget] = [ - PizzazWidget( - identifier="pizza-map", - title="Show Pizza Map", - template_uri="ui://widget/pizza-map.html", - invoking="Hand-tossing a map", - invoked="Served a fresh map", - html=( - "
\n" - "\n" - "" - ), - response_text="Rendered a pizza map!", - ), - 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=( - "\n" - "\n" - "" - ), - response_text="Rendered a pizza carousel!", - ), - PizzazWidget( - identifier="pizza-albums", - title="Show Pizza Album", - template_uri="ui://widget/pizza-albums.html", - invoking="Hand-tossing an album", - invoked="Served a fresh album", - html=( - "
\n" - "\n" - "" - ), - response_text="Rendered a pizza album!", - ), - PizzazWidget( - identifier="pizza-list", - title="Show Pizza List", - template_uri="ui://widget/pizza-list.html", - invoking="Hand-tossing a list", - invoked="Served a fresh list", - html=( - "
\n" - "\n" - "" - ), - response_text="Rendered a pizza list!", - ), - PizzazWidget( - identifier="pizza-video", - title="Show Pizza Video", - template_uri="ui://widget/pizza-video.html", - invoking="Hand-tossing a video", - invoked="Served a fresh video", - html=( - "
\n" - "\n" - "" - ), - response_text="Rendered a pizza video!", - ), -] +@dataclass(frozen=True) +class ResourceTemplateHandler: + widget: PizzazWidget + uri_template: str + pattern: Pattern[str] + parameter_names: List[str] + render: Callable[[Dict[str, str]], str] + + +def _inline_widget_markup(asset_name: str) -> str | None: + css_path = ASSETS_DIR / f"{asset_name}-{_asset_hash}.css" + js_path = ASSETS_DIR / f"{asset_name}-{_asset_hash}.js" + + try: + css = css_path.read_text(encoding="utf-8") + js = js_path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except OSError as exc: # pragma: no cover + logger.warning("Failed to load local assets for %s (%s)", asset_name, exc) + return None + + extra = VIDEO_URL_SCRIPT if asset_name == "pizzaz-video" else "" + + return ( + f'
\n' + f"\n" + f"\n" + f"{extra}" + ) + + +def _cdn_widget_markup(asset_name: str) -> str: + extra = VIDEO_URL_SCRIPT if asset_name == "pizzaz-video" else "" + + return ( + f'
\n' + f'\n' + f'\n' + f"{extra}" + ) + + +def _dev_hosted_widget_markup(asset_name: str) -> str | None: + if not _dev_asset_origin: + return None + + # Only serve from the dev origin if a corresponding entry exists under src/ + # This avoids emitting broken links for widgets that rely on CDN-only assets. + src_dir = REPO_ROOT / "src" / asset_name + if not src_dir.exists(): + return None + + hash_segment = f"-{_asset_hash}" if _dev_asset_hashed else "" + css_href = f"{_dev_asset_origin}/{asset_name}{hash_segment}.css" + js_src = f"{_dev_asset_origin}/{asset_name}{hash_segment}.js" + + extra = VIDEO_URL_SCRIPT if asset_name == "pizzaz-video" else "" + + return ( + f'
\n' + f'\n' + f'\n' + f"{extra}" + ) + + +def _build_widget_markup(asset_name: str) -> str: + dev_markup = _dev_hosted_widget_markup(asset_name) + if dev_markup is not None: + logger.info("Serving %s from dev asset origin %s", asset_name, _dev_asset_origin) + return dev_markup + + if not ENVIRONMENT: + logger.info( + "No ENVIRONMENT specified; falling back to CDN assets for %s", + asset_name, + ) + return _cdn_widget_markup(asset_name) + + inline = _inline_widget_markup(asset_name) + if inline is not None: + return inline + + logger.info( + "Using CDN assets for %s (no matching local assets for hash %s in %s)", + asset_name, + _asset_hash, + ASSETS_DIR, + ) + return _cdn_widget_markup(asset_name) + + +TEMPLATE_PARAM_GLOBAL = "__PIZZAZ_TEMPLATE_PARAMS__" + + +def append_template_params_script(base_html: str, params: Dict[str, str]) -> str: + if not params: + return base_html + + serialized = json.dumps(params) + # Escape the closing tag to avoid early termination when embedded inline + script = f"" + ) + + +def _solar_widget_html() -> str: + # Dev origin path (optionally hashed filenames) if configured + if _dev_asset_origin: + hash_segment = f"-{_asset_hash}" if _dev_asset_hashed else "" + css_href = f"{_dev_asset_origin}/solar-system{hash_segment}.css" + js_src = f"{_dev_asset_origin}/solar-system{hash_segment}.js" + return ( + '
\n' + f'\n' + f'' + ) + + if not ENVIRONMENT: + return ( + '
\n' + f'\n' + f'' + ) + + # Inline local hashed assets when available + inline = _inline_widget_markup() + if inline is not None: + return inline + + # CDN fallback + return ( + '
\n' + f'\n' + f'' + ) + + WIDGET = SolarWidget( identifier="solar-system", title="Explore the Solar System", - template_uri="ui://widget/solar-system.html", + template_uri=f"ui://widget/solar-system.html{_version_suffix}", invoking="Charting the solar system", invoked="Solar system ready", - html=( - "
\n" - "\n" - "" - ), + html=_solar_widget_html(), response_text="Solar system ready", ) @@ -112,21 +243,18 @@ def _tool_meta(widget: SolarWidget) -> Dict[str, Any]: "annotations": { "destructiveHint": False, "openWorldHint": False, - "readOnlyHint": True, + "readOnlyHint": True } } def _embedded_widget_resource(widget: SolarWidget) -> types.EmbeddedResource: - return types.EmbeddedResource( - type="resource", - resource=types.TextResourceContents( - uri=widget.template_uri, - mimeType=MIME_TYPE, - text=widget.html, - title=widget.title, - ), + text_contents = types.TextResourceContents( + uri=widget.template_uri, # type: ignore[arg-type] + mimeType=MIME_TYPE, + text=widget.html, ) + return types.EmbeddedResource(type="resource", resource=text_contents) def _normalize_planet(name: str) -> str | None: @@ -175,7 +303,7 @@ async def _list_resources() -> List[types.Resource]: types.Resource( name=WIDGET.title, title=WIDGET.title, - uri=WIDGET.template_uri, + uri=WIDGET.template_uri, # type: ignore[arg-type] description=_resource_description(WIDGET), mimeType=MIME_TYPE, _meta=_tool_meta(WIDGET), @@ -189,7 +317,7 @@ async def _list_resource_templates() -> List[types.ResourceTemplate]: types.ResourceTemplate( name=WIDGET.title, title=WIDGET.title, - uriTemplate=WIDGET.template_uri, + uriTemplate=WIDGET.template_uri, # type: ignore[arg-type] description=_resource_description(WIDGET), mimeType=MIME_TYPE, _meta=_tool_meta(WIDGET), @@ -208,16 +336,18 @@ async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerR ) ) - contents = [ + contents: List[types.TextResourceContents | types.BlobResourceContents] = [ types.TextResourceContents( - uri=WIDGET.template_uri, + uri=WIDGET.template_uri, # type: ignore[arg-type] mimeType=MIME_TYPE, text=WIDGET.html, _meta=_tool_meta(WIDGET), ) ] - return types.ServerResult(types.ReadResourceResult(contents=contents)) + return types.ServerResult( + types.ReadResourceResult(contents=contents) # type: ignore[arg-type,call-arg] + ) async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: @@ -234,7 +364,7 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: ) ], isError=True, - ) + ) # type: ignore[call-arg] ) planet = _normalize_planet(payload.planet_name) @@ -251,7 +381,7 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: ) ], isError=True, - ) + ) # type: ignore[call-arg] ) widget_resource = _embedded_widget_resource(WIDGET) @@ -282,7 +412,7 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: ], structuredContent=structured, _meta=meta, - ) + ) # type: ignore[call-arg] ) @@ -307,5 +437,8 @@ async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: if __name__ == "__main__": import uvicorn - - uvicorn.run("main:app", host="0.0.0.0", port=8000) + try: + _port = int(PORT or "8000") + except Exception: + _port = 8000 + uvicorn.run(app, host="0.0.0.0", port=_port) diff --git a/solar-system_server_python/requirements.txt b/solar-system_server_python/requirements.txt index 1f3183e..5ba673d 100644 --- a/solar-system_server_python/requirements.txt +++ b/solar-system_server_python/requirements.txt @@ -2,3 +2,4 @@ mcp[fastapi]>=0.1.0 fastapi>=0.115.0 uvicorn>=0.30.0 +python-dotenv>=1.0.1 diff --git a/src/pizzaz-carousel/PlaceCard.jsx b/src/pizzaz-carousel/PlaceCard.jsx index 6ad53dd..ccff03e 100644 --- a/src/pizzaz-carousel/PlaceCard.jsx +++ b/src/pizzaz-carousel/PlaceCard.jsx @@ -1,30 +1,42 @@ import React from "react"; -import { Star } from "lucide-react"; +import { MapPin, Star } from "lucide-react"; -export default function PlaceCard({ place }) { - if (!place) return null; +export default function PlaceCard({ item }) { + if (!item) return null; + const restaurant = item.restaurant ?? {}; return (
{place.name}
-
{place.name}
+
+ {restaurant.name} +
+
+
- {place.description ? ( -
- {place.description} + {item.description ? ( +
+ {item.description}
) : null} +
+ {item.name} +
+ {item.price ? ( +
{item.price}
+ ) : null}
-
- {places.slice(0, 7).map((place, i) => ( + {hasFilter && ( +
+
+ Showing pizzas with topping: + {requestedTopping} +
+
+ {noResults ? "No matches" : `${filteredItems.length} matches`} +
+
+ )} +
+ {visibleItems.map((item, i) => (
-
-
- {place.name} -
- {i + 1} +
+
+ {i + 1} +
+ {item.name} +
+
+ {item.name}
-
-
- {place.name} + {item.description ? ( +
+ {item.description}
-
-
- - - {place.rating?.toFixed - ? place.rating.toFixed(1) - : place.rating} - -
-
- {place.city || "–"} -
+ ) : null} + {item.price ? ( +
{item.price}
+ ) : null} +
+ {item.restaurant?.name || "–"} + + {item.restaurant?.city || ""} + +
+ + + {item.restaurant?.rating?.toFixed + ? item.restaurant.rating.toFixed(1) + : item.restaurant?.rating} +
-
- {place.city || "–"} +
+ {item.restaurant?.name || "–"} + {item.restaurant?.city || ""} +
+ + + {item.restaurant?.rating?.toFixed + ? item.restaurant.rating.toFixed(1) + : item.restaurant?.rating} + +
@@ -89,9 +218,9 @@ function App() {
))} - {places.length === 0 && ( + {noResults && (
- No pizzerias found. + No pizzerias found{hasFilter ? ` for “${requestedTopping}”.` : "."}
)}
diff --git a/src/pizzaz-video/index.css b/src/pizzaz-video/index.css new file mode 100644 index 0000000..2a0c38d --- /dev/null +++ b/src/pizzaz-video/index.css @@ -0,0 +1,3 @@ +@import "../index.css"; + +/* pizzaz-video specific styles can go here if needed */ diff --git a/src/pizzaz-video/index.jsx b/src/pizzaz-video/index.jsx new file mode 100644 index 0000000..5fefba2 --- /dev/null +++ b/src/pizzaz-video/index.jsx @@ -0,0 +1,46 @@ +import React, { useMemo } from "react"; +import { createRoot } from "react-dom/client"; +import { useMaxHeight } from "../use-max-height"; +import { useOpenAiGlobal } from "../use-openai-global"; + +const DEFAULT_VIDEO = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"; + +function VideoPlayer() { + const src = useMemo(() => { + const v = typeof window !== "undefined" ? window.__PIZZAZ_VIDEO_URL__ : undefined; + return typeof v === "string" && v.trim() ? v : DEFAULT_VIDEO; + }, []); + + const maxHeight = useMaxHeight() ?? undefined; + const displayMode = useOpenAiGlobal("displayMode"); + const containerHeight = typeof maxHeight === "number" && displayMode === "fullscreen" + ? Math.max(0, maxHeight - 40) // match spacing pattern used elsewhere + : 480; // sane default for inline mode + + return ( +
+
+ ); +} + +export default function App() { + return ; +} + +// Mount to the standard root expected by the dev server and widget HTML +const mountEl = document.getElementById("pizzaz-video-root"); +if (mountEl) { + createRoot(mountEl).render(); +} diff --git a/src/pizzaz/Inspector.jsx b/src/pizzaz/Inspector.jsx index 7666211..c93311e 100644 --- a/src/pizzaz/Inspector.jsx +++ b/src/pizzaz/Inspector.jsx @@ -35,7 +35,7 @@ export default function Inspector({ place, onClose }) {
diff --git a/src/pizzaz/Sidebar.jsx b/src/pizzaz/Sidebar.jsx index a34b4d4..aa72226 100644 --- a/src/pizzaz/Sidebar.jsx +++ b/src/pizzaz/Sidebar.jsx @@ -34,7 +34,7 @@ function PlaceListItem({ place, isSelected, onClick }) {
diff --git a/src/pizzaz/index.jsx b/src/pizzaz/index.jsx index ce51cd6..7e1d67e 100644 --- a/src/pizzaz/index.jsx +++ b/src/pizzaz/index.jsx @@ -9,6 +9,7 @@ import Sidebar from "./Sidebar"; import { useOpenAiGlobal } from "../use-openai-global"; import { useMaxHeight } from "../use-max-height"; import { Maximize2 } from "lucide-react"; +import { computePriceRange } from "../utils/price-range"; import { useNavigate, useLocation, @@ -38,7 +39,14 @@ export default function App() { const mapRef = useRef(null); const mapObj = useRef(null); const markerObjs = useRef([]); - const places = markers?.places || []; + const places = React.useMemo( + () => + (markers?.places || []).map((place) => ({ + ...place, + priceRange: computePriceRange(place.menu ?? []) + })), + [] + ); const markerCoords = places.map((p) => p.coords); const navigate = useNavigate(); const location = useLocation(); @@ -72,10 +80,16 @@ export default function App() { requestAnimationFrame(() => mapObj.current.resize()); // or keep it in sync with window resizes - window.addEventListener("resize", mapObj.current.resize); + const handleResize = () => { + if (mapObj.current) { + mapObj.current.resize(); + } + }; + + window.addEventListener("resize", handleResize); return () => { - window.removeEventListener("resize", mapObj.current.resize); + window.removeEventListener("resize", handleResize); mapObj.current.remove(); }; // eslint-disable-next-line @@ -242,7 +256,7 @@ export default function App() { >
{ + if (!item || typeof item.price !== "string") { + return null; + } + const match = item.price.match(/-?\d+(?:\.\d+)?/); + if (!match) { + return null; + } + const value = Number.parseFloat(match[0]); + return Number.isNaN(value) ? null : value; + }) + .filter((value) => value !== null); + + if (numericPrices.length === 0) { + return null; + } + + const min = Math.min(...numericPrices); + const max = Math.max(...numericPrices); + const format = (value) => + `$${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2)}`; + + if (Math.abs(min - max) < 0.01) { + return format(min); + } + + return `${format(min)}–${format(max)}`; +}