Files
FabledGrimoire/src/fabledassistant/mcp/server.py
T
bvandeusenandClaude Opus 4.7 fd0431dfb6 feat(mcp): tools/ package + fable_search
Establishes the tool pattern: each tool module exposes register(mcp),
register_all() aggregates them, build_mcp_server() calls register_all.

fable_search mirrors the existing fable-mcp contract (q/content_type/limit
in; {results, total} out) but calls services.embeddings.semantic_search_notes
directly instead of going over HTTP. User comes from mcp.current_user_id().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:18:36 -04:00

87 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 = """
Fabled 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.
- 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("fable", 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 "fable_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="fable-mcp"'),
],
})
await send({
"type": "http.response.body",
"body": b'{"error":"unauthorized"}',
})
return
scope["fable_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