/**
* manage_cart MCP Tool — Cart CRUD Operations
* Uses UCP line items with in-memory storage.
* When Shopify Storefront API credentials are configured, cart operations
* are transparently backed by real Shopify carts. Falls back to pure
* in-memory behavior when credentials are absent or API calls fail.
*/
import { randomUUID } from 'node:crypto';
import type { LineItem, CheckoutTotals } from '../types.js';
import { loadConfig } from '../types.js';
import { createLineItem, calculateTotals } from '../ucp/shopping-service.js';
import { ShopifyClient } from '../shopify/client.js';
import { StorefrontAPI } from '../shopify/storefront.js';
import { logger } from '../utils/logger.js';
// ─── Types ───
export interface CartParams {
action: 'create' | 'add' | 'remove' | 'get';
cart_id?: string;
variant_id?: string;
quantity?: number;
}
export interface CartResult {
cart_id: string;
line_items: LineItem[];
totals: CheckoutTotals;
item_count: number;
}
interface CartEntry {
lines: LineItem[];
currency: string;
}
// ─── In-Memory Cart Store ───
const cartStore = new Map<string, CartEntry>();
// ─── Storefront API Singleton ───
let cachedStorefrontAPI: StorefrontAPI | null | undefined;
/**
* Lazily initialise and cache a StorefrontAPI instance.
* Returns `null` when the required Shopify credentials are not configured.
*/
function getStorefrontAPI(): StorefrontAPI | null {
if (cachedStorefrontAPI !== undefined) {
return cachedStorefrontAPI;
}
try {
const config = loadConfig();
const { storeDomain, storefrontToken, accessToken } = config.shopify;
if (!storeDomain || !storefrontToken || !accessToken) {
logger.info('Storefront API credentials not configured — using in-memory cart only.');
cachedStorefrontAPI = null;
return null;
}
const client = new ShopifyClient({ storeDomain, accessToken, storefrontToken });
cachedStorefrontAPI = new StorefrontAPI(client);
logger.info('Storefront API client initialised.', { storeDomain });
return cachedStorefrontAPI;
} catch (err) {
logger.error('Failed to initialise Storefront API client — falling back to in-memory cart.', {
error: err instanceof Error ? err.message : String(err),
});
cachedStorefrontAPI = null;
return null;
}
}
// ─── Helpers ───
function buildResult(cartId: string, entry: CartEntry): CartResult {
const totals = calculateTotals(entry.lines, entry.currency);
const itemCount = entry.lines.reduce((sum, li) => sum + li.quantity, 0);
return {
cart_id: cartId,
line_items: entry.lines,
totals,
item_count: itemCount,
};
}
function assertCartExists(cartId: string | undefined): asserts cartId is string {
if (!cartId) {
throw new Error('cart_id is required for this action.');
}
if (!cartStore.has(cartId)) {
throw new Error(`Cart not found: ${cartId}`);
}
}
/** Retrieve a cart entry that is known to exist (call after assertCartExists). */
function getCartEntry(cartId: string): CartEntry {
const entry = cartStore.get(cartId);
if (!entry) {
throw new Error(`Cart not found: ${cartId}`);
}
return entry;
}
// ─── Action Handlers ───
async function handleCreate(): Promise<CartResult> {
const storefront = getStorefrontAPI();
if (storefront) {
try {
const shopifyCartId = await storefront.createCart();
logger.info('Created Shopify Storefront cart.', { cartId: shopifyCartId });
const entry: CartEntry = { lines: [], currency: 'USD' };
cartStore.set(shopifyCartId, entry);
return buildResult(shopifyCartId, entry);
} catch (err) {
logger.error('Shopify createCart failed — falling back to in-memory cart.', {
error: err instanceof Error ? err.message : String(err),
});
// Fall through to UUID-based approach
}
}
// In-memory fallback (original behaviour)
const cartId = randomUUID();
const entry: CartEntry = { lines: [], currency: 'USD' };
cartStore.set(cartId, entry);
logger.info('Created in-memory cart.', { cartId });
return buildResult(cartId, entry);
}
async function handleAdd(params: CartParams): Promise<CartResult> {
const { cart_id: cartId, variant_id: variantId, quantity } = params;
if (!variantId) {
throw new Error('variant_id is required for the "add" action.');
}
if (quantity === undefined || quantity === null) {
throw new Error('quantity is required for the "add" action.');
}
assertCartExists(cartId);
const storefront = getStorefrontAPI();
if (storefront) {
try {
await storefront.addToCart(cartId, variantId, quantity);
logger.info('Added item to Shopify cart via Storefront API.', { cartId, variantId, quantity });
// Fetch the full cart from Shopify to get authoritative line items & pricing
const shopifyCart = await storefront.getCart(cartId);
// Merge Shopify data into local cart entry
const entry = getCartEntry(cartId);
entry.lines = shopifyCart.lines;
entry.currency = shopifyCart.totals.currency;
return {
cart_id: cartId,
line_items: shopifyCart.lines,
totals: shopifyCart.totals,
item_count: shopifyCart.lines.reduce((sum, li) => sum + li.quantity, 0),
};
} catch (err) {
logger.error('Shopify addToCart failed — falling back to in-memory logic.', {
error: err instanceof Error ? err.message : String(err),
cartId,
variantId,
});
// Fall through to in-memory approach
}
}
// In-memory fallback (original behaviour)
const entry = getCartEntry(cartId);
// Check if a line item with this variant_id already exists
const existingIndex = entry.lines.findIndex((li) => li.variant_id === variantId);
if (existingIndex >= 0) {
// Update existing line item quantity
const existing = entry.lines[existingIndex];
if (!existing) {
throw new Error(`Line item at index ${existingIndex} not found.`);
}
const newQuantity = existing.quantity + quantity;
const updated = createLineItem({
product_id: existing.product_id,
variant_id: existing.variant_id,
title: existing.title,
quantity: newQuantity,
unit_amount: existing.unit_amount,
type: existing.type,
image_url: existing.image_url,
sku: existing.sku,
});
// Preserve the original line item ID for consistency
updated.id = existing.id;
entry.lines[existingIndex] = updated;
} else {
// Create new line item — use variant_id as placeholder for product_id and title
const lineItem = createLineItem({
product_id: variantId,
variant_id: variantId,
title: `Product ${variantId}`,
quantity,
unit_amount: 0, // Price will be resolved when connected to Shopify
type: 'product',
});
entry.lines.push(lineItem);
}
return buildResult(cartId, entry);
}
// TODO: Shopify Storefront API cart line removal requires the line item ID
// (not the variant ID). To support Shopify-backed removal we would need to
// look up the line item ID from the cart's lines first. Keeping in-memory
// behaviour for now.
function handleRemove(params: CartParams): CartResult {
const { cart_id: cartId, variant_id: variantId } = params;
if (!variantId) {
throw new Error('variant_id is required for the "remove" action.');
}
assertCartExists(cartId);
const entry = getCartEntry(cartId);
const initialLength = entry.lines.length;
entry.lines = entry.lines.filter((li) => li.variant_id !== variantId);
if (entry.lines.length === initialLength) {
throw new Error(`Line item with variant_id "${variantId}" not found in cart ${cartId}.`);
}
return buildResult(cartId, entry);
}
async function handleGet(params: CartParams): Promise<CartResult> {
const { cart_id: cartId } = params;
assertCartExists(cartId);
const storefront = getStorefrontAPI();
if (storefront) {
try {
const shopifyCart = await storefront.getCart(cartId);
logger.info('Fetched cart from Shopify Storefront API.', { cartId });
// Update local cache with fresh Shopify data
const entry = getCartEntry(cartId);
entry.lines = shopifyCart.lines;
entry.currency = shopifyCart.totals.currency;
return {
cart_id: cartId,
line_items: shopifyCart.lines,
totals: shopifyCart.totals,
item_count: shopifyCart.lines.reduce((sum, li) => sum + li.quantity, 0),
};
} catch (err) {
logger.error('Shopify getCart failed — falling back to in-memory data.', {
error: err instanceof Error ? err.message : String(err),
cartId,
});
// Fall through to in-memory data
}
}
// In-memory fallback (original behaviour)
const entry = getCartEntry(cartId);
return buildResult(cartId, entry);
}
// ─── Main Entry Point ───
/**
* Manage cart operations: create, add, remove, get.
* When Shopify Storefront API credentials are configured (SHOPIFY_STORE_DOMAIN,
* SHOPIFY_STOREFRONT_TOKEN, SHOPIFY_ACCESS_TOKEN), cart operations are backed by
* real Shopify carts. Otherwise, uses a pure in-memory Map for cart storage.
*/
export async function manageCart(params: CartParams): Promise<CartResult> {
switch (params.action) {
case 'create':
return handleCreate();
case 'add':
return handleAdd(params);
case 'remove':
return handleRemove(params);
case 'get':
return handleGet(params);
default: {
const exhaustive: never = params.action;
throw new Error(`Invalid action: ${String(exhaustive)}`);
}
}
}