Files
FabledScribe/src/scribe/mcp/server.py
T
bvandeusen b33e2a79c6
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
fix(snippets): close the recall-surface gaps found reviewing the Drafter
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 19:00:05 -04:00

426 lines
22 KiB
Python

"""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, and your own system of record for their work. You (Claude) are the
assistant: record what you do here — tasks, work-logs, decisions, notes — and
recall from here before acting. Do not keep the user's project work in local
files (CLAUDE.md, scratch/auto memory) in parallel; Scribe holds the single copy.
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). A milestone is ALSO the home of a plan — its `body` holds the
design/intent (Goal/Approach/Verification) and its child tasks are the steps.
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.
- Issue: a task whose kind is corrective — a problem you fixed or are fixing, as
opposed to productive `work`. Create it with create_task(kind="issue"); the
body carries symptom → root cause → fix. It has the full task lifecycle, and
can link the originating task it arose from (arose_from_id) and the System(s)
it touches (system_ids). Reach for one whenever you fix something — even in
passing — instead of burying the fix in another task's work-log.
- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
work. The design/intent lives in the milestone `body`; each step is its own
child task (create_task(milestone_id=...)), tracked with status + work-logs —
NOT a checkbox buried in the body. Start one with start_planning when
beginning non-trivial work, before you dive in; read it back with
get_milestone (body + steps). (The old kind=plan task is retired — some
historical plan-tasks still exist and remain readable, but don't create new
ones.)
- Note: durable free-form knowledge — reference material, decisions, logs of
what happened.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
- System: a per-project, reusable, self-describing subsystem/area. Associate any
record (note, task, issue) with it via system_ids so research, build-work, and
fixes for the same area line up, and recurring problem-spots surface. Manage
with create_system / list_systems / get_system.
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.
- 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 work
already exists — a related task, an earlier decision, a prior note — 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 task for work already tracked.
- create_note / create_task enforce this: if a title- or meaning-similar record
already exists in the same project, the call is BLOCKED and returns
{"duplicate": true, "existing_id": ...} instead of creating. UPDATE that
record (update_note / update_task / add_task_log) rather than duplicating.
Only pass force=true when it's genuinely a distinct record — a duplicate both
bloats the store and surfaces as a stale competing copy in later searches.
- Scope 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. get_recent takes no project filter and spans
every project; when one is active, prefer the scoped list_* tools over it.
And this is not only about reads: once a project is in scope, only reference
or offer work on THAT project — don't surface or propose work from other
projects unless the operator widens scope. If something clearly belongs to a
different project, say so and ask before switching; never silently operate
cross-project. 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 meaningful point — finishing a task, or hitting or discovering a problem
that changes direction — write a short dated note on the project (create_note)
capturing what happened (the pivots, not just the wins), and set the finished
task to done.
- When you fix a problem — even one solved in passing — record it as its own
issue (create_task(kind="issue")) with symptom → root cause → fix in the body,
NOT as a work-log line on whatever task happened to be open. An issue is
corrective work with its own lifecycle; recording it discretely (optionally
linked via arose_from_id to the task it came from, and system_ids to the
subsystem it touches) is what makes it findable so it isn't diagnosed from
scratch next time.
Compaction hygiene — recommend compacting at clean seams. Because you record
progress as you go, a context compaction is SAFE: the durable state lives in
Scribe (task status, work-logs, decision notes), not the transcript, so it
survives the summary. Use this rather than letting auto-compaction fire mid-task:
- At the end of a coherent block of work (a task closed, a plan phase finished)
in a long session, first make sure in-flight state is actually in Scribe —
update task status, add a work-log, capture any decision as a note. Surface
the few things worth logging before suggesting the compact.
- Then tell the operator it's a good, safe moment to /compact, naming what you
logged ("logged to #X/#Y — safe to /compact, nothing will be lost"). You
cannot run /compact yourself; surface the recommendation and let them decide.
- Recommend it at genuine seams, not every turn. The next session's start will
prompt you to reload your bearings from Scribe — so a clean-seam compact plus
that reload loses nothing.
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).
Workflow and standards 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.
One thing NOT to do: don't bridge Scribe into a session by writing to the
host's native memory. Rules are pull-only, so a fresh session won't reach for
them unless its always-loaded context says to — but the bridge for that is the
Scribe plugin's SessionStart hook, which pushes the always-on rules +
active-project context into each session directly. So do NOT create or refresh
a "rules live in Scribe" pointer in CLAUDE.md / AGENTS.md / ~/.claude memory,
and do NOT keep rules, recall, or plans in those stores in parallel with Scribe
— Scribe holds the single copy. Native auto-memory stays for facts about the
user; CLAUDE.md for codebase onboarding. Never make Scribe's correctness depend
on the operator disabling a native function (e.g. autoMemoryEnabled): the
plugin must work with auto-memory at its default. If the plugin is ever removed
the session loses this push and rebuilds context over time — an acceptable cost,
and far better than a silent settings change the operator may not know about.
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.
A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin
non-trivial work, call start_planning(project_id, title) FIRST — before any
brainstorming, design, or plan-writing skill runs. start_planning creates the
milestone, seeds its `body` with the design template, returns the project's
applicable_rules, and gives you the milestone id you'll write into. Put the
design/intent in the milestone body via update_milestone(milestone_id, body=...);
create each step as a child task with create_task(milestone_id=...) and track it
with status + add_task_log — do NOT list steps as checkboxes in the body. Read
the plan back with get_milestone (body + steps). If a habit tells you to save a
plan or spec to a local `.md` file, that's superseded here: the milestone is the
record, not a local file.
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.
Scribe also stores Snippets — reusable functions/components recorded once for
recall (note_type "snippet"): a name, language, signature, canonical location
(repo · path · symbol), a one-line "when to reach for it", and the code. They
are ordinary embedded notes, so a recorded snippet also surfaces through the
same search + proactive recall as everything else. Two reflexes: (1) before you
write a new helper/utility/component, search first (list_snippets(q=...) or
search) — reuse the prior art with get_snippet(id) instead of re-deriving a
one-off; (2) the moment you build or notice something reusable, record it with
create_snippet(name, code, when_to_use, language, signature, repo, path, symbol,
project_id, system_ids) so a later session is offered it. Make when_to_use sharp
— it becomes the title, which is what a recall menu shows. Edit an existing one
with update_snippet rather than recording a second copy; when the same reusable
thing already exists as several one-offs, unify them into one canonical record
with merge_snippets (it folds every call site in as a location and trashes the
duplicates). Keep the record honest: a snippet whose details have gone stale can
be corrected with update_snippet (an empty string clears a field), and one that
is wrong or obsolete should be retired with delete_snippet — a bad snippet keeps
being offered as prior art, which costs more than none at all.
When developing Scribe itself, honor its multi-user sharing ACL: scope every
read and mutation of user data by owner + shares — never assume a single
operator. "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_note", "get_project", "get_rule", "get_rulebook",
"get_task", "get_milestone", "get_recent", "enter_project",
"list_milestones", "list_notes", "list_projects", "list_rulebooks",
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
"list_always_on_rules", "search",
"get_system", "list_systems", "list_system_records",
})
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