Platform Spoke · Headless
Spoke · Make Your Platform Agent-Ready

Headless Stack + Agent Commerce — Sanity, Contentful, and Strapi Compared.

Headless commerce inverts the tradeoff: you build the data layer, the API, the frontend, the checkout — but you also own them all, which means you can wire agent-ready primitives directly into your schema with no plugin marketplace bottleneck. Sanity, Contentful, and Strapi are the three serious CMS choices for a decoupled commerce build; Stripe is the canonical payment + UCP partner.

4
Layers, Each × 3 CMSes
3
Python MCP Servers
Stripe
Canonical UCP Partner
Own It
Schema, API, Tools, Checkout
§1 · Thesis

The headless thesis: data ownership is the agent advantage.

Every agent-commerce pattern — MCP tools, structured data emission, UCP checkout — ultimately depends on two things: can the agent read what the merchant has, and can the agent act on it? Headless stacks answer both questions with maximum clarity because the merchant controls the data layer end to end.

Data ownership advantage. On a platform-native stack (Shopify, BigCommerce), the product schema is defined by the platform. Headless merchants define their own document types. A Sanity product schema can include trust_score, agent_notes, or negotiable_price as first-class fields. No plugin required, no metafield workaround, no API version to wait for. The 20-field MVP product object the agent-commerce spec calls for is a 20-minute schema exercise on any of the three CMSes compared here.

The clean API surface. Because there is no platform between the data and the API response, GROQ queries against Sanity, REST calls against Contentful's Delivery API, and Strapi's GraphQL queries all return exactly what the document contains. There is no implicit transformation layer an agent has to work around.

Setup cost is the countervailing fact. Headless means you maintain the frontend (typically Next.js App Router, Astro, or Remix), the CMS configuration, the hosting (Vercel, Fly.io, or a VPS), the checkout integration, and the MCP server. A managed platform ships all of this on day one. Framing: headless is a capital investment that pays compound interest in agent-readiness.

The four layers, one stack at a time.

The rest of this spoke walks through all four AgentMall layers — and at every layer, you get the Sanity approach, the Contentful approach, and the Strapi approach side by side. Stripe is the constant: it is the commerce engine and the UCP-compatible checkout in every variant.

Layer 1 · Schema

Structured Data — Your Schema, Your Rules

TypeScript schema for Sanity, JSON content model for Contentful, schema.json for Strapi. All three give you the 20-field MVP product object plus stripePriceId on day one, rendered as Schema.org JSON-LD by your Next.js App Router page.

Layer 2 · API

API Endpoints — GROQ, CDA, REST

Sanity exposes GROQ on its API + CDN endpoints. Contentful exposes the Content Delivery API (55 req/sec non-cached, CDN unlimited). Strapi exposes auto-generated REST and optional GraphQL. Eight canonical commerce endpoints, mapped across all three.

Layer 3 · MCP

MCP Tools — One Server Per CMS

Three Python FastMCP servers — sanity_mcp_server.py, contentful_mcp_server.py, strapi_mcp_server.py — each exposing the same eight tools and composing CMS catalog data with Stripe checkout in pure async Python.

Layer 4 · UCP

UCP Checkout — Stripe as the Constant

An 8-step UCP-compatible state machine where every step is a direct Stripe API call. No platform middleware, no redirect walls. Webhook handler decrements inventoryQuantity in the CMS via Management API or REST write.

Insight · Re-verify before launch

As of mid-2026, no official Sanity, Contentful, or Strapi MCP server exists for commerce use cases (re-verify before launch). General-purpose content-management MCP servers may exist for some CMSes — but the commerce-specific tool surface (product listing, inventory checks, Stripe Checkout initiation) is yours to build. That is the headless bargain, not the bug.

§2 · Layer 1 · Structured Data

Layer 1 — Define your product schema in your CMS of choice.

The 20-field MVP product object is the floor for agent-readability. Every field must be modeled intentionally; schema drift between dev and production is the number-one failure mode of headless MCP servers in production. Below: the same product schema, expressed natively in Sanity TypeScript, Contentful JSON, and Strapi schema.json — followed by a single Next.js App Router page that renders the JSON-LD identically regardless of CMS.

Sanity — TypeScript schema (sanity/schemas/product.ts)

Sanity schemas are defined in TypeScript using defineType and defineField from @sanity/types. The Studio reads them at build time; the Content Lake stores the resulting documents.

TypeScript · Sanity
// sanity/schemas/product.ts
import { defineType, defineField, defineArrayMember } from 'sanity'

export const productSchema = defineType({
  name: 'product',
  title: 'Product',
  type: 'document',
  fields: [
    defineField({
      name: 'productId',
      title: 'Product ID (SKU)',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'name',
      title: 'Product Name',
      type: 'string',
      validation: (Rule) => Rule.required().max(200),
    }),
    defineField({
      name: 'slug',
      title: 'URL Slug',
      type: 'slug',
      options: { source: 'name', maxLength: 200 },
      validation: (Rule) => Rule.required(),
    }),
    defineField({ name: 'description', title: 'Description (Short)', type: 'text', rows: 3 }),
    defineField({
      name: 'descriptionLong',
      title: 'Description (Long, Rich Text)',
      type: 'array',
      of: [defineArrayMember({ type: 'block' })],
    }),
    defineField({ name: 'brand', title: 'Brand', type: 'string' }),
    defineField({
      name: 'price',
      title: 'Price (USD cents)',
      type: 'number',
      validation: (Rule) => Rule.required().integer().positive(),
    }),
    defineField({ name: 'compareAtPrice', title: 'Compare-At Price (USD cents)', type: 'number' }),
    defineField({
      name: 'currency',
      title: 'Currency Code',
      type: 'string',
      initialValue: 'USD',
      options: { list: ['USD', 'EUR', 'GBP', 'CAD', 'AUD'] },
    }),
    defineField({
      name: 'availability',
      title: 'Availability',
      type: 'string',
      options: {
        list: [
          { title: 'In Stock', value: 'InStock' },
          { title: 'Out of Stock', value: 'OutOfStock' },
          { title: 'Pre-Order', value: 'PreOrder' },
          { title: 'Discontinued', value: 'Discontinued' },
        ],
        layout: 'radio',
      },
      initialValue: 'InStock',
    }),
    defineField({
      name: 'inventoryQuantity',
      title: 'Inventory Quantity',
      type: 'number',
      validation: (Rule) => Rule.integer().min(0),
    }),
    defineField({
      name: 'images',
      title: 'Product Images',
      type: 'array',
      of: [defineArrayMember({ type: 'image', options: { hotspot: true } })],
    }),
    defineField({ name: 'category', title: 'Category', type: 'string' }),
    defineField({
      name: 'tags',
      title: 'Tags',
      type: 'array',
      of: [defineArrayMember({ type: 'string' })],
    }),
    defineField({ name: 'gtin', title: 'GTIN / Barcode', type: 'string' }),
    defineField({ name: 'mpn', title: 'MPN (Manufacturer Part Number)', type: 'string' }),
    defineField({ name: 'weight', title: 'Weight (grams)', type: 'number' }),
    defineField({
      name: 'dimensions',
      title: 'Dimensions',
      type: 'object',
      fields: [
        defineField({ name: 'length', type: 'number', title: 'Length (cm)' }),
        defineField({ name: 'width', type: 'number', title: 'Width (cm)' }),
        defineField({ name: 'height', type: 'number', title: 'Height (cm)' }),
      ],
    }),
    defineField({
      name: 'aggregateRating',
      title: 'Aggregate Rating',
      type: 'object',
      fields: [
        defineField({ name: 'ratingValue', type: 'number', title: 'Rating Value (1–5)' }),
        defineField({ name: 'reviewCount', type: 'number', title: 'Review Count' }),
      ],
    }),
    defineField({
      name: 'stripePriceId',
      title: 'Stripe Price ID',
      type: 'string',
      description: 'The Stripe Price ID for this product (e.g. price_xxxxx)',
    }),
  ],
})

Contentful — content type JSON (importable via CLI)

Contentful content models are defined via the Content Management API (CMA) or imported via the CLI. The following JSON represents the full Product content type definition importable with contentful space import.

