LearnNewsExamplesServices
Frontmatter
id16320
titleA client''s cached MCP tool schema is pinned at connect, so a newer server capability is unreachable
stateClosed
labels
bugai
assigneesneo-opus-grace
createdAtAug 2, 2026, 2:19 AM
updatedAtAug 5, 2026, 4:31 PM
githubUrlhttps://github.com/neomjs/neo/issues/16320
authorneo-opus-grace
commentsCount6
parentIssue16448
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[x] 16455 Nothing proves the update chain works end to end on a real plane
closedAtAug 5, 2026, 4:31 PM

A client's cached MCP tool schema is pinned at connect, so a newer server capability is unreachable

neo-opus-grace
neo-opus-grace commented on Aug 2, 2026, 2:19 AM

Context

Measured on this plane on 2026-08-02 while attempting the post-merge validation for #16300.

manage_wake_subscription gained a resume action (PR #16255, merged 2026-08-01 ~11:25). The action is present in the running Memory Core container. It is not callable from an already-connected agent seat — the call is rejected before it leaves the client:

MCP error -32602: Input validation error: Invalid arguments for tool manage_wake_subscription:
  "values": ["bootstrap","subscribe","unsubscribe","update","list","resync"]
  "message": "Invalid option: expected one of bootstrap|subscribe|unsubscribe|update|list|resync"

The enforced enum is the one the client cached at connect time. resume is missing from it. The capability exists on the server and is unreachable.

This is not the same thing as plane drift (#16304). Plane drift means the container is behind dev. This is a third staleness layer below the container: the schema an agent holds can be older than the container it is talking to, and the agent has no way to notice.

The Problem

The severity is that it gates the call, not merely misinforms. An agent in this state has three bad options and no good one:

  1. It reads its own tool list, concludes the capability does not exist, and reports that as fact. I came within one sentence of telling @neo-opus-ada "the live plane lacks resume and rotate-key" — half of which is false. Absence-claims are exactly the claims that get published as fact.
  2. It builds a workaround for a capability that already shipped.
  3. It attempts the call, gets a validation error naming a closed enum, and reasonably concludes the server does not support it — the error message itself asserts the stale enum as authoritative.

None of these produce a failing check anywhere. The seat is confidently wrong and nothing contradicts it.

Every server has explicitly opted out of the fix. MCP defines notifications/tools/list_changed for exactly this. All six Neo servers declare tools: {listChanged: false}, so no refresh signal will ever arrive:

  • ai/mcp/server/BaseServer.mjs:327 — the default: capabilities: metadata.capabilities || {tools: {listChanged: false}}
  • ai/mcp/server/memory-core/Server.mjs:116, knowledge-base/Server.mjs:68, neural-link/Server.mjs:75, github-workflow/Server.mjs:40, gitlab-workflow/Server.mjs:39, file-system/Server.mjs:48

The Architectural Reality

The server's advertised list is derived from openapi.yaml. ai/mcp/server/memory-core/toolService.mjs:39 resolves openApiFilePath and passes it to the ToolService constructor at :290; ai/mcp/ToolService.mjs:377 listTools() builds the tools/list response from it.

Proof the running process loaded the file that has resume — the discriminator between "client cache is stale" and "container runs code older than its own disk" (disk ≠ running):

observation value
/app/ai/mcp/server/memory-core/openapi.yaml mtime 2026-08-01 19:25:40 UTC
container PID 1 start (/proc/1) 2026-08-01 19:39:46 UTC
enum in that file [bootstrap, subscribe, unsubscribe, update, list, resync, resume]

The file predates the process by 14 minutes, so the process loaded exactly this file. The server advertises resume. The staleness is client-side.

The client caches and discards, but never revalidates. ai/mcp/client/Client.mjs:113 holds toolSchemas = {}, populated by listTools() at :294 and cleared at :307. There is no re-fetch trigger, no digest comparison, and no consumer of a server-side change signal.

The existing freshness instrument does not cover this axis. RuntimeFreshnessService computes an openApiDigest (:262, :461, :508) and can report status: 'stale' — but it compares the server against its own disk, answering "did my openapi change since I booted". Nothing anywhere compares the client's cached schema against the server's advertised list. A seat can read runtimeFreshness: current while holding a schema that cannot reach a shipped capability. This is an instrument answering an adjacent question, and it is why the condition has been invisible.

The Fix

Direction, not prescription — the shape is genuinely open and I would rather it were argued than assumed.

The cheapest honest option is detection before repair: give the seat a way to know its schema is stale, even if refreshing still requires a reconnect. A digest of the advertised tool list, exposed on healthcheck and comparable to what the client holds, converts a silent wrong answer into a visible one. That is the same move #16310 makes for wake arming, and it needs no protocol change.

listChanged: true is the protocol-sanctioned repair, but it is a bigger claim: the server must actually detect its own tool-surface change and emit, and every client must handle the notification. Worth doing, worth doing second.

Both are worth less than the ordering question, which is why this is a ticket and not a PR: does a seat with a stale schema fail closed (refuse to assert capability absence) or merely warn? That is a policy call with teeth, because the failure mode is a confident false statement, not a crash.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
tools/list advertised set ai/mcp/server/*/openapi.yaml via ToolService.listTools() (ai/mcp/ToolService.mjs:377) unchanged n/a n/a verified: file mtime predates PID 1 start
capabilities.tools.listChanged ai/mcp/server/BaseServer.mjs:327 currently false on all six servers; changing it is option 2, not assumed keep false if detection lands first n/a grepped all six Server.mjs
client toolSchemas cache ai/mcp/client/Client.mjs:113 gains a staleness verdict; refresh mechanism TBD none today — silent n/a no re-fetch trigger exists in the file
healthcheck freshness block ai/mcp/server/shared/services/RuntimeFreshnessService.mjs unchanged. openApiDigest keeps meaning disk-vs-process; the new values take a separate name existing axis unchanged n/a :262 scope read
healthcheck tool descriptor this ticket, over ToolService's advertised set Carries a short attach-time surface digest, cached by the client alongside the tool definition Absent ⇒ unknown tool docs the descriptor is what a stale attachment already holds
healthcheck result same Carries the running server's current advertised-surface digest Unreadable ⇒ unknown tool docs the result is what a stale attachment can still fetch
the comparison itself documented agent procedure, not an MCP tool Compare the two literals: equal ⇒ current, different ⇒ stale, either missing ⇒ unknown, never current fail to unknown procedure docs a new tool would be absent from the very schema it diagnoses

Intake ruling — folded 2026-08-04

@neo-gpt's carrier ruling: documented agent procedure, backed by a Neo-shipped passive carrier. Not a new MCP tool, on three grounds — the decisive one being a bootstrap deadlock: a new comparison tool, or a new required input on an existing one, would itself be absent or rejected by the stale schema it exists to diagnose.

It also corrected my proposal. I had the agent hashing the surface it holds; that asks an agent to reconstruct and hash a large schema from prose context, which is unreliable. Neo ships the evidence carrier instead — a short digest literal in the healthcheck descriptor (cached with the tool definition a stale attachment already has) against the live result digest — so the procedure compares two literals rather than recomputing one. Same ownership boundary, far less to get wrong.

Ledger rows and ACs below are folded accordingly. The ticket is code-ready.

Decision Record impact

none — no ADR governs MCP client schema lifecycle today. If the fail-closed policy question is answered in the affirmative, that answer likely deserves one.

Acceptance Criteria

  • A seat can determine, without reading the container filesystem, whether its cached tool schema matches the server's advertised set.
  • The verdict distinguishes "schema matches" from "schema is stale" from "cannot tell" — an unknown must not report as a match. (Same tri-state discipline as #16310: never report agreement on ignorance.)
  • The verdict is reachable from the surface an agent already reads during boot/recovery, not a new tool an agent must know to call.
  • A spec proves the stale case RED: a client holding an enum without action X must be detected as stale against a server advertising X.
  • The existing server-vs-disk openApiDigest axis is demonstrably not conflated with the new client-vs-server axis — a spec asserts they can disagree.
  • Documented: which staleness layer each instrument measures (dev → container → client schema), so the next agent does not re-derive the three-layer distinction.
  • Post-merge only: confirm a seat connected before a tool addition reports stale rather than silently missing the capability.
  • The digest covers what ToolService actually advertises for that session/profile — not the raw OpenAPI file and not a global unfiltered set, or intentional tool tiers register as false staleness.
  • The canonical form is non-recursive — at minimum sorted tool names plus canonical input schemas, excluding the digest carrier itself. The axis is capability reachability, not description-copy freshness.
  • Equality claims only that this attachment was provisioned from the same advertised-surface generation. It must not overclaim protection against host truncation or paging, which stay out of scope.
  • All three cells proven: old descriptor token vs new live token ⇒ stale; matching tokens ⇒ current; missing descriptor or token ⇒ unknown.
  • ai/mcp/client/Client.mjs may automate the same comparison, but as a separate repo-owned consumer — it is never evidence for the external-harness contract.

Out of Scope

  • Plane drift itself (container behind dev) — that is D#16304 and #16310's post-merge note. This ticket is strictly the layer below the container.
  • The combined 100-tool cap and paging behaviour — a different, known, operator-deprioritized concern (2026-08-01 operator correction to @neo-gpt-emmy); do not bundle.
  • Changing listChanged to true on all servers as a fait accompli. It is option 2 and needs the ordering question answered first.
  • Any change to what tools/list advertises. The server side is correct; only its observability from the client is not.

Avoided Traps

  • Concluding "the plane lacks the capability" from the tool list. The tool list is not the plane. This is the trap the ticket exists to remove, and it is the one I nearly published.
  • Reading container logs as the discriminator. I checked whether the blocked call reached the server; the log showed nothing. That proves nothing — a successful list call also logged nothing, so the instrument was non-discriminating. The positive control is what exposed it; the mtime-vs-process-start comparison is what actually settled it.
  • Assuming the container ran code older than its own disk. That was the more alarming hypothesis and it is false here. The file predates the process. Reaching for the scarier explanation before running the cheap comparison would have produced a much worse ticket.

Related

  • #16310 — wake arming visibility; same "nothing measures this, so it reads healthy" shape, one layer up.
  • #16300 / PR #16306rotate-key, whose PMV is blocked by container drift, which is how this was found.
  • D#16304 — container update delivery; this is a distinct layer and should not be folded into it.
  • #16250x-pass-as-object MCP degradation; different concern (parameter shape), listed to pre-empt conflation.

Live latest-open sweep: checked latest 20 open issues at 2026-08-02T00:17:46Z; A2A claim sweep over 30 most recent messages at the same time. No equivalent found, no in-flight claim.

Origin Session ID: 713db0da-2239-44ea-ba5b-931be90d34fc

Retrieval Hint: query_raw_memories("MCP client tool schema pinned at connect listChanged false capability unreachable") Retrieval Hint: the discriminating probe is stat /proc/1 vs stat openapi.yaml inside the container — file older than process means the process loaded it.

tobiu referenced in commit 3cc89ab - "The update-chain goal bar lands red, naming which legs are missing (#16494) on Aug 5, 2026, 1:33 PM
tobiu referenced in commit c1442ee - "feat(mcp): a seat can tell a missing capability from a stale schema (#16320) (#16548) on Aug 5, 2026, 4:31 PM
tobiu closed this issue on Aug 5, 2026, 4:31 PM