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>
This commit is contained in:
2026-05-27 11:48:55 -04:00
parent 97c22941a8
commit 6aa84002b3
19 changed files with 191 additions and 190 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
"""Per-request MCP context. """Per-request MCP context.
The ASGI middleware (mcp/server.py) populates `_user_id_ctx` from The ASGI middleware (mcp/server.py) populates `_user_id_ctx` from
scope['fable_user_id'] before dispatching to FastMCP tool handlers. scope['scribe_user_id'] before dispatching to FastMCP tool handlers.
Tool functions then call `current_user_id()` to retrieve it. Tool functions then call `current_user_id()` to retrieve it.
Tools must never fall back to a default user — `current_user_id()` raises Tools must never fall back to a default user — `current_user_id()` raises
@@ -9,7 +9,7 @@ if called outside a properly-authed MCP request.
""" """
from contextvars import ContextVar from contextvars import ContextVar
_user_id_ctx: ContextVar[int | None] = ContextVar("fable_mcp_user_id", default=None) _user_id_ctx: ContextVar[int | None] = ContextVar("scribe_mcp_user_id", default=None)
def current_user_id() -> int: def current_user_id() -> int:
+10 -9
View File
@@ -5,15 +5,16 @@ from mcp.server.fastmcp import FastMCP
from quart import Quart from quart import Quart
_INSTRUCTIONS = """ _INSTRUCTIONS = """
Fabled Scribe is the user's self-hosted second-brain and project-management Scribe is the user's self-hosted second-brain and project-management data
data store. You (Claude) are the assistant. store. You (Claude) are the assistant.
Hierarchy: Project -> Milestone -> Task/Note. Hierarchy: Project -> Milestone -> Task/Note.
- Notes and Tasks share a model; tasks are notes with is_task=True. - Notes and Tasks share a model; tasks are notes with is_task=True.
- Use fable_*_note tools for notes, fable_*_task tools for tasks. - Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
- Typed entities (person, place, list) are notes with a type field plus - Typed entities (person, place, list) are notes with a non-default note_type
type-specific columns; use the dedicated tools for those. plus type-specific columns; use the dedicated *_person / *_place / *_list
tools rather than create_note.
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave - Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
unchanged on updates. unchanged on updates.
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean - For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
@@ -23,7 +24,7 @@ Hierarchy: Project -> Milestone -> Task/Note.
def build_mcp_server() -> FastMCP: def build_mcp_server() -> FastMCP:
"""Build the FastMCP instance with all tools registered.""" """Build the FastMCP instance with all tools registered."""
mcp = FastMCP("fable", instructions=_INSTRUCTIONS.strip()) mcp = FastMCP("scribe", instructions=_INSTRUCTIONS.strip())
from fabledassistant.mcp.tools import register_all from fabledassistant.mcp.tools import register_all
register_all(mcp) register_all(mcp)
return mcp return mcp
@@ -34,7 +35,7 @@ def mount_mcp(app: Quart) -> None:
A small ASGI middleware between Quart and the FastMCP sub-app validates the A small ASGI middleware between Quart and the FastMCP sub-app validates the
Bearer token against the api_keys table. Authenticated requests have their Bearer token against the api_keys table. Authenticated requests have their
user_id attached to the ASGI scope under "fable_user_id" for tool handlers user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
to read in later phases. to read in later phases.
""" """
from fabledassistant.mcp.auth import resolve_bearer_to_user_id from fabledassistant.mcp.auth import resolve_bearer_to_user_id
@@ -55,7 +56,7 @@ def mount_mcp(app: Quart) -> None:
"status": 401, "status": 401,
"headers": [ "headers": [
(b"content-type", b"application/json"), (b"content-type", b"application/json"),
(b"www-authenticate", b'Bearer realm="fable-mcp"'), (b"www-authenticate", b'Bearer realm="scribe-mcp"'),
], ],
}) })
await send({ await send({
@@ -63,7 +64,7 @@ def mount_mcp(app: Quart) -> None:
"body": b'{"error":"unauthorized"}', "body": b'{"error":"unauthorized"}',
}) })
return return
scope["fable_user_id"] = user_id scope["scribe_user_id"] = user_id
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
token = _user_id_ctx.set(user_id) token = _user_id_ctx.set(user_id)
try: try:
+13 -13
View File
@@ -2,7 +2,7 @@
These are notes with a non-'note' note_type and type-specific JSON metadata These are notes with a non-'note' note_type and type-specific JSON metadata
stored in the entity_meta column. Three tools per type — list, create, update. stored in the entity_meta column. Three tools per type — list, create, update.
For get and delete, use fable_get_note / fable_delete_note (typed entities For get and delete, use get_note / delete_note (typed entities
share the Note model). share the Note model).
The wrappers translate typed-field kwargs into the entity_meta dict shape that The wrappers translate typed-field kwargs into the entity_meta dict shape that
@@ -72,7 +72,7 @@ _PERSON_FIELDS = ("relationship", "email", "phone", "birthday",
"organization", "address") "organization", "address")
async def fable_list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict: async def list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict:
"""List people in the user's knowledge base. """List people in the user's knowledge base.
Args: Args:
@@ -83,7 +83,7 @@ async def fable_list_persons(q: str = "", tag: str = "", limit: int = 25) -> dic
return await _list_by_type("person", q, tag, limit) return await _list_by_type("person", q, tag, limit)
async def fable_create_person( async def create_person(
name: str, name: str,
relationship: str = "", relationship: str = "",
email: str = "", email: str = "",
@@ -109,7 +109,7 @@ async def fable_create_person(
return await _create_entity("person", name, meta, tags) return await _create_entity("person", name, meta, tags)
async def fable_update_person( async def update_person(
person_id: int, person_id: int,
name: str = "", name: str = "",
relationship: str = "", relationship: str = "",
@@ -135,12 +135,12 @@ async def fable_update_person(
# ─── Place ─────────────────────────────────────────────────────────────────── # ─── Place ───────────────────────────────────────────────────────────────────
async def fable_list_places(q: str = "", tag: str = "", limit: int = 25) -> dict: async def list_places(q: str = "", tag: str = "", limit: int = 25) -> dict:
"""List places (cafes, offices, addresses) in the user's knowledge base.""" """List places (cafes, offices, addresses) in the user's knowledge base."""
return await _list_by_type("place", q, tag, limit) return await _list_by_type("place", q, tag, limit)
async def fable_create_place( async def create_place(
name: str, name: str,
address: str = "", address: str = "",
phone: str = "", phone: str = "",
@@ -163,7 +163,7 @@ async def fable_create_place(
return await _create_entity("place", name, meta, tags) return await _create_entity("place", name, meta, tags)
async def fable_update_place( async def update_place(
place_id: int, place_id: int,
name: str = "", name: str = "",
address: str = "", address: str = "",
@@ -183,12 +183,12 @@ async def fable_update_place(
# ─── List ──────────────────────────────────────────────────────────────────── # ─── List ────────────────────────────────────────────────────────────────────
async def fable_list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict: async def list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict:
"""List checklists in the user's knowledge base.""" """List checklists in the user's knowledge base."""
return await _list_by_type("list", q, tag, limit) return await _list_by_type("list", q, tag, limit)
async def fable_create_list( async def create_list(
name: str, name: str,
category: str = "", category: str = "",
items: list[str] | None = None, items: list[str] | None = None,
@@ -210,7 +210,7 @@ async def fable_create_list(
return await _create_entity("list", name, meta, tags) return await _create_entity("list", name, meta, tags)
async def fable_update_list( async def update_list(
list_id: int, list_id: int,
name: str = "", name: str = "",
category: str = "", category: str = "",
@@ -238,8 +238,8 @@ async def fable_update_list(
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
fable_list_persons, fable_create_person, fable_update_person, list_persons, create_person, update_person,
fable_list_places, fable_create_place, fable_update_place, list_places, create_place, update_place,
fable_list_lists, fable_create_list, fable_update_list, list_lists, create_list, update_list,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+10 -10
View File
@@ -34,7 +34,7 @@ def _event_dict(event) -> dict:
return event if isinstance(event, dict) else event.to_dict() return event if isinstance(event, dict) else event.to_dict()
async def fable_list_events(date_from: str, date_to: str) -> dict: async def list_events(date_from: str, date_to: str) -> dict:
"""List events between date_from and date_to (YYYY-MM-DD, both inclusive at """List events between date_from and date_to (YYYY-MM-DD, both inclusive at
the day level — `date_to` is interpreted as end-of-that-day). the day level — `date_to` is interpreted as end-of-that-day).
@@ -47,7 +47,7 @@ async def fable_list_events(date_from: str, date_to: str) -> dict:
return {"events": [_event_dict(e) for e in rows], "total": len(rows)} return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
async def fable_create_event( async def create_event(
title: str, title: str,
start_date: str, start_date: str,
start_time: str = "00:00", start_time: str = "00:00",
@@ -80,7 +80,7 @@ async def fable_create_event(
return event.to_dict() return event.to_dict()
async def fable_get_event(event_id: int) -> dict: async def get_event(event_id: int) -> dict:
"""Fetch a single event by ID.""" """Fetch a single event by ID."""
uid = current_user_id() uid = current_user_id()
event = await events_svc.get_event(uid, event_id) event = await events_svc.get_event(uid, event_id)
@@ -89,7 +89,7 @@ async def fable_get_event(event_id: int) -> dict:
return event.to_dict() return event.to_dict()
async def fable_update_event( async def update_event(
event_id: int, event_id: int,
title: str = "", title: str = "",
start_date: str = "", start_date: str = "",
@@ -128,7 +128,7 @@ async def fable_update_event(
return event.to_dict() return event.to_dict()
async def fable_delete_event(event_id: int) -> str: async def delete_event(event_id: int) -> str:
"""Permanently delete a calendar event by ID. Cannot be undone.""" """Permanently delete a calendar event by ID. Cannot be undone."""
uid = current_user_id() uid = current_user_id()
existing = await events_svc.get_event(uid, event_id) existing = await events_svc.get_event(uid, event_id)
@@ -140,10 +140,10 @@ async def fable_delete_event(event_id: int) -> str:
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
fable_list_events, list_events,
fable_create_event, create_event,
fable_get_event, get_event,
fable_update_event, update_event,
fable_delete_event, delete_event,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+9 -9
View File
@@ -15,8 +15,8 @@ from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import milestones as milestones_svc from fabledassistant.services import milestones as milestones_svc
async def fable_list_milestones(project_id: int) -> dict: async def list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project, ordered by order_index. """List milestones for a Scribe project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, Returns id, title, description, status (active/done), order_index,
and task counts. and task counts.
@@ -26,13 +26,13 @@ async def fable_list_milestones(project_id: int) -> dict:
return {"milestones": rows} return {"milestones": rows}
async def fable_create_milestone( async def create_milestone(
project_id: int, project_id: int,
title: str, title: str,
description: str = "", description: str = "",
status: str = "active", status: str = "active",
) -> dict: ) -> dict:
"""Create a milestone within a Fable project. """Create a milestone within a Scribe project.
Args: Args:
project_id: The project this milestone belongs to (required). project_id: The project this milestone belongs to (required).
@@ -51,7 +51,7 @@ async def fable_create_milestone(
return milestone.to_dict() return milestone.to_dict()
async def fable_update_milestone( async def update_milestone(
project_id: int, project_id: int,
milestone_id: int, milestone_id: int,
title: str = "", title: str = "",
@@ -59,7 +59,7 @@ async def fable_update_milestone(
status: str = "", status: str = "",
order_index: int = -1, order_index: int = -1,
) -> dict: ) -> dict:
"""Update a Fable milestone. Only explicitly provided fields are changed. """Update a Scribe milestone. Only explicitly provided fields are changed.
Args: Args:
project_id: Project the milestone belongs to (preserved for API parity; project_id: Project the milestone belongs to (preserved for API parity;
@@ -88,8 +88,8 @@ async def fable_update_milestone(
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
fable_list_milestones, list_milestones,
fable_create_milestone, create_milestone,
fable_update_milestone, update_milestone,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+16 -16
View File
@@ -17,18 +17,18 @@ from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
async def fable_list_notes( async def list_notes(
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
tag: str = "", tag: str = "",
search_text: str = "", search_text: str = "",
) -> dict: ) -> dict:
"""List notes (non-task documents) stored in Fable. """List notes (non-task documents) stored in Scribe.
Optionally filter by a single tag (plain string, no # prefix) or a keyword Optionally filter by a single tag (plain string, no # prefix) or a keyword
search against title and body. Results are ordered by last-updated descending. search against title and body. Results are ordered by last-updated descending.
Use fable_search for semantic/meaning-based lookup instead of exact keyword search. Use search for semantic/meaning-based lookup instead of exact keyword search.
""" """
uid = current_user_id() uid = current_user_id()
rows, total = await notes_svc.list_notes( rows, total = await notes_svc.list_notes(
@@ -42,8 +42,8 @@ async def fable_list_notes(
return {"notes": [n.to_dict() for n in rows], "total": total} return {"notes": [n.to_dict() for n in rows], "total": total}
async def fable_get_note(note_id: int) -> dict: async def get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID. """Fetch the full content of a single Scribe note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at. Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
""" """
@@ -54,13 +54,13 @@ async def fable_get_note(note_id: int) -> dict:
return note.to_dict() return note.to_dict()
async def fable_create_note( async def create_note(
title: str, title: str,
body: str = "", body: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
project_id: int = 0, project_id: int = 0,
) -> dict: ) -> dict:
"""Create a new note in Fable. """Create a new note in Scribe.
Args: Args:
title: Note title (required). title: Note title (required).
@@ -81,14 +81,14 @@ async def fable_create_note(
return note.to_dict() return note.to_dict()
async def fable_update_note( async def update_note(
note_id: int, note_id: int,
title: str = "", title: str = "",
body: str = "", body: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
project_id: int = 0, project_id: int = 0,
) -> dict: ) -> dict:
"""Update an existing Fable note. Only explicitly provided fields are changed. """Update an existing Scribe note. Only explicitly provided fields are changed.
Args: Args:
note_id: ID of the note to update. note_id: ID of the note to update.
@@ -113,8 +113,8 @@ async def fable_update_note(
return note.to_dict() return note.to_dict()
async def fable_delete_note(note_id: int) -> str: async def delete_note(note_id: int) -> str:
"""Permanently delete a Fable note by ID. This cannot be undone.""" """Permanently delete a Scribe note by ID. This cannot be undone."""
uid = current_user_id() uid = current_user_id()
deleted = await notes_svc.delete_note(uid, note_id) deleted = await notes_svc.delete_note(uid, note_id)
if not deleted: if not deleted:
@@ -124,10 +124,10 @@ async def fable_delete_note(note_id: int) -> str:
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
fable_list_notes, list_notes,
fable_get_note, get_note,
fable_create_note, create_note,
fable_update_note, update_note,
fable_delete_note, delete_note,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+12 -12
View File
@@ -21,8 +21,8 @@ from fabledassistant.services import milestones as milestones_svc
from fabledassistant.services import projects as projects_svc from fabledassistant.services import projects as projects_svc
async def fable_list_projects() -> dict: async def list_projects() -> dict:
"""List all Fable projects for the current user. """List all Scribe projects for the current user.
Returns id, title, description, goal, status (active/archived), color, Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project. and a short auto-generated summary for each project.
@@ -32,8 +32,8 @@ async def fable_list_projects() -> dict:
return {"projects": [p.to_dict() for p in rows]} return {"projects": [p.to_dict() for p in rows]}
async def fable_get_project(project_id: int) -> dict: async def get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including its milestone summary. """Fetch a Scribe project by ID, including its milestone summary.
Returns full project fields plus a milestone_summary list with each Returns full project fields plus a milestone_summary list with each
milestone's id, title, status, and task counts. milestone's id, title, status, and task counts.
@@ -49,14 +49,14 @@ async def fable_get_project(project_id: int) -> dict:
return data return data
async def fable_create_project( async def create_project(
title: str, title: str,
description: str = "", description: str = "",
goal: str = "", goal: str = "",
status: str = "active", status: str = "active",
color: str = "", color: str = "",
) -> dict: ) -> dict:
"""Create a new project in Fable. """Create a new project in Scribe.
Args: Args:
title: Project name (required). title: Project name (required).
@@ -77,7 +77,7 @@ async def fable_create_project(
return project.to_dict() return project.to_dict()
async def fable_update_project( async def update_project(
project_id: int, project_id: int,
title: str = "", title: str = "",
description: str = "", description: str = "",
@@ -85,7 +85,7 @@ async def fable_update_project(
status: str = "", status: str = "",
color: str = "", color: str = "",
) -> dict: ) -> dict:
"""Update an existing Fable project. Only explicitly provided fields are changed. """Update an existing Scribe project. Only explicitly provided fields are changed.
Args: Args:
project_id: ID of the project to update. project_id: ID of the project to update.
@@ -115,9 +115,9 @@ async def fable_update_project(
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
fable_list_projects, list_projects,
fable_get_project, get_project,
fable_create_project, create_project,
fable_update_project, update_project,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+3 -3
View File
@@ -1,4 +1,4 @@
"""fable_get_recent — cross-type recent-activity tool. """get_recent — cross-type recent-activity tool.
Returns the most-recently-touched notes, tasks, projects, and events for the Returns the most-recently-touched notes, tasks, projects, and events for the
user, ordered by updated_at descending. Useful for Claude to bootstrap context user, ordered by updated_at descending. Useful for Claude to bootstrap context
@@ -20,7 +20,7 @@ from fabledassistant.models.note import Note
from fabledassistant.models.project import Project from fabledassistant.models.project import Project
async def fable_get_recent(days: int = 7, limit: int = 25) -> dict: async def get_recent(days: int = 7, limit: int = 25) -> dict:
"""Return recently-touched items across notes, tasks, projects, events. """Return recently-touched items across notes, tasks, projects, events.
Args: Args:
@@ -78,4 +78,4 @@ async def fable_get_recent(days: int = 7, limit: int = 25) -> dict:
def register(mcp) -> None: def register(mcp) -> None:
mcp.tool(name="fable_get_recent")(fable_get_recent) mcp.tool(name="get_recent")(get_recent)
+3 -3
View File
@@ -1,4 +1,4 @@
"""fable_search — semantic search across the user's notes and tasks. """search — semantic search across the user's notes and tasks.
Mirrors the existing fable-mcp contract so Claude's prior usage pattern keeps Mirrors the existing fable-mcp contract so Claude's prior usage pattern keeps
working. Differences from fable-mcp: working. Differences from fable-mcp:
@@ -11,7 +11,7 @@ from fabledassistant.mcp._context import current_user_id
from fabledassistant.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
async def fable_search(q: str, content_type: str = "all", limit: int = 10) -> dict: async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
"""Semantic search over the user's notes and tasks. """Semantic search over the user's notes and tasks.
Args: Args:
@@ -46,4 +46,4 @@ async def fable_search(q: str, content_type: str = "all", limit: int = 10) -> di
def register(mcp) -> None: def register(mcp) -> None:
mcp.tool(name="fable_search")(fable_search) mcp.tool(name="search")(search)
+3 -3
View File
@@ -1,4 +1,4 @@
"""fable_list_tags — return the user's tag vocabulary with usage counts. """list_tags — return the user's tag vocabulary with usage counts.
Python-side aggregation rather than SQL UNNEST. Personal-scale (~thousands of 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 notes) makes the perf cost negligible, and a one-pass dict counter is much
@@ -25,7 +25,7 @@ def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
return counts return counts
async def fable_list_tags(limit: int = 50) -> dict: async def list_tags(limit: int = 50) -> dict:
"""Return the user's most-used tags with usage counts. """Return the user's most-used tags with usage counts.
Args: Args:
@@ -51,4 +51,4 @@ async def fable_list_tags(limit: int = 50) -> dict:
def register(mcp) -> None: def register(mcp) -> None:
mcp.tool(name="fable_list_tags")(fable_list_tags) mcp.tool(name="list_tags")(list_tags)
+17 -17
View File
@@ -3,9 +3,9 @@
Tasks are notes with a non-null `status` — same model, different filter. Tasks are notes with a non-null `status` — same model, different filter.
Wrappers call services/notes.py for CRUD with is_task=True and add the Wrappers call services/notes.py for CRUD with is_task=True and add the
task-specific fields (status, priority, due_date, parent_id), plus task-specific fields (status, priority, due_date, parent_id), plus
services/task_logs.py for fable_add_task_log. services/task_logs.py for add_task_log.
There is no fable_delete_task — matches the existing fable-mcp surface. There is no delete_task — matches the existing fable-mcp surface.
Cancel by updating status to "cancelled". Cancel by updating status to "cancelled".
Sentinels (preserved from existing fable-mcp): Sentinels (preserved from existing fable-mcp):
@@ -23,13 +23,13 @@ from fabledassistant.services import notes as notes_svc
from fabledassistant.services import task_logs as task_logs_svc from fabledassistant.services import task_logs as task_logs_svc
async def fable_list_tasks( async def list_tasks(
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
status: str = "", status: str = "",
project_id: int = 0, project_id: int = 0,
) -> dict: ) -> dict:
"""List tasks in Fable. """List tasks in Scribe.
Args: Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all. status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
@@ -49,8 +49,8 @@ async def fable_list_tasks(
return {"tasks": [n.to_dict() for n in rows], "total": total} return {"tasks": [n.to_dict() for n in rows], "total": total}
async def fable_get_task(task_id: int) -> dict: async def get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID. """Fetch a single Scribe task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id, Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at. parent_id, parent_title, due_date, created_at, updated_at.
@@ -69,7 +69,7 @@ async def fable_get_task(task_id: int) -> dict:
return data return data
async def fable_create_task( async def create_task(
title: str, title: str,
body: str = "", body: str = "",
status: str = "todo", status: str = "todo",
@@ -79,7 +79,7 @@ async def fable_create_task(
parent_id: int = 0, parent_id: int = 0,
tags: list[str] | None = None, tags: list[str] | None = None,
) -> dict: ) -> dict:
"""Create a new task in Fable. """Create a new task in Scribe.
Args: Args:
title: Task title (required). title: Task title (required).
@@ -106,7 +106,7 @@ async def fable_create_task(
return note.to_dict() return note.to_dict()
async def fable_update_task( async def update_task(
task_id: int, task_id: int,
title: str = "", title: str = "",
body: str = "", body: str = "",
@@ -115,7 +115,7 @@ async def fable_update_task(
project_id: int = 0, project_id: int = 0,
milestone_id: int = 0, milestone_id: int = 0,
) -> dict: ) -> dict:
"""Update an existing Fable task. Only explicitly provided fields are changed. """Update an existing Scribe task. Only explicitly provided fields are changed.
Args: Args:
task_id: ID of the task to update. task_id: ID of the task to update.
@@ -146,8 +146,8 @@ async def fable_update_task(
return note.to_dict() return note.to_dict()
async def fable_add_task_log(task_id: int, content: str) -> dict: async def add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Fable task. """Append a timestamped progress log entry to a Scribe task.
Use this to record work sessions, decisions, or status updates over time Use this to record work sessions, decisions, or status updates over time
without overwriting the task's main body. Each entry is stored separately without overwriting the task's main body. Each entry is stored separately
@@ -163,10 +163,10 @@ async def fable_add_task_log(task_id: int, content: str) -> dict:
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
fable_list_tasks, list_tasks,
fable_get_task, get_task,
fable_create_task, create_task,
fable_update_task, update_task,
fable_add_task_log, add_task_log,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+13 -13
View File
@@ -8,9 +8,9 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.entities import ( from fabledassistant.mcp.tools.entities import (
fable_list_persons, fable_create_person, fable_update_person, list_persons, create_person, update_person,
fable_create_place, fable_update_place, create_place, update_place,
fable_create_list, fable_update_list, create_list, update_list,
) )
@@ -39,7 +39,7 @@ def _fake_note(*, note_type="note", entity_meta=None, **overrides) -> MagicMock:
async def test_list_persons_calls_knowledge_with_person_type(): async def test_list_persons_calls_knowledge_with_person_type():
mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1)) mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1))
with patch("fabledassistant.mcp.tools.entities.knowledge_svc.query_knowledge", mock): with patch("fabledassistant.mcp.tools.entities.knowledge_svc.query_knowledge", mock):
out = await fable_list_persons(tag="work") out = await list_persons(tag="work")
kwargs = mock.call_args.kwargs kwargs = mock.call_args.kwargs
assert kwargs["note_type"] == "person" assert kwargs["note_type"] == "person"
assert kwargs["tags"] == ["work"] assert kwargs["tags"] == ["work"]
@@ -56,7 +56,7 @@ async def test_create_person_only_includes_provided_fields_in_meta():
fake = _fake_note(note_type="person") fake = _fake_note(note_type="person")
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await fable_create_person(name="Alice", email="a@x.com") await create_person(name="Alice", email="a@x.com")
kwargs = mock.call_args.kwargs kwargs = mock.call_args.kwargs
assert kwargs["title"] == "Alice" assert kwargs["title"] == "Alice"
assert kwargs["note_type"] == "person" assert kwargs["note_type"] == "person"
@@ -69,7 +69,7 @@ async def test_create_person_all_empty_meta_is_none():
fake = _fake_note(note_type="person") fake = _fake_note(note_type="person")
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await fable_create_person(name="Bob") await create_person(name="Bob")
assert mock.call_args.kwargs["entity_meta"] is None assert mock.call_args.kwargs["entity_meta"] is None
@@ -78,7 +78,7 @@ async def test_create_place_categories_into_meta():
fake = _fake_note(note_type="place") fake = _fake_note(note_type="place")
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await fable_create_place(name="Cafe X", address="123 Main", category="coffee") await create_place(name="Cafe X", address="123 Main", category="coffee")
meta = mock.call_args.kwargs["entity_meta"] meta = mock.call_args.kwargs["entity_meta"]
assert meta == {"address": "123 Main", "category": "coffee"} assert meta == {"address": "123 Main", "category": "coffee"}
@@ -88,7 +88,7 @@ async def test_create_list_translates_strings_to_unchecked_items():
fake = _fake_note(note_type="list") fake = _fake_note(note_type="list")
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.entities.notes_svc.create_note", mock):
await fable_create_list(name="shopping", items=["milk", "bread"]) await create_list(name="shopping", items=["milk", "bread"])
meta = mock.call_args.kwargs["entity_meta"] meta = mock.call_args.kwargs["entity_meta"]
assert meta["list_items"] == [ assert meta["list_items"] == [
{"text": "milk", "checked": False}, {"text": "milk", "checked": False},
@@ -114,7 +114,7 @@ async def test_update_person_merges_meta_preserving_other_fields():
), patch( ), patch(
"fabledassistant.mcp.tools.entities.notes_svc.update_note", update_mock, "fabledassistant.mcp.tools.entities.notes_svc.update_note", update_mock,
): ):
await fable_update_person(person_id=5, email="new@x.com") await update_person(person_id=5, email="new@x.com")
new_meta = update_mock.call_args.kwargs["entity_meta"] new_meta = update_mock.call_args.kwargs["entity_meta"]
assert new_meta == {"email": "new@x.com", "phone": "555-1234"} assert new_meta == {"email": "new@x.com", "phone": "555-1234"}
@@ -133,7 +133,7 @@ async def test_update_person_no_typed_fields_keeps_meta_unchanged():
"fabledassistant.mcp.tools.entities.notes_svc.update_note", "fabledassistant.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated), AsyncMock(return_value=updated),
) as update_mock: ) as update_mock:
await fable_update_person(person_id=5, name="New Name") await update_person(person_id=5, name="New Name")
# entity_meta unchanged, but still passed (service gets the full new dict) # 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["entity_meta"] == {"email": "a@x.com"}
assert update_mock.call_args.kwargs["title"] == "New Name" assert update_mock.call_args.kwargs["title"] == "New Name"
@@ -148,7 +148,7 @@ async def test_update_person_rejects_wrong_type():
AsyncMock(return_value=wrong_type), AsyncMock(return_value=wrong_type),
): ):
with pytest.raises(ValueError, match="person 5 not found"): with pytest.raises(ValueError, match="person 5 not found"):
await fable_update_person(person_id=5, email="x@x.com") await update_person(person_id=5, email="x@x.com")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -166,7 +166,7 @@ async def test_update_list_items_empty_list_clears_items():
"fabledassistant.mcp.tools.entities.notes_svc.update_note", "fabledassistant.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated), AsyncMock(return_value=updated),
) as update_mock: ) as update_mock:
await fable_update_list(list_id=5, items=[]) await update_list(list_id=5, items=[])
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [] assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == []
@@ -184,7 +184,7 @@ async def test_update_list_items_none_leaves_items_unchanged():
"fabledassistant.mcp.tools.entities.notes_svc.update_note", "fabledassistant.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated), AsyncMock(return_value=updated),
) as update_mock: ) as update_mock:
await fable_update_list(list_id=5, name="renamed") await update_list(list_id=5, name="renamed")
# Existing items intact in the merged meta # Existing items intact in the merged meta
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [ assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [
{"text": "keep", "checked": False}, {"text": "keep", "checked": False},
+14 -14
View File
@@ -6,8 +6,8 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.events import ( from fabledassistant.mcp.tools.events import (
fable_list_events, fable_create_event, fable_get_event, list_events, create_event, get_event,
fable_update_event, fable_delete_event, update_event, delete_event,
) )
@@ -36,7 +36,7 @@ async def test_list_events_passes_datetime_range():
{"id": 1, "title": "morning standup"}, {"id": 1, "title": "morning standup"},
]) ])
with patch("fabledassistant.mcp.tools.events.events_svc.list_events", mock): with patch("fabledassistant.mcp.tools.events.events_svc.list_events", mock):
out = await fable_list_events(date_from="2026-06-01", date_to="2026-06-08") out = await list_events(date_from="2026-06-01", date_to="2026-06-08")
args, _ = mock.call_args args, _ = mock.call_args
assert args[0] == 7 # user_id assert args[0] == 7 # user_id
assert args[1] == datetime(2026, 6, 1) assert args[1] == datetime(2026, 6, 1)
@@ -49,7 +49,7 @@ async def test_create_event_combines_date_and_time():
e = _fake_event() e = _fake_event()
mock = AsyncMock(return_value=e) mock = AsyncMock(return_value=e)
with patch("fabledassistant.mcp.tools.events.events_svc.create_event", mock): with patch("fabledassistant.mcp.tools.events.events_svc.create_event", mock):
await fable_create_event( await create_event(
title="standup", start_date="2026-06-01", start_time="09:30", title="standup", start_date="2026-06-01", start_time="09:30",
duration_minutes=15, duration_minutes=15,
) )
@@ -64,7 +64,7 @@ async def test_create_event_zero_duration_means_point_event():
e = _fake_event() e = _fake_event()
mock = AsyncMock(return_value=e) mock = AsyncMock(return_value=e)
with patch("fabledassistant.mcp.tools.events.events_svc.create_event", mock): with patch("fabledassistant.mcp.tools.events.events_svc.create_event", mock):
await fable_create_event(title="x", start_date="2026-06-01") await create_event(title="x", start_date="2026-06-01")
assert mock.call_args.kwargs["duration_minutes"] is None assert mock.call_args.kwargs["duration_minutes"] is None
@@ -75,7 +75,7 @@ async def test_get_event_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="event 999 not found"): with pytest.raises(ValueError, match="event 999 not found"):
await fable_get_event(event_id=999) await get_event(event_id=999)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -83,7 +83,7 @@ async def test_update_event_only_sends_non_default_fields():
e = _fake_event() e = _fake_event()
mock = AsyncMock(return_value=e) mock = AsyncMock(return_value=e)
with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock):
await fable_update_event(event_id=1, title="new title") await update_event(event_id=1, title="new title")
args, kwargs = mock.call_args args, kwargs = mock.call_args
assert args == (7, 1) assert args == (7, 1)
assert kwargs == {"title": "new title"} assert kwargs == {"title": "new title"}
@@ -94,7 +94,7 @@ async def test_update_event_duration_minus_one_means_unchanged():
e = _fake_event() e = _fake_event()
mock = AsyncMock(return_value=e) mock = AsyncMock(return_value=e)
with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock):
await fable_update_event(event_id=1, duration_minutes=-1) await update_event(event_id=1, duration_minutes=-1)
assert "duration_minutes" not in mock.call_args.kwargs assert "duration_minutes" not in mock.call_args.kwargs
@@ -104,7 +104,7 @@ async def test_update_event_duration_zero_clears_to_point():
e = _fake_event() e = _fake_event()
mock = AsyncMock(return_value=e) mock = AsyncMock(return_value=e)
with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock):
await fable_update_event(event_id=1, duration_minutes=0) await update_event(event_id=1, duration_minutes=0)
assert mock.call_args.kwargs["duration_minutes"] is None assert mock.call_args.kwargs["duration_minutes"] is None
@@ -113,12 +113,12 @@ async def test_update_event_requires_both_date_and_time_to_move():
e = _fake_event() e = _fake_event()
mock = AsyncMock(return_value=e) mock = AsyncMock(return_value=e)
with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock):
await fable_update_event(event_id=1, start_date="2026-06-02") await update_event(event_id=1, start_date="2026-06-02")
# Only start_date, no start_time → start_dt NOT in fields # Only start_date, no start_time → start_dt NOT in fields
assert "start_dt" not in mock.call_args.kwargs assert "start_dt" not in mock.call_args.kwargs
mock.reset_mock() mock.reset_mock()
await fable_update_event( await update_event(
event_id=1, start_date="2026-06-02", start_time="11:00", event_id=1, start_date="2026-06-02", start_time="11:00",
) )
assert mock.call_args.kwargs["start_dt"] == datetime(2026, 6, 2, 11) assert mock.call_args.kwargs["start_dt"] == datetime(2026, 6, 2, 11)
@@ -131,7 +131,7 @@ async def test_update_event_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="event 999 not found"): with pytest.raises(ValueError, match="event 999 not found"):
await fable_update_event(event_id=999, title="x") await update_event(event_id=999, title="x")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -144,7 +144,7 @@ async def test_delete_event_checks_existence_then_returns_confirmation():
"fabledassistant.mcp.tools.events.events_svc.delete_event", "fabledassistant.mcp.tools.events.events_svc.delete_event",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
result = await fable_delete_event(event_id=7) result = await delete_event(event_id=7)
assert result == "Event 7 deleted." assert result == "Event 7 deleted."
@@ -155,4 +155,4 @@ async def test_delete_event_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="event 999 not found"): with pytest.raises(ValueError, match="event 999 not found"):
await fable_delete_event(event_id=999) await delete_event(event_id=999)
+8 -8
View File
@@ -5,7 +5,7 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.milestones import ( from fabledassistant.mcp.tools.milestones import (
fable_list_milestones, fable_create_milestone, fable_update_milestone, list_milestones, create_milestone, update_milestone,
) )
@@ -32,7 +32,7 @@ async def test_list_milestones_returns_dict_with_progress():
"fabledassistant.mcp.tools.milestones.milestones_svc.get_project_milestone_summary", "fabledassistant.mcp.tools.milestones.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=rows), AsyncMock(return_value=rows),
): ):
out = await fable_list_milestones(project_id=1) out = await list_milestones(project_id=1)
assert out["milestones"] == rows assert out["milestones"] == rows
@@ -41,7 +41,7 @@ async def test_create_milestone_passes_through():
m = _fake_ms(id=5) m = _fake_ms(id=5)
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("fabledassistant.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("fabledassistant.mcp.tools.milestones.milestones_svc.create_milestone", mock):
out = await fable_create_milestone(project_id=1, title="new", description="d") out = await create_milestone(project_id=1, title="new", description="d")
assert out["id"] == 5 assert out["id"] == 5
assert mock.call_args.kwargs["project_id"] == 1 assert mock.call_args.kwargs["project_id"] == 1
assert mock.call_args.kwargs["title"] == "new" assert mock.call_args.kwargs["title"] == "new"
@@ -53,7 +53,7 @@ async def test_create_milestone_empty_description_becomes_none():
m = _fake_ms() m = _fake_ms()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("fabledassistant.mcp.tools.milestones.milestones_svc.create_milestone", mock): with patch("fabledassistant.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await fable_create_milestone(project_id=1, title="t", description="") await create_milestone(project_id=1, title="t", description="")
assert mock.call_args.kwargs["description"] is None assert mock.call_args.kwargs["description"] is None
@@ -62,7 +62,7 @@ async def test_update_milestone_only_sends_non_default_fields():
m = _fake_ms() m = _fake_ms()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await fable_update_milestone(project_id=1, milestone_id=5, status="done") await update_milestone(project_id=1, milestone_id=5, status="done")
args, kwargs = mock.call_args args, kwargs = mock.call_args
assert args == (7, 5) assert args == (7, 5)
assert kwargs == {"status": "done"} assert kwargs == {"status": "done"}
@@ -74,7 +74,7 @@ async def test_update_milestone_order_index_negative_is_omitted():
m = _fake_ms() m = _fake_ms()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await fable_update_milestone(project_id=1, milestone_id=5, order_index=-1) await update_milestone(project_id=1, milestone_id=5, order_index=-1)
assert "order_index" not in mock.call_args.kwargs assert "order_index" not in mock.call_args.kwargs
@@ -84,7 +84,7 @@ async def test_update_milestone_order_index_zero_is_explicit():
m = _fake_ms() m = _fake_ms()
mock = AsyncMock(return_value=m) mock = AsyncMock(return_value=m)
with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock): with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await fable_update_milestone(project_id=1, milestone_id=5, order_index=0) await update_milestone(project_id=1, milestone_id=5, order_index=0)
assert mock.call_args.kwargs["order_index"] == 0 assert mock.call_args.kwargs["order_index"] == 0
@@ -95,4 +95,4 @@ async def test_update_milestone_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="milestone 999 not found"): with pytest.raises(ValueError, match="milestone 999 not found"):
await fable_update_milestone(project_id=1, milestone_id=999, title="x") await update_milestone(project_id=1, milestone_id=999, title="x")
+18 -18
View File
@@ -5,8 +5,8 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.notes import ( from fabledassistant.mcp.tools.notes import (
fable_list_notes, fable_get_note, fable_create_note, list_notes, get_note, create_note,
fable_update_note, fable_delete_note, update_note, delete_note,
) )
@@ -32,7 +32,7 @@ async def test_list_notes_repackages_tuple_into_dict():
"fabledassistant.mcp.tools.notes.notes_svc.list_notes", "fabledassistant.mcp.tools.notes.notes_svc.list_notes",
AsyncMock(return_value=(rows, 2)), AsyncMock(return_value=(rows, 2)),
): ):
out = await fable_list_notes() out = await list_notes()
assert out["total"] == 2 assert out["total"] == 2
assert len(out["notes"]) == 2 assert len(out["notes"]) == 2
@@ -41,7 +41,7 @@ async def test_list_notes_repackages_tuple_into_dict():
async def test_list_notes_passes_is_task_false(): async def test_list_notes_passes_is_task_false():
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock):
await fable_list_notes() await list_notes()
assert mock.call_args.kwargs["is_task"] is False assert mock.call_args.kwargs["is_task"] is False
@@ -50,7 +50,7 @@ async def test_list_notes_tag_filter_maps_to_list():
"""The single-tag string param maps to a one-element list at the service layer.""" """The single-tag string param maps to a one-element list at the service layer."""
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock):
await fable_list_notes(tag="ops") await list_notes(tag="ops")
assert mock.call_args.kwargs["tags"] == ["ops"] assert mock.call_args.kwargs["tags"] == ["ops"]
@@ -58,7 +58,7 @@ async def test_list_notes_tag_filter_maps_to_list():
async def test_list_notes_empty_tag_means_no_filter(): async def test_list_notes_empty_tag_means_no_filter():
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock):
await fable_list_notes(tag="") await list_notes(tag="")
assert mock.call_args.kwargs["tags"] is None assert mock.call_args.kwargs["tags"] is None
@@ -66,7 +66,7 @@ async def test_list_notes_empty_tag_means_no_filter():
async def test_list_notes_search_text_maps_to_q(): async def test_list_notes_search_text_maps_to_q():
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock):
await fable_list_notes(search_text="kafka") await list_notes(search_text="kafka")
assert mock.call_args.kwargs["q"] == "kafka" assert mock.call_args.kwargs["q"] == "kafka"
@@ -74,7 +74,7 @@ async def test_list_notes_search_text_maps_to_q():
async def test_list_notes_limit_clamped(): async def test_list_notes_limit_clamped():
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.list_notes", mock):
await fable_list_notes(limit=9999) await list_notes(limit=9999)
assert mock.call_args.kwargs["limit"] == 100 assert mock.call_args.kwargs["limit"] == 100
@@ -85,7 +85,7 @@ async def test_get_note_returns_dict():
"fabledassistant.mcp.tools.notes.notes_svc.get_note", "fabledassistant.mcp.tools.notes.notes_svc.get_note",
AsyncMock(return_value=fake), AsyncMock(return_value=fake),
): ):
out = await fable_get_note(note_id=5) out = await get_note(note_id=5)
assert out["id"] == 5 assert out["id"] == 5
assert out["title"] == "found" assert out["title"] == "found"
@@ -97,7 +97,7 @@ async def test_get_note_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="note 999 not found"): with pytest.raises(ValueError, match="note 999 not found"):
await fable_get_note(note_id=999) await get_note(note_id=999)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -105,7 +105,7 @@ async def test_create_note_passes_through():
fake = _fake_note(id=10, title="new") fake = _fake_note(id=10, title="new")
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.notes.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.create_note", mock):
out = await fable_create_note(title="new", body="x", tags=["a"], project_id=5) out = await create_note(title="new", body="x", tags=["a"], project_id=5)
assert out["id"] == 10 assert out["id"] == 10
assert mock.call_args.kwargs["title"] == "new" assert mock.call_args.kwargs["title"] == "new"
assert mock.call_args.kwargs["project_id"] == 5 assert mock.call_args.kwargs["project_id"] == 5
@@ -117,7 +117,7 @@ async def test_create_note_project_zero_becomes_none():
fake = _fake_note() fake = _fake_note()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.notes.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.create_note", mock):
await fable_create_note(title="t", project_id=0) await create_note(title="t", project_id=0)
assert mock.call_args.kwargs["project_id"] is None assert mock.call_args.kwargs["project_id"] is None
@@ -128,7 +128,7 @@ async def test_update_note_only_sends_non_default_fields():
fake = _fake_note() fake = _fake_note()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock):
await fable_update_note(note_id=1, title="new title") await update_note(note_id=1, title="new title")
# Service got user_id, note_id positional + only the title kwarg # Service got user_id, note_id positional + only the title kwarg
args, kwargs = mock.call_args args, kwargs = mock.call_args
assert args == (7, 1) assert args == (7, 1)
@@ -141,7 +141,7 @@ async def test_update_note_empty_tags_clears_explicitly():
fake = _fake_note() fake = _fake_note()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock):
await fable_update_note(note_id=1, tags=[]) await update_note(note_id=1, tags=[])
assert mock.call_args.kwargs == {"tags": []} assert mock.call_args.kwargs == {"tags": []}
@@ -150,7 +150,7 @@ async def test_update_note_tags_none_means_omit():
fake = _fake_note() fake = _fake_note()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock): with patch("fabledassistant.mcp.tools.notes.notes_svc.update_note", mock):
await fable_update_note(note_id=1, tags=None) await update_note(note_id=1, tags=None)
assert "tags" not in mock.call_args.kwargs assert "tags" not in mock.call_args.kwargs
@@ -161,7 +161,7 @@ async def test_update_note_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="note 999 not found"): with pytest.raises(ValueError, match="note 999 not found"):
await fable_update_note(note_id=999, title="x") await update_note(note_id=999, title="x")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -170,7 +170,7 @@ async def test_delete_note_returns_confirmation_string():
"fabledassistant.mcp.tools.notes.notes_svc.delete_note", "fabledassistant.mcp.tools.notes.notes_svc.delete_note",
AsyncMock(return_value=True), AsyncMock(return_value=True),
): ):
result = await fable_delete_note(note_id=7) result = await delete_note(note_id=7)
assert result == "Note 7 deleted." assert result == "Note 7 deleted."
@@ -181,4 +181,4 @@ async def test_delete_note_raises_when_not_found():
AsyncMock(return_value=False), AsyncMock(return_value=False),
): ):
with pytest.raises(ValueError, match="note 999 not found"): with pytest.raises(ValueError, match="note 999 not found"):
await fable_delete_note(note_id=999) await delete_note(note_id=999)
+8 -8
View File
@@ -5,8 +5,8 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.projects import ( from fabledassistant.mcp.tools.projects import (
fable_list_projects, fable_get_project, fable_create_project, list_projects, get_project, create_project,
fable_update_project, update_project,
) )
@@ -33,7 +33,7 @@ async def test_list_projects_wraps_in_dict():
"fabledassistant.mcp.tools.projects.projects_svc.list_projects", "fabledassistant.mcp.tools.projects.projects_svc.list_projects",
AsyncMock(return_value=rows), AsyncMock(return_value=rows),
): ):
out = await fable_list_projects() out = await list_projects()
assert len(out["projects"]) == 2 assert len(out["projects"]) == 2
@@ -48,7 +48,7 @@ async def test_get_project_enriches_with_milestone_summary():
"fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=milestone_summary), AsyncMock(return_value=milestone_summary),
): ):
out = await fable_get_project(project_id=5) out = await get_project(project_id=5)
assert out["id"] == 5 assert out["id"] == 5
assert out["milestone_summary"] == milestone_summary assert out["milestone_summary"] == milestone_summary
@@ -60,7 +60,7 @@ async def test_get_project_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="project 999 not found"): with pytest.raises(ValueError, match="project 999 not found"):
await fable_get_project(project_id=999) await get_project(project_id=999)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -68,7 +68,7 @@ async def test_create_project_passes_color_empty_as_none():
p = _fake_project() p = _fake_project()
mock = AsyncMock(return_value=p) mock = AsyncMock(return_value=p)
with patch("fabledassistant.mcp.tools.projects.projects_svc.create_project", mock): with patch("fabledassistant.mcp.tools.projects.projects_svc.create_project", mock):
await fable_create_project(title="P", color="") await create_project(title="P", color="")
assert mock.call_args.kwargs["color"] is None assert mock.call_args.kwargs["color"] is None
@@ -77,7 +77,7 @@ async def test_update_project_only_sends_non_default_fields():
p = _fake_project() p = _fake_project()
mock = AsyncMock(return_value=p) mock = AsyncMock(return_value=p)
with patch("fabledassistant.mcp.tools.projects.projects_svc.update_project", mock): with patch("fabledassistant.mcp.tools.projects.projects_svc.update_project", mock):
await fable_update_project(project_id=1, status="archived") await update_project(project_id=1, status="archived")
args, kwargs = mock.call_args args, kwargs = mock.call_args
assert args == (7, 1) assert args == (7, 1)
assert kwargs == {"status": "archived"} assert kwargs == {"status": "archived"}
@@ -90,4 +90,4 @@ async def test_update_project_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="project 999 not found"): with pytest.raises(ValueError, match="project 999 not found"):
await fable_update_project(project_id=999, title="x") await update_project(project_id=999, title="x")
+10 -10
View File
@@ -1,4 +1,4 @@
"""fable_search tool — proves the tool pattern (context + service call + dict shape). """search tool — proves the tool pattern (context + service call + dict shape).
Service call is mocked; no DB needed.""" Service call is mocked; no DB needed."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.search import fable_search from fabledassistant.mcp.tools.search import search
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -31,7 +31,7 @@ def _fake_note(*, id: int, title: str, body: str = "",
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fable_search_raises_without_context(): async def test_fable_search_raises_without_context():
with pytest.raises(RuntimeError, match="no MCP user context"): with pytest.raises(RuntimeError, match="no MCP user context"):
await fable_search(q="anything") await search(q="anything")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -43,7 +43,7 @@ async def test_fable_search_returns_repackaged_results():
"fabledassistant.mcp.tools.search.semantic_search_notes", "fabledassistant.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.93, fake)]), AsyncMock(return_value=[(0.93, fake)]),
): ):
out = await fable_search(q="kafka") out = await search(q="kafka")
assert out["total"] == 1 assert out["total"] == 1
assert len(out["results"]) == 1 assert len(out["results"]) == 1
@@ -65,7 +65,7 @@ async def test_fable_search_body_is_truncated_to_240_chars():
"fabledassistant.mcp.tools.search.semantic_search_notes", "fabledassistant.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]), AsyncMock(return_value=[(0.5, fake)]),
): ):
out = await fable_search(q="x") out = await search(q="x")
assert len(out["results"][0]["body"]) == 240 assert len(out["results"][0]["body"]) == 240
@@ -75,15 +75,15 @@ async def test_fable_search_content_type_filters_at_service_layer():
_user_id_ctx.set(7) _user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[]) mock_search = AsyncMock(return_value=[])
with patch("fabledassistant.mcp.tools.search.semantic_search_notes", mock_search): with patch("fabledassistant.mcp.tools.search.semantic_search_notes", mock_search):
await fable_search(q="x", content_type="task") await search(q="x", content_type="task")
assert mock_search.call_args.kwargs["is_task"] is True assert mock_search.call_args.kwargs["is_task"] is True
mock_search.reset_mock() mock_search.reset_mock()
await fable_search(q="x", content_type="note") await search(q="x", content_type="note")
assert mock_search.call_args.kwargs["is_task"] is False assert mock_search.call_args.kwargs["is_task"] is False
mock_search.reset_mock() mock_search.reset_mock()
await fable_search(q="x", content_type="all") await search(q="x", content_type="all")
assert mock_search.call_args.kwargs["is_task"] is None assert mock_search.call_args.kwargs["is_task"] is None
@@ -92,9 +92,9 @@ async def test_fable_search_limit_is_clamped():
_user_id_ctx.set(7) _user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[]) mock_search = AsyncMock(return_value=[])
with patch("fabledassistant.mcp.tools.search.semantic_search_notes", mock_search): with patch("fabledassistant.mcp.tools.search.semantic_search_notes", mock_search):
await fable_search(q="x", limit=999) await search(q="x", limit=999)
assert mock_search.call_args.kwargs["limit"] == 50 assert mock_search.call_args.kwargs["limit"] == 50
mock_search.reset_mock() mock_search.reset_mock()
await fable_search(q="x", limit=0) await search(q="x", limit=0)
assert mock_search.call_args.kwargs["limit"] == 1 assert mock_search.call_args.kwargs["limit"] == 1
+4 -4
View File
@@ -1,4 +1,4 @@
"""Tests for fable_list_tags. """Tests for list_tags.
The aggregation helper is unit-tested directly; the full tool is exercised The aggregation helper is unit-tested directly; the full tool is exercised
with a mocked async_session to keep tests DB-free. with a mocked async_session to keep tests DB-free.
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.tags import fable_list_tags, _aggregate_tag_counts from fabledassistant.mcp.tools.tags import list_tags, _aggregate_tag_counts
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -42,7 +42,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc():
mock_session.execute = AsyncMock(return_value=mock_result) mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session) mock_ctx = MagicMock(return_value=mock_session)
with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx): with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx):
out = await fable_list_tags() out = await list_tags()
assert out["tags"][0] == {"tag": "a", "count": 3} assert out["tags"][0] == {"tag": "a", "count": 3}
assert out["tags"][1] == {"tag": "b", "count": 1} assert out["tags"][1] == {"tag": "b", "count": 1}
assert out["total"] == 2 assert out["total"] == 2
@@ -59,5 +59,5 @@ async def test_fable_list_tags_clamps_limit():
mock_ctx = MagicMock(return_value=mock_session) mock_ctx = MagicMock(return_value=mock_session)
with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx): with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx):
# No exception — just exercises the clamping branch # No exception — just exercises the clamping branch
out = await fable_list_tags(limit=99999) out = await list_tags(limit=99999)
assert out["total"] == 0 assert out["total"] == 0
+18 -18
View File
@@ -6,8 +6,8 @@ import pytest
from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.tasks import ( from fabledassistant.mcp.tools.tasks import (
fable_list_tasks, fable_get_task, fable_create_task, list_tasks, get_task, create_task,
fable_update_task, fable_add_task_log, update_task, add_task_log,
) )
@@ -37,7 +37,7 @@ async def test_list_tasks_passes_is_task_true_and_repackages():
rows = [_fake_task(id=1), _fake_task(id=2)] rows = [_fake_task(id=1), _fake_task(id=2)]
mock = AsyncMock(return_value=(rows, 2)) mock = AsyncMock(return_value=(rows, 2))
with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
out = await fable_list_tasks() out = await list_tasks()
assert out["total"] == 2 assert out["total"] == 2
assert len(out["tasks"]) == 2 assert len(out["tasks"]) == 2
assert mock.call_args.kwargs["is_task"] is True assert mock.call_args.kwargs["is_task"] is True
@@ -47,7 +47,7 @@ async def test_list_tasks_passes_is_task_true_and_repackages():
async def test_list_tasks_status_filter(): async def test_list_tasks_status_filter():
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
await fable_list_tasks(status="in_progress") await list_tasks(status="in_progress")
assert mock.call_args.kwargs["status"] == "in_progress" assert mock.call_args.kwargs["status"] == "in_progress"
@@ -55,7 +55,7 @@ async def test_list_tasks_status_filter():
async def test_list_tasks_empty_status_means_no_filter(): async def test_list_tasks_empty_status_means_no_filter():
mock = AsyncMock(return_value=([], 0)) mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
await fable_list_tasks(status="") await list_tasks(status="")
assert mock.call_args.kwargs["status"] is None assert mock.call_args.kwargs["status"] is None
@@ -66,20 +66,20 @@ async def test_get_task_with_no_parent_returns_null_parent_title():
"fabledassistant.mcp.tools.tasks.notes_svc.get_note", "fabledassistant.mcp.tools.tasks.notes_svc.get_note",
AsyncMock(return_value=fake), AsyncMock(return_value=fake),
): ):
out = await fable_get_task(task_id=5) out = await get_task(task_id=5)
assert out["parent_title"] is None assert out["parent_title"] is None
assert out["title"] == "solo" assert out["title"] == "solo"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_enriches_with_parent_title(): async def test_get_task_enriches_with_parent_title():
"""When parent_id is set, fable_get_task fetches the parent and adds parent_title.""" """When parent_id is set, get_task fetches the parent and adds parent_title."""
child = _fake_task(id=10, title="child", parent_id=5) child = _fake_task(id=10, title="child", parent_id=5)
parent = _fake_task(id=5, title="parent of 10", parent_id=None) parent = _fake_task(id=5, title="parent of 10", parent_id=None)
# get_note is called twice: once for child, once for parent # get_note is called twice: once for child, once for parent
mock_get = AsyncMock(side_effect=[child, parent]) mock_get = AsyncMock(side_effect=[child, parent])
with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get): with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get):
out = await fable_get_task(task_id=10) out = await get_task(task_id=10)
assert out["parent_title"] == "parent of 10" assert out["parent_title"] == "parent of 10"
assert mock_get.await_count == 2 assert mock_get.await_count == 2
@@ -90,7 +90,7 @@ async def test_get_task_parent_missing_returns_null():
child = _fake_task(id=10, parent_id=5) child = _fake_task(id=10, parent_id=5)
mock_get = AsyncMock(side_effect=[child, None]) mock_get = AsyncMock(side_effect=[child, None])
with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get): with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get):
out = await fable_get_task(task_id=10) out = await get_task(task_id=10)
assert out["parent_title"] is None assert out["parent_title"] is None
@@ -101,7 +101,7 @@ async def test_get_task_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="task 999 not found"): with pytest.raises(ValueError, match="task 999 not found"):
await fable_get_task(task_id=999) await get_task(task_id=999)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -109,7 +109,7 @@ async def test_create_task_passes_status():
fake = _fake_task() fake = _fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
await fable_create_task(title="do x", status="todo") await create_task(title="do x", status="todo")
assert mock.call_args.kwargs["status"] == "todo" assert mock.call_args.kwargs["status"] == "todo"
@@ -118,7 +118,7 @@ async def test_create_task_priority_empty_becomes_none():
fake = _fake_task() fake = _fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
await fable_create_task(title="x", priority="") await create_task(title="x", priority="")
assert mock.call_args.kwargs["priority"] is None assert mock.call_args.kwargs["priority"] is None
@@ -127,7 +127,7 @@ async def test_create_task_zero_id_sentinels_become_none():
fake = _fake_task() fake = _fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
await fable_create_task(title="x", project_id=0, milestone_id=0, parent_id=0) await create_task(title="x", project_id=0, milestone_id=0, parent_id=0)
assert mock.call_args.kwargs["project_id"] is None assert mock.call_args.kwargs["project_id"] is None
assert mock.call_args.kwargs["milestone_id"] is None assert mock.call_args.kwargs["milestone_id"] is None
assert mock.call_args.kwargs["parent_id"] is None assert mock.call_args.kwargs["parent_id"] is None
@@ -138,7 +138,7 @@ async def test_update_task_only_sends_non_default_fields():
fake = _fake_task() fake = _fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await fable_update_task(task_id=1, status="done") await update_task(task_id=1, status="done")
args, kwargs = mock.call_args args, kwargs = mock.call_args
assert args == (7, 1) assert args == (7, 1)
assert kwargs == {"status": "done"} assert kwargs == {"status": "done"}
@@ -150,7 +150,7 @@ async def test_update_task_empty_priority_is_omitted():
fake = _fake_task() fake = _fake_task()
mock = AsyncMock(return_value=fake) mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock): with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await fable_update_task(task_id=1, status="done", priority="") await update_task(task_id=1, status="done", priority="")
assert "priority" not in mock.call_args.kwargs assert "priority" not in mock.call_args.kwargs
@@ -161,7 +161,7 @@ async def test_update_task_raises_when_not_found():
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
with pytest.raises(ValueError, match="task 999 not found"): with pytest.raises(ValueError, match="task 999 not found"):
await fable_update_task(task_id=999, status="done") await update_task(task_id=999, status="done")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -177,7 +177,7 @@ async def test_add_task_log_returns_log_dict():
"fabledassistant.mcp.tools.tasks.task_logs_svc.create_log", "fabledassistant.mcp.tools.tasks.task_logs_svc.create_log",
AsyncMock(return_value=log), AsyncMock(return_value=log),
): ):
out = await fable_add_task_log(task_id=1, content="checkpoint") out = await add_task_log(task_id=1, content="checkpoint")
assert out["id"] == 5 assert out["id"] == 5
assert out["task_id"] == 1 assert out["task_id"] == 1
assert out["content"] == "checkpoint" assert out["content"] == "checkpoint"
@@ -192,4 +192,4 @@ async def test_add_task_log_propagates_value_error_when_task_missing():
AsyncMock(side_effect=ValueError("Task 999 not found")), AsyncMock(side_effect=ValueError("Task 999 not found")),
): ):
with pytest.raises(ValueError): with pytest.raises(ValueError):
await fable_add_task_log(task_id=999, content="x") await add_task_log(task_id=999, content="x")