Skip to content

Commit 983ea31

Browse files
authored
feat(mcp): discovery surfaces, registry publish, and the stdio gateway (#9526) (#9735)
* feat(mcp): serve the discovery surfaces and publish server.json 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. * feat(mcp): mount the remote tool set from the stdio server (#9526) 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. * feat(mcp): derive every client-config surface and tag proxied calls (#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. * fix(mcp): pin the publisher binary by checksum and gate the publish job (#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. * fix(mcp): forward the caller's arguments through a proxied tool (#9526) 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. * test(mcp): cover init-client's host and mode guards (#9526) 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.
1 parent 81cecee commit 983ea31

40 files changed

Lines changed: 3887 additions & 379 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: Publish MCP Registry
2+
3+
# Publishes server.json to the official MCP registry (#9526), so `io.github.JSONbored/loopover` is
4+
# discoverable by any registry-aware client.
5+
#
6+
# workflow_dispatch ONLY, and main-only. Publishing announces a version to a public registry, which is not
7+
# something a branch build or a merge should do on its own -- the version it advertises comes from
8+
# @loopover/mcp's package.json, which the release automation bumps, so a publish is a deliberate follow-up to
9+
# a release rather than a side effect of one.
10+
#
11+
# ANTI-ROT: the first step re-runs scripts/check-server-manifest.ts, which asserts every watched path still
12+
# EXISTS before validating any field. metagraphed's version-sync workflow rotted silently for months because
13+
# it watched a path that had been renamed -- it kept passing while doing nothing at all. A workflow that can
14+
# quietly watch nothing is worse than no workflow, so this one fails loudly instead.
15+
16+
on:
17+
workflow_dispatch:
18+
inputs:
19+
dry_run:
20+
description: "Validate and print what would be published, without publishing"
21+
type: boolean
22+
default: false
23+
24+
permissions:
25+
contents: read
26+
# GitHub OIDC is how mcp-publisher proves this repo owns the io.github.JSONbored/* namespace. No
27+
# long-lived registry credential exists to leak.
28+
id-token: write
29+
30+
concurrency:
31+
group: publish-mcp-registry
32+
cancel-in-progress: false
33+
34+
jobs:
35+
publish:
36+
name: Publish server.json
37+
runs-on: ubuntu-latest
38+
# A deployment environment, so the one job in this repo that holds `id-token: write` and writes to a
39+
# public registry can carry protection rules (required reviewers, a wait timer) configured outside the
40+
# workflow file. Without it, anyone who can dispatch a workflow can publish.
41+
environment: mcp-registry
42+
# Belt and braces alongside the dispatch-only trigger: a dispatch can name any ref, and a registry
43+
# publish must only ever describe what is on main.
44+
if: github.ref == 'refs/heads/main'
45+
steps:
46+
- name: Checkout
47+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
48+
49+
- name: Setup Node
50+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
51+
with:
52+
node-version-file: .nvmrc
53+
54+
# Runs BEFORE anything else: a manifest whose version disagrees with the shipped package, or a watched
55+
# path that has been renamed away, must stop the publish rather than announce something untrue.
56+
- name: Validate server.json and its watched paths
57+
run: node --experimental-strip-types scripts/check-server-manifest.ts
58+
59+
- name: Install mcp-publisher
60+
env:
61+
MCP_PUBLISHER_VERSION: v1.8.0
62+
# The tag is mutable; this is not. A release tag can be moved to point at a different commit, and
63+
# this binary authenticates as this repository and writes to a public registry -- so the download
64+
# is checked against the exact bytes reviewed here, and a mismatch fails the job rather than
65+
# publishing under a binary nobody looked at.
66+
MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf
67+
run: |
68+
set -euo pipefail
69+
curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" -o mcp-publisher.tar.gz
70+
echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum --check --strict -
71+
tar -xzf mcp-publisher.tar.gz mcp-publisher
72+
chmod +x mcp-publisher
73+
74+
- name: Login via GitHub OIDC
75+
run: ./mcp-publisher login github-oidc
76+
77+
- name: Publish
78+
if: ${{ !inputs.dry_run }}
79+
run: ./mcp-publisher publish
80+
81+
- name: Dry run — report what would be published
82+
if: ${{ inputs.dry_run }}
83+
run: |
84+
echo "DRY RUN: server.json validated and OIDC login succeeded; publish skipped."
85+
cat server.json

apps/loopover-ui/content/docs/mcp-clients.mdx

Lines changed: 192 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,39 @@ description: Configure your coding agent to talk to the LoopOver MCP. Pick stdio
44
eyebrow: Get started
55
---
66

7+
{/* GENERATED:MCP-CLIENT-CONFIG:BEGIN — edit packages/loopover-contract/src/client-config.ts, then `npm run mcp:client-config` */}
8+
79
## Generate config
810

9-
These commands print config only. They do not mutate your local client files.
11+
Every block on this page is what `init-client` prints. It prints config only — it never edits your client files.
1012

1113
<CodeBlock
1214
lang="bash"
1315
code={`loopover-mcp init-client --print codex
1416
loopover-mcp init-client --print claude
1517
loopover-mcp init-client --print cursor
1618
loopover-mcp init-client --print mcp
17-
loopover-mcp init-client --print vscode`}
19+
loopover-mcp init-client --print vscode
20+
loopover-mcp init-client --print codex --mode remote
21+
loopover-mcp init-client --print claude --mode remote
22+
loopover-mcp init-client --print cursor --mode remote
23+
loopover-mcp init-client --print vscode --mode remote
24+
loopover-mcp init-client --print codex --mode miner
25+
loopover-mcp init-client --print claude --mode miner
26+
loopover-mcp init-client --print cursor --mode miner
27+
loopover-mcp init-client --print mcp --mode miner
28+
loopover-mcp init-client --print vscode --mode miner`}
1829
/>
1930

20-
`--print mcp` uses the same JSON snippet as Claude Desktop and Cursor for other stdio MCP hosts
21-
that expect the `mcpServers` shape. Every generated snippet assumes `loopover-mcp` is on your
22-
`PATH` (install it globally first, per [Quickstart](/docs/quickstart)) — pass
23-
`--command /absolute/path/to/loopover-mcp` if your client doesn't inherit your shell PATH.
31+
## Local stdio (gateway)
32+
33+
The recommended default. Runs `loopover-mcp` on your machine, keeps auth and git analysis local, and — once you have run `loopover-mcp login` — mounts the remote tool set too, so one entry serves every tool your session entitles you to.
2434

25-
## Codex (OpenAI)
35+
- Run `loopover-mcp login` before starting the client; without a session you get the local-git tools only, plus an advisory resource explaining how to get the rest.
36+
- Pass `--no-remote` to keep the server purely local and skip the remote mount entirely.
37+
- Assumes `loopover-mcp` is on your PATH; pass `--command /absolute/path/to/loopover-mcp` if your client does not inherit your shell PATH.
38+
39+
### Codex (OpenAI) (stdio)
2640

2741
<CodeBlock
2842
filename="~/.codex/config.toml"
@@ -32,7 +46,7 @@ command = "loopover-mcp"
3246
args = ["--stdio"]`}
3347
/>
3448

35-
## Claude Desktop
49+
### Claude Desktop / Claude Code (stdio)
3650

3751
<CodeBlock
3852
filename="claude_desktop_config.json"
@@ -47,7 +61,7 @@ args = ["--stdio"]`}
4761
}`}
4862
/>
4963

