Crystallize logo

A shopper MCP server on the Discovery and Shop APIs

A Model Context Protocol server lets remote AI assistants search your catalogue, read products, build a basket and hand it to the shopper. This page shows how to build one inside the storefront: a stateless endpoint, the tool set, server instructions, typed-spec search, baskets without sessions, and OAuth sign-in for personal tools.

This is a server for shoppers' assistants. It is a different thing from the Crystallize MCP server, which gives developers and editors access to a tenant. A shopper MCP server exposes only what a customer may do in the store.

A stateless endpoint in the storefront

Put the endpoint inside the storefront (for example app/api/mcp/route.ts), so it shares the storefront's Discovery and Shop API code and deploys with it. Use Streamable HTTP in stateless mode: every request builds a fresh server, so it runs on serverless functions without sessions.

import { createMcpHandler } from '@modelcontextprotocol/server';
import { buildServer } from '@/lib/mcp/server';

export const maxDuration = 60;

const handler = createMcpHandler((ctx) => buildServer({ authInfo: ctx.authInfo, site: siteUrl(ctx.requestInfo) }), {
  onerror: (error) => console.error('[mcp]', error),
});

Answer CORS preflights for the MCP headers (Mcp-Session-Id, MCP-Protocol-Version, Authorization) and expose WWW-Authenticate, so browser-based hosts can connect.

The tools

Every tool takes a lang argument that sets the market: currency, price variant, VAT rate, product names and links. A typical set:

Tool

What it does

Built on

search_products

Free text plus structured filters (category, brand, specs, price, stock); returns cards, facets and suggestions

Discovery browse / search

show_products

Cards for a list of SKUs, in order: the model's own shortlist

Discovery by SKU

get_product

Every variant with SKU, price and stock, the spec sheet, what is in the box

Discovery by path or SKU

similar_products

Nearest neighbours

Discovery nearestTo

list_categories, list_brands

The vocabulary, with counts and ids the other tools accept

Discovery facets

add_to_cart, update_cart_line, view_cart

The basket, by cartId

Shop API hydrate

checkout

Places the order, within limits

Shop API place, createFromCart

whoami, my_orders, recommendations

Personal tools, after sign-in

Shop API orders, Discovery with taste

Annotate every tool: readOnlyHint: true on reads, idempotentHint where it holds, and destructiveHint: true on checkout.

server.registerTool(
  'checkout',
  {
    title: 'Place order',
    description: 'Place the basket as an order. Prefer giving the user the checkoutUrl unless they explicitly ask you to order now.',
    inputSchema: z.object({
      cartId: z.string().uuid(),
      email: z.string().email().optional().describe('Guest checkout only.'),
      reference: z.string().max(80).optional().describe('Order reference / PO number.'),
      lang: langArg,
    }),
    annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
  },
  async ({ cartId, email, reference, lang }) => placeOrder(await ctxFor(lang), cartId, { email, reference }),
);

Server instructions

The server's instructions are read by the model on connect. Use them for what the tool descriptions can't say:

  • What the store sells, its markets and how to choose lang.
  • Show products only through tool calls. "Never answer a product question with a prose list alone. Never build product or cart links yourself." A model that writes its own list repeats stale prices and invents URLs.
  • On hosts that render cards, the reply adds one or two sentences (the pick and why) and doesn't repeat names, prices or stock.
  • When results carry "Did you mean" suggestions and the question was vague, offer two to four of them as a one-line question.
  • Pass cartId from add_to_cart to every later basket call, and give the shopper the checkout link.

Search on typed specs

Shoppers ask in their own words: "18V impact driver over 200 Nm", "125 mm angle grinder", "M class dust extractor". Turn the words into Discovery filters before you search, using the content model rather than a word list:

  • Numbers with units go to the fields that carry that unit. When several fields do (mm, kg, Nm), field-label words in the question decide, then the spec families of the product type it names.
  • A bare value is a narrow band (about ±3 %), so "18 V" doesn't match 20 V. Comparators ("over", "at least", "under") become bounds.
  • Option and yes/no labels match in every content language ("SDS-plus", "brushless", "with hammer mode"). Negations ("without Bluetooth") flip the value.
  • Brands, product types and platforms come from Discovery facets in each language, so translated names work.
  • Whatever is left goes to Discovery's fuzzy text search. If the leftover words sink a filtered search to zero, rerun it without them and say so in the result.

Typed specs appear in Discovery as filter fields named after the component path, such as power_voltage_number for a numeric and one field per selection option. Generate the parser's field list from your content model, and offer a structured specs argument too, so a model can be explicit:

{ "specs": [{ "field": "drilling.torqueHard", "gte": 60 }, { "field": "power.voltage", "equals": 18 }] }

Did you mean

Add up to four suggestions to a result, each with the exact arguments to rerun: a misspelt product type or brand, a relaxed bound when nothing matched, or the facets that split a broad result. The model offers them as a short question instead of guessing.

Baskets without sessions

The server keeps no state. add_to_cart creates a Shop API cart when no cartId is given and returns its id, and every later basket call passes it back. The cart lives in the Shop API like any other.

Give the shopper a way into the browser:

  • A checkout link such as /mcp/cart?id=… that adopts the conversation's cart in this browser (sets the cart cookie) and opens it. When the conversation was signed in, add a signed, short-lived note of who the shopper is, so the cart keeps their prices.
  • A product link such as /mcp/buy?sku=… that adds one variant to the browser's own cart.
  • A view link such as /mcp/view?skus=… that shows the products as the store's own cards, for hosts that can't render cards.

Keep these routes out of the sitemap and disallow them in robots.txt.

Sign-in with OAuth

Public tools (search, product, basket) need no account. Personal tools (order history, personal ranking, trade prices) need the shopper to sign in. MCP uses OAuth 2.1:

  • Authenticate lazily. initialize, tools/list and public tools never need a token. For a protected tool without one, answer HTTP 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/api/mcp". Only a transport-level 401 makes the host start sign-in, and a tool handler can't produce one, so check the JSON-RPC body in the route before the SDK sees it.
  • Publish protected-resource metadata (RFC 9728) that names your authorization server, and authorization-server metadata (RFC 8414) with PKCE S256. Support Dynamic Client Registration or Client ID Metadata Documents, so hosts can register themselves. Match loopback redirect URIs with the port ignored.
  • Access tokens carry `aud` = your MCP URL, and the server checks it.
  • Use the shop's identity provider as the authorization server in production. A demo can sign in by choosing a demo customer.

After sign-in, every ranking can add the shopper's taste (rebuilt from their Shop API order history on each call, with no stored profile), prices follow their company's price list, and the basket belongs to the company.

Test it

  • Unit tests for the question-to-filters parser in every content language.
  • A smoke test against a running store: discovery documents, the 401 gate, searches in each language, product detail, basket and hand-off, the UI resource, and the OAuth flow.
  • The MCP Inspector against the local endpoint.
tip

See it live

The Tools Universe MCP page shows how to connect a host to a live shopper MCP server with 18 tools, typed-spec search in four languages, rentals and OAuth sign-in. Photo Store runs the same pattern for cameras, with every result ranked by the shopper's taste.