plan_shopping
Plan a multi-store shopping trip near your location, choosing the best products across Swiss grocery chains to minimize cost or trips. Get a primary plan plus alternatives for your shopping list.
Instructions
Plan a multi-store shopping trip near a location, picking the best products across configured Swiss grocery chains. Items can be generic ("milch", "pasta") or pinned to a specific SKU. Returns a primary plan plus alternatives. Use when the user gives a list of items and asks "where should I shop?" or "what's cheapest?". Strategies: single_store (one chain), split_cart (multi-chain with stop penalty), absolute_cheapest (no penalty).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| items | Yes | The list of items to shop for. At least one item required. | |
| near | Yes | Shopper's location — used to find nearby stores. Pass coordinates, ZIP, or address. | |
| chains | No | Restrict the plan to these chains. Omit to consider all configured chains. | |
| strategy | Yes | single_store: buy everything at one chain (minimises trips). split_cart: allow multiple chains but add splitPenaltyChf per extra stop. absolute_cheapest: pick the cheapest source per item regardless of stops. | |
| splitPenaltyChf | No | Cost in CHF added per extra store stop in split_cart strategy. Default 2.00. | |
| radiusKm | No | Only consider stores within this radius of the provided location (1–50 km). Default 5 km. |
Implementation Reference
- src/tools/plan_shopping.ts:80-123 (handler)The main handler function `planShoppingHandler` that geocodes the user's location, resolves errors, then delegates to the `plan()` service to compute the shopping plan.
export async function planShoppingHandler( registry: AdapterRegistry, input: PlanShoppingInput, ): Promise<PlanResult> { const geo = await geocode(input.near as any); if (!geo.ok) { const err = geo.error; if (err.code === 'unknown_zip') { throw new ToolError( 'unknown_zip', `ZIP "${(err as any).zip}" is not in the lookup table`, 'Pass { lat, lng } directly or check that the ZIP is a valid Swiss PLZ (e.g. "8001").', ); } if (err.code === 'address_not_found') { throw new ToolError( 'address_not_found', `Address "${(err as any).query}" could not be geocoded`, 'Try a more specific address or pass a Swiss ZIP code or { lat, lng } coordinates.', ); } if (err.code === 'unavailable') { throw new ToolError( 'unavailable', (err as any).reason, 'The Nominatim geocoding service is temporarily unavailable. Try passing a ZIP or { lat, lng } instead.', ); } throw new ToolError( err.code, 'address_unsupported' in err ? (err as any).reason : err.code, 'Pass a Swiss ZIP code or { lat, lng } coordinates instead of a free-text address.', ); } return plan(registry, { items: input.items, near: { lat: geo.data.lat, lng: geo.data.lng, city: geo.data.city }, chains: input.chains, strategy: input.strategy, splitPenaltyChf: input.splitPenaltyChf, radiusKm: input.radiusKm, }); } - src/tools/plan_shopping.ts:41-76 (schema)Zod schema `planShoppingSchema` defining the tool input: items array with query/quantity/filters, near location (lat/lng, zip, or address), chains filter, strategy enum, splitPenaltyChf, and radiusKm.
export const planShoppingSchema = z.object({ items: z.array(itemSchema).min(1) .describe('The list of items to shop for. At least one item required.'), near: z.union([ z.object({ lat: z.number().describe('Latitude in decimal degrees (WGS 84).'), lng: z.number().describe('Longitude in decimal degrees (WGS 84).'), }).describe('GPS coordinates of the shopper\'s location'), z.object({ zip: z.string().describe('Swiss postal code (PLZ / NPA), e.g. "8001".'), }).describe('Swiss postal code (PLZ), e.g. "8001"'), z.object({ address: z.string().describe('Free-text address string — geocoded via OpenStreetMap Nominatim; prefer zip or lat/lng for speed.'), }).describe('Free-text address — geocoded via Nominatim; prefer zip or lat/lng for speed'), ]).describe('Shopper\'s location — used to find nearby stores. Pass coordinates, ZIP, or address.'), chains: z.array(z.enum(['migros', 'coop', 'aldi', 'denner', 'lidl', 'farmy', 'volgshop', 'ottos'])) .optional() .describe('Restrict the plan to these chains. Omit to consider all configured chains.'), strategy: z.enum(['single_store', 'split_cart', 'absolute_cheapest']) .describe([ 'single_store: buy everything at one chain (minimises trips).', 'split_cart: allow multiple chains but add splitPenaltyChf per extra stop.', 'absolute_cheapest: pick the cheapest source per item regardless of stops.', ].join(' ')), splitPenaltyChf: z.number().nonnegative() .optional() .describe('Cost in CHF added per extra store stop in split_cart strategy. Default 2.00.'), radiusKm: z.number().positive().max(50) .optional() .describe('Only consider stores within this radius of the provided location (1–50 km). Default 5 km.'), }).describe([ 'Plan a multi-store shopping trip near a location, picking the best products across configured Swiss grocery chains.', 'Items can be generic ("milch", "pasta") or pinned to a specific SKU. Returns a primary plan plus alternatives.', 'Use when the user gives a list of items and asks "where should I shop?" or "what\'s cheapest?".', 'Strategies: single_store (one chain), split_cart (multi-chain with stop penalty), absolute_cheapest (no penalty).', ].join(' ')); - src/index.ts:111-121 (registration)Registration of the 'plan_shopping' tool in the TOOLS array, mapping name, description, schema (planShoppingSchema), and handler (planShoppingHandler) together for the MCP server.
{ name: 'plan_shopping', description: [ 'Plan a multi-store shopping trip near a location, picking the best products across configured Swiss grocery chains.', 'Items can be generic ("milch", "pasta") or pinned to a specific SKU. Returns a primary plan plus alternatives.', 'Use when the user gives a list of items and asks "where should I shop?" or "what\'s cheapest?".', 'Strategies: single_store (one chain), split_cart (multi-chain with stop penalty), absolute_cheapest (no penalty).', ].join(' '), schema: planShoppingSchema, handler: planShoppingHandler, }, - src/services/planner.ts:39-157 (helper)The `plan()` service function that orchestrates store search, product search, canonicality filtering, and strategy solving to produce the primary plan and alternatives.
export async function plan(registry: AdapterRegistry, input: PlanInput): Promise<PlanResult> { const adapters = registry.list(input.chains).filter((a) => a.capabilities.productSearch); const radius = input.radiusKm ?? 5; const splitPenalty = input.splitPenaltyChf ?? 2.0; const errors: ChainError[] = []; // 1. Fan out store search per chain const storeResults = await Promise.all( adapters.map(async (a) => ({ adapter: a, result: await a.searchStores({ near: input.near, radiusKm: radius, cityHint: input.near.city }), })), ); const storeByChain: Partial<Record<Chain, NormalizedStore>> = {}; for (const { adapter, result } of storeResults) { if (!result.ok) { errors.push({ chain: adapter.chain, code: result.error.code, reason: 'reason' in result.error ? result.error.reason : undefined }); continue; } if (result.data.length === 0) continue; const sorted = [...result.data].sort((a, b) => haversineKm(input.near, a.location) - haversineKm(input.near, b.location)); storeByChain[adapter.chain] = sorted[0]; } // 2. Fan out product search per (item × chain) const matrix: Matrix = {}; for (const item of input.items) matrix[keyOf(item)] = {}; await Promise.all( adapters.flatMap((adapter) => input.items.map(async (item) => { if (storeByChain[adapter.chain] === undefined && adapter.capabilities.storeSearch) { // No store found for this chain. Still attempt product search so that // a failing adapter registers in errors; on success, skip (no local store). const probe = await adapter.searchProducts({ query: item.query, limit: 1 }); if (!probe.ok) { if (!errors.some((e) => e.chain === adapter.chain)) { errors.push({ chain: adapter.chain, code: probe.error.code, reason: 'reason' in probe.error ? probe.error.reason : undefined }); } } matrix[keyOf(item)][adapter.chain] = null; return; } const r = await adapter.searchProducts({ query: item.query, tags: item.filters?.tags, maxPrice: item.filters?.maxPrice, sizeRange: item.filters?.sizeRange, limit: 20, }); if (!r.ok) { if (!errors.some((e) => e.chain === adapter.chain)) { errors.push({ chain: adapter.chain, code: r.error.code, reason: 'reason' in r.error ? r.error.reason : undefined }); } matrix[keyOf(item)][adapter.chain] = null; return; } matrix[keyOf(item)][adapter.chain] = matchProduct(item, r.data); }), ), ); // Canonicality filter: for each item, if at least one chain has a product // whose category contains a query token/synonym, drop all non-canonical chains // to null for that item. This prevents tangential products (e.g. Apfelschorle // for "apfel") from winning cross-chain comparisons against real apples. for (const item of input.items) { const key = keyOf(item); const offers = matrix[key]; if (!offers) continue; const canonical: typeof offers = {}; let hasCanonical = false; for (const [chain, product] of Object.entries(offers) as [Chain, NormalizedProduct | null | undefined][]) { if (product && isCanonical(product, item)) { canonical[chain] = product; hasCanonical = true; } } if (hasCanonical) { // Replace the matrix row with only canonical matches. // Tangential chains become null for this item. for (const chain of Object.keys(offers) as Chain[]) { if (!canonical[chain]) offers[chain] = null; } } // If no chain has a canonical match, leave the row alone (best-effort fallback). } const completedStoreByChain = Object.fromEntries( Object.entries(storeByChain).filter(([_, v]) => v !== undefined), ) as Record<Chain, NormalizedStore>; const primaryPlan = solve(input.strategy, input.items, matrix, { splitPenaltyChf: splitPenalty, storeByChain: completedStoreByChain, }); // Build alternatives const altStrategies: Strategy[] = []; if (input.strategy !== 'single_store') altStrategies.push('single_store'); if (input.strategy !== 'split_cart') altStrategies.push('split_cart'); if (input.strategy !== 'absolute_cheapest') altStrategies.push('absolute_cheapest'); const alternatives: PlanWithMeta[] = altStrategies .map((s) => solve(s, input.items, matrix, { splitPenaltyChf: splitPenalty, storeByChain: completedStoreByChain, })) .filter((p) => p.stops.length > 0) .slice(0, 2); return { primary: { ...primaryPlan, unavailableChains: errors.length ? errors : undefined }, alternatives, }; } - src/services/strategy.ts:36-182 (helper)The `solve()` function and its sub-strategies (solveSingleStore, solveSplit) implementing the three strategies: single_store, split_cart, and absolute_cheapest.
export function solve( strategy: Strategy, items: ShoppingItem[], matrix: Matrix, opts: SolveOpts, ): Plan { const penalty = strategy === 'absolute_cheapest' ? 0 : opts.splitPenaltyChf; if (strategy === 'single_store') { return solveSingleStore(items, matrix, opts); } return solveSplit(strategy, items, matrix, { ...opts, splitPenaltyChf: penalty }); } function solveSingleStore(items: ShoppingItem[], matrix: Matrix, opts: SolveOpts): Plan { const chainSet = new Set<Chain>(); for (const offers of Object.values(matrix)) { for (const chain of Object.keys(offers ?? {}) as Chain[]) { chainSet.add(chain); } } const chains = [...chainSet]; let best: { chain: Chain; total: number; coverage: number; lines: PlanStop['items'] } | null = null; for (const chain of chains) { let total = 0; let coverage = 0; const lines: PlanStop['items'] = []; for (const item of items) { const product = matrix[keyOf(item)]?.[chain]; if (product) { const qty = item.quantity ?? 1; const line = product.price.current * qty; total += line; coverage++; lines.push({ requested: item, matched: product, lineTotal: line }); } } if ( best === null || coverage > best.coverage || (coverage === best.coverage && total < best.total) ) { best = { chain, total, coverage, lines }; } } if (!best || best.coverage === 0) { return { strategy: 'single_store', totalChf: 0, stops: [], unmatchedItems: items }; } const unmatched = items.filter( (item) => !best!.lines.find((l) => keyOf(l.requested) === keyOf(item)), ); return { strategy: 'single_store', totalChf: best.total, stops: [ { store: opts.storeByChain?.[best.chain] ? { chain: best.chain, id: opts.storeByChain[best.chain]!.id, name: opts.storeByChain[best.chain]!.name } : { chain: best.chain }, items: best.lines, subtotalChf: best.total, }, ], unmatchedItems: unmatched, }; } function solveSplit( strategy: 'split_cart' | 'absolute_cheapest', items: ShoppingItem[], matrix: Matrix, opts: SolveOpts, ): Plan { const perChain: Map<Chain, PlanStop['items']> = new Map(); const unmatched: ShoppingItem[] = []; for (const item of items) { const offers = matrix[keyOf(item)] ?? {}; // Cross-chain comparison: prefer the cheapest UNIT price (CHF/kg, CHF/l, // CHF/piece) so a 0.5L bottle isn't unfairly preferred over a 6×1.5L pack. // Restrict to products that share a `unitPrice.per` unit. Fall back to // absolute price when no candidate has a unit price (or units are mixed). const candidates: Array<[Chain, NormalizedProduct]> = []; for (const [chain, product] of Object.entries(offers) as [Chain, NormalizedProduct | null | undefined][]) { if (product) candidates.push([chain, product]); } if (candidates.length === 0) { unmatched.push(item); continue; } const withUnit = candidates.filter(([, p]) => p.unitPrice !== undefined); let pool = candidates; if (withUnit.length > 0) { // Pick the dominant `per` unit (most candidates share it) to compare like-for-like. const counts: Record<string, number> = {}; for (const [, p] of withUnit) counts[p.unitPrice!.per] = (counts[p.unitPrice!.per] ?? 0) + 1; const dominantPer = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; pool = withUnit.filter(([, p]) => p.unitPrice!.per === dominantPer); } // Prefer single packs unless the user asked for a multipack via query syntax. const userWantsBulk = /\d+\s*[x×]\s*\d|multipack|sixpack|sechserpack/i.test(item.query); if (!userWantsBulk) { const singles = pool.filter(([, p]) => !isMultipack(p)); if (singles.length > 0) pool = singles; } pool.sort((a, b) => { const ap = a[1].unitPrice?.value ?? a[1].price.current; const bp = b[1].unitPrice?.value ?? b[1].price.current; return ap - bp; }); const [bestChain, bestProduct] = pool[0]; const qty = item.quantity ?? 1; const line = { requested: item, matched: bestProduct, lineTotal: bestProduct.price.current * qty }; if (!perChain.has(bestChain)) perChain.set(bestChain, []); perChain.get(bestChain)!.push(line); } const stops: PlanStop[] = []; let total = 0; for (const [chain, lines] of perChain.entries()) { const subtotal = lines.reduce((s, l) => s + l.lineTotal, 0); total += subtotal; stops.push({ store: opts.storeByChain?.[chain] ? { chain, id: opts.storeByChain[chain]!.id, name: opts.storeByChain[chain]!.name } : { chain }, items: lines, subtotalChf: subtotal, }); } if (stops.length > 1) total += opts.splitPenaltyChf * (stops.length - 1); return { strategy, totalChf: total, stops, unmatchedItems: unmatched }; }