Read MCP registration sites in the zero-install detector (#485) - #526
Open
pengfei-threemoonslab wants to merge 8 commits into
Open
Read MCP registration sites in the zero-install detector (#485)#526pengfei-threemoonslab wants to merge 8 commits into
pengfei-threemoonslab wants to merge 8 commits into
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #485.
The gap
tools/shipgate-detect.pyis 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:
detectmongodb-js/mongodb-mcp-servertrue, 61 tools, routepackagesfalse— "Stop"true, 61 tools, routepackagesgrafana/mcp-grafanatrue, 114 tools, routetoolsfalse— "Stop"true, 114 tools, routetoolsgithub/github-mcp-servertrue, 114 tools, routepkg/githubfalse— "Stop"true, 114 tools, routepkg/githubOn all three,
suggested_sources,excluded_sourcesand the framework'sevidencelines 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.FRAMEWORKSgainsmcp_server_source;CONVENTIONAL_DIR_FRAMEWORKSis mirrored so it stays out of the conventional-directory bonus.SCRIPT_VERSION→0.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 usesjsoninstead of the pydantic-backedload_mcp_tools(the same accept rule_probe_mcpalready 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.pynow 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.pychecks the package reader against the expected names and omissions;tests/test_zero_install_detector.pychecks 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 existingsamples/parity sweep covers the route. Two tools named, one registration deliberately unenumerated.mcp_server_sourceis 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_omissionpasses with an emptyknown_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 inMustTool/NewTool/Tool, andoperationTypeis safe only because its pattern requiresstatic— which the module says and nothing checked, while its twindescriptionhad 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_sitesis redundant with the second-argument check beneath it.One defect fixed in both
With no MCP export in the workspace at all,
_covering_exportreturned 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 incli/discovery/mcp_source.pyand 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_suggestedin the script reads every glob-matched JSON candidate with no size bound, whileprobe_suggested_sourcerefuses inputs over 10 MB — a pre-existing divergence insuggested_sourcesand 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
ruff check .clean; full suite green (pytest -n auto -m "not perf",tests/test_adapter_static_only.py, and the perf run, each separately).agents_shipgateimportable, confirming it stayed stdlib-only.🤖 Generated with Claude Code