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", )