Skip to content

Commit f440a2d

Browse files
committed
feat: align template URI handling across MCP demos
- add environment-aware asset selection for Node and Python MCP servers - expose pizzaz video widget, shared scripts, and env examples
1 parent a272999 commit f440a2d

File tree

21 files changed

+1038
-218
lines changed

21 files changed

+1038
-218
lines changed

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,4 @@ yarn-error.log
3232
__pycache__/
3333
*.py[cod]
3434
*.egg-info/
35-
.venv/
36-
35+
.venv/

README.md

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
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.
66
It is meant to be used as a starting point and source of inspiration to build your own apps for ChatGPT.
77

8-
## MCP + Apps SDK overview
8+
## MCP + Apps SDK Overview
99

1010
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.
1111

@@ -19,7 +19,7 @@ Because the protocol is transport agnostic, you can host the server over Server-
1919

2020
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.
2121

22-
## Repository structure
22+
## Repository Structure
2323

2424
- `src/` – Source for each widget example.
2525
- `assets/` – Generated HTML, JS, and CSS bundles after running the build step.
@@ -52,14 +52,22 @@ The components are bundled into standalone assets that the MCP servers serve as
5252
pnpm run build
5353
```
5454

55-
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.
55+
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`).
5656

5757
To iterate locally, you can also launch the Vite dev server:
5858

5959
```bash
6060
pnpm run dev
6161
```
6262

63+
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`):
64+
65+
```bash
66+
pnpm run dev --host 0.0.0.0 --port 4000
67+
```
68+
69+
If you change the origin, update the MCP server `.env` (`DOMAIN=<new-origin>`) so widgets resolve correctly.
70+
6371
## Serve the static assets
6472

6573
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
6876
pnpm run serve
6977
```
7078

