SECTION 01

The number in the description is not the number in the image

An MCP client discovers tools by asking the server, so nobody outside the running process ever sees a description field. Catalogues do: they copy a sentence from a README, a registry inherits a one-line description from server.json, and both keep repeating it long after the code moved. We had three of those sentences pointing at an old count at the same time - ours, a directory's, and a plugin manifest - and none of them was a lie when written.

The fix is not to stop writing numbers. It is to make the number checkable by someone who has neither our source nor our credentials, which means reading the published artifact. That is a container image, and a container image is a tarball with a manifest in front of it.

SECTION 02 / THE DATA

Eight tags, measured on 2026-09-26

Each row below is a run of node tools/verify-image-roster.mjs <tag> against ghcr.io/happy520ai/unified-ai-system/mcp-server. The roster is the MCP_TOOL_NAMES array in app/packages/mcp-server/src/server.js as it exists inside that image - the same path in all eight rows.

Tool count per published tag, read from the image layer that ships it.
Tag Tools Layer digest Layer (compressed) Layers scanned
0.4.09sha256:477265fa7ee1908,385 B16
0.4.69sha256:9cad1b4ac2e5933,702 B16
0.4.99sha256:f70fae95935f935,604 B16
0.5.012sha256:d7c770d8f0b5966,351 B17
0.6.012sha256:c8491d23fd3d1,033,868 B18
0.7.012sha256:027b1ebf6f7a1,033,863 B18
0.8.015sha256:c585f08ef7de1,282,144 B21
latest15sha256:e2640984dc601,283,066 B21

Two of those rows deserve to be adjacent: 0.7.0 and 0.6.0 differ by five bytes in the layer that carries the roster and by nothing observable in the interface, so a description naming twelve of them was true of both and false of neither. The step happens at 0.5.0 and again at 0.8.0.

SECTION 03 / THE DIFF

What each step added

The three names that appear at 0.5.0 and stay:

  • gateway_prompt_enhance_llm - the model-backed enhancement path, next to the deterministic one.
  • knowledge_retrieve - retrieval against the indexed corpus, separated from readiness.
  • workflow_run - executing a workflow rather than only describing its actions.

The three that arrive at 0.8.0, all of them the governance surface:

  • agent_governance_describe
  • agent_governance_list
  • agent_governance_status

That ordering is the honest history of the project: routing and enhancement shipped first, execution second, and the part that makes an agent's authority inspectable last. Nothing was dropped on the way - the nine names present in 0.4.0 are all still in 0.8.0, which is the one property of this table a client upgrade depends on. It is also why "the gateway has N tools" was a sentence that kept needing repair: N was the interesting variable.

SECTION 04 / THE METHOD

Six steps, no Docker

  1. Ask ghcr.io/token for a pull-scoped token for the repository: ?scope=repository:happy520ai/unified-ai-system/mcp-server:pull.
  2. GET /v2/<repo>/manifests/<tag> with an Accept header that lists both the OCI index and the Docker manifest-list media types. Omit them and a perfectly published image answers 404 - a false negative that looks like an absent release.
  3. From the index, pick the linux/amd64 child by its platform object rather than by position, and fetch that child manifest.
  4. For each layer blob, GET /v2/<repo>/blobs/<digest> and hash what arrived. If sha256(bytes) is not the digest the manifest named, stop - that is the whole point of reading through the registry.
  5. Gunzip the layer and walk it as ustar by hand (name at offset 0, size an octal field at 124, prefix at 345). Look for a member whose path ends in mcp-server/src/server.js.
  6. Decode with TextDecoder - not Uint8Array.prototype.toString, which does not decode - and parse the roster.

The last step is the only one with a judgement in it, and it is where we were wrong first.

SECTION 05 / THE BUG

The first version counted everything after the array

The first probe was a regular expression that started at MCP_TOOL_NAMES and ran to the end of the file, collecting quoted strings. That sweeps up tool descriptions, config keys and error literals sitting after the array, so it returns a number larger than the image's real roster - on our file, larger by every string that happens to look like a snake_case identifier. The same class of mistake appeared separately when a count was searched for by substring across an aggregate plugin index: it attributed tool counts to servers that were not ours, because the match had no enclosing object.

