refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s

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:
2026-06-03 15:48:35 -04:00
parent 1d4c206563
commit b255a0f90e
167 changed files with 1183 additions and 2368 deletions
+8
View File
@@ -0,0 +1,8 @@
"""In-app MCP server. Exposes Fabled Scribe data via the MCP streamable-HTTP transport.
Auth uses the existing `api_keys` table: a Bearer token in the Authorization
header resolves to a user, and every tool call acts on that user's data only.
"""
from scribe.mcp.server import build_mcp_server, mount_mcp
__all__ = ["build_mcp_server", "mount_mcp"]
+20
View File
@@ -0,0 +1,20 @@
"""Per-request MCP context.
The ASGI middleware (mcp/server.py) populates `_user_id_ctx` from
scope['scribe_user_id'] before dispatching to FastMCP tool handlers.
Tool functions then call `current_user_id()` to retrieve it.
Tools must never fall back to a default user — `current_user_id()` raises
if called outside a properly-authed MCP request.
"""
from contextvars import ContextVar
_user_id_ctx: ContextVar[int | None] = ContextVar("scribe_mcp_user_id", default=None)
def current_user_id() -> int:
"""Return the user_id for the in-flight MCP request. Raises if unset."""
uid = _user_id_ctx.get()
if uid is None:
raise RuntimeError("no MCP user context — request was not bearer-authed")
return uid
+37
View File
@@ -0,0 +1,37 @@
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
from __future__ import annotations
from scribe.services.api_keys import lookup_key
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
Returns None if the header is missing, malformed, or the token is invalid
or revoked. The underlying lookup_key already updates last_used_at on hit.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[len("Bearer "):].strip()
if not raw_token:
return None
api_key = await lookup_key(raw_token)
return api_key.user_id if api_key else None
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
"""Resolve a Bearer token to (user_id, scope).
scope is 'read' or 'write'. Returns None for a missing/malformed/invalid
token. The MCP dispatch layer uses scope to deny write-class tool calls
from read-only keys — the same read/write boundary the REST API enforces.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[len("Bearer "):].strip()
if not raw_token:
return None
api_key = await lookup_key(raw_token)
if api_key is None:
return None
return api_key.user_id, (api_key.scope or "write")
+323
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
"""MCP tool implementations.
Each tool module exposes a `register(mcp)` function that attaches its tools
to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
)
def register_all(mcp) -> None:
"""Register every tool module's tools on the given FastMCP instance."""
search.register(mcp)
notes.register(mcp)
tasks.register(mcp)
projects.register(mcp)
milestones.register(mcp)
events.register(mcp)
tags.register(mcp)
recent.register(mcp)
entities.register(mcp)
processes.register(mcp)
rulebooks.register(mcp)
trash.register(mcp)
+245
View File
@@ -0,0 +1,245 @@
"""Typed-entity MCP tools: person, place, list.
These are notes with a non-'note' note_type and type-specific JSON metadata
stored in the entity_meta column. Three tools per type — list, create, update.
For get and delete, use get_note / delete_note (typed entities
share the Note model).
The wrappers translate typed-field kwargs into the entity_meta dict shape that
KnowledgeView.vue and services/knowledge.py expect.
Lists: a list entity stores its items in entity_meta["list_items"] as a list
of {text, checked} dicts. The create/update tools take a simpler `items` list
of plain strings for ergonomics; checked-state is reset to False on each call.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
"""Common list query for a typed entity."""
uid = current_user_id()
items, total = await knowledge_svc.query_knowledge(
user_id=uid,
note_type=note_type,
tags=[tag] if tag else [],
sort="modified",
q=q or None,
limit=max(1, min(limit, 100)),
offset=0,
)
# Map "items" key to a type-specific key for caller clarity.
plural = {"person": "persons", "place": "places", "list": "lists"}[note_type]
return {plural: items, "total": total}
async def _create_entity(note_type: str, name: str, entity_meta: dict,
tags: list[str] | None = None) -> dict:
"""Create a typed note with the given metadata."""
uid = current_user_id()
note = await notes_svc.create_note(
uid,
title=name,
note_type=note_type,
entity_meta=entity_meta or None,
tags=tags,
)
return note.to_dict()
async def _update_entity(entity_id: int, note_type: str, name: str,
meta_updates: dict) -> dict:
"""Merge updates into entity_meta and (optionally) update the title."""
uid = current_user_id()
note = await notes_svc.get_note(uid, entity_id)
if note is None or note.note_type != note_type:
raise ValueError(f"{note_type} {entity_id} not found")
new_meta = dict(note.entity_meta or {})
new_meta.update(meta_updates)
fields: dict = {"entity_meta": new_meta}
if name:
fields["title"] = name
updated = await notes_svc.update_note(uid, entity_id, **fields)
return updated.to_dict()
# ─── Person ──────────────────────────────────────────────────────────────────
_PERSON_FIELDS = ("relationship", "email", "phone", "birthday",
"organization", "address")
async def list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict:
"""List people in the user's knowledge base.
Args:
q: Free-text search across name + body (optional).
tag: Filter to a single tag (optional).
limit: Max results (1-100).
"""
return await _list_by_type("person", q, tag, limit)
async def create_person(
name: str,
relationship: str = "",
email: str = "",
phone: str = "",
birthday: str = "",
organization: str = "",
address: str = "",
tags: list[str] | None = None,
) -> dict:
"""Create a person in the user's knowledge base.
Args:
name: Person's name (required).
relationship: How the user knows them (e.g. "colleague", "friend").
email / phone / birthday (YYYY-MM-DD) / organization / address: optional.
tags: Plain-string tags, no # prefix.
"""
meta = {f: v for f, v in (
("relationship", relationship), ("email", email), ("phone", phone),
("birthday", birthday), ("organization", organization),
("address", address),
) if v}
return await _create_entity("person", name, meta, tags)
async def update_person(
person_id: int,
name: str = "",
relationship: str = "",
email: str = "",
phone: str = "",
birthday: str = "",
organization: str = "",
address: str = "",
) -> dict:
"""Update a person. Only explicitly provided fields are changed.
To clear a field, pass an explicit space character (this preserves the
fable-mcp empty-string-means-omit convention).
"""
meta_updates = {f: v for f, v in (
("relationship", relationship), ("email", email), ("phone", phone),
("birthday", birthday), ("organization", organization),
("address", address),
) if v}
return await _update_entity(person_id, "person", name, meta_updates)
# ─── Place ───────────────────────────────────────────────────────────────────
async def list_places(q: str = "", tag: str = "", limit: int = 25) -> dict:
"""List places (cafes, offices, addresses) in the user's knowledge base."""
return await _list_by_type("place", q, tag, limit)
async def create_place(
name: str,
address: str = "",
phone: str = "",
hours: str = "",
website: str = "",
category: str = "",
tags: list[str] | None = None,
) -> dict:
"""Create a place in the user's knowledge base.
Args:
name: Place name (required).
address / phone / hours / website / category: optional.
tags: Plain-string tags, no # prefix.
"""
meta = {f: v for f, v in (
("address", address), ("phone", phone), ("hours", hours),
("website", website), ("category", category),
) if v}
return await _create_entity("place", name, meta, tags)
async def update_place(
place_id: int,
name: str = "",
address: str = "",
phone: str = "",
hours: str = "",
website: str = "",
category: str = "",
) -> dict:
"""Update a place. Only explicitly provided fields are changed."""
meta_updates = {f: v for f, v in (
("address", address), ("phone", phone), ("hours", hours),
("website", website), ("category", category),
) if v}
return await _update_entity(place_id, "place", name, meta_updates)
# ─── List ────────────────────────────────────────────────────────────────────
async def list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict:
"""List checklists in the user's knowledge base."""
return await _list_by_type("list", q, tag, limit)
async def create_list(
name: str,
category: str = "",
items: list[str] | None = None,
tags: list[str] | None = None,
) -> dict:
"""Create a checklist (a list-type entity).
Args:
name: List name (required).
category: Optional category label (e.g. "shopping", "packing").
items: Initial item texts. All items start unchecked.
tags: Plain-string tags, no # prefix.
"""
meta: dict = {}
if category:
meta["category"] = category
if items:
meta["list_items"] = [{"text": t, "checked": False} for t in items]
return await _create_entity("list", name, meta, tags)
async def update_list(
list_id: int,
name: str = "",
category: str = "",
items: list[str] | None = None,
) -> dict:
"""Update a checklist.
Args:
list_id: ID of the list to update.
name: New title (optional).
category: New category (optional).
items: REPLACES the entire item set with these texts (all reset to
unchecked). Omit (None) to leave items unchanged. Pass an empty
list to clear all items.
"""
meta_updates: dict = {}
if category:
meta_updates["category"] = category
if items is not None:
meta_updates["list_items"] = [
{"text": t, "checked": False} for t in items
]
return await _update_entity(list_id, "list", name, meta_updates)
def register(mcp) -> None:
for fn in (
list_persons, create_person, update_person,
list_places, create_place, update_place,
list_lists, create_list, update_list,
):
mcp.tool(name=fn.__name__)(fn)
+171
View File
@@ -0,0 +1,171 @@
"""Calendar event MCP tools — new in Phase 3.
Events were previously only an internal-LLM tool; the MCP surface didn't have
them. Wraps services/events.py.
Date/time inputs are split: start_date (YYYY-MM-DD) + start_time (HH:MM) get
combined into a naive datetime; the service layer interprets it in the user's
local timezone. duration_minutes=0 ⇒ point event (NULL duration). The
LLM-era expected_weekday verification check is intentionally not replicated —
Claude doesn't need it.
For update, sentinels:
- title="" / location="" / description="" → leave unchanged
- start_date="" / start_time="" → leave unchanged (both must be provided to
move the event)
- duration_minutes=-1 → leave unchanged; 0 means "set to point event"
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from scribe.mcp._context import current_user_id
from scribe.services import events as events_svc
from scribe.services import trash as trash_svc
def _combine(start_date: str, start_time: str) -> datetime:
"""Combine YYYY-MM-DD + HH:MM into a naive datetime.
The events service interprets naive datetimes for create/update against
the user's configured timezone, so we don't attach tzinfo here.
"""
t = start_time or "00:00"
return datetime.fromisoformat(f"{start_date}T{t}:00")
def _day_range_utc(date_from: str, date_to: str) -> tuple[datetime, datetime]:
"""Return a UTC datetime range [start_of_date_from, end_of_date_to).
Event.start_dt is stored timezone-aware in the DB; comparing it against a
naive datetime raises TypeError. We anchor the range in UTC, which is a
reasonable default — refining to the user's local timezone for the
range boundaries is a separate improvement.
"""
start = datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
# `date_to` is inclusive at the day level — bump by 24h so events later
# on date_to are included.
end = (
datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc)
+ timedelta(days=1)
)
return start, end
def _event_dict(event) -> dict:
"""Render an Event model to a dict, handling list_events (already dicts)."""
return event if isinstance(event, dict) else event.to_dict()
async def list_events(date_from: str, date_to: str) -> dict:
"""List events between date_from and date_to (YYYY-MM-DD, both inclusive at
the day level — `date_to` is interpreted as end-of-that-day).
Recurring events are expanded into individual occurrences within the range.
"""
uid = current_user_id()
start, end = _day_range_utc(date_from, date_to)
rows = await events_svc.list_events(uid, start, end)
return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
async def create_event(
title: str,
start_date: str,
start_time: str = "00:00",
duration_minutes: int = 0,
all_day: bool = False,
location: str = "",
description: str = "",
) -> dict:
"""Create a calendar event.
Args:
title: Event title (required).
start_date: YYYY-MM-DD.
start_time: HH:MM (24-hour). Ignored when all_day=True.
duration_minutes: 0 for a point event (no duration); otherwise minutes.
all_day: True to make this an all-day event.
location: Optional location string.
description: Optional longer description.
"""
uid = current_user_id()
event = await events_svc.create_event(
uid,
title=title,
start_dt=_combine(start_date, start_time),
duration_minutes=duration_minutes or None,
all_day=all_day,
location=location,
description=description,
)
return event.to_dict()
async def get_event(event_id: int) -> dict:
"""Fetch a single event by ID."""
uid = current_user_id()
event = await events_svc.get_event(uid, event_id)
if event is None:
raise ValueError(f"event {event_id} not found")
return event.to_dict()
async def update_event(
event_id: int,
title: str = "",
start_date: str = "",
start_time: str = "",
duration_minutes: int = -1,
location: str = "",
description: str = "",
) -> dict:
"""Update an event. Only explicitly provided fields are changed.
Args:
event_id: ID of the event to update.
title: New title; omit to leave unchanged.
start_date / start_time: BOTH must be set to move the event. Omit either
to leave the start_dt unchanged.
duration_minutes: -1 leaves unchanged; 0 sets to point event; any
positive value sets the duration.
location / description: omit to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if location:
fields["location"] = location
if description:
fields["description"] = description
if start_date and start_time:
fields["start_dt"] = _combine(start_date, start_time)
if duration_minutes >= 0:
# 0 means point event (NULL); positive sets a real duration.
fields["duration_minutes"] = duration_minutes or None
event = await events_svc.update_event(uid, event_id, **fields)
if event is None:
raise ValueError(f"event {event_id} not found")
return event.to_dict()
async def delete_event(event_id: int) -> dict:
"""Move a calendar event to the trash (recoverable). Restore via restore(batch_id)."""
uid = current_user_id()
batch = await trash_svc.delete(uid, "event", event_id)
if batch is None:
raise ValueError(f"event {event_id} not found")
return {"deleted_batch_id": batch,
"message": f"Event {event_id} moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
for fn in (
list_events,
create_event,
get_event,
update_event,
delete_event,
):
mcp.tool(name=fn.__name__)(fn)
+108
View File
@@ -0,0 +1,108 @@
"""Milestone CRUD MCP tools — thin wrappers over services/milestones.py.
Mirrors existing fable-mcp milestone tool contracts: list/create/update. The
existing surface has no fable_get_milestone or fable_delete_milestone — kept
that way for parity.
Sentinels:
- title="" / description="" / status="""leave unchanged" on update
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
- status="active" default on create
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc
from scribe.services import trash as trash_svc
async def list_milestones(project_id: int) -> dict:
"""List milestones for a Scribe project, ordered by order_index.
Returns id, title, description, status (active/done), order_index,
and task counts.
"""
uid = current_user_id()
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
return {"milestones": rows}
async def create_milestone(
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Scribe project.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
status: active (default) or done.
"""
uid = current_user_id()
milestone = await milestones_svc.create_milestone(
uid,
project_id=project_id,
title=title,
description=description or None,
status=status,
)
return milestone.to_dict()
async def update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Scribe milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to (preserved for API parity;
ownership scoping is enforced by user_id at the service layer).
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if status:
fields["status"] = status
if order_index >= 0:
fields["order_index"] = order_index
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
return milestone.to_dict()
async def delete_milestone(milestone_id: int) -> dict:
"""Move a milestone to the trash (recoverable). Its tasks go with it as one batch.
Restore via restore(batch_id)."""
uid = current_user_id()
batch = await trash_svc.delete(uid, "milestone", milestone_id)
if batch is None:
raise ValueError(f"milestone {milestone_id} not found")
return {"deleted_batch_id": batch,
"message": f"Milestone {milestone_id} + its tasks moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
for fn in (
list_milestones,
create_milestone,
update_milestone,
delete_milestone,
):
mcp.tool(name=fn.__name__)(fn)
+135
View File
@@ -0,0 +1,135 @@
"""Note CRUD MCP tools.
Thin wrappers over services/notes.py with is_task=False. Signatures mirror
the existing fable-mcp contracts exactly so client behavior is preserved.
Sentinel conventions (inherited from existing fable-mcp tools):
- `tag: str = ""` / `search_text: str = ""` — empty means "no filter"
- `project_id: int = 0` — on create: orphan note (no project); on update:
"leave unchanged" (there is no "remove from project" through this tool,
which is a pre-existing limitation)
- `title: str = ""` / `body: str = ""` on update — empty means "leave unchanged"
- `tags: list[str] | None = None` — None means "leave unchanged"; [] clears
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import notes as notes_svc
from scribe.services import trash as trash_svc
async def list_notes(
limit: int = 20,
offset: int = 0,
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes (non-task documents) stored in Scribe.
Optionally filter by a single tag (plain string, no # prefix) or a keyword
search against title and body. Results are ordered by last-updated descending.
Use search for semantic/meaning-based lookup instead of exact keyword search.
"""
uid = current_user_id()
rows, total = await notes_svc.list_notes(
uid,
q=search_text or None,
tags=[tag] if tag else None,
is_task=False,
limit=max(1, min(limit, 100)),
offset=max(0, offset),
)
return {"notes": [n.to_dict() for n in rows], "total": total}
async def get_note(note_id: int) -> dict:
"""Fetch the full content of a single Scribe note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
"""
uid = current_user_id()
note = await notes_svc.get_note(uid, note_id)
if note is None:
raise ValueError(f"note {note_id} not found")
return note.to_dict()
async def create_note(
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Scribe.
Args:
title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
Returns the created note object including its assigned id.
"""
uid = current_user_id()
note = await notes_svc.create_note(
uid,
title=title,
body=body,
tags=tags,
project_id=project_id or None,
)
return note.to_dict()
async def update_note(
note_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Scribe note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association. Omit (or pass 0) to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if body:
fields["body"] = body
if tags is not None:
fields["tags"] = tags
if project_id:
fields["project_id"] = project_id
note = await notes_svc.update_note(uid, note_id, **fields)
if note is None:
raise ValueError(f"note {note_id} not found")
return note.to_dict()
async def delete_note(note_id: int) -> dict:
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
uid = current_user_id()
batch = await trash_svc.delete(uid, "note", note_id)
if batch is None:
raise ValueError(f"note {note_id} not found")
return {"deleted_batch_id": batch,
"message": f"Note {note_id} moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
for fn in (
list_notes,
get_note,
create_note,
update_note,
delete_note,
):
mcp.tool(name=fn.__name__)(fn)
+91
View File
@@ -0,0 +1,91 @@
"""Stored-process MCP tools: reusable saved prompts (note_type='process').
A process is a Note whose body is a prompt the operator fires later
("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly.
get_process is the fire mechanism: it returns the full prompt for Claude to run.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
"""List stored processes (reusable saved prompts).
Args:
q: Free-text search across title + body (optional).
tag: Filter to a single tag (optional).
limit: Max results (1-100).
Returns {"processes": [{id, title, tags, preview}], "total": int}.
"""
uid = current_user_id()
items, total = await knowledge_svc.query_knowledge(
user_id=uid, note_type="process", tags=[tag] if tag else [],
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
)
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
"preview": it.get("snippet", "")} for it in items]
return {"processes": procs, "total": total}
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
"""Create a stored process (a reusable saved prompt).
Args:
title: Process name, e.g. "Drift Audit" (required).
body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix.
"""
if not (title or "").strip() or not (body or "").strip():
raise ValueError("create_process requires a non-empty title and body")
uid = current_user_id()
note = await notes_svc.create_note(
uid, title=title.strip(), body=body, note_type="process", tags=tags,
)
return note.to_dict()
async def get_process(name_or_id: str) -> dict:
"""Fetch a stored process by name or id and return its full prompt — the
fire mechanism. The operator says "run the <name> process"; call this and
follow the returned body (including any 'clarify first' steps it contains).
Resolution: numeric id → exact (case-insensitive) title → substring. On an
ambiguous substring match, the best (most-recent) match is returned with an
`other_matches` list so you can disambiguate with the operator.
"""
uid = current_user_id()
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
if note is None:
raise ValueError(f"process {name_or_id!r} not found")
out = note.to_dict()
if candidates:
out["other_matches"] = candidates
return out
async def update_process(process_id: int, title: str = "", body: str = "",
tags: list[str] | None = None) -> dict:
"""Update a stored process. Only provided fields change — empty title/body
leave that field unchanged; pass tags to replace the tag set."""
uid = current_user_id()
note = await notes_svc.get_note(uid, process_id)
if note is None or note.note_type != "process":
raise ValueError(f"process {process_id} not found")
fields: dict = {}
if title.strip():
fields["title"] = title.strip()
if body.strip():
fields["body"] = body
if tags is not None:
fields["tags"] = tags
updated = await notes_svc.update_note(uid, process_id, **fields)
return updated.to_dict()
def register(mcp) -> None:
for fn in (list_processes, create_process, get_process, update_process):
mcp.tool(name=fn.__name__)(fn)
+216
View File
@@ -0,0 +1,216 @@
"""Project CRUD MCP tools — thin wrappers over services/projects.py.
Mirrors existing fable-mcp project tool contracts. Note: there is no
fable_delete_project here (matches existing fable-mcp surface). To stop
working on a project, update its status to 'archived'.
The LLM-era similarity-check / 'confirmed' guard from services/tools/projects.py
is intentionally NOT replicated here — Claude is the client, not a weak local
model that needs that guardrail. services.projects.create_project creates
directly with no similarity warning.
The auto-summary regeneration that services.projects.update_project triggers
async will be removed in Phase 7 (it's an LLM call). The wrapper makes no
assumption either way; once the service-layer side effect is gone, this code
keeps working.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
async def list_projects() -> dict:
"""List all Scribe projects for the current user.
Returns id, title, description, goal, status (active/paused/completed/archived), color,
and a short auto-generated summary for each project.
"""
uid = current_user_id()
rows = await projects_svc.list_projects(uid)
return {"projects": [p.to_dict() for p in rows]}
async def enter_project(project_id: int) -> dict:
"""Session-start handshake: load full context for working on a project.
Call this FIRST whenever you're about to do project-scoped work
(start_planning, create_task, update_*, anything that takes a project_id).
One round-trip returns the project, its applicable rules (both rulebook-
subscribed and project-scoped), milestone progress, open tasks, and
recently-updated notes — everything you need to know the lay of the land
before mutating.
No persistent server state: this is a read snapshot. Re-call if the
session goes idle long enough that the data feels stale.
Args:
project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
milestone_summary = await milestones_svc.get_project_milestone_summary(
uid, project_id,
)
open_tasks, _ = await notes_svc.list_notes(
uid, is_task=True, project_id=project_id,
status=["todo", "in_progress"], sort="updated_at", limit=10,
)
recent_notes, _ = await notes_svc.list_notes(
uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5,
)
return {
"project": project.to_dict(),
"milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"open_tasks": [
{
"id": t.id, "title": t.title, "status": t.status,
"priority": t.priority, "task_kind": t.task_kind,
"milestone_id": t.milestone_id,
}
for t in open_tasks
],
"recent_notes": [
{
"id": n.id, "title": n.title,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in recent_notes
],
}
async def get_project(project_id: int) -> dict:
"""Fetch a Scribe project by ID.
Returns full project fields, a milestone_summary list, and the
rulebook-applicable_rules / subscribed_rulebooks pair the assistant
should consult when working on this project.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
data = project.to_dict()
data["milestone_summary"] = await milestones_svc.get_project_milestone_summary(
uid, project_id,
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
data["applicable_rules"] = applicable["rules"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data
async def create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Scribe.
Args:
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
"""
uid = current_user_id()
project = await projects_svc.create_project(
uid,
title=title,
description=description,
goal=goal,
status=status,
color=color or None,
)
return project.to_dict()
async def update_project(
project_id: int,
title: str = "",
description: str = "",
goal: str = "",
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Scribe project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — one of active, paused, completed, archived.
color: New hex colour, or omit to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if goal:
fields["goal"] = goal
if status:
fields["status"] = status
if color:
fields["color"] = color
project = await projects_svc.update_project(uid, project_id, **fields)
if project is None:
raise ValueError(f"project {project_id} not found")
return project.to_dict()
async def delete_project(project_id: int) -> dict:
"""Move a project to the trash (recoverable). Its milestones, tasks, and notes
go with it as one batch. Restore via restore(batch_id)."""
uid = current_user_id()
batch = await trash_svc.delete(uid, "project", project_id)
if batch is None:
raise ValueError(f"project {project_id} not found")
return {"deleted_batch_id": batch,
"message": f"Project {project_id} + its contents moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
for fn in (
list_projects,
enter_project,
get_project,
create_project,
update_project,
delete_project,
):
mcp.tool(name=fn.__name__)(fn)
+84
View File
@@ -0,0 +1,84 @@
"""get_recent — cross-type recent-activity tool.
Returns the most-recently-touched notes, tasks, projects, and events for the
user, ordered by updated_at descending. Useful for Claude to bootstrap context
at the start of a conversation ("what was I working on?").
Aggregation is Python-side after three small per-table queries — simpler than
a UNION ALL with type-discriminating columns, and fine for personal-scale data.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from scribe.mcp._context import current_user_id
from scribe.models import async_session
from scribe.models.event import Event
from scribe.models.note import Note
from scribe.models.project import Project
async def get_recent(days: int = 7, limit: int = 25) -> dict:
"""Return recently-touched items across notes, tasks, projects, events.
Args:
days: Look-back window in days (1-90).
limit: Maximum number of items returned (1-100).
Returns:
{"items": [{"id", "type", "title", "updated_at"}], "total": int}
Sorted by updated_at descending.
"""
uid = current_user_id()
days = max(1, min(days, 90))
limit = max(1, min(limit, 100))
since = datetime.now(timezone.utc) - timedelta(days=days)
items: list[dict] = []
async with async_session() as session:
notes = (await session.execute(
select(Note).where(Note.user_id == uid, Note.updated_at >= since,
Note.deleted_at.is_(None))
.order_by(Note.updated_at.desc()).limit(limit)
)).scalars().all()
for n in notes:
items.append({
"id": n.id,
"type": "task" if n.is_task else "note",
"title": n.title,
"updated_at": n.updated_at.isoformat(),
})
projects = (await session.execute(
select(Project).where(Project.user_id == uid,
Project.updated_at >= since,
Project.deleted_at.is_(None))
.order_by(Project.updated_at.desc()).limit(limit)
)).scalars().all()
for p in projects:
items.append({
"id": p.id,
"type": "project",
"title": p.title,
"updated_at": p.updated_at.isoformat(),
})
events = (await session.execute(
select(Event).where(Event.user_id == uid,
Event.updated_at >= since,
Event.deleted_at.is_(None))
.order_by(Event.updated_at.desc()).limit(limit)
)).scalars().all()
for e in events:
items.append({
"id": e.id,
"type": "event",
"title": e.title,
"updated_at": e.updated_at.isoformat(),
})
items.sort(key=lambda r: r["updated_at"], reverse=True)
items = items[:limit]
return {"items": items, "total": len(items)}
def register(mcp) -> None:
mcp.tool(name="get_recent")(get_recent)
+433
View File
@@ -0,0 +1,433 @@
"""MCP tools for the Scribe Rulebook system.
Sixteen tools: rulebook/topic/rule CRUD + subscription management. Thin
wrappers over services/rulebooks.py — ownership is enforced in the service.
Destructive ops (delete_*) require confirmed=True; otherwise return a
preview-style warning. Mirrors the pattern in delete_event and the design
spec.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
# ── Rulebook CRUD ───────────────────────────────────────────────────────
async def list_rulebooks() -> dict:
"""List all rulebooks owned by the current user.
Returns id, title, description for each.
"""
uid = current_user_id()
rows = await rulebooks_svc.list_rulebooks(uid)
return {"rulebooks": [rb.to_dict() for rb in rows]}
async def get_rulebook(rulebook_id: int) -> dict:
"""Fetch a rulebook by id with its full topic list."""
uid = current_user_id()
rb = await rulebooks_svc.get_rulebook(rulebook_id, uid)
if rb is None:
raise ValueError(f"rulebook {rulebook_id} not found")
topics = await rulebooks_svc.list_topics(rulebook_id, uid)
data = rb.to_dict()
data["topics"] = [t.to_dict() for t in topics]
return data
async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook.
Args:
title: Rulebook name (e.g. "FabledSword family").
description: Optional short description of what this rulebook covers.
"""
uid = current_user_id()
rb = await rulebooks_svc.create_rulebook(
user_id=uid, title=title, description=description,
)
return rb.to_dict()
async def update_rulebook(
rulebook_id: int, title: str = "", description: str = "",
always_on: bool | None = None,
) -> dict:
"""Update an existing rulebook. Only non-empty fields are changed.
Args:
rulebook_id: Rulebook to update.
title: New title. Empty string leaves unchanged.
description: New description. Empty string leaves unchanged.
always_on: When True, rules in this rulebook are loaded at session
start by list_always_on_rules regardless of project context.
Pass None to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if always_on is not None:
fields["always_on"] = always_on
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
if rb is None:
raise ValueError(f"rulebook {rulebook_id} not found")
return rb.to_dict()
async def delete_rulebook(rulebook_id: int, confirmed: bool = False) -> dict:
"""Permanently delete a rulebook (cascades to all its topics and rules).
Pass confirmed=True to actually delete. Without confirmation, returns a
preview describing what will be cascaded.
"""
uid = current_user_id()
rb = await rulebooks_svc.get_rulebook(rulebook_id, uid)
if rb is None:
raise ValueError(f"rulebook {rulebook_id} not found")
if not confirmed:
topics = await rulebooks_svc.list_topics(rulebook_id, uid)
rule_count = 0
for t in topics:
rule_count += len(await rulebooks_svc.list_rules(uid, topic_id=t.id))
return {
"warning": (
f"Rulebook {rulebook_id} ('{rb.title}') contains "
f"{len(topics)} topics and {rule_count} rules; all will be "
f"deleted. Pass confirmed=True to proceed."
),
"confirmed_required": True,
}
batch = await trash_svc.delete(uid, "rulebook", rulebook_id)
return {"deleted": rulebook_id, "deleted_batch_id": batch,
"message": f"Moved to trash. Restore with restore('{batch}')."}
# ── Topic CRUD ─────────────────────────────────────────────────────────
async def list_topics(rulebook_id: int) -> dict:
"""List topics inside a rulebook."""
uid = current_user_id()
rows = await rulebooks_svc.list_topics(rulebook_id, uid)
return {"topics": [t.to_dict() for t in rows]}
async def create_topic(
rulebook_id: int, title: str,
description: str = "", order_index: int = 0,
) -> dict:
"""Create a topic within a rulebook.
Args:
rulebook_id: Rulebook to add the topic to.
title: Topic name (e.g. "git-workflow").
description: Optional description.
order_index: Display order (0-based; default 0).
"""
uid = current_user_id()
topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id, user_id=uid,
title=title, description=description, order_index=order_index,
)
return topic.to_dict()
async def update_topic(
topic_id: int, title: str = "",
description: str = "", order_index: int = -1,
) -> dict:
"""Update a topic. Sentinels: title="" / description="" leave unchanged;
order_index=-1 leaves unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if order_index >= 0:
fields["order_index"] = order_index
topic = await rulebooks_svc.update_topic(topic_id, uid, **fields)
if topic is None:
raise ValueError(f"topic {topic_id} not found")
return topic.to_dict()
async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
"""Delete a topic and all its rules. Requires confirmed=True."""
uid = current_user_id()
topic = await rulebooks_svc.get_topic(topic_id, uid)
if topic is None:
raise ValueError(f"topic {topic_id} not found")
if not confirmed:
rules = await rulebooks_svc.list_rules(uid, topic_id=topic_id)
return {
"warning": (
f"Topic {topic_id} ('{topic.title}') contains {len(rules)} "
f"rules; all will be deleted. Pass confirmed=True to proceed."
),
"confirmed_required": True,
}
batch = await trash_svc.delete(uid, "topic", topic_id)
return {"deleted": topic_id, "deleted_batch_id": batch,
"message": f"Moved to trash. Restore with restore('{batch}')."}
# ── Rule CRUD ──────────────────────────────────────────────────────────
async def list_rules(
rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
) -> dict:
"""List rules — filter by rulebook, topic, and/or project.
Args:
rulebook_id: 0 = no filter; positive = restrict to that rulebook.
topic_id: 0 = no filter; positive = restrict to that topic.
project_id: 0 = no filter; positive = restrict to rules applicable
to that project (via its rulebook subscriptions).
All filters are AND-combined; ownership-scoped.
"""
uid = current_user_id()
rows = await rulebooks_svc.list_rules(
user_id=uid,
rulebook_id=rulebook_id or None,
topic_id=topic_id or None,
project_id=project_id or None,
)
return {
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rows
],
"total": len(rows),
}
async def list_always_on_rules() -> dict:
"""Return all rules from rulebooks flagged always_on for the current user.
Call this at session start. Treat the returned rules as binding for the
session — they apply regardless of which project (if any) is in scope.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid)
return {
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rules
],
"total": len(rules),
}
async def get_rule(rule_id: int) -> dict:
"""Fetch a rule by id — full statement + why + how_to_apply."""
uid = current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
return rule.to_dict()
async def create_rule(
topic_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a new rule under a topic (cross-project rulebook rule).
Args:
topic_id: The topic to attach the rule to.
title: A short imperative title (e.g. "dev is home").
statement: The actionable instruction (required). 1-2 sentences.
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the topic (default 0).
For a rule that applies to a single project only, use create_project_rule
instead — no rulebook+topic ceremony required.
"""
uid = current_user_id()
rule = await rulebooks_svc.create_rule(
topic_id=topic_id, user_id=uid,
title=title, statement=statement,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return rule.to_dict()
async def create_project_rule(
project_id: int, statement: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
Use this when a rule only applies to one project — it bypasses the
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
applicable_rules (under project_rules) and in list_rules(project_id=...).
Args:
project_id: The project to attach the rule to.
statement: The actionable instruction (required). 1-2 sentences.
title: Short imperative title. If empty, derived from the first ~50
characters of statement.
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the project's rule list (default 0).
"""
uid = current_user_id()
derived_title = title.strip() or statement.strip().split(".")[0][:50]
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return rule.to_dict()
async def update_rule(
rule_id: int, title: str = "", statement: str = "",
why: str = "", how_to_apply: str = "", order_index: int = -1,
) -> dict:
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged."""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if statement:
fields["statement"] = statement
if why:
fields["why"] = why
if how_to_apply:
fields["how_to_apply"] = how_to_apply
if order_index >= 0:
fields["order_index"] = order_index
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
return rule.to_dict()
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
uid = current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
if not confirmed:
return {
"warning": (
f"Rule {rule_id} ('{rule.title}') will be moved to the trash "
f"(recoverable via restore). Pass confirmed=True to proceed."
),
"confirmed_required": True,
}
batch = await trash_svc.delete(uid, "rule", rule_id)
return {"deleted": rule_id, "deleted_batch_id": batch,
"message": f"Moved to trash. Restore with restore('{batch}')."}
# ── Subscriptions ──────────────────────────────────────────────────────
async def subscribe_project_to_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Subscribe a project to a rulebook. Its rules will apply to that project."""
uid = current_user_id()
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": True}
async def unsubscribe_project_from_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Remove a project's subscription to a rulebook."""
uid = current_user_id()
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Mute a single rulebook rule for one project.
The rule stays in its rulebook for other projects; only this project
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
Project-scoped rules (create_project_rule) are NOT suppressible — delete
them with delete_rule instead.
"""
uid = current_user_id()
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
async def unsuppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
async def suppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Mute every rule under a topic for one project.
Equivalent to suppressing each rule in the topic individually, but
auto-includes new rules added to the topic later. Idempotent.
"""
uid = current_user_id()
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
async def unsuppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
def register(mcp) -> None:
for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic,
list_rules, list_always_on_rules, get_rule,
create_rule, create_project_rule, update_rule, delete_rule,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
):
mcp.tool(name=fn.__name__)(fn)
+49
View File
@@ -0,0 +1,49 @@
"""search — semantic search across the user's notes and tasks.
Mirrors the existing fable-mcp contract so Claude's prior usage pattern keeps
working. Differences from fable-mcp:
- calls services.embeddings.semantic_search_notes directly instead of HTTP
- user_id comes from mcp.current_user_id() rather than a global API key
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services.embeddings import semantic_search_notes
async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
"""Semantic search over the user's notes and tasks.
Args:
q: search query string.
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
limit: maximum number of results (1-50).
Returns:
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
"total": int}
"""
uid = current_user_id()
limit = max(1, min(limit, 50))
is_task = {"note": False, "task": True}.get(content_type) # None => any
raw = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task,
)
return {
"results": [
{
"id": note.id,
"title": note.title,
"body": (note.body or "")[:240],
"is_task": bool(note.is_task),
"tags": list(note.tags or []),
"similarity": float(score),
}
for score, note in raw
],
"total": len(raw),
}
def register(mcp) -> None:
mcp.tool(name="search")(search)
+54
View File
@@ -0,0 +1,54 @@
"""list_tags — return the user's tag vocabulary with usage counts.
Python-side aggregation rather than SQL UNNEST. Personal-scale (~thousands of
notes) makes the perf cost negligible, and a one-pass dict counter is much
easier to mock in tests than a GROUP BY-with-unnest expression.
"""
from __future__ import annotations
from sqlalchemy import select
from scribe.mcp._context import current_user_id
from scribe.models import async_session
from scribe.models.note import Note
def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
"""Count occurrences across a sequence of (possibly-None) tag lists.
Extracted so the aggregation logic can be unit-tested without a DB.
"""
counts: dict[str, int] = {}
for tags in tag_lists:
for tag in (tags or []):
counts[tag] = counts.get(tag, 0) + 1
return counts
async def list_tags(limit: int = 50) -> dict:
"""Return the user's most-used tags with usage counts.
Args:
limit: Maximum number of tags to return (1-200).
Returns:
{"tags": [{"tag": "ops", "count": 12}, ...], "total": int}
Sorted by count descending.
"""
uid = current_user_id()
limit = max(1, min(limit, 200))
async with async_session() as session:
result = await session.execute(
select(Note.tags).where(Note.user_id == uid, Note.deleted_at.is_(None))
)
tag_lists = [row[0] for row in result.all()]
counts = _aggregate_tag_counts(tag_lists)
top = sorted(counts.items(), key=lambda x: x[1], reverse=True)[:limit]
return {
"tags": [{"tag": t, "count": c} for t, c in top],
"total": len(top),
}
def register(mcp) -> None:
mcp.tool(name="list_tags")(list_tags)
+235
View File
@@ -0,0 +1,235 @@
"""Task CRUD MCP tools.
Tasks are notes with a non-null `status` — same model, different filter.
Wrappers call services/notes.py for CRUD with is_task=True and add the
task-specific fields (status, priority, due_date, parent_id), plus
services/task_logs.py for add_task_log.
There is no delete_task — matches the existing fable-mcp surface.
Cancel by updating status to "cancelled".
Sentinels (preserved from existing fable-mcp):
- status="" / priority="" / title="" / body="""leave unchanged" on update
- status="todo" is the default on create (creates a task; non-null status is
what makes a Note a Task)
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import notes as notes_svc
from scribe.services import planning as planning_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc
async def list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
kind: str = "",
) -> dict:
"""List tasks in Scribe.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds.
Results are ordered by last-updated descending.
"""
uid = current_user_id()
rows, total = await notes_svc.list_notes(
uid,
is_task=True,
status=status or None,
project_id=project_id or None,
task_kind=kind or None,
limit=max(1, min(limit, 100)),
offset=max(0, offset),
)
return {"tasks": [n.to_dict() for n in rows], "total": total}
async def get_task(task_id: int) -> dict:
"""Fetch a single Scribe task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at. For kind=plan
tasks, the response also includes applicable_rules + subscribed_rulebooks
from the task's project's rulebook subscriptions.
"""
uid = current_user_id()
note = await notes_svc.get_note(uid, task_id)
if note is None:
raise ValueError(f"task {task_id} not found")
data = note.to_dict()
parent_title = None
if note.parent_id:
parent = await notes_svc.get_note(uid, note.parent_id)
if parent is not None:
parent_title = parent.title
data["parent_title"] = parent_title
if data.get("task_kind") == "plan" and note.project_id:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid,
)
data["applicable_rules"] = applicable["rules"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data
async def create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
kind: str = "work",
) -> dict:
"""Create a new task in Scribe.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, medium, high, or 'none'. Omit (empty string) to leave unset.
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
kind: 'work' (default) or 'plan'. Prefer the start_planning tool to create plans.
"""
uid = current_user_id()
note = await notes_svc.create_note(
uid,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
task_kind=kind,
)
return note.to_dict()
async def update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Scribe task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled. Drive
the lifecycle: set in_progress when you start, done when complete —
don't leave finished work at todo.
priority: New priority — one of: none, low, medium, high.
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
its project; also clears the milestone), positive = set.
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
from its milestone), positive = set.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if body:
fields["body"] = body
if status:
fields["status"] = status
if priority:
fields["priority"] = priority
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
if project_id == -1:
fields["project_id"] = None
fields["milestone_id"] = None # a milestone can't outlive its project
elif project_id:
fields["project_id"] = project_id
if milestone_id == -1:
fields["milestone_id"] = None
elif milestone_id:
fields["milestone_id"] = milestone_id
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
raise ValueError(f"task {task_id} not found")
return note.to_dict()
async def add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Scribe task.
Use this to record work sessions, decisions, or status updates over time
without overwriting the task's main body. Each entry is stored separately
and shown chronologically in the task view.
"""
uid = current_user_id()
log = await task_logs_svc.create_log(uid, task_id, content)
return log.to_dict() if hasattr(log, "to_dict") else {
"id": log.id, "task_id": log.task_id, "content": log.content,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
async def start_planning(project_id: int, title: str) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
Creates a plan-task (a task with kind=plan) seeded with a plan template under
the given project, and returns it together with the project's applicable
Rulebook rules and brief context. Maintain the plan afterwards with the normal
task tools (update_task to edit the body, add_task_log to record progress).
Args:
project_id: The project this plan is for.
title: A short title for the plan.
"""
uid = current_user_id()
return await planning_svc.start_planning(
user_id=uid, project_id=project_id, title=title,
)
async def delete_task(task_id: int) -> dict:
"""Move a Scribe task (or plan) to the trash (recoverable). Sub-tasks go with it.
Restore via restore(batch_id)."""
uid = current_user_id()
batch = await trash_svc.delete(uid, "task", task_id)
if batch is None:
raise ValueError(f"task {task_id} not found")
return {"deleted_batch_id": batch,
"message": f"Task {task_id} moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
for fn in (
list_tasks,
get_task,
create_task,
update_task,
add_task_log,
start_planning,
delete_task,
):
mcp.tool(name=fn.__name__)(fn)
+48
View File
@@ -0,0 +1,48 @@
"""Trash MCP tools — unified, batch-based recovery surface.
Deletes go through the per-type delete_* tools (which soft-delete via the trash
service). These tools list / restore / permanently-purge trashed content by
the deleted_batch_id that a delete operation returns.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import trash as trash_svc
async def list_trash() -> dict:
"""List trashed items, grouped by the deletion batch they were removed in.
Each batch has: batch_id, deleted_at, a summary (the lead item's title),
count, and the member items (type + id + title). Restore a batch with
restore(deleted_batch_id).
"""
return {"batches": await trash_svc.list_trash(current_user_id())}
async def restore(deleted_batch_id: str) -> dict:
"""Restore a whole deletion batch from the trash (un-delete)."""
n = await trash_svc.restore(current_user_id(), deleted_batch_id)
return {"restored": n, "batch_id": deleted_batch_id}
async def purge_trash(deleted_batch_id: str, confirmed: bool = False) -> dict:
"""Permanently delete a trashed batch. IRREVERSIBLE — pass confirmed=True.
This is the only true hard-delete tool; everything else is recoverable.
"""
if not confirmed:
return {
"warning": (
f"Permanently delete batch {deleted_batch_id}? This cannot be "
f"undone. Pass confirmed=True to proceed."
),
"confirmed_required": True,
}
n = await trash_svc.purge(current_user_id(), deleted_batch_id)
return {"purged": n, "batch_id": deleted_batch_id}
def register(mcp) -> None:
for fn in (list_trash, restore, purge_trash):
mcp.tool(name=fn.__name__)(fn)