JSON · Contentful CMA import
{
  "contentTypes": [
    {
      "sys": { "id": "product", "type": "ContentType" },
      "name": "Product",
      "displayField": "name",
      "description": "Commerce product for agent-ready headless storefront",
      "fields": [
        { "id": "productId", "name": "Product ID (SKU)", "type": "Symbol", "required": true, "localized": false },
        { "id": "name", "name": "Product Name", "type": "Symbol", "required": true, "localized": true,
          "validations": [{ "size": { "max": 200 } }] },
        { "id": "slug", "name": "URL Slug", "type": "Symbol", "required": true, "localized": false,
          "validations": [{ "unique": true }, { "regexp": { "pattern": "^[a-z0-9-]+$" } }] },
        { "id": "description", "name": "Description (Short)", "type": "Symbol", "required": false, "localized": true },
        { "id": "descriptionLong", "name": "Description (Long)", "type": "RichText", "required": false, "localized": true },
        { "id": "brand", "name": "Brand", "type": "Symbol", "required": false, "localized": false },
        { "id": "price", "name": "Price (USD cents)", "type": "Integer", "required": true, "localized": false,
          "validations": [{ "range": { "min": 0 } }] },
        { "id": "compareAtPrice", "name": "Compare-At Price (USD cents)", "type": "Integer", "required": false, "localized": false },
        { "id": "currency", "name": "Currency Code", "type": "Symbol", "required": false, "localized": false,
          "validations": [{ "in": ["USD", "EUR", "GBP", "CAD", "AUD"] }] },
        { "id": "availability", "name": "Availability", "type": "Symbol", "required": false, "localized": false,
          "validations": [{ "in": ["InStock", "OutOfStock", "PreOrder", "Discontinued"] }] },
        { "id": "inventoryQuantity", "name": "Inventory Quantity", "type": "Integer", "required": false, "localized": false },
        { "id": "images", "name": "Product Images", "type": "Array",
          "items": { "type": "Link", "linkType": "Asset" }, "required": false, "localized": false },
        { "id": "category", "name": "Category", "type": "Symbol", "required": false, "localized": false },
        { "id": "tags", "name": "Tags", "type": "Array",
          "items": { "type": "Symbol" }, "required": false, "localized": false },
        { "id": "gtin", "name": "GTIN / Barcode", "type": "Symbol", "required": false, "localized": false },
        { "id": "mpn", "name": "MPN", "type": "Symbol", "required": false, "localized": false },
        { "id": "weight", "name": "Weight (grams)", "type": "Number", "required": false, "localized": false },
        { "id": "dimensions", "name": "Dimensions", "type": "Object", "required": false, "localized": false },
        { "id": "aggregateRating", "name": "Aggregate Rating", "type": "Object", "required": false, "localized": false },
        { "id": "stripePriceId", "name": "Stripe Price ID", "type": "Symbol", "required": false, "localized": false }
      ]
    }
  ]
}
Bash · Import
contentful space import \
  --space-id $CONTENTFUL_SPACE_ID \
  --content-file product-content-type.json \
  --management-token $CONTENTFUL_MANAGEMENT_TOKEN

Strapi — schema.json in the repo

In Strapi, collection type schemas live at src/api/product/content-types/product/schema.json:

JSON · Strapi schema
{
  "kind": "collectionType",
  "collectionName": "products",
  "info": {
    "singularName": "product",
    "pluralName": "products",
    "displayName": "Product",
    "description": "Commerce product for agent-ready headless storefront"
  },
  "options": { "draftAndPublish": true },
  "attributes": {
    "productId": { "type": "string", "required": true, "unique": true },
    "name": { "type": "string", "required": true, "maxLength": 200 },
    "slug": { "type": "uid", "targetField": "name", "required": true },
    "description": { "type": "text" },
    "descriptionLong": { "type": "richtext" },
    "brand": { "type": "string" },
    "price": { "type": "integer", "required": true, "min": 0 },
    "compareAtPrice": { "type": "integer" },
    "currency": {
      "type": "enumeration",
      "enum": ["USD", "EUR", "GBP", "CAD", "AUD"],
      "default": "USD"
    },
    "availability": {
      "type": "enumeration",
      "enum": ["InStock", "OutOfStock", "PreOrder", "Discontinued"],
      "default": "InStock"
    },
    "inventoryQuantity": { "type": "integer", "min": 0 },
    "images": { "type": "media", "multiple": true, "allowedTypes": ["images"] },
    "category": { "type": "string" },
    "tags": { "type": "json" },
    "gtin": { "type": "string" },
    "mpn": { "type": "string" },
    "weight": { "type": "float" },
    "dimensions": { "type": "json" },
    "aggregateRating": { "type": "json" },
    "stripePriceId": { "type": "string" }
  }
}

Next.js App Router — render JSON-LD from any CMS

The following is a complete Next.js App Router page component that fetches product data from any of the three CMSes and renders full Schema.org JSON-LD including Product, Offer, AggregateRating, and Brand. This is the output that agent crawlers (and Google's AI Overviews) parse. The example below uses Sanity; for Contentful or Strapi, replace only the getProduct() function.

TSX · app/products/[slug]/page.tsx
// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { createClient } from '@sanity/client'

// Swap this fetch function for Contentful or Strapi as needed
const sanityClient = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2024-01-01',
  useCdn: true,
})

interface ProductDimensions { length?: number; width?: number; height?: number }
interface AggregateRating { ratingValue?: number; reviewCount?: number }

interface Product {
  productId: string
  name: string
  slug: { current: string }
  description: string
  brand: string
  price: number
  compareAtPrice?: number
  currency: string
  availability: string
  inventoryQuantity: number
  images: Array<{ asset: { url: string }; alt?: string }>
  category: string
  tags: string[]
  gtin?: string
  mpn?: string
  weight?: number
  dimensions?: ProductDimensions
  aggregateRating?: AggregateRating
  stripePriceId?: string
}

async function getProduct(slug: string): Promise<Product | null> {
  const query = `*[_type == "product" && slug.current == $slug][0]{
    productId, name, slug, description, brand, price, compareAtPrice, currency,
    availability, inventoryQuantity,
    "images": images[]{ "asset": asset->{url}, alt },
    category, tags, gtin, mpn, weight, dimensions, aggregateRating, stripePriceId
  }`
  return sanityClient.fetch(query, { slug })
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const product = await getProduct(slug)
  if (!product) return {}
  return {
    title: product.name,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: product.images?.[0]?.asset?.url ? [product.images[0].asset.url] : [],
    },
  }
}

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const product = await getProduct(slug)
  if (!product) notFound()

  const priceDecimal = (product.price / 100).toFixed(2)
  const compareAtDecimal = product.compareAtPrice
    ? (product.compareAtPrice / 100).toFixed(2)
    : null

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    sku: product.productId,
    ...(product.gtin && { gtin13: product.gtin }),
    ...(product.mpn && { mpn: product.mpn }),
    image: product.images?.map((img) => img.asset?.url).filter(Boolean) ?? [],
    ...(product.brand && {
      brand: { '@type': 'Brand', name: product.brand },
    }),
    offers: {
      '@type': 'Offer',
      url: `https://yourstore.com/products/${product.slug.current}`,
      priceCurrency: product.currency ?? 'USD',
      price: priceDecimal,
      ...(compareAtDecimal && {
        priceValidUntil: new Date(Date.now() + 30 * 86400000)
          .toISOString().split('T')[0],
      }),
      availability: `https://schema.org/${product.availability ?? 'InStock'}`,
      seller: { '@type': 'Organization', name: 'Your Store Name' },
    },
    ...(product.aggregateRating?.ratingValue && {
      aggregateRating: {
        '@type': 'AggregateRating',
        ratingValue: product.aggregateRating.ratingValue,
        reviewCount: product.aggregateRating.reviewCount ?? 0,
        bestRating: 5,
        worstRating: 1,
      },
    }),
  }

  return (
    <section>
      {/* Schema.org JSON-LD — parsed by Google AI Overviews, Perplexity, agent crawlers */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>
        <strong>${priceDecimal}</strong>
        {compareAtDecimal && (
          <s style={{ marginLeft: '0.5rem', color: '#999' }}>${compareAtDecimal}</s>
        )}
      </p>
      <p>Status: {product.availability}</p>
    </section>
  )
}

4-Layer × CMS — how each layer is expressed across all three

