From 6961144c3aaae12bddf7034d59f39d5ef56682d6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:51:59 -0400 Subject: [PATCH] feat(mcp): fable_list_tags + fable_get_recent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cross-type bootstrap tools: - fable_list_tags: tag vocabulary with usage counts, top-N by count. Aggregation in Python (not SQL UNNEST) — trivial perf cost at personal scale, much easier to test. - fable_get_recent: most-recently-touched items across notes, tasks, projects, events. Useful for Claude to ask 'what was I working on recently' at the start of a conversation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/mcp/tools/__init__.py | 4 +- src/fabledassistant/mcp/tools/recent.py | 81 +++++++++++++++++++++++ src/fabledassistant/mcp/tools/tags.py | 54 +++++++++++++++ tests/test_mcp_tool_tags.py | 63 ++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/fabledassistant/mcp/tools/recent.py create mode 100644 src/fabledassistant/mcp/tools/tags.py create mode 100644 tests/test_mcp_tool_tags.py diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index 6dead51..8b7af16 100644 --- a/src/fabledassistant/mcp/tools/__init__.py +++ b/src/fabledassistant/mcp/tools/__init__.py @@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called from `mcp.server.build_mcp_server`. """ from fabledassistant.mcp.tools import ( - events, milestones, notes, projects, search, tasks, + events, milestones, notes, projects, recent, search, tags, tasks, ) @@ -17,3 +17,5 @@ def register_all(mcp) -> None: projects.register(mcp) milestones.register(mcp) events.register(mcp) + tags.register(mcp) + recent.register(mcp) diff --git a/src/fabledassistant/mcp/tools/recent.py b/src/fabledassistant/mcp/tools/recent.py new file mode 100644 index 0000000..fb2f5a7 --- /dev/null +++ b/src/fabledassistant/mcp/tools/recent.py @@ -0,0 +1,81 @@ +"""fable_get_recent — cross-type recent-activity tool. + +Returns the most-recently-touched notes, tasks, projects, and events for the +user, ordered by updated_at descending. Useful for Claude to bootstrap context +at the start of a conversation ("what was I working on?"). + +Aggregation is Python-side after three small per-table queries — simpler than +a UNION ALL with type-discriminating columns, and fine for personal-scale data. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.models import async_session +from fabledassistant.models.event import Event +from fabledassistant.models.note import Note +from fabledassistant.models.project import Project + + +async def fable_get_recent(days: int = 7, limit: int = 25) -> dict: + """Return recently-touched items across notes, tasks, projects, events. + + Args: + days: Look-back window in days (1-90). + limit: Maximum number of items returned (1-100). + + Returns: + {"items": [{"id", "type", "title", "updated_at"}], "total": int} + Sorted by updated_at descending. + """ + uid = current_user_id() + days = max(1, min(days, 90)) + limit = max(1, min(limit, 100)) + since = datetime.now(timezone.utc) - timedelta(days=days) + items: list[dict] = [] + async with async_session() as session: + notes = (await session.execute( + select(Note).where(Note.user_id == uid, Note.updated_at >= since) + .order_by(Note.updated_at.desc()).limit(limit) + )).scalars().all() + for n in notes: + items.append({ + "id": n.id, + "type": "task" if n.is_task else "note", + "title": n.title, + "updated_at": n.updated_at.isoformat(), + }) + projects = (await session.execute( + select(Project).where(Project.user_id == uid, + Project.updated_at >= since) + .order_by(Project.updated_at.desc()).limit(limit) + )).scalars().all() + for p in projects: + items.append({ + "id": p.id, + "type": "project", + "title": p.title, + "updated_at": p.updated_at.isoformat(), + }) + events = (await session.execute( + select(Event).where(Event.user_id == uid, + Event.updated_at >= since) + .order_by(Event.updated_at.desc()).limit(limit) + )).scalars().all() + for e in events: + items.append({ + "id": e.id, + "type": "event", + "title": e.title, + "updated_at": e.updated_at.isoformat(), + }) + items.sort(key=lambda r: r["updated_at"], reverse=True) + items = items[:limit] + return {"items": items, "total": len(items)} + + +def register(mcp) -> None: + mcp.tool(name="fable_get_recent")(fable_get_recent) diff --git a/src/fabledassistant/mcp/tools/tags.py b/src/fabledassistant/mcp/tools/tags.py new file mode 100644 index 0000000..aa19ebe --- /dev/null +++ b/src/fabledassistant/mcp/tools/tags.py @@ -0,0 +1,54 @@ +"""fable_list_tags — return the user's tag vocabulary with usage counts. + +Python-side aggregation rather than SQL UNNEST. Personal-scale (~thousands of +notes) makes the perf cost negligible, and a one-pass dict counter is much +easier to mock in tests than a GROUP BY-with-unnest expression. +""" +from __future__ import annotations + +from sqlalchemy import select + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.models import async_session +from fabledassistant.models.note import Note + + +def _aggregate_tag_counts(tag_lists) -> dict[str, int]: + """Count occurrences across a sequence of (possibly-None) tag lists. + + Extracted so the aggregation logic can be unit-tested without a DB. + """ + counts: dict[str, int] = {} + for tags in tag_lists: + for tag in (tags or []): + counts[tag] = counts.get(tag, 0) + 1 + return counts + + +async def fable_list_tags(limit: int = 50) -> dict: + """Return the user's most-used tags with usage counts. + + Args: + limit: Maximum number of tags to return (1-200). + + Returns: + {"tags": [{"tag": "ops", "count": 12}, ...], "total": int} + Sorted by count descending. + """ + uid = current_user_id() + limit = max(1, min(limit, 200)) + async with async_session() as session: + result = await session.execute( + select(Note.tags).where(Note.user_id == uid) + ) + tag_lists = [row[0] for row in result.all()] + counts = _aggregate_tag_counts(tag_lists) + top = sorted(counts.items(), key=lambda x: x[1], reverse=True)[:limit] + return { + "tags": [{"tag": t, "count": c} for t, c in top], + "total": len(top), + } + + +def register(mcp) -> None: + mcp.tool(name="fable_list_tags")(fable_list_tags) diff --git a/tests/test_mcp_tool_tags.py b/tests/test_mcp_tool_tags.py new file mode 100644 index 0000000..31fae43 --- /dev/null +++ b/tests/test_mcp_tool_tags.py @@ -0,0 +1,63 @@ +"""Tests for fable_list_tags. + +The aggregation helper is unit-tested directly; the full tool is exercised +with a mocked async_session to keep tests DB-free. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx +from fabledassistant.mcp.tools.tags import fable_list_tags, _aggregate_tag_counts + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def test_aggregate_tag_counts_basic(): + assert _aggregate_tag_counts([["ops", "kafka"], ["ops"]]) == {"ops": 2, "kafka": 1} + + +def test_aggregate_tag_counts_handles_none_rows(): + """A note with NULL tags must not blow up the aggregator.""" + assert _aggregate_tag_counts([["a"], None, ["a", "b"]]) == {"a": 2, "b": 1} + + +def test_aggregate_tag_counts_empty_input(): + assert _aggregate_tag_counts([]) == {} + + +@pytest.mark.asyncio +async def test_fable_list_tags_returns_sorted_by_count_desc(): + """End-to-end: query returns three rows, top tag wins.""" + mock_result = MagicMock() + mock_result.all.return_value = [(["a"],), (["a", "b"],), (["a"],)] + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session.execute = AsyncMock(return_value=mock_result) + mock_ctx = MagicMock(return_value=mock_session) + with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx): + out = await fable_list_tags() + assert out["tags"][0] == {"tag": "a", "count": 3} + assert out["tags"][1] == {"tag": "b", "count": 1} + assert out["total"] == 2 + + +@pytest.mark.asyncio +async def test_fable_list_tags_clamps_limit(): + mock_result = MagicMock() + mock_result.all.return_value = [] + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session.execute = AsyncMock(return_value=mock_result) + mock_ctx = MagicMock(return_value=mock_session) + with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx): + # No exception — just exercises the clamping branch + out = await fable_list_tags(limit=99999) + assert out["total"] == 0