50-
## Cursor
64+
### Cursor (stdio)
5165

5266
<CodeBlock
5367
filename=".cursor/mcp.json"
@@ -62,10 +76,22 @@ args = ["--stdio"]`}
6276
}`}
6377
/>
6478

65-
## VS Code
79+
### Other `mcpServers` hosts (stdio)
80+
81+
<CodeBlock
82+
filename="mcp.json"
83+
lang="json"
84+
code={`{
85+
"mcpServers": {
86+
"loopover": {
87+
"command": "loopover-mcp",
88+
"args": ["--stdio"]
89+
}
90+
}
91+
}`}
92+
/>
6693

67-
VS Code's native MCP support uses a `servers` map with an explicit transport type instead of the
68-
`mcpServers` shape the other JSON hosts use:
94+
### VS Code (stdio)
6995

7096
<CodeBlock
7197
filename=".vscode/mcp.json"
@@ -81,14 +107,163 @@ VS Code's native MCP support uses a `servers` map with an explicit transport typ
81107
}`}
82108
/>
83109

84-
## Remote MCP
110+
## Remote streamable-http
111+
112+
For agents that run in the cloud, or anywhere you do not want a local Node process. Connects straight to the hosted server; the local-git tools are not available over this transport because there is no local checkout to read.
113+
114+
- Authenticates with a bearer token read from `LOOPOVER_API_TOKEN` — the same variable the CLI honors. Set it in the environment your client starts in; never paste the token into the config file.
115+
- Tools whose work is a local git operation are absent here by design. Use the stdio mode if you need them.
116+
117+
### Codex (OpenAI) (remote)
118+
119+
<CodeBlock
120+
filename="~/.codex/config.toml"
121+
lang="toml"
122+
code={`[mcp_servers.loopover]
123+
url = "https://api.loopover.ai/mcp"
124+
bearer_token_env_var = "LOOPOVER_API_TOKEN"`}
125+
/>
126+
127+
Codex releases before its RMCP client became the default also need `experimental_use_rmcp_client = true` at the top level of config.toml.
128+
129+
### Claude Desktop / Claude Code (remote)
130+
131+
<CodeBlock
132+
filename=".mcp.json"
133+
lang="json"
134+
code={`{
135+
"mcpServers": {
136+
"loopover": {
137+
"type": "http",
138+
"url": "https://api.loopover.ai/mcp",
139+
"headers": {
140+
"Authorization": "Bearer \${LOOPOVER_API_TOKEN}"
141+
}
142+
}
143+
}
144+
}`}
145+
/>
146+
147+
### Cursor (remote)
148+
149+
<CodeBlock
150+
filename=".cursor/mcp.json"
151+
lang="json"
152+
code={`{
153+
"mcpServers": {
154+
"loopover": {
155+
"type": "http",
156+
"url": "https://api.loopover.ai/mcp",
157+
"headers": {
158+
"Authorization": "Bearer \${LOOPOVER_API_TOKEN}"
159+
}
160+
}
161+
}
162+
}`}
163+
/>
164+
165+
### VS Code (remote)
166+
167+
<CodeBlock
168+
filename=".vscode/mcp.json"
169+
lang="json"
170+
code={`{
171+
"servers": {
172+
"loopover": {
173+
"type": "http",
174+
"url": "https://api.loopover.ai/mcp",
175+
"headers": {
176+
"Authorization": "Bearer \${LOOPOVER_API_TOKEN}"
177+
}
178+
}
179+
}
180+
}`}
181+
/>
182+
183+
## Miner stdio
184+
185+
AMS's own local state-visibility tools, as a separate stdio server. It stays separate on purpose: it reads this machine's SQLite state and shares no code or network path with the hosted server.
186+
187+
- Takes no flags and needs no login — everything it reads is already on this machine.
188+
- A dual-role operator runs this alongside the stdio gateway; the two entries coexist in one client config.
189+
190+
### Codex (OpenAI) (miner)
191+
192+
<CodeBlock
193+
filename="~/.codex/config.toml"
194+
lang="toml"
195+
code={`[mcp_servers.loopover-miner]
196+
command = "loopover-miner-mcp"
197+
args = []`}
198+
/>
199+
200+
### Claude Desktop / Claude Code (miner)
201+
202+
<CodeBlock
203+
filename="claude_desktop_config.json"
204+
lang="json"
205+
code={`{
206+
"mcpServers": {
207+
"loopover-miner": {
208+
"command": "loopover-miner-mcp",
209+
"args": []
210+
}
211+
}
212+
}`}
213+
/>
214+
215+
### Cursor (miner)
216+
217+
<CodeBlock
218+
filename=".cursor/mcp.json"
219+
lang="json"
220+
code={`{
221+
"mcpServers": {
222+
"loopover-miner": {
223+
"command": "loopover-miner-mcp",
224+
"args": []
225+
}
226+
}
227+
}`}
228+
/>
229+
230+
### Other `mcpServers` hosts (miner)
231+
232+
<CodeBlock
233+
filename="mcp.json"
234+
lang="json"
235+
code={`{
236+
"mcpServers": {
237+
"loopover-miner": {
238+
"command": "loopover-miner-mcp",
239+
"args": []
240+
}
241+
}
242+
}`}
243+
/>
244+
245+
### VS Code (miner)
246+
247+
<CodeBlock
248+
filename=".vscode/mcp.json"
249+
lang="json"
250+
code={`{
251+
"servers": {
252+
"loopover-miner": {
253+
"type": "stdio",
254+
"command": "loopover-miner-mcp",
255+
"args": []
256+
}
257+
}
258+
}`}
259+
/>
85260

86-
The Worker also exposes a remote MCP endpoint. Use this when your agent runs in the cloud or you
87-
don't want a local Node process.
261+
Every block above comes from the same grid the CLI prints from, so a snippet copied from here and one printed by `init-client` can never disagree.
88262

89-
<CodeBlock lang="http" code={`https://api.loopover.ai/mcp`} />
263+
{/* GENERATED:MCP-CLIENT-CONFIG:END */}
90264

91265
<Callout variant="safety">
92266
Local `--stdio` is the default recommendation. It keeps auth + analysis on your machine and is the
93-
easiest path to log into with GitHub Device Flow.
267+
easiest path to log into with GitHub Device Flow — and, since it mounts the remote tool set too, it
268+
is the only mode that gives you every tool from one entry.
94269
</Callout>

0 commit comments

Comments
 (0)