YoussefSharawy91's picture
Deploy Apps SDK product picker
0c683e6
Raw
History Blame Contribute Delete
9.97 kB
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}`);
});