LayerSanity ApproachContentful ApproachStrapi Approach
Structured Data TypeScript schema with defineType/defineField; GROQ projection to JSON-LD JSON content type via CMA import; CDA response mapped to JSON-LD JSON schema.json in repo; REST response mapped to JSON-LD
API Endpoint GROQ query endpoint ({projectId}.api.sanity.io/v…/data/query/{dataset}); CDN endpoint for cached reads Content Delivery API (cdn.contentful.com); Preview API; Management API REST (/api/products); GraphQL (/graphql); auto-generated per content type
MCP Tool Description Python FastMCP server using httpx GROQ queries; 8 tools; composes with Stripe Python FastMCP server using httpx CDA calls; 8 tools; composes with Stripe Python FastMCP server using httpx REST calls; 8 tools; composes with Stripe
UCP Compatibility stripePriceId field on product; Stripe Checkout Session create from MCP tool stripePriceId field on entry; Stripe Checkout Session create from MCP tool stripePriceId attribute on product; Stripe Checkout Session create from MCP tool
§3 · Layer 2 · API Endpoints

Layer 2 — The eight canonical commerce endpoints, three ways.

Sanity, Contentful, and Strapi are catalog sources, not commerce engines. They provide product data retrieval — list, get, search, filter — but do not natively implement cart operations, tax calculation, or checkout initiation. The commerce engine (Stripe, in this doc) handles the transactional side. This is explicit, not a limitation: the split is the architecture. Below, the same 8 canonical endpoints — mapped across all three CMSes with concrete curl examples.

#EndpointSanity (GROQ)Contentful (CDA)Strapi (REST)Commerce Engine
1list_productsGET /data/query/{dataset}?query=*[_type=="product"]GET cdn.contentful.com/spaces/{id}/entries?content_type=productGET /api/products
2get_productGROQ: *[_type=="product" && slug.current==$slug][0]?content_type=product&fields.slug=…GET /api/products/{documentId}
3search_productsGROQ full-text: [name, description] match $qCDA query param: ?query=keyword?filters[name][$containsi]=keyword
4check_inventoryField on product doc: inventoryQuantity, availabilityField on entry: fields.inventoryQuantity?fields[0]=inventoryQuantity
5calculate_totalFetch price + stripePriceIdFetch fields.price + fields.stripePriceIdFetch price + stripePriceIdStripe Price API or local calc
6initiate_checkoutBuild line_items from CMS stripePriceIdBuild line_items from fields.stripePriceIdBuild line_items from stripePriceIdPOST /v1/checkout/sessions
7get_order_status— (write-back via webhook)— (write-back via webhook)— (write-back via webhook)Stripe GET /v1/checkout/sessions/{id}
8request_returnStripe POST /v1/refunds

Sanity — GROQ query examples

Base URL: https://{projectId}.api.sanity.io/v2024-01-01/data/query/{dataset}
CDN URL (cached): https://{projectId}.apicdn.sanity.io/v2024-01-01/data/query/{dataset}
Auth: Bearer token in Authorization header, or ?token= query param for public datasets.

Bash · curl · GROQ
# List all published products (CDN-cached)
curl "https://your-project.apicdn.sanity.io/v2024-01-01/data/query/production" \
  --get \
  --data-urlencode "query=*[_type == \"product\" && !(_id in path('drafts.**'))]{productId, name, slug, price, availability, stripePriceId}" \
  -H "Authorization: Bearer $SANITY_READ_TOKEN"

# Get single product by slug
curl "https://your-project.apicdn.sanity.io/v2024-01-01/data/query/production" \
  --get \
  --data-urlencode 'query=*[_type == "product" && slug.current == $slug][0]' \
  --data-urlencode '$slug=blue-widget' \
  -H "Authorization: Bearer $SANITY_READ_TOKEN"

# Full-text search
curl "https://your-project.apicdn.sanity.io/v2024-01-01/data/query/production" \
  --get \
  --data-urlencode 'query=*[_type == "product" && [name, description] match $q]{name, slug, price}' \
  --data-urlencode '$q=widget*' \
  -H "Authorization: Bearer $SANITY_READ_TOKEN"

Contentful — Content Delivery API examples

Base URL: https://cdn.contentful.com
Auth: Authorization: Bearer {CDA_ACCESS_TOKEN} or ?access_token=…
Rate limit: 55 requests/second non-cached; CDN cache hits unlimited. (re-verify before launch)

Bash · curl · CDA
# List all product entries
curl "https://cdn.contentful.com/spaces/$SPACE_ID/environments/master/entries" \
  -H "Authorization: Bearer $CONTENTFUL_CDA_TOKEN" \
  -G \
  --data-urlencode "content_type=product" \
  --data-urlencode "select=fields.productId,fields.name,fields.slug,fields.price,fields.availability,fields.stripePriceId" \
  --data-urlencode "limit=100"

# Get single product by slug
curl "https://cdn.contentful.com/spaces/$SPACE_ID/environments/master/entries" \
  -H "Authorization: Bearer $CONTENTFUL_CDA_TOKEN" \
  -G \
  --data-urlencode "content_type=product" \
  --data-urlencode "fields.slug=blue-widget" \
  --data-urlencode "limit=1"

# Full-text search
curl "https://cdn.contentful.com/spaces/$SPACE_ID/environments/master/entries" \
  -H "Authorization: Bearer $CONTENTFUL_CDA_TOKEN" \
  -G \
  --data-urlencode "content_type=product" \
  --data-urlencode "query=widget"

Preview API (draft content): https://preview.contentful.com — same endpoints, use Preview access token. Management API (write): https://api.contentful.com — requires CMA token.

Strapi — REST API examples

Base URL: http://localhost:1337 (self-hosted) or your Strapi Cloud domain.
Auth: Authorization: Bearer {STRAPI_API_TOKEN}
Rate limits: Self-hosted = unlimited (you control the server). Strapi Cloud Free: 2.5K API requests/month; Essential: 50K/month; Pro: 1M/month; Scale: 10M/month. (re-verify before launch)

Bash · curl · Strapi REST
# List all published products
curl "https://your-strapi.com/api/products?publicationState=live&populate=images" \
  -H "Authorization: Bearer $STRAPI_API_TOKEN"

# Get single product by documentId
curl "https://your-strapi.com/api/products/$DOCUMENT_ID?populate=images" \
  -H "Authorization: Bearer $STRAPI_API_TOKEN"

# Filter by slug
curl "https://your-strapi.com/api/products?filters[slug][\$eq]=blue-widget&populate=images" \
  -H "Authorization: Bearer $STRAPI_API_TOKEN"

# Full-text search on name field
curl "https://your-strapi.com/api/products?filters[name][\$containsi]=widget" \
  -H "Authorization: Bearer $STRAPI_API_TOKEN"

# Check inventory
curl "https://your-strapi.com/api/products?filters[slug][\$eq]=blue-widget&fields[0]=inventoryQuantity&fields[1]=availability" \
  -H "Authorization: Bearer $STRAPI_API_TOKEN"

Rate limits — at a glance

CMSFree-tier API budgetPer-second / instantaneousCDN behaviour
Sanity250K non-CDN req/mo + 1M CDN req/moNo hard per-second cap documentedUse apicdn.sanity.io for reads — counts against the 1M CDN bucket, not the 250K bucket
Contentful100K API calls/mo (Free); 1M/mo (Lite, $300/mo)55 req/sec for non-cached requestsCDN cache hits unlimited and do not count against the 55 req/sec limit
Strapi (self-hosted)Unlimited (your server)Whatever your server can handleYou provide the CDN (Cloudflare, Vercel) in front
Strapi CloudFree: 2.5K req/mo; Essential: 50K; Pro: 1M; Scale: 10MPlan-dependentStrapi Cloud CDN included

All pricing & limits flagged · Re-verify before launch

One AgentMall note per week

Headless ships this Friday.

The next spoke — and the gnarly schema drift, rate-limit, and webhook stories from operator logs — lands in your inbox before you open Notion on Monday. One email. No fluff.

§4 · Layer 3 · MCP Servers

Layer 3 — Three Python MCP servers, one tool surface.

With a headless stack, the MCP server you write talks directly to your own data — no platform API wrappers, no proprietary SDK contracts, no third-party rate limits imposed by a closed marketplace. You own the GROQ queries or the REST calls. You define the tool signatures. You compose CMS catalog data with Stripe checkout in a single Python async function. This is the cleanest possible MCP build.

