diff --git a/docs/superpowers/plans/2026-05-25-mcp-server.md b/docs/superpowers/plans/2026-05-25-mcp-server.md new file mode 100644 index 0000000..c1aefae --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-mcp-server.md @@ -0,0 +1,1304 @@ +# MCP Server HomeHub — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Intégrer un serveur MCP (Model Context Protocol) dans le backend FastAPI de HomeHub, exposant 16 outils aux agents IA (Hermes, Claude Code) via Streamable HTTP authentifié par Bearer token. + +**Architecture:** Le serveur MCP est monté sous `/mcp` dans l'application FastAPI existante via `FastMCP.streamable_http_app()`, partageant le pool de connexions PostgreSQL via `AsyncSessionLocal`. Un middleware ASGI pur intercepte les requêtes `/mcp*` et vérifie le Bearer token défini en variable d'environnement. + +**Tech Stack:** FastAPI, `mcp>=1.9` (SDK officiel Anthropic — FastMCP), SQLAlchemy 2.0 async (`AsyncSessionLocal`), nginx proxy sans buffer, pytest-asyncio pour les tests. + +--- + +## Structure des fichiers + +| Fichier | Action | Rôle | +|---------|--------|------| +| `backend/requirements.txt` | Modifier | Ajouter `mcp>=1.9` | +| `backend/app/core/config.py` | Modifier | Ajouter le champ `mcp_api_key: str` | +| `backend/app/core/mcp_auth.py` | Créer | Middleware ASGI Bearer token pour `/mcp*` | +| `backend/app/api/mcp_server.py` | Créer | 16 outils MCP + instance `FastMCP` | +| `backend/app/main.py` | Modifier | Mount MCP ASGI + middleware MCPAuth | +| `backend/tests/test_mcp.py` | Créer | Tests auth + outils todos/notes/shopping | +| `docker-compose.yml` | Modifier | Ajouter `MCP_API_KEY` au service backend | +| `frontend/nginx.conf` | Modifier | Location `/mcp` sans buffer, timeout 86400s | + +--- + +### Task 1: Dépendances et configuration + +**Files:** +- Modify: `backend/requirements.txt` +- Modify: `backend/app/core/config.py` + +- [ ] **Step 1: Ajouter `mcp>=1.9` à requirements.txt** + +Fichier complet `backend/requirements.txt` : +``` +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 +pydantic-settings==2.6.1 +pillow==11.1.0 +python-multipart==0.0.20 +httpx==0.28.0 +arq==0.26.1 +mcp>=1.9 +pytest==8.3.4 +pytest-asyncio==0.24.0 +``` + +- [ ] **Step 2: Ajouter `mcp_api_key` dans Settings** + +Fichier complet `backend/app/core/config.py` : +```python +from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub" + upload_dir: str = "/data/uploads" + data_dir: str = "/data" + redis_url: str = "redis://redis:6379" + cors_origins: str = "http://localhost:3000" + mcp_api_key: str = "" + + @property + def cors_origins_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",")] + + @property + def upload_path(self) -> Path: + return Path(self.upload_dir) + + @property + def notes_md_path(self) -> Path: + return Path(self.data_dir) / "notes" + + @property + def backup_path(self) -> Path: + return Path(self.data_dir) / "backup" + + +settings = Settings() +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/requirements.txt backend/app/core/config.py +git commit -m "feat(mcp): dépendance mcp>=1.9 + champ mcp_api_key dans Settings" +``` + +--- + +### Task 2: Middleware d'authentification Bearer token + +**Files:** +- Create: `backend/app/core/mcp_auth.py` + +Le middleware est un ASGI pur (pas `BaseHTTPMiddleware`) pour éviter tout problème de streaming avec les réponses SSE du transport MCP. + +- [ ] **Step 1: Créer `backend/app/core/mcp_auth.py`** + +```python +import json +from starlette.types import ASGIApp, Receive, Scope, Send +from app.core.config import settings + + +class MCPAuthMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and scope.get("path", "").startswith("/mcp"): + headers = dict(scope.get("headers", [])) + auth = headers.get(b"authorization", b"").decode() + if auth != f"Bearer {settings.mcp_api_key}": + body = json.dumps({"detail": "Unauthorized"}).encode() + await send({ + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) + await send({"type": "http.response.body", "body": body}) + return + await self.app(scope, receive, send) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/core/mcp_auth.py +git commit -m "feat(mcp): middleware ASGI Bearer token pour /mcp*" +``` + +--- + +### Task 3: Outils MCP — Todos (5 outils) + +**Files:** +- Create: `backend/app/api/mcp_server.py` +- Create: `backend/tests/test_mcp.py` (début) + +- [ ] **Step 1: Écrire les tests failing pour les outils todos** + +Créer `backend/tests/test_mcp.py` : +```python +import json +import pytest +from app.api.mcp_server import ( + get_todos, create_todo, update_todo, postpone_todo, delete_todo, +) + + +async def test_get_todos_retourne_liste_json(): + result = await get_todos(status="pending") + data = json.loads(result) + assert isinstance(data, list) + + +async def test_create_todo_outil_cree_une_tache(): + result = await create_todo(title="TEST_MCP_todo_create") + data = json.loads(result) + assert data["title"] == "TEST_MCP_todo_create" + assert data["status"] == "pending" + assert data["priority"] == "medium" + # Cleanup + await delete_todo(id=str(data["id"])) + + +async def test_update_todo_outil(): + created = json.loads(await create_todo(title="TEST_MCP_todo_update")) + result = await update_todo(id=str(created["id"]), status="done") + data = json.loads(result) + assert data["status"] == "done" + await delete_todo(id=str(created["id"])) + + +async def test_postpone_todo_outil(): + created = json.loads(await create_todo(title="TEST_MCP_todo_postpone")) + result = await postpone_todo(id=str(created["id"]), days=3) + data = json.loads(result) + assert data["postponed_count"] == 1 + await delete_todo(id=str(created["id"])) + + +async def test_delete_todo_outil(): + created = json.loads(await create_todo(title="TEST_MCP_todo_delete")) + result = await delete_todo(id=str(created["id"])) + data = json.loads(result) + assert "deleted" in data + + +async def test_update_todo_id_invalide(): + result = await update_todo(id="pas-un-uuid", title="x") + data = json.loads(result) + assert "error" in data + + +async def test_delete_todo_introuvable(): + result = await delete_todo(id="00000000-0000-0000-0000-000000000000") + data = json.loads(result) + assert "error" in data +``` + +- [ ] **Step 2: Vérifier que les tests échouent (ImportError)** + +```bash +cd backend && pytest tests/test_mcp.py -v 2>&1 | head -20 +``` + +Résultat attendu : `ModuleNotFoundError` ou `ImportError` — `mcp_server` n'existe pas encore. + +- [ ] **Step 3: Créer `backend/app/api/mcp_server.py` avec les 5 outils todos** + +```python +import json +import uuid +from datetime import datetime, timedelta, timezone, date as date_type +from decimal import Decimal + +from mcp.server.fastmcp import FastMCP +from sqlalchemy import select, text, and_, or_ +from sqlalchemy.orm import selectinload + +from app.core.database import AsyncSessionLocal +from app.models.todos import TodoItem +from app.models.notes import NoteItem +from app.models.shopping import ShoppingList, ListItem, Product + +mcp = FastMCP("HomeHub", stateless_http=True) + + +def _serialize(obj): + if isinstance(obj, uuid.UUID): + return str(obj) + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, date_type): + return obj.isoformat() + if isinstance(obj, Decimal): + return float(obj) + raise TypeError(f"Type non sérialisable : {type(obj)}") + + +def _dumps(data) -> str: + return json.dumps(data, default=_serialize) + + +def _iso_week_label() -> str: + now = datetime.now(tz=timezone.utc) + iso = now.isocalendar() + return f"S{iso[1]} {iso[0]}" + + +# ── TODOS ────────────────────────────────────────────────────────────────────── + +@mcp.tool() +async def get_todos( + status: str = "pending", + domain: str | None = None, + priority: str | None = None, +) -> str: + """Liste filtrée des tâches. status: pending/done/cancelled. priority: low/medium/high.""" + async with AsyncSessionLocal() as session: + conditions = [TodoItem.status == status] + if domain: + conditions.append(TodoItem.domains.contains([domain])) + if priority: + conditions.append(TodoItem.priority == priority) + stmt = ( + select(TodoItem) + .where(and_(*conditions)) + .order_by(TodoItem.due_date.asc().nulls_last(), TodoItem.created_at.desc()) + .limit(100) + ) + result = await session.execute(stmt) + items = result.scalars().all() + return _dumps([{ + "id": item.id, + "title": item.title, + "status": item.status, + "priority": item.priority, + "domains": item.domains, + "tags": item.tags, + "due_date": item.due_date, + "postponed_count": item.postponed_count, + } for item in items]) + + +@mcp.tool() +async def create_todo( + title: str, + due_date: str | None = None, + priority: str | None = None, + domain: str | None = None, +) -> str: + """Crée une tâche. due_date en ISO 8601 (ex: 2025-06-01T10:00:00Z). priority: low/medium/high.""" + async with AsyncSessionLocal() as session: + parsed_due = None + if due_date: + try: + parsed_due = datetime.fromisoformat(due_date.replace("Z", "+00:00")) + except ValueError: + return _dumps({"error": f"Format de date invalide : {due_date}"}) + item = TodoItem( + title=title, + due_date=parsed_due, + priority=priority or "medium", + domains=[domain] if domain else [], + ) + session.add(item) + await session.commit() + await session.refresh(item) + return _dumps({ + "id": item.id, + "title": item.title, + "status": item.status, + "priority": item.priority, + "domains": item.domains, + "due_date": item.due_date, + }) + + +@mcp.tool() +async def update_todo( + id: str, + title: str | None = None, + status: str | None = None, + priority: str | None = None, +) -> str: + """Modifie une tâche. status: pending/done/cancelled. priority: low/medium/high.""" + async with AsyncSessionLocal() as session: + try: + todo_id = uuid.UUID(id) + except ValueError: + return _dumps({"error": f"UUID invalide : {id}"}) + item = await session.get(TodoItem, todo_id) + if not item: + return _dumps({"error": f"Tâche introuvable : {id}"}) + if title is not None: + item.title = title + if status is not None: + item.status = status + if priority is not None: + item.priority = priority + item.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(item) + return _dumps({ + "id": item.id, + "title": item.title, + "status": item.status, + "priority": item.priority, + }) + + +@mcp.tool() +async def postpone_todo(id: str, days: int) -> str: + """Reporte une tâche de N jours à partir de sa date d'échéance (ou maintenant si nulle).""" + async with AsyncSessionLocal() as session: + try: + todo_id = uuid.UUID(id) + except ValueError: + return _dumps({"error": f"UUID invalide : {id}"}) + item = await session.get(TodoItem, todo_id) + if not item: + return _dumps({"error": f"Tâche introuvable : {id}"}) + now = datetime.now(timezone.utc) + base = item.due_date if item.due_date else now + item.due_date = base + timedelta(days=days) + item.postponed_count += 1 + item.updated_at = now + await session.commit() + await session.refresh(item) + return _dumps({ + "id": item.id, + "title": item.title, + "due_date": item.due_date, + "postponed_count": item.postponed_count, + }) + + +@mcp.tool() +async def delete_todo(id: str) -> str: + """Supprime une tâche définitivement.""" + async with AsyncSessionLocal() as session: + try: + todo_id = uuid.UUID(id) + except ValueError: + return _dumps({"error": f"UUID invalide : {id}"}) + item = await session.get(TodoItem, todo_id) + if not item: + return _dumps({"error": f"Tâche introuvable : {id}"}) + await session.delete(item) + await session.commit() + return _dumps({"deleted": id}) +``` + +- [ ] **Step 4: Lancer les tests todos** + +```bash +cd backend && pytest tests/test_mcp.py -v -k "todo" +``` + +Résultat attendu : 7 tests PASSED. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/mcp_server.py backend/tests/test_mcp.py +git commit -m "feat(mcp): 5 outils todos + tests" +``` + +--- + +### Task 4: Outils MCP — Notes (5 outils) + +**Files:** +- Modify: `backend/app/api/mcp_server.py` (ajout en bas du fichier) +- Modify: `backend/tests/test_mcp.py` (ajout de tests) + +- [ ] **Step 1: Ajouter les tests failing pour les outils notes** + +Ajouter à la fin de `backend/tests/test_mcp.py` : +```python +from app.api.mcp_server import ( + search_notes, get_note, create_note, update_note, delete_note, +) + + +async def test_search_notes_retourne_liste(): + result = await search_notes(query="inexistant_xyz_abc_999") + data = json.loads(result) + assert isinstance(data, list) + assert data == [] + + +async def test_create_note_outil(): + result = await create_note(title="TEST_MCP_note_create", content="Contenu de test MCP") + data = json.loads(result) + assert data["title"] == "TEST_MCP_note_create" + assert "id" in data + await delete_note(id=str(data["id"])) + + +async def test_get_note_outil(): + created = json.loads(await create_note(title="TEST_MCP_note_get", content="Contenu get")) + result = await get_note(id=str(created["id"])) + data = json.loads(result) + assert data["title"] == "TEST_MCP_note_get" + assert data["content"] == "Contenu get" + assert "attachments" in data + await delete_note(id=str(created["id"])) + + +async def test_update_note_outil(): + created = json.loads(await create_note(title="TEST_MCP_note_update", content="avant")) + result = await update_note(id=str(created["id"]), content="après") + data = json.loads(result) + assert "id" in data + await delete_note(id=str(created["id"])) + + +async def test_delete_note_outil(): + created = json.loads(await create_note(title="TEST_MCP_note_delete", content="x")) + result = await delete_note(id=str(created["id"])) + data = json.loads(result) + assert "deleted" in data + + +async def test_get_note_introuvable(): + result = await get_note(id="00000000-0000-0000-0000-000000000000") + data = json.loads(result) + assert "error" in data +``` + +- [ ] **Step 2: Vérifier que les tests notes échouent (ImportError)** + +```bash +cd backend && pytest tests/test_mcp.py -v -k "note" 2>&1 | head -10 +``` + +Résultat attendu : `ImportError` — fonctions notes non définies dans `mcp_server.py`. + +- [ ] **Step 3: Ajouter les 5 outils notes dans `mcp_server.py`** + +Ajouter après le bloc `# ── TODOS ──` dans `backend/app/api/mcp_server.py` : +```python +# ── NOTES ────────────────────────────────────────────────────────────────────── + +@mcp.tool() +async def search_notes( + query: str | None = None, + category: str | None = None, + tag: str | None = None, +) -> str: + """Recherche de notes via FTS PostgreSQL (français). query cherche dans titre+contenu.""" + async with AsyncSessionLocal() as session: + conditions = [] + if category: + conditions.append(NoteItem.category == category) + if tag: + conditions.append(NoteItem.tags.contains([tag])) + if query: + conditions.append( + text( + "to_tsvector('french', coalesce(notes.items.title,'') || ' ' || notes.items.content)" + " @@ plainto_tsquery('french', :q)" + ).bindparams(q=query) + ) + stmt = ( + select(NoteItem) + .options(selectinload(NoteItem.attachments)) + .order_by(NoteItem.created_at.desc()) + .limit(20) + ) + if conditions: + stmt = stmt.where(and_(*conditions)) + result = await session.execute(stmt) + notes = result.scalars().all() + return _dumps([{ + "id": n.id, + "title": n.title, + "category": n.category, + "tags": n.tags, + "content_preview": n.content[:200] if n.content else "", + "created_at": n.created_at, + "attachment_count": len(n.attachments), + } for n in notes]) + + +@mcp.tool() +async def get_note(id: str) -> str: + """Retourne une note complète avec ses pièces jointes.""" + async with AsyncSessionLocal() as session: + try: + note_id = uuid.UUID(id) + except ValueError: + return _dumps({"error": f"UUID invalide : {id}"}) + stmt = ( + select(NoteItem) + .where(NoteItem.id == note_id) + .options(selectinload(NoteItem.attachments)) + ) + result = await session.execute(stmt) + note = result.scalar_one_or_none() + if not note: + return _dumps({"error": f"Note introuvable : {id}"}) + return _dumps({ + "id": note.id, + "title": note.title, + "content": note.content, + "category": note.category, + "tags": note.tags, + "created_at": note.created_at, + "attachments": [{ + "id": a.id, + "file_type": a.file_type, + "original_name": a.original_name, + } for a in note.attachments], + }) + + +@mcp.tool() +async def create_note( + title: str, + content: str, + category: str | None = None, + tags: list[str] | None = None, +) -> str: + """Crée une note avec titre, contenu markdown, catégorie optionnelle et tags.""" + async with AsyncSessionLocal() as session: + note = NoteItem( + title=title, + content=content, + category=category, + tags=tags or [], + ) + session.add(note) + await session.commit() + await session.refresh(note) + return _dumps({ + "id": note.id, + "title": note.title, + "category": note.category, + "tags": note.tags, + "created_at": note.created_at, + }) + + +@mcp.tool() +async def update_note( + id: str, + title: str | None = None, + content: str | None = None, + tags: list[str] | None = None, +) -> str: + """Modifie le titre, le contenu ou les tags d'une note.""" + async with AsyncSessionLocal() as session: + try: + note_id = uuid.UUID(id) + except ValueError: + return _dumps({"error": f"UUID invalide : {id}"}) + note = await session.get(NoteItem, note_id) + if not note: + return _dumps({"error": f"Note introuvable : {id}"}) + if title is not None: + note.title = title + if content is not None: + note.content = content + if tags is not None: + note.tags = tags + await session.commit() + await session.refresh(note) + return _dumps({"id": note.id, "title": note.title, "tags": note.tags}) + + +@mcp.tool() +async def delete_note(id: str) -> str: + """Supprime une note et toutes ses pièces jointes (cascade).""" + async with AsyncSessionLocal() as session: + try: + note_id = uuid.UUID(id) + except ValueError: + return _dumps({"error": f"UUID invalide : {id}"}) + note = await session.get(NoteItem, note_id) + if not note: + return _dumps({"error": f"Note introuvable : {id}"}) + await session.delete(note) + await session.commit() + return _dumps({"deleted": id}) +``` + +- [ ] **Step 4: Lancer les tests notes** + +```bash +cd backend && pytest tests/test_mcp.py -v -k "note" +``` + +Résultat attendu : 6 tests PASSED. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/mcp_server.py backend/tests/test_mcp.py +git commit -m "feat(mcp): 5 outils notes + tests" +``` + +--- + +### Task 5: Outils MCP — Shopping (6 outils) + +**Files:** +- Modify: `backend/app/api/mcp_server.py` (ajout en bas du fichier) +- Modify: `backend/tests/test_mcp.py` (ajout de tests) + +- [ ] **Step 1: Ajouter les tests failing pour les outils shopping** + +Ajouter à la fin de `backend/tests/test_mcp.py` : +```python +from app.api.mcp_server import ( + get_shopping_lists, get_active_shopping_list, search_products, + create_shopping_list, add_shopping_item, check_shopping_item, +) + + +async def test_get_shopping_lists_retourne_liste(): + result = await get_shopping_lists() + data = json.loads(result) + assert isinstance(data, list) + + +async def test_create_shopping_list_outil(): + result = await create_shopping_list(name="TEST_MCP_liste") + data = json.loads(result) + assert data["name"] == "TEST_MCP_liste" + assert data["status"] == "draft" + assert "id" in data + + +async def test_create_shopping_list_nom_auto(): + result = await create_shopping_list() + data = json.loads(result) + # Le nom auto commence par "S" (format "S{semaine} {année}") + assert data["name"].startswith("S") + + +async def test_add_shopping_item_outil(): + liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_item")) + result = await add_shopping_item( + list_id=str(liste["id"]), + name="TEST_MCP_article", + quantity=2.0, + unit="kg", + ) + data = json.loads(result) + assert data["name"] == "TEST_MCP_article" + assert data["is_checked"] is False + assert float(data["quantity"]) == 2.0 + + +async def test_check_shopping_item_outil(): + liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_check")) + article = json.loads(await add_shopping_item( + list_id=str(liste["id"]), + name="TEST_MCP_article_check", + )) + result = await check_shopping_item( + list_id=str(liste["id"]), + item_id=str(article["id"]), + ) + data = json.loads(result) + assert data["is_checked"] is True + + +async def test_search_products_retourne_liste(): + result = await search_products(q="inexistant_xyz_abc_999") + data = json.loads(result) + assert isinstance(data, list) + assert data == [] + + +async def test_get_active_shopping_list_sans_liste(): + # Si aucune liste draft, retourne une erreur structurée (pas une exception) + result = await get_active_shopping_list() + data = json.loads(result) + # Soit une liste avec des infos, soit une erreur — pas une exception Python + assert isinstance(data, dict) + + +async def test_add_item_liste_invalide(): + result = await add_shopping_item(list_id="pas-un-uuid", name="article") + data = json.loads(result) + assert "error" in data +``` + +- [ ] **Step 2: Vérifier que les tests shopping échouent (ImportError)** + +```bash +cd backend && pytest tests/test_mcp.py -v -k "shopping or liste or article or product" 2>&1 | head -10 +``` + +Résultat attendu : `ImportError` — fonctions shopping non définies dans `mcp_server.py`. + +- [ ] **Step 3: Ajouter les 6 outils shopping dans `mcp_server.py`** + +Ajouter après le bloc `# ── NOTES ──` dans `backend/app/api/mcp_server.py` : +```python +# ── SHOPPING ──────────────────────────────────────────────────────────────────── + +@mcp.tool() +async def get_shopping_lists() -> str: + """Retourne toutes les listes de courses avec compteurs d'articles cochés/total.""" + async with AsyncSessionLocal() as session: + stmt = ( + select(ShoppingList) + .options(selectinload(ShoppingList.items)) + .order_by(ShoppingList.created_at.desc()) + ) + result = await session.execute(stmt) + lists = result.scalars().all() + return _dumps([{ + "id": lst.id, + "name": lst.name, + "status": lst.status, + "week_date": lst.week_date, + "created_at": lst.created_at, + "item_count": len(lst.items), + "checked_count": sum(1 for i in lst.items if i.is_checked), + } for lst in lists]) + + +@mcp.tool() +async def get_active_shopping_list() -> str: + """Retourne la première liste en statut 'draft' avec tous ses articles triés.""" + async with AsyncSessionLocal() as session: + stmt = ( + select(ShoppingList) + .where(ShoppingList.status == "draft") + .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) + .order_by(ShoppingList.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + lst = result.scalar_one_or_none() + if not lst: + return _dumps({"error": "Aucune liste active (statut draft)"}) + sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) + return _dumps({ + "id": lst.id, + "name": lst.name, + "status": lst.status, + "item_count": len(lst.items), + "checked_count": sum(1 for i in lst.items if i.is_checked), + "items": [{ + "id": item.id, + "name": item.custom_name or (item.product.name if item.product else "Article inconnu"), + "quantity": item.quantity, + "unit": item.unit, + "is_checked": item.is_checked, + } for item in sorted_items], + }) + + +@mcp.tool() +async def search_products(q: str) -> str: + """Recherche dans le catalogue produits par nom, description ou catégorie.""" + async with AsyncSessionLocal() as session: + stmt = ( + select(Product) + .where( + or_( + Product.name.ilike(f"%{q}%"), + Product.description.ilike(f"%{q}%"), + Product.category.ilike(f"%{q}%"), + ) + ) + .order_by(Product.frequency_score.desc()) + .limit(20) + ) + result = await session.execute(stmt) + products = result.scalars().all() + return _dumps([{ + "id": p.id, + "name": p.name, + "brand": p.brand, + "category": p.category, + "default_unit": p.default_unit, + "frequency_score": p.frequency_score, + "last_purchased_at": p.last_purchased_at, + } for p in products]) + + +@mcp.tool() +async def create_shopping_list(name: str | None = None) -> str: + """Crée une liste de courses. name auto = semaine ISO courante si absent (ex: 'S22 2026').""" + async with AsyncSessionLocal() as session: + lst = ShoppingList(name=name or _iso_week_label()) + session.add(lst) + await session.commit() + await session.refresh(lst) + return _dumps({ + "id": lst.id, + "name": lst.name, + "status": lst.status, + "created_at": lst.created_at, + }) + + +@mcp.tool() +async def add_shopping_item( + list_id: str, + name: str, + quantity: float | None = 1.0, + unit: str | None = None, +) -> str: + """Ajoute un article à une liste de courses par son ID.""" + async with AsyncSessionLocal() as session: + try: + lid = uuid.UUID(list_id) + except ValueError: + return _dumps({"error": f"UUID de liste invalide : {list_id}"}) + lst = await session.get(ShoppingList, lid) + if not lst: + return _dumps({"error": f"Liste introuvable : {list_id}"}) + item = ListItem( + list_id=lid, + custom_name=name, + quantity=Decimal(str(quantity)) if quantity is not None else None, + unit=unit, + ) + session.add(item) + await session.commit() + await session.refresh(item) + return _dumps({ + "id": item.id, + "name": item.custom_name, + "quantity": item.quantity, + "unit": item.unit, + "is_checked": item.is_checked, + }) + + +@mcp.tool() +async def check_shopping_item(list_id: str, item_id: str) -> str: + """Coche un article (marque comme acheté). Met à jour les stats du produit lié si présent.""" + async with AsyncSessionLocal() as session: + try: + lid = uuid.UUID(list_id) + iid = uuid.UUID(item_id) + except ValueError: + return _dumps({"error": "UUID invalide (list_id ou item_id)"}) + stmt = ( + select(ListItem) + .where(ListItem.id == iid, ListItem.list_id == lid) + .options(selectinload(ListItem.product)) + ) + result = await session.execute(stmt) + item = result.scalar_one_or_none() + if not item: + return _dumps({"error": f"Article introuvable : {item_id} dans liste {list_id}"}) + was_checked = item.is_checked + item.is_checked = True + if not was_checked and item.product_id: + product = await session.get(Product, item.product_id) + if product: + today = date_type.today() + if product.last_purchased_at and product.last_purchased_at < today: + days = (today - product.last_purchased_at).days + if product.avg_interval_days is None: + product.avg_interval_days = Decimal(str(days)) + else: + product.avg_interval_days = Decimal(str( + round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1) + )) + product.last_purchased_at = today + product.frequency_score += 1 + await session.commit() + return _dumps({"id": item.id, "is_checked": item.is_checked}) +``` + +- [ ] **Step 4: Lancer les tests shopping** + +```bash +cd backend && pytest tests/test_mcp.py -v -k "shopping or liste or article or product" +``` + +Résultat attendu : 8 tests PASSED. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/mcp_server.py backend/tests/test_mcp.py +git commit -m "feat(mcp): 6 outils shopping + tests" +``` + +--- + +### Task 6: Câblage complet (main.py + nginx + docker-compose) + +**Files:** +- Modify: `backend/app/main.py` +- Modify: `frontend/nginx.conf` +- Modify: `docker-compose.yml` +- Modify: `backend/tests/test_mcp.py` (ajout tests auth) + +- [ ] **Step 1: Modifier `backend/app/main.py`** + +Fichier complet `backend/app/main.py` : +```python +from contextlib import asynccontextmanager +from pathlib import Path +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from app.api.admin import router as admin_router +from app.api.events import router as events_router +from app.api.health import router as health_router +from app.api.media import router as media_router +from app.api.mcp_server import mcp +from app.api.notes import router as notes_router +from app.api.todos import router as todos_router +from app.api.shopping import router as shopping_router +from app.core.config import settings +from app.core.mcp_auth import MCPAuthMiddleware +from app.core.redis import init_redis, close_redis +from app.data.seed import run_seed + + +@asynccontextmanager +async def lifespan(app: FastAPI): + for subdir in ("uploads", "notes", "backup"): + Path(settings.data_dir, subdir).mkdir(parents=True, exist_ok=True) + await run_seed() + await init_redis() + yield + await close_redis() + + +app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(MCPAuthMiddleware) + +app.include_router(health_router, prefix="/api") +app.include_router(events_router, prefix="/api/events") +app.include_router(admin_router, prefix="/api/admin") +app.include_router(media_router, prefix="/api/media") +app.include_router(notes_router, prefix="/api/notes") +app.include_router(todos_router, prefix="/api/todos") +app.include_router(shopping_router, prefix="/api/shopping") + +app.mount("/mcp", mcp.streamable_http_app()) +app.mount("/media", StaticFiles(directory=str(settings.upload_path)), name="media") +``` + +**Note ordre middleware :** `add_middleware` en FastAPI — le dernier ajouté est le plus externe (s'exécute en premier). `MCPAuthMiddleware` ajouté en dernier intercepte donc les requêtes `/mcp` avant tout. + +- [ ] **Step 2: Modifier `frontend/nginx.conf`** + +Fichier complet `frontend/nginx.conf` : +```nginx +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 200m; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|webp|woff2|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location /api/events/stream { + proxy_pass http://backend:8000/api/events/stream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } + + location /mcp { + proxy_pass http://backend:8000/mcp; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location ^~ /media/ { + proxy_pass http://backend:8000/media/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Step 3: Modifier `docker-compose.yml` — ajouter MCP_API_KEY** + +Dans la section `backend.environment`, ajouter la ligne `MCP_API_KEY` : +```yaml + backend: + build: ./backend + user: "1000:1000" + environment: + DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub + UPLOAD_DIR: /data/uploads + DATA_DIR: /data + REDIS_URL: redis://redis:6379 + CORS_ORIGINS: http://localhost:3001,http://localhost:3000 + MCP_API_KEY: "4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI" +``` + +Faire de même pour `backend-worker.environment` si ce service accède jamais au MCP (facultatif, par cohérence). + +- [ ] **Step 4: Ajouter les tests d'authentification dans `test_mcp.py`** + +Ajouter en haut de `backend/tests/test_mcp.py` (après les imports existants) : +```python +import pytest +from app.core.config import settings +``` + +Ajouter à la fin de `backend/tests/test_mcp.py` : +```python +async def test_mcp_auth_rejet_sans_token(client): + """Le middleware renvoie 401 si aucun header Authorization.""" + resp = await client.get("/mcp") + assert resp.status_code == 401 + + +async def test_mcp_auth_rejet_mauvais_token(client): + """Le middleware renvoie 401 si le token est incorrect.""" + resp = await client.get("/mcp", headers={"Authorization": "Bearer mauvais-token"}) + assert resp.status_code == 401 + + +async def test_mcp_auth_accepte_bon_token(client, monkeypatch): + """Le middleware laisse passer avec le token correct.""" + monkeypatch.setattr(settings, "mcp_api_key", "test-mcp-key-xyz") + resp = await client.get( + "/mcp", + headers={"Authorization": "Bearer test-mcp-key-xyz"}, + ) + assert resp.status_code != 401 +``` + +- [ ] **Step 5: Lancer la suite de tests complète** + +```bash +cd backend && pytest tests/test_mcp.py -v +``` + +Résultat attendu : tous les tests PASSED (auth + todos + notes + shopping). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/main.py frontend/nginx.conf docker-compose.yml backend/tests/test_mcp.py +git commit -m "feat(mcp): câblage FastAPI + nginx proxy + docker-compose MCP_API_KEY" +``` + +--- + +### Task 7: Validation et suite de tests finale + +**Files:** +- No new files — validation de l'existant + +- [ ] **Step 1: Lancer la suite de tests complète du backend** + +```bash +cd backend && pytest -v +``` + +Résultat attendu : tous les tests PASSED (todos, shopping, health, media, mcp). + +- [ ] **Step 2: Vérifier que l'import MCP fonctionne** + +```bash +cd backend && python -c "from app.api.mcp_server import mcp; print('OK — outils:', len(mcp._tool_manager._tools))" +``` + +Résultat attendu : `OK — outils: 16` + +- [ ] **Step 3: Vérifier la description des outils exposés** + +```bash +cd backend && python -c " +from app.api.mcp_server import mcp +tools = mcp._tool_manager._tools +for name in sorted(tools.keys()): + print(f' {name}') +" +``` + +Résultat attendu (16 lignes) : +``` + add_shopping_item + check_shopping_item + create_note + create_shopping_list + create_todo + delete_note + delete_todo + get_active_shopping_list + get_note + get_shopping_lists + get_todos + postpone_todo + search_notes + search_products + update_note + update_todo +``` + +- [ ] **Step 4: Commit de version** + +```bash +cd frontend && npm version patch +git add frontend/package.json +git commit -m "chore: bump version — MCP server v0.5.10" +``` + +--- + +## Post-déploiement : Intégration avec Hermes + +> Cette section est à exécuter sur la machine Hermes (10.0.0.80) après que le backend HomeHub soit redéployé avec les nouvelles images Docker. + +### Prérequis + +HomeHub doit être accessible depuis Hermes à l'adresse `http://10.0.0.50:3001`. Vérifier : +```bash +curl -s http://10.0.0.50:3001/api/health +``` +Réponse attendue : `{"status": "ok"}` (ou similaire). + +### Configuration Hermes + +Dans le fichier de configuration YAML de Hermes (chemin typique : `~/.config/hermes/config.yml` ou `~/hermes/config.yml`), ajouter le bloc suivant dans la section `mcp_servers` : + +```yaml +mcp_servers: + homehub: + url: http://10.0.0.50:3001/mcp + headers: + Authorization: "Bearer 4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI" +``` + +**Champs :** +- `url` — point d'entrée Streamable HTTP du serveur MCP (proxifié par nginx) +- `headers.Authorization` — Bearer token statique défini dans `MCP_API_KEY` + +**Filtrage optionnel des outils** (si Hermes doit n'exposer qu'un sous-ensemble) : +```yaml +mcp_servers: + homehub: + url: http://10.0.0.50:3001/mcp + headers: + Authorization: "Bearer 4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI" + tools: + include: + - get_todos + - create_todo + - update_todo + - postpone_todo + - get_active_shopping_list + - add_shopping_item + - search_notes +``` + +### Rechargement des outils + +Après avoir sauvegardé la configuration, dans une session Hermes active, taper : +``` +/reload-mcp +``` + +Hermes affichera la liste des outils chargés depuis HomeHub. Les 16 outils doivent apparaître avec leurs descriptions. + +### Test rapide depuis Hermes + +Demander à Hermes : +> "Quelles sont mes tâches en attente ?" + +Hermes devrait appeler `get_todos(status="pending")` et afficher la liste des todos. + +### Utilisation avec Claude Code + +Pour utiliser le serveur MCP depuis Claude Code (dans `.mcp.json` ou `claude_desktop_config.json`) : +```json +{ + "mcpServers": { + "homehub": { + "url": "http://10.0.0.50:3001/mcp", + "headers": { + "Authorization": "Bearer 4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI" + } + } + } +} +``` + +--- + +## Auto-review du plan + +**Couverture spec :** +- ✅ 5 outils Todos (get, create, update, postpone, delete) +- ✅ 5 outils Notes (search, get, create, update, delete) +- ✅ 6 outils Shopping (get_lists, get_active, search_products, create_list, add_item, check_item) +- ✅ Middleware auth Bearer token +- ✅ Mount `/mcp` FastAPI +- ✅ nginx proxy sans buffer + timeout 86400s +- ✅ `docker-compose.yml` — MCP_API_KEY +- ✅ Retour JSON structuré avec `{"error": "..."}` sans exception MCP +- ✅ Clé API `4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI` dans docker-compose +- ✅ Tutoriel Hermes (YAML, `/reload-mcp`, filtrage outils) +- ✅ Config Claude Code (`mcpServers` JSON) + +**Cohérence des types :** +- `_serialize` gère UUID, datetime, date, Decimal — utilisé dans `_dumps` partout +- `AsyncSessionLocal` importé depuis `app.core.database` — même pool que les routes REST +- Les noms d'outils dans les tests (ex: `get_todos`) correspondent exactement aux fonctions décorées `@mcp.tool()` + +**Aucun placeholder détecté.**