diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index 93a5393..58c776d 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 ( - entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash, + entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash, ) @@ -20,5 +20,6 @@ def register_all(mcp) -> None: tags.register(mcp) recent.register(mcp) entities.register(mcp) + processes.register(mcp) rulebooks.register(mcp) trash.register(mcp) diff --git a/src/fabledassistant/mcp/tools/processes.py b/src/fabledassistant/mcp/tools/processes.py new file mode 100644 index 0000000..7718dd3 --- /dev/null +++ b/src/fabledassistant/mcp/tools/processes.py @@ -0,0 +1,91 @@ +"""Stored-process MCP tools: reusable saved prompts (note_type='process'). + +A process is a Note whose body is a prompt the operator fires later +("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly. +get_process is the fire mechanism: it returns the full prompt for Claude to run. +""" +from __future__ import annotations + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services import knowledge as knowledge_svc +from fabledassistant.services import notes as notes_svc + + +async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict: + """List stored processes (reusable saved prompts). + + Args: + q: Free-text search across title + body (optional). + tag: Filter to a single tag (optional). + limit: Max results (1-100). + + Returns {"processes": [{id, title, tags, preview}], "total": int}. + """ + uid = current_user_id() + items, total = await knowledge_svc.query_knowledge( + user_id=uid, note_type="process", tags=[tag] if tag else [], + sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0, + ) + procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []), + "preview": it.get("snippet", "")} for it in items] + return {"processes": procs, "total": total} + + +async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict: + """Create a stored process (a reusable saved prompt). + + Args: + title: Process name, e.g. "Drift Audit" (required). + body: The full prompt to run later (markdown). Required. + tags: Plain-string tags, no # prefix. + """ + if not (title or "").strip() or not (body or "").strip(): + raise ValueError("create_process requires a non-empty title and body") + uid = current_user_id() + note = await notes_svc.create_note( + uid, title=title.strip(), body=body, note_type="process", tags=tags, + ) + return note.to_dict() + + +async def get_process(name_or_id: str) -> dict: + """Fetch a stored process by name or id and return its full prompt — the + fire mechanism. The operator says "run the process"; call this and + follow the returned body (including any 'clarify first' steps it contains). + + Resolution: numeric id → exact (case-insensitive) title → substring. On an + ambiguous substring match, the best (most-recent) match is returned with an + `other_matches` list so you can disambiguate with the operator. + """ + uid = current_user_id() + note, candidates = await notes_svc.resolve_process(uid, name_or_id) + if note is None: + raise ValueError(f"process {name_or_id!r} not found") + out = note.to_dict() + if candidates: + out["other_matches"] = candidates + return out + + +async def update_process(process_id: int, title: str = "", body: str = "", + tags: list[str] | None = None) -> dict: + """Update a stored process. Only provided fields change — empty title/body + leave that field unchanged; pass tags to replace the tag set.""" + uid = current_user_id() + note = await notes_svc.get_note(uid, process_id) + if note is None or note.note_type != "process": + raise ValueError(f"process {process_id} not found") + fields: dict = {} + if title.strip(): + fields["title"] = title.strip() + if body.strip(): + fields["body"] = body + if tags is not None: + fields["tags"] = tags + updated = await notes_svc.update_note(uid, process_id, **fields) + return updated.to_dict() + + +def register(mcp) -> None: + for fn in (list_processes, create_process, get_process, update_process): + mcp.tool(name=fn.__name__)(fn) diff --git a/tests/test_mcp_tool_processes.py b/tests/test_mcp_tool_processes.py new file mode 100644 index 0000000..3b6a784 --- /dev/null +++ b/tests/test_mcp_tool_processes.py @@ -0,0 +1,91 @@ +"""Tests for MCP process tools — patches the service layer.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def _fake_note(id=1, title="Drift Audit", note_type="process"): + n = MagicMock() + n.id = id + n.title = title + n.note_type = note_type + n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type} + return n + + +@pytest.mark.asyncio +async def test_create_process_requires_title_and_body(): + from fabledassistant.mcp.tools.processes import create_process + with pytest.raises(ValueError): + await create_process(title="", body="something") + with pytest.raises(ValueError): + await create_process(title="X", body=" ") + + +@pytest.mark.asyncio +async def test_create_process_sets_note_type(): + created = _fake_note() + with patch("fabledassistant.services.notes.create_note", + AsyncMock(return_value=created)) as mock_create: + from fabledassistant.mcp.tools.processes import create_process + out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"]) + assert out["note_type"] == "process" + # the service was asked to create a process + assert mock_create.await_args.kwargs["note_type"] == "process" + assert mock_create.await_args.kwargs["title"] == "Drift Audit" + + +@pytest.mark.asyncio +async def test_get_process_returns_body_and_candidates(): + note = _fake_note(id=7) + with patch("fabledassistant.services.notes.resolve_process", + AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))): + from fabledassistant.mcp.tools.processes import get_process + out = await get_process("drift") + assert out["id"] == 7 + assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}] + + +@pytest.mark.asyncio +async def test_get_process_not_found_raises(): + with patch("fabledassistant.services.notes.resolve_process", + AsyncMock(return_value=(None, []))): + from fabledassistant.mcp.tools.processes import get_process + with pytest.raises(ValueError): + await get_process("missing") + + +@pytest.mark.asyncio +async def test_update_process_rejects_non_process_note(): + plain = _fake_note(id=3, note_type="note") + with patch("fabledassistant.services.notes.get_note", + AsyncMock(return_value=plain)): + from fabledassistant.mcp.tools.processes import update_process + with pytest.raises(ValueError): + await update_process(process_id=3, title="x") + + +def test_register_attaches_four_tools(): + from fabledassistant.mcp.tools import processes + names: list[str] = [] + + class FakeMcp: + def tool(self, name): + names.append(name) + def deco(fn): + return fn + return deco + + processes.register(FakeMcp()) + assert set(names) == { + "list_processes", "create_process", "get_process", "update_process", + }