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
174 lines
6.6 KiB
Python
174 lines
6.6 KiB
Python
"""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 notes as notes_svc
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from scribe.services import trash as trash_svc
|
|
from scribe.services.record_refs import refuse_guessed_ids
|
|
|
|
|
|
async def list_milestones(project_id: int) -> dict:
|
|
"""List milestones for a Scribe project, ordered by order_index.
|
|
|
|
Returns every milestone, done ones included: id, title, description,
|
|
status (active/done), order_index and progress (total, completed, pct,
|
|
status_counts). The plan itself is not listed: get_milestone(id) returns a
|
|
milestone's body and its steps.
|
|
"""
|
|
uid = current_user_id()
|
|
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
|
|
brief, _ = milestones_svc.brief_milestone_summary(rows)
|
|
return {"milestones": brief}
|
|
|
|
|
|
async def get_milestone(milestone_id: int) -> dict:
|
|
"""Fetch a milestone (the plan container) with its step-tasks and rules.
|
|
|
|
A milestone IS a plan: its `body` holds the design/intent, and its steps
|
|
are the child tasks listed here. Use this to read a plan top-to-bottom —
|
|
the body for the design, `steps` for the trackable units of work. Mirrors
|
|
the planning context that start_planning returns (applicable rules), so the
|
|
rules surface again on recall.
|
|
|
|
Returns: milestone (incl. body), progress, steps (its tasks ordered by
|
|
status then update), and applicable_rules / subscribed_rulebooks.
|
|
"""
|
|
uid = current_user_id()
|
|
milestone = await milestones_svc.get_milestone(uid, milestone_id)
|
|
if milestone is None:
|
|
raise ValueError(f"milestone {milestone_id} not found")
|
|
progress = await milestones_svc.get_milestone_progress(milestone_id)
|
|
steps, _ = await notes_svc.list_notes(
|
|
uid, is_task=True, milestone_id=milestone_id, sort="status", limit=200,
|
|
)
|
|
applicable = await rulebooks_svc.get_applicable_rules(
|
|
project_id=milestone.project_id, user_id=uid,
|
|
)
|
|
out = milestone.to_dict()
|
|
out.update(progress)
|
|
return {
|
|
"milestone": out,
|
|
"steps": [t.to_dict() for t in steps],
|
|
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_milestone"),
|
|
}
|
|
|
|
|
|
async def create_milestone(
|
|
project_id: int,
|
|
title: str,
|
|
description: str = "",
|
|
body: str = "",
|
|
status: str = "active",
|
|
) -> dict:
|
|
"""Create a milestone within a Scribe project.
|
|
|
|
A milestone can serve as a plan container — put the design/intent in `body`
|
|
and track each step as a child task (create_task(milestone_id=...)). For a
|
|
fresh plan, prefer start_planning, which seeds the body template + surfaces
|
|
the project's rules.
|
|
|
|
Args:
|
|
project_id: The project this milestone belongs to (required).
|
|
title: Milestone name (required).
|
|
description: Optional one-line summary of what this milestone covers.
|
|
body: Optional plan/design (markdown) — the milestone's full plan text.
|
|
status: active (default) or done.
|
|
"""
|
|
uid = current_user_id()
|
|
await refuse_guessed_ids(title, description, body)
|
|
milestone = await milestones_svc.create_milestone(
|
|
uid,
|
|
project_id=project_id,
|
|
title=title,
|
|
description=description or None,
|
|
body=body or None,
|
|
status=status,
|
|
)
|
|
return milestone.to_dict()
|
|
|
|
|
|
async def update_milestone(
|
|
project_id: int,
|
|
milestone_id: int,
|
|
title: str = "",
|
|
description: str = "",
|
|
body: 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 one-line summary, or omit to leave unchanged.
|
|
body: New plan/design (markdown), 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 body:
|
|
fields["body"] = body
|
|
if status:
|
|
fields["status"] = status
|
|
if order_index >= 0:
|
|
fields["order_index"] = order_index
|
|
await refuse_guessed_ids(title, description, body)
|
|
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()
|
|
# Read the title BEFORE the delete: afterwards the row is trashed and the
|
|
# confirmation could only echo the number back. A deletion the operator
|
|
# cannot recognise is one they cannot tell was the wrong one.
|
|
# Fail-open: the title is a COURTESY on top of the delete, so a lookup
|
|
# that errors must not stop the delete happening. Same posture the
|
|
# staleness marker takes — a decoration may never break its payload.
|
|
try:
|
|
doomed = await milestones_svc.get_milestone(uid, milestone_id)
|
|
title = getattr(doomed, "title", "") if doomed else ""
|
|
except Exception:
|
|
title = ""
|
|
batch = await trash_svc.delete(uid, "milestone", milestone_id)
|
|
if batch is None:
|
|
raise ValueError(f"milestone {milestone_id} not found")
|
|
return {"deleted": milestone_id, "title": title, "deleted_batch_id": batch,
|
|
"message": f'Milestone {milestone_id} ("{title}") and its tasks '
|
|
f"moved to trash. Restore with restore('{batch}')."}
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
list_milestones,
|
|
get_milestone,
|
|
create_milestone,
|
|
update_milestone,
|
|
delete_milestone,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|