feat(skills): add marktguru supermarket offer and price comparison skill
Introduce the marktguru agent skill and MCP integration for searching supermarket deals, comparing product prices, and querying supported retail chains: - Add Marktguru API client and MCP server script - Add skill definition (SKILL.md) and supported supermarkets reference - Add test suite covering Marktguru API integration and offer queries
This commit is contained in:
Binary file not shown.
Binary file not shown.
+577
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Marktguru API Client (Production Grade)
|
||||
Fetches promotional offers and discounts from German supermarkets via the Marktguru API.
|
||||
|
||||
Key Design Principles:
|
||||
- Zero external dependencies (uses standard library only: urllib, json, os, time, datetime).
|
||||
- Resilient: Retries with exponential backoff on transient errors.
|
||||
- Low Maintenance: Configurable keys and postal codes via environment variables.
|
||||
- Fail-Safe: Caching with stale fallback if upstream API is temporarily unreachable.
|
||||
- Defensive: Robust parsing protecting against upstream schema shifts and malformed payloads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
|
||||
# Logger writing to stderr to keep stdout pure for JSON / CLI
|
||||
logger = logging.getLogger("marktguru_client")
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
API_BASE = "https://api.marktguru.de/api/v1"
|
||||
|
||||
# Environment variable overrides for zero-code configuration changes
|
||||
DEFAULT_ZIP_CODE = os.environ.get("MARKTGURU_ZIP_CODE", "93059")
|
||||
CLIENT_KEY = os.environ.get("MARKTGURU_CLIENT_KEY", "WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90=")
|
||||
API_KEY = os.environ.get("MARKTGURU_API_KEY", "8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=")
|
||||
CACHE_TTL = int(os.environ.get("MARKTGURU_CACHE_TTL", "1800")) # 30 minutes
|
||||
CACHE_ENABLED = os.environ.get("MARKTGURU_CACHE_ENABLED", "true").lower() in ("true", "1", "yes")
|
||||
|
||||
HEADERS = {
|
||||
"x-clientkey": CLIENT_KEY,
|
||||
"x-apikey": API_KEY,
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Major supermarket chains and their Marktguru retailer IDs
|
||||
MAJOR_RETAILERS: Dict[str, Dict[str, Any]] = {
|
||||
"ALDI SÜD": {"id": 127153, "type": "Discounter", "major": True},
|
||||
"ALDI NORD": {"id": 127139, "type": "Discounter", "major": True},
|
||||
"Lidl": {"id": 126679, "type": "Discounter", "major": True},
|
||||
"EDEKA": {"id": 127093, "type": "Supermarkt", "major": True},
|
||||
"E center": {"id": 126699, "type": "Supermarkt", "major": True},
|
||||
"REWE": {"id": 126802, "type": "Supermarkt", "major": True},
|
||||
"REWE Center": {"id": 126804, "type": "Supermarkt", "major": True},
|
||||
"Kaufland": {"id": 126654, "type": "Großfläche/Hypermarkt", "major": True},
|
||||
"PENNY": {"id": 126765, "type": "Discounter", "major": True},
|
||||
"Netto Marken-Discount": {"id": 126735, "type": "Discounter", "major": True},
|
||||
"Netto (mit Hund)": {"id": 126734, "type": "Discounter", "major": False},
|
||||
"NORMA": {"id": 126745, "type": "Discounter", "major": True},
|
||||
"Globus": {"id": 127177, "type": "Großfläche/Hypermarkt", "major": True},
|
||||
"HIT": {"id": 126598, "type": "Supermarkt", "major": False},
|
||||
"tegut": {"id": 126866, "type": "Supermarkt", "major": False},
|
||||
"nahkauf": {"id": 137320, "type": "Supermarkt", "major": False},
|
||||
"dm-drogerie markt": {"id": 127057, "type": "Drogerie", "major": True},
|
||||
"ROSSMANN": {"id": 126811, "type": "Drogerie", "major": True},
|
||||
"Müller Drogeriemarkt": {"id": 126720, "type": "Drogerie", "major": True},
|
||||
}
|
||||
|
||||
# Aliases mapping user query strings to Marktguru retailer IDs
|
||||
RETAILER_ALIASES: Dict[str, List[int]] = {
|
||||
"aldi": [127153, 127139],
|
||||
"aldi süd": [127153],
|
||||
"aldi sued": [127153],
|
||||
"aldi nord": [127139],
|
||||
"lidl": [126679],
|
||||
"edeka": [127093, 126699],
|
||||
"e center": [126699],
|
||||
"ecenter": [126699],
|
||||
"rewe": [126802, 126804],
|
||||
"kaufland": [126654],
|
||||
"penny": [126765],
|
||||
"netto": [126735, 126734],
|
||||
"norma": [126745],
|
||||
"globus": [127177],
|
||||
"hit": [126598],
|
||||
"tegut": [126866],
|
||||
"nahkauf": [137320],
|
||||
"dm": [127057],
|
||||
"rossmann": [126811],
|
||||
"müller": [126720],
|
||||
"mueller": [126720],
|
||||
}
|
||||
|
||||
|
||||
def _get_cache_dir() -> str:
|
||||
"""Returns directory path for local cached responses."""
|
||||
cache_dir = os.path.join(tempfile.gettempdir(), "marktguru_cache")
|
||||
try:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _read_cache(cache_key: str, max_age_seconds: int = CACHE_TTL) -> Tuple[Optional[Dict[str, Any]], bool]:
|
||||
"""
|
||||
Reads cache file. Returns (data, is_fresh).
|
||||
If cache is expired, returns (data, False) so caller can use it as emergency fallback.
|
||||
"""
|
||||
if not CACHE_ENABLED:
|
||||
return None, False
|
||||
|
||||
cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json")
|
||||
if not os.path.isfile(cache_file):
|
||||
return None, False
|
||||
|
||||
try:
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
cached_time = payload.get("_cached_at", 0)
|
||||
age = time.time() - cached_time
|
||||
data = payload.get("data")
|
||||
is_fresh = age < max_age_seconds
|
||||
return data, is_fresh
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read cache {cache_key}: {e}")
|
||||
return None, False
|
||||
|
||||
|
||||
def _write_cache(cache_key: str, data: Dict[str, Any]) -> None:
|
||||
"""Writes data to cache file atomically."""
|
||||
if not CACHE_ENABLED:
|
||||
return
|
||||
|
||||
cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json")
|
||||
temp_file = f"{cache_file}.tmp.{os.getpid()}"
|
||||
try:
|
||||
payload = {
|
||||
"_cached_at": time.time(),
|
||||
"data": data,
|
||||
}
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False)
|
||||
os.replace(temp_file, cache_file)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache {cache_key}: {e}")
|
||||
if os.path.exists(temp_file):
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def resolve_supermarket_ids(supermarkets: Optional[List[str]]) -> Optional[str]:
|
||||
"""Resolves human supermarket names/aliases to comma-separated Marktguru retailer IDs."""
|
||||
if not supermarkets:
|
||||
return None
|
||||
|
||||
# Handle string passed instead of list (e.g. "Aldi, Lidl")
|
||||
if isinstance(supermarkets, str):
|
||||
supermarkets = [s.strip() for s in supermarkets.split(",") if s.strip()]
|
||||
|
||||
resolved_ids: Set[int] = set()
|
||||
for name in supermarkets:
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
cleaned = name.strip().lower()
|
||||
if not cleaned:
|
||||
continue
|
||||
|
||||
if cleaned in RETAILER_ALIASES:
|
||||
resolved_ids.update(RETAILER_ALIASES[cleaned])
|
||||
elif cleaned.isdigit():
|
||||
resolved_ids.add(int(cleaned))
|
||||
else:
|
||||
found = False
|
||||
for r_name, r_info in MAJOR_RETAILERS.items():
|
||||
if cleaned in r_name.lower():
|
||||
resolved_ids.add(r_info["id"])
|
||||
found = True
|
||||
if not found:
|
||||
logger.warning(f"Unknown supermarket name '{name}' skipped.")
|
||||
|
||||
return ",".join(str(i) for i in sorted(resolved_ids)) if resolved_ids else None
|
||||
|
||||
|
||||
def format_iso_date(iso_str: Optional[str]) -> str:
|
||||
"""Formats an ISO-8601 date string to human-readable DD.MM.YYYY format."""
|
||||
if not iso_str or not isinstance(iso_str, str):
|
||||
return "-"
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
return iso_str[:10] if len(iso_str) >= 10 else iso_str
|
||||
|
||||
|
||||
def parse_offer(raw: Any, zip_code: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Defensively parses and standardizes an offer payload from Marktguru.
|
||||
Protects against null fields, malformed dates, and missing keys.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
try:
|
||||
offer_id = raw.get("id")
|
||||
price = raw.get("price")
|
||||
if isinstance(price, (int, float)):
|
||||
price = round(float(price), 2)
|
||||
else:
|
||||
price = None
|
||||
|
||||
old_price = raw.get("oldPrice")
|
||||
if isinstance(old_price, (int, float)):
|
||||
old_price = round(float(old_price), 2)
|
||||
else:
|
||||
old_price = None
|
||||
|
||||
ref_price = raw.get("referencePrice")
|
||||
if isinstance(ref_price, (int, float)):
|
||||
ref_price = round(float(ref_price), 2)
|
||||
else:
|
||||
ref_price = None
|
||||
|
||||
discount_pct = None
|
||||
if old_price and price and old_price > price:
|
||||
discount_pct = round(((old_price - price) / old_price) * 100)
|
||||
|
||||
# Validity dates
|
||||
validity = raw.get("validityDates")
|
||||
valid_from_raw = None
|
||||
valid_to_raw = None
|
||||
is_valid_now = True
|
||||
days_remaining = None
|
||||
|
||||
if isinstance(validity, list) and len(validity) > 0 and isinstance(validity[0], dict):
|
||||
valid_from_raw = validity[0].get("from")
|
||||
valid_to_raw = validity[0].get("to")
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
if valid_to_raw and isinstance(valid_to_raw, str):
|
||||
dt_to = datetime.fromisoformat(valid_to_raw.replace("Z", "+00:00"))
|
||||
diff = (dt_to - now).days
|
||||
days_remaining = max(0, diff)
|
||||
if dt_to < now:
|
||||
is_valid_now = False
|
||||
|
||||
if valid_from_raw and isinstance(valid_from_raw, str):
|
||||
dt_from = datetime.fromisoformat(valid_from_raw.replace("Z", "+00:00"))
|
||||
if dt_from > now:
|
||||
is_valid_now = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Supermarket / advertiser extraction
|
||||
supermarket = "Unbekannt"
|
||||
supermarket_id = None
|
||||
advertisers = raw.get("advertisers")
|
||||
if isinstance(advertisers, list) and len(advertisers) > 0 and isinstance(advertisers[0], dict):
|
||||
supermarket = advertisers[0].get("name") or "Unbekannt"
|
||||
supermarket_id = advertisers[0].get("id")
|
||||
|
||||
# High resolution CDN image
|
||||
image_url = None
|
||||
if offer_id:
|
||||
image_url = f"https://mg2de.b-cdn.net/api/v1/offers/{offer_id}/images/default/0/medium.webp"
|
||||
|
||||
# Unit & volume
|
||||
unit_info = raw.get("unit")
|
||||
unit_name = None
|
||||
if isinstance(unit_info, dict):
|
||||
unit_name = unit_info.get("shortName") or unit_info.get("name")
|
||||
|
||||
product_info = raw.get("product")
|
||||
product_name = None
|
||||
if isinstance(product_info, dict):
|
||||
product_name = product_info.get("name")
|
||||
|
||||
brand_info = raw.get("brand")
|
||||
brand_name = None
|
||||
if isinstance(brand_info, dict):
|
||||
brand_name = brand_info.get("name")
|
||||
|
||||
return {
|
||||
"id": offer_id,
|
||||
"supermarket": supermarket,
|
||||
"supermarket_id": supermarket_id,
|
||||
"product_name": product_name,
|
||||
"brand": brand_name,
|
||||
"description": raw.get("description"),
|
||||
"price": price,
|
||||
"old_price": old_price,
|
||||
"reference_price": ref_price,
|
||||
"discount_percent": discount_pct,
|
||||
"unit": unit_name,
|
||||
"volume": raw.get("volume"),
|
||||
"loyalty_app_required": bool(raw.get("requiresLoyalityMembership", False)),
|
||||
"is_valid_now": is_valid_now,
|
||||
"days_remaining": days_remaining,
|
||||
"valid_from": format_iso_date(valid_from_raw),
|
||||
"valid_to": format_iso_date(valid_to_raw),
|
||||
"image_url": image_url,
|
||||
"zip_code": str(zip_code),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing single offer {raw.get('id') if isinstance(raw, dict) else raw}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_with_retry(url: str, max_retries: int = 2) -> Dict[str, Any]:
|
||||
"""
|
||||
Executes HTTP request with exponential backoff on transient errors (5xx / network blips).
|
||||
"""
|
||||
last_err: Optional[Exception] = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
req = urllib.request.Request(url, headers=HEADERS)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw_bytes = resp.read()
|
||||
return json.loads(raw_bytes.decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
# Retry only on transient server errors (500, 502, 503, 504) or rate limits (429)
|
||||
if e.code in (429, 500, 502, 503, 504) and attempt < max_retries:
|
||||
backoff = 0.5 * (2 ** attempt)
|
||||
logger.warning(f"Marktguru HTTP {e.code}. Retrying in {backoff:.1f}s (attempt {attempt+1}/{max_retries})...")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
last_err = e
|
||||
if attempt < max_retries:
|
||||
backoff = 0.5 * (2 ** attempt)
|
||||
logger.warning(f"Connection issue ({e}). Retrying in {backoff:.1f}s...")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
raise
|
||||
|
||||
raise last_err or RuntimeError("Max retries exceeded")
|
||||
|
||||
|
||||
def search_offers(
|
||||
query: str,
|
||||
zip_code: str = DEFAULT_ZIP_CODE,
|
||||
supermarkets: Optional[List[str]] = None,
|
||||
limit: int = 24,
|
||||
offset: int = 0,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Searches current supermarket offers in Germany via the Marktguru API.
|
||||
|
||||
Args:
|
||||
query: Search term (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch')
|
||||
zip_code: German postal code (default: '93059' Regensburg)
|
||||
supermarkets: List of supermarket names or aliases (e.g. ['Aldi', 'Lidl', 'Edeka'])
|
||||
limit: Max number of items to return (default: 24, max: 50)
|
||||
offset: Result pagination offset
|
||||
force_refresh: If True, bypasses local TTL cache
|
||||
"""
|
||||
cleaned_query = (query or "").strip()
|
||||
cleaned_zip = str(zip_code or DEFAULT_ZIP_CODE).strip()
|
||||
limit = max(1, min(int(limit), 50))
|
||||
offset = max(0, int(offset))
|
||||
|
||||
retailer_ids = resolve_supermarket_ids(supermarkets)
|
||||
|
||||
# Compute deterministic cache key
|
||||
cache_raw = f"{cleaned_query.lower()}:{cleaned_zip}:{retailer_ids or ''}:{limit}:{offset}"
|
||||
cache_key = hashlib.sha256(cache_raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
if not force_refresh:
|
||||
cached_data, is_fresh = _read_cache(cache_key)
|
||||
if is_fresh and cached_data:
|
||||
return cached_data
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"as": "web",
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"q": cleaned_query,
|
||||
"zipCode": cleaned_zip,
|
||||
}
|
||||
if retailer_ids:
|
||||
params["retailers"] = retailer_ids
|
||||
|
||||
url = f"{API_BASE}/offers/search?{urllib.parse.urlencode(params)}"
|
||||
|
||||
try:
|
||||
data = _fetch_with_retry(url)
|
||||
except Exception as e:
|
||||
logger.error(f"Marktguru API query failed: {e}")
|
||||
# Emergency fail-safe: Check if stale cache is available
|
||||
cached_data, _ = _read_cache(cache_key, max_age_seconds=86400 * 7)
|
||||
if cached_data:
|
||||
logger.warning("Serving stale cached data due to API failure.")
|
||||
cached_data["warning"] = "Stale cache returned due to temporary upstream API issue."
|
||||
return cached_data
|
||||
|
||||
return {
|
||||
"error": f"API request failed: {str(e)}",
|
||||
"query": cleaned_query,
|
||||
"zip_code": cleaned_zip,
|
||||
"total_results": 0,
|
||||
"returned_count": 0,
|
||||
"offers": [],
|
||||
"available_retailers": [],
|
||||
}
|
||||
|
||||
raw_results = data.get("results") or []
|
||||
parsed_offers: List[Dict[str, Any]] = []
|
||||
for item in raw_results:
|
||||
parsed = parse_offer(item, cleaned_zip)
|
||||
if parsed is not None:
|
||||
parsed_offers.append(parsed)
|
||||
|
||||
filters = data.get("filters") or {}
|
||||
raw_retailers = filters.get("retailers") or []
|
||||
available_retailers = [
|
||||
{"name": r.get("name"), "id": r.get("id"), "count": r.get("resultsCount")}
|
||||
for r in raw_retailers if isinstance(r, dict) and r.get("name")
|
||||
]
|
||||
|
||||
result = {
|
||||
"query": cleaned_query,
|
||||
"zip_code": cleaned_zip,
|
||||
"total_results": data.get("totalResults", len(parsed_offers)),
|
||||
"returned_count": len(parsed_offers),
|
||||
"available_retailers": available_retailers,
|
||||
"offers": parsed_offers,
|
||||
}
|
||||
|
||||
_write_cache(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def compare_prices(
|
||||
query: str,
|
||||
zip_code: str = DEFAULT_ZIP_CODE,
|
||||
supermarkets: Optional[List[str]] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compares prices for a given product query across supermarkets, sorted from cheapest to most expensive.
|
||||
"""
|
||||
data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=50, force_refresh=force_refresh)
|
||||
offers = data.get("offers", [])
|
||||
|
||||
# Filter out items without price and sort ascending
|
||||
priced_offers = [o for o in offers if o.get("price") is not None]
|
||||
priced_offers.sort(key=lambda x: x["price"])
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"zip_code": str(zip_code),
|
||||
"total_compared": len(priced_offers),
|
||||
"cheapest_offer": priced_offers[0] if priced_offers else None,
|
||||
"all_offers_sorted": priced_offers,
|
||||
}
|
||||
|
||||
|
||||
def list_supported_supermarkets() -> Dict[str, Any]:
|
||||
"""Returns a structured catalog of all major supermarket chains supported by Marktguru."""
|
||||
categories: Dict[str, List[Dict[str, Any]]] = {
|
||||
"Discounter": [],
|
||||
"Supermarkt": [],
|
||||
"Großfläche / Hypermarkt": [],
|
||||
"Drogerie": [],
|
||||
}
|
||||
|
||||
for name, info in MAJOR_RETAILERS.items():
|
||||
cat = info.get("type", "Sonstige")
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append({
|
||||
"name": name,
|
||||
"id": info["id"],
|
||||
"major": info.get("major", False)
|
||||
})
|
||||
|
||||
return {
|
||||
"default_zip_code": DEFAULT_ZIP_CODE,
|
||||
"cache_ttl_seconds": CACHE_TTL,
|
||||
"supported_chains_count": len(MAJOR_RETAILERS),
|
||||
"categories": categories,
|
||||
"supported_aliases": list(RETAILER_ALIASES.keys()),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Query German supermarket flyers/discounts via Marktguru")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# Search
|
||||
search_p = subparsers.add_parser("search", help="Search offers for a product")
|
||||
search_p.add_argument("query", help="Product to search (e.g. Butter, Kaffee)")
|
||||
search_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})")
|
||||
search_p.add_argument("--supermarket", help="Comma-separated supermarkets (e.g. 'Aldi,Lidl,Edeka')")
|
||||
search_p.add_argument("--limit", type=int, default=15, help="Max results (default 15)")
|
||||
search_p.add_argument("--refresh", action="store_true", help="Bypass local cache")
|
||||
search_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
# Compare
|
||||
comp_p = subparsers.add_parser("compare", help="Compare prices across supermarkets")
|
||||
comp_p.add_argument("query", help="Product to compare")
|
||||
comp_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})")
|
||||
comp_p.add_argument("--supermarket", help="Comma-separated supermarkets")
|
||||
comp_p.add_argument("--refresh", action="store_true", help="Bypass local cache")
|
||||
comp_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
# Supermarkets
|
||||
subparsers.add_parser("supermarkets", help="List all supported supermarkets and chains")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "supermarkets":
|
||||
data = list_supported_supermarkets()
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
sm_list = [s.strip() for s in args.supermarket.split(",")] if getattr(args, "supermarket", None) else None
|
||||
|
||||
if args.command == "search":
|
||||
result = search_offers(
|
||||
args.query,
|
||||
zip_code=args.zip,
|
||||
supermarkets=sm_list,
|
||||
limit=args.limit,
|
||||
force_refresh=args.refresh
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
if "error" in result and not result.get("offers"):
|
||||
print(f"❌ Fehler: {result['error']}")
|
||||
return
|
||||
|
||||
print(f"\n🔍 Angebote für '{args.query}' in PLZ {args.zip} (Gefunden: {result['total_results']}):\n")
|
||||
for o in result["offers"]:
|
||||
disc_str = f" (-{o['discount_percent']}%)" if o.get("discount_percent") else ""
|
||||
loyalty_str = " 📱 [App Coupon]" if o.get("loyalty_app_required") else ""
|
||||
timing_str = f"noch {o['days_remaining']} Tage" if o.get("days_remaining") is not None else f"bis {o['valid_to']}"
|
||||
print(f" • [{o['supermarket']}] {o['description'] or o['product_name'] or 'Angebot'}")
|
||||
print(f" Preis: {o['price']:.2f} €{disc_str}{loyalty_str} | Gültig: {timing_str}")
|
||||
if o.get("reference_price"):
|
||||
print(f" Grundpreis: {o['reference_price']:.2f} € / {o.get('unit') or 'Einheit'}")
|
||||
print()
|
||||
|
||||
elif args.command == "compare":
|
||||
result = compare_prices(
|
||||
args.query,
|
||||
zip_code=args.zip,
|
||||
supermarkets=sm_list,
|
||||
force_refresh=args.refresh
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"\n🏷️ Preisvergleich für '{args.query}' in PLZ {args.zip}:\n")
|
||||
for idx, o in enumerate(result["all_offers_sorted"], 1):
|
||||
disc_str = f" (UVP {o['old_price']:.2f}€)" if o.get("old_price") else ""
|
||||
loyalty_str = " [App erforderlich]" if o.get("loyalty_app_required") else ""
|
||||
print(f" {idx:2d}. {o['price']:.2f} € - [{o['supermarket']}] {o['description'] or o['product_name']}{disc_str}{loyalty_str}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Marktguru MCP (Model Context Protocol) Server (Production Grade)
|
||||
Exposes German supermarket flyers, discounts, and price comparison tools over standard JSON-RPC 2.0 stdio.
|
||||
|
||||
Key Design Principles:
|
||||
- Zero external dependencies: Uses standard library only.
|
||||
- Strict Protocol Compliance: Conforms to MCP (2024-11-05) and JSON-RPC 2.0 specifications.
|
||||
- Defensive Argument Coercion: Tolerates string/number variations commonly output by LLMs.
|
||||
- Graceful Signal Handling: Clean exit on SIGINT, SIGTERM, and EOF.
|
||||
- Stream Isolation: All logs and diagnostics route to stderr, ensuring stdout is 100% clean JSON-RPC.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import signal
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Import business logic from hardened client script
|
||||
from marktguru_client import (
|
||||
search_offers,
|
||||
compare_prices,
|
||||
list_supported_supermarkets,
|
||||
DEFAULT_ZIP_CODE,
|
||||
MAJOR_RETAILERS,
|
||||
RETAILER_ALIASES,
|
||||
)
|
||||
|
||||
# Configure logging strictly to stderr
|
||||
logging.basicConfig(
|
||||
stream=sys.stderr,
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("marktguru-mcp")
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
SERVER_NAME = "marktguru-mcp-server"
|
||||
SERVER_VERSION = "1.1.0"
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "search_supermarket_offers",
|
||||
"description": (
|
||||
"Search current weekly supermarket offers and promotional discounts in Germany via the Marktguru API. "
|
||||
"Supports filtering by postal code (PLZ) and specific supermarket chains (e.g. Aldi Süd, Aldi Nord, Lidl, Edeka, Rewe, Kaufland, Penny, Netto)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The product or keyword to search for (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch', 'Nutella', 'Schokolade')."
|
||||
},
|
||||
"zip_code": {
|
||||
"type": "string",
|
||||
"description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)."
|
||||
},
|
||||
"supermarkets": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of supermarket names to filter by (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe', 'Kaufland', 'Penny', 'Netto', 'dm', 'Rossmann']). If omitted, all available retailers are included."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of offers to return (default: 20, max: 50)."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "compare_supermarket_prices",
|
||||
"description": (
|
||||
"Compare current prices for a product across German supermarkets in a given postal code. "
|
||||
"Returns results sorted from lowest to highest price, highlighting the cheapest deal, regular prices (UVP), and required loyalty app memberships."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The product or grocery item to compare (e.g. 'Kaffee', 'Butter', 'Bier', 'Olivenöl')."
|
||||
},
|
||||
"zip_code": {
|
||||
"type": "string",
|
||||
"description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)."
|
||||
},
|
||||
"supermarkets": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of supermarket names to compare (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe'])."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_supported_supermarkets",
|
||||
"description": (
|
||||
"List all supermarket, discounter, hypermarket, and drugstore chains supported by the Marktguru service in Germany, "
|
||||
"including their categories (Discounter, Supermarkt, Großfläche, Drogerie) and accepted name aliases."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_supermarkets(sm_input: Any) -> Optional[List[str]]:
|
||||
"""Coerces various LLM input formats (list, comma-string, single string) into a clean list."""
|
||||
if not sm_input:
|
||||
return None
|
||||
if isinstance(sm_input, list):
|
||||
return [str(s).strip() for s in sm_input if str(s).strip()]
|
||||
if isinstance(sm_input, str):
|
||||
return [s.strip() for s in sm_input.split(",") if s.strip()]
|
||||
return None
|
||||
|
||||
|
||||
def handle_initialize(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {
|
||||
"tools": {
|
||||
"listChanged": False
|
||||
}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": SERVER_NAME,
|
||||
"version": SERVER_VERSION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def handle_tools_list(req_id: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"tools": TOOLS
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def handle_tools_call(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
logger.info(f"Tool call requested: {tool_name} with args={args}")
|
||||
|
||||
try:
|
||||
if tool_name == "search_supermarket_offers":
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}],
|
||||
"isError": True
|
||||
}
|
||||
}
|
||||
|
||||
zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip()
|
||||
supermarkets = _sanitize_supermarkets(args.get("supermarkets"))
|
||||
try:
|
||||
limit = int(args.get("limit") or 20)
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=limit)
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
elif tool_name == "compare_supermarket_prices":
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}],
|
||||
"isError": True
|
||||
}
|
||||
}
|
||||
|
||||
zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip()
|
||||
supermarkets = _sanitize_supermarkets(args.get("supermarkets"))
|
||||
|
||||
data = compare_prices(query, zip_code=zip_code, supermarkets=supermarkets)
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
elif tool_name == "list_supported_supermarkets":
|
||||
data = list_supported_supermarkets()
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"Method or tool '{tool_name}' not found."
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": text_result
|
||||
}
|
||||
],
|
||||
"isError": False
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception during tool execution '{tool_name}': {e}")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Tool execution failed: {type(e).__name__}: {str(e)}"
|
||||
}
|
||||
],
|
||||
"isError": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def process_message(line: str) -> Optional[Dict[str, Any]]:
|
||||
if not line.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Malformed JSON-RPC received: {e}")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32700, "message": f"Parse error: {str(e)}"}
|
||||
}
|
||||
|
||||
method = msg.get("method")
|
||||
req_id = msg.get("id")
|
||||
params = msg.get("params", {})
|
||||
|
||||
# Notifications do not return a response
|
||||
if req_id is None:
|
||||
if method == "notifications/initialized":
|
||||
logger.info("Client acknowledged initialized notification.")
|
||||
elif method == "notifications/cancelled":
|
||||
logger.info(f"Client cancelled operation: {params}")
|
||||
return None
|
||||
|
||||
# Standard Requests
|
||||
if method == "initialize":
|
||||
return handle_initialize(req_id, params)
|
||||
elif method == "tools/list":
|
||||
return handle_tools_list(req_id)
|
||||
elif method == "tools/call":
|
||||
return handle_tools_call(req_id, params)
|
||||
elif method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"Method '{method}' is not supported."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _sig_handler(sig, frame):
|
||||
logger.info(f"Received termination signal {sig}. Exiting gracefully.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
signal.signal(signal.SIGINT, _sig_handler)
|
||||
signal.signal(signal.SIGTERM, _sig_handler)
|
||||
|
||||
logger.info(f"Starting {SERVER_NAME} v{SERVER_VERSION} (stdio mode)...")
|
||||
|
||||
try:
|
||||
for raw_line in sys.stdin:
|
||||
response = process_message(raw_line)
|
||||
if response is not None:
|
||||
output = json.dumps(response, ensure_ascii=False) + "\n"
|
||||
sys.stdout.write(output)
|
||||
sys.stdout.flush()
|
||||
except (KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception(f"Fatal error in stdio loop: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("Marktguru MCP server shutdown complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user