From e902452781a74a62c60ef46dab3848a20efaf346 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Mon, 25 May 2026 23:11:15 +0200 Subject: [PATCH] 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 --- backend/app/api/mcp_server.py | 149 +++++++++++++++++++++++++++++++++- backend/tests/test_mcp.py | 58 ++++++++++++- 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/backend/app/api/mcp_server.py b/backend/app/api/mcp_server.py index d2faac1..248e13a 100644 --- a/backend/app/api/mcp_server.py +++ b/backend/app/api/mcp_server.py @@ -4,10 +4,12 @@ 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_ +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"} @@ -185,3 +187,148 @@ async def delete_todo(id: str) -> str: 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}) diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py index 2f73e11..094aed0 100644 --- a/backend/tests/test_mcp.py +++ b/backend/tests/test_mcp.py @@ -1,11 +1,15 @@ import json import pytest from sqlalchemy import delete +import app.api.mcp_server as mcp_server_module from app.api.mcp_server import ( get_todos, create_todo, update_todo, postpone_todo, delete_todo, ) -from app.core.database import AsyncSessionLocal +from app.api.mcp_server import ( + search_notes, get_note, create_note, update_note, delete_note, +) from app.models.todos import TodoItem +from app.models.notes import NoteItem pytestmark = pytest.mark.usefixtures("mcp_nullpool_session") @@ -13,8 +17,10 @@ pytestmark = pytest.mark.usefixtures("mcp_nullpool_session") @pytest.fixture(autouse=True) async def cleanup_mcp_todos(): yield - async with AsyncSessionLocal() as session: + # Utilise AsyncSessionLocal depuis le module mcp_server (patché par mcp_nullpool_session) + async with mcp_server_module.AsyncSessionLocal() as session: await session.execute(delete(TodoItem).where(TodoItem.title.like("TEST_MCP_%"))) + await session.execute(delete(NoteItem).where(NoteItem.title.like("TEST_MCP_%"))) await session.commit() @@ -67,3 +73,51 @@ async def test_delete_todo_introuvable(): result = await delete_todo(id="00000000-0000-0000-0000-000000000000") data = json.loads(result) assert "error" in data + + +# ── NOTES ────────────────────────────────────────────────────────────────────── + +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