Files
home_hub/backend/app/api/mcp_server.py
T
gilles e902452781 feat(mcp): 5 outils notes + tests
Ajoute search_notes, get_note, create_note, update_note, delete_note au serveur MCP.
Tests: 6 nouveaux tests notes (13 tests MCP au total, tous passent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:11:15 +02:00

335 lines
11 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 sqlalchemy import select, and_, text
from sqlalchemy.orm import selectinload
from app.core.database import AsyncSessionLocal
from app.models.todos import TodoItem
from app.models.notes import NoteItem
_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(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})