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:
@@ -1,7 +1,7 @@
|
||||
"""Per-request MCP context.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
_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:
|
||||
|
||||
@@ -5,15 +5,16 @@ from mcp.server.fastmcp import FastMCP
|
||||
from quart import Quart
|
||||
|
||||
_INSTRUCTIONS = """
|
||||
Fabled Scribe is the user's self-hosted second-brain and project-management
|
||||
data store. You (Claude) are the assistant.
|
||||
Scribe is the user's self-hosted second-brain and project-management data
|
||||
store. You (Claude) are the assistant.
|
||||
|
||||
Hierarchy: Project -> Milestone -> Task/Note.
|
||||
|
||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||
- Use fable_*_note tools for notes, fable_*_task tools for tasks.
|
||||
- Typed entities (person, place, list) are notes with a type field plus
|
||||
type-specific columns; use the dedicated tools for those.
|
||||
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
|
||||
- Typed entities (person, place, list) are notes with a non-default note_type
|
||||
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
|
||||
unchanged on updates.
|
||||
- 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:
|
||||
"""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
|
||||
register_all(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
|
||||
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.
|
||||
"""
|
||||
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
|
||||
@@ -55,7 +56,7 @@ def mount_mcp(app: Quart) -> None:
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"www-authenticate", b'Bearer realm="fable-mcp"'),
|
||||
(b"www-authenticate", b'Bearer realm="scribe-mcp"'),
|
||||
],
|
||||
})
|
||||
await send({
|
||||
@@ -63,7 +64,7 @@ def mount_mcp(app: Quart) -> None:
|
||||
"body": b'{"error":"unauthorized"}',
|
||||
})
|
||||
return
|
||||
scope["fable_user_id"] = user_id
|
||||
scope["scribe_user_id"] = user_id
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
token = _user_id_ctx.set(user_id)
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def fable_create_person(
|
||||
async def create_person(
|
||||
name: str,
|
||||
relationship: str = "",
|
||||
email: str = "",
|
||||
@@ -109,7 +109,7 @@ async def fable_create_person(
|
||||
return await _create_entity("person", name, meta, tags)
|
||||
|
||||
|
||||
async def fable_update_person(
|
||||
async def update_person(
|
||||
person_id: int,
|
||||
name: str = "",
|
||||
relationship: str = "",
|
||||
@@ -135,12 +135,12 @@ async def fable_update_person(
|
||||
# ─── 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."""
|
||||
return await _list_by_type("place", q, tag, limit)
|
||||
|
||||
|
||||
async def fable_create_place(
|
||||
async def create_place(
|
||||
name: str,
|
||||
address: str = "",
|
||||
phone: str = "",
|
||||
@@ -163,7 +163,7 @@ async def fable_create_place(
|
||||
return await _create_entity("place", name, meta, tags)
|
||||
|
||||
|
||||
async def fable_update_place(
|
||||
async def update_place(
|
||||
place_id: int,
|
||||
name: str = "",
|
||||
address: str = "",
|
||||
@@ -183,12 +183,12 @@ async def fable_update_place(
|
||||
# ─── 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."""
|
||||
return await _list_by_type("list", q, tag, limit)
|
||||
|
||||
|
||||
async def fable_create_list(
|
||||
async def create_list(
|
||||
name: str,
|
||||
category: str = "",
|
||||
items: list[str] | None = None,
|
||||
@@ -210,7 +210,7 @@ async def fable_create_list(
|
||||
return await _create_entity("list", name, meta, tags)
|
||||
|
||||
|
||||
async def fable_update_list(
|
||||
async def update_list(
|
||||
list_id: int,
|
||||
name: str = "",
|
||||
category: str = "",
|
||||
@@ -238,8 +238,8 @@ async def fable_update_list(
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
fable_list_persons, fable_create_person, fable_update_person,
|
||||
fable_list_places, fable_create_place, fable_update_place,
|
||||
fable_list_lists, fable_create_list, fable_update_list,
|
||||
list_persons, create_person, update_person,
|
||||
list_places, create_place, update_place,
|
||||
list_lists, create_list, update_list,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -34,7 +34,7 @@ def _event_dict(event) -> 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
|
||||
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)}
|
||||
|
||||
|
||||
async def fable_create_event(
|
||||
async def create_event(
|
||||
title: str,
|
||||
start_date: str,
|
||||
start_time: str = "00:00",
|
||||
@@ -80,7 +80,7 @@ async def fable_create_event(
|
||||
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."""
|
||||
uid = current_user_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()
|
||||
|
||||
|
||||
async def fable_update_event(
|
||||
async def update_event(
|
||||
event_id: int,
|
||||
title: str = "",
|
||||
start_date: str = "",
|
||||
@@ -128,7 +128,7 @@ async def fable_update_event(
|
||||
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."""
|
||||
uid = current_user_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:
|
||||
for fn in (
|
||||
fable_list_events,
|
||||
fable_create_event,
|
||||
fable_get_event,
|
||||
fable_update_event,
|
||||
fable_delete_event,
|
||||
list_events,
|
||||
create_event,
|
||||
get_event,
|
||||
update_event,
|
||||
delete_event,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -15,8 +15,8 @@ from fabledassistant.mcp._context import current_user_id
|
||||
from fabledassistant.services import milestones as milestones_svc
|
||||
|
||||
|
||||
async def fable_list_milestones(project_id: int) -> dict:
|
||||
"""List milestones for a Fable project, ordered by order_index.
|
||||
async def list_milestones(project_id: int) -> dict:
|
||||
"""List milestones for a Scribe project, ordered by order_index.
|
||||
|
||||
Returns id, title, description, status (active/done), order_index,
|
||||
and task counts.
|
||||
@@ -26,13 +26,13 @@ async def fable_list_milestones(project_id: int) -> dict:
|
||||
return {"milestones": rows}
|
||||
|
||||
|
||||
async def fable_create_milestone(
|
||||
async def create_milestone(
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "active",
|
||||
) -> dict:
|
||||
"""Create a milestone within a Fable project.
|
||||
"""Create a milestone within a Scribe project.
|
||||
|
||||
Args:
|
||||
project_id: The project this milestone belongs to (required).
|
||||
@@ -51,7 +51,7 @@ async def fable_create_milestone(
|
||||
return milestone.to_dict()
|
||||
|
||||
|
||||
async def fable_update_milestone(
|
||||
async def update_milestone(
|
||||
project_id: int,
|
||||
milestone_id: int,
|
||||
title: str = "",
|
||||
@@ -59,7 +59,7 @@ async def fable_update_milestone(
|
||||
status: str = "",
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
"""Update a Fable milestone. Only explicitly provided fields are changed.
|
||||
"""Update a Scribe milestone. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
project_id: Project the milestone belongs to (preserved for API parity;
|
||||
@@ -88,8 +88,8 @@ async def fable_update_milestone(
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
fable_list_milestones,
|
||||
fable_create_milestone,
|
||||
fable_update_milestone,
|
||||
list_milestones,
|
||||
create_milestone,
|
||||
update_milestone,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -17,18 +17,18 @@ from fabledassistant.mcp._context import current_user_id
|
||||
from fabledassistant.services import notes as notes_svc
|
||||
|
||||
|
||||
async def fable_list_notes(
|
||||
async def list_notes(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
tag: str = "",
|
||||
search_text: str = "",
|
||||
) -> 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
|
||||
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()
|
||||
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}
|
||||
|
||||
|
||||
async def fable_get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Fable note by its ID.
|
||||
async def get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Scribe note by its ID.
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def fable_create_note(
|
||||
async def create_note(
|
||||
title: str,
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new note in Fable.
|
||||
"""Create a new note in Scribe.
|
||||
|
||||
Args:
|
||||
title: Note title (required).
|
||||
@@ -81,14 +81,14 @@ async def fable_create_note(
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def fable_update_note(
|
||||
async def update_note(
|
||||
note_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update an existing Fable note. Only explicitly provided fields are changed.
|
||||
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
note_id: ID of the note to update.
|
||||
@@ -113,8 +113,8 @@ async def fable_update_note(
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def fable_delete_note(note_id: int) -> str:
|
||||
"""Permanently delete a Fable note by ID. This cannot be undone."""
|
||||
async def delete_note(note_id: int) -> str:
|
||||
"""Permanently delete a Scribe note by ID. This cannot be undone."""
|
||||
uid = current_user_id()
|
||||
deleted = await notes_svc.delete_note(uid, note_id)
|
||||
if not deleted:
|
||||
@@ -124,10 +124,10 @@ async def fable_delete_note(note_id: int) -> str:
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
fable_list_notes,
|
||||
fable_get_note,
|
||||
fable_create_note,
|
||||
fable_update_note,
|
||||
fable_delete_note,
|
||||
list_notes,
|
||||
get_note,
|
||||
create_note,
|
||||
update_note,
|
||||
delete_note,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -21,8 +21,8 @@ from fabledassistant.services import milestones as milestones_svc
|
||||
from fabledassistant.services import projects as projects_svc
|
||||
|
||||
|
||||
async def fable_list_projects() -> dict:
|
||||
"""List all Fable projects for the current user.
|
||||
async def list_projects() -> dict:
|
||||
"""List all Scribe projects for the current user.
|
||||
|
||||
Returns id, title, description, goal, status (active/archived), color,
|
||||
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]}
|
||||
|
||||
|
||||
async def fable_get_project(project_id: int) -> dict:
|
||||
"""Fetch a Fable project by ID, including its milestone summary.
|
||||
async def get_project(project_id: int) -> dict:
|
||||
"""Fetch a Scribe project by ID, including its milestone summary.
|
||||
|
||||
Returns full project fields plus a milestone_summary list with each
|
||||
milestone's id, title, status, and task counts.
|
||||
@@ -49,14 +49,14 @@ async def fable_get_project(project_id: int) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
async def fable_create_project(
|
||||
async def create_project(
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
status: str = "active",
|
||||
color: str = "",
|
||||
) -> dict:
|
||||
"""Create a new project in Fable.
|
||||
"""Create a new project in Scribe.
|
||||
|
||||
Args:
|
||||
title: Project name (required).
|
||||
@@ -77,7 +77,7 @@ async def fable_create_project(
|
||||
return project.to_dict()
|
||||
|
||||
|
||||
async def fable_update_project(
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
@@ -85,7 +85,7 @@ async def fable_update_project(
|
||||
status: str = "",
|
||||
color: str = "",
|
||||
) -> dict:
|
||||
"""Update an existing Fable project. Only explicitly provided fields are changed.
|
||||
"""Update an existing Scribe project. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to update.
|
||||
@@ -115,9 +115,9 @@ async def fable_update_project(
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
fable_list_projects,
|
||||
fable_get_project,
|
||||
fable_create_project,
|
||||
fable_update_project,
|
||||
list_projects,
|
||||
get_project,
|
||||
create_project,
|
||||
update_project,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -78,4 +78,4 @@ async def fable_get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
mcp.tool(name="fable_get_recent")(fable_get_recent)
|
||||
mcp.tool(name="get_recent")(get_recent)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -46,4 +46,4 @@ async def fable_search(q: str, content_type: str = "all", limit: int = 10) -> di
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
mcp.tool(name="fable_search")(fable_search)
|
||||
mcp.tool(name="search")(search)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -51,4 +51,4 @@ async def fable_list_tags(limit: int = 50) -> dict:
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
mcp.tool(name="fable_list_tags")(fable_list_tags)
|
||||
mcp.tool(name="list_tags")(list_tags)
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
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
|
||||
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".
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def fable_list_tasks(
|
||||
async def list_tasks(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""List tasks in Fable.
|
||||
"""List tasks in Scribe.
|
||||
|
||||
Args:
|
||||
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}
|
||||
|
||||
|
||||
async def fable_get_task(task_id: int) -> dict:
|
||||
"""Fetch a single Fable task by ID.
|
||||
async def get_task(task_id: int) -> dict:
|
||||
"""Fetch a single Scribe task by ID.
|
||||
|
||||
Returns id, title, body, status, priority, tags, project_id, milestone_id,
|
||||
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
|
||||
|
||||
|
||||
async def fable_create_task(
|
||||
async def create_task(
|
||||
title: str,
|
||||
body: str = "",
|
||||
status: str = "todo",
|
||||
@@ -79,7 +79,7 @@ async def fable_create_task(
|
||||
parent_id: int = 0,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a new task in Fable.
|
||||
"""Create a new task in Scribe.
|
||||
|
||||
Args:
|
||||
title: Task title (required).
|
||||
@@ -106,7 +106,7 @@ async def fable_create_task(
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def fable_update_task(
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
@@ -115,7 +115,7 @@ async def fable_update_task(
|
||||
project_id: int = 0,
|
||||
milestone_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update an existing Fable task. Only explicitly provided fields are changed.
|
||||
"""Update an existing Scribe task. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
task_id: ID of the task to update.
|
||||
@@ -146,8 +146,8 @@ async def fable_update_task(
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def fable_add_task_log(task_id: int, content: str) -> dict:
|
||||
"""Append a timestamped progress log entry to a Fable task.
|
||||
async def add_task_log(task_id: int, content: str) -> dict:
|
||||
"""Append a timestamped progress log entry to a Scribe task.
|
||||
|
||||
Use this to record work sessions, decisions, or status updates over time
|
||||
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:
|
||||
for fn in (
|
||||
fable_list_tasks,
|
||||
fable_get_task,
|
||||
fable_create_task,
|
||||
fable_update_task,
|
||||
fable_add_task_log,
|
||||
list_tasks,
|
||||
get_task,
|
||||
create_task,
|
||||
update_task,
|
||||
add_task_log,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -8,9 +8,9 @@ import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.entities import (
|
||||
fable_list_persons, fable_create_person, fable_update_person,
|
||||
fable_create_place, fable_update_place,
|
||||
fable_create_list, fable_update_list,
|
||||
list_persons, create_person, update_person,
|
||||
create_place, update_place,
|
||||
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():
|
||||
mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1))
|
||||
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
|
||||
assert kwargs["note_type"] == "person"
|
||||
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")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
assert kwargs["title"] == "Alice"
|
||||
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")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ 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 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"]
|
||||
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")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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"]
|
||||
assert meta["list_items"] == [
|
||||
{"text": "milk", "checked": False},
|
||||
@@ -114,7 +114,7 @@ async def test_update_person_merges_meta_preserving_other_fields():
|
||||
), patch(
|
||||
"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"]
|
||||
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",
|
||||
AsyncMock(return_value=updated),
|
||||
) 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)
|
||||
assert update_mock.call_args.kwargs["entity_meta"] == {"email": "a@x.com"}
|
||||
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),
|
||||
):
|
||||
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
|
||||
@@ -166,7 +166,7 @@ async def test_update_list_items_empty_list_clears_items():
|
||||
"fabledassistant.mcp.tools.entities.notes_svc.update_note",
|
||||
AsyncMock(return_value=updated),
|
||||
) 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"] == []
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ async def test_update_list_items_none_leaves_items_unchanged():
|
||||
"fabledassistant.mcp.tools.entities.notes_svc.update_note",
|
||||
AsyncMock(return_value=updated),
|
||||
) 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
|
||||
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [
|
||||
{"text": "keep", "checked": False},
|
||||
|
||||
@@ -6,8 +6,8 @@ import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.events import (
|
||||
fable_list_events, fable_create_event, fable_get_event,
|
||||
fable_update_event, fable_delete_event,
|
||||
list_events, create_event, get_event,
|
||||
update_event, delete_event,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ async def test_list_events_passes_datetime_range():
|
||||
{"id": 1, "title": "morning standup"},
|
||||
])
|
||||
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
|
||||
assert args[0] == 7 # user_id
|
||||
assert args[1] == datetime(2026, 6, 1)
|
||||
@@ -49,7 +49,7 @@ async def test_create_event_combines_date_and_time():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
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",
|
||||
duration_minutes=15,
|
||||
)
|
||||
@@ -64,7 +64,7 @@ async def test_create_event_zero_duration_means_point_event():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
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
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ async def test_get_event_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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
|
||||
@@ -83,7 +83,7 @@ async def test_update_event_only_sends_non_default_fields():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
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
|
||||
assert args == (7, 1)
|
||||
assert kwargs == {"title": "new title"}
|
||||
@@ -94,7 +94,7 @@ async def test_update_event_duration_minus_one_means_unchanged():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
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
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ async def test_update_event_duration_zero_clears_to_point():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
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
|
||||
|
||||
|
||||
@@ -113,12 +113,12 @@ async def test_update_event_requires_both_date_and_time_to_move():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
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
|
||||
assert "start_dt" not in mock.call_args.kwargs
|
||||
|
||||
mock.reset_mock()
|
||||
await fable_update_event(
|
||||
await update_event(
|
||||
event_id=1, start_date="2026-06-02", start_time="11:00",
|
||||
)
|
||||
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),
|
||||
):
|
||||
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
|
||||
@@ -144,7 +144,7 @@ async def test_delete_event_checks_existence_then_returns_confirmation():
|
||||
"fabledassistant.mcp.tools.events.events_svc.delete_event",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
result = await fable_delete_event(event_id=7)
|
||||
result = await delete_event(event_id=7)
|
||||
assert result == "Event 7 deleted."
|
||||
|
||||
|
||||
@@ -155,4 +155,4 @@ async def test_delete_event_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="event 999 not found"):
|
||||
await fable_delete_event(event_id=999)
|
||||
await delete_event(event_id=999)
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
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",
|
||||
AsyncMock(return_value=rows),
|
||||
):
|
||||
out = await fable_list_milestones(project_id=1)
|
||||
out = await list_milestones(project_id=1)
|
||||
assert out["milestones"] == rows
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ async def test_create_milestone_passes_through():
|
||||
m = _fake_ms(id=5)
|
||||
mock = AsyncMock(return_value=m)
|
||||
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 mock.call_args.kwargs["project_id"] == 1
|
||||
assert mock.call_args.kwargs["title"] == "new"
|
||||
@@ -53,7 +53,7 @@ async def test_create_milestone_empty_description_becomes_none():
|
||||
m = _fake_ms()
|
||||
mock = AsyncMock(return_value=m)
|
||||
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
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_update_milestone_only_sends_non_default_fields():
|
||||
m = _fake_ms()
|
||||
mock = AsyncMock(return_value=m)
|
||||
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
|
||||
assert args == (7, 5)
|
||||
assert kwargs == {"status": "done"}
|
||||
@@ -74,7 +74,7 @@ async def test_update_milestone_order_index_negative_is_omitted():
|
||||
m = _fake_ms()
|
||||
mock = AsyncMock(return_value=m)
|
||||
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
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ async def test_update_milestone_order_index_zero_is_explicit():
|
||||
m = _fake_ms()
|
||||
mock = AsyncMock(return_value=m)
|
||||
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
|
||||
|
||||
|
||||
@@ -95,4 +95,4 @@ async def test_update_milestone_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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")
|
||||
|
||||
@@ -5,8 +5,8 @@ import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.notes import (
|
||||
fable_list_notes, fable_get_note, fable_create_note,
|
||||
fable_update_note, fable_delete_note,
|
||||
list_notes, get_note, create_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",
|
||||
AsyncMock(return_value=(rows, 2)),
|
||||
):
|
||||
out = await fable_list_notes()
|
||||
out = await list_notes()
|
||||
assert out["total"] == 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():
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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"]
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ async def test_list_notes_tag_filter_maps_to_list():
|
||||
async def test_list_notes_empty_tag_means_no_filter():
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ async def test_list_notes_empty_tag_means_no_filter():
|
||||
async def test_list_notes_search_text_maps_to_q():
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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"
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ async def test_list_notes_search_text_maps_to_q():
|
||||
async def test_list_notes_limit_clamped():
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ async def test_get_note_returns_dict():
|
||||
"fabledassistant.mcp.tools.notes.notes_svc.get_note",
|
||||
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["title"] == "found"
|
||||
|
||||
@@ -97,7 +97,7 @@ async def test_get_note_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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
|
||||
@@ -105,7 +105,7 @@ async def test_create_note_passes_through():
|
||||
fake = _fake_note(id=10, title="new")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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 mock.call_args.kwargs["title"] == "new"
|
||||
assert mock.call_args.kwargs["project_id"] == 5
|
||||
@@ -117,7 +117,7 @@ async def test_create_note_project_zero_becomes_none():
|
||||
fake = _fake_note()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ async def test_update_note_only_sends_non_default_fields():
|
||||
fake = _fake_note()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
args, kwargs = mock.call_args
|
||||
assert args == (7, 1)
|
||||
@@ -141,7 +141,7 @@ async def test_update_note_empty_tags_clears_explicitly():
|
||||
fake = _fake_note()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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": []}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ async def test_update_note_tags_none_means_omit():
|
||||
fake = _fake_note()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ async def test_update_note_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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
|
||||
@@ -170,7 +170,7 @@ async def test_delete_note_returns_confirmation_string():
|
||||
"fabledassistant.mcp.tools.notes.notes_svc.delete_note",
|
||||
AsyncMock(return_value=True),
|
||||
):
|
||||
result = await fable_delete_note(note_id=7)
|
||||
result = await delete_note(note_id=7)
|
||||
assert result == "Note 7 deleted."
|
||||
|
||||
|
||||
@@ -181,4 +181,4 @@ async def test_delete_note_raises_when_not_found():
|
||||
AsyncMock(return_value=False),
|
||||
):
|
||||
with pytest.raises(ValueError, match="note 999 not found"):
|
||||
await fable_delete_note(note_id=999)
|
||||
await delete_note(note_id=999)
|
||||
|
||||
@@ -5,8 +5,8 @@ import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.projects import (
|
||||
fable_list_projects, fable_get_project, fable_create_project,
|
||||
fable_update_project,
|
||||
list_projects, get_project, create_project,
|
||||
update_project,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async def test_list_projects_wraps_in_dict():
|
||||
"fabledassistant.mcp.tools.projects.projects_svc.list_projects",
|
||||
AsyncMock(return_value=rows),
|
||||
):
|
||||
out = await fable_list_projects()
|
||||
out = await list_projects()
|
||||
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",
|
||||
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["milestone_summary"] == milestone_summary
|
||||
|
||||
@@ -60,7 +60,7 @@ async def test_get_project_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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
|
||||
@@ -68,7 +68,7 @@ async def test_create_project_passes_color_empty_as_none():
|
||||
p = _fake_project()
|
||||
mock = AsyncMock(return_value=p)
|
||||
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
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ async def test_update_project_only_sends_non_default_fields():
|
||||
p = _fake_project()
|
||||
mock = AsyncMock(return_value=p)
|
||||
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
|
||||
assert args == (7, 1)
|
||||
assert kwargs == {"status": "archived"}
|
||||
@@ -90,4 +90,4 @@ async def test_update_project_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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")
|
||||
|
||||
@@ -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."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
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)
|
||||
@@ -31,7 +31,7 @@ def _fake_note(*, id: int, title: str, body: str = "",
|
||||
@pytest.mark.asyncio
|
||||
async def test_fable_search_raises_without_context():
|
||||
with pytest.raises(RuntimeError, match="no MCP user context"):
|
||||
await fable_search(q="anything")
|
||||
await search(q="anything")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -43,7 +43,7 @@ async def test_fable_search_returns_repackaged_results():
|
||||
"fabledassistant.mcp.tools.search.semantic_search_notes",
|
||||
AsyncMock(return_value=[(0.93, fake)]),
|
||||
):
|
||||
out = await fable_search(q="kafka")
|
||||
out = await search(q="kafka")
|
||||
|
||||
assert out["total"] == 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",
|
||||
AsyncMock(return_value=[(0.5, fake)]),
|
||||
):
|
||||
out = await fable_search(q="x")
|
||||
out = await search(q="x")
|
||||
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)
|
||||
mock_search = AsyncMock(return_value=[])
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -92,9 +92,9 @@ async def test_fable_search_limit_is_clamped():
|
||||
_user_id_ctx.set(7)
|
||||
mock_search = AsyncMock(return_value=[])
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
with a mocked async_session to keep tests DB-free.
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.tags import fable_list_tags, _aggregate_tag_counts
|
||||
from fabledassistant.mcp.tools.tags import list_tags, _aggregate_tag_counts
|
||||
|
||||
|
||||
@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_ctx = MagicMock(return_value=mock_session)
|
||||
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"][1] == {"tag": "b", "count": 1}
|
||||
assert out["total"] == 2
|
||||
@@ -59,5 +59,5 @@ async def test_fable_list_tags_clamps_limit():
|
||||
mock_ctx = MagicMock(return_value=mock_session)
|
||||
with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx):
|
||||
# No exception — just exercises the clamping branch
|
||||
out = await fable_list_tags(limit=99999)
|
||||
out = await list_tags(limit=99999)
|
||||
assert out["total"] == 0
|
||||
|
||||
@@ -6,8 +6,8 @@ import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.tasks import (
|
||||
fable_list_tasks, fable_get_task, fable_create_task,
|
||||
fable_update_task, fable_add_task_log,
|
||||
list_tasks, get_task, create_task,
|
||||
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)]
|
||||
mock = AsyncMock(return_value=(rows, 2))
|
||||
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 len(out["tasks"]) == 2
|
||||
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():
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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"
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_list_tasks_status_filter():
|
||||
async def test_list_tasks_empty_status_means_no_filter():
|
||||
mock = AsyncMock(return_value=([], 0))
|
||||
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
|
||||
|
||||
|
||||
@@ -66,20 +66,20 @@ async def test_get_task_with_no_parent_returns_null_parent_title():
|
||||
"fabledassistant.mcp.tools.tasks.notes_svc.get_note",
|
||||
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["title"] == "solo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
parent = _fake_task(id=5, title="parent of 10", parent_id=None)
|
||||
# get_note is called twice: once for child, once for parent
|
||||
mock_get = AsyncMock(side_effect=[child, parent])
|
||||
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 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)
|
||||
mock_get = AsyncMock(side_effect=[child, None])
|
||||
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
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ async def test_get_task_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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
|
||||
@@ -109,7 +109,7 @@ async def test_create_task_passes_status():
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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"
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ async def test_create_task_priority_empty_becomes_none():
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ async def test_create_task_zero_id_sentinels_become_none():
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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["milestone_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()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
assert args == (7, 1)
|
||||
assert kwargs == {"status": "done"}
|
||||
@@ -150,7 +150,7 @@ async def test_update_task_empty_priority_is_omitted():
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
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
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ async def test_update_task_raises_when_not_found():
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
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
|
||||
@@ -177,7 +177,7 @@ async def test_add_task_log_returns_log_dict():
|
||||
"fabledassistant.mcp.tools.tasks.task_logs_svc.create_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["task_id"] == 1
|
||||
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")),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
await fable_add_task_log(task_id=999, content="x")
|
||||
await add_task_log(task_id=999, content="x")
|
||||
|
||||
Reference in New Issue
Block a user