From e3c6124912b73b44e7cf75b7b5ad30c8cd21a762 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 22:43:55 -0400 Subject: [PATCH] feat(mcp): make Scribe reflexively recall + scope reads to the active project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP surface advertised writing well but recall poorly, and project scoping had no anchor that survived past enter_project's snapshot: - search / list_notes dropped the project_id their services already support, so a scoped search was impossible — every query swept all projects and bled unrelated work into the session. - The tool descriptions were mechanical ("Semantic search over the user's notes and tasks") with no trigger telling Claude WHEN to reach for them; the server instructions were all write-discipline and said nothing about searching before answering or starting work. Changes: - search, list_notes: add project_id param, wired to the service. - search, list_notes, list_tasks: trigger-worded descriptions that push passing the active project's id and reserve project_id=0 for a deliberate cross-project sweep. - _INSTRUCTIONS: add a 'Reach for Scribe to RECALL, not just to record' block — search before answering/starting, check for an existing ticket before create_task, scope reads to the active project (which does not stick on the server). Paired with always-on rule #75 in the FabledSword-family rulebook. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/mcp/server.py | 16 ++++++++++++++++ src/scribe/mcp/tools/notes.py | 8 ++++++++ src/scribe/mcp/tools/search.py | 22 ++++++++++++++++++++-- src/scribe/mcp/tools/tasks.py | 5 ++++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 8a46ad8..5766704 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -39,6 +39,22 @@ Mechanics: "not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1 removes the task from its milestone); 0 leaves it unchanged. +Reach for Scribe to RECALL, not just to record. Scribe is a second brain — +its value is mostly in what it already holds, so make searching it a reflex, +not something you wait to be asked for: +- Before you answer a question about the user's work, or start a task, search + Scribe first (search / list_tasks / list_notes). Assume relevant prior art + already exists — a related ticket, an earlier decision, a dev-log — and look + before you re-derive it or open a duplicate. +- Before creating a task, search for an existing one (search content_type= + 'task') — don't open a second ticket for work already tracked. +- Scope reads to the project in scope. When a project is active (you called + enter_project), pass its project_id to search / list_tasks / list_notes so + results stay inside that project. Querying with no project_id pulls in every + project and bleeds unrelated work into the session — only do it for a + deliberate cross-project sweep. The active project does not stick on the + server (each call is self-contained); carrying its id forward is on you. + Keep task state honest — this is what makes the project a trustworthy record: - When you begin working a task, set it to in_progress (update_task status=in_progress). diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index fecd89e..cbfc7f6 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -23,6 +23,7 @@ async def list_notes( offset: int = 0, tag: str = "", search_text: str = "", + project_id: int = 0, ) -> dict: """List notes (non-task documents) stored in Scribe. @@ -30,6 +31,12 @@ async def list_notes( search against title and body. Results are ordered by last-updated descending. Use search for semantic/meaning-based lookup instead of exact keyword search. + + Args: + project_id: Scope to one project. PASS THE ACTIVE PROJECT'S ID whenever a + project is in scope so you list that project's notes, not every + project's. 0 = no filter (all projects — use only for a deliberate + cross-project view). """ uid = current_user_id() rows, total = await notes_svc.list_notes( @@ -37,6 +44,7 @@ async def list_notes( q=search_text or None, tags=[tag] if tag else None, is_task=False, + project_id=project_id or None, limit=max(1, min(limit, 100)), offset=max(0, offset), ) diff --git a/src/scribe/mcp/tools/search.py b/src/scribe/mcp/tools/search.py index 536317b..215df01 100644 --- a/src/scribe/mcp/tools/search.py +++ b/src/scribe/mcp/tools/search.py @@ -11,13 +11,30 @@ 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. +async def search( + q: str, + content_type: str = "all", + limit: int = 10, + project_id: int = 0, +) -> dict: + """Semantic search over the user's existing notes and tasks — Scribe's recall. + + Reach for this BEFORE answering a question about the user's work or starting + a task: the user's second-brain almost always already holds related prior + art. Check for an existing ticket before opening a new one (search with + content_type='task'), and for prior notes/decisions before re-deriving them. + Treating Scribe as the first place to look — not a place to only write — is + the difference between it being a trustworthy record and a write-only log. Args: q: search query string. content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only). limit: maximum number of results (1-50). + project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID + whenever a project is in scope (the one you entered with + enter_project) — otherwise this searches across ALL projects and + bleeds unrelated work into the result set. 0 = search everything + (use only when you genuinely want a cross-project sweep). Returns: {"results": [{"id", "title", "body", "is_task", "tags", "similarity"}], @@ -28,6 +45,7 @@ async def search(q: str, content_type: str = "all", limit: int = 10) -> dict: is_task = {"note": False, "task": True}.get(content_type) # None => any raw = await semantic_search_notes( uid, q, limit=limit, is_task=is_task, + project_id=project_id or None, ) return { "results": [ diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 28b36e8..62a7f18 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -37,7 +37,10 @@ async def list_tasks( 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. + project_id: Filter to a specific project. PASS THE ACTIVE PROJECT'S ID + whenever a project is in scope so you list that project's tasks, not + every project's. 0 = no filter (all projects — use only for a + deliberate cross-project view). kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds. Results are ordered by last-updated descending.