Skip to content

fix(mcp): reload the served snapshot when project files change - #438

Merged
jimisola merged 1 commit into
mainfrom
feat/mcp-snapshot-freshness
Aug 15, 2026
Merged

fix(mcp): reload the served snapshot when project files change#438
jimisola merged 1 commit into
mainfrom
feat/mcp-snapshot-freshness

Conversation

@jimisola

Copy link
Copy Markdown
Member

What & Why

reqstool mcp parsed its project once at startup and served that snapshot for the lifetime of the process. AI harnesses keep the server spawned for days while builds regenerate annotations.yml and JUnit XML underneath it, so it answered from a tree that no longer existed — and the stale answer was well-formed, indistinguishable from a real one.

Two things caused it, and both are fixed here:

  • The repository was bound into the tool closures at startup. ProjectSession.rebuild() already existed (the LSP uses it on file-change notifications), but calling it would have been invisible to the MCP tools, which held the old repo. Tools now resolve the repository per call via a _repo() helper.
  • Nothing recorded which files had been parsed. source_paths covered only the four data YAMLs, and the JUnit XML files matched by each test_results glob were discarded after parsing — so "the pattern matched no files" was indistinguishable from "this project has no tests".

Changes:

  • SnapshotFingerprint (common/snapshot_fingerprint.py) — stamps (exists, mtime_ns, size) for the four data YAMLs plus reqstool_config.yml, and records each test_results pattern with the concrete files it matched. Files absent at parse time are stamped deliberately: an annotations.yml the build has yet to generate is the common staleness trigger. Paths are recorded under the real project directory, not the temp symlink tree, which is removed once parsing finishes.
  • ProjectSession.ensure_fresh() — re-stats the fingerprint and rebuilds only on a mismatch. The LSP is untouched: it keeps rebuilding from its client's didChangeWatchedFiles notifications. MCP has no such channel, which is why it checks per request.
  • A failed reload is an error, not a fallback — if the inputs changed but the new state does not parse, tools raise SnapshotReloadError rather than answering from the superseded snapshot. The failed build re-stamps the inputs it knew about, so a broken tree is parsed once, not once per request; fixing the file restores service on the next call.
  • refresh tool — reloads unconditionally, for use right after a build.
  • snapshot field on get_statusbuilt_at, reload, tracked_files, warnings. A test_results pattern matching no files is reported there instead of being silently counted as zero tests.

Only local sources are fingerprinted; remote ones (git/maven/npm/pypi) are version-pinned downloads materialized into a temp directory, so there is nothing stable to stat. refresh covers them.

Per-request re-parsing (the issue's first suggestion) was rejected in favour of stat-then-rebuild: a full rebuild walks the whole import chain including network fetches for remote imports, whereas the freshness check costs a few dozen stat()s and one rglob per configured pattern. No debounce interval — a debounce window is exactly the "answered from just before the build" case this is meant to eliminate. Rationale in openspec/changes/mcp-snapshot-freshness/design.md.

Deliberately not included: the verify tool from point 3 of the issue. It belongs with the status/export redesign in #311 and must derive from the shared verdict computation (MCP_0005) rather than becoming a second opinion on completeness.

New requirements MCP_0006 (freshness), MCP_0007 (reload failure reporting), MCP_0008 (snapshot provenance and missing-artifact reporting) with matching SVCs; OpenSpec change mcp-snapshot-freshness.

Closes: #437

Author Checklist

  • Ran /quality:full-pr-review and addressed findings
  • docs/modules/ROOT/pages/mcp.adoc updated (new refresh tool, snapshot field, "Snapshot Freshness" section)
  • reqstool SSOT updated and openspec validate --all --strict passes
  • CLAUDE.md design-decision note added (long-lived servers must not bind the repository at startup)

Test Plan

Before Deployment

Author

  • hatch run dev:pytest --cov=reqstool — 987 passed, 2 skipped (need GITHUB_TOKEN/GITLAB_TOKEN)
  • hatch run dev:flake8 and hatch run dev:black --check clean
  • Regression smoke diffs vs main byte-identical for status and report --format asciidoc on test_standard/ms-001, test_basic/ms-101, and ../reqstool-demo — the CLI is unaffected
  • reqstool status local -p docs/reqstool → 75/75 complete · PASS (new SVCs covered)
  • Integration test drives a really spawned server: annotations added after startup are served, refresh reloads, a project that no longer parses errors instead of answering stale
  • Exercised against a long-lived server in a real harness (the reported skills-gateway scenario)

Reviewer

  • Confirm no MCP tool still captures session.repo at startup
  • Confirm the LSP path is unchanged (no ensure_fresh() calls; rebuild() still driven by watched-file events)
  • Sanity-check the freshness check cost on a large repository — one rglob per configured test_results pattern runs per request
  • Confirm a failed reload surfacing as a tool error (rather than stale data) is the behaviour you want for agents mid-loop

An MCP server parsed its project once at startup and served that snapshot
for the lifetime of the process. Harnesses keep the server up for days
while builds regenerate annotations.yml and JUnit XML underneath it, so
it answered from a tree that no longer existed — and the stale answer was
well-formed, indistinguishable from a real one (#437).

Record which local files each parse read: the four data YAMLs and
reqstool_config.yml, stamped whether or not they exist, plus every
test_results glob pattern with the concrete files it matched. Absent
files are tracked deliberately — an annotations.yml the build has yet to
generate is the common trigger. ProjectSession.ensure_fresh() re-stats
that fingerprint and rebuilds only on a mismatch; the LSP keeps
rebuilding from its client's file-change notifications.

The MCP tools now resolve the repository per call rather than closing
over it at startup, without which reloading would be invisible to them.
A reload that fails raises instead of falling back to the superseded
snapshot, and the failed build re-stamps its inputs so a tree that does
not parse is parsed once, not once per request.

Adds a refresh tool for unconditional reloads and a snapshot field on
get_status reporting built_at, tracked_files and warnings — where a
test_results pattern matching no files is reported as such rather than
counted as zero tests.

Only local sources are watched; remote ones are version-pinned downloads.

Requirements: MCP_0006, MCP_0007, MCP_0008
Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
@jimisola jimisola self-assigned this Aug 15, 2026
@jimisola
jimisola merged commit 3ad8744 into main Aug 15, 2026
7 checks passed
@jimisola
jimisola deleted the feat/mcp-snapshot-freshness branch August 15, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP server: reload and staleness detection for the spawn-time snapshot

1 participant