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>
88 lines
3.3 KiB
Python
88 lines
3.3 KiB
Python
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
|
from __future__ import annotations
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from quart import Quart
|
|
|
|
_INSTRUCTIONS = """
|
|
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 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
|
|
"not set".
|
|
"""
|
|
|
|
|
|
def build_mcp_server() -> FastMCP:
|
|
"""Build the FastMCP instance with all tools registered."""
|
|
mcp = FastMCP("scribe", instructions=_INSTRUCTIONS.strip())
|
|
from fabledassistant.mcp.tools import register_all
|
|
register_all(mcp)
|
|
return mcp
|
|
|
|
|
|
def mount_mcp(app: Quart) -> None:
|
|
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
|
|
|
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 "scribe_user_id" for tool handlers
|
|
to read in later phases.
|
|
"""
|
|
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
|
|
|
|
mcp = build_mcp_server()
|
|
mcp_asgi = mcp.streamable_http_app()
|
|
app.mcp_instance = mcp
|
|
|
|
async def auth_wrapped(scope, receive, send):
|
|
if scope["type"] != "http":
|
|
return await mcp_asgi(scope, receive, send)
|
|
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
|
|
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
|
|
if user_id is None:
|
|
await send({
|
|
"type": "http.response.start",
|
|
"status": 401,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"www-authenticate", b'Bearer realm="scribe-mcp"'),
|
|
],
|
|
})
|
|
await send({
|
|
"type": "http.response.body",
|
|
"body": b'{"error":"unauthorized"}',
|
|
})
|
|
return
|
|
scope["scribe_user_id"] = user_id
|
|
from fabledassistant.mcp._context import _user_id_ctx
|
|
token = _user_id_ctx.set(user_id)
|
|
try:
|
|
await mcp_asgi(scope, receive, send)
|
|
finally:
|
|
_user_id_ctx.reset(token)
|
|
|
|
original_asgi = app.asgi_app
|
|
|
|
async def dispatch(scope, receive, send):
|
|
if scope["type"] == "http":
|
|
path = scope.get("path", "")
|
|
if path == "/mcp" or path.startswith("/mcp/"):
|
|
sub_scope = dict(scope)
|
|
sub_scope["path"] = path[len("/mcp"):] or "/"
|
|
sub_scope["root_path"] = scope.get("root_path", "") + "/mcp"
|
|
return await auth_wrapped(sub_scope, receive, send)
|
|
return await original_asgi(scope, receive, send)
|
|
|
|
app.asgi_app = dispatch
|