Skip to content

serverInfo.version and server.json disagree, and neither moves when the tool surface does #573

Closed

Description

@serge-ivo

The MCP server reports two different versions, and neither has moved since June

initialize is the only moment a client learns what build it is talking to. Ours answers with
a version that is both wrong and stale, in two disagreeing places.

Verified

Two values, one server:

  • workers/mcp/src/index.ts:43 — new McpServer({ name: "ProAgentStore", version: "0.1.0" }, …)
    — this is what a connecting client reads from serverInfo.version.
  • server.json:6 — "version": "0.1.1" — this is the public MCP-registry manifest, published by
    .github/workflows/publish-mcp-registry.yml on any push touching that file.

They have never agreed. server.json was last touched by 45c2c4d (2026-06-23), a commit about
parse-tool-calls tests. index.ts's 0.1.0 predates it.

Four commits today changed what the surface serves, and neither version moved:

SHA Change
951ef59 every tool re-registered via registerTool; server instructions added
b2b0ac4 readOnlyHint on all 135 tools
778c5d2 destructiveHint on runtime tools
e392754 outputSchema + structuredContent on list_agents / my_instances

e392754 in particular changed a response shape — my_instances went from a bare array to
{"instances":[…]}. A client that cached the old contract has no signal that anything changed.

Why this is more than hygiene

Hosts cache tool metadata. That is not a hypothesis: the ProAgentStore connector in ChatGPT
served the pre-b2b0ac4 unannotated tool list until manually refreshed, because the annotations
landed with no version change to invalidate against. "Hit Refresh" is currently the only
invalidation mechanism this server offers, and it requires a human who already knows something
shipped.

A monotonic serverInfo.version is the standard signal here, and we serve a constant.

Acceptance criteria

  1. index.ts:43 and server.json derive from one source rather than two hand-typed
    literals, so they cannot disagree again.
  2. That version changes when the served surface changes. Decide and state what "changes" means —
    tool count, annotation set, and declared outputSchema are the candidates, since those are
    what a caching host keys on. A commit that only edits a handler's internals need not bump it.
  3. A docs-drift.mjs check in its existing style: the advertised version, the manifest version,
    and any documented version claim agree. Per ADR 0002 the success line states its denominator,
    and the input set is asserted rather than assumed.
  4. The check goes red when the two literals are edited apart, demonstrated.
  5. Decide whether publish-mcp-registry.yml should fire on an MCP surface change rather than
    only on a server.json edit — today the registry entry can only be refreshed by remembering
    to touch one file by hand. If the answer is no, say why in the issue.

Verified vs inferred

  • Verified: both literals and their file:line, server.json's last-touching commit and
    date, the four SHAs and what each changed, the my_instances shape change (measured live
    2026-08-15), and the registry workflow's paths: trigger.
  • Inferred: that a moving serverInfo.version would have caused ChatGPT to re-fetch. The
    stale-cache symptom is observed and the absent version signal is verified; that a bump would
    specifically have invalidated it is standard host behaviour but was not tested against
    OpenAI's client.