Below: the full sanity_mcp_server.py, plus the same eight tools re-implemented for Contentful and Strapi. Each server exposes the identical eight tools: list_products, get_product, search_products, check_inventory, calculate_total, initiate_checkout, get_order_status, request_return.

Critical · Re-verify before launch

As of mid-2026, no official Sanity, Contentful, or Strapi MCP server exists for commerce use cases (re-verify before launch). The servers below are reference implementations you deploy yourself — that is the headless model: you own the tool surface end to end.

Sanity MCP server (Python · FastMCP)

Python · sanity_mcp_server.py
# sanity_mcp_server.py
# Requirements: pip install "mcp[cli]" httpx stripe
import os
import httpx
import stripe
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("sanity-commerce")

SANITY_PROJECT_ID = os.environ["SANITY_PROJECT_ID"]
SANITY_DATASET = os.environ.get("SANITY_DATASET", "production")
SANITY_API_VERSION = "2024-01-01"
SANITY_TOKEN = os.environ["SANITY_READ_TOKEN"]
SANITY_CDN_BASE = f"https://{SANITY_PROJECT_ID}.apicdn.sanity.io/v{SANITY_API_VERSION}/data/query/{SANITY_DATASET}"

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
STRIPE_SUCCESS_URL = os.environ.get("STRIPE_SUCCESS_URL", "https://yourstore.com/checkout/success")
STRIPE_CANCEL_URL = os.environ.get("STRIPE_CANCEL_URL", "https://yourstore.com/cart")

async def groq_query(query: str, params: dict = None) -> dict:
    """Execute a GROQ query against the Sanity CDN."""
    query_params = {"query": query}
    if params:
        query_params.update({f"${k}": v for k, v in params.items()})
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            SANITY_CDN_BASE,
            params=query_params,
            headers={"Authorization": f"Bearer {SANITY_TOKEN}"},
            timeout=10.0,
        )
        resp.raise_for_status()
        return resp.json()

@mcp.tool()
async def list_products(limit: int = 20, offset: int = 0) -> str:
    """List available products from the Sanity catalog."""
    query = f'*[_type == "product" && !(_id in path("drafts.**")) && availability == "InStock"][{offset}...{offset + limit}]{{productId, name, "slug": slug.current, price, currency, availability, inventoryQuantity, brand, category, stripePriceId}}'
    result = await groq_query(query)
    products = result.get("result", [])
    if not products:
        return "No products found."
    lines = []
    for p in products:
        price_str = f"${p['price'] / 100:.2f} {p.get('currency', 'USD')}"
        lines.append(f"- {p['name']} (SKU: {p['productId']}) — {price_str} — Stock: {p.get('inventoryQuantity', 'N/A')} — slug: {p['slug']}")
    return "\n".join(lines)

@mcp.tool()
async def get_product(slug: str) -> str:
    """Get full product details by URL slug."""
    query = '*[_type == "product" && slug.current == $slug][0]{productId, name, "slug": slug.current, description, brand, price, compareAtPrice, currency, availability, inventoryQuantity, "imageUrls": images[].asset->url, category, tags, gtin, mpn, weight, dimensions, aggregateRating, stripePriceId}'
    result = await groq_query(query, {"slug": slug})
    product = result.get("result")
    if not product:
        return f"Product with slug '{slug}' not found."
    price_str = f"${product['price'] / 100:.2f} {product.get('currency', 'USD')}"
    return (
        f"**{product['name']}** (SKU: {product['productId']})\n"
        f"Price: {price_str}\n"
        f"Availability: {product.get('availability', 'Unknown')}\n"
        f"Inventory: {product.get('inventoryQuantity', 'N/A')} units\n"
        f"Brand: {product.get('brand', 'N/A')}\n"
        f"Description: {product.get('description', 'N/A')}\n"
        f"Stripe Price ID: {product.get('stripePriceId', 'not set')}"
    )

@mcp.tool()
async def search_products(query_string: str, limit: int = 10) -> str:
    """Search products by keyword across name and description."""
    query = f'*[_type == "product" && [name, description] match $q][0...{limit}]{{productId, name, "slug": slug.current, price, availability}}'
    result = await groq_query(query, {"q": f"{query_string}*"})
    products = result.get("result", [])
    if not products:
        return f"No products found matching '{query_string}'."
    return "\n".join(
        f"- {p['name']} — ${p['price'] / 100:.2f} — {p.get('availability', '?')} — slug: {p['slug']}"
        for p in products
    )

@mcp.tool()
async def check_inventory(slug: str) -> str:
    """Check inventory quantity and availability for a product."""
    query = '*[_type == "product" && slug.current == $slug][0]{name, inventoryQuantity, availability}'
    result = await groq_query(query, {"slug": slug})
    product = result.get("result")
    if not product:
        return f"Product '{slug}' not found."
    return f"{product['name']}: {product.get('inventoryQuantity', 0)} units in stock — Status: {product.get('availability', 'Unknown')}"

@mcp.tool()
async def calculate_total(slug: str, quantity: int = 1) -> str:
    """Calculate the total price for a product and quantity."""
    query = '*[_type == "product" && slug.current == $slug][0]{name, price, currency}'
    result = await groq_query(query, {"slug": slug})
    product = result.get("result")
    if not product:
        return f"Product '{slug}' not found."
    unit_price = product["price"]
    total_cents = unit_price * quantity
    currency = product.get("currency", "USD")
    return (
        f"{product['name']} × {quantity}\n"
        f"Unit price: ${unit_price / 100:.2f} {currency}\n"
        f"Subtotal: ${total_cents / 100:.2f} {currency}\n"
        f"(Tax and shipping calculated at checkout by Stripe)"
    )

@mcp.tool()
async def initiate_checkout(slug: str, quantity: int = 1, customer_email: str = None) -> str:
    """Create a Stripe Checkout Session for a product purchase."""
    query = '*[_type == "product" && slug.current == $slug][0]{name, price, currency, stripePriceId, availability, inventoryQuantity}'
    result = await groq_query(query, {"slug": slug})
    product = result.get("result")
    if not product:
        return f"Product '{slug}' not found."
    if product.get("availability") not in ("InStock", "PreOrder"):
        return f"Product '{product['name']}' is not available for purchase (status: {product.get('availability')})."
    if product.get("inventoryQuantity", 0) < quantity:
        return f"Insufficient inventory: {product.get('inventoryQuantity', 0)} units available, {quantity} requested."

    session_params = {
        "mode": "payment",
        "success_url": STRIPE_SUCCESS_URL + "?session_id={CHECKOUT_SESSION_ID}",
        "cancel_url": STRIPE_CANCEL_URL,
        "automatic_tax": {"enabled": True},
        "shipping_address_collection": {"allowed_countries": ["US", "CA", "GB"]},
        "metadata": {"cms_product_id": slug, "source": "mcp_agent", "cms": "sanity"},
    }
    if product.get("stripePriceId"):
        session_params["line_items"] = [{"price": product["stripePriceId"], "quantity": quantity}]
    else:
        session_params["line_items"] = [{
            "price_data": {
                "currency": product.get("currency", "usd").lower(),
                "product_data": {"name": product["name"]},
                "unit_amount": product["price"],
            },
            "quantity": quantity,
        }]
    if customer_email:
        session_params["customer_email"] = customer_email

    session = stripe.checkout.Session.create(**session_params)
    return f"Checkout URL: {session.url}\nSession ID: {session.id}\n(Session expires in 24 hours)"

@mcp.tool()
async def get_order_status(session_id: str) -> str:
    """Get the status of a Stripe Checkout Session."""
    session = stripe.checkout.Session.retrieve(session_id)
    return (
        f"Session: {session.id}\n"
        f"Status: {session.status}\n"
        f"Payment status: {session.payment_status}\n"
        f"Amount total: ${session.amount_total / 100:.2f} {session.currency.upper()}"
    )

@mcp.tool()
async def request_return(payment_intent_id: str, amount_cents: int = None, reason: str = "requested_by_customer") -> str:
    """Initiate a refund for a completed order."""
    refund_params = {"payment_intent": payment_intent_id, "reason": reason}
    if amount_cents:
        refund_params["amount"] = amount_cents
    refund = stripe.Refund.create(**refund_params)
    return f"Refund ID: {refund.id}\nStatus: {refund.status}\nAmount: ${refund.amount / 100:.2f}"

def main():
    mcp.run(transport="stdio")

if __name__ == "__main__":
    main()
