Files
FabledScribe/tests/test_mcp_tool_entities.py
T
bvandeusen 6aa84002b3 refactor(mcp): drop fable_ prefix from tool names; rebrand to Scribe
MCP clients see tools namespaced by the server's local name already
(mcp__<server>__<tool>), so the fable_ prefix on every tool name was
redundant and ate tokens in the model's tool list.

Tools renamed (34 total):
  fable_search → search
  fable_list_notes / get_note / create_note / update_note / delete_note → list_notes / ...
  fable_list_tasks / get_task / create_task / update_task / add_task_log → list_tasks / ...
  fable_list_projects / get_project / create_project / update_project → list_projects / ...
  fable_list_milestones / create_milestone / update_milestone → list_milestones / ...
  fable_list_events / create_event / get_event / update_event / delete_event → list_events / ...
  fable_list_tags → list_tags
  fable_get_recent → get_recent
  fable_list_persons / create_person / update_person → list_persons / ...
  fable_list_places / create_place / update_place → list_places / ...
  fable_list_lists / create_list / update_list → list_lists / ...

Also rebranded in MCP scope:
  FastMCP("fable", ...) → FastMCP("scribe", ...)
  auth realm "fable-mcp" → "scribe-mcp"
  ASGI scope key fable_user_id → scribe_user_id
  ContextVar label fable_mcp_user_id → scribe_mcp_user_id
  Tool docstrings "in Fable" / "Fable task" → "in Scribe" / "Scribe task"
  Server _INSTRUCTIONS prose

Deliberately kept:
  - The internal Python package name `fabledassistant` (per project naming
    convention — internal stays).
  - "Fabled Scribe" as the official product/brand name (page footer,
    smtp_from_name default).
  - References to the legacy `fable-mcp/` standalone package in docstrings
    explaining what we ported from — accurate until that directory is
    deleted in Phase 10.

Client impact: existing MCP registrations need
  claude mcp remove <name> && claude mcp add ...
once with a freshly-copied snippet from Settings → MCP Access. Claude
Code then re-discovers tools on connect — old conversations that
referenced fable_* tool names will see "tool not found" on those calls
until updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:48:55 -04:00

192 lines
7.3 KiB
Python

"""Tests for typed-entity tools (person/place/list).
Focuses on the metadata-shape translations and the entity_meta merge logic,
since that's where bugs would hide."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.entities import (
list_persons, create_person, update_person,
create_place, update_place,
create_list, update_list,
)
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_note(*, note_type="note", entity_meta=None, **overrides) -> MagicMock:
n = MagicMock()
n.note_type = note_type
n.entity_meta = entity_meta
base = {"id": 1, "title": "t", "note_type": note_type,
"metadata": entity_meta or {}}
base.update(overrides)
n.to_dict.return_value = base
return n
# ─── list ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_persons_calls_knowledge_with_person_type():
mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1))
with patch("fabledassistant.mcp.tools.entities.knowledge_svc.query_knowledge", mock):
out = await list_persons(tag="work")
kwargs = mock.call_args.kwargs
assert kwargs["note_type"] == "person"
assert kwargs["tags"] == ["work"]
assert out["total"] == 1
assert "persons" in out # type-specific plural key
# ─── create: metadata building ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_person_only_includes_provided_fields_in_meta():
"""Empty-string fields must NOT pollute entity_meta."""
fake = _fake_note(note_type="person")
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await create_person(name="Alice", email="a@x.com")
kwargs = mock.call_args.kwargs
assert kwargs["title"] == "Alice"
assert kwargs["note_type"] == "person"
assert kwargs["entity_meta"] == {"email": "a@x.com"}
@pytest.mark.asyncio
async def test_create_person_all_empty_meta_is_none():
"""Service is called with entity_meta=None when no typed fields were given."""
fake = _fake_note(note_type="person")
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await create_person(name="Bob")
assert mock.call_args.kwargs["entity_meta"] is None
@pytest.mark.asyncio
async def test_create_place_categories_into_meta():
fake = _fake_note(note_type="place")
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await create_place(name="Cafe X", address="123 Main", category="coffee")
meta = mock.call_args.kwargs["entity_meta"]
assert meta == {"address": "123 Main", "category": "coffee"}
@pytest.mark.asyncio
async def test_create_list_translates_strings_to_unchecked_items():
fake = _fake_note(note_type="list")
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await create_list(name="shopping", items=["milk", "bread"])
meta = mock.call_args.kwargs["entity_meta"]
assert meta["list_items"] == [
{"text": "milk", "checked": False},
{"text": "bread", "checked": False},
]
# ─── update: meta merge ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_update_person_merges_meta_preserving_other_fields():
"""Updating one field must NOT clobber the others stored in entity_meta."""
existing = _fake_note(
id=5, note_type="person",
entity_meta={"email": "old@x.com", "phone": "555-1234"},
)
get_mock = AsyncMock(return_value=existing)
updated = _fake_note(note_type="person")
update_mock = AsyncMock(return_value=updated)
with patch(
"fabledassistant.mcp.tools.entities.notes_svc.get_note", get_mock,
), patch(
"fabledassistant.mcp.tools.entities.notes_svc.update_note", update_mock,
):
await update_person(person_id=5, email="new@x.com")
new_meta = update_mock.call_args.kwargs["entity_meta"]
assert new_meta == {"email": "new@x.com", "phone": "555-1234"}
@pytest.mark.asyncio
async def test_update_person_no_typed_fields_keeps_meta_unchanged():
existing = _fake_note(
id=5, note_type="person",
entity_meta={"email": "a@x.com"},
)
updated = _fake_note(note_type="person")
with patch(
"fabledassistant.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=existing),
), patch(
"fabledassistant.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated),
) as update_mock:
await update_person(person_id=5, name="New Name")
# entity_meta unchanged, but still passed (service gets the full new dict)
assert update_mock.call_args.kwargs["entity_meta"] == {"email": "a@x.com"}
assert update_mock.call_args.kwargs["title"] == "New Name"
@pytest.mark.asyncio
async def test_update_person_rejects_wrong_type():
"""Trying to update a 'place' as a 'person' must fail."""
wrong_type = _fake_note(id=5, note_type="place")
with patch(
"fabledassistant.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=wrong_type),
):
with pytest.raises(ValueError, match="person 5 not found"):
await update_person(person_id=5, email="x@x.com")
@pytest.mark.asyncio
async def test_update_list_items_empty_list_clears_items():
"""items=[] clears all items; items=None leaves unchanged."""
existing = _fake_note(
id=5, note_type="list",
entity_meta={"list_items": [{"text": "old", "checked": True}]},
)
updated = _fake_note(note_type="list")
with patch(
"fabledassistant.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=existing),
), patch(
"fabledassistant.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated),
) as update_mock:
await update_list(list_id=5, items=[])
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == []
@pytest.mark.asyncio
async def test_update_list_items_none_leaves_items_unchanged():
existing = _fake_note(
id=5, note_type="list",
entity_meta={"list_items": [{"text": "keep", "checked": False}]},
)
updated = _fake_note(note_type="list")
with patch(
"fabledassistant.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=existing),
), patch(
"fabledassistant.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated),
) as update_mock:
await update_list(list_id=5, name="renamed")
# Existing items intact in the merged meta
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [
{"text": "keep", "checked": False},
]