| 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}`); |
| }); |
|
|