diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 24184b6..eb67b73 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -24,7 +24,8 @@ Hierarchy: Project -> Milestone -> Task/Note. def build_mcp_server() -> FastMCP: """Build the FastMCP instance with all tools registered.""" mcp = FastMCP("fable", instructions=_INSTRUCTIONS.strip()) - # Tools will be registered here in Phase 2/3. + from fabledassistant.mcp.tools import register_all + register_all(mcp) return mcp diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py new file mode 100644 index 0000000..4cb1b19 --- /dev/null +++ b/src/fabledassistant/mcp/tools/__init__.py @@ -0,0 +1,12 @@ +"""MCP tool implementations. + +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 + + +def register_all(mcp) -> None: + """Register every tool module's tools on the given FastMCP instance.""" + search.register(mcp) diff --git a/src/fabledassistant/mcp/tools/search.py b/src/fabledassistant/mcp/tools/search.py new file mode 100644 index 0000000..73a69f9 --- /dev/null +++ b/src/fabledassistant/mcp/tools/search.py @@ -0,0 +1,49 @@ +"""fable_search — semantic search across the user's notes and tasks. + +Mirrors the existing fable-mcp contract so Claude's prior usage pattern keeps +working. Differences from fable-mcp: + - calls services.embeddings.semantic_search_notes directly instead of HTTP + - user_id comes from mcp.current_user_id() rather than a global API key +""" +from __future__ import annotations + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services.embeddings import semantic_search_notes + + +async def fable_search(q: str, content_type: str = "all", limit: int = 10) -> dict: + """Semantic search over the user's notes and tasks. + + Args: + q: search query string. + content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only). + limit: maximum number of results (1-50). + + Returns: + {"results": [{"id", "title", "body", "is_task", "tags", "similarity"}], + "total": int} + """ + uid = current_user_id() + limit = max(1, min(limit, 50)) + is_task = {"note": False, "task": True}.get(content_type) # None => any + raw = await semantic_search_notes( + uid, q, limit=limit, is_task=is_task, + ) + return { + "results": [ + { + "id": note.id, + "title": note.title, + "body": (note.body or "")[:240], + "is_task": bool(note.is_task), + "tags": list(note.tags or []), + "similarity": float(score), + } + for score, note in raw + ], + "total": len(raw), + } + + +def register(mcp) -> None: + mcp.tool(name="fable_search")(fable_search) diff --git a/tests/test_mcp_tool_search.py b/tests/test_mcp_tool_search.py new file mode 100644 index 0000000..89424c7 --- /dev/null +++ b/tests/test_mcp_tool_search.py @@ -0,0 +1,100 @@ +"""fable_search tool — proves the tool pattern (context + service call + dict shape). + +Service call is mocked; no DB needed.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx +from fabledassistant.mcp.tools.search import fable_search + + +@pytest.fixture(autouse=True) +def _reset_user_ctx(): + """Each test starts with no MCP context. Tests set it explicitly.""" + token = _user_id_ctx.set(None) + yield + _user_id_ctx.reset(token) + + +def _fake_note(*, id: int, title: str, body: str = "", + tags: list[str] | None = None, is_task: bool = False) -> MagicMock: + note = MagicMock() + note.id = id + note.title = title + note.body = body + note.tags = tags or [] + note.is_task = is_task + return note + + +@pytest.mark.asyncio +async def test_fable_search_raises_without_context(): + with pytest.raises(RuntimeError, match="no MCP user context"): + await fable_search(q="anything") + + +@pytest.mark.asyncio +async def test_fable_search_returns_repackaged_results(): + _user_id_ctx.set(7) + fake = _fake_note(id=1, title="kafka rebalance", body="HPA details", + tags=["ops"], is_task=False) + with patch( + "fabledassistant.mcp.tools.search.semantic_search_notes", + AsyncMock(return_value=[(0.93, fake)]), + ): + out = await fable_search(q="kafka") + + assert out["total"] == 1 + assert len(out["results"]) == 1 + r = out["results"][0] + assert r["id"] == 1 + assert r["title"] == "kafka rebalance" + assert r["body"] == "HPA details" + assert r["is_task"] is False + assert r["tags"] == ["ops"] + assert r["similarity"] == pytest.approx(0.93) + + +@pytest.mark.asyncio +async def test_fable_search_body_is_truncated_to_240_chars(): + _user_id_ctx.set(7) + long_body = "x" * 500 + fake = _fake_note(id=1, title="t", body=long_body) + with patch( + "fabledassistant.mcp.tools.search.semantic_search_notes", + AsyncMock(return_value=[(0.5, fake)]), + ): + out = await fable_search(q="x") + assert len(out["results"][0]["body"]) == 240 + + +@pytest.mark.asyncio +async def test_fable_search_content_type_filters_at_service_layer(): + """content_type maps to the is_task kwarg passed to the service.""" + _user_id_ctx.set(7) + mock_search = AsyncMock(return_value=[]) + with patch("fabledassistant.mcp.tools.search.semantic_search_notes", mock_search): + await fable_search(q="x", content_type="task") + assert mock_search.call_args.kwargs["is_task"] is True + + mock_search.reset_mock() + await fable_search(q="x", content_type="note") + assert mock_search.call_args.kwargs["is_task"] is False + + mock_search.reset_mock() + await fable_search(q="x", content_type="all") + assert mock_search.call_args.kwargs["is_task"] is None + + +@pytest.mark.asyncio +async def test_fable_search_limit_is_clamped(): + _user_id_ctx.set(7) + mock_search = AsyncMock(return_value=[]) + with patch("fabledassistant.mcp.tools.search.semantic_search_notes", mock_search): + await fable_search(q="x", limit=999) + assert mock_search.call_args.kwargs["limit"] == 50 + + mock_search.reset_mock() + await fable_search(q="x", limit=0) + assert mock_search.call_args.kwargs["limit"] == 1