Skip to content

fix: repair stale update-available notice after a successful gateway.update_server - #147

Merged
ViperJuice merged 8 commits into
mainfrom
wip/fix-stale-version-notice
Aug 12, 2026
Merged

ViperJuice merged 8 commits into
mainfrom
wip/fix-stale-version-notice

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause

The maintainer ran gateway.update_server(server_name="context7"). It reported success, but the gateway kept advertising Update available for 'context7': ... on every subsequent call, and still did after a restart.

update_server calls gateway.refresh() to reconnect downstream servers after a package update. refresh() was not enough because it only reloads .mcp.json configs and reconnects transports — it never touches the descriptions cache. The "current version" shown in the stale notice comes from self._descriptions_cache.servers[server_name].version (GeneratedServerDescriptions.version), which is an upstream-latest snapshot from the last pmcp refresh/describe pass, not the installed version. update_server had no code path that ever rewrote it, so after a successful update the value stayed pinned at its pre-update snapshot forever.

Three sites read that stale value and kept reproducing the identical notice:

  • _get_update_warning() (handlers.py)
  • catalog_search's stale_updates list comprehension, which reads _stale_check_cache directly
  • the background _run_stale_index() sweep

The old code's self._stale_check_cache.pop(server_name, None) on success only cleared the memoized stale-check tuple — the very next call to any of the three sites above recomputed it from the same stale desc.version and reproduced the same notice. A restart made it permanent, since load_descriptions_cache reads that same stale value back off disk.

Fix

On update_server's success path, using the latest_version it already fetches via get_package_version:

  1. If latest_version is truthy and the server has a descriptions-cache entry, write latest_version into self._descriptions_cache.servers[server_name].version (in place — this is the same object GatewayServer holds; refresh() never replaces it, so the in-memory fix takes effect everywhere immediately).
  2. Persist it to disk with save_descriptions_cache, so the fix survives a restart, not just the current process. GatewayTools did not previously have access to the cache path, so I added a descriptions_cache_path: Path | None = None constructor parameter (defaulting to None, so all ~80 existing test call sites are unaffected) and wired it from GatewayServer.__init__ via the existing get_cache_path(self._cache_dir) helper. A disk-write failure is logged and swallowed — the package update itself already succeeded, so ok still reflects that, not the bookkeeping.
  3. _stale_check_cache is now repopulated with (now, latest_version, latest_version) instead of just popped, when latest_version is truthy. This makes catalog_search's stale_updates (which reads this cache directly, independent of the descriptions cache) agree the server is current immediately, and avoids a redundant network re-fetch for the full 6h TTL. When latest_version is None (fetch failed), the old pop() behavior is kept — there's nothing fresher to record.
  4. Nothing is written when latest_version is None — leaving the old version is better than overwriting it with an unknown/failed value.
  5. A failed update probe (ok=False return before this point) never reaches the version-bump code, so the recorded version is untouched on failure.

Scope note

This fix introduces a side effect, not a pre-existing one. Bumping desc.version on a successful update changes what a later refresh_server staleness check (src/pmcp/manifest/refresher.py:213-223) does:

  • Before this PR: after update_server, desc.version stayed at its pre-update snapshot (e.g. 2.1.2) while upstream was 4.0.x. is_version_newer(2.1.2, 4.0.x) -> True -> a plain pmcp refresh would regenerate that server's cached tool descriptions.
  • After this PR: desc.version is now 4.0.x immediately. is_version_newer(4.0.x, 4.0.x) -> False -> the staleness check's early return fires -> pmcp refresh skips regenerating that server's tool descriptions, even if the update shipped different tools.

So the stale version was accidentally also acting as the trigger that got tool descriptions regenerated on the next refresh, and this PR removes that trigger for the updated server. Blast radius is bounded and non-blocking:

  • The descriptions cache only supplies tool info for offline servers (handlers.py skips any server that is_server_online() -- "live tools take precedence"). A server that's online right after its own update is unaffected.
  • refresh_all() only runs unconditionally at startup when no cache exists yet (server.py:783).
  • Net effect: an updated server's offline tool listing stays pinned to the old package's tool set until pmcp refresh --force (which bypasses the staleness check).

