6c889f1561
MCP : - FastMCP recevait Host=localhost (sans port) mais le pattern par défaut allowed_hosts=["localhost:*", ...] EXIGE un port → 421 Invalid Host header pour tout accès non-localhost (ex: Hermes via http://10.0.0.50:3001/mcp) - Désactive enable_dns_rebinding_protection : le Bearer MCP_API_KEY est la vraie barrière (protection rebinding = anti-attaque navigateur, inutile ici) - nginx /mcp : retour à Host $host (le rewrite localhost était cassé) Alembic : - Collision : 006_notes_urls et 006_product_tags partageaient revision='006' → "Multiple head revisions" au démarrage - Renumérote notes_urls en 0061, chaîné après product_tags Chaîne finale : 005 -> 006 (product_tags) -> 0061 (notes_urls) -> 007 v0.5.15 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
524 lines
18 KiB
Python
524 lines
18 KiB
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 mcp.server.transport_security import TransportSecuritySettings
|
|
from sqlalchemy import select, and_, text, 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
|
|
|
|
_VALID_STATUSES = {"pending", "done", "cancelled"}
|
|
_VALID_PRIORITIES = {"low", "medium", "high"}
|
|
|
|
# La protection DNS rebinding (défaut FastMCP) valide le header Host contre
|
|
# ["127.0.0.1:*", "localhost:*", "[::1]:*"]. Elle est conçue contre les attaques
|
|
# navigateur sur des services localhost. Ici l'accès se fait depuis des agents
|
|
# externes (Hermes) via l'IP du serveur, et la vraie barrière est le Bearer token
|
|
# MCP_API_KEY (cf. MCPAuthMiddleware). On désactive donc cette protection devenue
|
|
# redondante et bloquante (sinon 421 "Invalid Host header" sur toute IP non-localhost).
|
|
mcp = FastMCP(
|
|
"HomeHub",
|
|
stateless_http=True,
|
|
streamable_http_path="/",
|
|
transport_security=TransportSecuritySettings(
|
|
enable_dns_rebinding_protection=False,
|
|
),
|
|
)
|
|
|
|
|
|
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."""
|
|
if priority and priority not in _VALID_PRIORITIES:
|
|
return _dumps({"error": f"Priorité invalide : {priority}. Valeurs: 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 status is not None and status not in _VALID_STATUSES:
|
|
return _dumps({"error": f"Statut invalide : {status}. Valeurs: pending/done/cancelled"})
|
|
if priority is not None and priority not in _VALID_PRIORITIES:
|
|
return _dumps({"error": f"Priorité invalide : {priority}. Valeurs: low/medium/high"})
|
|
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})
|
|
|
|
|
|
# ── 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(title,'') || ' ' || 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})
|
|
|
|
|
|
# ── 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' ou 'active' avec tous ses articles triés."""
|
|
async with AsyncSessionLocal() as session:
|
|
stmt = (
|
|
select(ShoppingList)
|
|
.where(ShoppingList.status.in_(["draft", "active"]))
|
|
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
|
|
.order_by(ShoppingList.status.asc(), 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 ou active)"})
|
|
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."""
|
|
if not q or not q.strip():
|
|
return _dumps({"error": "Paramètre q requis"})
|
|
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."""
|
|
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:
|
|
product = item.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})
|