feat(mcp): discovery surfaces, registry publish, and the stdio gateway (#9526) - #9735
Conversation
|
Warning ⏸️ LoopOver review result - manual review recommendedReview updated: 2026-07-29 08:04:26 UTC
Review summary Nits — 8 non-blocking
Decision drivers
Context & advisory signals — never blocks the verdict
Linked issue satisfactionAddressed Review context
Contributor next steps
Signal definitions
🧪 Chat with LoopOverAsk LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.
Full command reference: https://loopover.ai/docs/loopover-commands 🧪 Experimental — new and may change. Decision record
Visual preview
Click any thumbnail to open the full-size screenshot. Before = production · After = this PR's preview deploy. Scroll preview
A short scroll-through clip (desktop) — click either thumbnail to open the full animation. Evidence for scroll-linked behavior a single screenshot can't show. 🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.
|
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
loopover-ui | 12e2b8e | Commit Preview URL Branch Preview URL |
Jul 29 2026, 07:42 AM |
Bundle ReportChanges will increase total bundle size by 31.51kB (0.4%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: loopover-uiAssets Changed:
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9735 +/- ##
==========================================
+ Coverage 90.28% 90.30% +0.01%
==========================================
Files 907 912 +5
Lines 113357 113525 +168
Branches 26896 26944 +48
==========================================
+ Hits 102342 102514 +172
+ Misses 9683 9682 -1
+ Partials 1332 1329 -3
Flags with carried forward coverage won't be shown. Click here to find out more.
|
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
274be17 to
206f804
Compare
The server card and the agent-tools trio are COMPUTED at request time from the contract registry. metagraphed committed its card and paid for it: every concurrent tool PR conflicted on the same generated file. There is nothing here to regenerate and nothing a tool PR can collide on. The same routes serve both deployments over their own availability-filtered tool set, so a self-host card is truthful rather than a copy of the cloud one -- a cloud-only tool is absent because it is absent from that deployment's list, not because a second implementation remembered to exclude it. Locality is deliberately NOT filtered: the remote serves local-git tools too, it just expects the caller to supply the branch metadata. generated_at derives from the version rather than the clock. A wall-clock value would change the body on every request, changing the ETag with it and leaving the 304 path as dead code that never fires. serverInfo.version stops being the hardcoded "0.1.0" it has reported since the server was written, and reads the same @loopover/mcp version the compatibility metadata already did -- so serverInfo, the card, and server.json cannot disagree about what shipped. The manifest check carries the anti-rot guard this issue asked for: it asserts every watched path EXISTS before validating a field. metagraphed's version-sync workflow watched a renamed path and kept passing for months while doing nothing, and a workflow that can quietly watch nothing is worse than no workflow.
The stdio server now discovers the remote server's tools/list with the login session and registers anything it does not already serve, so one config gets a contributor every tool their session entitles them to instead of one stdio config for local-git tools and a separate remote endpoint for the rest. The mount is best-effort by design. No session, no network, or a hostile response all leave a server that starts and lists its local tools plus one advisory resource explaining how to get the rest; --no-remote opts out to byte-identical pre-gateway behavior. Proxied tools re-use the contract registry's zod schemas rather than trusting a remote-supplied schema, so the proxy advertises exactly what the remote enforces, and carry _meta.transport="proxied" so telemetry can tell a proxied call from a local one. Name collisions cannot happen by construction — one registry, one entry per name, so a name is either local-git or remote — and validate:mcp now asserts that invariant. The mount still skips an already-registered name so a future violation degrades to "local wins" rather than crashing the server on a duplicate registration.
…9526) The three ways to connect -- the stdio gateway, the remote streamable-http endpoint, and the miner's own stdio server -- were spelled out by hand in four places: clientSnippet() in the stdio bin, the mcp-clients docs page, and both package READMEs. They had already drifted; the docs page documented no remote auth at all, and nothing described the gateway because the prose predates it. The grid now lives in @loopover/contract as data, `init-client --print` renders it, and a generator writes the docs and README blocks from it under a --check drift guard, so a snippet copied from the docs and one printed by the CLI are byte-identical. `init-client` gains `--mode stdio|remote|miner`, defaulting to stdio so a pre-gateway invocation prints exactly what it always did. A remote entry names the token's environment variable in whichever dialect the host reads -- Codex's own key, the JSON hosts' header -- and never carries a value; a host whose remote dialect this repo cannot vouch for is refused rather than given a plausible snippet that fails on paste. Telemetry gains a `transport` dimension, so gateway adoption is measurable: `surface` says which server was asked, and only `transport` says whether a stdio call ran against the local checkout or was forwarded. Proxied tools now run through the same telemetry chokepoint local ones do, and re-register with the contract's schema OBJECTS rather than their `.shape`, which the SDK re-wraps in a way that drops the catchall and turns every extra field into a -32602. The discovery routes are specced through the #9519 seam with real response schemas: the four documents are now zod schemas with their TypeScript types inferred, so the published operation describes the object actually served instead of a second declaration of it. They also report the deployment that ANSWERED -- src/server.ts serves this same Hono app, so a self-host card was advertising the cloud's tool set, which is a list of calls that 404 there. Fix-what-you-find, all three the same rot: the vitest alias list for @loopover/contract, the published-tarball allowlist, and the mount's startup cost. The first two are now derived from the package's own exports map and its committed sources -- both had just failed by lagging a new file, with an error naming the symptom and not the cause. The third: the mount moved after connect() and is no longer awaited, so a network round-trip no longer sits between a client launching the process and it answering, and registering a tool on a connected server emits notifications/tools/list_changed -- the refresh path a stateless remote cannot push itself.
…ob (#9526) Three defects in the registry-publish workflow, two of them raised by the security scan and all three real. The install step's comment said "SHA-pinned" while the code floated on a release tag, which a maintainer can move to a different commit after review -- for a binary that authenticates as this repository and writes to a public registry. It now verifies a sha256 of the download before extracting it, so a moved tag fails the job rather than publishing under bytes nobody looked at. The job holds `id-token: write` and had no deployment environment, so protection rules had nothing to attach to and anyone able to dispatch a workflow could publish. It now declares one. And the download would have 404'd regardless: the asset was requested as `mcp-publisher_${VERSION}_linux_amd64.tar.gz`, but the release publishes it with no version in the name. A publish workflow that cannot fetch its own tool is a workflow nobody has run, which is exactly the class the anti-rot guard exists for -- so the guard now covers the workflow itself: dispatch-only, main-only, environment present, permissions minimal, actions sha-pinned, the checksum verified before extraction, and the manifest check ordered before login and publish. Also fixes a genuine flake this PR's CI hit twice, unrelated to the change: selfhost-pg-queue's recentDeadCount assertion compared the bound cutoff only against a timestamp captured BEFORE the call, while the implementation reads the clock after it -- so one millisecond ticking between the two failed the test (1785302335173 <= 1785302335172). Bracketed on both sides now.
Raising the gateway's patch coverage found a real defect in it. registerProxiedTool declared an
inputSchema only for a tool the contract registry knows, and the SDK invokes a handler as
`(args, extra)` ONLY when an inputSchema is declared -- as `(extra)` alone when it is not. So a
proxied tool absent from the registry, which is precisely the case the fallback exists for (a
remote running ahead of this package), forwarded the SDK's own `{ signal, requestId }` to the
remote AS THE ARGUMENTS and silently dropped everything the caller passed.
An unmodelled tool now gets a fully open input schema instead of none, which keeps the remote the
only validator -- where validation belongs for a tool this package does not model -- while making
the SDK route arguments the way it does for every other tool.
The test that caught it was previously asserting only that the call resolved. callTool resolves
for a FAILED call too, so it passed while nothing was being proxied at all: the fixture had no
/mcp route, the proxy 404'd, and the SDK turned the throw into an isError result nobody checked.
The fixture now answers tools/call with a real JSON-RPC envelope and the assertion is on the
payload -- the tool name and the arguments as the remote received them.
Also: the advisory resource is registered on every outcome rather than only on failure, so a
successful mount no longer leaves an earlier failure's advisory standing as the answer to "why
don't I have the remote tools". Plus coverage for the paths that had none -- calling a proxied
tool, reading the advisory, a descriptor carrying annotations, one carrying only a name, an
envelope with no `result`, the real fetch transport against the loopback fixture, and the
no-session path that makes no outbound request at all.
The five branch partials codecov/patch was holding the PR on, all in init-client's argument handling: no host at all, an unknown host, the --client spelling as well as --print, a host/mode pair the grid refuses, and the per-host remote caveat's absent side. Each asserts the message rather than merely the throw -- "unsupported client" without the list leaves a reader guessing at a five-value set they cannot see from outside the process.
206f804 to
12e2b8e
Compare


Closes #9526.
What changed
The last mile of #9515: LoopOver's MCP servers now advertise themselves, publish to the registry, and sit behind one client entry.
Discovery surfaces, computed at request time.
/.well-known/mcp.jsonplus the/.well-known/agent-tools/{index,openai,anthropic}.jsontrio, built from the #9518 contract registry on every request — never a committed artifact. That is metagraphed's lesson taken literally: its committed server card made every concurrent tool PR conflict on the same generated file. There is nothing here to regenerate and nothing a tool PR can conflict on. Each document carries a weak ETag and answers 304 on a matchingif-none-match, andgenerated_atis derived from the deploy's version rather than the clock, because a wall-clock field would change the ETag on every request and leave the 304 path dead code.The routes report the deployment that answered.
src/server.tsserves this same Hono app, so the first cut's hardcoded"cloud"meant a self-host card advertised the cloud's tool set — a list of calls that 404 there.server.json+ an OIDC publish workflow, validated field-by-field against the tri-lock, with the anti-rot guard the issue asked for: the check fails loudly if any path it watches is missing from HEAD. metagraphed's version-sync workflow rotted silently for months watching a path that had been renamed away, and a workflow that can quietly watch nothing is worse than no workflow.serverInfo.versionno longer says"0.1.0"forever; it tracks@loopover/mcp's released version.Gateway mode for the stdio bin. With a
loopover-mcp loginsession, the stdio server discovers the remote server'stools/listand mounts every tool it does not already serve. One config, every tool your session entitles you to — rather than a local entry for the local-git tools and a separate remote endpoint for the rest.The mount is best-effort by construction. No session, no network, or a hostile response all leave a server that starts and lists its local tools plus one advisory resource explaining how to get the rest;
--no-remoteopts out to byte-identical pre-gateway behavior. An enhancement that can break startup is a liability, and a contributor on a plane still needs their local tools. Name collisions cannot happen — one registry, one entry per name, so a name is eitherlocal-gitorremote— andvalidate:mcpnow asserts that invariant; the mount still skips an already-registered name so a future violation degrades to "local wins" instead of crashing on a duplicate registration.Client config, derived. The three connection modes across five hosts were written out by hand in four places —
clientSnippet()in the bin,mcp-clients.mdx, and both package READMEs — and had already drifted; the docs page documented no remote auth at all. The grid is now data in@loopover/contract,init-client --printrenders it, and a generator writes the docs and README blocks from it under a--checkdrift guard.init-clientgains--mode stdio|remote|miner, defaulting tostdioso a pre-gateway invocation prints exactly what it always did. A remote entry names the token's environment variable in whichever dialect the host reads and never carries a value; a host whose remote dialect this repo cannot vouch for is refused rather than handed a plausible snippet that fails on paste.Transport-tagged telemetry.
surfacesays which server was asked; only the newtransportsays whether a stdio call ran against the local checkout or was forwarded — so gateway adoption is exactly the count ofstdio+proxied, and was unmeasurable without it. Proxied tools now run through the same telemetry chokepoint local ones do.AMS bin decision, recorded.
loopover-miner-mcpstays a separate stdio server, per #9184's constraint that local UX must not regress. The dual-role two-entry config is generated from the same grid and pin-tested.Fix-what-you-find
Three instances of the same rot, all found by this work:
@loopover/contractwas typed out one subpath at a time; adding an export without its alias produced aCannot find packagenaming the symptom and not the cause. Now derived from the package's ownexportsmap.lib/*.jstripped the gate that exists to catch strays. Now derived from the committed sources, which keeps the property that matters: the tarball may contain the compiled form of committed source and nothing else.registerProxiedToolpassedcontract.input.shapewhere the rest of the bin passes the schema object. The SDK re-wraps a raw shape in a plainz.objectand drops the catchall, turning every extra field the remote allows into a-32602the caller cannot act on.Plus: the mount moved after
connect()and is no longer awaited, so a network round-trip no longer sits between a client launching the process and it answering anything — and registering a tool on a connected server emitsnotifications/tools/list_changed, which is the refresh path a stateless remote cannot push on its own.Validation
npx vitest run --changed=origin/main: 24708 passed, 21 skipped, 1295 files — clean on a tree rebased ontoorigin/main(post-ams(mcp): the AMS management tool family — governor-gated mutations, doctor, tenant health (#9523) #9607).tsc --noEmitclean;mcp:tool-reference:check,mcp:client-config:check,ui:openapi:check,command-reference:check,docs:drift-check,server-manifest:check,test:mcp-packall pass.client-config.ts,discovery.ts,discovery-routes.ts,discovery-route-specs.ts, andgateway.tsare at 100% statements/branches/functions/lines.