refactor: rename package fabledassistant -> scribe (code-only)
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from quart import Quart
|
||||
|
||||
_INSTRUCTIONS = """
|
||||
Scribe is the user's self-hosted second-brain and project-management data
|
||||
store. You (Claude) are the assistant.
|
||||
|
||||
Hierarchy: Project -> Milestone -> Task/Note.
|
||||
|
||||
What each part is for, and when to reach for it:
|
||||
- Project: the top-level container for a body of work.
|
||||
- Milestone: groups related tasks within a project toward a goal (status
|
||||
active/done). Use one when a chunk of work needs its own arc.
|
||||
- Task: a unit of actionable work with a lifecycle (status
|
||||
todo/in_progress/done/cancelled, optional priority). A task is a note with a
|
||||
status — reach for one when there is something to DO. Record progress over
|
||||
time with work-logs (add_task_log) rather than rewriting the body.
|
||||
- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body
|
||||
holds the design + step checklist; work-logs record progress. Start one with
|
||||
start_planning when beginning non-trivial work, before writing code.
|
||||
- Note: durable free-form knowledge — reference material, decisions, dev-logs.
|
||||
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
||||
- Typed entities (person/place/list): structured records about people, places,
|
||||
and checklists.
|
||||
|
||||
Mechanics:
|
||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
|
||||
- Typed entities (person, place, list) are notes with a non-default note_type
|
||||
plus type-specific columns; use the dedicated *_person / *_place / *_list
|
||||
tools rather than create_note.
|
||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||
unchanged on updates.
|
||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
|
||||
removes the task from its milestone); 0 leaves it unchanged.
|
||||
|
||||
Keep task state honest — this is what makes the project a trustworthy record:
|
||||
- When you begin working a task, set it to in_progress (update_task
|
||||
status=in_progress).
|
||||
- Log progress as you go with add_task_log — at meaningful steps, not saved up
|
||||
for the end.
|
||||
- The moment a task's work is complete, set it done. Never leave finished work
|
||||
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
|
||||
left to do.
|
||||
- At a significant landing (a merge, a shipped feature, a finished plan), write
|
||||
a short dated dev-log note on the project (create_note) summarizing what
|
||||
landed, and mark the plan/task done.
|
||||
|
||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||
an actionable statement plus optional Why and How-to-apply context. At the
|
||||
start of any session that touches Scribe, call list_always_on_rules() to
|
||||
load the standing rules — treat them as binding. When you also have a project
|
||||
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
|
||||
project subscribes to) and subscribed_rulebooks; consult those too. Full text
|
||||
(Why / How-to-apply) is available via get_rule(id).
|
||||
|
||||
Engineering and workflow rules live in Scribe. When you notice a pattern
|
||||
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
|
||||
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
|
||||
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
|
||||
— those stores are reserved for facts about the user (preferences, role,
|
||||
communication style) and codebase onboarding pointers, respectively. Before
|
||||
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||
in a project, an existing rulebook+topic, or a new rulebook.
|
||||
|
||||
That boundary cuts the other way too. Because rules are pull-only, a fresh
|
||||
session won't reach for them unless its always-loaded context says to. So
|
||||
when the current project subscribes to a rulebook (enter_project /
|
||||
get_project reports subscribed_rulebooks), make sure the host's persistent
|
||||
memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at
|
||||
startup — carries a short pointer: that the engineering and workflow rules
|
||||
for this work live in Scribe and must be loaded via list_always_on_rules (or
|
||||
enter_project when a project is in scope), plus a one-line note of what the
|
||||
current project is and what is in flight. Add or refresh that pointer when
|
||||
it's missing or stale; never copy the rules' content into memory — the
|
||||
pointer plus project context is the whole job. This is what lets the next
|
||||
session reach for Scribe instead of trusting a stale local copy.
|
||||
|
||||
When you are working on a specific project, call enter_project(project_id)
|
||||
ONCE at session start (or whenever the active project changes). It returns the
|
||||
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||
summary, open tasks, and recent notes — everything you need to know the lay of
|
||||
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||
search separately when enter_project already composes them.
|
||||
|
||||
Don't wait to be told which project you're in. At the start of a session that
|
||||
touches Scribe — or the moment work clearly belongs to a project but none is in
|
||||
scope — bootstrap project context proactively: search for a related existing
|
||||
project (search / list_projects, matching on the work's subject, the repo or
|
||||
directory name, and recent activity). If you find a confident match, propose it
|
||||
and call enter_project once the operator confirms. If nothing matches, offer to
|
||||
create a project, confirming its name and goal first. Always confirm before
|
||||
adopting or creating — never do either silently, and never guess a project into
|
||||
existence. Once a project is in scope, the enter_project handshake and the
|
||||
host-memory pointer step above both apply.
|
||||
|
||||
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||
seeds the plan body, returns the project's applicable_rules, and gives you the
|
||||
task id you'll write into. If a skill or habit tells you to save a plan or spec
|
||||
to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is
|
||||
superseded here: put the spec/plan content in the kind=plan task's body via
|
||||
update_task, and record progress with add_task_log. Local .md files are not
|
||||
the record — the task is.
|
||||
|
||||
Deletes are recoverable: every delete_* tool moves the entity (and its
|
||||
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
||||
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
||||
auto-purges after the operator's retention window.
|
||||
|
||||
Scribe stores reusable Processes — saved prompts/workflows (note_type
|
||||
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
|
||||
X process" or otherwise references a saved process, call list_processes() /
|
||||
get_process(name) and follow the returned prompt verbatim, including any
|
||||
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||
body); edit with update_process.
|
||||
|
||||
When you are developing Scribe itself (not just using it as a data store),
|
||||
honor the multi-user sharing ACL: every read or mutation of user data must
|
||||
scope by owner + direct shares + group shares through services/access.py
|
||||
(can_read_* / can_write_* / can_admin_*) — never assume a single operator. An
|
||||
unscoped query (a fetch-by-id with no ownership check) is a cross-user data
|
||||
leak; "works for one user" is not done.
|
||||
"""
|
||||
|
||||
|
||||
# Tools a read-only API key may call. Anything not listed is treated as a
|
||||
# write for read keys (default-deny), so a newly-added tool is locked down
|
||||
# until explicitly classified here.
|
||||
_READ_ONLY_TOOLS = frozenset({
|
||||
"get_event", "get_note", "get_project", "get_rule", "get_rulebook",
|
||||
"get_task", "get_recent", "enter_project",
|
||||
"list_events", "list_lists", "list_milestones", "list_notes",
|
||||
"list_persons", "list_places", "list_projects", "list_rulebooks",
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
})
|
||||
|
||||
|
||||
async def _buffer_request_body(receive):
|
||||
"""Drain the ASGI request body and return (body_bytes, replay_receive).
|
||||
|
||||
The MCP sub-app still needs to read the body, so we return a fresh
|
||||
`receive` that replays the buffered bytes.
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
more = True
|
||||
while more:
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
chunks.append(message.get("body", b""))
|
||||
more = message.get("more_body", False)
|
||||
else: # http.disconnect
|
||||
more = False
|
||||
body = b"".join(chunks)
|
||||
|
||||
sent = False
|
||||
|
||||
async def replay():
|
||||
nonlocal sent
|
||||
if not sent:
|
||||
sent = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
return body, replay
|
||||
|
||||
|
||||
def _body_calls_write_tool(body: bytes) -> bool:
|
||||
"""True if the JSON-RPC body invokes a tool outside the read all-list."""
|
||||
import json
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except Exception:
|
||||
return False
|
||||
items = payload if isinstance(payload, list) else [payload]
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("method") == "tools/call":
|
||||
name = (item.get("params") or {}).get("name", "")
|
||||
if name and name not in _READ_ONLY_TOOLS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_mcp_server() -> FastMCP:
|
||||
"""Build the FastMCP instance with all tools registered.
|
||||
|
||||
DNS-rebinding protection is disabled: FastMCP's default allow-list is
|
||||
just localhost variants, which means any deployment behind a reverse
|
||||
proxy (Traefik with a hostname like devassistant.traefik.internal,
|
||||
Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
|
||||
model that protection addresses — a malicious browser page rebinding
|
||||
DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
|
||||
behind a reverse proxy with bearer-token auth as the real security
|
||||
boundary.
|
||||
"""
|
||||
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
|
||||
# The stateful default strands Claude Code after a container redeploy —
|
||||
# it reconnects with the now-unknown session id, the server returns 404,
|
||||
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
|
||||
# so the connection stays dead until a manual /mcp retry. Stateless makes
|
||||
# every request self-contained (bearer-auth only), so a post-deploy
|
||||
# reconnect just works. Trade-off: no server-pushed list_changed stream,
|
||||
# which we don't use — tools are re-fetched on reconnect anyway.
|
||||
mcp = FastMCP(
|
||||
"scribe",
|
||||
instructions=_INSTRUCTIONS.strip(),
|
||||
stateless_http=True,
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False,
|
||||
),
|
||||
)
|
||||
from scribe.mcp.tools import register_all
|
||||
register_all(mcp)
|
||||
return mcp
|
||||
|
||||
|
||||
def mount_mcp(app: Quart) -> None:
|
||||
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
||||
|
||||
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
||||
Bearer token against the api_keys table. Authenticated requests have their
|
||||
user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
|
||||
to read.
|
||||
|
||||
FastMCP's streamable_http session manager owns a task group that must be
|
||||
running before it can serve requests. In a stand-alone Starlette deployment
|
||||
that would happen via the Starlette `lifespan` parameter. Since we're hosted
|
||||
inside Quart, we hook the session manager's `run()` async context manager
|
||||
into Quart's serving lifecycle (before_serving / after_serving).
|
||||
"""
|
||||
from scribe.mcp.auth import resolve_bearer
|
||||
|
||||
mcp = build_mcp_server()
|
||||
mcp_asgi = mcp.streamable_http_app()
|
||||
app.mcp_instance = mcp
|
||||
|
||||
@app.before_serving
|
||||
async def _start_mcp_session() -> None:
|
||||
cm = mcp.session_manager.run()
|
||||
await cm.__aenter__()
|
||||
app._mcp_session_cm = cm
|
||||
|
||||
@app.after_serving
|
||||
async def _stop_mcp_session() -> None:
|
||||
cm = getattr(app, "_mcp_session_cm", None)
|
||||
if cm is not None:
|
||||
await cm.__aexit__(None, None, None)
|
||||
|
||||
async def auth_wrapped(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return await mcp_asgi(scope, receive, send)
|
||||
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
|
||||
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
||||
resolved = await resolve_bearer(headers.get("authorization"))
|
||||
if resolved is None:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"www-authenticate", b'Bearer realm="scribe-mcp"'),
|
||||
],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"error":"unauthorized"}',
|
||||
})
|
||||
return
|
||||
user_id, key_scope = resolved
|
||||
|
||||
# Enforce read-only keys: REST blocks non-GET for scope='read', and the
|
||||
# MCP surface must match or the read-only guarantee is void. A tool call
|
||||
# arrives as a JSON-RPC POST; buffer the body, and if it invokes a tool
|
||||
# outside the read all-list, reject before dispatch. (default-deny: any
|
||||
# unknown/new tool is treated as a write for read keys.)
|
||||
if key_scope == "read" and scope.get("method") == "POST":
|
||||
body, receive = await _buffer_request_body(receive)
|
||||
if _body_calls_write_tool(body):
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 403,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"error":"read-only API key cannot call write tools"}',
|
||||
})
|
||||
return
|
||||
|
||||
scope["scribe_user_id"] = user_id
|
||||
from scribe.mcp._context import _user_id_ctx
|
||||
token = _user_id_ctx.set(user_id)
|
||||
try:
|
||||
await mcp_asgi(scope, receive, send)
|
||||
finally:
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
original_asgi = app.asgi_app
|
||||
|
||||
async def dispatch(scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
path = scope.get("path", "")
|
||||
if path == "/mcp" or path.startswith("/mcp/"):
|
||||
# Don't rewrite the path: FastMCP's streamable_http_app mounts
|
||||
# its handler at /mcp by default. If we strip the prefix to "/",
|
||||
# FastMCP's internal routing returns 404 because there's no
|
||||
# handler at "/" — only at "/mcp". Pass the scope through
|
||||
# untouched and let FastMCP's own routing match.
|
||||
return await auth_wrapped(scope, receive, send)
|
||||
return await original_asgi(scope, receive, send)
|
||||
|
||||
app.asgi_app = dispatch
|
||||
Reference in New Issue
Block a user