Skip to main content
The MCP server declares the tools and resources that ChatGPT discovers and invokes during a conversation.
1

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.
2

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(),
  };
}
3

Create the HTTP server

ChatGPT sends requests to a single /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 }>();
}
Each ChatGPT conversation gets its own MCP session. The first 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);
  });
Handle 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);
  });
Add any additional endpoints and start the server:
  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);
});
4

Register tools and resources

Start with imports, widget URIs, and a helper to load the widget HTML files from disk.
// 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.
Register widget resourcesRegister each widget with a name, URI, MIME type, and a handler that returns the HTML:
  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 }],
  }));
Register the 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.
Register the 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,
      },
    })
  );
Register the 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;
}
With the server complete, continue to build the widgets.