Skip to main content
Glama
nicktcode

Swissgroceries MCP

plan_shopping

Plan a multi-store shopping trip near your location by comparing prices across Swiss grocery chains. Get the cheapest plan with alternatives.

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 executes the plan_shopping tool logic. It geocodes the input location, then delegates to the 'plan' service function to compute the multi-store 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,
      });
    }
  • The input schema 'planShoppingSchema' (Zod) defining the tool's input: items (array with query, quantity, preferredChain, preferredProductId, filters), near (lat/lng, zip, or address), chains, strategy (single_store/split_cart/absolute_cheapest), 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(' '));
  • The item sub-schema defining each shopping list item with query, quantity, preferredChain, preferredProductId (chain+id pin), and filters (tags, maxPrice, sizeRange).
    const itemSchema = z.object({
      query: z.string().min(1)
        .describe('Product search term, e.g. "Milch", "pasta integrale". The planner searches this across chains.'),
      quantity: z.number().int().positive()
        .optional()
        .describe('Number of units needed (default 1). Used for total cost calculation.'),
      preferredChain: z.enum(['migros', 'coop', 'aldi', 'denner', 'lidl', 'farmy', 'volgshop', 'ottos'])
        .optional()
        .describe('Prefer this chain for this item when scores are tied. Useful for loyalty card preferences.'),
      preferredProductId: z.object({
        chain: z.enum(['migros', 'coop', 'aldi', 'denner', 'lidl', 'farmy', 'volgshop', 'ottos'])
          .describe('Chain the pinned product belongs to.'),
        id: z.string()
          .describe('Exact product ID to use for this line item; bypasses search.'),
      }).optional()
        .describe('Pin a specific product by chain + ID. If set, query is ignored for product selection.'),
      filters: z.object({
        tags: z.array(z.enum(TAG_VALUES))
          .optional()
          .describe('Require all listed tags on matching products, e.g. ["organic", "vegan"].'),
        maxPrice: z.number().positive()
          .optional()
          .describe('Reject products above this CHF price per unit.'),
        sizeRange: z.object({
          minMl: z.number().nonnegative().optional().describe('Minimum size in ml.'),
          maxMl: z.number().nonnegative().optional().describe('Maximum size in ml.'),
        }).optional()
          .describe('Size range filter in millilitres; useful for beverages.'),
      }).optional()
        .describe('Optional product constraints applied when searching for this item.'),
    }).describe('A single item in the shopping list.');
  • src/index.ts:111-121 (registration)
    Tool registration in the TOOLS array: name 'plan_shopping' with description, schema (planShoppingSchema), and handler (planShoppingHandler).
    {
      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' function that orchestrates the shopping plan: fans out store searches per chain, fans out product searches per item×chain, applies canonicality filtering, then calls 'solve' for the primary strategy 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,
      };
    }
Behavior3/5

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

No annotations are provided, so the description carries full responsibility. It mentions return of 'primary plan plus alternatives' and strategies, but does not elaborate on side effects, rate limits, or error behavior (e.g., no stores found). It is adequate but not detailed.

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?

Four sentences, front-loaded with purpose, no redundant text. Each sentence contributes context: purpose, item types, usage hint, and strategy options.

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?

Covers high-level purpose, item flexibility, and strategies. However, it lacks details on the return format (no output schema) and does not describe what happens when constraints fail (e.g., no matching products). Still, it is largely complete for a planning tool.

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 thoroughly. The description adds little new information beyond strategies, making it a baseline 3.

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 specifies the tool's function: planning a multi-store shopping trip near a location, picking the best products across Swiss grocery chains. It distinguishes itself from siblings like find_stock and find_stores, which handle single-store queries or product lookups.

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

Usage Guidelines4/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?"' and outlines three strategies. However, it lacks explicit when-not-to-use guidance compared to alternatives.

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