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: {}