"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/.""" from __future__ import annotations from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings 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. What each part is for, and when to reach for it: - Project: the top-level container for a body of work. - Milestone: groups related tasks within a project toward a goal (status active/done). Use one when a chunk of work needs its own arc. - Task: a unit of actionable work with a lifecycle (status todo/in_progress/done/cancelled, optional priority). A task is a note with a status — reach for one when there is something to DO. Record progress over time with work-logs (add_task_log) rather than rewriting the body. - Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body holds the design + step checklist; work-logs record progress. Start one with start_planning when beginning non-trivial work, before writing code. - Note: durable free-form knowledge — reference material, decisions, dev-logs. No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping. - Typed entities (person/place/list): structured records about people, places, and checklists. Mechanics: - 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". Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry an actionable statement plus optional Why and How-to-apply context. When you start work on a project, get_project(id) returns applicable_rules (the rules from rulebooks the project subscribes to) and subscribed_rulebooks. Consult these before making decisions about workflow, conventions, or scope. Full text (Why / How-to-apply) is available via get_rule(id). You may create new rules via create_rule when you notice a pattern worth codifying — coordinate with the operator on whether it belongs in an existing rulebook+topic or a new one. Plans are tasks with kind=plan. Begin a plan with start_planning(project_id, title) — that seeds a plan template and returns the project's applicable_rules. Maintain the plan with the normal task tools (update_task for the body, add_task_log for progress); its work-logs are the build record. Build plans in Scribe via start_planning, not in local .md files. Deletes are recoverable: every delete_* tool moves the entity (and its descendants) to the trash and returns a deleted_batch_id. Use list_trash() to see trashed batches, restore(deleted_batch_id) to undo a deletion, and purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash auto-purges after the operator's retention window. """ def build_mcp_server() -> FastMCP: """Build the FastMCP instance with all tools registered. DNS-rebinding protection is disabled: FastMCP's default allow-list is just localhost variants, which means any deployment behind a reverse proxy (Traefik with a hostname like devassistant.traefik.internal, Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat model that protection addresses — a malicious browser page rebinding DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport behind a reverse proxy with bearer-token auth as the real security boundary. """ # stateless_http=True: don't hand the client a persistent Mcp-Session-Id. # The stateful default strands Claude Code after a container redeploy — # it reconnects with the now-unknown session id, the server returns 404, # and the client won't re-initialize on a 404 (Claude Code issue #60949), # so the connection stays dead until a manual /mcp retry. Stateless makes # every request self-contained (bearer-auth only), so a post-deploy # reconnect just works. Trade-off: no server-pushed list_changed stream, # which we don't use — tools are re-fetched on reconnect anyway. mcp = FastMCP( "scribe", instructions=_INSTRUCTIONS.strip(), stateless_http=True, transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=False, ), ) 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. FastMCP's streamable_http session manager owns a task group that must be running before it can serve requests. In a stand-alone Starlette deployment that would happen via the Starlette `lifespan` parameter. Since we're hosted inside Quart, we hook the session manager's `run()` async context manager into Quart's serving lifecycle (before_serving / after_serving). """ from fabledassistant.mcp.auth import resolve_bearer_to_user_id mcp = build_mcp_server() mcp_asgi = mcp.streamable_http_app() app.mcp_instance = mcp @app.before_serving async def _start_mcp_session() -> None: cm = mcp.session_manager.run() await cm.__aenter__() app._mcp_session_cm = cm @app.after_serving async def _stop_mcp_session() -> None: cm = getattr(app, "_mcp_session_cm", None) if cm is not None: await cm.__aexit__(None, None, None) 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/"): # Don't rewrite the path: FastMCP's streamable_http_app mounts # its handler at /mcp by default. If we strip the prefix to "/", # FastMCP's internal routing returns 404 because there's no # handler at "/" — only at "/mcp". Pass the scope through # untouched and let FastMCP's own routing match. return await auth_wrapped(scope, receive, send) return await original_asgi(scope, receive, send) app.asgi_app = dispatch