Skip to content

Read MCP registration sites in the zero-install detector (#485) - #526

Open
pengfei-threemoonslab wants to merge 8 commits into
mainfrom
claude/issue-485-review-49c78e
Open

Read MCP registration sites in the zero-install detector (#485)#526
pengfei-threemoonslab wants to merge 8 commits into
mainfrom
claude/issue-485-review-49c78e

Conversation

@pengfei-threemoonslab

@pengfei-threemoonslab pengfei-threemoonslab commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #485.

The gap

tools/shipgate-detect.py is the documented first command an agent runs against a repository that has not adopted Shipgate — which is 100% of the population #431 is about. #431 taught the installed CLI to read a tool's name out of a TypeScript or Go registration site; the script did not gain it, so the two disagreed on the one question the script exists to answer.

Measured on live checkouts, before and after:

workspace detect script (before) script (after)
mongodb-js/mongodb-mcp-server true, 61 tools, route packages false — "Stop" true, 61 tools, route packages
grafana/mcp-grafana true, 114 tools, route tools false — "Stop" true, 114 tools, route tools
github/github-mcp-server true, 114 tools, route pkg/github false — "Stop" true, 114 tools, route pkg/github

On all three, suggested_sources, excluded_sources and the framework's evidence lines are now identical between the two detectors, character for character.

What is ported

Into the stdlib-only single file: the masking lexer (both language grammars, both escape decoders, the regex/division heuristic), the five idioms, is_scannable_path, the containment rule, the declared-dependency gate, the common-ancestor route, export precedence, and the scoring. FRAMEWORKS gains mcp_server_source; CONVENTIONAL_DIR_FRAMEWORKS is mirrored so it stays out of the conventional-directory bonus. SCRIPT_VERSION0.5.0.

Of the 40 reader functions shared with agents_shipgate.inputs.mcp_idioms, all 40 have identical bodies; of the 9 shared discovery functions, 7 do. The two that differ do so because they must, and the script says so where a reviewer diffing the files will read it: the export probe uses json instead of the pydantic-backed load_mcp_tools (the same accept rule _probe_mcp already mirrors), and the source read is factored into one function so the strict-decode contract is visible in one place.

Why the duplication is affordable

A second implementation of the load-bearing matcher is the recurring bug class here, so the two are not allowed to become different implementations.

tests/mcp_idiom_corpus.py now holds every case either reader has ever been asked about — each idiom's positive sample, the whole adversarial sweep, the path predicate's cases, both escape grammars, the masking failures, and the review regressions. tests/test_mcp_idioms.py checks the package reader against the expected names and omissions; tests/test_zero_install_detector.py checks the two readers against each other on the same inputs, comparing every field of every site including its byte span — the span is what decides whether a wrapper call reports a second omission for a tool its own argument already named. The whole corpus runs a second time with CRLF line endings, because a Windows checkout is a supported one and it moves every offset.

Around that:

  • samples/mcp_source_only_server — a TypeScript MCP server with no export, so the existing samples/ parity sweep covers the route. Two tools named, one registration deliberately unenumerated.
  • Thirteen constructed workspaces pin the branches a sample cannot reach: TypeScript, Go, both languages, a declared language that registers nothing, no declared dependency, no resolved registration, test-only registrations, covering export, partial export, wildcard export, an unresolved site outside the route, two registration directories, and the workspace root. A companion test pins the CLI's own answer for each, because a fixture set that never fires the route would agree perfectly and prove nothing.
  • Parity on the strict read (undecodable bytes in a comment, placed so a lenient decode would widen the route directory), on the source-file cap, and on the export size bound.
  • mcp_server_source is the one framework whose score, confidence, evidence lines and exclusion reason are pinned, not just its presence — the conjunction scoring, the "N registrations name themselves at runtime" line, and the sentence a reader gets when the route is withheld are all invisible to every other assertion.
  • test_framework_vocabulary_names_every_cli_omission passes with an empty known_omissions; the idiom ids and omission reasons are pinned from both sides, against the CLI registry and against what the script's reader actually emits.

Six review rounds ran a perturbation sweep over the port, the discovery layer and the masker. Everything reachable is now caught by the test that claims to guard it. Rounds five and six audit the original reader as much as the port: a Go raw string decoded as though it were escape-processed invents raw_name, a / inside a regex character class ends the pattern early and swallows the line after it, three word-boundary lookbehinds admit constructs that merely end in MustTool / NewTool / Tool, and operationType is safe only because its pattern requires static — which the module says and nothing checked, while its twin description had both the fix and a regression. All six are corpus cases now, so they pin both readers.

Two perturbations stay invisible, provably, and the code says why at the line: the wildcard branch in the ported export reader cannot be distinguished from its fall-through for any input the probe accepts, and the ) early exit in _call_sites is redundant with the second-argument check beneath it.

One defect fixed in both

With no MCP export in the workspace at all, _covering_export returned every resolved name as "uncovered", and the caller renders a shortfall as "An MCP tool export is also present and does not name N of these registrations". A server whose surface exists only as source is exactly the population this input was built for, so that claim about a file that does not exist was published into the adoption evidence for every one of them — MongoDB, Grafana and GitHub included. Guarded in cli/discovery/mcp_source.py and in the port, with a regression test.

Also refreshed one stale prose count in cli/scope_routing.py (samples/ was 23 of 25 adopted, not 21 of 22, before this PR added a sample).

Left out, deliberately

_probe_suggested in the script reads every glob-matched JSON candidate with no size bound, while probe_suggested_source refuses inputs over 10 MB — a pre-existing divergence in suggested_sources and an unbounded read in a script that is curled onto unknown repositories. The new export reader here is bounded; the probe is filed separately rather than widened into this PR.

Verification

🤖 Generated with Claude Code

`tools/shipgate-detect.py` is the documented first command run against a
repository that has *not* adopted Shipgate — which is every repository #431
was about. #431 taught the installed CLI to read a tool's name out of a
TypeScript or Go registration site; the script did not gain it, so the two
disagreed on the one question the script exists to answer:
`mongodb-js/mongodb-mcp-server`, `github/github-mcp-server` and
`grafana/mcp-grafana` were agent projects to the CLI and "Stop, not an agent
project" to the script.

The masking lexer, the five idioms, the path predicate, the dependency gate,
the export-precedence rule and the scoring are now in the script too,
stdlib-only. On live checkouts of all three vendor servers both detectors now
return `is_agent_project: true` with identical suggested sources, identical
excluded sources and byte-identical evidence (61, 114 and 114 tools).

Porting a load-bearing matcher means a second implementation of it, which is
this repository's recurring bug class. What makes it affordable is that the
two are not allowed to become *different* implementations: every case either
reader has ever been asked about now lives once in `tests/mcp_idiom_corpus.py`
— every idiom's positive sample, the whole adversarial sweep, the path
predicate's cases and both escape grammars — and both readers are driven
through all of it, compared site by site including each site's byte span.
`samples/mcp_source_only_server` puts the route inside the existing `samples/`
parity sweep, nine constructed workspaces pin the branches around it, and
`test_framework_vocabulary_names_every_cli_omission` passes with an empty
`known_omissions`.

One defect surfaced while porting and is fixed in both readers: with no MCP
export in the workspace at all, `_covering_export` returned every resolved name
as "uncovered", and the caller renders a shortfall as "An MCP tool export is
also present and does not name N of these registrations". A server whose
surface exists only as source is the population this input was built for, so
that claim about a file that does not exist was published into the adoption
evidence for every one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things from the review pass over #526.

**The ported export reader had no size bound.** `load_mcp_tools`, which
`_mcp_export_tool_names` mirrors, refuses any input over 10 MB before parsing
it, so an oversized export names nothing on the CLI side while the port read
the whole file and withheld the source route from it. `MAX_STRUCTURED_FILE_BYTES`
is the same number and the script already applies it elsewhere.

The remaining half of that divergence is `_probe_suggested`, which reads every
glob-matched candidate unbounded and predates this change; it is filed
separately rather than widened into this PR.

**A route branch no fixture reached.** A single-file server registers at the
workspace root, so the route is `"."` — its own branch in the unresolved-count
rollup, and the one route path that reaches `agent_project_candidates` as a
bare workspace.

**The docs claimed less than the tests enforce.** `docs/zero-install.md` said
evidence strings and scores are simplified; for `mcp_server_source` they are
pinned, because the declared dependency is what carries the label to `medium`
and the unenumerated-count line is a claim rather than prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A perturbation sweep over the ported reader found four changes no test could
see. Three were gaps in the shared corpus or its fixtures and are now closed;
the fourth turned out to be a comment claiming more than the code does.

- **The export size bound had no test.** Driven by lowering the bound rather
  than by writing a 10 MB fixture — the file being over it is the whole
  condition — and asserted against the outcome the CLI reaches by a different
  route: the loader refuses the file at the probe, so the source route stands.
- **Three escape cases refused by a guard rather than by falling through.**
  `\u{zz}`, `\u{110000}` and Go's `\U00110000`: drop the check and
  `int(digits, 16)` raises out of `scan_source`, losing the whole file's
  surface instead of one name. The corpus had `\u{}`, which the emptiness
  check catches on its own.
- **The wildcard fixture now carries the `tools: []` the CLI's own wildcard
  test uses.**

And the honest note: `_mcp_export_tool_names`'s wildcard branch cannot be
distinguished from its fall-through by any input, because every wildcard shape
the probe accepts also has no usable `tools` array and the one that does is
refused before this function is reached. That is written down where the branch
is, rather than left as an untested-looking line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git for Windows translates line endings on checkout by default, so the
detector a maintainer curls onto their own repository is often reading `\r\n`
source — and this repository has lost time to CRLF in a corpus reader before.
The whole corpus now runs through both readers a second time with CRLF
endings, asserting the CLI's own answer does not move and that the two readers
still agree site for site.

It passed on the first run, and one perturbation showed why that was luck
rather than coverage: `_literal_is_whole_value` reaches the character *after*
the literal only when the statement ends at a line break, and every case in
the corpus ended in `;`, `,` or `}`. Deleting `\r` from its skip set changed no
answer at all. So the corpus gains the shape that reaches it — a semicolon-less
`static toolName = "…"` — and the perturbation now fails there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ished (#485)

A perturbation over the discovery layer found three more changes no fixture
could see. All three are claims a reader acts on, and none of them moves a
verdict.

- **The unresolved count is scoped to the route directory.** A registration
  outside the subtree the manifest will point at is one `scan` never reaches,
  so counting it is the mirror of the over-claim the count exists to prevent.
  Every fixture had its registrations inside the route.
- **The evidence names the language the tools were *read* in, not the languages
  the workspace declared.** `grafana/mcp-grafana` is the shape: a `ui/` package
  declares an MCP dependency and every tool is in Go. Reading the gate instead
  would report a TypeScript surface this reader never found.
- **The withheld route's reason is pinned, not just its path.** It is the only
  thing a reader gets when a route disappears, and a route that vanishes
  without one is indistinguishable from one nobody implemented.

Also adds the both-languages fixture, which is what makes the second point
testable in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… see (#485)

A sweep over the masker itself. Both cases invent or lose a tool, and both are
now corpus cases, so they pin the CLI's reader as much as the port's.

- **A Go raw string is not escape-processed.** ``MustTool(`raw\137name`, …)``
  registers that literal, which is not a tool-name shape and is recorded as an
  omission. Decode it the way an interpreted string is decoded and it becomes
  `raw_name` — a name the server does not serve, entered into the catalog as
  though it did. The corpus had a raw string, but with no backslash in it, so
  decoding was a no-op.
- **A `/` inside a regex character class does not end the regex.** Stop
  tracking the class and the pattern ends early, leaving `']/;` as code — an
  apostrophe that opens a string and swallows the line, which is how one regex
  costs the registrations after it.

One perturbation stayed invisible and should: dropping the `)` early exit in
`_call_sites` routes a one-argument call through the "not a literal, so it
needs a second argument" check, which drops it for a different stated reason
and the same observable answer. No input distinguishes them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#485)

The last sweep over the reader. Four more perturbations changed an answer with
no test failing; all four are corpus cases now, so they pin the CLI's reader
too.

- **Three word-boundary lookbehinds.** `mcpgrafana.MustTool(` matches because a
  `.` is not a word character; a helper of the repository's own whose name
  merely *ends* in `MustTool`, `NewTool` or `Tool` is a different construct,
  and reading one invents a tool nobody serves. The existing
  `ToolDependencies{` sample does not reach the struct boundary — that name has
  text between `Tool` and the brace, so it never matched at all.
- **`operationType` is safe only because its pattern requires `static`.** That
  is written in the module and was checked by nothing: `description` had the
  identical defect and a regression, and its sibling had neither. The
  regression fixture now carries a `this.operationType = "delete"` beside the
  `this.description` it already had, and asserts the field stays unset — a
  scratch assignment must not infer a `delete` risk tag.

Two perturbations remain invisible and both provably: the wildcard branch in
`_mcp_export_tool_names` (noted at the line) and the `)` early exit in
`_call_sites`, whose removal routes a one-argument call through the
second-argument check for a different stated reason and the same answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A route is only worth suggesting if the step after it can act on it, so the
sample now records what `init` and `scan` actually do, not just what `detect`
reports: the manifest gets `tool_sources: [{type: mcp_server_source, path:
src}]`, and `report.json`'s `tool_catalog` carries both tools at `medium`
confidence with the file and line each was registered at, with the
runtime-named registration in `surface_exclusions`.

Writing it down caught an over-claim in my own first draft. The terminal says
`Surface: 0 tools` and stops at `insufficient_evidence` — the catalog has two
tools and the reviewed *surface* has none, because an MCP server has no agent
object to bind them to (`0/2 catalog tools reachable`). Telling an adopter to
expect two tools in the terminal would have been exactly the kind of
over-claim this input exists to prevent.

`samples/mcp_only_server`, whose surface is a committed export, stops in the
same place with the same `tool_sources[].binding` next step, so this is the
shape of an MCP-server workspace rather than anything the new route
introduces — and the README says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The zero-install detector does not read MCP registration sites, so it still reports vendor MCP servers as non-agent projects

1 participant