JSON · Claude Desktop config
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "sanity-commerce": {
      "command": "uv",
      "args": ["--directory", "/path/to/sanity-mcp", "run", "sanity_mcp_server.py"],
      "env": {
        "SANITY_PROJECT_ID": "your-project-id",
        "SANITY_DATASET": "production",
        "SANITY_READ_TOKEN": "sk...",
        "STRIPE_SECRET_KEY": "sk_live_...",
        "STRIPE_SUCCESS_URL": "https://yourstore.com/checkout/success",
        "STRIPE_CANCEL_URL": "https://yourstore.com/cart"
      }
    }
  }
}

Contentful MCP server (Python · FastMCP)

Python · contentful_mcp_server.py
# contentful_mcp_server.py
# Requirements: pip install "mcp[cli]" httpx stripe
import os
import httpx
import stripe
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("contentful-commerce")

SPACE_ID = os.environ["CONTENTFUL_SPACE_ID"]
CDA_TOKEN = os.environ["CONTENTFUL_CDA_TOKEN"]
ENVIRONMENT = os.environ.get("CONTENTFUL_ENVIRONMENT", "master")
CDA_BASE = f"https://cdn.contentful.com/spaces/{SPACE_ID}/environments/{ENVIRONMENT}"

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
STRIPE_SUCCESS_URL = os.environ.get("STRIPE_SUCCESS_URL", "https://yourstore.com/checkout/success")
STRIPE_CANCEL_URL = os.environ.get("STRIPE_CANCEL_URL", "https://yourstore.com/cart")

