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..9863efe 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 + +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. -For example: `https://.ngrok-free.app/mcp` +Switch modes freely → **Actions → Refresh app** → keep building. Once you add a connector, you can use it in ChatGPT conversations. 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..7278582 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,6 +23,80 @@ import { type Tool } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import pkg from "../../package.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(); } -const widgets: PizzazWidget[] = [ +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 widgetConfigs: WidgetConfig[] = [ { id: "pizza-map", title: "Show Pizza Map", - templateUri: "ui://widget/pizza-map.html", + templateUriBase: "ui://widget/pizza-map.html", invoking: "Hand-tossing a map", invoked: "Served a fresh map", - html: ` -
- - - `.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" }, { 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 widgets: PizzazWidget[] = widgetConfigs.map(({ assetName, templateUriBase, ...rest }) => ({ + ...rest, + templateUri: `${templateUriBase}${versionSuffix}`, + html: buildWidgetHtml(assetName) +})); + const widgetsById = new Map(); const widgetsByUri = new Map(); @@ -116,6 +264,16 @@ widgets.forEach((widget) => { widgetsByUri.set(widget.templateUri, widget); }); +function widgetMeta(widget: PizzazWidget) { + return { + "openai/outputTemplate": widget.templateUri, + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetAccessible": true, + "openai/resultCanProduceWidget": true + } as const; +} + const toolInputSchema = { type: "object", properties: { @@ -296,7 +454,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..789cd3a 100644 --- a/pizzaz_server_python/main.py +++ b/pizzaz_server_python/main.py @@ -11,12 +11,108 @@ from copy import deepcopy from dataclasses import dataclass +from pathlib import Path from typing import Any, Dict, List +import re + +import hashlib +from dotenv import load_dotenv +import json +import logging +import os +import time 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: @@ -29,82 +125,150 @@ class PizzazWidget: response_text: 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) + + +_WIDGET_CONFIGS: List[Dict[str, str]] = [ + { + "identifier": "pizza-map", + "title": "Show Pizza Map", + "template_uri_base": "ui://widget/pizza-map.html", + "invoking": "Hand-tossing a map", + "invoked": "Served a fresh map", + "response_text": "Rendered a pizza map!", + "asset_name": "pizzaz", + }, + { + "identifier": "pizza-carousel", + "title": "Show Pizza Carousel", + "template_uri_base": "ui://widget/pizza-carousel.html", + "invoking": "Carousel some spots", + "invoked": "Served a fresh carousel", + "response_text": "Rendered a pizza carousel!", + "asset_name": "pizzaz-carousel", + }, + { + "identifier": "pizza-albums", + "title": "Show Pizza Album", + "template_uri_base": "ui://widget/pizza-albums.html", + "invoking": "Hand-tossing an album", + "invoked": "Served a fresh album", + "response_text": "Rendered a pizza album!", + "asset_name": "pizzaz-albums", + }, + { + "identifier": "pizza-list", + "title": "Show Pizza List", + "template_uri_base": "ui://widget/pizza-list.html", + "invoking": "Hand-tossing a list", + "invoked": "Served a fresh list", + "response_text": "Rendered a pizza list!", + "asset_name": "pizzaz-list", + }, + { + "identifier": "pizza-video", + "title": "Show Pizza Video", + "template_uri_base": "ui://widget/pizza-video.html", + "invoking": "Hand-tossing a video", + "invoked": "Served a fresh video", + "response_text": "Rendered a pizza video!", + "asset_name": "pizzaz-video", + }, +] + + 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!", - ), + identifier=config["identifier"], + title=config["title"], + template_uri=f"{config['template_uri_base']}{_version_suffix}", + invoking=config["invoking"], + invoked=config["invoked"], + html=_build_widget_markup(config["asset_name"]), + response_text=config["response_text"], + ) + for config in _WIDGET_CONFIGS ] @@ -160,20 +324,22 @@ def _tool_meta(widget: PizzazWidget) -> Dict[str, Any]: "annotations": { "destructiveHint": False, "openWorldHint": False, - "readOnlyHint": True, + "readOnlyHint": True } } def _embedded_widget_resource(widget: PizzazWidget) -> types.EmbeddedResource: + # Some typed clients expect AnyUrl; cast string to the expected type at runtime + text_contents = types.TextResourceContents( + uri=widget.template_uri, # type: ignore[arg-type] + mimeType=MIME_TYPE, + text=widget.html, + ) + # EmbeddedResource in latest FastMCP generally takes (type, resource) return types.EmbeddedResource( type="resource", - resource=types.TextResourceContents( - uri=widget.template_uri, - mimeType=MIME_TYPE, - text=widget.html, - title=widget.title, - ), + resource=text_contents, ) @@ -196,8 +362,7 @@ async def _list_resources() -> List[types.Resource]: return [ 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), @@ -211,8 +376,7 @@ async def _list_resource_templates() -> List[types.ResourceTemplate]: return [ 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), @@ -231,16 +395,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] + ) async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: @@ -321,5 +487,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/pizzaz_server_python/requirements.txt b/pizzaz_server_python/requirements.txt index 6eb7e58..912fe42 100644 --- a/pizzaz_server_python/requirements.txt +++ b/pizzaz_server_python/requirements.txt @@ -1,3 +1,4 @@ mcp[fastapi]>=0.1.0 fastapi>=0.115.0 uvicorn>=0.30.0 +python-dotenv>=1.0.1 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba08cb1..5004564 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^0.5.0 version: 0.5.0 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 zod: specifier: ^3.23.8 version: 3.25.76 @@ -1392,6 +1395,10 @@ packages: dompurify@3.2.6: resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} @@ -3692,6 +3699,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} + draco3d@1.5.7: {} earcut@3.0.2: {} diff --git a/scripts/run-python-server.mjs b/scripts/run-python-server.mjs new file mode 100644 index 0000000..606c580 --- /dev/null +++ b/scripts/run-python-server.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { existsSync } from "node:fs"; +import process from "node:process"; + +const [, , scriptPath, ...scriptArgs] = process.argv; + +if (!scriptPath) { + console.error("Usage: node scripts/run-python-server.mjs [args...]"); + process.exit(1); +} + +const resolvedScript = resolve(process.cwd(), scriptPath); + +if (!existsSync(resolvedScript)) { + console.error(`Cannot find Python entry point at ${resolvedScript}`); + process.exit(1); +} + +const envPreferred = process.env.PYTHON || process.env.PYTHON_CMD; + +function tokenizeCommand(value) { + if (!value) { + return []; + } + const matches = value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g); + if (!matches) { + return []; + } + + return matches + .map((token) => { + const trimmed = token.trim(); + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; + }) + .filter((token) => token.length > 0); +} + +const candidates = []; + +if (envPreferred) { + const parsed = tokenizeCommand(envPreferred); + if (parsed.length > 0) { + candidates.push(parsed); + } +} + +candidates.push( + ["python3", "-u"], + ["python", "-u"], + ["py", "-3", "-u"], + ["py", "-u"], +); + +const errors = []; + +for (const candidate of candidates) { + if (!candidate || candidate.length === 0) { + continue; + } + const [cmd, ...baseArgs] = candidate; + const result = spawnSync(cmd, [...baseArgs, resolvedScript, ...scriptArgs], { + stdio: "inherit", + env: process.env, + }); + + if (result.status === 0) { + process.exit(0); + } + + const reason = result.error?.message || `exit code ${result.status}`; + errors.push(`${cmd} ${baseArgs.join(" ")}`.trim() + ` (${reason})`); +} + +console.error("Unable to start Python interpreter. Tried:\n" + errors.map((e) => ` - ${e}`).join("\n")); +console.error("Set PYTHON or PYTHON_CMD env var to point at your interpreter if needed."); +process.exit(1); diff --git a/solar-system_server_python/.env.example b/solar-system_server_python/.env.example new file mode 100644 index 0000000..42be6ef --- /dev/null +++ b/solar-system_server_python/.env.example @@ -0,0 +1,5 @@ +## Solar System MCP (Python) environment variables +# Note: All variables are optional. With nothing set, the server defaults to CDN and sensible defaults. +# 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/solar-system_server_python/README.md b/solar-system_server_python/README.md index 60e90b1..94f4167 100644 --- a/solar-system_server_python/README.md +++ b/solar-system_server_python/README.md @@ -1,9 +1,6 @@ # Solar system MCP server (Python) -This directory packages a Python implementation of the solar-system demo server -using the official Model Context Protocol FastMCP helper. It mirrors the widget -experience shipped in this repository and lets you drive the 3D solar system UI -from ChatGPT or the MCP Inspector. +This directory packages a Python implementation of the solar-system demo server using the official Model Context Protocol FastMCP helper. It mirrors the widget experience shipped in this repository and lets you drive the 3D solar system UI from ChatGPT or the MCP Inspector. It shares configuration through a local `.env` file while falling back to the published CDN bundles whenever local assets are unavailable. ## Prerequisites @@ -13,14 +10,18 @@ from ChatGPT or the MCP Inspector. ## 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 ``` -> The requirements pin the official `mcp` distribution with its FastAPI extra. If -you previously installed the unrelated `modelcontextprotocol` package, uninstall -it first to avoid import conflicts. +> The requirements pin the official `mcp` distribution with its FastAPI extra. If you previously installed the unrelated `modelcontextprotocol` package, uninstall it first to avoid import conflicts. ## Run the server @@ -28,19 +29,47 @@ it first to avoid import conflicts. python main.py ``` -This boots a FastAPI app with uvicorn on `http://127.0.0.1:8000` (equivalently -`uvicorn solar-system_server_python.main:app --port 8000`). The server exposes -streaming endpoints compatible with the MCP Inspector and ChatGPT connectors: +This boots a FastAPI app with uvicorn on `http://127.0.0.1:8000` (equivalently `uvicorn solar-system_server_python.main:app --port 8000`). The server exposes streaming endpoints compatible with the MCP Inspector and ChatGPT connectors: - `GET /mcp` provides the SSE stream. - `POST /mcp/messages?sessionId=...` receives follow-up messages for a session. -Each tool call returns a small JSON payload describing the requested planet plus -metadata that embeds the solar-system widget, so the Apps SDK can render the 3D -experience inline. +Configuration lives in `.env` in this directory. Update it before launching to control asset origin and port selection: + +```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 +``` + +- When `ENVIRONMENT=local`, the widget hydrates from the Vite dev server without hashed filenames. +- When `ENVIRONMENT=production` with a `DOMAIN`, assets are served from your local static server. +- When `ENVIRONMENT` is omitted entirely—or local assets are missing—the server defaults to the CDN bundles (version `0038`). +- Each tool call returns a JSON payload describing the requested planet plus metadata that embeds the solar-system widget so the Apps SDK can render the 3D experience inline. + +Prefer not to type the Python entry point directly? After activating the environment you can run: + +```bash +pnpm start:solar-python +``` + +## Hot-swap reminder + +When you change `.env`, rebuild assets, or toggle between dev/static/CDN, don't delete the connector—just reopen it in ChatGPT (**Settings → Apps & Connectors → [your app] → Actions → Refresh app**). ChatGPT keeps the existing MCP base URL and fetches the newest widget templates right away. The repo [README](../README.md#hot-swap-modes-without-reconnecting) includes the full mode cheat sheet and VM considerations. ## Next steps - Expand the schema with additional celestial bodies or mission telemetry. - Source live ephemeris data to position planets in real time. - Gate access with authentication before exposing the widget in production. + +See main [README.md](../README.md) for: +- Testing in ChatGPT +- Architecture overview +- Advanced configuration diff --git a/solar-system_server_python/main.py b/solar-system_server_python/main.py index d03917a..4af790b 100644 --- a/solar-system_server_python/main.py +++ b/solar-system_server_python/main.py @@ -3,13 +3,100 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path +import os +import json +import time +from dotenv import load_dotenv from typing import Any, Dict, List +import re import mcp.types as types from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, ConfigDict, Field, ValidationError MIME_TYPE = "text/html+skybridge" + +# Repo root for locating package.json if needed, and .env file +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Load .env from this server directory if present; OS env takes precedence +try: + load_dotenv(REPO_ROOT / "solar-system_server_python" / ".env") +except Exception: + pass + +# Asset configuration mirrors the Pizzaz servers +CDN_BASE = "https://persistent.oaistatic.com/ecosystem-built-assets" +CDN_VERSION = "0038" + + +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 + +# Compute a default 4-char asset hash from package version +ASSETS_DIR = REPO_ROOT / "assets" +try: + with (REPO_ROOT / "package.json").open("r", encoding="utf-8") as _pkg: + _version = json.load(_pkg)["version"] +except Exception: + _version = "0.0.0" +import hashlib +_default_asset_hash = hashlib.sha256(_version.encode("utf-8")).hexdigest()[:4] + +# 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 + +def _discover_asset_hash() -> str | None: + try: + candidates = sorted( + ASSETS_DIR.glob("solar-system-*.js"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + except FileNotFoundError: + return None + except OSError: + return None + + pattern = re.compile(r"^solar-system-([0-9a-f]{4})\.js$") + for candidate in candidates: + match = pattern.match(candidate.name) + if match: + return match.group(1) + return None + + +_asset_hash = ( + (_get_env("ASSET_HASH") or "").strip().lower() + or (_discover_asset_hash() or _default_asset_hash).lower() +) + +# Derive a version tag from the process start minute when serving un-hashed dev assets +_is_dev_unhashed = bool(_dev_asset_origin) and (not _dev_asset_hashed) +_auto_dev_version = None +if _is_dev_unhashed: + _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 "" PLANETS = [ "Mercury", "Venus", @@ -56,19 +143,63 @@ class SolarWidget: response_text: str +def _inline_widget_markup() -> str | None: + css_path = ASSETS_DIR / f"solar-system-{_asset_hash}.css" + js_path = ASSETS_DIR / f"solar-system-{_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: + return None + + return ( + '
\n' + f"\n" + 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-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/index.jsx b/src/pizzaz/index.jsx index ce51cd6..1278f22 100644 --- a/src/pizzaz/index.jsx +++ b/src/pizzaz/index.jsx @@ -72,10 +72,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 +248,7 @@ export default function App() { >