"""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". On update_task, -1 clears an existing FK (e.g. milestone_id=-1 removes the task from its milestone); 0 leaves it unchanged. Reach for Scribe to RECALL, not just to record. Scribe is a second brain — its value is mostly in what it already holds, so make searching it a reflex, not something you wait to be asked for: - Before you answer a question about the user's work, or start a task, search Scribe first (search / list_tasks / list_notes). Assume relevant prior art already exists — a related ticket, an earlier decision, a dev-log — and look before you re-derive it or open a duplicate. - Before creating a task, search for an existing one (search content_type= 'task') — don't open a second ticket for work already tracked. - Scope reads to the project in scope. When a project is active (you called enter_project), pass its project_id to search / list_tasks / list_notes so results stay inside that project. Querying with no project_id pulls in every project and bleeds unrelated work into the session — only do it for a deliberate cross-project sweep. The active project does not stick on the server (each call is self-contained); carrying its id forward is on you. Keep task state honest — this is what makes the project a trustworthy record: - When you begin working a task, set it to in_progress (update_task status=in_progress). - Log progress as you go with add_task_log — at meaningful steps, not saved up for the end. - The moment a task's work is complete, set it done. Never leave finished work at todo/in_progress — an out-of-date status makes Scribe misrepresent what's left to do. - At a significant landing (a merge, a shipped feature, a finished plan), write a short dated dev-log note on the project (create_note) summarizing what landed, and mark the plan/task done. Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry an actionable statement plus optional Why and How-to-apply context. At the start of any session that touches Scribe, call list_always_on_rules() to load the standing rules — treat them as binding. When you also have a project in scope, get_project(id) returns applicable_rules (rules from rulebooks the project subscribes to) and subscribed_rulebooks; consult those too. Full text (Why / How-to-apply) is available via get_rule(id). Engineering and workflow rules live in Scribe. When you notice a pattern worth codifying, call create_rule (cross-project, lands in a rulebook+topic) or create_project_rule (one project only, no rulebook ceremony). Do NOT add new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md — those stores are reserved for facts about the user (preferences, role, communication style) and codebase onboarding pointers, respectively. Before creating a rule, call list_always_on_rules and list_rules(project_id=...) to avoid duplicates. Choose a rule's home by WHO it should bind, and keep each home's rules at the right altitude: - Always-on rulebook (a rulebook flagged always_on) — universal norms that bind EVERY one of your projects. Reserve for cross-project standards. - Subscribed rulebook (always_on off; projects opt in via subscribe_project_to_rulebook) — a reusable, THEMED module of general rules that binds only the projects which subscribe. Its rules must make sense for every project that could subscribe, never one specific project (e.g. a design-system rulebook: design-specific but project-agnostic — no rule names a single app). - Project rule (create_project_rule) — anything specific to ONE project. Both rulebook tiers are SHARED, so their rules stay general; the difference between them is REACH (all projects vs opt-in by theme), not generality. Rule of thumb: names a specific project's files/paths/quirks -> project rule; a standard a CATEGORY of projects shares -> subscribed rulebook; a universal norm -> always-on rulebook. Coordinate with the operator on which home fits. That boundary cuts the other way too. Because rules are pull-only, a fresh session won't reach for them unless its always-loaded context says to. So when the current project subscribes to a rulebook (enter_project / get_project reports subscribed_rulebooks), make sure the host's persistent memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at startup — carries a short pointer: that the engineering and workflow rules for this work live in Scribe and must be loaded via list_always_on_rules (or enter_project when a project is in scope), plus a one-line note of what the current project is and what is in flight. Add or refresh that pointer when it's missing or stale; never copy the rules' content into memory — the pointer plus project context is the whole job. This is what lets the next session reach for Scribe instead of trusting a stale local copy. When you are working on a specific project, call enter_project(project_id) ONCE at session start (or whenever the active project changes). It returns the project, its applicable_rules + project_rules + subscribed_rulebooks, milestone summary, open tasks, and recent notes — everything you need to know the lay of the land before mutating. Don't call get_project + get_applicable_rules + a search separately when enter_project already composes them. Don't wait to be told which project you're in. At the start of a session that touches Scribe — or the moment work clearly belongs to a project but none is in scope — bootstrap project context proactively: search for a related existing project (search / list_projects, matching on the work's subject, the repo or directory name, and recent activity). If you find a confident match, propose it and call enter_project once the operator confirms. If nothing matches, offer to create a project, confirming its name and goal first. Always confirm before adopting or creating — never do either silently, and never guess a project into existence. Once a project is in scope, the enter_project handshake and the host-memory pointer step above both apply. Plans are tasks with kind=plan, and Scribe is the canonical home for them. When you begin non-trivial work, call start_planning(project_id, title) FIRST — before any brainstorming, design, or plan-writing skill runs. start_planning seeds the plan body, returns the project's applicable_rules, and gives you the task id you'll write into. If a skill or habit tells you to save a plan or spec to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is superseded here: put the spec/plan content in the kind=plan task's body via update_task, and record progress with add_task_log. Local .md files are not the record — the task is. 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. Scribe stores reusable Processes — saved prompts/workflows (note_type "process"), e.g. a drift audit or a DRY pass. When the operator says "run the X process" or otherwise references a saved process, call list_processes() / get_process(name) and follow the returned prompt verbatim, including any "clarify first" steps it contains. Author a new one with create_process(title, body); edit with update_process. When you are developing Scribe itself (not just using it as a data store), honor the multi-user sharing ACL: every read or mutation of user data must scope by owner + direct shares + group shares through services/access.py (can_read_* / can_write_* / can_admin_*) — never assume a single operator. An unscoped query (a fetch-by-id with no ownership check) is a cross-user data leak; "works for one user" is not done. """ # Tools a read-only API key may call. Anything not listed is treated as a # write for read keys (default-deny), so a newly-added tool is locked down # until explicitly classified here. _READ_ONLY_TOOLS = frozenset({ "get_event", "get_note", "get_project", "get_rule", "get_rulebook", "get_task", "get_recent", "enter_project", "list_events", "list_lists", "list_milestones", "list_notes", "list_persons", "list_places", "list_projects", "list_rulebooks", "list_rules", "list_tags", "list_tasks", "list_topics", "list_trash", "list_always_on_rules", "search", }) async def _buffer_request_body(receive): """Drain the ASGI request body and return (body_bytes, replay_receive). The MCP sub-app still needs to read the body, so we return a fresh `receive` that replays the buffered bytes. """ chunks: list[bytes] = [] more = True while more: message = await receive() if message["type"] == "http.request": chunks.append(message.get("body", b"")) more = message.get("more_body", False) else: # http.disconnect more = False body = b"".join(chunks) sent = False async def replay(): nonlocal sent if not sent: sent = True return {"type": "http.request", "body": body, "more_body": False} return {"type": "http.disconnect"} return body, replay def _body_calls_write_tool(body: bytes) -> bool: """True if the JSON-RPC body invokes a tool outside the read all-list.""" import json try: payload = json.loads(body) except Exception: return False items = payload if isinstance(payload, list) else [payload] for item in items: if not isinstance(item, dict): continue if item.get("method") == "tools/call": name = (item.get("params") or {}).get("name", "") if name and name not in _READ_ONLY_TOOLS: return True return False 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 scribe.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 scribe.mcp.auth import resolve_bearer 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", [])} resolved = await resolve_bearer(headers.get("authorization")) if resolved 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 user_id, key_scope = resolved # Enforce read-only keys: REST blocks non-GET for scope='read', and the # MCP surface must match or the read-only guarantee is void. A tool call # arrives as a JSON-RPC POST; buffer the body, and if it invokes a tool # outside the read all-list, reject before dispatch. (default-deny: any # unknown/new tool is treated as a write for read keys.) if key_scope == "read" and scope.get("method") == "POST": body, receive = await _buffer_request_body(receive) if _body_calls_write_tool(body): await send({ "type": "http.response.start", "status": 403, "headers": [(b"content-type", b"application/json")], }) await send({ "type": "http.response.body", "body": b'{"error":"read-only API key cannot call write tools"}', }) return scope["scribe_user_id"] = user_id from scribe.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