Define your data types
Define the shared types that both your server and widgets use. The
Product shape is app-specific—adapt it to your domain.// src/server/types.ts
export interface Product {
id: string;
name: string;
price: number;
description: string;
image: string;
sunlight: "Low Light" | "Indirect Light" | "Full Sun";
water: "Low" | "Moderate" | "Frequent";
climate: "Tropical" | "Arid" | "Temperate";
}
export interface CartItem {
product_id: string;
name: string;
price: number;
quantity: number;
}
export interface EmbedConfig {
token: string;
gr4vy_id: string;
environment: string;
currency: string;
country: string;
merchant_account_id: string;
amount: number;
}
The TypeScript widgets have their own shared types file that adds
CatalogData, CartData, and CheckoutData—the contract for the structuredContent that flows from each tool to its widget.Define your product catalog
Create the canonical source of truth for product names and pricing. The checkout tool looks up prices from this catalog rather than trusting values from ChatGPT or the widget.
// src/server/data/products.ts
import type { Product } from "../types.js";
export const PRODUCTS: Product[] = [
{
id: "aloe-vera",
name: "Aloe Vera",
price: 14.99,
description: "Low-maintenance succulent with air-purifying qualities",
image: "https://images.unsplash.com/photo-1509423350716-97f9360b4e09?w=400",
sunlight: "Full Sun",
water: "Low",
climate: "Arid",
},
{
id: "monstera",
name: "Monstera Deliciosa",
price: 34.99,
description: "Iconic split-leaf tropical plant, thrives in indirect light",
image: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?w=400",
sunlight: "Indirect Light",
water: "Moderate",
climate: "Tropical",
},
// ... more products
];
export interface ProductFilters {
sunlight?: string | string[];
water?: string | string[];
climate?: string | string[];
}
function matchesFilter(value: string, filter?: string | string[]): boolean {
if (!filter) return true;
if (Array.isArray(filter)) return filter.length === 0 || filter.includes(value);
return value === filter;
}
export function getProducts(filters?: ProductFilters): Product[] {
if (!filters) return PRODUCTS;
return PRODUCTS.filter((p) => {
if (!matchesFilter(p.sunlight, filters.sunlight)) return false;
if (!matchesFilter(p.water, filters.water)) return false;
if (!matchesFilter(p.climate, filters.climate)) return false;
return true;
});
}
export function getProductById(id: string): Product | undefined {
return PRODUCTS.find((p) => p.id === id);
}
export interface FilterOptions {
sunlight: string[];
water: string[];
climate: string[];
}
export function getFilterOptions(): FilterOptions {
return {
sunlight: [...new Set(PRODUCTS.map((p) => p.sunlight))].sort(),
water: [...new Set(PRODUCTS.map((p) => p.water))].sort(),
climate: [...new Set(PRODUCTS.map((p) => p.climate))].sort(),
};
}
Create the HTTP server
ChatGPT sends requests to a single Each ChatGPT conversation gets its own MCP session. The first Handle Add any additional endpoints and start the server:
/mcp endpoint. Your server needs to handle POST (tool calls), GET (SSE streams), and DELETE (session cleanup), plus manage per-conversation session state:// src/server/index.ts
import "dotenv/config";
import express from "express";
import { randomUUID } from "crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpServer } from "./mcp.js";
import { handleWebhook } from "./services/purchases.js";
const PORT = parseInt(process.env.PORT || "3000", 10);
async function main() {
const app = express();
// Parse JSON for all routes except webhooks (which need raw text for
// signature verification)
app.use((req, res, next) => {
if (req.path === "/webhooks/gr4vy") return next();
express.json()(req, res, next);
});
// Track MCP sessions in memory
const sessions = new Map<string, { transport: StreamableHTTPServerTransport }>();
}
POST /mcp creates a session ID; subsequent requests include it in the mcp-session-id header: app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
// Known session? Route to the existing transport.
if (sessionId && sessions.has(sessionId)) {
const { transport } = sessions.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
// No (or unknown) session ID—this is a new conversation.
const newSessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId,
onsessioninitialized: (id) => {
sessions.set(id, { transport });
},
});
transport.onclose = () => {
const id = [...sessions.entries()]
.find(([, v]) => v.transport === transport)?.[0];
if (id) sessions.delete(id);
};
// Fresh MCP server for this session, connected to the transport
const server = createMcpServer(newSessionId);
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
GET /mcp (SSE streaming) and DELETE /mcp (session cleanup): app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !sessions.has(sessionId)) {
res.status(400).json({ error: "Invalid or missing session ID" });
return;
}
const { transport } = sessions.get(sessionId)!;
await transport.handleRequest(req, res);
});
app.delete("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !sessions.has(sessionId)) {
res.status(400).json({ error: "Invalid or missing session ID" });
return;
}
const { transport } = sessions.get(sessionId)!;
await transport.handleRequest(req, res);
});
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.post("/webhooks/gr4vy", express.text({ type: "*/*" }), (req, res) => {
try {
const signature = req.headers["x-gr4vy-signature"] as string | undefined;
const timestamp = req.headers["x-gr4vy-timestamp"] as string | undefined;
handleWebhook(req.body, signature, timestamp);
res.status(200).json({ received: true });
} catch (err) {
res.status(400).json({ error: "Webhook verification failed" });
}
});
app.listen(PORT, () => {
console.log(`MCP Server running at http://localhost:${PORT}`);
console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
});
}
main().catch((err) => {
console.error("Failed to start server:", err);
process.exit(1);
});
Register tools and resources
Start with imports, widget URIs, and a helper to load the widget HTML files from disk.Register widget resourcesRegister each widget with a name, URI, MIME type, and a handler that returns the HTML:Register the Register the Register the
// src/server/mcp.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readFileSync } from "fs";
import { resolve } from "path";
import { getProducts, getFilterOptions, getProductById } from "./data/products.js";
import { generateEmbedToken } from "./services/purchases.js";
// Widget URIs and the skybridge MIME type
const CATALOG_URI = "ui://widget/product-catalog.html";
const CART_URI = "ui://widget/shopping-cart.html";
const CHECKOUT_URI = "ui://widget/checkout.html";
const WIDGET_MIME = "text/html+skybridge";
// Widget state session ID; ChatGPT uses this to persist widget state
// (like cart contents) across tool invocations in the same conversation.
const WIDGET_SESSION_ID = "plantly-user";
function loadWidgetHtml(name: string): string {
const path = resolve(process.cwd(), "dist", "widgets", `${name}.html`);
try {
return readFileSync(path, "utf-8");
} catch {
return `<html><body><p>Widget "${name}" not built yet. Run: npm run build:widgets</p></body></html>`;
}
}
export function createMcpServer(sessionId?: string): McpServer {
const server = new McpServer({
name: "Plantly",
version: "0.1.0",
});
}
The
ui:// URIs are identifiers connecting a tool’s _meta.openai/outputTemplate to a registered resource. The text/html+skybridge MIME type tells ChatGPT this is an embeddable widget. server.registerResource(
"product-catalog", // Resource name
CATALOG_URI, // URI that tools reference in _meta
{ mimeType: WIDGET_MIME }, // text/html+skybridge
async (uri) => ({
contents: [{
uri: uri.toString(),
text: loadWidgetHtml("product-catalog"),
mimeType: WIDGET_MIME,
}],
})
);
server.registerResource("shopping-cart", CART_URI, { mimeType: WIDGET_MIME }, async (uri) => ({
contents: [{ uri: uri.toString(), text: loadWidgetHtml("shopping-cart"), mimeType: WIDGET_MIME }],
}));
server.registerResource("checkout", CHECKOUT_URI, { mimeType: WIDGET_MIME }, async (uri) => ({
contents: [{ uri: uri.toString(), text: loadWidgetHtml("checkout"), mimeType: WIDGET_MIME }],
}));
list_products toolEach tool has a name, a configuration (description, input schema, annotations, and _meta pointing to the widget), and a handler: server.registerTool(
"list_products",
{
description:
"List available Plantly plants and show the interactive catalog. "
+ "Supports filtering by plant care attributes: sunlight needs, watering "
+ "frequency, and climate/hardiness zone. The UI widget handles all display "
+ "— do NOT repeat product names, prices, or descriptions in your response. "
+ "Just present the widget silently.",
inputSchema: z.object({
sunlight: z
.union([
z.enum(["Low Light", "Indirect Light", "Full Sun"]),
z.array(z.enum(["Low Light", "Indirect Light", "Full Sun"])),
])
.optional()
.describe("Filter by sunlight requirement — single value or array"),
water: z
.union([
z.enum(["Low", "Moderate", "Frequent"]),
z.array(z.enum(["Low", "Moderate", "Frequent"])),
])
.optional()
.describe("Filter by watering frequency — single value or array"),
climate: z
.union([
z.enum(["Tropical", "Arid", "Temperate"]),
z.array(z.enum(["Tropical", "Arid", "Temperate"])),
])
.optional()
.describe("Filter by climate/hardiness zone — single value or array"),
search: z.string().optional().describe("Free-text search"),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
"openai/outputTemplate": CATALOG_URI,
"openai/toolInvocation/invoking": "Loading products...",
"openai/toolInvocation/invoked": "Product catalog ready",
},
},
async (args) => {
// Normalise filter values to arrays for multi-select support
const toArray = (val: string | string[] | undefined): string[] | undefined =>
val === undefined ? undefined : Array.isArray(val) ? val : [val];
const initialFilters: Record<string, string[]> = {};
const sunArr = toArray(args.sunlight);
const waterArr = toArray(args.water);
const climateArr = toArray(args.climate);
if (sunArr) initialFilters.sunlight = sunArr;
if (waterArr) initialFilters.water = waterArr;
if (climateArr) initialFilters.climate = climateArr;
// Return all products — the widget handles client-side filtering.
const products = getProducts();
const filters = getFilterOptions();
return {
content: [
{ type: "text" as const, text: `Showing ${products.length} plants` },
],
structuredContent: {
products,
filters,
initialFilters:
Object.keys(initialFilters).length > 0 ? initialFilters : undefined,
},
_meta: {
"openai/widgetSessionId": WIDGET_SESSION_ID,
"openai/widgetAccessible": true,
},
};
}
);
For a small catalog, returning all products and filtering client-side gives instant, responsive interactions without round-tripping through ChatGPT. The
initialFilters field pre-selects filters based on the user’s request while still allowing free exploration. For larger catalogs, push filtering to the server.show_cart toolReceives cart items and passes them to the shopping cart widget: server.registerTool(
"show_cart",
{
description:
"Show the shopping cart with the given items. The UI widget handles all display "
+ "— do NOT repeat item names, quantities, or totals in your response.",
inputSchema: z.object({
items: z.array(
z.object({
product_id: z.string(),
name: z.string(),
price: z.number(),
quantity: z.number(),
})
),
}),
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
_meta: {
"openai/outputTemplate": CART_URI,
"openai/toolInvocation/invoking": "Loading cart...",
"openai/toolInvocation/invoked": "Shopping cart ready",
},
},
async (args) => ({
content: [
{ type: "text" as const, text: `Cart has ${args.items.length} item(s)` },
],
structuredContent: { items: args.items },
_meta: {
"openai/widgetSessionId": WIDGET_SESSION_ID,
"openai/widgetAccessible": true,
},
})
);
start_checkout toolThis tool handles server-side price validation and embed token generation: server.registerTool(
"start_checkout",
{
description:
"Start the checkout process. Shows order summary and Gr4vy Embed for payment. "
+ "The UI widget handles all display — do NOT repeat order details.",
inputSchema: z.object({
items: z.array(
z.object({
product_id: z.string(),
name: z.string(),
price: z.number(),
quantity: z.number(),
})
),
}),
annotations: {
readOnlyHint: false, // Creates a checkout session (side effect)
destructiveHint: false,
openWorldHint: true, // Calls an external payment API
},
_meta: {
"openai/outputTemplate": CHECKOUT_URI,
"openai/toolInvocation/invoking": "Preparing checkout...",
"openai/toolInvocation/invoked": "Checkout ready",
},
},
async (args) => {
// ALWAYS validate prices server-side. Never trust prices from the model or widget.
const validatedItems = args.items.map((item) => {
const product = getProductById(item.product_id);
if (!product) throw new Error(`Unknown product: ${item.product_id}`);
return {
...item,
price: product.price, // Canonical price from YOUR catalog
name: product.name,
};
});
// Compute total in cents (Gr4vy expects amounts in minor currency units)
let totalAmount = 0;
for (const item of validatedItems) {
totalAmount += Math.round(item.price * 100) * (item.quantity || 1);
}
const checkoutItems = validatedItems.map((item) => ({
name: item.name,
quantity: item.quantity,
unitAmount: Math.round(item.price * 100),
}));
// Generate Gr4vy embed token (see the Gr4vy Embed page)
let embedConfig: Record<string, unknown> | null = null;
let embedError: string | null = null;
try {
const embedToken = await generateEmbedToken(totalAmount, "USD", checkoutItems);
embedConfig = {
token: embedToken,
gr4vy_id: process.env.GR4VY_ID,
environment: process.env.GR4VY_ENVIRONMENT || "sandbox",
currency: "USD",
country: "US",
merchant_account_id: process.env.GR4VY_MERCHANT_ACCOUNT_ID,
amount: totalAmount,
};
} catch (err) {
embedError = "Payment processing is temporarily unavailable. Please try again later.";
}
return {
content: [
{
type: "text" as const,
text: embedConfig
? `Checkout with ${validatedItems.length} item(s)`
: `Checkout ready but payment form unavailable: ${embedError}`,
},
],
structuredContent: {
items: validatedItems,
...(embedConfig ? { embed: embedConfig } : { embed_error: embedError }),
},
_meta: {
"openai/widgetSessionId": WIDGET_SESSION_ID,
"openai/widgetAccessible": true,
},
};
}
);
return server;
}