fix(mcp): project reads list milestones without their plans, and cap done ones (#4045)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 30s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 30s
enter_project returned every milestone's full plan body. On a project with 39 milestones the handshake came to ~222k characters, 168k of them bodies (110k from done milestones). That is past what an MCP client accepts as a tool result, so the call meant to orient a session arrived as a file to page through. It grows with a project's history, so any long-lived project on any install gets there. - brief_milestone_summary (services/milestones.py) trims summary rows to the listing fields: id, title, description, status, order_index and progress. The plan is get_milestone's job. user_id, project_id and timestamps repeat what the caller knows. - enter_project and get_project share one block: every open milestone plus the 5 most recently updated done ones, in order. milestone_summary_omitted is attached only when older done ones were left out, and names list_milestones and get_milestone. - list_milestones lists every milestone, done included, without bodies. It is the call the omitted line points to, and it had the same size problem. - The REST project summary is unchanged; the web UI reads it. Tests: trimming, the done cap and its order, the omitted key present and absent, get_project and list_milestones, and a size ceiling on enter_project's milestone block for a 200-milestone history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -238,3 +238,38 @@ async def get_project_milestone_summary(user_id: int, project_id: int) -> list[d
|
||||
"""Ordered milestones with progress — the one-project view of
|
||||
get_project_milestone_summaries (two queries, not N+1)."""
|
||||
return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
|
||||
|
||||
|
||||
# What a milestone LISTING needs: enough to say what each plan is and how far
|
||||
# along it is. The plan itself (`body`) is get_milestone's job. Summaries once
|
||||
# carried it, and on a project with 39 milestones enter_project came to ~222k
|
||||
# characters, 168k of them plan bodies. That is past what an MCP client will
|
||||
# accept as a tool result, so the session handshake arrived as a file to page
|
||||
# through (#4045). user_id / project_id / timestamps repeat what the caller
|
||||
# already knows.
|
||||
_BRIEF_FIELDS = (
|
||||
"id", "title", "description", "status", "order_index",
|
||||
"total", "completed", "pct", "status_counts",
|
||||
)
|
||||
|
||||
|
||||
def brief_milestone_summary(
|
||||
rows: list[dict], *, done_kept: int | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Trim summary rows to the listing fields, optionally capping done ones.
|
||||
|
||||
`done_kept` keeps only the N most recently updated done milestones (every
|
||||
open one stays), in the original order_index order. A done plan is
|
||||
history, and a handshake that grows with a project's whole history grows
|
||||
past what a client accepts. None keeps every row. Returns (rows, omitted),
|
||||
where `omitted` counts the done milestones left out.
|
||||
"""
|
||||
if done_kept is None:
|
||||
kept = rows
|
||||
else:
|
||||
done = [r for r in rows if r.get("status") == "done"]
|
||||
recent = sorted(done, key=lambda r: r.get("updated_at") or "", reverse=True)
|
||||
keep_ids = {r.get("id") for r in recent[:done_kept]}
|
||||
kept = [r for r in rows if r.get("status") != "done" or r.get("id") in keep_ids]
|
||||
brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept]
|
||||
return brief, len(rows) - len(kept)
|
||||
|
||||
Reference in New Issue
Block a user