feat(mcp): fable_list_tags + fable_get_recent
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) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
|||||||
from `mcp.server.build_mcp_server`.
|
from `mcp.server.build_mcp_server`.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.mcp.tools import (
|
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)
|
projects.register(mcp)
|
||||||
milestones.register(mcp)
|
milestones.register(mcp)
|
||||||
events.register(mcp)
|
events.register(mcp)
|
||||||
|
tags.register(mcp)
|
||||||
|
recent.register(mcp)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user