From 580e4a2c0a7962f90af8eb442e94dad39e9b2c9b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 21:10:47 -0400 Subject: [PATCH] feat(trash): list_trash/restore/purge_trash MCP tools + register + instructions --- src/fabledassistant/mcp/server.py | 6 +++ src/fabledassistant/mcp/tools/__init__.py | 3 +- src/fabledassistant/mcp/tools/trash.py | 48 +++++++++++++++++ tests/test_mcp_tool_trash.py | 66 +++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/fabledassistant/mcp/tools/trash.py create mode 100644 tests/test_mcp_tool_trash.py diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index f9443ad..69871e8 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -53,6 +53,12 @@ title) — that seeds a plan template and returns the project's applicable_rules Maintain the plan with the normal task tools (update_task for the body, add_task_log for progress); its work-logs are the build record. Build plans in Scribe via start_planning, not in local .md files. + +Deletes are recoverable: every delete_* tool moves the entity (and its +descendants) to the trash and returns a deleted_batch_id. Use list_trash() to +see trashed batches, restore(deleted_batch_id) to undo a deletion, and +purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash +auto-purges after the operator's retention window. """ diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index f3ec021..93a5393 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, + entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash, ) @@ -21,3 +21,4 @@ def register_all(mcp) -> None: recent.register(mcp) entities.register(mcp) rulebooks.register(mcp) + trash.register(mcp) diff --git a/src/fabledassistant/mcp/tools/trash.py b/src/fabledassistant/mcp/tools/trash.py new file mode 100644 index 0000000..dfb9cdb --- /dev/null +++ b/src/fabledassistant/mcp/tools/trash.py @@ -0,0 +1,48 @@ +"""Trash MCP tools — unified, batch-based recovery surface. + +Deletes go through the per-type delete_* tools (which soft-delete via the trash +service). These tools list / restore / permanently-purge trashed content by +the deleted_batch_id that a delete operation returns. +""" +from __future__ import annotations + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services import trash as trash_svc + + +async def list_trash() -> dict: + """List trashed items, grouped by the deletion batch they were removed in. + + Each batch has: batch_id, deleted_at, a summary (the lead item's title), + count, and the member items (type + id + title). Restore a batch with + restore(deleted_batch_id). + """ + return {"batches": await trash_svc.list_trash(current_user_id())} + + +async def restore(deleted_batch_id: str) -> dict: + """Restore a whole deletion batch from the trash (un-delete).""" + n = await trash_svc.restore(current_user_id(), deleted_batch_id) + return {"restored": n, "batch_id": deleted_batch_id} + + +async def purge_trash(deleted_batch_id: str, confirmed: bool = False) -> dict: + """Permanently delete a trashed batch. IRREVERSIBLE — pass confirmed=True. + + This is the only true hard-delete tool; everything else is recoverable. + """ + if not confirmed: + return { + "warning": ( + f"Permanently delete batch {deleted_batch_id}? This cannot be " + f"undone. Pass confirmed=True to proceed." + ), + "confirmed_required": True, + } + n = await trash_svc.purge(current_user_id(), deleted_batch_id) + return {"purged": n, "batch_id": deleted_batch_id} + + +def register(mcp) -> None: + for fn in (list_trash, restore, purge_trash): + mcp.tool(name=fn.__name__)(fn) diff --git a/tests/test_mcp_tool_trash.py b/tests/test_mcp_tool_trash.py new file mode 100644 index 0000000..a20a91f --- /dev/null +++ b/tests/test_mcp_tool_trash.py @@ -0,0 +1,66 @@ +"""Tests for the trash MCP tools — patch the trash service.""" +from unittest.mock import AsyncMock, 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) + + +@pytest.mark.asyncio +async def test_list_trash_wraps_batches(): + with patch("fabledassistant.mcp.tools.trash.trash_svc.list_trash", + AsyncMock(return_value=[{"batch_id": "b1", "count": 2}])): + from fabledassistant.mcp.tools.trash import list_trash + out = await list_trash() + assert out["batches"][0]["batch_id"] == "b1" + + +@pytest.mark.asyncio +async def test_restore_delegates(): + with patch("fabledassistant.mcp.tools.trash.trash_svc.restore", + AsyncMock(return_value=3)) as mock: + from fabledassistant.mcp.tools.trash import restore + out = await restore("b1") + assert out == {"restored": 3, "batch_id": "b1"} + assert mock.call_args.args == (7, "b1") + + +@pytest.mark.asyncio +async def test_purge_trash_requires_confirmed(): + with patch("fabledassistant.mcp.tools.trash.trash_svc.purge", AsyncMock()) as mock: + from fabledassistant.mcp.tools.trash import purge_trash + out = await purge_trash("b1", confirmed=False) + assert out.get("confirmed_required") is True + assert not mock.called + + +@pytest.mark.asyncio +async def test_purge_trash_when_confirmed(): + with patch("fabledassistant.mcp.tools.trash.trash_svc.purge", + AsyncMock(return_value=5)) as mock: + from fabledassistant.mcp.tools.trash import purge_trash + out = await purge_trash("b1", confirmed=True) + assert out == {"purged": 5, "batch_id": "b1"} + assert mock.called + + +def test_register_attaches_three_tools(): + from fabledassistant.mcp.tools.trash import register + names: list[str] = [] + + class FakeMCP: + def tool(self, name=None): + def deco(fn): + names.append(name) + return fn + return deco + + register(FakeMCP()) + assert set(names) == {"list_trash", "restore", "purge_trash"}