import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const repoRoot = join(__dirname, ".."); const localDataPath = join(__dirname, "data", "products.json"); const repoDataPath = join(repoRoot, "data", "products.json"); const localRenderPreviewPath = join(__dirname, "data", "render-preview.json"); const repoRenderPreviewPath = join(repoRoot, "data", "render-preview.json"); const widgetHtml = readFileSync(join(__dirname, "public", "product-picker.html"), "utf8"); const productsCatalog = JSON.parse(readFileSync(fileExists(localDataPath) ? localDataPath : repoDataPath, "utf8")); const renderPreview = JSON.parse(readFileSync(fileExists(localRenderPreviewPath) ? localRenderPreviewPath : repoRenderPreviewPath, "utf8")); const WIDGET_URI = "ui://widget/product-picker.html"; function fileExists(path) { try { readFileSync(path); return true; } catch { return false; } } const productOutputSchema = { recommendation: z.object({ product_id: z.string(), name: z.string(), price_usd: z.number(), setup_time_days: z.number(), best_for: z.string(), reasons: z.array(z.string()), avoid_if: z.string(), badge: z.string(), emoji: z.string(), accent_color: z.string(), cta: z.string(), }), products: z.array( z.object({ id: z.string(), name: z.string(), price_usd: z.number(), setup_time_days: z.number(), best_for: z.string(), agent_score: z.number(), badge: z.string(), emoji: z.string(), accent_color: z.string(), cta: z.string(), avoid_if: z.string(), }) ), quick_replies: z.array( z.object({ label: z.string(), value: z.string(), action: z.string(), }) ), theme: z.object({ selected: z.string(), options: z.array( z.object({ name: z.string(), description: z.string(), }) ), }), citations: z.array( z.object({ label: z.string(), url: z.string(), }) ), }; const recommendProductInputSchema = { launch_timeline: z .enum(["this_weekend", "this_week", "this_month", "flexible"]) .default("this_weekend"), budget_usd: z.number().optional(), site_type: z .enum(["landing_page", "shop", "service_business", "unknown"]) .default("unknown"), theme: z.enum(["Daylight", "Aurora", "Midnight"]).default("Aurora"), }; const selectProductInputSchema = { product_id: z.string().min(1), theme: z.enum(["Daylight", "Aurora", "Midnight"]).default("Aurora"), }; function flattenProduct(product) { return { id: product.id, name: product.name, price_usd: product.price_usd, setup_time_days: product.setup_time_days, best_for: product.best_for, agent_score: product.agent_score, badge: product.display?.badge ?? "Agent-ready", emoji: product.display?.emoji ?? "✨", accent_color: product.display?.accent_color ?? "#3158ff", cta: product.display?.cta ?? "Choose this", avoid_if: product.avoid_if, }; } function rankProducts({ launch_timeline, budget_usd, site_type }) { const products = [...productsCatalog.products]; return products.sort((a, b) => { const aBudget = budget_usd == null || a.price_usd <= budget_usd ? 0 : 1; const bBudget = budget_usd == null || b.price_usd <= budget_usd ? 0 : 1; if (aBudget !== bBudget) return aBudget - bBudget; const aFast = launch_timeline === "this_weekend" && a.setup_time_days <= 1 ? 1 : 0; const bFast = launch_timeline === "this_weekend" && b.setup_time_days <= 1 ? 1 : 0; if (aFast !== bFast) return bFast - aFast; const aFit = productFitsSiteType(a, site_type) ? 1 : 0; const bFit = productFitsSiteType(b, site_type) ? 1 : 0; if (aFit !== bFit) return bFit - aFit; if (a.agent_score !== b.agent_score) return b.agent_score - a.agent_score; if (a.setup_time_days !== b.setup_time_days) return a.setup_time_days - b.setup_time_days; return a.price_usd - b.price_usd; }); } function productFitsSiteType(product, siteType) { if (siteType === "landing_page") return product.id === "launch-kit"; if (siteType === "shop") return product.id === "signal-shop"; if (siteType === "service_business") return product.id === "studio-os"; return false; } function buildPayload({ selectedProduct, theme }) { const flattenedProducts = productsCatalog.products.map(flattenProduct); const recommendation = { product_id: selectedProduct.id, name: selectedProduct.name, price_usd: selectedProduct.price_usd, setup_time_days: selectedProduct.setup_time_days, best_for: selectedProduct.best_for, reasons: selectedProduct.recommendation_reasons, avoid_if: selectedProduct.avoid_if, badge: selectedProduct.display?.badge ?? "Agent-ready", emoji: selectedProduct.display?.emoji ?? "✨", accent_color: selectedProduct.display?.accent_color ?? "#3158ff", cta: selectedProduct.display?.cta ?? "Choose this", }; return { recommendation, products: flattenedProducts, quick_replies: renderPreview.components.find((component) => component.id === "quick-replies")?.choices ?? [], theme: { selected: theme, options: renderPreview.components.find((component) => component.id === "theme-selector")?.themes ?? [], }, citations: [ { label: "Product facts", url: "https://youssefelshaarawy.github.io/AgentFriendlyDemoWebsite/data/products.json" }, { label: "Render preview", url: "https://youssefelshaarawy.github.io/AgentFriendlyDemoWebsite/data/render-preview.json" }, { label: "Display contract", url: "https://youssefelshaarawy.github.io/AgentFriendlyDemoWebsite/agent.json" }, ], }; } function replyWithProductPicker(payload, message) { return { content: [{ type: "text", text: message }], structuredContent: payload, }; } function createProductPickerServer() { const server = new McpServer({ name: "agent-friendly-product-picker", version: "0.1.0", }); registerAppResource(server, "product-picker-widget", WIDGET_URI, {}, async () => ({ contents: [ { uri: WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml, }, ], })); registerAppTool( server, "recommend_product", { title: "Recommend product", description: "Recommends the best website package and renders product cards inside ChatGPT.", inputSchema: recommendProductInputSchema, outputSchema: productOutputSchema, _meta: { ui: { resourceUri: WIDGET_URI }, }, }, async (args) => { const ranked = rankProducts(args ?? {}); const selected = ranked[0] ?? productsCatalog.products[0]; const payload = buildPayload({ selectedProduct: selected, theme: args?.theme ?? "Aurora" }); return replyWithProductPicker(payload, `Recommended ${selected.name}.`); } ); registerAppTool( server, "select_product", { title: "Select product", description: "Updates the product picker UI after a user selects a package card.", inputSchema: selectProductInputSchema, outputSchema: productOutputSchema, _meta: { ui: { resourceUri: WIDGET_URI }, }, }, async (args) => { const selected = productsCatalog.products.find((product) => product.id === args?.product_id) ?? productsCatalog.products[0]; const payload = buildPayload({ selectedProduct: selected, theme: args?.theme ?? "Aurora" }); return replyWithProductPicker(payload, `Selected ${selected.name}.`); } ); return server; } const port = Number(process.env.PORT ?? 8787); const MCP_PATH = "/mcp"; const httpServer = createServer(async (req, res) => { if (!req.url) { res.writeHead(400).end("Missing URL"); return; } const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`); if (req.method === "OPTIONS" && url.pathname === MCP_PATH) { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS", "Access-Control-Allow-Headers": "content-type, mcp-session-id", "Access-Control-Expose-Headers": "Mcp-Session-Id", }); res.end(); return; } if (req.method === "GET" && url.pathname === "/") { res .writeHead(200, { "content-type": "text/plain" }) .end("Agent-Friendly Product Picker MCP server. Connect ChatGPT to /mcp."); return; } const MCP_METHODS = new Set(["POST", "GET", "DELETE"]); if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); const server = createProductPickerServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on("close", () => { transport.close(); server.close(); }); try { await server.connect(transport); await transport.handleRequest(req, res); } catch (error) { console.error("Error handling MCP request:", error); if (!res.headersSent) { res.writeHead(500).end("Internal server error"); } } return; } res.writeHead(404).end("Not Found"); }); httpServer.listen(port, () => { console.log(`Product Picker MCP server listening on http://localhost:${port}${MCP_PATH}`); });