1305 lines
41 KiB
Markdown
1305 lines
41 KiB
Markdown
# 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é.**
|