Task 2 of #582. New mcp/tools/processes.py mirrors entities.py — tools wrap
notes_svc directly. get_process is the fire mechanism (returns the full prompt
via resolve_process; surfaces other_matches on an ambiguous name). Registered
in register_all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 1 of the Stored Processes plan (#582). resolve_process(user_id, name_or_id)
resolves a note_type=process note owner-scoped + non-trashed, precedence
numeric id -> exact case-insensitive title -> substring; returns
(note, other_candidates) so an ambiguous fuzzy match can be disambiguated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI fix for aef5009: test_resolve_bearer_none_for_invalid referenced
resolve_bearer but the import lived inside an earlier test only. Hoist it
to the module import. Production code unaffected (1 failed / 284 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drift-audit Group 5 (high-severity contract drift):
- MCP read-only keys could call every write tool: the Bearer resolver
discarded api_key.scope and dispatch had no gate. Add resolve_bearer()
(returns user_id + scope) and a scope gate in the /mcp ASGI wrapper that
buffers the JSON-RPC body and rejects tools/call for any tool outside a
read all-list when scope=='read' (default-deny for unknown/new tools).
- Shared project notes/tasks panel was empty for non-owners: get_project_notes_route
now queries notes/milestones with the project OWNER's uid (mirrors the
already-fixed milestones route).
- Shared editors couldn't save/delete shared NOTES (tasks worked): the three
notes write routes now resolve via get_note_for_user, gate on can_write_note,
and write as the owner — matching the tasks routes.
- Event timezone drift: naive datetimes from the MCP date+time split are now
localized to the user's tz at a single canonical service point (create_event
/update_event), so MCP- and UI-created events agree. tz-aware inputs
(REST/CalDAV) pass through untouched.
- create_note validates status/priority (TaskStatus/TaskPriority), closing the
MCP create_task path that let out-of-enum values persist (no DB CHECK).
Tests cover resolve_bearer scope + the write-tool classifier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drift-audit Group 3 (soft-delete lifecycle gaps). Trashed rows were
leaking into reads and being mutated/resurrected by writes:
- update SELECTs now exclude trashed rows: update_milestone,
update_project, update_event, and get_milestone_in_project (the latter
backs all four milestone routes). Mutating a trashed row silently
persisted and reappeared on restore.
- MCP get_recent (notes/projects/events) and list_tags now filter
deleted_at IS NULL, so trashed items stop surfacing in the agent's
bootstrap context and tag counts.
- convert_task_to_note clears recurrence_rule + recurrence_next_spawn_at
so a demoted note can't spawn children via the (now-live) sweep.
- caldav pull skips locally-trashed events (by caldav_uid) instead of
resurrecting them via update or creating a duplicate live copy.
- trash _cascade now stamps the FULL sub-task subtree (iterative descent),
not just direct children, so deeply nested sub-tasks restore as one
batch. Test updated for the new descent query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drift-audit Group 1 (authz/IDOR). Multi-user is live, so these were
exploitable ACL bypasses:
- trash.py: add _owner_clause() and apply it to _exists_alive, restore,
purge, list_trash, and purge_expired. A batch_id is a bearer token;
without an owner predicate a leaked/guessed id let one tenant read
(list_trash), restore, or PERMANENTLY purge another's content. Topics
and rules carried no owner check at all (_OWNER mapped them to None) —
ownership now derives through the parent rulebook (or owning project,
for project-scoped rules).
- purge_expired is now per-user; trash_scheduler iterates every user and
applies that user's own trash_retention_days window, instead of
applying user 1's window to everyone (early data loss for other users).
- rulebooks subscribe/unsubscribe_project now assert project ownership,
matching the suppression endpoints.
- topic/rule DELETE routes return 404 when nothing owned was removed.
Regression test locks in that every model — including topics/rules —
gets a real owner clause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a project mute individual rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two new association
tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic),
4 REST endpoints, and an inline "× skip" affordance plus collapsed
"Suppressed (N)" section in the project's Rules tab.
get_applicable_rules now emits suppressed_rules and suppressed_topics
(detail objects with rulebook/topic context, not just IDs) so the UI
can render the suppressed list without a follow-up lookup. The main
rules projection grew topic_id and rulebook_id columns for the per-row
suppress affordance.
Project deletion cascades the suppression rows via hard DELETE — they
are pure associations with no soft-delete column, and restoring a
deleted project should start fresh, not inherit stale mutes.
Project-scoped rules (Rule.project_id) are deliberately not suppressible
— delete them with delete_rule instead.
Implements plan-task #187.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New enter_project(project_id) MCP tool composes get_project +
get_applicable_rules + get_project_milestone_summary + recent
open-tasks + recent notes into one round-trip, intended to be called
at session start (or whenever the active project changes) so Claude
has the full project context loaded before it starts mutating.
_INSTRUCTIONS now points Claude at enter_project for project-scoped
work, alongside the existing list_always_on_rules instruction. No
schema change; pure composition over existing services.
Closes the four-slice rules-consolidation plan (Scribe task #508):
S1+S2 (always_on flag + Scribe-first prompt, 658348f), S3 (project-
scoped rules, 43a860c), and now S4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rules can now belong to either a rulebook topic OR a single project,
enforced by a CHECK constraint (exactly-one of topic_id/project_id).
Adds the create_project_rule MCP tool + REST endpoint, surfaces
project-scoped rules in get_project/get_task/start_planning under a
new project_rules field, and adds a project Rules tab section with an
inline create form so the operator can author project rules from the
UI without rulebook ceremony.
- migration 0059: rules.project_id (FK projects ON DELETE CASCADE),
topic_id now nullable, CHECK ck_rule_topic_xor_project, index on
project_id
- model: Rule gains project_id; to_dict exposes it
- service: create_project_rule with project-ownership guard; list_rules
with project_id filter UNIONs subscription-derived + project-scoped;
get_applicable_rules adds a project_rules field; get_rule / update_rule
/ delete_rule fetch via a shared _fetch_owned_rule that handles both
rulebook and project ownership paths
- trash: project delete cascades to project-scoped rules
- MCP: create_project_rule tool registered; _INSTRUCTIONS mentions both
create_rule and create_project_rule paths
- REST: POST /api/projects/<id>/rules (statement required, title derived
if omitted)
- frontend: Rule type gains nullable topic_id + project_id; createProjectRule
client; ProjectRulesTab.vue gains a "Project rules" section with inline
create form and per-rule expand/delete
- tests: register count → 18; create_project_rule unit tests (required
fields, title derivation, explicit-title pass-through); applicable_rules
shape tests now include project_rules; trash cascade test updated to
expect 5 executions
S1+S2 (always_on flag + Scribe-first prompt) shipped in 658348f.
S4 (enter_project handshake) follows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds rulebooks.always_on (migration 0058) and a new list_always_on_rules
MCP tool so a session-start eager pull can fetch standing rules without
needing an active-project notion. Updates _INSTRUCTIONS so Claude calls
the new tool at session start and codifies engineering rules in Scribe
rather than CLAUDE.md / auto-memory.
Seeds FabledSword family rulebook to always_on=true on migrate, matching
its design role as the cross-project standards rulebook.
Frontend: badge in RulebookListPane for always-on rulebooks; toggle in
RulebookDetailPane header bound to a new toggleAlwaysOn store action.
This is S1+S2 of the rules-consolidation plan (Scribe task #508). S3
(project-scoped rules) and S4 (enter_project handshake) follow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tested that services/tools/_registry exposed event tools to the LLM
tool layer. That layer was removed in Phase 8 commit 91bafb6; event
CRUD is now covered via the MCP tools in test_mcp_tool_events.py.
Phase 6 smoke caught:
Error executing tool list_events:
can't compare offset-naive and offset-aware datetimes
Event.start_dt is stored timezone-aware; the wrapper was passing naive
datetimes built from datetime.fromisoformat("YYYY-MM-DD"), so the SQL
comparison crashed. Also: the docstring promises "date_to inclusive at
end-of-day" but the code was using midnight-of-date_to, which would
silently miss same-day events after midnight.
Extracted the range math into _day_range_utc() so create/update_event's
_combine() can stay as-is (it stays naive — the service localizes
create/update inputs against the user's tz, that path didn't crash).
Test updated to match: assert tz-aware UTC datetimes and the +24h
bump for end-of-day-inclusive semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FastMCP's transport_security module enforces a Host header as DNS-
rebinding protection. Raw-ASGI scope construction doesn't fill it in
automatically (real HTTP clients always send one), so the test
request was getting 421 Misdirected Request with a log warning:
Missing Host header in request
Production is unaffected — real curl, Claude Code, and any real
client send a Host header.
After fixing the /mcp path forwarding in 1fd303a, requests now reach
FastMCP — but its StreamableHTTPSessionManager raises:
RuntimeError: Task group is not initialized. Make sure to use run().
The session manager owns a task group that must be running before it
can handle requests. In a stand-alone Starlette app this happens via
the `lifespan` parameter (lifespan = session_manager.run). Hosted
inside Quart, my dispatch wrapper only forwards HTTP events, not
lifespan, so the manager never got its startup signal.
Fix: hook session_manager.run() (an async context manager) into
Quart's @app.before_serving and @app.after_serving so the task group
is alive across the serving window.
The CI integration test was hitting the same crash because it drives
app.asgi_app raw without going through Quart's serving lifecycle —
@before_serving never fires. Updated the test to manually enter
session_manager.run() around the request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dispatch wrapper was rewriting scope['path'] from '/mcp' to '/'
before handing off to FastMCP. But FastMCP's streamable_http_app
mounts the JSON-RPC handler at '/mcp' (its default), so the rewritten
'/' had no matching route and FastMCP returned 404. Auth middleware
was correctly firing first (a no-auth request still gets 401), the
bug was only on the post-auth path.
Symptom: `claude mcp add ...` succeeds, registration shows in
`claude mcp list`, but connection fails because the initialize
handshake returns 404 instead of an MCP capabilities response.
Fix: pass the scope through unmodified. FastMCP's own routing matches
the '/mcp' path.
Also tightened the integration test that should have caught this —
it was asserting `status != 401`, which a 404 trivially passes. Now
asserts `== 200`, the actual expected response for initialize.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MCP clients see tools namespaced by the server's local name already
(mcp__<server>__<tool>), so the fable_ prefix on every tool name was
redundant and ate tokens in the model's tool list.
Tools renamed (34 total):
fable_search → search
fable_list_notes / get_note / create_note / update_note / delete_note → list_notes / ...
fable_list_tasks / get_task / create_task / update_task / add_task_log → list_tasks / ...
fable_list_projects / get_project / create_project / update_project → list_projects / ...
fable_list_milestones / create_milestone / update_milestone → list_milestones / ...
fable_list_events / create_event / get_event / update_event / delete_event → list_events / ...
fable_list_tags → list_tags
fable_get_recent → get_recent
fable_list_persons / create_person / update_person → list_persons / ...
fable_list_places / create_place / update_place → list_places / ...
fable_list_lists / create_list / update_list → list_lists / ...
Also rebranded in MCP scope:
FastMCP("fable", ...) → FastMCP("scribe", ...)
auth realm "fable-mcp" → "scribe-mcp"
ASGI scope key fable_user_id → scribe_user_id
ContextVar label fable_mcp_user_id → scribe_mcp_user_id
Tool docstrings "in Fable" / "Fable task" → "in Scribe" / "Scribe task"
Server _INSTRUCTIONS prose
Deliberately kept:
- The internal Python package name `fabledassistant` (per project naming
convention — internal stays).
- "Fabled Scribe" as the official product/brand name (page footer,
smtp_from_name default).
- References to the legacy `fable-mcp/` standalone package in docstrings
explaining what we ported from — accurate until that directory is
deleted in Phase 10.
Client impact: existing MCP registrations need
claude mcp remove <name> && claude mcp add ...
once with a freshly-copied snippet from Settings → MCP Access. Claude
Code then re-discovers tools on connect — old conversations that
referenced fable_* tool names will see "tool not found" on those calls
until updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Ollama HTTP get_embedding with a fastembed.TextEmbedding
singleton loaded lazily on first call. Model: BAAI/bge-small-en-v1.5
(384-dim), cached to /data/fastembed-cache.
Public API unchanged:
- get_embedding(text, model=None) — `model` now silently ignored
- upsert_note_embedding
- semantic_search_notes
- backfill_note_embeddings
_cosine_similarity gains a defensive length-mismatch check so any
stale 768-dim row that survived the migration is treated as 0.0
similarity rather than crashing zip().
The Ollama client dep stays in pyproject for now (other services still
use it); Phase 7 removes it once chat/journal/curator are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine tools — list/create/update for each of person, place, list.
Get and delete reuse fable_get_note / fable_delete_note (typed
entities share the Note model).
Lists: the wrappers accept an `items: list[str]` for ergonomics and
translate to the {text, checked} dict shape that
services/knowledge.py and KnowledgeView.vue expect. items=[] clears;
items=None leaves unchanged.
Updates do an explicit get → merge → update round trip so updating
one typed field doesn't clobber the others stored alongside it in
entity_meta (which is a single JSONB column).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cross-type bootstrap tools:
- fable_list_tags: tag vocabulary with usage counts, top-N by count.
Aggregation in Python (not SQL UNNEST) — trivial perf cost at
personal scale, much easier to test.
- fable_get_recent: most-recently-touched items across notes, tasks,
projects, events. Useful for Claude to ask 'what was I working on
recently' at the start of a conversation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new tools (events weren't in fable-mcp before). Split
start_date + start_time inputs combine into a naive datetime that
services/events.py interprets in the user's local timezone.
Sentinels for update:
- empty strings → leave unchanged
- duration_minutes=-1 → leave unchanged
- duration_minutes=0 → set to point event (NULL duration)
- start_date/start_time must BOTH be set to move the event
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tools matching existing fable-mcp contracts:
- fable_list/get/create/update_project (no delete; archive via status)
- fable_list/create/update_milestone (no get; no delete)
LLM-era similarity-check / 'confirmed' guard for create_project is
NOT replicated — Claude doesn't need it. The service's auto-summary
regeneration side effect (services.projects.update_project) stays
for now; gets removed in Phase 7 along with all other LLM code.
Notable sentinels:
- update_milestone: order_index=-1 means "leave unchanged" (0 is valid)
- create_milestone: description="" becomes None at the service layer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tools wrapping services/notes.py with is_task=True (tasks are
notes with non-null status) plus services/task_logs.create_log for
add_task_log. Matches existing fable-mcp contracts. No delete_task —
preserves existing surface; cancel by updating status to "cancelled".
fable_get_task enriches with parent_title (extra service call when
parent_id is set), matching the existing route's behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tools wrapping services/notes.py with is_task=False. Signatures
mirror the existing fable-mcp note tool contracts so Claude usage is
unchanged.
Key behavior the tests pin down:
- list_notes repackages (rows, total) tuple into {notes, total}
- tag=""/search_text="" are "no filter" sentinels
- update_note ONLY sends non-default fields to the service (the
main risk: a default empty string overwriting real data)
- tags=[] is an explicit clear; tags=None is "leave unchanged"
- project_id=0 on create => orphan; on update => leave unchanged
(preserved limitation from existing fable-mcp)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establishes the tool pattern: each tool module exposes register(mcp),
register_all() aggregates them, build_mcp_server() calls register_all.
fable_search mirrors the existing fable-mcp contract (q/content_type/limit
in; {results, total} out) but calls services.embeddings.semantic_search_notes
directly instead of going over HTTP. User comes from mcp.current_user_id().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds mcp._context.current_user_id() backed by a ContextVar. The ASGI
auth middleware sets it before dispatching to FastMCP and resets it
on the way out, so tool handlers can read the acting user without
re-parsing the request scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Driving Quart's full request pipeline via a hand-rolled ASGI scope
(no lifespan startup, no hypercorn-provided state) doesn't produce
a response. The 3 remaining tests cover the actual MCP middleware
behavior. The bypass property is implicit — if the middleware ate
non-/mcp requests, every existing /api/* test would fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quart's test_client expects its request pipeline to populate
app._preserved_context. Our /mcp middleware deliberately bypasses
that pipeline (forwarding straight to FastMCP), so test_client's
teardown blew up with AttributeError. The middleware is correct;
the test harness was wrong.
Build raw ASGI scope/receive/send and call app.asgi_app directly —
which is what production hypercorn does anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires FastMCP's streamable-HTTP ASGI sub-app into the Quart app via
asgi_app replacement. Requests under /mcp are stripped, auth-checked
against api_keys, and forwarded to FastMCP with fable_user_id set on
the ASGI scope. All other paths pass through to the original Quart
dispatch unchanged.
Tests cover the three auth paths (no header, invalid token, valid
token) plus a regression check that non-/mcp paths bypass the MCP
dispatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>