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>
This commit is contained in:
@@ -4,10 +4,12 @@ from datetime import datetime, timedelta, timezone, date as date_type
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
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.core.database import AsyncSessionLocal
|
||||||
from app.models.todos import TodoItem
|
from app.models.todos import TodoItem
|
||||||
|
from app.models.notes import NoteItem
|
||||||
|
|
||||||
_VALID_STATUSES = {"pending", "done", "cancelled"}
|
_VALID_STATUSES = {"pending", "done", "cancelled"}
|
||||||
_VALID_PRIORITIES = {"low", "medium", "high"}
|
_VALID_PRIORITIES = {"low", "medium", "high"}
|
||||||
@@ -185,3 +187,148 @@ async def delete_todo(id: str) -> str:
|
|||||||
await session.delete(item)
|
await session.delete(item)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return _dumps({"deleted": id})
|
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})
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
import app.api.mcp_server as mcp_server_module
|
||||||
from app.api.mcp_server import (
|
from app.api.mcp_server import (
|
||||||
get_todos, create_todo, update_todo, postpone_todo, delete_todo,
|
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.todos import TodoItem
|
||||||
|
from app.models.notes import NoteItem
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
|
pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
|
||||||
|
|
||||||
@@ -13,8 +17,10 @@ pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def cleanup_mcp_todos():
|
async def cleanup_mcp_todos():
|
||||||
yield
|
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(TodoItem).where(TodoItem.title.like("TEST_MCP_%")))
|
||||||
|
await session.execute(delete(NoteItem).where(NoteItem.title.like("TEST_MCP_%")))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -67,3 +73,51 @@ async def test_delete_todo_introuvable():
|
|||||||
result = await delete_todo(id="00000000-0000-0000-0000-000000000000")
|
result = await delete_todo(id="00000000-0000-0000-0000-000000000000")
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
assert "error" in data
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user