The rule that came out of it: a parser must be bounded by the syntax it claims to read, and a count must be scoped to the record it belongs to. rosterFromSource() takes the array literal and nothing else - it stops at the closing ]) - and returns null when the marker is absent rather than an empty list, so "this file has no roster" and "this roster is empty" stay different answers. Both are pinned by tests, and one of them builds a source file that the bounded parser reads as fifteen names while a greedy regex over the same bytes reads more - the assertion is that the two disagree, so the test cannot pass by accident.

node tools/verify-image-roster.mjs 0.8.0

Expected: a line reading tools 15. Run it against 0.4.0 and it reads tools 9 with no other judgement - the tool reports what the artifact contains, not what is current.

SECTION 06 / THE PART THAT MATTERS MORE

latest and 0.8.0 are not the same bytes

Both expose the same fifteen names. Their roster layers are different:

  • 0.8.0 - sha256:c585f08ef7de5b8682599839204bc87d0b1b89f23f774cdee4a981c3d88ba408, 1,282,144 bytes compressed
  • latest - sha256:e2640984dc601d3c91414dc9830b182d2554b4d9238cc1d436942d65017c2d74, 1,283,066 bytes compressed

Same interface, same source file, different artifact. Any instruction that says "pull latest" therefore describes a moving target, and any security review that names a tag has reviewed nothing yet. This is why the install skill pins what it recommends (mcp-server@sha256:751a0d32…) instead of a floating tag, why the image review notes record the OCI-index and per-platform digests side by side, and why every command on this site names a version rather than latest. A reader should be able to say which bytes they ran.

SECTION 07 / BOUNDARY

What this does not establish

It reads source that the image ships. That is stronger than a README, and the server from what this page still cannot tell you:

  • It does not prove the server answers tools/list with those names - this page now has that reading separately. The repository's own handshake, node tools/mcp-smoke.mjs, starts the stdio entry point, sends server/discover, requests tools/list, and compares the returned names, sorted, against the roster it expects. Run on 2026-09-26 at commit fd33ac3f on Node v25.8.1 (Windows), it reported toolCount: 15 - the same fifteen names as the 0.8.0 image layer above, none missing and none extra - with provider: "local-fake-provider", executionMode: "fake", promptEnhancementProviderCalled: false, realProviderCallsMade: false, managedGatewayCleanedUp: true and exit code 0.
  • What the handshake still does not close: it runs the source in a working tree, not the bytes inside the published image, so the two readings agree about the same fifteen names without one being executed inside the other. Only a handshake against the container connects them, and that was not run here because no Docker daemon was available on the machine doing this work.
  • One number a client will feel: on this machine the stdio server answered initialize after 7.5-8.5 seconds across four consecutive runs, cold and warm alike, because the entry point starts a managed gateway and polls its health (250 ms ticks, 30 s budget) before it serves anything. It is a stable cost, not startup jitter, and a client with a short MCP startup timeout may show the server as failing to start while it is still booting.
  • It does not attest the build. The digests are checked against the manifest, and the manifest is what the registry served - a registry-side substitution would need a signature to notice.
  • It says nothing about the other architecture. Each row reads one child manifest for linux/amd64; the index also carries linux/arm64.

And it is not a general "is this image safe" claim. It answers exactly one question - which tools does the shipped server declare - because that was the question a stale catalogue entry was getting wrong.

SECTION 08 / TRY IT

Reproduce a row

The script is dependency-free ESM, so it runs from a clone with any Node that supports fetch. It makes anonymous read requests to ghcr.io and writes nothing:

git clone --depth 1 https://github.com/happy520ai/unified-ai-system.git && cd unified-ai-system && node tools/verify-image-roster.mjs 0.8.0

Point it at any public image with --repository owner/name/repo. The tests around it - node --test tools/verify-image-roster.test.mjs - include the greedy-parser case and a hand-built ustar layer, so the reader can check the instrument without pulling anything.

A Public Preview of self-hosted software. Real providers are disabled by default, and no part of this page claims production readiness, L5 autonomy or AGI.