CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Successful in 41s
Step 3 shipped a line a session can receive — "Preference that may apply here …" — into surfaces that told it, in the most authoritative voice it has, that anything arriving in that shape is binding. That is the confusion milestone 399 exists to prevent, arriving through the one channel a session has least reason to doubt. Silent in both directions, which is why it could not wait for step 6. A session treating a preference as a rule refuses to proceed over something the operator merely preferred; and it loses the whole reason preferences exist, which is that they are brought up to date rather than obeyed. Three surfaces, each to its own budget: - SKILL.md gets the full account: kind decides force, the injected line names which in its opening words, and a preference is the one record a session keeps current itself (update_preference, with what taught the change). - scribe_static_context.md gets six lines — enough to tell the kinds apart and to say a preference is yours to update. - _INSTRUCTIONS gets four words. It is a MAP at 1978 of its 2000-char budget (#2562), and the detail belongs in the surfaces above and in the tool docstrings, which is what that budget exists to force. Guarded so it cannot drift back: a surface that claims rules bind must name the kind that does not. Pinned on the CLAIM rather than the word "bind", because a bare substring also matches bind_repo, list_repo_bindings and server.py's DNS-rebinding comment — a guard that would one day fail a skill about repo binding is rule 167's named failure, raising a false alarm about the very thing it protects. Falsified against all three surfaces losing the mention. Plugin version minted: the cache refreshes only on a version bump (#2209), so a skill edit without one reaches no installed plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
397 lines
19 KiB
Python
397 lines
19 KiB
Python
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
from quart import Quart
|
|
|
|
## The delivery budget — read before editing this block
|
|
#
|
|
# Claude Code injects only the FIRST ~2,048 CHARACTERS of an MCP server's
|
|
# instructions into the system prompt; the rest is silently cut mid-word
|
|
# (#2562 — the cut was observed live at exactly offset 2,048, and ~90% of the
|
|
# previous 20k-char version of this block never reached any session). So this
|
|
# block is deliberately a MAP, not a manual, and a test pins it under the
|
|
# fold (test_instruction_surfaces_agree.py::test_instructions_fit_the_fold).
|
|
#
|
|
# Where the detail lives instead — each surface has one job:
|
|
# - Tool docstrings: the per-tool HOW. Delivered with the tool schema, at
|
|
# reach-for time when the client defers tools. Guidance about one tool
|
|
# belongs there, not here.
|
|
# - Plugin static context (plugin/hooks/scribe_static_context.md): the
|
|
# session-level reflexes (recall-first, record-as-you-go, tag-to-Systems,
|
|
# compaction). Always delivered in full; needs no key and no network.
|
|
# - Plugin skills: process arcs (planning, debugging, verification…).
|
|
# Their listing line is the always-visible trigger; the body loads on
|
|
# match. Stored Processes become skills via /scribe:sync.
|
|
# - The server itself: behaviors prose can't be trusted to fire (the
|
|
# duplicate gate, the untagged-record systems_hint) act in-band in tool
|
|
# responses, at the moment they apply.
|
|
# Grow one of those, not this block.
|
|
# BUDGET: ~1980 of the client's ~2048-char cap (#2562). Everything below is
|
|
# competing for the last ~68 characters, so an addition here is a trade, never
|
|
# an append.
|
|
#
|
|
# Milestone 317 (a note's own verify_with / expires_when, and the sweep over
|
|
# them) was DECLINED a line, deliberately, by the operator — not overlooked.
|
|
# The reasoning, so it is not re-litigated blind: this is a map, and its own
|
|
# closing line says each tool's description carries the full contract. The
|
|
# sweep is a curation act, not a session-start reflex like enter_project or
|
|
# list_always_on_rules. Spending the last of the budget on it would leave the
|
|
# map unable to grow for something more central later.
|
|
#
|
|
# The accepted cost: an agent that never opens create_note's docstring never
|
|
# learns the field exists. Guidance lives in the create_note / update_note
|
|
# docstrings and the using-scribe skill instead.
|
|
#
|
|
# Milestone 333 step 3 (2026-09-04) bought the HOW bullet's second clause —
|
|
# search(content_type="rule") before a consequential act — by TRADING OUT
|
|
# "Processes are saved procedures (follow verbatim)" and "Deletes are
|
|
# trash-recoverable". Recorded so the trade is not silently reversed:
|
|
# - Both were already in test_instruction_surfaces_agree's DISPLACED_TOPICS
|
|
# and already stated on a delivered surface, so nothing fell off: the
|
|
# process reflex is in every scribe-proc-* skill listing (each says the
|
|
# process governs and is followed verbatim), and trash recovery is in the
|
|
# delete_*/list_trash/restore docstrings, which is where per-tool guidance
|
|
# belongs by this block's own doctrine.
|
|
# - What it bought is not per-tool guidance and has nowhere else to live at
|
|
# session-start altitude. Rules were retrievable only by RESIDENCY: the
|
|
# always-on preload put them in front of the agent, and nothing told a
|
|
# session to go looking for one it had not been handed. The tier split is
|
|
# therefore load-bearing on ANY install (rule 115): a delivered rule costs
|
|
# tokens in every session forever, so a rulebook that only delivers cannot
|
|
# grow past what one session can hold, and every rule worth keeping has to
|
|
# become resident to bind at all. Retrieval is what lets it keep growing —
|
|
# and retrieval fires only if something asks, which nothing told a session
|
|
# to do. A tool-choice reflex asks least of all (#3476, #161).
|
|
# - This states the PULL for conditional rules, exactly as the surrounding
|
|
# line states it for always-on ones. Rule 119 makes these surfaces the
|
|
# specification, so the same sentence lands on all three session-start
|
|
# surfaces, and test_instruction_surfaces_agree pins it.
|
|
_INSTRUCTIONS = """
|
|
Scribe is the operator's self-hosted second brain and system of record — and
|
|
yours: recall from it before acting, record as you go. Keep no parallel copy
|
|
in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
|
|
|
|
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
|
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
|
notes, Systems, design system. `inception` key: ask what the project
|
|
inherits, decide_project_inception (create_project takes the same).
|
|
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
|
|
fix), never a work-log line on an unrelated task. Log with add_task_log;
|
|
keep status honest — in_progress on start, done on finish.
|
|
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is
|
|
a child task, not a checkbox. No local plan .md files.
|
|
- CAPTURE: create_note. RECALL: search first — prior art exists; pass the
|
|
active project_id to stay in scope.
|
|
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
|
create_system when the area is unmodelled.
|
|
- HOW: rules bind; preferences guide. list_always_on_rules() at start;
|
|
before a consequential act, search(content_type="rule") — the resident
|
|
set is not all of them.
|
|
- UI: the project's design system is binding — resolve_design_system /
|
|
get_design_system_stylesheet before hand-writing a value.
|
|
- REUSE: search snippets before writing a helper; record what you build with
|
|
create_snippet; classify shapes against canon (classify_shapes) — a
|
|
consumer map is rows, never prose.
|
|
|
|
A task is a note with status (*_note vs *_task tools).
|
|
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
|
|
id — update it, don't force. shared:true records are another user's — a
|
|
suggestion, not the operator's settled practice.
|
|
|
|
This is only a map — the client injects ~2k chars and cuts the rest. Each
|
|
tool's description carries its full contract: read it when you load the
|
|
tool, and trust it over habit.
|
|
"""
|
|
|
|
|
|
# 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.
|
|
#
|
|
# The list stays EXPLICIT rather than being derived from the name. A read key is
|
|
# what you hand to something you don't fully trust — a dashboard, a CI job, a
|
|
# shared integration — and a boundary inferred from a naming convention grants
|
|
# access to whatever a future author happens to call `get_*`. Enumerating it is
|
|
# the point; staleness is the cost, and test_mcp_auth covers that (a read-shaped
|
|
# tool must appear here or in _DELIBERATELY_WRITE_SCOPED below, so adding one
|
|
# forces a decision instead of silently denying it).
|
|
#
|
|
# Membership means "reads the operator's data and mutates none of it". Several
|
|
# getters record a retrieval event via record_pulled; that is telemetry about
|
|
# the read itself, not a change to what was read, and it must keep working for a
|
|
# read key or the corpus's surfaced:pulled ratio silently under-counts whichever
|
|
# consumers hold one.
|
|
_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",
|
|
# The global area catalog and its mapping REPORT — propose writes nothing;
|
|
# map_system_to_canonical is the separate, explicitly-called write.
|
|
"list_canonical_systems", "propose_canonical_mappings",
|
|
# Reports on the corpus. Reads only — the merge or supersession each
|
|
# suggests is a separate, explicitly-called write.
|
|
"find_duplicate_snippets", "find_duplicate_records",
|
|
# Snippets and processes are notes with a kind. A key that may read a note
|
|
# but not a snippet inverts the sensitivity ordering: it exposes the
|
|
# free-text records and withholds the structured ones (#2496).
|
|
"get_snippet", "list_snippets",
|
|
"get_process", "list_processes",
|
|
# Design systems: read, resolve (inheritance + mode), render, and compare
|
|
# against recorded snippets. All four compute from stored records and write
|
|
# nothing — the drift report is a report, and applying it is a separate
|
|
# explicit call.
|
|
"get_design_system", "list_design_systems", "resolve_design_system",
|
|
"get_design_system_stylesheet", "list_design_tokens",
|
|
"check_snippets_against_design_system", "list_starter_role_groups",
|
|
# Which repos map to which project. Read-only by nature; bind_repo /
|
|
# unbind_repo are the writes.
|
|
"list_repo_bindings",
|
|
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
|
|
# the write, and it is deliberately NOT here.
|
|
"list_shapes", "shape_history",
|
|
# The retrieval telemetry readout (#2975). Aggregates two log tables and
|
|
# writes nothing. Listed explicitly because its name carries no read
|
|
# prefix, so the completeness test below cannot derive it — the same
|
|
# reason `enter_project` is spelled out above.
|
|
"retrieval_telemetry",
|
|
# The note staleness sweep (milestone 317). A pure read — mark_note_verified
|
|
# is the write, and it is deliberately NOT here. Spelled out for
|
|
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of
|
|
# the prefixes the completeness test derives from, so nothing would have
|
|
# prompted this decision. `rules_due_for_verification` is in the same
|
|
# position and is NOT listed — see #3191.
|
|
"notes_due_for_verification",
|
|
})
|
|
|
|
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
|
|
# creates on miss, a list that has a side effect. Empty today, and deliberately
|
|
# kept as a declared escape hatch rather than left implicit: without it, the
|
|
# completeness test would push a future `get_or_create_*` into the allow-list
|
|
# above, which is exactly the wrong way to make a test pass.
|
|
_DELIBERATELY_WRITE_SCOPED: frozenset[str] = frozenset()
|
|
|
|
|
|
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
|
|
|
|
|
|
class StrictArgsFastMCP(FastMCP):
|
|
"""A FastMCP that REJECTS tool calls carrying undeclared arguments.
|
|
|
|
FastMCP validates arguments with a pydantic model built from the tool
|
|
signature, and pydantic's default extra-field policy is "ignore" — so a
|
|
misnamed argument simply vanishes and the tool runs with that field's
|
|
default. On the create/update tools the default is "", which turns a
|
|
plausible near-miss (`content=` for `body=`, primed by add_task_log's
|
|
`content`) into SILENT DATA LOSS: the call reports success and stores an
|
|
empty body, leaving a record search cannot see (#2709). Two notes were
|
|
persisted body-less that way before anyone noticed.
|
|
|
|
An error the caller sees once is strictly better than data half-written
|
|
forever, so the policy is applied to every tool, not just the two that
|
|
bit: nothing here knows tool semantics, only that an argument nobody
|
|
declared cannot have been meant to be dropped.
|
|
"""
|
|
|
|
async def call_tool(self, name, arguments):
|
|
try:
|
|
tool = self._tool_manager.get_tool(name)
|
|
except Exception:
|
|
tool = None # unknown tool → let upstream produce its own error
|
|
if tool is not None:
|
|
declared = set((tool.parameters or {}).get("properties", {}))
|
|
unknown = sorted(set(arguments or {}) - declared)
|
|
if unknown:
|
|
hints = []
|
|
for arg in unknown:
|
|
close = difflib.get_close_matches(arg, sorted(declared), n=1)
|
|
suggestion = f" (did you mean '{close[0]}'?)" if close else ""
|
|
hints.append(f"'{arg}'{suggestion}")
|
|
raise ValueError(
|
|
f"{name} does not accept argument(s) {', '.join(hints)}. "
|
|
f"It accepts: {', '.join(sorted(declared))}. Nothing was "
|
|
"created or changed — retry with the declared names."
|
|
)
|
|
return await super().call_tool(name, arguments)
|
|
|
|
|
|
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 = StrictArgsFastMCP(
|
|
"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
|