diff --git a/.env.example b/.env.example
index f7fa990d41..e182d740f1 100644
--- a/.env.example
+++ b/.env.example
@@ -3,3 +3,6 @@ SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
+# Optional. A Chrome origin-trial token for WebMCP, bound to the exact origin you deploy to.
+# Leave empty to keep WebMCP available only in browsers with the testing flag enabled.
+WEBMCP_ORIGIN_TRIAL_TOKEN=""
diff --git a/README.md b/README.md
index ea48c93fbe..d9e0201206 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,60 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
1. Run `pnpm dev` to ensure everything is working correctly.
+## WebMCP tools for AI agents
+
+[WebMCP](https://github.com/webmachinelearning/webmcp) is a proposed browser API that lets a page hand AI agents a set of typed tools, so an agent can call `shop.search_products` instead of guessing at the DOM. This storefront registers four:
+
+| Tool | What it does |
+| -------------------------- | ---------------------------------------- |
+| `shop.search_products` | Search the catalog |
+| `shop.get_product_options` | List a product's option names and values |
+| `shop.get_cart` | Read what is in the cart |
+| `shop.add_to_cart` | Add one variant to the cart |
+
+Two files: `components/webmcp-tools.tsx` registers the tools with [`use-webmcp-tool`](https://github.com/GoogleChromeLabs/use-webmcp-tool), Chrome's React hook for `document.modelContext`, and `lib/webmcp/actions.ts` implements them as server actions on top of the existing `lib/shopify` functions. Browsers without WebMCP are unaffected — the hook feature-detects and does nothing.
+
+### Try it locally
+
+1. Open `chrome://flags/#enable-webmcp-testing`, enable **WebMCP for testing**, and relaunch Chrome.
+2. Install the [Model Context Tool Inspector](https://github.com/beaufortfrancois/model-context-tool-inspector) extension.
+3. Run `pnpm dev`, open the storefront, and use the inspector's side panel to list the tools and call them by hand.
+
+The inspector is a development tool, not a security boundary. Only use it on pages you trust.
+
+### Add your own tool
+
+Write a server action that returns a plain object, then register it. That is the whole pattern:
+
+```tsx
+// lib/webmcp/actions.ts
+export async function getShippingPolicy() {
+ return { policy: "Free shipping over $50, delivered in 3-5 business days." };
+}
+
+// components/webmcp-tools.tsx
+useWebMCP({
+ name: "shop.get_shipping_policy",
+ description: "Explain this store's shipping cost and delivery time.",
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: getShippingPolicy,
+ formatOutput: reportErrors,
+});
+```
+
+Two habits worth copying. Validate arguments inside the action — the `inputSchema` tells the agent what to send, but it cannot stop a confused or hostile one sending something else, which is why `shop.add_to_cart` re-derives the variant from the product instead of accepting a variant id. And return failures as `{ error: "..." }` so `reportErrors` can mark them as real MCP errors; otherwise the agent reads a failure as success.
+
+### Enabling it on a deployment
+
+The flag above only affects your own browser. For real visitors, WebMCP runs as a Chrome origin trial **through Chrome 156**, and the deployed origin needs its own token:
+
+1. Register the exact origin at [developer.chrome.com/origintrials](https://developer.chrome.com/origintrials).
+2. Set the token as `WEBMCP_ORIGIN_TRIAL_TOKEN`.
+
+`app/layout.tsx` emits `` only when that variable is set, so leaving it unset is a clean no-op. Tokens are origin-bound and expire, so a token for one deployment does nothing on preview URLs or forks.
+
+> **Not yet enrolled.** No origin-trial token is registered for `demo.vercel.store`, so these tools currently register only in a browser with the testing flag on.
+
## Vercel, Next.js Commerce, and Shopify Integration Guide
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
diff --git a/app/layout.tsx b/app/layout.tsx
index 4e4b4afa64..873241f407 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,6 @@
import { CartProvider } from "components/cart/cart-context";
import { Navbar } from "components/layout/navbar";
+import { WebMCPTools } from "components/webmcp-tools";
import { WelcomeToast } from "components/welcome-toast";
import { GeistSans } from "geist/font/sans";
import { getCart } from "lib/shopify";
@@ -10,6 +11,10 @@ import { baseUrl } from "lib/utils";
const { SITE_NAME } = process.env;
+// TODO(gaojude): no origin-trial token is registered for demo.vercel.store yet, so
+// WebMCP only works in a browser with chrome://flags/#enable-webmcp-testing enabled.
+const webmcpOriginTrialToken = process.env.WEBMCP_ORIGIN_TRIAL_TOKEN;
+
export const metadata = {
metadataBase: new URL(baseUrl),
title: {
@@ -32,6 +37,11 @@ export default async function RootLayout({
return (
+
+ {webmcpOriginTrialToken ? (
+
+ ) : null}
+
@@ -40,6 +50,7 @@ export default async function RootLayout({
+
diff --git a/components/webmcp-tools.tsx b/components/webmcp-tools.tsx
new file mode 100644
index 0000000000..36182e0900
--- /dev/null
+++ b/components/webmcp-tools.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import {
+ addProductToCart,
+ getCartSummary,
+ getProductOptions,
+ searchProducts,
+} from "lib/webmcp/actions";
+import { useWebMCP } from "use-webmcp-tool";
+
+// WebMCP lets this page hand an AI agent a set of typed tools instead of making
+// it read the DOM. Each useWebMCP call registers one tool while this component
+// is mounted, and unregisters it on unmount. Browsers without WebMCP get a no-op.
+//
+// To add your own tool, copy any block below: give it a name, a description the
+// agent will read, a JSON Schema for its arguments, and an execute function.
+
+const READ_ONLY = { readOnlyHint: true, untrustedContentHint: true };
+const WRITES = { readOnlyHint: false, untrustedContentHint: false };
+
+// Our actions report a problem as { error: "..." }. Without this the hook would
+// serialize that object as a *successful* result and the agent could not tell a
+// failed lookup from a good one, so turn it into a real MCP error instead.
+function reportErrors(result: unknown) {
+ if (result && typeof result === "object" && "error" in result) {
+ return {
+ content: [{ type: "text", text: String(result.error) }],
+ isError: true,
+ };
+ }
+ return result;
+}
+
+const searchProductsSchema = {
+ type: "object",
+ properties: {
+ query: {
+ type: "string",
+ description: "Words to search for in the catalog.",
+ },
+ limit: {
+ type: "integer",
+ description: "How many products to return. Defaults to 5, maximum 10.",
+ },
+ },
+ required: ["query"],
+ additionalProperties: false,
+};
+
+const getProductOptionsSchema = {
+ type: "object",
+ properties: {
+ handle: {
+ type: "string",
+ description: "A product handle returned by shop.search_products.",
+ },
+ },
+ required: ["handle"],
+ additionalProperties: false,
+};
+
+const addToCartSchema = {
+ type: "object",
+ properties: {
+ handle: {
+ type: "string",
+ description: "A product handle returned by shop.search_products.",
+ },
+ selectedOptions: {
+ type: "array",
+ description:
+ "One entry per product option, using names and values from shop.get_product_options.",
+ items: {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ value: { type: "string" },
+ },
+ required: ["name", "value"],
+ additionalProperties: false,
+ },
+ },
+ quantity: {
+ type: "integer",
+ description: "How many units to add. Defaults to 1.",
+ },
+ },
+ required: ["handle", "selectedOptions"],
+ additionalProperties: false,
+};
+
+export function WebMCPTools() {
+ useWebMCP({
+ name: "shop.search_products",
+ description:
+ "Search this store's catalog. Pass a returned handle to shop.get_product_options.",
+ inputSchema: searchProductsSchema,
+ annotations: READ_ONLY,
+ execute: searchProducts,
+ formatOutput: reportErrors,
+ });
+
+ useWebMCP({
+ name: "shop.get_product_options",
+ description:
+ "List the option names and values for one product, such as size or color.",
+ inputSchema: getProductOptionsSchema,
+ annotations: READ_ONLY,
+ execute: getProductOptions,
+ formatOutput: reportErrors,
+ });
+
+ useWebMCP({
+ name: "shop.get_cart",
+ description: "Read what is currently in the shopper's cart.",
+ annotations: READ_ONLY,
+ execute: getCartSummary,
+ formatOutput: reportErrors,
+ });
+
+ useWebMCP({
+ name: "shop.add_to_cart",
+ description:
+ "Add one product variant to the cart. Choose every option first with shop.get_product_options.",
+ inputSchema: addToCartSchema,
+ annotations: WRITES,
+ execute: addProductToCart,
+ formatOutput: reportErrors,
+ });
+
+ return null;
+}
diff --git a/lib/webmcp/actions.ts b/lib/webmcp/actions.ts
new file mode 100644
index 0000000000..3d43762b26
--- /dev/null
+++ b/lib/webmcp/actions.ts
@@ -0,0 +1,237 @@
+"use server";
+
+import { TAGS } from "lib/constants";
+import {
+ addToCart as addCartLines,
+ createCart,
+ getCart,
+ getProduct,
+ getProducts,
+} from "lib/shopify";
+import type { Money, Product } from "lib/shopify/types";
+import { updateTag } from "next/cache";
+import { cookies } from "next/headers";
+
+// Every action below follows the same four steps: validate the input, call an
+// existing lib/shopify function, shape a small result, return it.
+
+const DEFAULT_SEARCH_RESULTS = 5;
+const MAX_SEARCH_RESULTS = 10;
+const MAX_QUANTITY = 99;
+
+function toolError(message: string) {
+ return { error: message };
+}
+
+function formatMoney(money: Money) {
+ return `${money.amount} ${money.currencyCode}`;
+}
+
+function sameText(left: string, right: string) {
+ return left.toLowerCase() === right.toLowerCase();
+}
+
+// Tool input comes from an agent rather than from our own UI, so the JSON Schema
+// on the client is only a hint. Everything is re-checked here.
+function readText(value: unknown, maxLength: number) {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ if (trimmed.length === 0 || trimmed.length > maxLength) return undefined;
+ return trimmed;
+}
+
+function readCount(value: unknown, fallback: number, max: number) {
+ if (value === undefined) return fallback;
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
+ return undefined;
+ }
+ return Math.min(value, max);
+}
+
+export async function searchProducts(input: unknown) {
+ const { query, limit } = (input ?? {}) as {
+ query?: unknown;
+ limit?: unknown;
+ };
+
+ const searchTerm = readText(query, 120);
+ if (!searchTerm) return toolError("Provide a non-empty search query.");
+
+ const count = readCount(limit, DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS);
+ if (count === undefined) {
+ return toolError("limit must be a positive whole number.");
+ }
+
+ try {
+ const products = await getProducts({ query: searchTerm });
+
+ return {
+ totalMatches: products.length,
+ products: products.slice(0, count).map((product) => ({
+ handle: product.handle,
+ title: product.title,
+ price: formatMoney(product.priceRange.minVariantPrice),
+ available: product.availableForSale,
+ })),
+ };
+ } catch (error) {
+ console.error("WebMCP search_products failed", error);
+ return toolError("Product search is unavailable right now.");
+ }
+}
+
+export async function getProductOptions(input: unknown) {
+ const { handle } = (input ?? {}) as { handle?: unknown };
+
+ const productHandle = readText(handle, 255);
+ if (!productHandle) {
+ return toolError("Provide a product handle from shop.search_products.");
+ }
+
+ try {
+ const product = await getProduct(productHandle);
+ if (!product) return toolError("No product exists for that handle.");
+
+ return {
+ handle: product.handle,
+ title: product.title,
+ options: product.options.map((option) => ({
+ name: option.name,
+ values: option.values,
+ })),
+ };
+ } catch (error) {
+ console.error("WebMCP get_product_options failed", error);
+ return toolError("Product options are unavailable right now.");
+ }
+}
+
+export async function getCartSummary() {
+ try {
+ const cart = await getCart();
+
+ // Cart ids and checkout urls stay server-side; an agent never needs them.
+ if (!cart || cart.lines.length === 0) {
+ return { empty: true, lines: [], totalQuantity: 0 };
+ }
+
+ return {
+ empty: false,
+ totalQuantity: cart.totalQuantity,
+ total: formatMoney(cart.cost.totalAmount),
+ lines: cart.lines.map((line) => ({
+ productTitle: line.merchandise.product.title,
+ variantTitle: line.merchandise.title,
+ quantity: line.quantity,
+ lineTotal: formatMoney(line.cost.totalAmount),
+ })),
+ };
+ } catch (error) {
+ console.error("WebMCP get_cart failed", error);
+ return toolError("The cart is unavailable right now.");
+ }
+}
+
+function readSelectedOptions(value: unknown) {
+ if (!Array.isArray(value) || value.length === 0) return undefined;
+
+ const choices: { name: string; value: string }[] = [];
+ for (const entry of value) {
+ const { name, value: optionValue } = (entry ?? {}) as {
+ name?: unknown;
+ value?: unknown;
+ };
+ const choiceName = readText(name, 255);
+ const choiceValue = readText(optionValue, 255);
+ if (!choiceName || !choiceValue) return undefined;
+ choices.push({ name: choiceName, value: choiceValue });
+ }
+
+ return choices;
+}
+
+// The variant is re-derived from the product rather than taken from the caller,
+// so an agent can never add something the storefront would not offer.
+function findVariant(
+ product: Product,
+ choices: { name: string; value: string }[],
+) {
+ if (choices.length !== product.options.length) return undefined;
+
+ return product.variants.find(
+ (variant) =>
+ variant.selectedOptions.length === choices.length &&
+ variant.selectedOptions.every((option) =>
+ choices.some(
+ (choice) =>
+ sameText(choice.name, option.name) &&
+ sameText(choice.value, option.value),
+ ),
+ ),
+ );
+}
+
+export async function addProductToCart(input: unknown) {
+ const { handle, selectedOptions, quantity } = (input ?? {}) as {
+ handle?: unknown;
+ selectedOptions?: unknown;
+ quantity?: unknown;
+ };
+
+ const productHandle = readText(handle, 255);
+ if (!productHandle) {
+ return toolError("Provide a product handle from shop.search_products.");
+ }
+
+ const choices = readSelectedOptions(selectedOptions);
+ if (!choices) {
+ return toolError(
+ "Provide one name and value per product option, from shop.get_product_options.",
+ );
+ }
+
+ const count = readCount(quantity, 1, MAX_QUANTITY);
+ if (count === undefined) {
+ return toolError("quantity must be a positive whole number.");
+ }
+
+ try {
+ const product = await getProduct(productHandle);
+ if (!product) return toolError("No product exists for that handle.");
+
+ const variant = findVariant(product, choices);
+ if (!variant) {
+ return toolError(
+ "No variant matches those options. Call shop.get_product_options first.",
+ );
+ }
+ if (!variant.availableForSale) {
+ return toolError("That variant is sold out.");
+ }
+
+ // addToCart expects an existing cart cookie, which the cart modal normally
+ // creates on mount. An agent can act before that, so make sure one exists.
+ const cookieStore = await cookies();
+ if (!cookieStore.get("cartId")) {
+ const cart = await createCart();
+ cookieStore.set("cartId", cart.id!);
+ }
+
+ await addCartLines([{ merchandiseId: variant.id, quantity: count }]);
+ updateTag(TAGS.cart);
+
+ return {
+ added: true,
+ title: product.title,
+ variantTitle: variant.title,
+ quantity: count,
+ };
+ } catch (error) {
+ console.error("WebMCP add_to_cart failed", error);
+ // Deliberately does not promise the cart was untouched — a request can fail
+ // after Shopify applied it, and a blind retry would add the item twice.
+ return toolError(
+ "Adding to the cart failed. Check shop.get_cart before retrying.",
+ );
+ }
+}
diff --git a/package.json b/package.json
index 2a8a76e763..c5cc4d97dc 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"next": "15.6.0-canary.60",
"react": "19.0.0",
"react-dom": "19.0.0",
- "sonner": "^2.0.1"
+ "sonner": "^2.0.1",
+ "use-webmcp-tool": "^0.2.0"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3c0f7ef628..9d01ea0cf3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,6 +31,9 @@ importers:
sonner:
specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ use-webmcp-tool:
+ specifier: ^0.2.0
+ version: 0.2.0(react@19.0.0)
devDependencies:
"@tailwindcss/container-queries":
specifier: ^0.1.1
@@ -1079,6 +1082,14 @@ packages:
integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==,
}
+ use-webmcp-tool@0.2.0:
+ resolution:
+ {
+ integrity: sha512-kYUpV1xFmX3Wnq68KOM1MbedpRc6mbgWEcVFtWpFPBTGEcuc7Sva4bHjRxM3k8mkGshkwxnLKn9/I4/NP2bTBw==,
+ }
+ peerDependencies:
+ react: ">=18"
+
util-deprecate@1.0.2:
resolution:
{
@@ -1598,4 +1609,8 @@ snapshots:
undici-types@6.20.0: {}
+ use-webmcp-tool@0.2.0(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+
util-deprecate@1.0.2: {}