Skip to content

Schema cache serves stale tool schema after upstream contract bump (no invalidation on version change) #58

Closed

Description

@rodaddy

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.json plus 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)

  1. Invalidate-on-version-change. Open Brain publishes an authoritative contract_version and a deterministic schema_hash (excludes generated_at) via get_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.)
  2. Fix cache warm on the daemon so the hub can refresh without a manual rm of cache files (avoid the self-call deadlock).
  3. Ensure generate-skills regenerates 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 by contract_version/schema_hash change for servers that publish it).
  • Invalidation covers ~/.cache/mcp2cli/schemas/ AND daemon /var/lib/mcp2cli/schemas/, including all credential:* keys.
  • mcp2cli cache warm open-brain succeeds on the CT216 daemon.
  • generate-skills open-brain emits the v11 schema (incl. create_if_missing and the nested metadata fields 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

🤖 Generated with Claude Code

Activity

  1. rodaddy commented on Jun 28, 2026

    @rodaddy
    OwnerAuthor

    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_hash as the fingerprint instead of re-diffing the live tools/list. This makes it cheap, exact, and zero per-read cost.

    Why not the others

    • Option 3 (probe on read): adds a round-trip to every schema read and couples read latency to OB reachability — defeats the cache.
    • Option 2 (short TTL + bg revalidate): still serves stale on the first read after a bump (the exact bug here — create_if_missing invisible 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_contract on hosted v11 publishes everything you need to pin against:

    • contract_version = 2026-06-28.memory-tools.v11
    • schema_hash = 68be7d3fa46ec75266eaf407a4ed911ac046588ea1148136a32a516aca796120
    • min_client_versions.mcp2cli = 0.3.6; compatible_client_ranges.mcp2cli = >=0.3.6 <1.0.0

    schema_hash is safe to use as the drift fingerprint — proven 3 ways:

    1. Live: two get_contract calls → identical schema_hash, different generated_at.
    2. Source (open-brain/src/contract.ts:300-306): hashes stable-sorted JSON of Omit<Contract,"generated_at"|"schema_hash"> — deterministic, excludes the timestamp.
    3. Tests (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_hash next to the cached schema and compares one string on connect — no full tools/list diff, no timestamp false-positives.

    Suggested shape

    1. Drift-hook (on connect, already runs) calls get_contract, reads schema_hash.
    2. If it differs from the stored value → clear all cache layers for that service: client ~/.cache/mcp2cli/schemas/, daemon /var/lib/mcp2cli/schemas/, AND every credential:* key.
    3. Tiered fingerprint for generality (matches Schema view doesn't expose nested object sub-fields (e.g. Open Brain metadata.share_candidate) #55's source-agnostic goal): prefer schema_hash when the server advertises the contract capability (OB does); fall back to hashing tools/list for servers without get_contract; TTL as the floor.
    4. Keep a --force manual override.
    5. Fix cache warm self-call deadlock on the daemon so the documented refresh works.

    Acceptance addition

    • Drift detected via schema_hash change clears all layers + credential keys; stale window = next connect, not 24h.
    • No per-read live fetch on the happy path.
    • Non-contract servers fall back to tools/list hash, then TTL.

    (open-brain docs/downstream-rollout.md updated to document the current manual workaround and point the durable fix here.)

  2. rodaddy commented on Jun 28, 2026

    @rodaddy
    OwnerAuthor

    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 --force as a manual override, but it can't be the sole client path.

    Implementation caveat: the envelope stamp must be per-service (schema_hash for the service the call targeted), so a multi-service daemon response lets the client attribute drift to the right service.

    Net design (daemon + client)

    • Daemon cache: self-heal on connect via schema_hash drift (clears all daemon layers + credential:* keys).
    • Client cache: piggyback schema_hash on responses → invalidate on mismatch; explicit check on cold read; --force override.
    • Fingerprint source: OB's deterministic schema_hash from get_contract (verified: stable across calls, excludes generated_at); fall back to tools/list hash for servers without a contract; TTL as floor.
  3. rodaddy commented on Jun 28, 2026

    @rodaddy
    OwnerAuthor

    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), and cache warm --force. The cache warm daemon 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions