feat(shopping): add Bring! shopping lists MCP server and protocol

This commit is contained in:
Moritz Graf
2026-09-15 21:47:26 +02:00
parent 505249748e
commit 9a857ac23b
2 changed files with 592 additions and 1 deletions
+47 -1
View File
@@ -1,6 +1,6 @@
---
name: shopping
description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, and eBay marketplace searches. Use when the user asks for shopping advice, supermarket offers (e.g. Mutti/Oro Tomatensaucen, Windeln Größe X, Vorratskäufe), price checks, or second-hand/refurbished deals.
description: Intelligent shopping advisory, price comparisons across German supermarkets (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Geizhals retail best prices & price history, eBay marketplace searches, and Bring! shopping list management (Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste). Use when the user asks for shopping advice, supermarket offers, adding items to their shopping lists, or checking off items.
---
# 🛒 Smart Shopper & Vorrats-Concierge
@@ -30,6 +30,20 @@ Comprehensive shopping advisor for the Graf family. Combines live German superma
- **`ebay_get_item(item_id)`**:
- Fetches listing details, photos, location, and seller reputation.
### 4. Bring! Shopping Lists (`bring`)
- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**:
- Adds an item with optional quantity/specification (e.g. `specification="2L"`, `"100 Stück"`) to the target list.
- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**:
- Adds multiple items at once (ideal for recipe ingredients or bulk additions).
- **`bring_get_items(list_name="Einkaufsladen")`**:
- Retrieves active to-buy items and recently bought items for the specified list.
- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**:
- Marks an item as purchased/completed (moves it to "recently bought").
- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**:
- Completely deletes an item from the list.
- **`bring_list_lists()`**:
- Lists all Bring! shopping lists with their UUIDs and item counts.
---
## 🍅 Vorratskauf & Supermarkt Protocol
@@ -81,6 +95,38 @@ When the user asks whether to buy a gadget, phone, laptop, or appliance:
---
## 📝 Bring! Shopping Lists Protocol
OpenClaw manages the Graf family's Bring! shopping lists using the `bring` MCP tools:
- **`bring_add_item(item_name, specification="", list_name="Einkaufsladen")`**
- **`bring_add_batch(items=[{"name": "...", "specification": "..."}], list_name="Einkaufsladen")`**
- **`bring_get_items(list_name="Einkaufsladen")`**
- **`bring_complete_item(item_name, list_name="Einkaufsladen")`**
- **`bring_remove_item(item_name, list_name="Einkaufsladen")`**
- **`bring_list_lists()`**
### 1. List Routing & Defaults
The family maintains multiple distinct lists in Bring!:
1. **`Einkaufsladen`** (**Default**): Groceries, supermarket items, drugstore (dm/Rossmann), household staples. Used for all general shopping queries unless another store is explicitly mentioned.
2. **`Baumarkt`**: Hardware store items, DIY supplies, tools, screws/nails, timber, paint, plumbing, garden supplies.
3. **`Ikea`**: Furniture, home decor, Swedish food market, home organization items.
4. **`Gewuerzeinkaufsliste`**: Dedicated list for spices, herbs, and seasonings.
### 2. Interaction Rules
- **Adding Items**:
- *"Pack Milch auf die Einkaufsliste"* -> `bring_add_item(item_name="Milch", list_name="Einkaufsladen")`
- *"Schreib 50er Spax Schrauben auf die Baumarkt-Liste"* -> `bring_add_item(item_name="Spax Schrauben", specification="50er", list_name="Baumarkt")`
- *"Wir brauchen Koriander und Kardamom"* -> `bring_add_item` with `list_name="Gewuerzeinkaufsliste"` (or `bring_add_batch`)
- *"Brauchen neue Kissenbezüge von Ikea"* -> `bring_add_item(item_name="Kissenbezüge", list_name="Ikea")`
- **Confirmation**:
- Always give a concise confirmation with markdown bolding:
- *„✅ **Milch** (3.8%) auf die Liste **Einkaufsladen** gesetzt.“*
- *„✅ **Spax Schrauben** (50er) auf die Liste **Baumarkt** gesetzt.“*
- **Reading / Checking**:
- When asked *"Was steht auf der Einkaufsliste?"*: call `bring_get_items(list_name="Einkaufsladen")` and summarize the items clearly.
---
## 🔗 Presentation Rules
- Follow OpenClaw's SOUL protocol: Hide hyperlinks behind descriptive text (`[Händler (Preis €)](url)`).
- Never send raw search URLs or unverified Google links.
+545
View File
@@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""
Bring! Shopping Lists MCP Server & CLI Tool
Interacts with the unofficial Bring! Shopping List API using bring-api.
Provides single-turn MCP tools and CLI utilities to manage multiple shopping lists:
- "Einkaufsladen" (Default for groceries / normal shopping)
- "Baumarkt" (Hardware store, DIY, tools, repairs)
- "Ikea" (Furniture, home decor, Swedish goods)
- "Gewuerzeinkaufsliste" (Spices and seasoning shopping)
Supports dynamic matching against any other list present in the user's Bring! account.
"""
import os
import sys
import json
import asyncio
from typing import Dict, Any, List, Optional, Tuple
import aiohttp
from bring_api import Bring
DEFAULT_LIST_NAME = os.environ.get("BRING_DEFAULT_LIST", "Einkaufsladen")
# Common aliases mapping user shorthand / natural language to canonical list names
LIST_ALIASES: Dict[str, str] = {
"normal": "Einkaufsladen",
"normal shopping": "Einkaufsladen",
"normale shopping": "Einkaufsladen",
"einkaufen": "Einkaufsladen",
"einkauf": "Einkaufsladen",
"einkaufsladen": "Einkaufsladen",
"lebensmittel": "Einkaufsladen",
"supermarkt": "Einkaufsladen",
"groceries": "Einkaufsladen",
"shopping": "Einkaufsladen",
"baumarkt": "Baumarkt",
"diy": "Baumarkt",
"hardware": "Baumarkt",
"werkzeug": "Baumarkt",
"obi": "Baumarkt",
"bauhaus": "Baumarkt",
"hornbach": "Baumarkt",
"ikea": "Ikea",
"möbel": "Ikea",
"moebel": "Ikea",
"gewürz": "Gewuerzeinkaufsliste",
"gewuerz": "Gewuerzeinkaufsliste",
"gewürze": "Gewuerzeinkaufsliste",
"gewuerze": "Gewuerzeinkaufsliste",
"gewürzeinkaufsliste": "Gewuerzeinkaufsliste",
"gewuerzeinkaufsliste": "Gewuerzeinkaufsliste",
"spices": "Gewuerzeinkaufsliste",
}
class BringClientManager:
"""Manages Bring API session, authentication, and list name resolution."""
def __init__(self, email: Optional[str] = None, password: Optional[str] = None):
self.email = email or os.environ.get("BRING_EMAIL") or os.environ.get("MAIL")
self.password = password or os.environ.get("BRING_PASSWORD") or os.environ.get("PW")
self.default_list = DEFAULT_LIST_NAME
self._lists_cache: List[Dict[str, Any]] = []
def _check_credentials(self):
if not self.email or not self.password:
raise ValueError(
"Missing Bring credentials. Please set BRING_EMAIL and BRING_PASSWORD environment variables."
)
async def _get_client_and_session(self) -> Tuple[Bring, aiohttp.ClientSession]:
self._check_credentials()
session = aiohttp.ClientSession()
client = Bring(session, self.email, self.password)
await client.login()
return client, session
async def load_lists(self) -> List[Dict[str, Any]]:
client, session = await self._get_client_and_session()
async with session:
res = await client.load_lists()
lists_data = []
for lst in getattr(res, "lists", res):
lists_data.append({
"listUuid": getattr(lst, "listUuid", lst.get("listUuid") if isinstance(lst, dict) else None),
"name": getattr(lst, "name", lst.get("name") if isinstance(lst, dict) else None),
"theme": getattr(lst, "theme", lst.get("theme") if isinstance(lst, dict) else None),
})
self._lists_cache = lists_data
return lists_data
async def resolve_list(self, list_name: Optional[str] = None) -> Tuple[str, str]:
"""
Resolves a user-provided or alias list name to (listUuid, canonical_name).
Falls back to default_list ('Einkaufsladen') if omitted or not matched.
"""
raw_name = (list_name or "").strip()
alias_target = LIST_ALIASES.get(raw_name.lower()) if raw_name else self.default_list
target_name = alias_target or raw_name or self.default_list
if not self._lists_cache:
await self.load_lists()
# 1. Exact match against canonical / loaded names
for item in self._lists_cache:
if item["name"].lower() == target_name.lower():
return item["listUuid"], item["name"]
# 2. Substring match
for item in self._lists_cache:
if target_name.lower() in item["name"].lower() or item["name"].lower() in target_name.lower():
return item["listUuid"], item["name"]
# 3. Fallback to default list
for item in self._lists_cache:
if item["name"].lower() == self.default_list.lower():
return item["listUuid"], item["name"]
# 4. If default list not found, pick first list available
if self._lists_cache:
first = self._lists_cache[0]
return first["listUuid"], first["name"]
raise ValueError(f"No shopping lists available in Bring account for target '{target_name}'")
async def get_items(self, list_name: Optional[str] = None) -> Dict[str, Any]:
client, session = await self._get_client_and_session()
async with session:
res = await client.load_lists()
self._lists_cache = [
{
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
}
for l in getattr(res, "lists", res)
]
list_uuid, canonical_name = await self.resolve_list(list_name)
list_details = await client.get_list(list_uuid)
# Purchase items (active to-buy)
purchase_items = []
for item in getattr(list_details, "purchase", []):
purchase_items.append({
"name": getattr(item, "name", item.get("name") if isinstance(item, dict) else str(item)),
"specification": getattr(item, "specification", item.get("specification", "") if isinstance(item, dict) else ""),
})
# Recently bought items
recently_items = []
for item in getattr(list_details, "recently", []):
recently_items.append({
"name": getattr(item, "name", item.get("name") if isinstance(item, dict) else str(item)),
"specification": getattr(item, "specification", item.get("specification", "") if isinstance(item, dict) else ""),
})
return {
"list": canonical_name,
"listUuid": list_uuid,
"count": len(purchase_items),
"items": purchase_items,
"recently": recently_items,
}
async def add_item(self, item_name: str, specification: str = "", list_name: Optional[str] = None) -> Dict[str, Any]:
client, session = await self._get_client_and_session()
async with session:
res = await client.load_lists()
self._lists_cache = [
{
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
}
for l in getattr(res, "lists", res)
]
list_uuid, canonical_name = await self.resolve_list(list_name)
await client.save_item(list_uuid, item_name.strip(), specification.strip())
return {
"success": True,
"action": "added",
"item": item_name.strip(),
"specification": specification.strip() or None,
"list": canonical_name,
"listUuid": list_uuid,
}
async def add_batch(self, items: List[Dict[str, str]], list_name: Optional[str] = None) -> Dict[str, Any]:
client, session = await self._get_client_and_session()
async with session:
res = await client.load_lists()
self._lists_cache = [
{
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
}
for l in getattr(res, "lists", res)
]
list_uuid, canonical_name = await self.resolve_list(list_name)
results = []
for entry in items:
name = entry.get("name") or entry.get("itemName") or ""
spec = entry.get("specification") or entry.get("spec") or ""
if name.strip():
await client.save_item(list_uuid, name.strip(), spec.strip())
results.append({"name": name.strip(), "specification": spec.strip() or None})
return {
"success": True,
"action": "batch_added",
"count": len(results),
"items": results,
"list": canonical_name,
"listUuid": list_uuid,
}
async def complete_item(self, item_name: str, list_name: Optional[str] = None) -> Dict[str, Any]:
client, session = await self._get_client_and_session()
async with session:
res = await client.load_lists()
self._lists_cache = [
{
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
}
for l in getattr(res, "lists", res)
]
list_uuid, canonical_name = await self.resolve_list(list_name)
await client.complete_item(list_uuid, item_name.strip())
return {
"success": True,
"action": "completed",
"item": item_name.strip(),
"list": canonical_name,
"listUuid": list_uuid,
}
async def remove_item(self, item_name: str, list_name: Optional[str] = None) -> Dict[str, Any]:
client, session = await self._get_client_and_session()
async with session:
res = await client.load_lists()
self._lists_cache = [
{
"listUuid": getattr(l, "listUuid", l.get("listUuid") if isinstance(l, dict) else None),
"name": getattr(l, "name", l.get("name") if isinstance(l, dict) else None),
}
for l in getattr(res, "lists", res)
]
list_uuid, canonical_name = await self.resolve_list(list_name)
await client.remove_item(list_uuid, item_name.strip())
return {
"success": True,
"action": "removed",
"item": item_name.strip(),
"list": canonical_name,
"listUuid": list_uuid,
}
# MCP Server Implementation
MCP_TOOLS = [
{
"name": "bring_list_lists",
"description": "List all shopping lists available in the Bring! account (e.g. Einkaufsladen, Baumarkt, Ikea, Gewuerzeinkaufsliste).",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
},
},
{
"name": "bring_get_items",
"description": "Get all active to-buy items on a Bring! shopping list. Defaults to 'Einkaufsladen'.",
"inputSchema": {
"type": "object",
"properties": {
"list_name": {
"type": "string",
"description": "Target list name or alias ('Einkaufsladen' [default for normal shopping], 'Baumarkt' [hardware/DIY], 'Ikea', 'Gewuerzeinkaufsliste').",
"default": "Einkaufsladen",
}
},
"required": [],
},
},
{
"name": "bring_add_item",
"description": "Add an item to a Bring! shopping list. Defaults to 'Einkaufsladen' (normal shopping). Use 'Baumarkt' for hardware/tools, 'Ikea' for furniture/decor, or 'Gewuerzeinkaufsliste' for spices.",
"inputSchema": {
"type": "object",
"properties": {
"item_name": {
"type": "string",
"description": "Name of the item to add (e.g. 'Milch', 'Haferflocken', 'Spax Schrauben 4x40', 'Zimt').",
},
"specification": {
"type": "string",
"description": "Optional specification or quantity (e.g. '2L', '3.8%', '100 Stück', 'Bio').",
"default": "",
},
"list_name": {
"type": "string",
"description": "Target list name or alias ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
"default": "Einkaufsladen",
},
},
"required": ["item_name"],
},
},
{
"name": "bring_add_batch",
"description": "Add multiple items to a Bring! shopping list at once. Useful for recipes or ingredient lists.",
"inputSchema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "List of objects with 'name' and optional 'specification' (e.g. [{'name': 'Eier', 'specification': '10er'}, {'name': 'Butter'}])",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Item name"},
"specification": {"type": "string", "description": "Quantity or details", "default": ""},
},
"required": ["name"],
},
},
"list_name": {
"type": "string",
"description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
"default": "Einkaufsladen",
},
},
"required": ["items"],
},
},
{
"name": "bring_complete_item",
"description": "Mark an item as purchased / completed (moves it to recently bought) on a Bring! shopping list.",
"inputSchema": {
"type": "object",
"properties": {
"item_name": {
"type": "string",
"description": "Exact name of the item to mark as completed.",
},
"list_name": {
"type": "string",
"description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
"default": "Einkaufsladen",
},
},
"required": ["item_name"],
},
},
{
"name": "bring_remove_item",
"description": "Completely remove an item from a Bring! shopping list.",
"inputSchema": {
"type": "object",
"properties": {
"item_name": {
"type": "string",
"description": "Exact name of the item to delete completely.",
},
"list_name": {
"type": "string",
"description": "Target list name ('Einkaufsladen' [default], 'Baumarkt', 'Ikea', 'Gewuerzeinkaufsliste').",
"default": "Einkaufsladen",
},
},
"required": ["item_name"],
},
},
]
def run_mcp_server():
"""Runs standard MCP stdio JSON-RPC loop."""
mgr = BringClientManager()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except Exception:
continue
msg_id = msg.get("id")
method = msg.get("method")
params = msg.get("params", {})
if method == "initialize":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "bring-mcp-server", "version": "1.0.0"},
},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "notifications/initialized":
pass
elif method == "ping":
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/list":
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": MCP_TOOLS}}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if tool_name == "bring_list_lists":
data = loop.run_until_complete(mgr.load_lists())
elif tool_name == "bring_get_items":
data = loop.run_until_complete(mgr.get_items(list_name=args.get("list_name")))
elif tool_name == "bring_add_item":
data = loop.run_until_complete(
mgr.add_item(
item_name=args.get("item_name", ""),
specification=args.get("specification", ""),
list_name=args.get("list_name"),
)
)
elif tool_name == "bring_add_batch":
data = loop.run_until_complete(
mgr.add_batch(items=args.get("items", []), list_name=args.get("list_name"))
)
elif tool_name == "bring_complete_item":
data = loop.run_until_complete(
mgr.complete_item(item_name=args.get("item_name", ""), list_name=args.get("list_name"))
)
elif tool_name == "bring_remove_item":
data = loop.run_until_complete(
mgr.remove_item(item_name=args.get("item_name", ""), list_name=args.get("list_name"))
)
else:
raise ValueError(f"Unknown tool: {tool_name}")
loop.close()
text_result = json.dumps(data, indent=2, ensure_ascii=False)
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {"content": [{"type": "text", "text": text_result}]},
}
except Exception as e:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"isError": True,
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}],
},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
else:
if msg_id is not None:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
def run_cli():
import argparse
parser = argparse.ArgumentParser(description="Bring! Shopping Lists MCP & CLI Tool")
subparsers = parser.add_subparsers(dest="command")
# lists
subparsers.add_parser("lists", help="List all Bring! shopping lists")
# get
sp_get = subparsers.add_parser("get", help="Get items from a shopping list")
sp_get.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
# add
sp_add = subparsers.add_parser("add", help="Add an item to a list")
sp_add.add_argument("item", help="Item name")
sp_add.add_argument("--spec", default="", help="Specification (e.g. 2L, 500g)")
sp_add.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
# complete
sp_comp = subparsers.add_parser("complete", help="Mark an item as completed")
sp_comp.add_argument("item", help="Item name")
sp_comp.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
# remove
sp_rem = subparsers.add_parser("remove", help="Remove an item from a list")
sp_rem.add_argument("item", help="Item name")
sp_rem.add_argument("--list", default=DEFAULT_LIST_NAME, help="List name or alias")
args = parser.parse_args()
mgr = BringClientManager()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
if args.command == "lists":
res = loop.run_until_complete(mgr.load_lists())
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "get":
res = loop.run_until_complete(mgr.get_items(args.list))
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "add":
res = loop.run_until_complete(mgr.add_item(args.item, specification=args.spec, list_name=args.list))
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "complete":
res = loop.run_until_complete(mgr.complete_item(args.item, list_name=args.list))
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "remove":
res = loop.run_until_complete(mgr.remove_item(args.item, list_name=args.list))
print(json.dumps(res, indent=2, ensure_ascii=False))
else:
parser.print_help()
finally:
loop.close()
if __name__ == "__main__":
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
run_mcp_server()
else:
run_cli()