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 Python example uses plain dictionaries rather than typed interfaces.

# Product (products.py)
product = {
    "id": "aloe-vera",
    "name": "Aloe Vera",
    "category": "Top Rated",
    "price": 14.99,
    "description": "Low-maintenance succulent with air-purifying qualities",
}

# Cart item (server.py — show_cart and start_checkout tools)
cart_item = {
    "product_id": "aloe-vera",
    "name": "Aloe Vera",
    "price": 14.99,
    "quantity": 2,
}

# Embed config (server.py—start_checkout tool)
embed_config = {
    "token": "signed-jwt-token",
    "gr4vy_id": "your-gr4vy-id",
    "buyer_id": "buyer-id",
    "environment": "sandbox",
    "currency": "USD",
    "country": "US",
    "merchant_account_id": "your-merchant-account-id",
    "amount": 2998,  # Total in cents
}
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(),
  };
}
# products.py
PRODUCTS = [
    {
        "id": "aloe-vera",
        "name": "Aloe Vera",
        "category": "Top Rated",
        "price": 14.99,
        "description": "Low-maintenance succulent with air-purifying qualities",
    },
    {
        "id": "monstera",
        "name": "Monstera Deliciosa",
        "category": "Top Rated",
        "price": 34.99,
        "description": "Iconic split-leaf tropical plant, thrives in indirect light",
    },
    # ... more products
]

def get_products(category: str | None = None) -> list[dict]:
    if category:
        return [p for p in PRODUCTS if p["category"].lower() == category.lower()]
    return PRODUCTS

def get_product_by_id(product_id: str) -> dict | None:
    for p in PRODUCTS:
        if p["id"] == product_id:
            return p
    return None
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 }>();
}
# server.py
import os
from pathlib import Path
from dotenv import load_dotenv
from fastmcp import FastMCP

load_dotenv()

# Load widget HTML files
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
CATALOG_HTML = (ASSETS_DIR / "product-catalog.html").read_text(encoding="utf-8")
CART_HTML = (ASSETS_DIR / "shopping-cart.html").read_text(encoding="utf-8")
CHECKOUT_HTML = (ASSETS_DIR / "checkout.html").read_text(encoding="utf-8")

# Widget URIs and MIME type
CATALOG_URI = "ui://widget/product-catalog.html"
CART_URI = "ui://widget/shopping-cart.html"
CHECKOUT_URI = "ui://widget/checkout.html"
WIDGET_MIME = "text/html+skybridge"

WIDGET_SESSION_ID = "plantly-user"

mcp = FastMCP(name="Plantly")
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);
  });
# FastMCP handles session management, POST/GET/DELETE routing, and
# SSE streaming automatically. No equivalent code is needed—when
# you call mcp.run(transport="http"), FastMCP sets up all routes
# and manages per-session state internally.
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);
  });
# Handled automatically by FastMCP—no manual route wiring needed.
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);
});
# server.py (continued) — entry point
def main():
    mcp.run(
        transport="http",
        host=os.getenv("HOST", "0.0.0.0"),
        port=int(os.getenv("PORT", "8000")),
    )

if __name__ == "__main__":
    main()
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",
  });
}
# In Python with FastMCP, the server setup, widget loading, and URI
# constants were defined earlier in Step 3. Resources and tools are
# registered using decorators directly on the FastMCP instance —
# see below.
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 }],
  }));
# server.py — widget resources
@mcp.resource(CATALOG_URI, mime_type=WIDGET_MIME)
def catalog_widget() -> str:
    """Product catalog widget markup."""
    return CATALOG_HTML

@mcp.resource(CART_URI, mime_type=WIDGET_MIME)
def cart_widget() -> str:
    """Shopping cart widget markup."""
    return CART_HTML

@mcp.resource(CHECKOUT_URI, mime_type=WIDGET_MIME)
def checkout_widget() -> str:
    """Checkout widget markup."""
    return CHECKOUT_HTML
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"),
      }),
      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,
        },
      };
    }
  );
# server.py — list_products tool
from typing import Optional
from fastmcp.tools.tool import ToolResult
from mcp.types import TextContent
from products import get_products

@mcp.tool(
    meta={
        "openai/outputTemplate": CATALOG_URI,
        "openai/toolInvocation/invoking": "Loading products...",
        "openai/toolInvocation/invoked": "Product catalog ready",
    },
)
def list_products(category: Optional[str] = None) -> ToolResult:
    """List available Plantly products and show the interactive catalog.

    The UI widget handles all display — do NOT repeat product names, prices,
    or descriptions in your response. Just present the widget silently.

    Args:
        category: Optional filter by category (Top Rated, Essentials, Accessories).
    """
    products = get_products(category)
    categories = sorted(set(p["category"] for p in products))

    return ToolResult(
        content=[TextContent(type="text", text=f"Showing {len(products)} products")],
        structured_content={"products": products, "categories": categories},
        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,
      },
    })
  );
# server.py — show_cart tool
@mcp.tool(
    meta={
        "openai/outputTemplate": CART_URI,
        "openai/toolInvocation/invoking": "Loading cart...",
        "openai/toolInvocation/invoked": "Shopping cart ready",
    },
)
def show_cart(items: list[dict]) -> ToolResult:
    """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. Just present the widget silently.

    Args:
        items: List of cart items, each with product_id, name, price, and quantity.
    """
    return ToolResult(
        content=[TextContent(type="text", text=f"Cart has {len(items)} item(s)")],
        structured_content={"items": 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;
}
# server.py — start_checkout tool
from purchases import generate_embed_token

@mcp.tool(
    meta={
        "openai/outputTemplate": CHECKOUT_URI,
        "openai/toolInvocation/invoking": "Preparing checkout...",
        "openai/toolInvocation/invoked": "Checkout ready",
    },
)
def start_checkout(items: list[dict]) -> ToolResult:
    """Start the checkout process. Shows order summary and Gr4vy Embed for payment.

    This should be called when the user wants to pay for items in their cart.
    The user will enter payment details via the Gr4vy Embed widget.
    The UI widget handles all display — do NOT repeat order details or payment
    info in your response. Just present the widget silently.

    Args:
        items: List of cart items, each with product_id, name, price, and quantity.
    """
    # Compute total in cents
    total_amount = 0
    for item in items:
        qty = item.get("quantity", 1)
        total_amount += int(round(item["price"] * 100)) * qty

    embed_token = generate_embed_token(
        buyer_id="buyer-id",  # In production, look up from your user store
        amount=total_amount,
        currency="USD",
    )

    gr4vy_id = os.environ.get("GR4VY_ID")
    merchant_account_id = os.environ.get("GR4VY_MERCHANT_ACCOUNT_ID")

    return ToolResult(
        content=[TextContent(type="text", text=f"Checkout with {len(items)} item(s)")],
        structured_content={
            "items": items,
            "embed": {
                "token": embed_token,
                "gr4vy_id": gr4vy_id,
                "environment": "sandbox",
                "currency": "USD",
                "country": "US",
                "merchant_account_id": merchant_account_id,
                "amount": total_amount,
            },
        },
        meta={
            "openai/widgetSessionId": WIDGET_SESSION_ID,
            "openai/widgetAccessible": True,
        },
    )
With the server complete, continue to build the widgets.