b49efdcb11
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Has been skipped
Narrow Scribe to a Claude-Code work system-of-record (milestone #194, decision note #1759). Wholesale removal per rule #22 — backend + schema half. Calendar/events + CalDAV: delete models/event, services/{events,caldav, caldav_sync}, routes/events, mcp/tools/events; strip event branches from backup (bump v3->v4), dashboard (upcoming_events), trash, recent, and the mcp server read-only allowlist + instructions. Typed entities (person/place/list): delete mcp/tools/entities; drop the notes.metadata (entity_meta) column from model/service/routes and the knowledge browse service. note_type STAYS — it also marks 'process' notes. Scheduler: event_scheduler -> recurrence_scheduler, keeping only the recurring-task spawn job (drops event reminders + CalDAV sync). Schema: migration 0069 drops the events table + notes.metadata column + orphan caldav settings rows (faithful downgrade recreates them). KEEP: recurrence.py (task recurrence), notifications task reminders, graph view, and every work surface. Frontend + plugin/docs true-up follow next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
407 lines
21 KiB
Python
407 lines
21 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.
|
|
|
|
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
|