"""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