@commercengine/ai
v0.2.2
Published
Browser-native agent tools (WebMCP) for Commerce Engine storefronts
Maintainers
Readme
@commercengine/ai
Browser-native agent tools for Commerce Engine storefronts, built on WebMCP.
Where @commercengine/seo builds the surfaces an agent reads — structured data, Markdown, llms.txt, sitemaps — this package registers the tools an agent can act through: search the catalog, resolve a variant, navigate the storefront, look up policies, and work with the shopper's real cart.
Status
WebMCP is a W3C Community Group draft, not a standard. This package targets the 19 August 2026 draft and pins that contract in tests. In a browser without WebMCP, registration returns null and nothing else happens.
Install
pnpm add @commercengine/ai @commercengine/storefront@commercengine/checkout is an optional peer, needed only for the cart/checkout drawer. Cart reads and mutations go through the authenticated SDK, so a storefront with a custom checkout gets the full cart surface without it.
Quick start
"use client";
import { registerCommerceWebMcp } from "@commercengine/ai/webmcp";
import { storefront } from "@/lib/storefront";
const registration = await registerCommerceWebMcp({
storefront,
siteUrl: "https://acme.example",
});
// Keep this controller for teardown. Calling it unregisters every installed tool.
// registration?.abort();Registering from a component
Registrations against one model context are serialized. Claiming a tool set is not atomic — it is a sequence of awaited registerTool calls — so two overlapping registrations would race for the same names and one would throw InvalidStateError: Duplicate tool name. A second call therefore waits for the first to settle, including any rollback. React Strict Mode makes this the ordinary case rather than an edge one: it mounts, cleans up, and mounts again back-to-back on every dev mount.
Serialization prevents two setup passes from interleaving. The lifecycle signal is still required for correctness: cleanup can run before the returned controller exists, and without a signal the stale pass remains live after its component has gone away. config.signal cancels that registration while it is in progress, including the registerTool call currently being awaited, so a lifecycle replacement starts only after rollback has completed. Two genuinely active registrations still conflict, as they should — one model context cannot have two owners for the same tool names.
Two signals, doing different jobs — the returned controller revokes tools that did register; config.signal cancels one still running, which the controller cannot cover because it does not exist until the promise resolves.
useEffect(() => {
const lifecycle = new AbortController();
let registration: AbortController | null = null;
registerCommerceWebMcp({ storefront, siteUrl, signal: lifecycle.signal })
.then((controller) => {
if (lifecycle.signal.aborted) controller?.abort();
else registration = controller;
})
// Cancellation is your own cleanup arriving, not a fault. Without a catch, every failure is an
// unhandled rejection: it takes down the dev overlay and is invisible in production.
.catch((error) => {
if (!lifecycle.signal.aborted) console.error("[commerce-ai]", error);
});
return () => {
lifecycle.abort();
registration?.abort();
};
}, []);A cancelled registration rolls back whatever it had claimed and rejects with CommerceWebMcpAbortError, so the registration that replaces it starts from a clean model context.
This registers the four catalog tools and, because a storefront resolves its own browser session, get_session_state plus the cart tools — get_cart, add_to_cart, set_cart_item_quantity, remove_from_cart. Cart access needs no sign-in and no Hosted Checkout; see How the cart works.
For catalog tools only, opt out of the session explicitly:
await registerCommerceWebMcp({ storefront, siteUrl, session: null });
// search_products, get_product, browse_store, get_variantAdding cart, navigation, and content
import { createHostedCheckoutBridge } from "@commercengine/ai/checkout";
import { registerCommerceWebMcp } from "@commercengine/ai/webmcp";
import { getCheckout } from "@commercengine/checkout";
await registerCommerceWebMcp({
storefront,
siteUrl: "https://acme.example",
routes,
checkout: createHostedCheckoutBridge({ getState: () => getCheckout() }),
navigation: {
navigate: (url) => router.push(url),
ordersUrl: "/account/orders",
},
shopContent: {
searchPoliciesAndFaqs: (query, signal) => searchContent(query, { signal }),
},
});createHostedCheckoutBridge() reads checkout state your app already initialized, and only to open drawers. It never creates a session, initializes checkout, or touches tokens — Hosted Checkout stays in authMode: "provided" with its existing two-way token sync, which is what makes it and the SDK resolve the same cart.
Tool inventory
| Tool | Read-only | Requires |
|---|:--:|---|
| search_products | yes | — |
| get_product | yes | — |
| browse_store | yes | — |
| get_variant | yes | — |
| open_product | no | navigation |
| manage_orders | no | navigation.ordersUrl |
| search_shop_policies_and_faqs | yes | shopContent |
| get_session_state | yes | authenticated session |
| open_login | no | checkout exposing openLogin |
| get_cart | yes | authenticated session |
| add_to_cart | no | authenticated session |
| set_cart_item_quantity | no | authenticated session |
| remove_from_cart | no | authenticated session |
| open_cart | no | checkout drawer |
| open_checkout | no | checkout drawer |
Setting a quantity of 0 removes a line too; remove_from_cart is the explicit form of the same operation.
readOnlyHint is fixed per tool and never derived from configuration. get_variant resolves a URL and open_product navigates to it — two tools, because one tool that changes its own annotation depending on whether a bridge was passed describes itself differently to different shoppers.
There is deliberately no clear_cart, no order placement, and no payment tool.
Results
Tools return plain objects. The user agent serializes them once; returning JSON text would hand the agent a string to re-parse.
{ ok: true, data: { /* … */ } }
{ ok: false, error: { code: "product_not_found", message: "…", retryable: false } }A cart mutation returns the resulting cart:
{ ok: true, data: { cart: { /* allow-listed snapshot */ }, added: 2 } }retryable distinguishes "the catalog was briefly unavailable" from "this product does not exist". Treat a failed add as non-retryable regardless: adds accumulate, so repeating one that already applied adds duplicates.
How the cart works
Cart tools call the Commerce Engine SDK directly through storefront.session() (or clientStorefront() on a framework wrapper). The response is the confirmation: every mutation returns the resulting cart, so there is no correlation and no waiting.
One caveat, stated precisely: the SDK takes no abort signal, so cancelling an execution is not a rollback. Once a request is in flight the server may apply it regardless. Where a mutation has already returned, this package returns the confirmed cart rather than throwing on a late abort — hiding a change that happened is what invites a duplicating retry.
{ ok: true, data: { cart: { cartId, itemCount, subtotal, grandTotal, currency, items: [...] }, added: 2 } }Hosted Checkout is not required for any of this. It contributes the drawer, and nothing else. A storefront with a custom or headless checkout gets get_cart, add_to_cart, set_cart_item_quantity, and remove_from_cart all the same.
Both see the same cart. Hosted Checkout resolves its cart with getUserCart({ user_id }) from the session token, exactly as the SDK does — so with authMode: "provided" and its existing two-way token sync, they are the same cart by construction. The session is also the only thing the cart ever needed from checkout: it already holds the access token and refreshes it.
The drawer stays in step. After a mutation the package calls openCart(), and checkout:open-cart runs initializeCart() on the iframe side — a refetch. The shopper never sees a stale drawer behind a change the agent made.
Batching
add_to_cart takes up to 10 items in one call, because the round trip that matters is between the model and the page:
await tools.add_to_cart.execute({ items: [
{ productId: "P1", variantId: null, quantity: 2 },
{ productId: "P2", variantId: "V1" },
]});A shopper with no cart yet gets one created with the whole batch in a single request. Onto an existing cart the API takes one line per request, so lines are applied in turn — and a failure mid-batch reports which lines already landed rather than implying the batch was atomic.
Quantity rules
quantity is absolute, not a delta, and adds resolve to current + requested before being sent.
Ordering constraints — minOrderQuantity, maxOrderQuantity, incrementalQuantity — are enforced in two places, and which one applies depends on whether the line is already in the cart:
| | Enforced by | Behaviour |
|---|---|---|
| A line already in the cart | this package, client-side | Uses the constraints on that cart line, applying the same logic as the Hosted Checkout cart store — so a quantity the drawer would accept is not rejected for an agent, or the reverse. maxOrderQuantity is refused rather than clamped; quantities are rounded up to incrementalQuantity; raising a line that currently has 0 paid units to minOrderQuantity happens here, which is what a promotional free line looks like when paid units are added to it. |
| A line being added for the first time | Commerce Engine, server-side | The cart has no line to read constraints from yet, so nothing is checked locally. The server enforces and its message is returned verbatim. |
That second row is why search_products and get_product return minOrderQuantity / maxOrderQuantity / incrementalQuantity, and why both cart mutation tools name those fields in their descriptions: an agent should pick a valid quantity for a new line from catalog data rather than discover the rule from a rejection.
Two rules hold regardless of which side enforces:
- quantity 0 removes the line, which Commerce Engine supports directly;
remove_from_cartdoes the same thing more explicitly.quantityis a required field, so an omission is rejected by the schema and can never read as a deletion - removal is never subject to ordering rules — an item whose
maxOrderQuantityis 0 cannot be bought but must still be removable
Promotional free items
A promotion can add a line the shopper never chose, priced at zero. Each cart line separates the two:
| Field | Meaning |
|---|---|
| quantity | the paid units — what mutations operate on |
| freeQuantity | units granted by a promotion, priced at zero |
| totalQuantity | quantity + freeQuantity, what the shopper sees on the line |
| removable | false when the line is entirely promotional |
A line with quantity: 0 and freeQuantity > 0 cannot be removed or reduced — remove_from_cart and set_cart_item_quantity(0) both return free_item_not_removable, which is what the checkout UI disables those controls for. Buying paid units of the same product is still allowed, and a line the shopper chose stays removable even when it carries some free units.
search_products, get_product, and get_cart all return minOrderQuantity / maxOrderQuantity / incrementalQuantity, so an agent can pick a valid quantity instead of guessing and being rejected.
Sign-in
Shoppers do not need to sign in. An anonymous session is a real Commerce Engine session — guest tokens carry a user ID, which is all the cart needs. Browsing, cart changes, and checkout all work signed out. Framework wrappers establish one for you: bootstrap() is eager anonymous-session establishment, and the starters already call it before anything mounts.
Signing in adds order history, saved addresses, loyalty, and customer pricing.
No tool can authenticate anyone. open_login opens the sign-in screen; the shopper completes it; get_session_state confirms. That is the same rule as checkout — the agent opens the door and never walks through it — and it matters most here:
- an OTP or password passed as a tool argument is a credential in a model provider's log
- catalog and CMS output is already marked
untrustedContentHintbecause it is attacker-influenceable; an agent that could both request and submit a one-time code turns prompt injection into account takeover - the same auth client carries
deleteUser,deactivateUserAccount, andchangePassword
get_session_state returns { isLoggedIn, isAnonymous, loginAvailable } and no identity. Whether someone is signed in is what routes a request; who they are is not.
A guest cart survives sign-in on the same device — the cart follows the token. Signing in on a different browser or device will not carry it, which is worth saying plainly to a shopper rather than promising a merge that cannot happen.
Security boundary
The agent prepares the cart and opens checkout. The human completes the purchase. No tool can select an address, choose fulfillment, apply payment, or place an order.
- Cart output is an allow-list projection, not a redaction:
Carthas 47 fields including addresses, metadata, and payment state, and a future API addition must not silently become agent-visible. - Navigation is same-origin unless an origin is listed in
navigation.allowedExternalOrigins. Permission is never inferred fromsiteUrl, because a URL an agent passes may have come from catalog or CMS content. - Catalog and CMS results are marked
untrustedContentHint. - Tool descriptions are static and authored here; CMS content is never interpolated into them.
- Every free-form input is length-bounded and every schema sets
additionalProperties: false.
Sharing routes with @commercengine/seo
CommerceAiRoutes is structurally compatible with CommerceSeoRoutes, so one object configures both and a crawler and an agent are told the same URL for the same product:
const routes = { productBase: "/product", categoryBase: "/collections" };
const seo = createCommerceSeo({ storefront, site, routes });
await registerCommerceWebMcp({ storefront, siteUrl: site.url, routes });This package has no runtime dependency on @commercengine/seo. The two sets of defaults are kept identical by a test that imports both and compares them, so changing one alone fails CI.
The resolvers run in the browser here
One condition the shared type cannot express: a routes object is client-safe by construction, but the functions inside it are not automatically. routes.product is called during tool execution, in the browser, so a resolver that performs a CMS lookup would need that credential on the client.
The portable shape is a pure function over route data the browser already has. For a small catalog,
@commercengine/seo/routes provides a client-safe synchronous manifest over the same records the
crawler resolves against:
import { createCommerceRouteManifest } from "@commercengine/seo/routes";
const manifest = createCommerceRouteManifest(await loadRouteRecords());
export const routes = {
product: (input) => manifest.productPath(input),
resolveProductRoute: (publicSlug) => manifest.resolveProduct(publicSlug)?.productId ?? publicSlug,
};Do not invent a catalog-slug fallback when the manifest returns null: null means the storefront
has no known public page, and a guessed URL is a shopper-facing 404. Also do not ship a complete
100,000-product manifest merely to resolve a search page. At that size, back the same hooks with a
storefront-owned point cache that loads only the result set. This package calls the resolver you
provide; it neither fetches route data nor dictates an API, batching strategy, or cache policy.
resolveProductRoute matters as much as product: an agent that reads a CMS page slug off the page and calls get_product("knee-pain-relief-oil") sends it straight to a catalog that has never heard of it. Both directions come from the same records.
Handing the two packages different routes also works, and gives up the guarantee sharing exists to provide — a crawler and an agent can then be told different URLs for one product.
@commercengine/seo passes the route being rendered as a trailing second argument — routes.product(input, hint) and routes.category(category, hint) — so a CMS-backed storefront can return the page that was requested rather than deriving one:
product: ({ productSlug }, hint) => hint?.path ?? `/products/${productSlug}`,It is a positional parameter, never a field on input. That is deliberate: a resolver written as ({ productSlug, hint }) compiles, reads undefined forever, and silently falls through to its default — whereas a resolver that ignores the second parameter is visibly ignoring it.
This package never supplies a hint — an agent asking for a product's URL is not rendering a page — so a resolver that reads it must still answer when it is absent, exactly as it must for sitemaps and static generation.
Extending
Tools are grouped into modules, and a module is a plain value. Adding a capability never means editing a central switch:
const wishlistModule = {
name: "wishlist",
create: (context) => [{
name: "add_to_wishlist",
description: "Add a product to the shopper's wishlist.",
inputSchema: { type: "object", additionalProperties: false, properties: { /* … */ } },
annotations: { readOnlyHint: false },
execute: async (input, options) => ok({ /* … */ }),
}],
};
await registerCommerceWebMcp({ storefront, siteUrl, extraModules: [wishlistModule] });Use modules instead of extraModules to replace the default set outright — to drop a capability or reorder tools. Duplicate tool names throw at composition time rather than reaching a browser.
createCommerceAiTools(config) builds the same tools without a DOM, so they are unit-testable in Node and reusable by a future server-side MCP transport.
Knowing whether it registered
Registration is a no-op in a browser without WebMCP, which is silent by design — so registerCommerceWebMcp reports the outcome through diagnostics rather than leaving you to probe globals:
await registerCommerceWebMcp({
storefront,
siteUrl,
diagnostics: (event) => console.info(event.code, event.message ?? ""),
});
// unsupported No document.modelContext. WebMCP is available in Chromium 149+
// behind chrome://flags/#enable-webmcp-testing, or under an origin trial token.
// registered:9 every tool installedunsupported is the ordinary case, not a fault: WebMCP is a Community Group draft behind an origin trial. createCommerceAiTools(config) builds the same tool set without a DOM, so the list is inspectable before the API ships anywhere.
With the flag enabled, document.modelContext exists and DevTools gains a WebMCP panel listing the registered tools and their schemas.
Framework setup
Registration is client-only. Guard it against SSR and abort on teardown.
// Next.js / TanStack Start — a client component at the root
useEffect(() => {
const lifecycle = new AbortController();
let registration: AbortController | null = null;
registerCommerceWebMcp({ storefront, siteUrl, signal: lifecycle.signal })
.then((controller) => {
if (lifecycle.signal.aborted) controller?.abort();
else registration = controller;
})
.catch((error) => {
if (!lifecycle.signal.aborted) console.error("[commerce-ai] registration failed", error);
});
return () => {
lifecycle.abort();
registration?.abort();
};
}, []);// SvelteKit
onMount(() => {
const lifecycle = new AbortController();
let registration: AbortController | null = null;
registerCommerceWebMcp({ storefront, siteUrl, signal: lifecycle.signal })
.then((controller) => {
if (lifecycle.signal.aborted) controller?.abort();
else registration = controller;
})
.catch((error) => {
if (!lifecycle.signal.aborted) console.error("[commerce-ai] registration failed", error);
});
return () => {
lifecycle.abort();
registration?.abort();
};
});// Astro — a client script; abort before re-registering so View Transitions cannot duplicate tools.
let lifecycle: AbortController | null = null;
let registration: AbortController | null = null;
document.addEventListener("astro:page-load", () => {
lifecycle?.abort();
registration?.abort();
const current = new AbortController();
lifecycle = current;
registerCommerceWebMcp({ storefront, siteUrl, signal: current.signal })
.then((controller) => {
if (current.signal.aborted) controller?.abort();
else registration = controller;
})
.catch((error) => {
if (!current.signal.aborted) console.error("[commerce-ai] registration failed", error);
});
});Validation
pnpm --filter @commercengine/ai typecheck
pnpm --filter @commercengine/ai test:coverage
pnpm --filter @commercengine/ai check-exportssrc/__type-tests__ compiles against the real @commercengine/checkout and @commercengine/seo packages, so a released change to either shape fails the build here rather than in a storefront.
