Repository navigation
Schema cache serves stale tool schema after upstream contract bump (no invalidation on version change) #58
Description
Problem
mcp2cli's per-credential/per-service schema cache is not invalidated when an upstream MCP server changes its contract. It only expires on a 24h TTL, so for up to a day after an upstream deploy, mcp2cli schema and generate-skills emit the old tool shape — silently. Agents/Claude then form calls against an outdated schema even though live tool calls (transport) already work against the new contract.
This surfaced during the Open Brain memory-tools.v11 rollout (open-brain #228, adds append_session_event.create_if_missing + exact-scope fields). It is the staleness counterpart to #55: #55 is about nested-field discoverability in the schema view; this is about the schema view being stale entirely after a version bump. They should be worked together.
Evidence / repro (verified live 2026-06-28)
# Hosted OB already serving v11:
mcp2cli open-brain get_contract --params '{}' | grep contract_version
# -> "contract_version":"2026-06-28.memory-tools.v11" (correct, live)
# But the cached schema view is stale:
mcp2cli schema open-brain.append_session_event | grep create_if_missing
# -> (nothing — old 9-field schema served from cache)
# Manually clearing the stale per-client cache fixes it:
rm ~/.cache/mcp2cli/schemas/open-brain.json
mcp2cli schema open-brain.append_session_event | grep create_if_missing
# -> "create_if_missing" (now present; refetched from v11 daemon)
Root cause
The schema cache is stored in two layers and keyed per credential AND per bare service, none of which invalidate on contract change:
- Client:
~/.cache/mcp2cli/schemas/open-brain.json - Daemon (CT216):
/var/lib/mcp2cli/schemas/open-brain.jsonplus per-credential keys, e.g.credential:WyJvcGVuLWJyYWluIiwidXNlcjpyaWNvIl0.json(base64 of["open-brain","user:rico"]),credential:...c2tpcHB5...json.
A version bump must invalidate all of these or it looks refreshed in one place and stale in another (observed: client cache stale while daemon-side appeared empty to cache status).
Second bug found while diagnosing: cache warm broken on the daemon
On CT216 (daemon mcp2cli v0.3.10), warming through the running daemon fails:
mcp2cli cache warm open-brain
# -> open-brain: failed (Failed to communicate with daemon: Daemon failed to start within 10000ms)
This is a self-call conflict (warming a service whose calls route through the same running daemon). The refresh command documented in open-brain/docs/downstream-rollout.md (mcp2cli cache diff/warm open-brain) is therefore currently non-functional on the hub. Note: cache warm/cache diff exist on daemon v0.3.10 but NOT on older client builds (e.g. v0.3.7), which is an additional version-skew gotcha.
Why the existing design assumption fails
rtech-mcps #11 and mcp2cli #55 both state "no static cache to update / live discovery." That holds for transport (tool calls reach the live daemon) but is false for the schema cache view, which is what schema/generate-skills/help render. The cache is the source of the stale view.
Proposed fix (maintainer's call)
- Invalidate-on-version-change. Open Brain publishes an authoritative
contract_versionand a deterministicschema_hash(excludesgenerated_at) viaget_contract. mcp2cli should record the version/hash alongside the cached schema and refetch when the live value differs — across both cache layers and all credential keys. (Generalizable: any server that publishes a version/hash; fall back to TTL otherwise.) - Fix
cache warmon the daemon so the hub can refresh without a manualrmof cache files (avoid the self-call deadlock). - Ensure
generate-skillsregenerates from the current contract after invalidation.
Acceptance
- After an upstream contract bump,
mcp2cli schema <svc>.<tool>reflects the new schema without a manual cache delete (invalidation triggered bycontract_version/schema_hashchange for servers that publish it). - Invalidation covers
~/.cache/mcp2cli/schemas/AND daemon/var/lib/mcp2cli/schemas/, including allcredential:*keys. mcp2cli cache warm open-brainsucceeds on the CT216 daemon.generate-skills open-brainemits the v11 schema (incl.create_if_missingand the nestedmetadatafields from Schema view doesn't expose nested object sub-fields (e.g. Open Brain metadata.share_candidate) #55).- No regression for servers that don't publish a version/hash (TTL behavior preserved).
Related
- Schema view doesn't expose nested object sub-fields (e.g. Open Brain metadata.share_candidate) #55 (nested-field schema discoverability) — work together; both are schema-view fidelity, this one is staleness.
- rtech-mcps Auto-regenerate skills on schema change #11 (OB registry thin-pointer design) — the "no static cache" assumption needs the caveat that the schema view cache does go stale.
- open-brain #228 /
memory-tools.v11(the contract bump that exposed this);open-brain/docs/downstream-rollout.mddocuments the (currently broken) refresh step.
🤖 Generated with Claude Code
Activity
rodaddy commentedon Jun 28, 2026
Recommended trigger: daemon push-invalidate on drift, fingerprinted on OB's
schema_hash(option 1, refined)Of the three options, option 1 (daemon drift-hook invalidation) is right, with one refinement: use Open Brain's published
schema_hashas the fingerprint instead of re-diffing the livetools/list. This makes it cheap, exact, and zero per-read cost.Why not the others
create_if_missinginvisible immediately post-deploy), and adds background-fetch machinery the drift-hook already obviates.Producer side is already done — verified facts (no OB change needed)
get_contracton hosted v11 publishes everything you need to pin against:contract_version=2026-06-28.memory-tools.v11schema_hash=68be7d3fa46ec75266eaf407a4ed911ac046588ea1148136a32a516aca796120min_client_versions.mcp2cli=0.3.6;compatible_client_ranges.mcp2cli=>=0.3.6 <1.0.0schema_hashis safe to use as the drift fingerprint — proven 3 ways:get_contractcalls → identicalschema_hash, differentgenerated_at.open-brain/src/contract.ts:300-306): hashes stable-sorted JSON ofOmit<Contract,"generated_at"|"schema_hash">— deterministic, excludes the timestamp.open-brain/src/contract.test.ts:275-280): pins "hash stable when only generated_at changes"; 309/345/385 prove it does change on real content change.So the daemon stores
schema_hashnext to the cached schema and compares one string on connect — no fulltools/listdiff, no timestamp false-positives.Suggested shape
get_contract, readsschema_hash.~/.cache/mcp2cli/schemas/, daemon/var/lib/mcp2cli/schemas/, AND everycredential:*key.schema_hashwhen the server advertises the contract capability (OB does); fall back to hashingtools/listfor servers withoutget_contract; TTL as the floor.--forcemanual override.cache warmself-call deadlock on the daemon so the documented refresh works.Acceptance addition
schema_hashchange clears all layers + credential keys; stale window = next connect, not 24h.tools/listhash, then TTL.(open-brain
docs/downstream-rollout.mdupdated to document the current manual workaround and point the durable fix here.)rodaddy commentedon Jun 28, 2026
Gap A (client cache on a different host than the daemon): hybrid — piggyback primary + explicit check on cold read
The daemon's drift-hook heals
/var/lib/mcp2cli/schemas/but cannot reach a client's~/.cache/mcp2cli/. To close that without reintroducing a per-read round-trip:Primary — option 2 (piggyback fingerprint on daemon responses). The client already calls the daemon for real tool calls. Stamp the current
schema_hash(or a short prefix) into the response envelope; the client compares it to its cached fingerprint and invalidates its local cache on mismatch. Zero extra round-trips on the hot path — a prior tool call tells the client its schema is stale before the next schema read.Fallback — option 1 (explicit fingerprint check) ONLY on a cold read. If the client has no cached fingerprint yet (first read after a bump, or cache miss) there's nothing to compare against, so option 2 alone has a cold-start gap. On that cold path only, do one cheap
get_contract/fingerprint call. Once cached, the piggyback keeps it fresh — so this is NOT per-read.Why not option 1 as primary: "ask the daemon for the fingerprint on each schema read" is a per-read round-trip that fails closed when the daemon is unreachable — same category we rejected for the per-read live probe, just lighter.
Why not option 3 as the only client mechanism: it leaves the client silently stale after every bump — that's the exact bug. Keep
cache warm --forceas a manual override, but it can't be the sole client path.Implementation caveat: the envelope stamp must be per-service (
schema_hashfor the service the call targeted), so a multi-service daemon response lets the client attribute drift to the right service.Net design (daemon + client)
schema_hashdrift (clears all daemon layers +credential:*keys).schema_hashon responses → invalidate on mismatch; explicit check on cold read;--forceoverride.schema_hashfromget_contract(verified: stable across calls, excludesgenerated_at); fall back totools/listhash for servers without a contract; TTL as floor.rodaddy commentedon Jun 28, 2026
PR #59 opens with the cache-coherence core for this: schema fingerprint in cache metadata, invalidation across the base entry + all
credential:*keys, daemon drift-hook invalidation, client-side piggyback coherence (daemon stamps fingerprint on /list-tools and /schema; client drops stale ~/.cache on mismatch), andcache warm --force. Thecache warmdaemon self-call deadlock is deferred to a focused follow-up (touches #52's daemon-routing decision); the drift-hook repopulation here already routes around it. Full suite 1069 pass / 0 fail. Worked alongside #55.fix(cache): align coherence fingerprint key + canonical hash surface (#…
test(cache): pin daemon bare-key fingerprint stamp (#58 B1 regression…
fix(cache): invalidate schema cache on contract change (#58) (#59)