Skip to main content
Glama
nicktcode

Swissgroceries MCP

plan_shopping

Compare prices across Swiss grocery chains and generate an optimal shopping plan for your list, choosing the cheapest single or multi-store route near your location.

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

  • Core handler function that geocodes the location input and delegates to the planner service.
    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 defining the input structure: items array, near location (lat/lng, zip, or address), chains filter, strategy, 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(' '));
    
    export type PlanShoppingInput = z.infer<typeof planShoppingSchema>;
  • src/index.ts:111-121 (registration)
    Registration of the plan_shopping tool in the TOOLS array, linking its name, description, Zod schema, and handler.
    {
      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 planner service that fans out store searches and product searches across chains, applies canonicality filtering, solves the strategy, and returns primary + alternative plans.
    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,
      };
    }
  • Custom error class used by the handler to throw structured errors with a code, message, and hint.
    export class ToolError extends Error {
      constructor(
        public readonly code: string,
        message: string,
        public readonly hint?: string,
      ) {
        super(message);
        this.name = 'ToolError';
      }
    }
Behavior3/5

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

No annotations are provided, so the description must carry the full burden. It mentions strategies and returns a plan with alternatives, but lacks details on side effects, auth requirements, or rate limits. The disclosure is adequate but not comprehensive.

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 concise (about 100 words) and front-loaded with the main purpose. It efficiently covers key points without redundancy, making it easy for an agent to parse.

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

Completeness3/5

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

Given the absence of an output schema, the description only vaguely states 'Returns a primary plan plus alternatives,' lacking specifics on response structure or key fields. It does not fully compensate for the missing output schema, leaving room for ambiguity.

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?

The input schema has 100% description coverage, with each parameter well-documented. The description adds context about generic vs pinned items and strategies, but does not significantly enhance understanding beyond the schema.

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: planning a multi-store shopping trip with product selection across Swiss chains, and returning a primary plan plus alternatives. It distinguishes itself from sibling tools like search_products or find_stores by focusing on trip planning and optimization.

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?

The description explicitly says 'Use when the user gives a list of items and asks "where should I shop?" or "what's cheapest?"', providing clear usage context. However, it does not explicitly say when not to use it or mention alternatives among siblings.

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