diff --git a/README.md b/README.md
index cb01f13..8a68ee9 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ 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.
-To iterate locally, you can also launch the Vite dev server:
+To iterate on your components locally, you can also launch the Vite dev server:
```bash
pnpm run dev
@@ -130,12 +130,23 @@ 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.
- Create your own components and add them to the gallery: drop new entries into `src/` and they will be picked up automatically by the build script.
+### Deploy your MCP server
+
+You can use the cloud environment of your choice to deploy your MCP server.
+
+Include this in the environment variables:
+
+```
+BASE_URL=https://your-server.com
+```
+
+This will be used to generate the HTML for the widgets so that they can serve static assets from this hosted url.
+
## Contributing
You are welcome to open issues or submit PRs to improve this app, however, please note that we may not review all suggestions.
diff --git a/build-all.mts b/build-all.mts
index abd9832..044f2f1 100644
--- a/build-all.mts
+++ b/build-all.mts
@@ -21,7 +21,6 @@ const targets: string[] = [
"pizzaz-carousel",
"pizzaz-list",
"pizzaz-albums",
- "pizzaz-video",
];
const builtNames: string[] = [];
@@ -143,8 +142,6 @@ const outputs = fs
.map((f) => path.join("assets", f))
.filter((p) => fs.existsSync(p));
-const renamed = [];
-
const h = crypto
.createHash("sha256")
.update(pkg.version, "utf8")
@@ -159,38 +156,35 @@ for (const out of outputs) {
const newName = path.join(dir, `${base}-${h}${ext}`);
fs.renameSync(out, newName);
- renamed.push({ old: out, neu: newName });
console.log(`${out} -> ${newName}`);
}
console.groupEnd();
console.log("new hash: ", h);
+const defaultBaseUrl = "http://localhost:4444";
+const baseUrlCandidate = process.env.BASE_URL?.trim() ?? "";
+const baseUrlRaw = baseUrlCandidate.length > 0 ? baseUrlCandidate : defaultBaseUrl;
+const normalizedBaseUrl = baseUrlRaw.replace(/\/+$/, "") || defaultBaseUrl;
+console.log(`Using BASE_URL ${normalizedBaseUrl} for generated HTML`);
+
for (const name of builtNames) {
const dir = outDir;
- const htmlPath = path.join(dir, `${name}-${h}.html`);
- 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 cssBlock = css ? `\n \n` : "";
- const jsBlock = js ? `\n ` : "";
-
- const html = [
- "",
- "",
- `
${cssBlock}`,
- "",
- `
${jsBlock}`,
- "",
- "",
- ].join("\n");
- fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
- console.log(`${htmlPath} (generated)`);
+ const hashedHtmlPath = path.join(dir, `${name}-${h}.html`);
+ const liveHtmlPath = path.join(dir, `${name}.html`);
+ const html = `
+
+
+
+
+
+
+
+
+
+`;
+ fs.writeFileSync(hashedHtmlPath, html, { encoding: "utf8" });
+ fs.writeFileSync(liveHtmlPath, html, { encoding: "utf8" });
+ console.log(`${hashedHtmlPath} (generated live HTML)`);
+ console.log(`${liveHtmlPath} (generated live HTML)`);
}
diff --git a/pizzaz_server_node/src/server.ts b/pizzaz_server_node/src/server.ts
index cdef68f..fec0fd5 100644
--- a/pizzaz_server_node/src/server.ts
+++ b/pizzaz_server_node/src/server.ts
@@ -1,5 +1,11 @@
-import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
-import { URL } from "node:url";
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import { URL, fileURLToPath } from "node:url";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
@@ -16,7 +22,7 @@ import {
type ReadResourceRequest,
type Resource,
type ResourceTemplate,
- type Tool
+ type Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
@@ -30,13 +36,51 @@ type PizzazWidget = {
responseText: string;
};
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.resolve(__dirname, "..", "..");
+const ASSETS_DIR = path.resolve(ROOT_DIR, "assets");
+
+function readWidgetHtml(componentName: string): string {
+ if (!fs.existsSync(ASSETS_DIR)) {
+ throw new Error(
+ `Widget assets not found. Expected directory ${ASSETS_DIR}. Run "pnpm run build" before starting the server.`
+ );
+ }
+
+ const directPath = path.join(ASSETS_DIR, `${componentName}.html`);
+ let htmlContents: string | null = null;
+
+ if (fs.existsSync(directPath)) {
+ htmlContents = fs.readFileSync(directPath, "utf8");
+ } else {
+ const candidates = fs
+ .readdirSync(ASSETS_DIR)
+ .filter(
+ (file) => file.startsWith(`${componentName}-`) && file.endsWith(".html")
+ )
+ .sort();
+ const fallback = candidates[candidates.length - 1];
+ if (fallback) {
+ htmlContents = fs.readFileSync(path.join(ASSETS_DIR, fallback), "utf8");
+ }
+ }
+
+ if (!htmlContents) {
+ throw new Error(
+ `Widget HTML for "${componentName}" not found in ${ASSETS_DIR}. Run "pnpm run build" to generate the assets.`
+ );
+ }
+
+ return htmlContents;
+}
+
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
+ "openai/resultCanProduceWidget": true,
} as const;
}
@@ -47,12 +91,8 @@ const widgets: PizzazWidget[] = [
templateUri: "ui://widget/pizza-map.html",
invoking: "Hand-tossing a map",
invoked: "Served a fresh map",
- html: `
-
-
-
- `.trim(),
- responseText: "Rendered a pizza map!"
+ html: readWidgetHtml("pizzaz"),
+ responseText: "Rendered a pizza map!",
},
{
id: "pizza-carousel",
@@ -60,12 +100,8 @@ const widgets: PizzazWidget[] = [
templateUri: "ui://widget/pizza-carousel.html",
invoking: "Carousel some spots",
invoked: "Served a fresh carousel",
- html: `
-
-
-
- `.trim(),
- responseText: "Rendered a pizza carousel!"
+ html: readWidgetHtml("pizzaz-carousel"),
+ responseText: "Rendered a pizza carousel!",
},
{
id: "pizza-albums",
@@ -73,12 +109,8 @@ const widgets: PizzazWidget[] = [
templateUri: "ui://widget/pizza-albums.html",
invoking: "Hand-tossing an album",
invoked: "Served a fresh album",
- html: `
-
-
-
- `.trim(),
- responseText: "Rendered a pizza album!"
+ html: readWidgetHtml("pizzaz-albums"),
+ responseText: "Rendered a pizza album!",
},
{
id: "pizza-list",
@@ -86,26 +118,9 @@ const widgets: PizzazWidget[] = [
templateUri: "ui://widget/pizza-list.html",
invoking: "Hand-tossing a list",
invoked: "Served a fresh list",
- html: `
-
-
-
- `.trim(),
- responseText: "Rendered a pizza list!"
+ html: readWidgetHtml("pizzaz-list"),
+ responseText: "Rendered a pizza list!",
},
- {
- id: "pizza-video",
- title: "Show Pizza Video",
- templateUri: "ui://widget/pizza-video.html",
- invoking: "Hand-tossing a video",
- invoked: "Served a fresh video",
- html: `
-
-
-
- `.trim(),
- responseText: "Rendered a pizza video!"
- }
];
const widgetsById = new Map();
@@ -121,15 +136,15 @@ const toolInputSchema = {
properties: {
pizzaTopping: {
type: "string",
- description: "Topping to mention when rendering the widget."
- }
+ description: "Topping to mention when rendering the widget.",
+ },
},
required: ["pizzaTopping"],
- additionalProperties: false
+ additionalProperties: false,
} as const;
const toolInputParser = z.object({
- pizzaTopping: z.string()
+ pizzaTopping: z.string(),
});
const tools: Tool[] = widgets.map((widget) => ({
@@ -137,7 +152,7 @@ const tools: Tool[] = widgets.map((widget) => ({
description: widget.title,
inputSchema: toolInputSchema,
title: widget.title,
- _meta: widgetMeta(widget)
+ _meta: widgetMeta(widget),
}));
const resources: Resource[] = widgets.map((widget) => ({
@@ -145,7 +160,7 @@ const resources: Resource[] = widgets.map((widget) => ({
name: widget.title,
description: `${widget.title} widget markup`,
mimeType: "text/html+skybridge",
- _meta: widgetMeta(widget)
+ _meta: widgetMeta(widget),
}));
const resourceTemplates: ResourceTemplate[] = widgets.map((widget) => ({
@@ -153,76 +168,91 @@ const resourceTemplates: ResourceTemplate[] = widgets.map((widget) => ({
name: widget.title,
description: `${widget.title} widget markup`,
mimeType: "text/html+skybridge",
- _meta: widgetMeta(widget)
+ _meta: widgetMeta(widget),
}));
function createPizzazServer(): Server {
const server = new Server(
{
name: "pizzaz-node",
- version: "0.1.0"
+ version: "0.1.0",
},
{
capabilities: {
resources: {},
- tools: {}
- }
+ tools: {},
+ },
}
);
- server.setRequestHandler(ListResourcesRequestSchema, async (_request: ListResourcesRequest) => ({
- resources
- }));
+ server.setRequestHandler(
+ ListResourcesRequestSchema,
+ async (_request: ListResourcesRequest) => ({
+ resources,
+ })
+ );
- server.setRequestHandler(ReadResourceRequestSchema, async (request: ReadResourceRequest) => {
- const widget = widgetsByUri.get(request.params.uri);
+ 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) {
+ throw new Error(`Unknown resource: ${request.params.uri}`);
+ }
- return {
- contents: [
- {
- uri: widget.templateUri,
- mimeType: "text/html+skybridge",
- text: widget.html,
- _meta: widgetMeta(widget)
- }
- ]
- };
- });
-
- server.setRequestHandler(ListResourceTemplatesRequestSchema, async (_request: ListResourceTemplatesRequest) => ({
- resourceTemplates
- }));
-
- server.setRequestHandler(ListToolsRequestSchema, async (_request: ListToolsRequest) => ({
- tools
- }));
-
- server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
- const widget = widgetsById.get(request.params.name);
-
- if (!widget) {
- throw new Error(`Unknown tool: ${request.params.name}`);
+ return {
+ contents: [
+ {
+ uri: widget.templateUri,
+ mimeType: "text/html+skybridge",
+ text: widget.html,
+ _meta: widgetMeta(widget),
+ },
+ ],
+ };
}
+ );
- const args = toolInputParser.parse(request.params.arguments ?? {});
-
- return {
- content: [
- {
- type: "text",
- text: widget.responseText
- }
- ],
- structuredContent: {
- pizzaTopping: args.pizzaTopping
- },
- _meta: widgetMeta(widget)
- };
- });
+ server.setRequestHandler(
+ ListResourceTemplatesRequestSchema,
+ async (_request: ListResourceTemplatesRequest) => ({
+ resourceTemplates,
+ })
+ );
+
+ server.setRequestHandler(
+ ListToolsRequestSchema,
+ async (_request: ListToolsRequest) => ({
+ tools,
+ })
+ );
+
+ server.setRequestHandler(
+ CallToolRequestSchema,
+ async (request: CallToolRequest) => {
+ const widget = widgetsById.get(request.params.name);
+
+ if (!widget) {
+ throw new Error(`Unknown tool: ${request.params.name}`);
+ }
+
+ const args = toolInputParser.parse(request.params.arguments ?? {});
+
+ return {
+ content: [
+ {
+ type: "text",
+ text: widget.responseText,
+ },
+ ],
+ structuredContent: {
+ pizzaTopping: args.pizzaTopping,
+ },
+ _meta: widgetMeta(widget),
+ };
+ }
+ );
return server;
}
@@ -299,36 +329,41 @@ async function handlePostMessage(
const portEnv = Number(process.env.PORT ?? 8000);
const port = Number.isFinite(portEnv) ? portEnv : 8000;
-const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
- if (!req.url) {
- res.writeHead(400).end("Missing URL");
- return;
- }
+const httpServer = createServer(
+ async (req: IncomingMessage, res: ServerResponse) => {
+ if (!req.url) {
+ res.writeHead(400).end("Missing URL");
+ return;
+ }
- const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
+ const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
+
+ if (
+ req.method === "OPTIONS" &&
+ (url.pathname === ssePath || url.pathname === postPath)
+ ) {
+ res.writeHead(204, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "content-type",
+ });
+ res.end();
+ return;
+ }
- if (req.method === "OPTIONS" && (url.pathname === ssePath || url.pathname === postPath)) {
- res.writeHead(204, {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
- "Access-Control-Allow-Headers": "content-type"
- });
- res.end();
- return;
- }
+ if (req.method === "GET" && url.pathname === ssePath) {
+ await handleSseRequest(res);
+ return;
+ }
- if (req.method === "GET" && url.pathname === ssePath) {
- await handleSseRequest(res);
- return;
- }
+ if (req.method === "POST" && url.pathname === postPath) {
+ await handlePostMessage(req, res, url);
+ return;
+ }
- if (req.method === "POST" && url.pathname === postPath) {
- await handlePostMessage(req, res, url);
- return;
+ res.writeHead(404).end("Not Found");
}
-
- res.writeHead(404).end("Not Found");
-});
+);
httpServer.on("clientError", (err: Error, socket) => {
console.error("HTTP client error", err);
@@ -338,5 +373,7 @@ httpServer.on("clientError", (err: Error, socket) => {
httpServer.listen(port, () => {
console.log(`Pizzaz MCP server listening on http://localhost:${port}`);
console.log(` SSE stream: GET http://localhost:${port}${ssePath}`);
- console.log(` Message post endpoint: POST http://localhost:${port}${postPath}?sessionId=...`);
+ console.log(
+ ` Message post endpoint: POST http://localhost:${port}${postPath}?sessionId=...`
+ );
});
diff --git a/pizzaz_server_python/main.py b/pizzaz_server_python/main.py
index 2b2a1f1..70e4095 100644
--- a/pizzaz_server_python/main.py
+++ b/pizzaz_server_python/main.py
@@ -11,6 +11,8 @@
from copy import deepcopy
from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
from typing import Any, Dict, List
import mcp.types as types
@@ -29,6 +31,25 @@ class PizzazWidget:
response_text: str
+ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
+
+
+@lru_cache(maxsize=None)
+def _load_widget_html(component_name: str) -> str:
+ html_path = ASSETS_DIR / f"{component_name}.html"
+ if html_path.exists():
+ return html_path.read_text(encoding="utf8")
+
+ fallback_candidates = sorted(ASSETS_DIR.glob(f"{component_name}-*.html"))
+ if fallback_candidates:
+ return fallback_candidates[-1].read_text(encoding="utf8")
+
+ raise FileNotFoundError(
+ f'Widget HTML for "{component_name}" not found in {ASSETS_DIR}. '
+ "Run `pnpm run build` to generate the assets before starting the server."
+ )
+
+
widgets: List[PizzazWidget] = [
PizzazWidget(
identifier="pizza-map",
@@ -36,13 +57,7 @@ class PizzazWidget:
template_uri="ui://widget/pizza-map.html",
invoking="Hand-tossing a map",
invoked="Served a fresh map",
- html=(
- "
\n"
- " \n"
- ""
- ),
+ html=_load_widget_html("pizzaz"),
response_text="Rendered a pizza map!",
),
PizzazWidget(
@@ -51,13 +66,7 @@ class PizzazWidget:
template_uri="ui://widget/pizza-carousel.html",
invoking="Carousel some spots",
invoked="Served a fresh carousel",
- html=(
- "
\n"
- " \n"
- ""
- ),
+ html=_load_widget_html("pizzaz-carousel"),
response_text="Rendered a pizza carousel!",
),
PizzazWidget(
@@ -66,13 +75,7 @@ class PizzazWidget:
template_uri="ui://widget/pizza-albums.html",
invoking="Hand-tossing an album",
invoked="Served a fresh album",
- html=(
- "
\n"
- " \n"
- ""
- ),
+ html=_load_widget_html("pizzaz-albums"),
response_text="Rendered a pizza album!",
),
PizzazWidget(
@@ -81,30 +84,9 @@ class PizzazWidget:
template_uri="ui://widget/pizza-list.html",
invoking="Hand-tossing a list",
invoked="Served a fresh list",
- html=(
- "
\n"
- " \n"
- ""
- ),
+ html=_load_widget_html("pizzaz-list"),
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!",
- ),
+ )
]
diff --git a/solar-system_server_python/main.py b/solar-system_server_python/main.py
index d03917a..d7b3cad 100644
--- a/solar-system_server_python/main.py
+++ b/solar-system_server_python/main.py
@@ -3,6 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
from typing import Any, Dict, List
import mcp.types as types
@@ -56,19 +58,32 @@ class SolarWidget:
response_text: str
+ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
+
+
+@lru_cache(maxsize=None)
+def _load_widget_html(component_name: str) -> str:
+ html_path = ASSETS_DIR / f"{component_name}.html"
+ if html_path.exists():
+ return html_path.read_text(encoding="utf8")
+
+ fallback_candidates = sorted(ASSETS_DIR.glob(f"{component_name}-*.html"))
+ if fallback_candidates:
+ return fallback_candidates[-1].read_text(encoding="utf8")
+
+ raise FileNotFoundError(
+ f'Widget HTML for "{component_name}" not found in {ASSETS_DIR}. '
+ "Run `pnpm run build` to generate the assets before starting the server."
+ )
+
+
WIDGET = SolarWidget(
identifier="solar-system",
title="Explore the Solar System",
template_uri="ui://widget/solar-system.html",
invoking="Charting the solar system",
invoked="Solar system ready",
- html=(
- "
\n"
- " \n"
- ""
- ),
+ html=_load_widget_html("solar-system"),
response_text="Solar system ready",
)