Activity

  1. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Landed in three commits: d4efd1d (AC1–4), 53b7ed7 (AC5 fallout, below), 43376c8 (a fourth statement of the version the first pass missed). CI green on all three; Deploy MCP Worker green.

    A measurement that makes this concrete rather than hygienic

    GET https://registry.modelcontextprotocol.io/v0/servers?search=ProAgentStore returns two entries for io.github.ProAgentStore/platform:

    version publishedAt isLatest
    0.1.0 2026-06-20T12:34:01Z false
    0.1.1 2026-06-20T21:36:48Z true

    So index.ts was not merely carrying a different number from server.json — it was advertising the entry the registry has itself superseded. A client that discovered this server as 0.1.1 and connected was told by serverInfo it had reached 0.1.0. That is a contradiction between two surfaces we both publish, and unlike the caching argument it requires no inference. It also settles which literal wins: the registry's latest, because the alternative downgrades a public artefact. (This corrects a claim in the first draft that 0.1.0 "was never durably published" — it was.)

    AC1 — one constant, MCP_SERVER_VERSION in workers/mcp/src/server-version.ts. index.ts imports it; server.json and the docs restate it and docs-drift enforces agreement. index.ts is modelled as a binding, not a restatement, so it fails even when a re-typed literal agrees — two values that happen to match today is the state this issue is about, not the fix.

    AC2 — the rule is in the constant's docstring, stated as "every field of the published tool definition except description, plus the server instructions" rather than an enumeration, so it stays true when the spec adds a field. Explicitly not a bump: a reworded description (making every wording fix a bump trains people to skip the bump), handler internals, and title (derived from the name). #565's usage_summary rewrite, which landed in this same batch, is therefore correctly not a bump.

    AC3/AC4 — the check shares one design with #572's; see that issue for the mechanism. Red demo reproduced by me post-rebase, re-typing the literal:

    ✗ [wire-surface] advertised version: workers/mcp/src/index.ts — advertises the literal "0.1.0" instead of MCP_SERVER_VERSION.
    ✗ [wire-surface] advertised version: server.json gives the wrong value for 1 version(s):
        serverInfo.version: says "0.1.2", the code defines "0.1.1"
    

    Version not bumped. Reconciling two literals is not the same act as picking a new number.

    AC5 — and the red build it produced, which is the sharpest thing here

    The answer is no, do not fire on workers/mcp/** — reasoning now a comment in the workflow. The manifest is a pointer to the endpoint and does not publish the tool list, so a surface change alters nothing in it except the version; and the version cannot move without server.json moving, because the constant is the one source and docs-drift is a required gate. "The advertised version bumped" and "server.json changed in this push" are the same event.

    Then the push proved the trigger set was already broken. d4efd1d's only change to that workflow was the comment block explaining this decision — and that was enough to fire it, because the workflow listed its own path as a trigger:

    Error: publish failed: server returned status 400:
      {"errors":[{"message":"invalid version: cannot publish duplicate version"}]}
    

    Editing a workflow cannot change the manifest's version, so that trigger could only ever no-op or fail. 53b7ed7 removes the self-path, and goes further: the registry is append-only per version, so any server.json edit that does not move the version — a reworded description, a changed title — fails identically. "Already published" is a correct outcome and must not be a red build, because a workflow that is red for a correct outcome is one people stop reading. The job now asks the registry what it holds and skips when it matches, stating what it measured (name, entries seen, latest published, version wanted) and failing rather than skipping if the registry is unreachable or unparseable — set -euo pipefail, curl --fail, jq -e. Verified live: 2 entries, latest 0.1.1, wants 0.1.1 → skip; a bumped value still publishes. Confirmed in production: neither of the two subsequent pushes fired the workflow.

    The first pass missed a fourth statement of the version — same class as check 6's miss

    store/.well-known/mcp-server.json is a separate committed file with its own hand-typed version, served publicly at https://proagentstore.online/.well-known/mcp-server.json (workers/host/build.js:155 reads it, src/index.ts:204 routes it, pages.ts's llms.txt advertises it as "MCP discovery"). It serves 0.1.1, verified live — so nothing was broken, and that is the problem: a guard comparing three of four statements and passing is certifying ground it never walked. It is the same miss on the same class of file as the one recorded in docs-drift.mjs's own header, where check 6 printed a green tick over three hand-written paths while proagentstore.online told visitors the server had "~67 tools". 43376c8 adds it as a table entry, with its own red demonstration.

    All four public statements now agree at 0.1.1: serverInfo (source), server.json, the served /.well-known/mcp-server.json, and the docs page.

    Left open — AC2 is stated but not enforced

    Nothing fails when the surface moves and the version does not; docs-drift enforces only that the statements of the version agree. The check that would enforce it is a committed fingerprint over the published tool definitions minus description, ratcheted against MCP_SERVER_VERSION — and conformance.test.ts already holds exactly the wire objects it would be taken from. Deliberately not built here: it is a second ratchet, it belongs in a file this work did not own, and landing it untested as a rider on this fix is the failure mode ADR 0002 is about. It should be its own issue, and this one stays open until that call is made.

    Not verified

    serverInfo.version on the wire. Unauthenticated POST /mcp initialize returns 401 invalid_token (www-authenticate: Bearer realm="OAuth"), so both the old and new values are read from source. Public MCP facts available for verification are /health ({"ok":true,"service":"proagentstore-mcp","tools":135}, green after deploy) and the two manifests. Confirming the advertised value needs one authenticated initialize.

    Also unverified and correctly labelled inferred in the code: that a moving serverInfo.version would specifically have made ChatGPT re-fetch. It is the standard host mechanism; it was not tested against OpenAI's client.

  2. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    AC2 is now built and enforced — 1d5cb24. CI green, Deploy MCP Worker green. This closes the gap I left open earlier today.

    The two decisions, stated

    1. What is in the fingerprint: everything a client receives EXCEPT description, plus SERVER_INSTRUCTIONS.

    Not an enumerated field list, deliberately — a list drifts from what the SDK actually publishes, and this issue exists because two hand-maintained restatements of one fact disagreed for two months. The hash is taken over the real tools/list objects with description deleted, so a field the MCP SDK starts publishing tomorrow is inside the fingerprint the day it appears. In practice that covers the tool name set, inputSchema, annotations, outputSchema and title — exactly what MCP_SERVER_VERSION's docstring already declared the surface to be, so the rule and the mechanism cannot come apart.

    description is excluded as a decision, not an oversight, and the code says so: a description is prose a model reads, not a contract a client builds against. #565 rewrote usage_summary's description hours before this landed and correctly did not bump the version — a test asserts that rewording it leaves the hash byte-identical, which is what makes that correct rather than merely tolerated. Making every wording fix a bump trains people to skip the bump.

    2. On mismatch it FAILS and names the fix. It prints the computed hash and says "bump MCP_SERVER_VERSION, add a new SURFACE_LOCK entry". It does not auto-bump: a number that changes without anyone deciding it changed is how a version stops meaning anything, which is the state this issue was filed about.

    It earned itself before it landed

    Rebasing onto #574 — which changed the write tools' inputSchema and bumped MCP_SERVER_VERSION to 0.1.2 by hand, correctly, hours earlier — failed with:

    × has a lock entry for the version it advertises
      → surface-lock.ts has no entry for MCP_SERVER_VERSION 0.1.2 (it records: 0.1.1).
        A bumped version needs a NEW entry recording the surface it publishes —
        that pairing is the whole mechanism.
      computed: sha256:fa4b1a42a18b7b0bc9f52f81d4443b6a934a898bf0e1fcd1dcaf9d0d610405bb
    

    That is the mechanism working on a real bump nobody staged for it. Both entries are kept — only the current version's is compared, but a map that remembers is what makes editing a published version's surface a visible act rather than a one-line "update the fingerprint".

    What it guarantees, and what it does not

    It makes a surface change impossible to ship silently: the build goes red and the failure names the bump. It does not mechanically prove the version moved, because the entry can be edited in place. That is why the lock is keyed by version rather than being a bare hash. Forcing the pair to move together needs a git-history arm of the kind check-migrations.mjs --require-history carries — named in the code as separate work rather than left implied.

    Home: conformance.test.ts, and why not a new file

    The hash must be taken over the objects a host actually receives, and that is where those objects are. A separate file would need either a second harness — a reconstruction, which that file's header argues at length is worthless — or an extracted helper that tsconfig.json's src/**/*.test.ts exclude would not cover, dragging build config into a test's plumbing. Three claims measured off one honest reading of the wire beats three files measuring three different things. The file header now declares the third layer and why it sits there. (workers/mcp/src/instance-tools/ was the contended directory; conformance.test.ts was untouched since #561.)

    Guards, per ADR 0002

    • G1: the lock must hold an entry for the advertised version, or the comparison is undefined === undefined — a check that stopped running, not a green build.
    • G4, three times: the placeholder entry (locked: PLACEHOLDER); a real surface change (one sentence appended to SERVER_INSTRUCTIONS, version untouched) reproducing the full failure with the bump instruction; and A ticket's reasoning can be written over MCP and never read back #574 above, unplanned.
    • A property arm asserting all seven classes of surface change move the hash — tool added, removed, renamed; annotation flipped; outputSchema appearing; inputSchema changed; instructions changed — and that key reorder does not, because a ratchet that cries wolf is one people regenerate without reading.

    Two other things confirmed live

    • server-version.ts's "nothing enforces the bump today" is now false and has been replaced with which check enforces which half: docs-drift proves the four statements agree, the lock proves the number has to move. workers/mcp/CLAUDE.md gained the regeneration instruction.
    • The AC5 publish guard (53b7ed7) was validated in both directions within the hour. It skipped correctly on my own pushes (version unchanged), and then published correctly on A ticket's reasoning can be written over MCP and never read back #574's bump — the registry now serves 0.1.2 as isLatest, alongside the superseded 0.1.0 and 0.1.1.

    I am leaving this issue open only if you want the git-history arm tracked here; otherwise every stated acceptance criterion (AC1–AC5, plus AC2's enforcement) is now delivered and it can be closed.

  3. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Closing. Every acceptance criterion walked against the code on main today, with the commit that satisfied each.

    AC Commit Evidence
    1 — one source, cannot disagree again d4efd1d index.ts:44 reads version: MCP_SERVER_VERSION; server-version.ts:82 is the only literal. index.ts is modelled as a binding, not a restatement, so it fails even when a re-typed literal agrees — two values that happen to match is the state this issue is about, not the fix.
    2 — the version moves when the surface does, and "changes" is stated d4efd1d decided + stated it; 1d5cb24 enforced it; 6000b97 (#576) closed the rewrite hole The rule lives in MCP_SERVER_VERSION's docstring as "every field of a published tool definition except description, plus the server instructions" — a rule, not an enumeration, so it stays true when the spec adds a field. Enforced by a fingerprint over the real tools/list objects, now append-only.
    3 — advertised, manifest and documented versions agree, ADR 0002 denominator d4efd1d, extended by 43376c8 ✓ wire surface — advertised version: 1 version(s) from 1 code file(s) == server.json (1), store/.well-known/mcp-server.json (1), platform-docs/mcp.md (1), advertised from workers/mcp/src/index.ts. The first pass compared three of four statements — 43376c8 added the served /.well-known manifest, which agreed at the time and was therefore exactly the kind of file that hides.
    4 — red when the literals are edited apart, demonstrated d4efd1d Demonstrated then, and re-verified on today's main just now: setting server.json to 0.1.9 gives ✗ [wire-surface] advertised version: server.json gives the wrong value for 1 version(s): serverInfo.version: says "0.1.9", the code defines "0.1.2", pnpm docs:drift exit 1. Re-typing the literal into the constructor reds separately with advertises the literal "0.1.0" instead of MCP_SERVER_VERSION.
    5 — decide the registry workflow's trigger 53b7ed7 No, it should not fire on workers/mcp/**. Reasoning in the workflow: the manifest is a pointer to the endpoint and does not publish the tool list, so a surface change alters nothing in it except the version — and the version cannot move without server.json moving, since the constant is the one source and docs-drift is a required gate. "The advertised version bumped" and "server.json changed in this push" are the same event.

    Verified in production

    All four statements now agree at 0.1.2 — serverInfo (source), server.json, the served https://proagentstore.online/.well-known/mcp-server.json, and the docs page. The public registry's isLatest is 0.1.2, alongside the superseded 0.1.0 and 0.1.1.

    The AC5 guard was validated in both directions within an hour of landing: it skipped correctly on pushes that did not move the version, and published correctly on #574's bump to 0.1.2. That guard also fixed a red build I caused — d4efd1d triggered the workflow via its own paths: self-reference and got invalid version: cannot publish duplicate version, which turned out to be a pre-existing trap that would have fired on the next description typo.

    What this issue is NOT held open for

    The rewrite-in-place gap — that the lock could be edited rather than appended to — was written into 1d5cb24's header as a known limit, filed as #576, and is now closed by 6000b97 with a CI gate (Surface lock is append-only (#576): success). Before building it, an in-place rewrite plus a real SERVER_INSTRUCTIONS change was measured passing all twelve other gates, so the gap was real rather than theoretical.

    One claim that stayed inferred throughout

    That a moving serverInfo.version would specifically have made ChatGPT re-fetch. It is the standard host mechanism and the stale-cache symptom was observed, but it was never tested against OpenAI's client — and it is labelled that way in server-version.ts rather than hardened into a measured cause. The evidence that carried this issue in the end needed no inference at all: the registry held 0.1.0 and 0.1.1 simultaneously, and the server was advertising the entry the registry had itself superseded.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions