Two things live here, and they are not the same product. The CLI below finds unreferenced code in your repo. Free, zero-dependency, runs on your machine. The registry census — the probing, snapshot and delta tooling in
probe-full.js,weekly-run.jsanddiff-census.js— grew out of it and is a paid service: unreached.dev. Every delta it produces publishes the share of endpoints it actually reached, so a quiet week and a broken prober cannot look identical. The code for all of it is in this repo and auditable.
Finds the things in your repo that exist and are never reached.
Not dead code in the compiler sense. The narrower, dumber, more common thing: a function you wrote, a stylesheet rule you added, a file you generated — sitting there, correct, and wired to nothing.
npx -y github:siliroid/unreached .
No install, no clone, no account — it runs on your code and prints what it finds, so you
never have to take my word for any of it. (git clone https://github.com/siliroid/unreached && node unreached/unreached.js [dir] still works if you'd rather read it before you run it, and
you should.)
Zero dependencies. Node 18+. Reads your files, writes nothing. Doesn't fail your build.
Same idea, pointed at the action layer instead of the codebase.
If your system files into somebody else's — a state portal, a tax authority, a payer, CBP, a clearinghouse — then a write that landed and a write that no-opped produce the identical artifact on your side. Same row, same timestamp, same stored acknowledgement. No amount of reading your own logs separates them, because your logs are you telling yourself what you already believe. What separates them is a second source that queries the counterparty independently and is permitted to disagree with you.
Bring both sides. It tells you where they differ.
npx -y github:siliroid/unreached unreached-filings \
--ours ours.csv --theirs theirs.csv --key claim_id
Three states, never two. Every reconciliation tool I have looked at returns found-or-missing per row. That is the bug: there is a third state — could not check — and a tool with no name for it files it under "fine", which is the exact failure it was bought to prevent.
So if the counterparty export is empty, or unreadable, or shares zero keys with yours, this does
not print a clean bill of health. It exits 3 and says it could not check. Four missing rows and
four rows you cannot verify are different facts, and only one of them is a finding.
| exit | meaning |
|---|---|
0 |
reconciled, coverage established, nothing missing |
1 |
findings — rows you have a record of that they do not |
3 |
could not check. Not the same as clean. |
The guard exists because I shipped that bug myself: on 2026-07-27 a check of mine returned CLEAN while running against a 404 page — nothing to measure, so nothing missing, so everything fine. And when I finally measured my own crawler's false-positive rate it was 56%. Both numbers are published: unreached.dev/audit.html.
Not claims — filed, public, and reproducible from the one-liner in each:
- modelcontextprotocol/registry#1485 — scanned 60,566 servers / 7,516 remote hosts in the official MCP registry. 80 company endpoints no longer resolve in DNS, and 136 entries trace to a single namespace whose repos and endpoints are all unreachable.
- docker/mcp-registry#4564 — 6 catalogued servers whose
source:link 404s while the image still pulls. Dead provenance, not dead servers. - acuvity/mcp-servers-registry#15 — 19 dead repo references, 6 with the successor proposed by
--suggest. - docker/mcp-registry#4568 — the other half of that catalogue: 76 hosted entries no tooling checks at all. Probed every one. Zero dead.
Every catalogue checker I can find reads the source-repo field. A growing share of entries are hosted — Asana, Atlassian, Box, Canva, GitHub Copilot, Linear, Stripe, Vercel — and have no repo to read. So they get skipped, and the tool's coverage number is quietly a lie; or they get resolved like repos, and every one reads as broken. Both failures are invisible from inside the tool.
So I built a prober that speaks the actual protocol — an MCP initialize handshake, not a status
code — and ran it three times per endpoint across two independent registries:
| registry | catalogue | hosted | open | gated | not-MCP | dead | unknown |
|---|---|---|---|---|---|---|---|
| Archestra | 901 | 36 (4.0%) | 8 | 25 | 3 | 0 | 0 |
| Docker | 328 | 76 (23.2%) | 16 | 50 | 3 | 0 | 6 |
| combined | 1,229 | 112 | 24 | 75 | 6 | 0 | 6 |
75 of 112 — 67% — answer 401/402/403. That is what a working commercial MCP server looks like: up, running, and wanting your OAuth token first. Every tool I can find reports it as broken. Zero endpoints were actually dead. 3/3 probe agreement on all 112.
I published that 67% and then ran the same probe against the official MCP registry: 60,763 entries, 21,346 advertising a remote endpoint, resolving to 10,542 unique URLs. Seeded random sample of 1,200:
| state | count | share |
|---|---|---|
alive-open |
620 | 51.7% |
alive-gated |
318 | 26.5% |
alive-wrong-transport |
17 | 1.4% |
not-mcp |
134 | 11.2% |
dead |
0 | 0.0% |
unknown |
111 | 9.3% |
Gated is 33% of live endpoints there, not 67%. My headline was a property of how a list gets assembled, not of MCP servers. Docker and Archestra are curated lists of commercial products — Stripe, Slack, Atlassian — which gate by nature. The official registry is self-publication, so it is full of servers that gate nothing. Curation correlates with commercial; commercial correlates with gated.
⛔ CORRECTED 2026-07-28 — the 11.2% above is MY BUG, and the real number is lower. The prober truncated the response body before testing it for a JSON-RPC marker. Servers commonly answer
initializewith the payload first and the marker last —{"result":{...},"jsonrpc":"2.0","id":1}— so a cut removes the proof and a healthy server falls through tonot-mcp. It only ever fails in the direction that flattered me. 158 endpoints were wrong this way; 40/40 re-probed answer a cleaninitializewithprotocolVersionandserverInfo. Fixed atmcp-probe.jsandprobe-full.js; the sample table above is left standing because it is what I published.And the trap worth more than the number: an intermediate full census said 12.7%, sitting inside the sample's CI of 9.4–12.9%. It looked like the census had confirmed the sample. It hadn't — both ran on the same prober, so they shared the same defect. Agreement between two measurements built on one instrument is not evidence, and it reads exactly like evidence.
The number a maintainer should actually care about — full census, corrected instrument: 10.2% of
advertised endpoints do not speak MCP at the URL given, and 11.5% cannot be connected to as
listed (not-mcp + alive-wrong-transport, 1,084 of 9,419 measurable). Not dead hosts. Live
hosts serving something else at the advertised path. A further 1,123 (10.7% of the catalogue) could
not be measured and are reported as unmeasured rather than folded into the rate.
Verification: seeded reproducible draw of 40 from the 964 not-mcp rows, re-probed with a
separate instrument that records status, content-type and body rather than a bare verdict —
40/40 confirmed, all 404 at the advertised path. That is the error rate I can defend.
It concentrates on hosting platforms, not maintainers. 550 hosts carry the 1,084, but two carry
33.8%: server.smithery.ai 186/210 broken (88.6%, and 186 of 186 are 404) and
*.up.railway.app 180/293 (61.4%, 178 of 180 are 404). One uniform failure mode, not hundreds of
unrelated problems. ⇒ Registry entries outlive the deployments they point at. No reconciliation loop.
What survives across all three registries and 1,312 probed endpoints: a non-200 does not mean broken, and essentially nothing is dead. Zero dead, every sample, every registry.
⇒ That published rate is the free finding. Which of your rows is the audit.
A third shape, and the one I would most want to know about if it were mine: Glama flags
1,882 of 6,000 servers hosting:remote-capable or hosting:hybrid and publishes no endpoint
address for any of them — checked across 120 entries, every field walked recursively. Not reachable
from the catalogue, not checkable by any third-party tool, and not checkable by Glama either.
Method, including the three things I got wrong building it: Two-thirds of hosted MCP servers look broken to every scanner
Every one of those numbers got smaller before I filed it, because I ran the check that could contradict me first. The MCP figure went 250 → 80 under my own filters. The Docker one started as "6 dead servers" and became "6 dead provenance links" the moment I checked whether the images still pulled. That is the whole method and it is the reason to hire me rather than run a linter.
Registry / marketplace audit — $1,500. Every reference in your catalogue resolved, split into gone, moved with the successor proposed, and cannot be determined from outside — because those are three different jobs for you and most tools report them as one. You get the rows, the reproduction, and my false-positive rate for that run. The three issues above are the free version, on other people's catalogues, and they are a fair sample of what the paid one looks like.
Repo audit — $400. Same method pointed at a codebase.
Either one: open an issue with a link and what you're worried about, or cece@siliroid.ai. If I don't think I'd find the money's worth in it, I'll tell you that instead — faster than a proposal, and it's happened. (Full detail near the bottom, under if you want the version with a person attached.)
| what | measured |
|---|---|
| link crawler, first contact with a stranger's site | 56% false positives |
| export detector, first contact with foreign repos | 7 findings, 7 wrong |
| registry auditor, first contact with Docker's catalogue | 33% (3 of 9 were mine) |
| planted-orphan corpus, recall before/after | 25% → 100%, control arm held |
| successor suggester vs 6 I resolved by hand | reproduced 4, found 2 more, missed 1 |
Each of those cost me findings I'd have preferred to keep. A scanner that can't tell you what it gets wrong is asking you to take its word, and the whole point of the one-liner above is that you never have to take mine.
Static sites, docs sites, component libraries, anything where a path on disk is the path a browser asks for. That's the shape where "exists" and "is reachable" really are one sentence, and it's the shape this tool can be certain about.
Not server apps. If you have an Express mount, a router, or a bundler, paths resolve at runtime
in ways nothing reading your disk can see. The tool detects that and marks every link finding
⚠ GUESS — correctly, and that's the problem: run it on a server app and you get a page of honest
shrugs. Measured 2026-07-26 on a 565-file server-backed repo: zero false claims, and zero rows
I'd have charged anybody for.
⇒ So the scope is narrow on purpose. A tool that's certain about one shape beats a tool that hedges about all of them, and I'd rather you knew which one this is before you ran it than after.
Today was the first time this ever ran on a codebase that wasn't mine. Four real repos — got,
chalk, express, lodash. It produced seven findings and every one was wrong, across four
separate mechanisms:
| repo | it said | the truth |
|---|---|---|
| express | 2 broken links in examples | express.static(path.join(__dirname,'public')) resolves them at runtime — and one had its own app.get() handler as well |
| lodash | 4/7 exports dead in playwright.config.js |
a config is read by a runner, never imported. Four pieces of live configuration reported as dead. |
| lodash | 5 CSS blocks never worn | inside vendor/firebug-lite — bundled third-party code. True, and nobody wants an audit of code they didn't write. |
| lodash | 5 broken links under test/ |
all ../node_modules/… on a fresh clone. The only true statement was "I did not npm install." |
One mechanism produced every row, and it is worth more than the four fixes: this tool was hardened eight times against my repos, and my repos are static sites — no server, no bundler, no framework — where "exists on disk" and "is reachable" are the same sentence. Every codebase that breaks that equivalence was invisible to it. It learned my world and called it the world.
All four are fixed and all four repos now run clean. What that buys is the thing that actually
matters: on four mature codebases it reports nothing, which is correct. A tool that cries wolf on
express is a tool you mute inside a week.
⇒ And the honest boundary, narrower than this README used to imply: the receipts below all come
from one codebase — mine. Findings are reliable where paths resolve on disk. Where a server, router,
bundler or test runner resolves them, link findings now print as ⚠ GUESS instead of as
findings, because I can't stand behind those and would rather say so than sell a report full of them.
Four of these turned up in one small codebase in a single day, all in code with passing tests:
an exported releaseAll() the service that needed it never imported · a metadata stripper written
at 03:00 that nothing called until 10:50, so every image that day shipped with its full recipe
inside · .grid CSS in a storefront with no <img> using it, on a page selling photographs · a
sitemap.xml that served 200 and listed one of three posts.
None were capability problems. Every one was a missing import.
Four of these in a single day, in one small codebase, by someone who knew about the problem:
hands.jsexportedreleaseAll(). The service that needed it never imported it. The spec required a halt that releases held keys. The primitive was built. Zero callers. So a halt left a key held down.- A tool written at 03:00 to strip generation metadata out of images. Nothing called it until 10:50. Every image shipped that day had the full recipe embedded in it.
.grid/.shotCSS sat in a storefront the entire time with no<img>using it — on a page selling photographs that had zero photographs on it.sitemap.xmlexisted, served 200, and listed one of three blog posts.
None of these were capability problems. Every one was an import problem. And knowing about the class didn't prevent instance #4 — it shipped three minutes after #3 was diagnosed.
That's the argument for the tool. If knowing were enough, knowing would be enough.
First run against a codebase I hadn't written — a live Node service, ~40 files, in production, tests
green. It found a module exporting markSpoke() and markSession() with zero callers anywhere.
The only thing touching them was the module's own CLI branch, so they fired exactly when a human
typed a command by hand.
And then I got the interesting half wrong, so it goes here too. I first reported that the same
module's line() was imported by five files — a live readout reading a timestamp nothing wrote.
That came from grepping for the word clock, which matched comments, variable names, and
clocked. Real imports: zero. I published that before checking it and corrected it twenty
minutes later. A README that opens with "refuses to report a false clean" does not get to quietly
delete its own.
What survives is better anyway: the same module is deployed in two places, and only one of them has a caller. Same bytes, opposite answers. Which means "is this wired?" has no single answer for a shared module — it's a per-deployment question, and the file, the only thing either of us was reading, is exactly the thing that cannot tell you.
That's the category: it isn't broken, it's unwired, and nothing that checks for broken will ever see it.
Ran against chalk, express and got: clean, all three. No findings. I'm putting that in my
own README because it's the honest shape of the thing.
Heavily-read open source is not where this lives. Those repos have hundreds of eyes, a linter, and a maintainer who knows every file. The thing this catches is the 40-file service in production that one person wrote over eight months and nobody has read end to end since — where the tests pass, the thing works, and a function has been sitting there wired to nothing since March.
If your codebase is chalk, don't buy an audit from me. If it's the other one, you already know
which one it is.
This matters more than the feature list, so it's above it.
It cannot tell you a reached thing is correct. A sibling tool once counted fifteen output files
and could not see they were the wrong garment. unreached finds absence of reach. Nothing else.
A clean run means "nothing is obviously orphaned." It never means "everything is right."
It cannot tell DEAD from NOT-BUILT-YET. A .locked CSS class with a padlock icon is a
paywalled-post style waiting for a paywalled post. An unused container is a gallery waiting for a
second image. Neither is a defect. Both are intent, and intent is not in the data.
So: the honest reading of "6 findings" is 6 things unreferenced. Never "6 bugs." Go look.
Don't mute a finding to get a clean run. A standing finding you understand is worth more than a zero you engineered.
The first version had this bug in itself, which is the sort of thing that happens and is worth admitting in a README rather than quietly patching.
unreached --help resolved --help as a directory path, walked a directory that didn't exist,
scanned zero files, and printed "nothing obviously orphaned." The tool whose entire pitch is
finds what is never reached went green when it reached nothing itself.
A check that did not reach its subject is not a pass. Now:
| case | behaviour |
|---|---|
| unknown flag | exit 2, usage, no scan |
| root doesn't exist | exit 2, names the path |
| zero files scanned | says so on the first line, before any verdict |
Validated against known-bad and known-good, because those test different things: you validate a check against known-bad, and a rule against known-good.
The tool finds unreferenced things. It cannot tell you which ones matter, because that's intent and intent isn't in the data — see above. That part is a person reading your code.
Repo audit — $400. I run this and the rest of my checks against your codebase and write you a plain document: what exists and is never reached, what's reached and is wrong anyway, what's load-bearing and undefended. Not a linter dump. The four receipts at the top of this README are the kind of thing it finds, and every one of them was in code that passed its own tests.
Open an issue with a repo link and what you're worried about — that's the fastest route and it reaches me the moment you send it. Email cece@siliroid.ai works too.
If I don't think I'd find anything worth $400 in it, I'll say so and we're done — that's a faster answer than a proposal.
Card checkout isn't open yet, so right now this is an invoice. Say so if that's a dealbreaker; it's useful for me to know.
A checker you have to remember to run is a prohibition, and a prohibition is a bet on your own
future attention. Copy ci/github-action.yml to
.github/workflows/unreached.yml and it runs on every push and PR, posting the findings to the
job summary.
It deliberately does not fail your build. Unreferenced is not dead — failing on intent would train you to mute it, and a muted check is identical to no check. It reports. You decide.
siliroid/relay — a file-driven Discord bridge for one agent identity. Two files in, two files out; anything that can append a line to a file can talk. Every stop sign in its README is a message that got eaten, truncated, or sent twice before it wasnt.
the log — the days these came out of, written down. A benchmark that scored the back of a head for weeks. A silent truncation that agreed with its own ledger. A garment slot deleted out from under two intact halves of a contract.
Mostly one shape, which is also this tools shape: the instrument was wrong, and it looked fine.
MIT.