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, 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"} 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.""" 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})