I run AgentShortlist, a directory of MCP servers where every line has a source. This is our own data.
A hash of a server's tool descriptions can change between two runs without anyone touching the code. We saw it with Desktop Commander 0.3.1. Across two runs only the description of start_process differed, and the only difference in that text was a Docker container ID: fd1cb95d1f61 in one run, 6f6d4f1f57de in the other. It sits at character 1348 of a description 4180 characters long. The schemas and the other 24 descriptions were identical.
Our first reading was that something had changed in the tool. It had not. The server writes its container hostname into the description, so the text depends on where it runs. This is an effect of the environment, not a vulnerability and not a sign of risk. We corrected our own notes the same day.
The practical lesson is for anyone who watches MCP servers for changes by hashing tools/list: compare hashes taken in the same environment, or strip hostnames and paths before hashing. Otherwise a harmless container ID looks like a changed tool.
The rest of this post is what else those runs recorded.
What we ran
On 25 October 2026 we installed 10 servers from our verified cards in Docker on a Windows machine with WSL2, with no credentials and only synthetic data. Seven ran in node:22-slim. Three (excel, fetch, git) ran in an image with uv and Python 3.12; the git one also needed git installed, and a first attempt without it did not start.
For each we sent initialize and tools/list with our probe script and stored the tool names and a sha256 of the canonical list (sorted by name and by key), with separate hashes for descriptions, schemas and both together. The records are on each card under "Tested by us".
All 10 passed. Here "pass" means three things: the package installed and started, it answered the handshake with a valid protocol version, and it returned a tool list. It does not mean the tools work, that they do what their descriptions say, or that any limit the project documents holds. In this first run we called no tools. A second run later the same day added one harmless call, with synthetic data, to four of the servers (memory, everything, git and fetch); the result is on their cards.
| Server | Version | Tools listed |
|---|---|---|
| excel-mcp-server | 2.2.2 | 46 |
| chrome-devtools-mcp | 1.12.0 | 32 |
| @playwright/mcp | 0.0.83 | 25 |
| Desktop Commander | 0.3.1 | 25 |
| codebase-memory-mcp | 0.12.1 | 17 |
| server-everything | 2.0.0 | 16 |
| mcp-server-git | 2026.10.19 | 12 |
| server-memory | 2.0.0 | 9 |
| Context7 | 4.4.4 | 2 |
| mcp-server-fetch | 2026.10.19 | 1 |
Playwright's count is its default tool set. Its README lists more behind optional --caps groups, which we did not enable.
Same hash twice, or only once
Eight servers (chrome-devtools, codebase-memory, Context7, excel, memory, everything, git and fetch) gave identical hashes in two runs. Desktop Commander was run twice and differed, as above. For Playwright our record does not show a second run, so a repeat could still differ. One matching pair is weak evidence of stability, not proof.
What the names say
From the tool names alone we group tools by what they appear to do. The rules are in the open and deliberately simple: a name such as start_process counts as running commands, write_file as writing files. These groups come from names, not from what the tools do inside, and not from any limit the server enforces. A tool called read_file might refuse most paths; we did not test that.
With that caveat, some names are worth knowing before you connect a server:
-
Desktop Commander: 7 tool names that start, talk to or stop processes (
start_process,interact_with_process,kill_process,force_terminate,list_processes,read_process_output,list_sessions), 5 that write files or folders, and 2 that read or set its own config. -
Playwright:
browser_evaluateandbrowser_run_code_unsafe, two tools that run code. The second one has "unsafe" in its name; we only know what the name says. -
Chrome DevTools MCP:
evaluate_scriptis the one name our rules treat as running code, among 32. -
mcp-server-git: 5 names that change repository state (
git_add,git_commit,git_checkout,git_create_branch,git_reset) and 7 that only read. - server-memory: 6 of 9 names create, add or delete stored data.
-
server-everything: one name,
get-env, says it returns environment variables. We did not call it. The project is a reference server for testing clients, so this is not a statement about what it exposes in normal use. -
mcp-server-fetch: one tool,
fetch, which reaches the network by its name.
The grouping has gaps. For codebase-memory, 16 of 17 names match no group, and for Context7 neither of its two does. That does not mean those tools are harmless; it means our name rules say nothing about them. Cards for those servers show how many tools fall outside any group.
One count that was off
The Desktop Commander card said 24 tools, counted from the source when we wrote it. The running server listed 25 for our test client. We checked the project's src/server.ts on its main branch, where package.json says 0.3.1: it defines 25 distinct tool names, the same 25 the server returned. One of them, give_feedback_to_desktop_commander, is left out when the connecting client is named desktop-commander-app, so for that client the list has 24. The card will say 25, with a note that the count depends on the client.
What this does not show
Apart from one harmless call on four of the servers, we did not test whether any tool works, what it does with real data, or whether it is safe to run. Ten passes on one machine on one day say these packages can be installed and answer a basic request in that environment. For any one server, check the card's other lines (source per line, advisories, access type) before you connect it, and use the report link on the card if something here is wrong.
Full post with the per-server cards: https://agentshortlist.dev/blog/10-mcp-servers-in-docker-what-their-tool-lists-tell-you?utm_source=devto. We also publish a short note on MCP advisories, the MCP Risk Radar: https://agentshortlist.dev/radar?utm_source=devto
Top comments (14)
Leaking ephemeral container IDs into tool descriptions isn’t just an idempotency nitpick—it silently wrecks prompt caching at the inference layer. When tool schemas drift across runs, your KV cache hit rate drops to zero, turning what should be a low-latency warm invocation into a full prefix recomputation that inflates TTFT and inference spend. Runtime identifiers belong strictly in telemetry spans or transport headers, keeping the model-facing tool contract strictly deterministic.
Bohdan Hordii // bohdanhordii.dev
that's an angle i hadn't measured. if the tool definitions sit in the prompt prefix, a different id on every new container would change it, and a prefix cache would miss. all we saw is the description changing between two runs in docker, nothing on the model side, so i can't say how much it costs in practice. it is a good reason to keep run-specific values out of descriptions though.
With Claude Code the definitions are deferred, so a drifting description only costs from the turn it gets loaded. With eager loading your prefix point holds. I have not measured the cache miss itself.
I'd keep the raw descriptions alongside the normalized hash. A hostname inside explanatory text can be incidental, but a hostname or path identifying the actual target can change what a tool does. A small paired test could change only the container ID in one case and the permitted target in another: normalization should suppress the first difference without hiding the second. Would a server-specific normalization rule, with a visible raw diff, fit your cards better than stripping all hostnames and paths?
Fair point. We do keep the raw hash: the normalized one only swaps 12-character hex strings (the container ID) and sits next to the raw one, it never replaces it. But stripping every hostname and path would hide a changed target, you're right, and I wrote that too loosely in the post. Your paired test is a nice idea, I haven't run it. Might try it with a dummy server.
@innokentyb ran the paired test. synthetic server with one tool, three variants: a base, one where only a container-style id changes in the description, and one where only the allowed target changes in the input schema. changing the id moved the description hash and the combined one. replacing 12-character hex strings made all three hashes identical again, and the schema hash never moved. changing the target left the descriptions alone and moved the schema hash, raw and normalized. so for this case the normalization removes the incidental difference without hiding the real one. it's a toy, one tool, nothing like a real server. the script and the raw comparisons will be in the public repo soon.
The pass criterion has a protocol revision built into it. Answering initialize with a valid protocol version is how every session opens under the revisions up to 2025-11-25. The current revision, 2026-07-28, removes the initialize and notifications/initialized handshake: every request carries its protocol version, and servers must implement server/discover, which returns the versions and capabilities a server supports and its identity. A server built only for the new revision rejects initialize with an error, so a probe that starts with it would record a failure that belongs to the probe rather than to the server.
Sending server/discover first and falling back to initialize when it errors or times out is the order the spec gives for stdio clients that speak both eras, and it keeps a pass meaning what the post says it means whichever revision the server speaks: installed, started, answered with a valid protocol version and listed its tools. Recording on each card which of the two the server answered, and the version it returned, would also show how many of the ten passes would read the same to a client that speaks only the new revision.
you're right, and i checked the spec before answering. our probe only sends initialize, so a server built only for 2026-07-28 would show up as a failure that belongs to the probe, not the server. everything we recorded answered initialize, so those speak the older era or both. i've changed the probe to send server/discover first and fall back to initialize like the stdio section says, and to record which one answered and the version. so far i've only tried it on a toy server, i'll rerun the real ones soon and put the era on each card.
@izgorodin ran it. 27 servers: 12 answer server/discover, 10 error and then work with initialize, 4 give no answer in 20 s and work with initialize, and one failed on an npm error. for 11 of the 12 modern ones the tool list is identical either way. server-everything isn't: 16 tools with initialize, 22 with discover, same version. one run, one machine, so i wouldn't generalize.
The server-everything split is the result worth putting on its card, because it makes the era part of what a hash means. Same version, 16 tools after initialize and 22 after server/discover: a stability hash over tools/list now has to say which handshake produced the list, or a client that moves to the new revision reads as a changed server while nothing in the package changed. Hashing per era and comparing like with like keeps the Desktop Commander lesson intact on the new axis.
The four that stayed silent for 20 seconds are the case the spec describes for older servers, which may answer server/discover with an error or not at all. Across a directory that is the slow path of every probe, and recording how long each server took to fall back would show whether a shorter wait would mislabel any of them.
agreed on the card. the 22 tool list sits next to the 16-tool one as its own record, with the era on each, so the hash says which handshake made it. it goes live with the next deploy. fair point on the 20 secns too: we only logged total time per run, not how long each server took to answer discover or fall back, so i can't tell yet if a shorter wait would mislabel any. i've added it to the probe.
same version, different description is the kind of drift that makes you distrust the whole run.
yeah, that was my first reaction too. the other 24 descriptions and all the schemas were identical though, and the only difference was a 12-character container id in one description, so the rest of the run held up. still a good reason to look at the raw text before trusting a hash.
that 12-character id detail is what saves the run, hash alone would have called the whole thing drift.