async def cda_get(path: str, params: dict = None) -> dict:
    """GET request to the Contentful CDA."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"{CDA_BASE}{path}",
            params=params or {},
            headers={"Authorization": f"Bearer {CDA_TOKEN}"},
            timeout=10.0,
        )
        resp.raise_for_status()
        return resp.json()

def parse_entry(entry: dict) -> dict:
    """Flatten Contentful entry fields."""
    fields = entry.get("fields", {})
    return {
        "id": entry["sys"]["id"],
        "productId": fields.get("productId", ""),
        "name": fields.get("name", ""),
        "slug": fields.get("slug", ""),
        "description": fields.get("description", ""),
        "brand": fields.get("brand", ""),
        "price": fields.get("price", 0),
        "currency": fields.get("currency", "USD"),
        "availability": fields.get("availability", "InStock"),
        "inventoryQuantity": fields.get("inventoryQuantity", 0),
        "stripePriceId": fields.get("stripePriceId", ""),
    }

@mcp.tool()
async def list_products(limit: int = 20, skip: int = 0) -> str:
    """List available products from the Contentful catalog."""
    data = await cda_get("/entries", {
        "content_type": "product",
        "fields.availability": "InStock",
        "select": "fields.productId,fields.name,fields.slug,fields.price,fields.currency,fields.availability,fields.inventoryQuantity,fields.stripePriceId",
        "limit": min(limit, 100),
        "skip": skip,
    })
    items = data.get("items", [])
    if not items:
        return "No products found."
    lines = []
    for entry in items:
        p = parse_entry(entry)
        lines.append(f"- {p['name']} (SKU: {p['productId']}) — ${p['price'] / 100:.2f} {p['currency']} — Stock: {p['inventoryQuantity']} — slug: {p['slug']}")
    return "\n".join(lines)

@mcp.tool()
async def initiate_checkout(slug: str, quantity: int = 1, customer_email: str = None) -> str:
    """Create a Stripe Checkout Session for a Contentful product."""
    data = await cda_get("/entries", {"content_type": "product", "fields.slug": slug, "limit": 1})
    items = data.get("items", [])
    if not items:
        return f"Product '{slug}' not found."
    p = parse_entry(items[0])
    if p["availability"] not in ("InStock", "PreOrder"):
        return f"Product not available (status: {p['availability']})."

    session_params = {
        "mode": "payment",
        "success_url": STRIPE_SUCCESS_URL + "?session_id={CHECKOUT_SESSION_ID}",
        "cancel_url": STRIPE_CANCEL_URL,
        "automatic_tax": {"enabled": True},
        "shipping_address_collection": {"allowed_countries": ["US", "CA", "GB"]},
        "metadata": {"cms_product_id": slug, "source": "mcp_agent", "cms": "contentful"},
    }
    if p.get("stripePriceId"):
        session_params["line_items"] = [{"price": p["stripePriceId"], "quantity": quantity}]
    else:
        session_params["line_items"] = [{
            "price_data": {"currency": p["currency"].lower(), "product_data": {"name": p["name"]}, "unit_amount": p["price"]},
            "quantity": quantity,
        }]
    if customer_email:
        session_params["customer_email"] = customer_email
    session = stripe.checkout.Session.create(**session_params)
    return f"Checkout URL: {session.url}\nSession ID: {session.id}"

# get_product, search_products, check_inventory, calculate_total, get_order_status, request_return
# follow the same pattern: cda_get() with the appropriate filter, parse_entry(), Stripe call.
# Full source available in the build kit.

def main():
    mcp.run(transport="stdio")

if __name__ == "__main__":
    main()

Strapi MCP server (Python · FastMCP)

Python · strapi_mcp_server.py
# strapi_mcp_server.py
# Requirements: pip install "mcp[cli]" httpx stripe
import os
import httpx
import stripe
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("strapi-commerce")

STRAPI_BASE = os.environ.get("STRAPI_BASE_URL", "http://localhost:1337")
STRAPI_TOKEN = os.environ["STRAPI_API_TOKEN"]
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
STRIPE_SUCCESS_URL = os.environ.get("STRIPE_SUCCESS_URL", "https://yourstore.com/checkout/success")
STRIPE_CANCEL_URL = os.environ.get("STRIPE_CANCEL_URL", "https://yourstore.com/cart")

async def strapi_get(path: str, params: dict = None) -> dict:
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"{STRAPI_BASE}{path}",
            params=params or {},
            headers={"Authorization": f"Bearer {STRAPI_TOKEN}"},
            timeout=10.0,
        )
        resp.raise_for_status()
        return resp.json()

def parse_product(item: dict) -> dict:
    # Strapi 5 flat response format — attributes at top level of data item
    return {
        "documentId": item.get("documentId", ""),
        "productId": item.get("productId", ""),
        "name": item.get("name", ""),
        "slug": item.get("slug", ""),
        "description": item.get("description", ""),
        "brand": item.get("brand", ""),
        "price": item.get("price", 0),
        "currency": item.get("currency", "USD"),
        "availability": item.get("availability", "InStock"),
        "inventoryQuantity": item.get("inventoryQuantity", 0),
        "stripePriceId": item.get("stripePriceId", ""),
    }

@mcp.tool()
async def list_products(page: int = 1, page_size: int = 20) -> str:
    """List available products from the Strapi catalog."""
    data = await strapi_get("/api/products", {
        "filters[availability][$eq]": "InStock",
        "filters[publishedAt][$notNull]": "true",
        "fields[0]": "productId", "fields[1]": "name", "fields[2]": "slug",
        "fields[3]": "price", "fields[4]": "currency",
        "fields[5]": "availability", "fields[6]": "inventoryQuantity",
        "fields[7]": "stripePriceId",
        "pagination[page]": page,
        "pagination[pageSize]": min(page_size, 100),
    })
    items = data.get("data", [])
    if not items:
        return "No products found."
    lines = []
    for item in items:
        p = parse_product(item)
        lines.append(f"- {p['name']} (SKU: {p['productId']}) — ${p['price'] / 100:.2f} {p['currency']} — Stock: {p['inventoryQuantity']} — slug: {p['slug']}")
    return "\n".join(lines)

@mcp.tool()
async def initiate_checkout(slug: str, quantity: int = 1, customer_email: str = None) -> str:
    """Create a Stripe Checkout Session for a Strapi product."""
    data = await strapi_get("/api/products", {"filters[slug][$eq]": slug, "pagination[limit]": 1})
    items = data.get("data", [])
    if not items:
        return f"Product '{slug}' not found."
    p = parse_product(items[0])
    if p["availability"] not in ("InStock", "PreOrder"):
        return f"Product not available (status: {p['availability']})."
    session_params = {
        "mode": "payment",
        "success_url": STRIPE_SUCCESS_URL + "?session_id={CHECKOUT_SESSION_ID}",
        "cancel_url": STRIPE_CANCEL_URL,
        "automatic_tax": {"enabled": True},
        "shipping_address_collection": {"allowed_countries": ["US", "CA", "GB"]},
        "metadata": {"cms_product_id": slug, "source": "mcp_agent", "cms": "strapi"},
    }
    if p.get("stripePriceId"):
        session_params["line_items"] = [{"price": p["stripePriceId"], "quantity": quantity}]
    else:
        session_params["line_items"] = [{
            "price_data": {"currency": p["currency"].lower(), "product_data": {"name": p["name"]}, "unit_amount": p["price"]},
            "quantity": quantity,
        }]
    if customer_email:
        session_params["customer_email"] = customer_email
    session = stripe.checkout.Session.create(**session_params)
    return f"Checkout URL: {session.url}\nSession ID: {session.id}"

# get_product, search_products, check_inventory, calculate_total, get_order_status, request_return
# follow the same pattern. Full source available in the build kit.

def main():
    mcp.run(transport="stdio")

if __name__ == "__main__":
    main()

MCP deployment patterns

PatternStackUse Case
Local STDIOClaude Desktop + uvDevelopment, single-merchant, small team
Vercel Edge FunctionsNext.js API Route + SanityProduction, Sanity-fronted sites, SSR
Cloudflare WorkersWrangler + any CMSEdge-deployed, low-latency, global
Fly.ioDocker + Python serverStateful, persistent connections, Strapi self-hosted
§5 · Layer 4 · UCP Checkout

Layer 4 — Stripe is the canonical UCP partner.

In a headless stack with Stripe, you are the merchant of record and the integration owner. No platform takes a commerce fee. No storefront API wraps your payment flow. You call stripe.checkout.Session.create() directly from your MCP tool or your Next.js API route. You control what goes into line_items, whether you use Stripe Tax (automatic_tax: enabled), which shipping rates appear, and what metadata you attach. This is the cleanest possible UCP-compatible flow.

8-step UCP with Stripe + Headless

StepActionImplementation
1InitiateAgent calls initiate_checkout(slug, quantity) → MCP server creates POST /v1/checkout/sessions with mode=payment, automatic_tax, shipping_address_collection
2Price confirmRetrieve session: stripe.checkout.Session.retrieve(session_id) → confirm amount_total, currency, line_items
3Shipping & taxStripe calculates automatically via automatic_tax: {enabled: true} and presented shipping_options. Merchant configures tax in Stripe Dashboard.
4Payment authCustomer completes Stripe-hosted (or embedded) payment page — Stripe validates card, runs fraud detection, confirms payment method
5Payment executeStripe charges card, creates PaymentIntent with status succeeded
6Webhook receiptStripe sends checkout.session.completed to your webhook. Handler verifies signature, reads session.metadata.cms_product_id, updates inventory in CMS
7Post-purchase fulfillmentWebhook handler triggers fulfillment: email via SendGrid/Resend, update inventoryQuantity in CMS via Management API or Strapi REST write, create order record
8Return / refundAgent calls request_return(payment_intent_id) → MCP server calls stripe.Refund.create()

Stripe Checkout Session — full create example

Python · Stripe Checkout
import stripe
import os

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

# product data fetched from your CMS
product_name = "Blue Widget Pro"
unit_amount_cents = 4999  # $49.99
stripe_price_id = "price_1OZxxxxxxxxxx"  # pre-created Stripe Price

session = stripe.checkout.Session.create(
    mode="payment",
    success_url="https://yourstore.com/checkout/success?session_id={CHECKOUT_SESSION_ID}",
    cancel_url="https://yourstore.com/cart",
    line_items=[
        {
            # Option A: reference a pre-created Stripe Price (recommended for recurring SKUs)
            "price": stripe_price_id,
            "quantity": 2,
        }
        # Option B: ad-hoc price_data
        # {
        #     "price_data": {
        #         "currency": "usd",
        #         "product_data": {"name": product_name},
        #         "unit_amount": unit_amount_cents,
        #     },
        #     "quantity": 2,
        # }
    ],
    automatic_tax={"enabled": True},
    shipping_address_collection={"allowed_countries": ["US", "CA", "GB", "AU"]},
    shipping_options=[
        {
            "shipping_rate_data": {
                "type": "fixed_amount",
                "fixed_amount": {"amount": 0, "currency": "usd"},
                "display_name": "Standard Shipping (5–7 business days)",
                "delivery_estimate": {
                    "minimum": {"unit": "business_day", "value": 5},
                    "maximum": {"unit": "business_day", "value": 7},
                },
            }
        },
        {
            "shipping_rate_data": {
                "type": "fixed_amount",
                "fixed_amount": {"amount": 1500, "currency": "usd"},
                "display_name": "Expedited Shipping (2 business days)",
                "delivery_estimate": {
                    "minimum": {"unit": "business_day", "value": 1},
                    "maximum": {"unit": "business_day", "value": 2},
                },
            }
        },
    ],
    allow_promotion_codes=True,
    metadata={
        "cms_product_id": "blue-widget-pro",
        "source": "mcp_agent",
        "cms": "sanity",  # or contentful / strapi
    },
)

print(f"Checkout URL: {session.url}")
print(f"Session ID: {session.id}")

Stripe webhook handler (Next.js App Router · TypeScript)

This is the half of UCP that closes the loop: when checkout.session.completed fires, decrement inventoryQuantity in the CMS so the next agent that calls check_inventory sees the truth.

TypeScript · app/api/webhooks/stripe/route.ts
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@sanity/client'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

const sanity = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  token: process.env.SANITY_WRITE_TOKEN!,
  apiVersion: '2024-01-01',
  useCdn: false,
})

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const slug = session.metadata?.cms_product_id

    if (slug) {
      // Decrement inventory in Sanity
      const product = await sanity.fetch(
        '*[_type == "product" && slug.current == $slug][0]{_id, inventoryQuantity}',
        { slug }
      )
      if (product && product.inventoryQuantity > 0) {
        const lineItems = await stripe.checkout.Session.listLineItems(session.id)
        const qty = lineItems.data[0]?.quantity ?? 1
        await sanity
          .patch(product._id)
          .dec({ inventoryQuantity: qty })
          .commit()
      }
    }
  }

  return NextResponse.json({ received: true })
}

For Contentful, swap the Sanity client for the Contentful Management API (PUT /spaces/{id}/entries/{entryId}). For Strapi, swap to PUT /api/products/{documentId} with the API token.

§6 · Three-Way Comparison

Sanity vs. Contentful vs. Strapi — dev ex, agent-readiness, pricing.

Developer experience

DimensionSanityContentfulStrapi
Schema definitionTypeScript (defineType/defineField), version-controlledJSON via CMA or web UI, less portableJSON file in repo, fully version-controlled
Query languageGROQ (powerful, Sanity-native, learning curve)REST/GraphQL with CDA (standard, widely documented)REST + GraphQL, standard, familiar
Studio / admin UISanity Studio (React, highly customizable, open-source)Contentful web app (polished, less extensible)Strapi admin panel (full-featured, open-source, self-hosted)
Local dev setupsanity dev — Studio runs locally, cloud APINo true local: requires cloud API alwaysyarn develop — fully local including DB
Next.js integrationFirst-class: @sanity/next-sanity, live previewGood: contentful SDK, Preview APIGood: via REST/GraphQL, no official Next.js package
TypeScript supportExcellent (schema-dts, @sanity/types)GoodGood (Strapi 5 improved TS support)

Agent-readiness tradeoffs

FactorSanityContentfulStrapi
Schema controlFull (TS file)Full (JSON/CMA)Full (JSON file in repo)
MCP build complexityLow — GROQ is expressiveLow — CDA is RESTLow — REST is universal
API stability for agentsHigh — GROQ endpoint stableHigh — CDA endpoint stableHigh (self-hosted) / Medium (Strapi Cloud)
Rate limits (free tier)250K API req/mo + 1M CDN/mo100K API calls/moSelf-hosted: unlimited; Cloud Free: 2.5K/mo
Real-time / webhooksGROQ Listen API (streaming)Webhook on content eventsStrapi webhooks
Data residencyUS-only (Sanity cloud)US/EUYour server (self-hosted) or Strapi Cloud regions

Pricing summary

CMSFree TierFirst Paid TierNotes
Sanity$0 — 20 seats, 10K docs, 250K API req/mo, 1M CDN req/mo (re-verify)$15/seat/month (Growth) — 25K docs (re-verify)Pay-as-you-go overages; Enterprise custom
Contentful$0 — 10 users, 100K API calls/mo, 1 Space (re-verify)$300/month (Lite) — 20 users, 1M API calls/mo (re-verify)Premium: custom enterprise pricing
Strapi (self-hosted OSS)$0 — unlimited everything (you host)$0 (always free for self-hosted)You pay hosting costs (~$5–40/mo VPS)
Strapi Cloud$0 — 500 entries, 2.5K API req/mo (re-verify)$18/project/month (Essential) — unlimited entries, 50K req/mo (re-verify)Pro: $90/mo (1M req); Scale: $450/mo (10M req)
StripePay-per-transaction only2.9% + $0.30 per successful US-domestic card charge (re-verify)No platform fee on top — Stripe is the entire commerce stack

(All pricing and rate limits flagged · Re-verify before launch — sources: sanity.io/pricing, contentful.com/pricing, strapi.io/pricing-cloud, stripe.com/pricing.)

When to choose each

Sanity when: your team is React/Next.js native, you want the most expressive query language (GROQ), you need real-time collaborative editing in Studio, and you're comfortable with cloud-only data storage (Sanity Content Lake). Best for small-to-medium dev teams building modern storefronts.

Contentful when: you're in an enterprise environment with multiple editors, content governance and role-based access are priorities, you need proven SLAs and enterprise support, and your team is already familiar with Contentful from a previous project. Best for larger orgs where content strategy and commerce overlap.

Strapi when: full data ownership is non-negotiable (GDPR, data residency, cost control), you want zero per-seat licensing costs for self-hosted, your team is comfortable running Node.js infrastructure, or you're cost-optimizing and want to host on a $20/month VPS. Best for teams who prioritize sovereignty over managed convenience.

§7 · Tradeoffs

What headless makes easy — and what it makes hard.

Easy · Schema

You add fields without permission

sustainability_certifications, agent_description, negotiable_price — any field an AI agent might need, on day one. No platform plugin marketplace.

Easy · API

API surface is yours

GROQ queries and REST endpoints don't change unless you change them. No platform-imposed breaking changes in a minor release.

Easy · MCP

One server, three data sources, eight tools

No marketplace app compatibility matrix. No platform-managed tool registry. Your MCP server is the integration layer.

Easy · Checkout

Pluggable commerce engine

Swap Stripe for Adyen, Braintree, or Paddle without touching the CMS. Zero platform revenue share — Stripe 2.9% + $0.30 only (re-verify).

Hard · Build

Everything is yours to ship

Product page, cart, checkout, order confirmation, inventory management, webhook handlers — all your build and all your maintenance.

Hard · Cost

Infra adds up

Vercel + CMS plan + Stripe fees + database (if you add one) + monitoring. Estimate $100–500/month for production before CMS plan (re-verify).

Hard · Discipline

Schema drift breaks MCP outputs

Different field names in dev vs production produce nulls in production MCP tools. Treat schema changes as code releases.

Hard · Security

Token rotation is on you

CMS read token, CMS write token, Stripe secret key, Stripe webhook secret — four secrets minimum. Leaked tokens are your problem alone.

§8 · Test It with a Real Agent

Connect Claude Desktop, run the real flow.

Once the MCP server is registered in claude_desktop_config.json and Claude Desktop is restarted, Claude sees your eight tools. Open a new conversation:

  1. Install the MCP server for your chosen CMS (see server code above).
  2. Add to claude_desktop_config.json with correct env vars.
  3. Restart Claude Desktop.
  4. Open a new conversation. Claude will see your MCP tools.

Sample agent session (Sanity)

Prompt: "What products do you have under $50? I want to buy 2 of the cheapest one."

Agent calls: list_products(limit=20)

JSON · GROQ result
{
  "result": [
    {
      "productId": "WDGT-001",
      "name": "Blue Widget Standard",
      "slug": "blue-widget-standard",
      "price": 2999,
      "currency": "USD",
      "availability": "InStock",
      "inventoryQuantity": 47,
      "stripePriceId": "price_1OZabc123"
    },
    {
      "productId": "WDGT-002",
      "name": "Blue Widget Pro",
      "slug": "blue-widget-pro",
      "price": 4999,
      "currency": "USD",
      "availability": "InStock",
      "inventoryQuantity": 12,
      "stripePriceId": "price_1OZdef456"
    }
  ]
}

Agent calls: calculate_total(slug="blue-widget-standard", quantity=2)Blue Widget Standard × 2 = $59.98 USD (Tax/shipping at checkout)

Agent calls: initiate_checkout(slug="blue-widget-standard", quantity=2)Checkout URL: https://checkout.stripe.com/c/pay/cs_live_…

Agent responds: "I found Blue Widget Standard at $29.99 each. Your total before tax and shipping is $59.98. Here's your checkout link: [Stripe URL]. Complete payment there — Stripe will calculate tax and shipping for your address."

Customer follows the URL → Stripe Checkout → enters shipping address → Stripe calculates tax automatically → customer pays → checkout.session.completed webhook fires → your Next.js handler decrements inventoryQuantity in Sanity by 2 → fulfillment triggered.

§9 · Common Mistakes (with Fixes)

What goes wrong on the first headless launch — and how to fix it.

1. Storing prices as floating-point decimals

price: 49.99 causes rounding errors in Stripe. Fix: store as integer cents (price: 4999). Convert on display: (price / 100).toFixed(2).

2. Not storing stripePriceId on the product

Teams create Stripe Prices separately and lose the mapping. Fix: add stripePriceId as a required field in the CMS schema. Make it part of the 20-field MVP, not an afterthought.

3. Using public Sanity datasets without CDN for MCP queries

MCP tools calling the non-CDN endpoint on every agent request burns through the 250K/month quota. Fix: use the CDN endpoint (apicdn.sanity.io) for read queries. Use non-CDN only for mutations and draft previews.

4. Not verifying Stripe webhook signatures

Teams skip stripe.webhooks.constructEvent() in development and forget to re-add it in production. Fix: verify the stripe-signature header on every webhook. Unverified webhooks are a fraud vector.

5. Schema drift between dev and production datasets

Adding a field in dev but not production means MCP tools return null for that field in production. Fix: use Sanity's schema migration tooling (@sanity/migrate) — or Contentful CLI / Strapi schema sync — and treat schema changes as code releases.

6. Contentful rate-limit surprises on launch day

The free tier (100K API calls/month) and Lite tier (1M/month) hit fast under traffic (re-verify). Fix: enable CDN caching — CDA responses cached at the CDN don't count against rate limits. Design queries to maximize cache hits (consistent params).

7. Strapi Cloud Free-tier API limits blocking MCP agents

2.5K API requests/month on the free tier is exhausted by a handful of agent sessions (re-verify). Fix: self-host Strapi on a $10/month VPS for unlimited requests, or upgrade to Essential ($18/month, 50K/month).

8. Not handling OutOfStock in MCP initiate_checkout

Agents will check out OOS products if the MCP tool doesn't gate on availability and inventoryQuantity before calling Stripe. Fix: add an inventory guard in initiate_checkout (as shown in the server code above) that returns a clean error string before creating the Stripe session.

§10 · FAQ

Frequently asked questions.

Do I need Sanity Connect for Shopify if I'm going fully headless?

No. Sanity Connect for Shopify is for hybrid scenarios — syncing Shopify product data into Sanity, or using Sanity for editorial content while Shopify handles checkout. In a fully headless stack where Stripe is the commerce engine, you define the product schema directly in Sanity and call Stripe from your MCP server. Sanity Connect for Shopify is currently active and available on the Shopify App Store (re-verify before launch), but it is not required for a Stripe-native headless stack.

What is the cheapest production-ready stack?

Strapi self-hosted (free OSS license) on a $10–20/month VPS (Railway, Fly.io, DigitalOcean Droplet) + Stripe (2.9% + $0.30 per transaction) + Vercel Hobby or Pro for the Next.js frontend. Total fixed cost: ~$20–50/month before transaction fees. No CMS licensing cost.

Can I use GraphQL with any of these CMSes in my MCP server?

Yes. Strapi exposes GraphQL at /graphql (enable via plugin: @strapi/plugin-graphql). Contentful's GraphQL Content API is available on all plans at https://graphql.contentful.com/content/v1/spaces/{spaceId}. Sanity's GROQ endpoint is the native choice, but the community @sanity/groq-js library also supports local GROQ execution. The Python MCP servers in this doc use REST/GROQ for simplicity, but GraphQL works identically — swap httpx.get() for a GraphQL query.

How do I handle product variants (size, color) in this schema?

The 20-field MVP handles flat products. For variants, extend the Sanity schema with a variants array of objects (each with sku, price, inventoryQuantity, attributes, stripePriceId). In Contentful, create a productVariant content type and reference it from the product entry. In Strapi, add a variants component or relation. Each variant gets its own stripePriceId. The MCP initiate_checkout tool accepts a variant_id parameter and uses the variant's Stripe Price ID.

What is the rate limit for Contentful's Content Delivery API in practice?

The CDA has a standard rate limit of 55 requests per second for non-cached requests. CDN cache hits (responses served from Contentful's CDN) are unlimited and do not count against this limit. On the Free tier, the monthly cap is 100K API calls. On Lite, it's 1M/month. Design your MCP server to use CDN-friendly requests (GET with consistent query params) to maximize cache efficiency (re-verify current limits before launch at contentful.com/developers/docs).

How do I sync order data back to the CMS after a Stripe payment?

Use Stripe webhooks. When checkout.session.completed fires, your webhook handler (Next.js API route or standalone server) reads session.metadata to identify the product, then uses the CMS write API to update inventory: Sanity Management API (sanity.patch(id).dec({inventoryQuantity: qty}).commit()), Contentful Management API (PUT /spaces/{id}/entries/{entryId}), or Strapi REST (PUT /api/products/{documentId}). For orders as a standalone record, write to a separate CMS collection or database.

Is there an official MCP server for any of these CMSes?

As of mid-2026, there is no official Sanity, Contentful, or Strapi MCP server for commerce use cases (re-verify before launch). Sanity and Contentful have released general-purpose MCP servers for content management (re-verify status), but commerce-specific tooling — product listing, inventory checking, Stripe checkout initiation — is custom built, as shown in this document. That is the point: the headless merchant owns this layer.

How do Claude, ChatGPT Operator, and Perplexity Comet interact with this MCP server differently?

Claude Desktop connects via STDIO (local process) using the claude_desktop_config.json config. Claude.ai's web interface can connect to remote MCP servers over HTTP/SSE. ChatGPT Operator (OpenAI) uses OpenAI's Actions / tool-calling framework — not MCP directly; you'd expose the same 8 tools as OpenAI function schemas via a REST API wrapper. Perplexity Comet (re-verify availability and MCP support status before launch) is expected to support MCP. Gemini Search Agents consume structured data primarily via Schema.org JSON-LD on the frontend, not via MCP. The cleanest approach: build the MCP server for Claude/MCP clients, expose a parallel REST API for OpenAI Actions, and ensure JSON-LD is correct for Gemini/Google AI Overviews.

§11 · Step-by-Step

The headless build, in five steps.

Each step mirrors the HowTo JSON-LD at the top of this page word for word. Execute in order. Budget two to four weeks for a green-field build, depending on starting point and team size.

Step 1 — Define and deploy your product schema (Sanity TS, Contentful JSON, or Strapi schema.json)

Choose your CMS. Copy the 20-field MVP schema from this document (Sanity TypeScript, Contentful JSON import, or Strapi schema.json). Add stripePriceId as required. Deploy schema changes. Populate at least 5–10 products with complete data including inventoryQuantity, availability, and stripePriceId. Validate by querying the API directly (curl examples above).

Step 2 — Build the Next.js frontend with JSON-LD

Copy the app/products/[slug]/page.tsx component from Layer 1. Replace the Sanity getProduct() fetch with your CMS's fetch function if using Contentful or Strapi. Deploy to Vercel (or your host). Validate JSON-LD output using Google's Rich Results Test (https://search.google.com/test/rich-results) and Schema Markup Validator (https://validator.schema.org/).

Step 3 — Set up the MCP server for your CMS

Copy the appropriate Python server (sanity_mcp_server.py, contentful_mcp_server.py, or strapi_mcp_server.py). Install dependencies: uv add "mcp[cli]" httpx stripe. Set environment variables (CMS token, Stripe secret key, success/cancel URLs). Test locally: run uv run sanity_mcp_server.py and verify it starts without errors. Configure Claude Desktop via claude_desktop_config.json.

Step 4 — Configure Stripe and set up webhooks

Create a Stripe account if needed. For each product in your CMS with a stripePriceId, create a corresponding Stripe Price object in the Stripe Dashboard (or via stripe.Price.create()). Copy the Price ID back to the CMS field. Set up a Stripe webhook endpoint pointing to your Next.js /api/webhooks/stripe route. Add the webhook secret as STRIPE_WEBHOOK_SECRET env var. Test the webhook with Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe.

Step 5 — Test end-to-end with a real agent session

Open Claude Desktop. Ask: "What products do you have in stock under $50?" — verify list_products returns correct data. Ask to buy one — verify initiate_checkout returns a Stripe URL. Open the URL in a browser, complete checkout with Stripe's test card (4242 4242 4242 4242). Verify the webhook fires and inventoryQuantity decrements in the CMS. Ask for order status using the session ID — verify get_order_status returns payment_status: paid. Ship.

§12 · Continue the Guide

Next stops in the AgentMall guide.

Platform Spoke

Shopify — The Native UCP Path

The four-layer build on Shopify: Schema.org metafields, the native /api/mcp endpoint, the Storefront MCP server, and the UCP / Checkout MCP preview. The fastest agent-ready stack if you're already on Shopify.

Platform Spoke

WooCommerce — Plugin-Driven Agent Readiness

How to wire WooCommerce + WordPress into the same four-layer stack using Schema Pro, Action Scheduler, REST API, and a custom MCP wrapper.

Layer 3 · Protocol

MCP — Model Context Protocol

Tool schemas, JSON-RPC envelope, transports, and the framework you implement once and reuse across Claude, ChatGPT, Cursor, and any future agent runtime.

Layer 4 · Protocol

UCP — Universal Commerce Protocol

The eight-step state machine, agent profile schema, and the cross-platform contract that powers ChatGPT, Copilot, and Google AI Mode checkouts.

Layer 1 · Reference

Product Data — The 20-Field MVP

Field-by-field reference for the Schema.org block that gets you discoverable across every agent runtime, with mapping tables for Shopify, WooCommerce, and BigCommerce.

Layer 2 · Build

API Endpoint — The Eight-Endpoint Surface

The framework-agnostic, OpenAPI-driven endpoint pattern. FastAPI + Vercel reference implementation. Drop-in for any non-Shopify stack — including the headless builds in this guide.

Stack

The Agent-Commerce Stack

End-to-end reference stack — storefront, schema, API, MCP, UCP, observability — for operators building greenfield.

Stack

Free vs Paid Stack Choices

Where the free tiers run out and which paid services are worth the upgrade for a real production agent integration.

Market

The Agent Commerce Market

TAM, channel mix, and the buyer-side data that lets you size the agent-commerce opportunity before you ship a single tool.

Tools

Picks & Shovels

The cross-platform tools (review collectors, schema validators, MCP scaffolds, monitoring) that work the same whether you are on Shopify, Woo, BigCommerce, or custom.

Pillar

The Full AgentMall Roadmap

The pillar page that ties the four layers and every platform spoke together into one 30-day operator plan.

The Window

The window for agent-ready headless commerce is now.

Google + Shopify co-launched UCP at NRF on January 11, 2026 (re-verify per plan). The protocol is platform-agnostic by design — Sanity, Contentful, and Strapi merchants who wire up the four layers this quarter will share catalog placement, trusted-checkout status, and operator-class agent telemetry with the Shopify natives. The official commerce-grade MCP servers for these CMSes have not shipped (re-verify); the merchants who build their own now will own the integration pattern when the official servers arrive. Every quarter you delay, the floor moves up — schema fields once treated as "nice to have" become disqualifying, the agent tool surface gets richer, and the buyers experimenting in ChatGPT and Google AI Mode become habitual agent shoppers. Headless is the harder build, but it is also the build where you own every primitive. Ship the four layers this weekend.

Open the AgentMall Roadmap →

One AgentMall note per week.

Platform-specific weekend builds, real failure modes from operator logs, and the next spoke the morning it ships. No fluff. Cancel any time.