Filed as #148, with the before/after mechanism, blast-radius bounds, and a candidate fix (regenerate descriptions directly from the tool list update_server already has post-reconnect, instead of relying on a later refresh_server pass). Not implemented here -- out of scope for this PR.

Verification

RED check (required by this repo's testing standard — a passing test is not evidence a test works): reverted just the fix (src/pmcp/tools/handlers.py + src/pmcp/server.py, tests untouched) and reran the 6 new tests. 4 of the 6 went RED as expected (the 2 failure-path tests stayed green because those paths were already correct pre-fix):

FAILED test_update_server_bumps_descriptions_cache_version
  AssertionError: assert '0.1.0' == '0.2.0'
FAILED test_update_server_clears_get_update_warning
  assert "Update available for 'playwright': 0.1.0 -> 0.2.0. ..." is None
FAILED test_update_server_clears_catalog_search_stale_updates
  assert ["Update available for 'playwright': 0.1.0 -> 0.2.0. ..."] is None
FAILED test_update_server_persists_version_across_restart
  TypeError: GatewayTools.__init__() got an unexpected keyword argument 'descriptions_cache_path'
4 failed, 2 passed, 166 deselected in 1.33s

Note on test_update_server_clears_catalog_search_stale_updates: a naive version of this test would pass even without the fix, because the old code's own _stale_check_cache.pop() already silences an immediate catalog_search. To actually discriminate the bug, the test calls _get_update_warning() between update_server() and catalog_search() to force the same recompute production traffic would trigger — that's what turned it red above.

Restored the fix, reran: all 6 green.

Gates (from inside the worktree, uv run):

  • pytest -q: 2527 passed, 3 skipped, 0 failed (baseline on main was 2521 passed / 3 skipped; +6 = my new tests, nothing else changed)
  • ruff check .: all checks passed
  • ruff format --check .: my 3 touched files (src/pmcp/tools/handlers.py, src/pmcp/server.py, tests/test_tools.py) are clean; 2 pre-existing unformatted files under diagnostics/issue-79-1b/ are unrelated and unchanged by this PR (confirmed present on main before this branch)
  • mypy src: Success: no issues found in 42 source files

Scope discipline

Fixed this bug only. Did not touch _version_cache in version_checker.py, add TTLs, or refactor the version-checking subsystem.


View with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is enabled.

@ViperJuice
ViperJuice force-pushed the wip/fix-stale-version-notice branch from d33eaec to 24c3c4a Compare August 12, 2026 07:35
Board review round 3 finding 2: detect_package_type strips the :tag off a
docker image reference, so "docker run acme/server:1.2.3" would pull
acme/server:latest, restart the still-pinned 1.2.3 config, and record the
new digest as active -- the same silent misreporting the npm pin gate
already prevented. Factors the docker image scan out of detect_package_type
(_docker_image_arg) so pin detection and package detection cannot disagree,
adds _docker_image_tag (final path segment only, so registry:5000/img is not
misread as a tag of 5000), and covers cargo's separate --version pin.
@ViperJuice

Copy link
Copy Markdown
Collaborator Author

Round-3 board reconciliation. Docker/cargo pin gap fixed here (2c1b440); the other two findings filed as #150 (notice paths still use the manifest-only resolver) and #151 (config resolved twice across the probe window).

Verified independently by the reviewer, not relayed:

  • pytest -q → 2536 passed, 3 skipped, 0 failed; ruff check, ruff format --check, mypy src clean.
  • RED proof for the docker gap: removing just the package_type == "docker" branch from _detect_effective_version_pin turns test_update_server_refuses_pinned_docker_tag and test_detect_effective_version_pin_matrix red (assert None == '1.2.3'); restored → green.
  • RED proof for round 2: reverting the round-3 src turns test_update_server_refuses_pinned_configured_override red with ok=True and message "the new version is now active" — the silent-wrong-state for a pinned server.
  • RED proof for round 1: reverting the restart-gating turns 6 tests red including assert result.ok is False / assert True is False.

@ViperJuice
ViperJuice merged commit 2533cc1 into main Aug 12, 2026
11 checks passed
@ViperJuice
ViperJuice deleted the wip/fix-stale-version-notice branch August 12, 2026 14:36
@ViperJuice ViperJuice mentioned this pull request Aug 12, 2026
ViperJuice added a commit that referenced this pull request Aug 12, 2026
Ships the gateway.update_server repair from #147: the tool now actually
restarts the target server (refresh() is a diff-based reconcile that never
respawned an argv-unchanged process, so the OLD package kept serving),
gates every cache/notice side effect on that restart succeeding, resolves
the probe and the restart through one config resolver so a .mcp.json
override cannot diverge from the manifest, and refuses version-pinned
servers across npm/uvx/docker/cargo instead of silently recording an
update that cannot take effect.

Note for callers: gateway.update_server gains an optional force parameter
(default false) that cancels the target's pending requests before the
restart, and can now return ok=false when the package was fetched but the
server could not be restarted.
@ViperJuice

Copy link
Copy Markdown
Collaborator Author

Post-merge board record: claude/correctness seat filled natively

The 4th seat on the review board (claude:claude-sonnet-5:max:correctness) had deferred UNAVAILABLE on every round of this PR, so all three rounds ran at 2-3 usable seats against a 3-seat independence floor. That seat has now been filled and its verdict is AGREE — no new blocking defect in the merged code.

How it was filled, for the record. The request was built through the runtime's own native_agent_leg_request() builder with a non-fable model, so the seat ran under the identical review-instructions.md brief and verdict contract as the CLI legs. The fable/opus subscription-TUI boundary was not crossed — those model families are policy-excluded from native fill by design, and this seat is the shape the runtime is supposed to hand out. Two upstream defects made this manual: agent-harness#538 (the fill request is never attached — the gate reads a variable a preceding branch already blanked) and agent-harness#525 (the default board seats a policy-excluded model).

What the seat verified independently, against source at the merge commit rather than from the bundle narrative:

  • No race in the tools regeneration. _connect_stdio awaits _index_capabilities and only then flips status to ONLINE, both before restart_server returns — so the post-restart get_all_tools() read is synchronously fresh. This is the load-bearing assumption behind regenerating tools/generated_at, and it holds.
  • Pin detection across every branch — npm scoped/unscoped/dist-tag/@latest, uvx ==, docker tag/registry-port/digest, cargo --version and --version=.
  • _stale_check_cache repopulation as (now, latest, latest) is safe even in the edge case where the descriptions-cache branch is skipped: _get_update_warning gates on a non-empty current_version before consulting the cache, and catalog_search's stale list filters on is_version_newer(current, latest), which is False when they are equal.
  • Every failure path (probe fail, latest_version=None, restart refused, persistence raises) leaves desc_entry.version and _stale_check_cache consistent with each other.
  • Ran pytest -k "update_server or TestUpdateServerVersionRepair" (16 passed), plus ruff and mypy on the five touched files.

It also caught a defect in the review process, not the code. The round-3 bundle handed to the board was not the diff that shipped: 2533cc1 is a squash that includes the later docker/cargo pin-refusal commit. The seat noticed the gap and verified that code by hand instead of reviewing stale material.

That directly explains the standing DISAGREE from the codex leg on the last run: its blocking finding was "Docker version pins are not refused." True of the bundle, false of the merged code — _detect_effective_version_pin("docker", "docker", ["run","acme/img:1.2.3"]) returns '1.2.3' and refuses. Independently confirmed. The lesson is on the bundle-staging side: a board reviewing a pre-squash diff can report a defect that no longer exists.

Nothing here changes the merged result. #150 and #151 remain open and correctly non-blocking.

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.

1 participant