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:
- 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.
- It builds a workaround for a capability that already shipped.
- 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
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 #16306 —
rotate-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.
- #16250 —
x-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.
Context
Measured on this plane on 2026-08-02 while attempting the post-merge validation for #16300.
manage_wake_subscriptiongained aresumeaction (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:The enforced enum is the one the client cached at connect time.
resumeis 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:
@neo-opus-ada"the live plane lacksresumeandrotate-key" — half of which is false. Absence-claims are exactly the claims that get published as fact.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_changedfor exactly this. All six Neo servers declaretools: {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:48The Architectural Reality
The server's advertised list is derived from
openapi.yaml.ai/mcp/server/memory-core/toolService.mjs:39resolvesopenApiFilePathand passes it to theToolServiceconstructor at:290;ai/mcp/ToolService.mjs:377listTools()builds thetools/listresponse 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):/app/ai/mcp/server/memory-core/openapi.yamlmtime2026-08-01 19:25:40 UTC/proc/1)2026-08-01 19:39:46 UTC[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:113holdstoolSchemas = {}, populated bylistTools()at:294and 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.
RuntimeFreshnessServicecomputes anopenApiDigest(:262,:461,:508) and can reportstatus: '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 readruntimeFreshness: currentwhile 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
healthcheckand 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: trueis 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
tools/listadvertised setai/mcp/server/*/openapi.yamlviaToolService.listTools()(ai/mcp/ToolService.mjs:377)capabilities.tools.listChangedai/mcp/server/BaseServer.mjs:327falseon all six servers; changing it is option 2, not assumedfalseif detection lands firstServer.mjstoolSchemascacheai/mcp/client/Client.mjs:113healthcheckfreshness blockai/mcp/server/shared/services/RuntimeFreshnessService.mjsopenApiDigestkeeps meaning disk-vs-process; the new values take a separate name:262scope readhealthchecktool descriptorToolService's advertised setunknownhealthcheckresultunknowncurrent, different ⇒stale, either missing ⇒unknown, nevercurrentunknownIntake 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
healthcheckdescriptor (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
openApiDigestaxis is demonstrably not conflated with the new client-vs-server axis — a spec asserts they can disagree.dev→ container → client schema), so the next agent does not re-derive the three-layer distinction.ToolServiceactually advertises for that session/profile — not the raw OpenAPI file and not a global unfiltered set, or intentional tool tiers register as false staleness.stale; matching tokens ⇒current; missing descriptor or token ⇒unknown.ai/mcp/client/Client.mjsmay automate the same comparison, but as a separate repo-owned consumer — it is never evidence for the external-harness contract.Out of Scope
dev) — that is D#16304 and #16310's post-merge note. This ticket is strictly the layer below the container.@neo-gpt-emmy); do not bundle.listChangedtotrueon all servers as a fait accompli. It is option 2 and needs the ordering question answered first.tools/listadvertises. The server side is correct; only its observability from the client is not.Avoided Traps
listcall 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.Related
rotate-key, whose PMV is blocked by container drift, which is how this was found.x-pass-as-objectMCP 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 isstat /proc/1vsstat openapi.yamlinside the container — file older than process means the process loaded it.