Skip to main content
Glama
nicktcode

Swissgroceries MCP

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

TableJSON Schema
NameRequiredDescriptionDefault
itemsYesThe list of items to shop for. At least one item required.
nearYesShopper's location — used to find nearby stores. Pass coordinates, ZIP, or address.
chainsNoRestrict the plan to these chains. Omit to consider all configured chains.
strategyYessingle_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.
splitPenaltyChfNoCost in CHF added per extra store stop in split_cart strategy. Default 2.00.
radiusKmNoOnly consider stores within this radius of the provided location (1–50 km). Default 5 km.

Implementation Reference

  • 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,
      });
    }
  • 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,
    },
  • 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,
      };
    }
  • 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 };
    }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, so description carries full burden. It discloses that items can be generic or pinned, and that the tool returns a primary plan plus alternatives. It also mentions the use of configured chains and location-based store finding. However, it does not describe the output format or any side effects, but given it's a planning tool, that's acceptable.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is three sentences, each adding value: purpose, use case, strategies. No redundancy or fluff; information is front-loaded.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool has 6 parameters (3 required) and no output schema, the description covers the core functionality, usage, and strategies. It lacks details about return format or error handling, but is sufficient for most intended use cases.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the schema already documents all parameters with descriptions. The description adds context by grouping strategies and clarifying usage, but does not significantly augment the schema's own parameter explanations.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: plan a multi-store shopping trip near a location, selecting best products across Swiss grocery chains. It differentiates from sibling tools (e.g., search_products, find_stores) by focusing on trip planning with multiple stores and strategies.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly states when to use: 'Use when the user gives a list of items and asks "where should I shop?" or "what's cheapest?"'. Also describes three strategies to guide selection.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nicktcode/swissgroceries-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server