Files
mop-skills/skills/shopping/scripts/bring_mcp.py
T

546 lines
22 KiB
Python
Executable File

#!/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()