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:

GapWorkaround todayExtra calls
No cross-plan followup listread every plan individuallyN serial read_plan calls where N = plan count
No cross-plan question listsameN serial reads
No resolve_questionpatch_plan with full questions arrayread + write with full payload
No add_researchpatch_plan with full research arrayread + write with full payload
No list_projectsuser must know project names a priori0 calls (blocked entirely)
list_plans blind without index.jsononly repos with central-index layout work0 plans returned for per-doc repos
Note ID collisiondelete-then-add creates duplicate IDssilent 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


§ 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


§ 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


§ 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


§ 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


§ 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


§ 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:

Tests


§ 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:

  1. Add list_followups_across + list_questions_across to _store.py (no versioning concerns — read-only fns).
  2. Add all 5 new tools to mcp.py + update _list_plans + fix note-ID.
  3. Add 3 new Pydantic models to _mcp_tools.py.
  4. Write tests/test_mcp_tools.py and verify all tests pass.
  5. Run uv run pytest tests/ -q — must see 8 original + ≥25 new = ≥33 passing.
  6. Commit: feat(mcp): add list_followups, list_questions, resolve_question, add_research, list_projects; fix note-id; list_plans discovery fallback
  7. 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.