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.
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:
+10 -9
View File
@@ -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:
+13 -13
View File
@@ -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)
+10 -10
View File
@@ -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)
+9 -9
View File
@@ -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)
+16 -16
View File
@@ -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)
+12 -12
View File
@@ -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)
+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
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)
+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
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)
+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
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)
+17 -17
View File
@@ -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)