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
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
426 lines
22 KiB
Python
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
|