> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gr4vy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build the MCP server

> Define data types, product catalog, HTTP transport, and MCP tools and resources.

The MCP server declares the [tools and resources](/guides/payments/chatgpt-app/architecture) that ChatGPT discovers and invokes during a conversation.

<Steps>
  <Step title="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.

    <CodeGroup>
      ```ts TypeScript theme={"system"}
      // 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;
      }
      ```

      ```python Python theme={"system"}
      # 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
      }
      ```
    </CodeGroup>

    <Note>
      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.
    </Note>
  </Step>

  <Step title="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.

    <CodeGroup>
      ```ts TypeScript theme={"system"}
      // 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(),
        };
      }
      ```

      ```python Python theme={"system"}
      # 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
      ```
    </CodeGroup>
  </Step>

  <Step title="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:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
      // 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 }>();
      }
      ```

      ```python Python theme={"system"}
      # 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")
      ```
    </CodeGroup>

    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:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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);
        });
      ```

      ```python Python theme={"system"}
      # 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.
      ```
    </CodeGroup>

    Handle `GET /mcp` (SSE streaming) and `DELETE /mcp` (session cleanup):

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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);
        });
      ```

      ```python Python theme={"system"}
      # Handled automatically by FastMCP—no manual route wiring needed.
      ```
    </CodeGroup>

    Add any additional endpoints and start the server:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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);
      });
      ```

      ```python Python theme={"system"}
      # 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()
      ```
    </CodeGroup>
  </Step>

  <Step title="Register tools and resources">
    Start with imports, widget URIs, and a helper to load the widget HTML files from disk.

    <CodeGroup>
      ```ts TypeScript theme={"system"}
      // 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",
        });
      }
      ```

      ```python Python theme={"system"}
      # 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.
      ```
    </CodeGroup>

    <Note>
      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.
    </Note>

    **Register widget resources**

    Register each widget with a name, URI, MIME type, and a handler that returns the HTML:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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 }],
        }));
      ```

      ```python Python theme={"system"}
      # 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
      ```
    </CodeGroup>

    **Register the `list_products` tool**

    Each tool has a **name**, a **configuration** (description, input schema, annotations, and `_meta` pointing to the widget), and a **handler**:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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,
              },
            };
          }
        );
      ```

      ```python Python theme={"system"}
      # 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,
              },
          )
      ```
    </CodeGroup>

    <Tip>
      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.
    </Tip>

    **Register the `show_cart` tool**

    Receives cart items and passes them to the shopping cart widget:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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,
            },
          })
        );
      ```

      ```python Python theme={"system"}
      # 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,
              },
          )
      ```
    </CodeGroup>

    **Register the `start_checkout` tool**

    This tool handles **server-side price validation** and **embed token generation**:

    <CodeGroup>
      ```ts TypeScript theme={"system"}
        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;
      }
      ```

      ```python Python theme={"system"}
      # 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,
              },
          )
      ```
    </CodeGroup>
  </Step>
</Steps>

With the server complete, continue to [build the widgets](/guides/payments/chatgpt-app/widgets).
