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.
| Tag | Tools | Layer digest | Layer (compressed) | Layers scanned |
|---|---|---|---|---|
| 0.4.0 | 9 | sha256:477265fa7ee1 | 908,385 B | 16 |
| 0.4.6 | 9 | sha256:9cad1b4ac2e5 | 933,702 B | 16 |
| 0.4.9 | 9 | sha256:f70fae95935f | 935,604 B | 16 |
| 0.5.0 | 12 | sha256:d7c770d8f0b5 | 966,351 B | 17 |
| 0.6.0 | 12 | sha256:c8491d23fd3d | 1,033,868 B | 18 |
| 0.7.0 | 12 | sha256:027b1ebf6f7a | 1,033,863 B | 18 |
| 0.8.0 | 15 | sha256:c585f08ef7de | 1,282,144 B | 21 |
| latest | 15 | sha256:e2640984dc60 | 1,283,066 B | 21 |
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_describeagent_governance_listagent_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
-
Ask
ghcr.io/tokenfor a pull-scoped token for the repository:?scope=repository:happy520ai/unified-ai-system/mcp-server:pull. -
GET /v2/<repo>/manifests/<tag>with anAcceptheader 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. -
From the index, pick the
linux/amd64child by its platform object rather than by position, and fetch that child manifest. -
For each layer blob,
GET /v2/<repo>/blobs/<digest>and hash what arrived. Ifsha256(bytes)is not the digest the manifest named, stop - that is the whole point of reading through the registry. -
Gunzip the layer and walk it as ustar by hand (
nameat offset 0,sizean octal field at 124, prefix at 345). Look for a member whose path ends inmcp-server/src/server.js. -
Decode with
TextDecoder- notUint8Array.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 compressedlatest-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/listwith those names - this page now has that reading separately. The repository's own handshake,node tools/mcp-smoke.mjs, starts the stdio entry point, sendsserver/discover, requeststools/list, and compares the returned names, sorted, against the roster it expects. Run on 2026-09-26 at commitfd33ac3fon Node v25.8.1 (Windows), it reportedtoolCount: 15- the same fifteen names as the0.8.0image layer above, none missing and none extra - withprovider: "local-fake-provider",executionMode: "fake",promptEnhancementProviderCalled: false,realProviderCallsMade: false,managedGatewayCleanedUp: trueand 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
initializeafter 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 carrieslinux/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.