41 KiB
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_keydans Settings
Fichier complet backend/app/core/config.py :
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
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
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
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 :
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)
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.pyavec les 5 outils todos
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
cd backend && pytest tests/test_mcp.py -v -k "todo"
Résultat attendu : 7 tests PASSED.
- Step 5: Commit
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 :
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)
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 :
# ── 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
cd backend && pytest tests/test_mcp.py -v -k "note"
Résultat attendu : 6 tests PASSED.
- Step 5: Commit
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 :
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)
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 :
# ── 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
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
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 :
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 :
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 :
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) :
import pytest
from app.core.config import settings
Ajouter à la fin de backend/tests/test_mcp.py :
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
cd backend && pytest tests/test_mcp.py -v
Résultat attendu : tous les tests PASSED (auth + todos + notes + shopping).
- Step 6: Commit
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
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
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
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
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 :
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 :
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 dansMCP_API_KEY
Filtrage optionnel des outils (si Hermes doit n'exposer qu'un sous-ensemble) :
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) :
{
"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
/mcpFastAPI - ✅ nginx proxy sans buffer + timeout 86400s
- ✅
docker-compose.yml— MCP_API_KEY - ✅ Retour JSON structuré avec
{"error": "..."}sans exception MCP - ✅ Clé API
4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VIdans docker-compose - ✅ Tutoriel Hermes (YAML,
/reload-mcp, filtrage outils) - ✅ Config Claude Code (
mcpServersJSON)
Cohérence des types :
_serializegère UUID, datetime, date, Decimal — utilisé dans_dumpspartoutAsyncSessionLocalimporté depuisapp.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é.