diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index 4cb1b19..cc45bd2 100644 --- a/src/fabledassistant/mcp/tools/__init__.py +++ b/src/fabledassistant/mcp/tools/__init__.py @@ -4,9 +4,10 @@ Each tool module exposes a `register(mcp)` function that attaches its tools to a FastMCP instance. `register_all(mcp)` is the single entry point called from `mcp.server.build_mcp_server`. """ -from fabledassistant.mcp.tools import search +from fabledassistant.mcp.tools import notes, search def register_all(mcp) -> None: """Register every tool module's tools on the given FastMCP instance.""" search.register(mcp) + notes.register(mcp) diff --git a/src/fabledassistant/mcp/tools/notes.py b/src/fabledassistant/mcp/tools/notes.py new file mode 100644 index 0000000..b6325ed --- /dev/null +++ b/src/fabledassistant/mcp/tools/notes.py @@ -0,0 +1,133 @@ +"""Note CRUD MCP tools. + +Thin wrappers over services/notes.py with is_task=False. Signatures mirror +the existing fable-mcp contracts exactly so client behavior is preserved. + +Sentinel conventions (inherited from existing fable-mcp tools): + - `tag: str = ""` / `search_text: str = ""` — empty means "no filter" + - `project_id: int = 0` — on create: orphan note (no project); on update: + "leave unchanged" (there is no "remove from project" through this tool, + which is a pre-existing limitation) + - `title: str = ""` / `body: str = ""` on update — empty means "leave unchanged" + - `tags: list[str] | None = None` — None means "leave unchanged"; [] clears +""" +from __future__ import annotations + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services import notes as notes_svc + + +async def fable_list_notes( + limit: int = 20, + offset: int = 0, + tag: str = "", + search_text: str = "", +) -> dict: + """List notes (non-task documents) stored in Fable. + + Optionally filter by a single tag (plain string, no # prefix) or a keyword + search against title and body. Results are ordered by last-updated descending. + + Use fable_search for semantic/meaning-based lookup instead of exact keyword search. + """ + uid = current_user_id() + rows, total = await notes_svc.list_notes( + uid, + q=search_text or None, + tags=[tag] if tag else None, + is_task=False, + limit=max(1, min(limit, 100)), + offset=max(0, offset), + ) + return {"notes": [n.to_dict() for n in rows], "total": total} + + +async def fable_get_note(note_id: int) -> dict: + """Fetch the full content of a single Fable note by its ID. + + Returns id, title, body (markdown), tags, project_id, created_at, updated_at. + """ + uid = current_user_id() + note = await notes_svc.get_note(uid, note_id) + if note is None: + raise ValueError(f"note {note_id} not found") + return note.to_dict() + + +async def fable_create_note( + title: str, + body: str = "", + tags: list[str] | None = None, + project_id: int = 0, +) -> dict: + """Create a new note in Fable. + + Args: + title: Note title (required). + body: Markdown content. Supports [[wikilinks]] to other notes by title. + tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"]. + project_id: Associate with a project (use 0 for no project / orphan note). + + Returns the created note object including its assigned id. + """ + uid = current_user_id() + note = await notes_svc.create_note( + uid, + title=title, + body=body, + tags=tags, + project_id=project_id or None, + ) + return note.to_dict() + + +async def fable_update_note( + note_id: int, + title: str = "", + body: str = "", + tags: list[str] | None = None, + project_id: int = 0, +) -> dict: + """Update an existing Fable note. Only explicitly provided fields are changed. + + Args: + note_id: ID of the note to update. + title: New title, or omit to leave unchanged. + body: New markdown body, or omit to leave unchanged. + tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged. + project_id: New project association. Omit (or pass 0) to leave unchanged. + """ + uid = current_user_id() + fields: dict = {} + if title: + fields["title"] = title + if body: + fields["body"] = body + if tags is not None: + fields["tags"] = tags + if project_id: + fields["project_id"] = project_id + note = await notes_svc.update_note(uid, note_id, **fields) + if note is None: + raise ValueError(f"note {note_id} not found") + return note.to_dict() + + +async def fable_delete_note(note_id: int) -> str: + """Permanently delete a Fable note by ID. This cannot be undone.""" + uid = current_user_id() + deleted = await notes_svc.delete_note(uid, note_id) + if not deleted: + raise ValueError(f"note {note_id} not found") + return f"Note {note_id} deleted." + + +def register(mcp) -> None: + for fn in ( + fable_list_notes, + fable_get_note, + fable_create_note, + fable_update_note, + fable_delete_note, + ): + mcp.tool(name=fn.__name__)(fn) diff --git a/tests/test_mcp_tool_notes.py b/tests/test_mcp_tool_notes.py new file mode 100644 index 0000000..ea21286 --- /dev/null +++ b/tests/test_mcp_tool_notes.py @@ -0,0 +1,184 @@ +"""Tests for fable_*_note tools.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx +from fabledassistant.mcp.tools.notes import ( + fable_list_notes, fable_get_note, fable_create_note, + fable_update_note, fable_delete_note, +) + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def _fake_note(**overrides) -> MagicMock: + note = MagicMock() + base = {"id": 1, "title": "t", "body": "b", "tags": [], "is_task": False} + base.update(overrides) + note.to_dict.return_value = base + return note + + +@pytest.mark.asyncio +async def test_list_notes_repackages_tuple_into_dict(): + rows = [_fake_note(id=1), _fake_note(id=2)] + with patch( + "fabledassistant.mcp.tools.notes.notes_svc.list_notes", + AsyncMock(return_value=(rows, 2)), + ): + out = await fable_list_notes() + assert out["total"] == 2 + assert len(out["notes"]) == 2 + + +@pytest.mark.asyncio +async def test_list_notes_passes_is_task_false(): + mock = AsyncMock(return_value=([], 0)) + with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): + await fable_list_notes() + assert mock.call_args.kwargs["is_task"] is False + + +@pytest.mark.asyncio +async def test_list_notes_tag_filter_maps_to_list(): + """The single-tag string param maps to a one-element list at the service layer.""" + mock = AsyncMock(return_value=([], 0)) + with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): + await fable_list_notes(tag="ops") + assert mock.call_args.kwargs["tags"] == ["ops"] + + +@pytest.mark.asyncio +async def test_list_notes_empty_tag_means_no_filter(): + mock = AsyncMock(return_value=([], 0)) + with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): + await fable_list_notes(tag="") + assert mock.call_args.kwargs["tags"] is None + + +@pytest.mark.asyncio +async def test_list_notes_search_text_maps_to_q(): + mock = AsyncMock(return_value=([], 0)) + with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): + await fable_list_notes(search_text="kafka") + assert mock.call_args.kwargs["q"] == "kafka" + + +@pytest.mark.asyncio +async def test_list_notes_limit_clamped(): + mock = AsyncMock(return_value=([], 0)) + with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): + await fable_list_notes(limit=9999) + assert mock.call_args.kwargs["limit"] == 100 + + +@pytest.mark.asyncio +async def test_get_note_returns_dict(): + fake = _fake_note(id=5, title="found") + with patch( + "fabledassistant.mcp.tools.notes.notes_svc.get_note", + AsyncMock(return_value=fake), + ): + out = await fable_get_note(note_id=5) + assert out["id"] == 5 + assert out["title"] == "found" + + +@pytest.mark.asyncio +async def test_get_note_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.notes.notes_svc.get_note", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="note 999 not found"): + await fable_get_note(note_id=999) + + +@pytest.mark.asyncio +async def test_create_note_passes_through(): + fake = _fake_note(id=10, title="new") + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.notes.notes_svc.create_note", mock): + out = await fable_create_note(title="new", body="x", tags=["a"], project_id=5) + assert out["id"] == 10 + assert mock.call_args.kwargs["title"] == "new" + assert mock.call_args.kwargs["project_id"] == 5 + + +@pytest.mark.asyncio +async def test_create_note_project_zero_becomes_none(): + """project_id=0 sentinel must become None at the service layer (orphan note).""" + fake = _fake_note() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.notes.notes_svc.create_note", mock): + await fable_create_note(title="t", project_id=0) + assert mock.call_args.kwargs["project_id"] is None + + +@pytest.mark.asyncio +async def test_update_note_only_sends_non_default_fields(): + """Omitted (default) fields must NOT reach the service — otherwise they'd + overwrite real data with empty strings.""" + fake = _fake_note() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock): + await fable_update_note(note_id=1, title="new title") + # Service got user_id, note_id positional + only the title kwarg + args, kwargs = mock.call_args + assert args == (7, 1) + assert kwargs == {"title": "new title"} + + +@pytest.mark.asyncio +async def test_update_note_empty_tags_clears_explicitly(): + """tags=[] is an explicit clear, distinct from tags=None (omit).""" + fake = _fake_note() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock): + await fable_update_note(note_id=1, tags=[]) + assert mock.call_args.kwargs == {"tags": []} + + +@pytest.mark.asyncio +async def test_update_note_tags_none_means_omit(): + fake = _fake_note() + mock = AsyncMock(return_value=fake) + with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock): + await fable_update_note(note_id=1, tags=None) + assert "tags" not in mock.call_args.kwargs + + +@pytest.mark.asyncio +async def test_update_note_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.notes.notes_svc.update_note", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="note 999 not found"): + await fable_update_note(note_id=999, title="x") + + +@pytest.mark.asyncio +async def test_delete_note_returns_confirmation_string(): + with patch( + "fabledassistant.mcp.tools.notes.notes_svc.delete_note", + AsyncMock(return_value=True), + ): + result = await fable_delete_note(note_id=7) + assert result == "Note 7 deleted." + + +@pytest.mark.asyncio +async def test_delete_note_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.notes.notes_svc.delete_note", + AsyncMock(return_value=False), + ): + with pytest.raises(ValueError, match="note 999 not found"): + await fable_delete_note(note_id=999)