One-line: Five missing MCP tools (cross-plan followup/question scanning,
project discovery, research capture, question close) plus a
list_plans discovery fallback and a note-ID collision fix. All work lands
in reckon/mcp.py, reckon/_store.py,
reckon/_mcp_tools.py, and tests/.
Motivation: During the 2026-05-26 MCP tool evaluation (reading 7 imas-ambix
plans to find 7 unresolved followups), the following gaps were confirmed by measuring
call count and token cost against what a purpose-built query tool would take:
| Gap | Workaround today | Extra calls |
| No cross-plan followup list | read every plan individually | N serial read_plan calls where N = plan count |
| No cross-plan question list | same | N serial reads |
| No resolve_question | patch_plan with full questions array | read + write with full payload |
| No add_research | patch_plan with full research array | read + write with full payload |
| No list_projects | user must know project names a priori | 0 calls (blocked entirely) |
| list_plans blind without index.json | only repos with central-index layout work | 0 plans returned for per-doc repos |
| Note ID collision | delete-then-add creates duplicate IDs | silent data corruption |
§ 1 — list_followups — cross-plan unresolved followup scanner
Files: reckon/_store.py (new store fn),
reckon/mcp.py (new tool), reckon/_mcp_tools.py (new model).
Store layer — list_followups_across
def list_followups_across(
project: str,
unresolved_only: bool = True,
) -> list[dict]:
"""Scan all per-plan state files for followups.
Skips index.json and project.json (meta files).
Returns lightweight dicts: {slug, followup_id, title, blocked_by,
est_turn, tier, written_at}.
"""
root = _state_root() / project
results = []
if not root.is_dir():
return results
for json_file in sorted(root.glob("*.json")):
slug = json_file.stem
if slug in ("index", "project"):
continue
data, _ = _load_envelope(json_file)
for f in data.get("followups", []):
if not isinstance(f, dict):
continue
if unresolved_only and f.get("resolved_at"):
continue
results.append({
"slug": slug,
"followup_id": f.get("id"),
"title": f.get("title"),
"blocked_by": f.get("blocked_by"),
"est_turn": f.get("est_turn"),
"tier": f.get("tier"),
"written_at": f.get("written_at"),
})
return results
MCP tool — _list_followups
def _list_followups(
project: str,
unresolved_only: bool = True,
) -> dict[str, Any]:
"""List followups across all plans for a project.
unresolved_only=True (default) omits followups that have a resolved_at
timestamp. Returns {project, count, followups: [{slug, followup_id,
title, blocked_by, est_turn, tier, written_at}]}.
"""
items = list_followups_across(project, unresolved_only)
return {"project": project, "count": len(items), "followups": items}
Pydantic model
class ListFollowupsArgs(BaseModel):
project: str
unresolved_only: bool = Field(True, description="When True, omit resolved followups")
Tests
test_list_followups_empty_project — state root has no files → returns []
test_list_followups_unresolved_only — mix of resolved + unresolved; filter works
test_list_followups_all — unresolved_only=False returns all followups
test_list_followups_skips_meta — index.json and project.json are skipped
test_list_followups_cross_plan — followups from 3 plans all aggregated
§ 2 — list_questions — cross-plan open question scanner
Files: reckon/_store.py, reckon/mcp.py,
reckon/_mcp_tools.py.
Store layer — list_questions_across
def list_questions_across(
project: str,
open_only: bool = True,
) -> list[dict]:
"""Scan all per-plan state files for questions.
Returns {slug, question_id, section, body, opened_by, opened_at}.
open_only=True omits questions that have resolved_at set.
"""
root = _state_root() / project
results = []
if not root.is_dir():
return results
for json_file in sorted(root.glob("*.json")):
slug = json_file.stem
if slug in ("index", "project"):
continue
data, _ = _load_envelope(json_file)
for q in data.get("questions", []):
if not isinstance(q, dict):
continue
if open_only and q.get("resolved_at"):
continue
results.append({
"slug": slug,
"question_id": q.get("id"),
"section": q.get("section"),
"body": q.get("body"),
"opened_by": q.get("opened_by"),
"opened_at": q.get("opened_at"),
})
return results
MCP tool — _list_questions
def _list_questions(
project: str,
open_only: bool = True,
) -> dict[str, Any]:
"""List questions across all plans for a project.
Returns {project, count, questions: [{slug, question_id, section,
body, opened_by, opened_at}]}.
"""
items = list_questions_across(project, open_only)
return {"project": project, "count": len(items), "questions": items}
Tests
test_list_questions_open_only — resolved questions excluded
test_list_questions_all — open_only=False includes resolved
test_list_questions_cross_plan — aggregation across 2 plans
§ 3 — resolve_question — close a question with resolution text
Files: reckon/mcp.py, reckon/_mcp_tools.py.
No new store function — reuses resolve_in_list(..., "questions", ...).
MCP tool
def _resolve_question(
project: str,
slug: str,
question_id: str,
resolution: str,
by: str,
expected_version: int,
) -> dict[str, Any]:
"""Mark a question as resolved.
Sets resolved_at, resolved_by, resolution on the question with the
given id. The question_id is the 'id' field in data.questions[].
"""
updates = {
"resolved_at": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"),
"resolved_by": by,
"resolution": resolution,
}
try:
new_version = resolve_in_list(
project, slug, "questions", question_id, updates, expected_version
)
return {"ok": True, "project": project, "slug": slug, "new_version": new_version}
except VersionConflict as e:
return _conflict_response(e)
except KeyError as e:
return {"ok": False, "error": str(e)}
Pydantic model
class ResolveQuestionArgs(BaseModel):
project: str
slug: str
question_id: str = Field(..., description="The 'id' field of the question to resolve")
resolution: str = Field(..., description="Concise statement of what was decided or learned")
by: str = Field(..., description="Who resolved the question")
expected_version: int
Tests
test_resolve_question_happy_path — resolved_at + resolution set, other fields preserved
test_resolve_question_missing_id — returns ok:False with error string
test_resolve_question_version_conflict — stale expected_version returns conflict response
§ 4 — add_research — append a research item
Files: reckon/mcp.py, reckon/_mcp_tools.py.
No new store function — reuses append_to_list(..., "research", ...).
MCP tool
def _add_research(
project: str,
slug: str,
item: dict[str, Any],
expected_version: int,
) -> dict[str, Any]:
"""Append a research item to data.research.
Required item fields: id, type, title, source, added_by, when.
Optional: url.
Valid type values: paper | doc | web | dataset | thread | image | plan.
"""
required = {"id", "type", "title", "source", "added_by", "when"}
missing = required - set(item.keys())
if missing:
return {
"ok": False,
"error": f"research item missing required fields: {sorted(missing)}",
}
try:
new_version = append_to_list(project, slug, "research", item, expected_version)
return {"ok": True, "project": project, "slug": slug, "new_version": new_version}
except VersionConflict as e:
return _conflict_response(e)
Pydantic model
class AddResearchArgs(BaseModel):
project: str
slug: str
item: dict[str, Any] = Field(
...,
description=(
"Research item. Required: id, type, title, source, added_by, when. "
"Optional: url. "
"type ∈ {paper, doc, web, dataset, thread, image, plan}."
),
)
expected_version: int
Tests
test_add_research_success — item appended, version bumped
test_add_research_missing_fields — ok:False with field list in error
test_add_research_conflict — stale version returns conflict response
§ 5 — list_projects — discover registered projects
Files: reckon/mcp.py, reckon/_mcp_tools.py.
No store function — reads ~/docs-server/mounts.json directly and checks
the state root for index.json existence.
MCP tool
def _list_projects() -> dict[str, Any]:
"""List all projects registered in the reckon server's mounts.json.
Returns {projects: [{project, path, has_index}]}.
Useful for discovering valid project keys before calling list_plans
or read_plan in a fresh session.
"""
from reckon._store import _state_root
mounts_file = Path.home() / "docs-server" / "mounts.json"
if not mounts_file.exists():
return {"projects": []}
try:
mounts: dict = json.loads(mounts_file.read_text())
except (OSError, json.JSONDecodeError):
return {"projects": []}
state_root = _state_root()
results = []
for name, path in sorted(mounts.items()):
results.append({
"project": name,
"path": path,
"has_index": (state_root / name / "index.json").is_file(),
})
return {"projects": results}
Note: No Pydantic args model needed — zero parameters.
Tests
test_list_projects_with_mounts — reads a temp mounts.json, returns entries
test_list_projects_no_mounts_file — returns {"projects": []} gracefully
test_list_projects_has_index — has_index=True when index.json present
§ 6 — list_plans discovery fallback
Files: reckon/mcp.py (update _list_plans only).
No store changes.
Currently _list_plans reads index.json → data.inventory.
When inventory is empty (no central-index layout), it returns zero plans.
The fix: fall back to discover_plans from reckon.serve
when inventory is empty.
def _list_plans(project: str, status: str | None = None) -> dict[str, Any]:
data, _ = read_plan(project, "index")
inventory = data.get("inventory", [])
# Fallback: no central-index → discover from HTML meta tags
if not inventory:
try:
from pathlib import Path as _Path
import json as _json
from reckon.serve import discover_plans
mounts_file = _Path.home() / "docs-server" / "mounts.json"
if mounts_file.exists():
raw = _json.loads(mounts_file.read_text())
docs_path = raw.get(project)
if docs_path:
discovered = discover_plans(
_Path(docs_path), project, _state_root()
)
inventory = discovered.get("inventory", [])
except Exception:
pass # discovery is best-effort; never surface import errors as tool errors
if status:
inventory = [p for p in inventory if p.get("status") == status]
return {
"project": project,
"plans": [
{
"slug": p.get("slug"),
"title": p.get("title"),
"status": p.get("status"),
"impl": p.get("impl"),
"ms": p.get("ms"),
"sprint": p.get("sprint"),
"roi": p.get("roi"),
"effort": p.get("effort"),
}
for p in inventory
],
}
Tests
test_list_plans_from_index — existing behaviour, no regression
test_list_plans_discovery_fallback — temp docs dir with one HTML plan file,
no index.json → plan appears in result (requires writing a minimal HTML fixture)
§ 7 — Note ID collision fix in append_comment
File: reckon/mcp.py only (one-line change).
Current: note_id = f"n{len(notes) + 1}" — collides when notes are
deleted, or when two agents append in rapid succession (same length before write).
Fix: timestamp-based ID with microsecond resolution:
# Before (fragile):
note_id = f"n{len(notes) + 1}"
# After (collision-resistant):
note_id = f"n-{datetime.now(tz=timezone.utc):%Y%m%dT%H%M%S%f}"
The ID format n-20260526T162100123456 is:
- Sortable (lexicographic = chronological)
- Collision-resistant to microsecond (12 µs between writes on the same file is effectively
impossible given the atomic-rename write cost)
- Human-readable
Tests
- Update
test_mcp_store.py if any test checks note ID format (none currently do
— the append_comment tests are in test_mcp_tools.py)
test_append_comment_note_id_format — ID starts with "n-" and is unique
across two rapid calls
§ 8 — Test coverage for all new tools
File: tests/test_mcp_tools.py (new file).
The existing tests/test_mcp_store.py covers the store layer only. This
new file tests the MCP tool functions (_list_followups,
_list_questions, _resolve_question, _add_research,
_list_projects, _append_comment note-ID).
All tests use the same state_root fixture pattern as
test_mcp_store.py: RECKON_STATE_ROOT env var → tmp_path,
module reload so _state_root() picks it up. For _list_projects,
an additional monkeypatch replaces Path.home() with a temp dir
containing a synthetic docs-server/mounts.json.
Target: ≥25 test cases across the 7 items, all passing in <0.5s
(uv run pytest tests/ -q --tb=short).
§ 9 — Implementation notes & ordering
Single Sonnet agent, sequential within files:
- Add
list_followups_across + list_questions_across to
_store.py (no versioning concerns — read-only fns).
- Add all 5 new tools to
mcp.py + update _list_plans + fix
note-ID.
- Add 3 new Pydantic models to
_mcp_tools.py.
- Write
tests/test_mcp_tools.py and verify all tests pass.
- Run
uv run pytest tests/ -q — must see 8 original + ≥25 new = ≥33 passing.
- Commit:
feat(mcp): add list_followups, list_questions, resolve_question, add_research, list_projects; fix note-id; list_plans discovery fallback
- Write a followup to this plan's state JSON resolving the driving followup and
recording what landed.
No parallelism needed — all 4 files are in scope for the same agent.
Fleet dispatch would only add coordination overhead for this size of work.
§ Decisions
How should list_followups / list_questions scan plans — glob state dir vs read index inventory?
Index inventory may be stale; direct glob always reflects current files. Meta files are excluded by name. Consistent with serve.py's discover_plans.
What format for the collision-resistant note ID?
Sortable, human-readable, microsecond resolution. Collision probability at µs is negligible given atomic-rename write cost (>1ms).
Should list_plans surface discover_plans import/IO errors?
Discovery is an enhancement, not the primary path. Surfacing fallback errors would break list_plans for all repos on any import issue. Graceful degradation is correct.
§ Followups
Implement all 8 sprint items — single Sonnet turn
All 8 items are scoped to 4 files with no overlapping write scope issues. Sequential within the single agent. See §9 for ordering. Done-when: ≥33 tests passing, commit with conventional message, followup written resolving this one.
Project: reckon
Plan: reckon-mcp-gaps (docs/reckon-mcp-gaps.html)
Section: §1–§8 (all items)
Tier: sonnet
Context
During the 2026-05-26 MCP tool evaluation, 7 opportunity gaps were confirmed
by measuring actual call overhead against purpose-built queries. This sprint
item closes all 7 in one pass. The plan HTML at docs/reckon-mcp-gaps.html
has the full implementation spec per section.
State to read
docs/state/reckon/reckon-mcp-gaps.json (this file)
reckon/_store.py (add 2 new store fns at bottom)
reckon/mcp.py (add 5 tools, update _list_plans, fix note-ID)
reckon/_mcp_tools.py (add 3 Pydantic models)
tests/test_mcp_store.py (read for fixture pattern; do NOT modify)
Locked decisions to honour
scan-strategy → glob state dir (*.json, skip index.json + project.json)
note-id-format → f'n-{datetime.now(tz=timezone.utc):%Y%m%dT%H%M%S%f}'
discover-fallback-errors → silent pass in except clause
Open decisions to surface (do not resolve)
(none — all decisions for this plan are locked above)
Constraints
- Python >=3.12, uv, mcp>=1.0.0, pydantic>=2.0
- Do NOT use git add -A / git add . — stage specific paths only
- Do NOT modify tests/test_mcp_store.py
- All 8 original store tests must still pass after changes
- The mcp SDK @mcp.tool() registration block at the bottom of mcp.py
must be updated to register all 5 new tools
Implementation order (§9):
1. reckon/_store.py — add list_followups_across + list_questions_across
2. reckon/mcp.py — add 5 new tools + update _list_plans + fix note-ID
+ register new tools at bottom of file
3. reckon/_mcp_tools.py — add ListFollowupsArgs, ResolveQuestionArgs, AddResearchArgs
4. tests/test_mcp_tools.py — write ≥25 test cases
5. uv run pytest tests/ -q --tb=short → must show ≥33 passing, 0 failed
Done-when
1. uv run pytest tests/ -q shows ≥33 passing, 0 failed
2. Committed with: feat(mcp): add list_followups, list_questions, resolve_question,
add_research, list_projects; fix note-id; list_plans discovery fallback
3. This followup (f-mcp-gaps-ship) resolved in reckon-mcp-gaps.json with outcome
4. data.impl set to 1.0 in reckon-mcp-gaps.json
5. data.status set to shipped in reckon-mcp-gaps.json
The collapsed MCP surface shipped previously; dependency and workflow metadata reconciled during the roadmap tooling audit.