fix(mcp): list tools return rows that say what a record is, not what it says (#4061)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 22s

list_tasks returned every row's to_dict(), body included: a project's todo list
came to 93-165k characters, past what an MCP client accepts inline, so the list
arrived as a file to page through (the #4045 failure, one call over).

- notes.brief_row: id, title, type, project, tags, updated_at; for tasks,
  status, kind, priority, milestone id and title; description, parent and due
  date only when set.
- milestones.titles_for: one query for the milestone titles a page of rows
  names.
- Brief rows on list_tasks, list_notes, get_milestone's steps, get_system and
  list_system_records. get_task / get_note / get_snippet read a record in full,
  and each docstring says so.
- tests/test_list_rows_brief.py pins the ceiling: 100 rows of ~5k-character
  step plans stay under 40k characters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 16:07:00 -04:00
co-authored by Claude Opus 5
parent 6eb5ef5c73
commit 5e4fd017ae
10 changed files with 188 additions and 17 deletions
+10 -3
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import canonical_systems as canonical_systems_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
@@ -267,16 +268,18 @@ async def get_system(system_id: int) -> dict:
"""Fetch a System plus the records associated with it.
Returns the system, plus its associated records split into `issues`,
`tasks` (work/plan), and `notes`.
`tasks` (work/plan), and `notes` — each saying what the record is and where
it sits, not what it says (open one with get_note / get_task).
"""
uid = current_user_id()
system = await systems_svc.get_system(uid, system_id)
if system is None:
raise ValueError(f"system {system_id} not found")
records = await systems_svc.list_records_for_system(uid, system_id)
titles = await milestones_svc.titles_for({r.milestone_id for r in records})
issues, tasks, notes = [], [], []
for r in records:
d = r.to_dict()
d = notes_svc.brief_row(r, titles)
if r.status is None:
notes.append(d)
elif r.task_kind == "issue":
@@ -339,12 +342,16 @@ async def list_system_records(
kind: filter by task_kind — 'issue', 'work', 'spike' (or the retired
'plan'). Omit for all.
open_only: limit to tasks not done/cancelled (e.g. open issues only).
Each record says what it is and where it sits, not what it says — open one
with get_note / get_task / get_snippet.
"""
uid = current_user_id()
rows = await systems_svc.list_records_for_system(
uid, system_id, kind=kind or None, open_only=open_only,
)
return {"records": [r.to_dict() for r in rows]}
titles = await milestones_svc.titles_for({r.milestone_id for r in rows})
return {"records": [notes_svc.brief_row(r, titles) for r in rows]}
async def delete_system(system_id: int) -> dict: