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.
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),
});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.
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 |
|---|---|---|
| Free text plus structured filters (category, brand, specs, price, stock); returns cards, facets and suggestions | Discovery |
| Cards for a list of SKUs, in order: the model's own shortlist | Discovery by SKU |
| Every variant with SKU, price and stock, the spec sheet, what is in the box | Discovery by path or SKU |
| Nearest neighbours | Discovery |
| The vocabulary, with counts and ids the other tools accept | Discovery facets |
| The basket, by | Shop API |
| Places the order, within limits | Shop API |
| 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.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 }),
);The server's instructions are read by the model on connect. Use them for what the tool descriptions can't say:
lang.cartId from add_to_cart to every later basket call, and give the shopper the checkout link.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:
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 }] }{ "specs": [{ "field": "drilling.torqueHard", "gte": 60 }, { "field": "power.voltage", "equals": 18 }] }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.
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:
/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./mcp/buy?sku=… that adds one variant to the browser's own cart./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.
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:
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.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.
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.