79+
This static server also defaults to port `4444`. Override it when needed:
80+
81+
```bash
82+
pnpm run serve -p 4000
83+
```
84+
85+
Make sure the MCP server `DOMAIN` matches the port you choose.
86+
7187
The assets are exposed at [`http://localhost:4444`](http://localhost:4444) with CORS enabled so that local tooling (including MCP inspectors) can fetch them.
7288

7389
## Run the MCP servers
@@ -79,31 +95,66 @@ The repository ships several demo MCP servers that highlight different widget bu
7995

8096
Every tool response includes plain text content, structured JSON, and `_meta.openai/outputTemplate` metadata so the Apps SDK can hydrate the matching widget.
8197

98+
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`:
99+
100+
```env
101+
# Development: consume Vite dev assets on http://localhost:5173
102+
ENVIRONMENT=local
103+
104+
# Production-style: point to the static asset server started with `pnpm run serve`
105+
# ENVIRONMENT=production
106+
# DOMAIN=http://localhost:4444
107+
108+
# Port override (defaults to 8000 when omitted)
109+
# PORT=8123
110+
```
111+
112+
- Use `ENVIRONMENT=local` while `pnpm run dev` is serving assets so widgets load without hash suffixes.
113+
- Switch to `ENVIRONMENT=production` and set `DOMAIN` after running `pnpm run build` and `pnpm run serve` to reference the static bundles.
114+
- Adjust `PORT` if you need the MCP endpoint on something other than `http://localhost:8000/mcp`.
115+
82116
### Pizzaz Node server
83117

84118
```bash
85119
cd pizzaz_server_node
120+
pnpm install
86121
pnpm start
87122
```
88123

89124
### Pizzaz Python server
90125

91126
```bash
127+
cd pizzaz_server_python
92128
python -m venv .venv
129+
# Windows PowerShell
130+
.\.venv\Scripts\activate
131+
# macOS/Linux
93132
source .venv/bin/activate
94-
pip install -r pizzaz_server_python/requirements.txt
95-
uvicorn pizzaz_server_python.main:app --port 8000
133+
pip install -r requirements.txt
134+
python main.py
96135
```
97136

137+
Prefer invoking uvicorn directly? From the repository root you can run `uvicorn pizzaz_server_python.main:app --port 8000` once dependencies are installed.
138+
139+
> Prefer pnpm scripts? After activating the virtual environment, return to the repository root (for example `cd ..`) and run `pnpm start:pizzaz-python`.
140+
98141
### Solar system Python server
99142

100143
```bash
144+
cd solar-system_server_python
101145
python -m venv .venv
146+
# Windows PowerShell
147+
.\.venv\Scripts\activate
148+
# macOS/Linux
102149
source .venv/bin/activate
103-
pip install -r solar-system_server_python/requirements.txt
104-
uvicorn solar-system_server_python.main:app --port 8000
150+
pip install -r requirements.txt
151+
python main.py
105152
```
106153

154+
Prefer invoking uvicorn directly? From the repository root you can run `uvicorn solar-system_server_python.main:app --port 8000` once dependencies are installed.
155+
156+
> Similarly, once the virtual environment is active, head back to the repository root and run `pnpm start:solar-python` to use the wrapper script.
157+
107158
You can reuse the same virtual environment for all Python servers—install the dependencies once and run whichever entry point you need.
108159

109160
## Testing in ChatGPT
@@ -112,15 +163,35 @@ To add these apps to ChatGPT, enable [developer mode](https://platform.openai.co
112163

113164
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.
114165

115-
For example, once your mcp servers are running, you can run:
166+
For example, once your MCP servers are running, you can run:
116167

117168
```bash
118169
ngrok http 8000
119170
```
120171

121-
You will get a public URL that you can use to add your local server to ChatGPT in Settings > Connectors.
172+
Use the generated URL (for example `https://<custom_endpoint>.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.
173+
174+
### Hot-swap modes without reconnecting
175+
176+
You can swap between CDN, static builds, and the Vite dev server without reconfiguring ChatGPT:
177+
178+
1. Change the environment you care about (edit the relevant `.env`, run `pnpm run dev`, or rebuild assets and rerun the MCP server).
179+
2. In ChatGPT, open **Settings → Apps & Connectors →** select your connected app → **Actions → Refresh app**.
180+
3. Continue the conversation, no reconnects or page reloads are needed.
181+
182+
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.
183+
184+
| Mode | What you change | Typical `.env` |
185+
| --- | --- | --- |
186+
| CDN (easiest) | Nothing beyond the MCP server | (leave `PORT`, `ENVIRONMENT` & `DOMAIN` unset) |
187+
| Static serve (inline bundles) | `pnpm run build` (optionally `pnpm run serve` to inspect) | `ENVIRONMENT=production` / `PORT=8000` |
188+
| 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` |
189+
190+
#### Working inside virtual machines
191+
192+
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=<that URL>`), or expose the VM via an HTTPS-accessible IP and point `DOMAIN` there.
122193

123-
For example: `https://<custom_endpoint>.ngrok-free.app/mcp`
194+
Switch modes freely → **Actions → Refresh app** → keep building.
124195

125196
Once you add a connector, you can use it in ChatGPT conversations.
126197

build-all.mts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,11 @@ const outputs = fs
145145

146146
const renamed = [];
147147

148+
const buildSalt = process.env.BUILD_SALT ?? new Date().toISOString();
149+
148150
const h = crypto
149151
.createHash("sha256")
150-
.update(pkg.version, "utf8")
152+
.update(`${pkg.version}:${buildSalt}`, "utf8")
151153
.digest("hex")
152154
.slice(0, 4);
153155

@@ -172,25 +174,47 @@ for (const name of builtNames) {
172174
const cssPath = path.join(dir, `${name}-${h}.css`);
173175
const jsPath = path.join(dir, `${name}-${h}.js`);
174176

175-
const css = fs.existsSync(cssPath)
176-
? fs.readFileSync(cssPath, { encoding: "utf8" })
177-
: "";
178-
const js = fs.existsSync(jsPath)
179-
? fs.readFileSync(jsPath, { encoding: "utf8" })
180-
: "";
177+
const cssHref = fs.existsSync(cssPath)
178+
? `/${path.basename(cssPath)}?v=${h}`
179+
: undefined;
180+
const jsSrc = fs.existsSync(jsPath)
181+
? `/${path.basename(jsPath)}?v=${h}`
182+
: undefined;
181183

182-
const cssBlock = css ? `\n <style>\n${css}\n </style>\n` : "";
183-
const jsBlock = js ? `\n <script type="module">\n${js}\n </script>` : "";
184+
const extraScript = name === "pizzaz-video"
185+
? "\n <script>window.__PIZZAZ_VIDEO_URL__ = \"https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4\";<\\/script>"
186+
: "";
184187

185188
const html = [
186189
"<!doctype html>",
187190
"<html>",
188-
`<head>${cssBlock}</head>`,
191+
"<head>",
192+
cssHref ? ` <link rel=\"stylesheet\" href=\"${cssHref}\">` : "",
193+
"</head>",
189194
"<body>",
190-
` <div id="${name}-root"></div>${jsBlock}`,
195+
` <div id=\"${name}-root\"></div>`,
196+
jsSrc ? ` <script type=\"module\" src=\"${jsSrc}\"></script>` : "",
197+
extraScript,
191198
"</body>",
192199
"</html>",
193-
].join("\n");
200+
]
201+
.filter(Boolean)
202+
.join("\n");
203+
194204
fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
195205
console.log(`${htmlPath} (generated)`);
206+
207+
const stableHtmlPath = path.join(dir, `${name}.html`);
208+
fs.writeFileSync(stableHtmlPath, html, { encoding: "utf8" });
209+
console.log(`${stableHtmlPath} (generated)`);
210+
211+
const cleanUrlDir = path.join(dir, name);
212+
fs.mkdirSync(cleanUrlDir, { recursive: true });
213+
const cleanUrlIndexPath = path.join(cleanUrlDir, "index.html");
214+
const cleanHtml = html
215+
.replace(`href="${cssHref ?? ""}"`, cssHref ? `href="${cssHref}"` : "")
216+
.replace(`src="${jsSrc ?? ""}"`, jsSrc ? `src="${jsSrc}"` : "");
217+
218+
fs.writeFileSync(cleanUrlIndexPath, cleanHtml, { encoding: "utf8" });
219+
console.log(`${cleanUrlIndexPath} (generated)`);
196220
}

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
"main": "host/main.ts",
66
"scripts": {
77
"build": "tsx ./build-all.mts",
8-
"serve": "serve -s ./assets -p 4444 --cors",
8+
"serve": "serve -s ./assets --cors",
99
"dev": "vite --config vite.config.mts",
1010
"tsc": "tsc -b",
1111
"tsc:app": "tsc -p tsconfig.app.json",
1212
"tsc:node": "tsc -p tsconfig.node.json",
13-
"dev:host": "vite --config vite.host.config.mts"
13+
"dev:host": "vite --config vite.host.config.mts",
14+
"start:pizzaz-node": "pnpm -C pizzaz_server_node start",
15+
"start:pizzaz-python": "node ./scripts/run-python-server.mjs pizzaz_server_python/main.py",
16+
"start:solar-python": "node ./scripts/run-python-server.mjs solar-system_server_python/main.py"
1417
},
1518
"keywords": [],
1619
"author": "",

pizzaz_server_node/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## Pizzaz MCP (Node) environment variables
2+
# ENVIRONMENT=local # Optional: 'local' or 'production' (default)
3+
# DOMAIN=http://localhost:4444 # Override dev/serve origin (leave unset for CDN)
4+
# PORT=8000 # Optional: change server port (default 8000)

pizzaz_server_node/README.md

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Pizzaz MCP server (Node)
1+
# Pizzaz MCP Server (Node)
22

3-
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.
3+
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.
44

55
## Prerequisites
66

@@ -13,20 +13,54 @@ This directory contains a minimal Model Context Protocol (MCP) server implemente
1313
pnpm install
1414
```
1515

16-
If you prefer npm or yarn, adjust the command accordingly.
16+
Adjust the command if you prefer npm or yarn.
1717

1818
## Run the server
1919

2020
```bash
2121
pnpm start
2222
```
2323

24-
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.
24+
This launches an HTTP MCP server on `http://localhost:8000/mcp` with two endpoints:
2525

26-
Each tool responds with:
26+
- `GET /mcp` provides the SSE stream.
27+
- `POST /mcp/messages?sessionId=...` accepts follow-up messages for active sessions.
2728

28-
- `content`: a short text confirmation that mirrors the original Pizzaz examples.
29-
- `structuredContent`: a small JSON payload that echoes the topping argument, demonstrating how to ship data alongside widgets.
30-
- `_meta.openai/outputTemplate`: metadata that binds the response to the matching Skybridge widget shell.
29+
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:
3130

32-
Feel free to extend the handlers with real data sources, authentication, and persistence.
31+
```env
32+
# Use the Vite dev server started with `pnpm run dev`
33+
ENVIRONMENT=local
34+
35+
# After `pnpm run build && pnpm run serve`, point to the static bundles
36+
# ENVIRONMENT=production
37+
# DOMAIN=http://localhost:4444
38+
39+
# Change the default port (defaults to 8000)
40+
# PORT=8123
41+
```
42+
43+
Key behaviors:
44+
45+
- When `ENVIRONMENT=local`, widgets load from the Vite dev server (`pnpm run dev` from the repo root) without hashed filenames.
46+
- When `ENVIRONMENT=production` and `DOMAIN` is set, widgets are served from your local static server (typically `pnpm run serve`).
47+
- When `ENVIRONMENT` is omitted entirely—or neither local option provides assets—the server falls back to the CDN bundles (version `0038`).
48+
49+
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.
50+
- Each tool emits:
51+
- `content`: confirmation text matching the requested action.
52+
- `structuredContent`: JSON reflecting the requested topping.
53+
- `_meta.openai/outputTemplate`: metadata binding the response to the Skybridge widget.
54+
55+
### Hot-swap reminder
56+
57+
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.
58+
59+
## Next Steps
60+
61+
Extend these handlers with real data sources, authentication, or localization, and customize the widget configuration under `src/` to align with your application.
62+
63+
See main [README.md](../README.md) for:
64+
- Testing in ChatGPT
65+
- Architecture overview
66+
- Advanced configuration

pizzaz_server_node/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
},
1010
"dependencies": {
1111
"@modelcontextprotocol/sdk": "^0.5.0",
12+
"dotenv": "^16.4.5",
1213
"zod": "^3.23.8"
1314
},
1415
"devDependencies": {

0 commit comments

Comments
 (0)