diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml
new file mode 100644
index 0000000000..8525e65c14
--- /dev/null
+++ b/.github/workflows/publish-mcp-registry.yml
@@ -0,0 +1,85 @@
+name: Publish MCP Registry
+
+# Publishes server.json to the official MCP registry (#9526), so `io.github.JSONbored/loopover` is
+# discoverable by any registry-aware client.
+#
+# workflow_dispatch ONLY, and main-only. Publishing announces a version to a public registry, which is not
+# something a branch build or a merge should do on its own -- the version it advertises comes from
+# @loopover/mcp's package.json, which the release automation bumps, so a publish is a deliberate follow-up to
+# a release rather than a side effect of one.
+#
+# ANTI-ROT: the first step re-runs scripts/check-server-manifest.ts, which asserts every watched path still
+# EXISTS before validating any field. metagraphed's version-sync workflow rotted silently for months because
+# it watched a path that had been renamed -- it kept passing while doing nothing at all. A workflow that can
+# quietly watch nothing is worse than no workflow, so this one fails loudly instead.
+
+on:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Validate and print what would be published, without publishing"
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+ # GitHub OIDC is how mcp-publisher proves this repo owns the io.github.JSONbored/* namespace. No
+ # long-lived registry credential exists to leak.
+ id-token: write
+
+concurrency:
+ group: publish-mcp-registry
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Publish server.json
+ runs-on: ubuntu-latest
+ # A deployment environment, so the one job in this repo that holds `id-token: write` and writes to a
+ # public registry can carry protection rules (required reviewers, a wait timer) configured outside the
+ # workflow file. Without it, anyone who can dispatch a workflow can publish.
+ environment: mcp-registry
+ # Belt and braces alongside the dispatch-only trigger: a dispatch can name any ref, and a registry
+ # publish must only ever describe what is on main.
+ if: github.ref == 'refs/heads/main'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+
+ - name: Setup Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version-file: .nvmrc
+
+ # Runs BEFORE anything else: a manifest whose version disagrees with the shipped package, or a watched
+ # path that has been renamed away, must stop the publish rather than announce something untrue.
+ - name: Validate server.json and its watched paths
+ run: node --experimental-strip-types scripts/check-server-manifest.ts
+
+ - name: Install mcp-publisher
+ env:
+ MCP_PUBLISHER_VERSION: v1.8.0
+ # The tag is mutable; this is not. A release tag can be moved to point at a different commit, and
+ # this binary authenticates as this repository and writes to a public registry -- so the download
+ # is checked against the exact bytes reviewed here, and a mismatch fails the job rather than
+ # publishing under a binary nobody looked at.
+ MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf
+ run: |
+ set -euo pipefail
+ curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" -o mcp-publisher.tar.gz
+ echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum --check --strict -
+ tar -xzf mcp-publisher.tar.gz mcp-publisher
+ chmod +x mcp-publisher
+
+ - name: Login via GitHub OIDC
+ run: ./mcp-publisher login github-oidc
+
+ - name: Publish
+ if: ${{ !inputs.dry_run }}
+ run: ./mcp-publisher publish
+
+ - name: Dry run — report what would be published
+ if: ${{ inputs.dry_run }}
+ run: |
+ echo "DRY RUN: server.json validated and OIDC login succeeded; publish skipped."
+ cat server.json
diff --git a/apps/loopover-ui/content/docs/mcp-clients.mdx b/apps/loopover-ui/content/docs/mcp-clients.mdx
index 7a7259815d..00540ac751 100644
--- a/apps/loopover-ui/content/docs/mcp-clients.mdx
+++ b/apps/loopover-ui/content/docs/mcp-clients.mdx
@@ -4,9 +4,11 @@ description: Configure your coding agent to talk to the LoopOver MCP. Pick stdio
eyebrow: Get started
---
+{/* GENERATED:MCP-CLIENT-CONFIG:BEGIN — edit packages/loopover-contract/src/client-config.ts, then `npm run mcp:client-config` */}
+
## Generate config
-These commands print config only. They do not mutate your local client files.
+Every block on this page is what `init-client` prints. It prints config only — it never edits your client files.
-`--print mcp` uses the same JSON snippet as Claude Desktop and Cursor for other stdio MCP hosts
-that expect the `mcpServers` shape. Every generated snippet assumes `loopover-mcp` is on your
-`PATH` (install it globally first, per [Quickstart](/docs/quickstart)) — pass
-`--command /absolute/path/to/loopover-mcp` if your client doesn't inherit your shell PATH.
+## Local stdio (gateway)
+
+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.
-## Codex (OpenAI)
+- 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.
+- Pass `--no-remote` to keep the server purely local and skip the remote mount entirely.
+- Assumes `loopover-mcp` is on your PATH; pass `--command /absolute/path/to/loopover-mcp` if your client does not inherit your shell PATH.
+
+### Codex (OpenAI) (stdio)
-## Claude Desktop
+### Claude Desktop / Claude Code (stdio)
-## Cursor
+### Cursor (stdio)
-## VS Code
+### Other `mcpServers` hosts (stdio)
+
+
-VS Code's native MCP support uses a `servers` map with an explicit transport type instead of the
-`mcpServers` shape the other JSON hosts use:
+### VS Code (stdio)
-## Remote MCP
+## Remote streamable-http
+
+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.
+
+- 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.
+- Tools whose work is a local git operation are absent here by design. Use the stdio mode if you need them.
+
+### Codex (OpenAI) (remote)
+
+
+
+Codex releases before its RMCP client became the default also need `experimental_use_rmcp_client = true` at the top level of config.toml.
+
+### Claude Desktop / Claude Code (remote)
+
+
+
+### Cursor (remote)
+
+
+
+### VS Code (remote)
+
+
+
+## Miner stdio
+
+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.
+
+- Takes no flags and needs no login — everything it reads is already on this machine.
+- A dual-role operator runs this alongside the stdio gateway; the two entries coexist in one client config.
+
+### Codex (OpenAI) (miner)
+
+
+
+### Claude Desktop / Claude Code (miner)
+
+
+
+### Cursor (miner)
+
+
+
+### Other `mcpServers` hosts (miner)
+
+
+
+### VS Code (miner)
+
+
-The Worker also exposes a remote MCP endpoint. Use this when your agent runs in the cloud or you
-don't want a local Node process.
+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.
-
+{/* GENERATED:MCP-CLIENT-CONFIG:END */}
Local `--stdio` is the default recommendation. It keeps auth + analysis on your machine and is the
- easiest path to log into with GitHub Device Flow.
+ easiest path to log into with GitHub Device Flow — and, since it mounts the remote tool set too, it
+ is the only mode that gives you every tool from one entry.
diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json
index 281e2017db..bb41b1537c 100644
--- a/apps/loopover-ui/public/openapi.json
+++ b/apps/loopover-ui/public/openapi.json
@@ -26440,6 +26440,457 @@
}
}
}
+ },
+ "/.well-known/mcp.json": {
+ "get": {
+ "operationId": "getMcpServerCard",
+ "tags": [
+ "Discovery"
+ ],
+ "summary": "MCP server card for this deployment",
+ "description": "Computed at request time from the tool contract registry, filtered to what this deployment serves. Carries a weak ETag; send `if-none-match` to get a 304 instead of the body.",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "MCP server card for this deployment",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "deployment": {
+ "type": "string",
+ "enum": [
+ "cloud",
+ "selfhost"
+ ]
+ },
+ "generated_at": {
+ "type": "string"
+ },
+ "capabilities": {
+ "type": "object",
+ "properties": {
+ "tools": {
+ "type": "object",
+ "properties": {
+ "listChanged": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "listChanged"
+ ]
+ }
+ },
+ "required": [
+ "tools"
+ ]
+ },
+ "remotes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "streamable-http"
+ ]
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "url"
+ ]
+ }
+ },
+ "tools": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string"
+ },
+ "annotations": {
+ "type": "object",
+ "properties": {
+ "readOnlyHint": {
+ "type": "boolean"
+ },
+ "destructiveHint": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "readOnlyHint",
+ "destructiveHint"
+ ]
+ }
+ },
+ "required": [
+ "name",
+ "title",
+ "description",
+ "category",
+ "annotations"
+ ]
+ }
+ }
+ },
+ "required": [
+ "name",
+ "version",
+ "description",
+ "deployment",
+ "generated_at",
+ "capabilities",
+ "remotes",
+ "tools"
+ ]
+ }
+ }
+ }
+ },
+ "304": {
+ "description": "The caller's `if-none-match` already names this document"
+ }
+ }
+ }
+ },
+ "/.well-known/agent-tools/index.json": {
+ "get": {
+ "operationId": "getAgentToolsIndex",
+ "tags": [
+ "Discovery"
+ ],
+ "summary": "Neutral agent-tools catalog with both schemas per tool",
+ "description": "Computed at request time from the tool contract registry, filtered to what this deployment serves. Carries a weak ETag; send `if-none-match` to get a 304 instead of the body.",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "Neutral agent-tools catalog with both schemas per tool",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "generated_at": {
+ "type": "string"
+ },
+ "executor": {
+ "type": "object",
+ "properties": {
+ "transport": {
+ "type": "string",
+ "enum": [
+ "streamable-http"
+ ]
+ },
+ "url": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "tools/call"
+ ]
+ }
+ },
+ "required": [
+ "transport",
+ "url",
+ "method"
+ ]
+ },
+ "tools": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string"
+ },
+ "annotations": {
+ "type": "object",
+ "properties": {
+ "readOnlyHint": {
+ "type": "boolean"
+ },
+ "destructiveHint": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "readOnlyHint",
+ "destructiveHint"
+ ]
+ },
+ "input_schema": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": {
+ "nullable": true
+ },
+ "description": "JSON Schema (draft 2020-12) for a tool's arguments or result."
+ },
+ "output_schema": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": {
+ "nullable": true
+ },
+ "description": "JSON Schema (draft 2020-12) for a tool's arguments or result."
+ }
+ },
+ "required": [
+ "name",
+ "title",
+ "description",
+ "category",
+ "annotations",
+ "input_schema",
+ "output_schema"
+ ]
+ }
+ }
+ },
+ "required": [
+ "generated_at",
+ "executor",
+ "tools"
+ ]
+ }
+ }
+ }
+ },
+ "304": {
+ "description": "The caller's `if-none-match` already names this document"
+ }
+ }
+ }
+ },
+ "/.well-known/agent-tools/openai.json": {
+ "get": {
+ "operationId": "getAgentToolsOpenAi",
+ "tags": [
+ "Discovery"
+ ],
+ "summary": "Agent-tools catalog in OpenAI's function-tool shape",
+ "description": "Computed at request time from the tool contract registry, filtered to what this deployment serves. Carries a weak ETag; send `if-none-match` to get a 304 instead of the body.",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "Agent-tools catalog in OpenAI's function-tool shape",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "generated_at": {
+ "type": "string"
+ },
+ "executor": {
+ "type": "object",
+ "properties": {
+ "transport": {
+ "type": "string",
+ "enum": [
+ "streamable-http"
+ ]
+ },
+ "url": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "tools/call"
+ ]
+ }
+ },
+ "required": [
+ "transport",
+ "url",
+ "method"
+ ]
+ },
+ "tools": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "function"
+ ]
+ },
+ "function": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": {
+ "nullable": true
+ },
+ "description": "JSON Schema (draft 2020-12) for a tool's arguments or result."
+ }
+ },
+ "required": [
+ "name",
+ "description",
+ "parameters"
+ ]
+ }
+ },
+ "required": [
+ "type",
+ "function"
+ ]
+ }
+ }
+ },
+ "required": [
+ "generated_at",
+ "executor",
+ "tools"
+ ]
+ }
+ }
+ }
+ },
+ "304": {
+ "description": "The caller's `if-none-match` already names this document"
+ }
+ }
+ }
+ },
+ "/.well-known/agent-tools/anthropic.json": {
+ "get": {
+ "operationId": "getAgentToolsAnthropic",
+ "tags": [
+ "Discovery"
+ ],
+ "summary": "Agent-tools catalog in Anthropic's tool shape",
+ "description": "Computed at request time from the tool contract registry, filtered to what this deployment serves. Carries a weak ETag; send `if-none-match` to get a 304 instead of the body.",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "Agent-tools catalog in Anthropic's tool shape",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "generated_at": {
+ "type": "string"
+ },
+ "executor": {
+ "type": "object",
+ "properties": {
+ "transport": {
+ "type": "string",
+ "enum": [
+ "streamable-http"
+ ]
+ },
+ "url": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string",
+ "enum": [
+ "tools/call"
+ ]
+ }
+ },
+ "required": [
+ "transport",
+ "url",
+ "method"
+ ]
+ },
+ "tools": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "input_schema": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": {
+ "nullable": true
+ },
+ "description": "JSON Schema (draft 2020-12) for a tool's arguments or result."
+ }
+ },
+ "required": [
+ "name",
+ "description",
+ "input_schema"
+ ]
+ }
+ }
+ },
+ "required": [
+ "generated_at",
+ "executor",
+ "tools"
+ ]
+ }
+ }
+ }
+ },
+ "304": {
+ "description": "The caller's `if-none-match` already names this document"
+ }
+ }
+ }
}
},
"servers": [
diff --git a/apps/loopover-ui/src/routeTree.gen.ts b/apps/loopover-ui/src/routeTree.gen.ts
index 1409d48c93..7ac38456ef 100644
--- a/apps/loopover-ui/src/routeTree.gen.ts
+++ b/apps/loopover-ui/src/routeTree.gen.ts
@@ -9,63 +9,63 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as RoadmapRouteImport } from './routes/roadmap'
-import { Route as MinersRouteImport } from './routes/miners'
-import { Route as MaintainersRouteImport } from './routes/maintainers'
-import { Route as InstallRouteImport } from './routes/install'
-import { Route as FairnessRouteImport } from './routes/fairness'
-import { Route as DocsRouteImport } from './routes/docs'
-import { Route as ChangelogRouteImport } from './routes/changelog'
-import { Route as AppRouteImport } from './routes/app'
-import { Route as ApiRouteImport } from './routes/api'
-import { Route as AgentsRouteImport } from './routes/agents'
import { Route as IndexRouteImport } from './routes/index'
-import { Route as InstallIndexRouteImport } from './routes/install.index'
-import { Route as DocsIndexRouteImport } from './routes/docs.index'
-import { Route as AppIndexRouteImport } from './routes/app.index'
+import { Route as AgentsRouteImport } from './routes/agents'
+import { Route as ApiRouteImport } from './routes/api'
+import { Route as AppRouteImport } from './routes/app'
+import { Route as ChangelogRouteImport } from './routes/changelog'
+import { Route as DocsRouteImport } from './routes/docs'
+import { Route as FairnessRouteImport } from './routes/fairness'
+import { Route as InstallRouteImport } from './routes/install'
+import { Route as MaintainersRouteImport } from './routes/maintainers'
+import { Route as MinersRouteImport } from './routes/miners'
+import { Route as RoadmapRouteImport } from './routes/roadmap'
import { Route as ApiIndexRouteImport } from './routes/api.index'
-import { Route as InstallPermissionsRouteImport } from './routes/install.permissions'
-import { Route as DocsFumadocsSpikeApiReferenceRouteImport } from './routes/docs.fumadocs-spike-api-reference'
-import { Route as DocsSlugRouteImport } from './routes/docs.$slug'
-import { Route as AppWorkbenchRouteImport } from './routes/app.workbench'
-import { Route as AppRunsRouteImport } from './routes/app.runs'
-import { Route as AppReposRouteImport } from './routes/app.repos'
-import { Route as AppPlaygroundRouteImport } from './routes/app.playground'
-import { Route as AppOwnerRouteImport } from './routes/app.owner'
-import { Route as AppOperatorRouteImport } from './routes/app.operator'
-import { Route as AppMinerRouteImport } from './routes/app.miner'
-import { Route as AppMaintainerRouteImport } from './routes/app.maintainer'
-import { Route as AppDigestRouteImport } from './routes/app.digest'
-import { Route as AppConfigGeneratorRouteImport } from './routes/app.config-generator'
-import { Route as AppCommandsRouteImport } from './routes/app.commands'
-import { Route as AppAuditRouteImport } from './routes/app.audit'
-import { Route as AppAnalyticsRouteImport } from './routes/app.analytics'
import { Route as ApiOpRouteImport } from './routes/api.$op'
+import { Route as AppIndexRouteImport } from './routes/app.index'
+import { Route as AppAnalyticsRouteImport } from './routes/app.analytics'
+import { Route as AppAuditRouteImport } from './routes/app.audit'
+import { Route as AppCommandsRouteImport } from './routes/app.commands'
+import { Route as AppConfigGeneratorRouteImport } from './routes/app.config-generator'
+import { Route as AppDigestRouteImport } from './routes/app.digest'
+import { Route as AppMaintainerRouteImport } from './routes/app.maintainer'
+import { Route as AppMinerRouteImport } from './routes/app.miner'
+import { Route as AppOperatorRouteImport } from './routes/app.operator'
+import { Route as AppOwnerRouteImport } from './routes/app.owner'
+import { Route as AppPlaygroundRouteImport } from './routes/app.playground'
+import { Route as AppReposRouteImport } from './routes/app.repos'
+import { Route as AppRunsRouteImport } from './routes/app.runs'
+import { Route as AppWorkbenchRouteImport } from './routes/app.workbench'
+import { Route as DocsIndexRouteImport } from './routes/docs.index'
+import { Route as DocsSlugRouteImport } from './routes/docs.$slug'
+import { Route as DocsFumadocsSpikeApiReferenceRouteImport } from './routes/docs.fumadocs-spike-api-reference'
+import { Route as InstallIndexRouteImport } from './routes/install.index'
+import { Route as InstallPermissionsRouteImport } from './routes/install.permissions'
import { Route as ReposOwnerRepoQualityRouteImport } from './routes/repos.$owner.$repo.quality'
-const RoadmapRoute = RoadmapRouteImport.update({
- id: '/roadmap',
- path: '/roadmap',
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
getParentRoute: () => rootRouteImport,
} as any)
-const MinersRoute = MinersRouteImport.update({
- id: '/miners',
- path: '/miners',
+const AgentsRoute = AgentsRouteImport.update({
+ id: '/agents',
+ path: '/agents',
getParentRoute: () => rootRouteImport,
} as any)
-const MaintainersRoute = MaintainersRouteImport.update({
- id: '/maintainers',
- path: '/maintainers',
+const ApiRoute = ApiRouteImport.update({
+ id: '/api',
+ path: '/api',
getParentRoute: () => rootRouteImport,
} as any)
-const InstallRoute = InstallRouteImport.update({
- id: '/install',
- path: '/install',
+const AppRoute = AppRouteImport.update({
+ id: '/app',
+ path: '/app',
getParentRoute: () => rootRouteImport,
} as any)
-const FairnessRoute = FairnessRouteImport.update({
- id: '/fairness',
- path: '/fairness',
+const ChangelogRoute = ChangelogRouteImport.update({
+ id: '/changelog',
+ path: '/changelog',
getParentRoute: () => rootRouteImport,
} as any)
const DocsRoute = DocsRouteImport.update({
@@ -73,95 +73,74 @@ const DocsRoute = DocsRouteImport.update({
path: '/docs',
getParentRoute: () => rootRouteImport,
} as any)
-const ChangelogRoute = ChangelogRouteImport.update({
- id: '/changelog',
- path: '/changelog',
+const FairnessRoute = FairnessRouteImport.update({
+ id: '/fairness',
+ path: '/fairness',
getParentRoute: () => rootRouteImport,
} as any)
-const AppRoute = AppRouteImport.update({
- id: '/app',
- path: '/app',
+const InstallRoute = InstallRouteImport.update({
+ id: '/install',
+ path: '/install',
getParentRoute: () => rootRouteImport,
} as any)
-const ApiRoute = ApiRouteImport.update({
- id: '/api',
- path: '/api',
+const MaintainersRoute = MaintainersRouteImport.update({
+ id: '/maintainers',
+ path: '/maintainers',
getParentRoute: () => rootRouteImport,
} as any)
-const AgentsRoute = AgentsRouteImport.update({
- id: '/agents',
- path: '/agents',
+const MinersRoute = MinersRouteImport.update({
+ id: '/miners',
+ path: '/miners',
getParentRoute: () => rootRouteImport,
} as any)
-const IndexRoute = IndexRouteImport.update({
- id: '/',
- path: '/',
+const RoadmapRoute = RoadmapRouteImport.update({
+ id: '/roadmap',
+ path: '/roadmap',
getParentRoute: () => rootRouteImport,
} as any)
-const InstallIndexRoute = InstallIndexRouteImport.update({
+const ApiIndexRoute = ApiIndexRouteImport.update({
id: '/',
path: '/',
- getParentRoute: () => InstallRoute,
+ getParentRoute: () => ApiRoute,
} as any)
-const DocsIndexRoute = DocsIndexRouteImport.update({
- id: '/',
- path: '/',
- getParentRoute: () => DocsRoute,
+const ApiOpRoute = ApiOpRouteImport.update({
+ id: '/$op',
+ path: '/$op',
+ getParentRoute: () => ApiRoute,
} as any)
const AppIndexRoute = AppIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppRoute,
} as any)
-const ApiIndexRoute = ApiIndexRouteImport.update({
- id: '/',
- path: '/',
- getParentRoute: () => ApiRoute,
-} as any)
-const InstallPermissionsRoute = InstallPermissionsRouteImport.update({
- id: '/permissions',
- path: '/permissions',
- getParentRoute: () => InstallRoute,
-} as any)
-const DocsFumadocsSpikeApiReferenceRoute =
- DocsFumadocsSpikeApiReferenceRouteImport.update({
- id: '/fumadocs-spike-api-reference',
- path: '/fumadocs-spike-api-reference',
- getParentRoute: () => DocsRoute,
- } as any)
-const DocsSlugRoute = DocsSlugRouteImport.update({
- id: '/$slug',
- path: '/$slug',
- getParentRoute: () => DocsRoute,
-} as any)
-const AppWorkbenchRoute = AppWorkbenchRouteImport.update({
- id: '/workbench',
- path: '/workbench',
+const AppAnalyticsRoute = AppAnalyticsRouteImport.update({
+ id: '/analytics',
+ path: '/analytics',
getParentRoute: () => AppRoute,
} as any)
-const AppRunsRoute = AppRunsRouteImport.update({
- id: '/runs',
- path: '/runs',
+const AppAuditRoute = AppAuditRouteImport.update({
+ id: '/audit',
+ path: '/audit',
getParentRoute: () => AppRoute,
} as any)
-const AppReposRoute = AppReposRouteImport.update({
- id: '/repos',
- path: '/repos',
+const AppCommandsRoute = AppCommandsRouteImport.update({
+ id: '/commands',
+ path: '/commands',
getParentRoute: () => AppRoute,
} as any)
-const AppPlaygroundRoute = AppPlaygroundRouteImport.update({
- id: '/playground',
- path: '/playground',
+const AppConfigGeneratorRoute = AppConfigGeneratorRouteImport.update({
+ id: '/config-generator',
+ path: '/config-generator',
getParentRoute: () => AppRoute,
} as any)
-const AppOwnerRoute = AppOwnerRouteImport.update({
- id: '/owner',
- path: '/owner',
+const AppDigestRoute = AppDigestRouteImport.update({
+ id: '/digest',
+ path: '/digest',
getParentRoute: () => AppRoute,
} as any)
-const AppOperatorRoute = AppOperatorRouteImport.update({
- id: '/operator',
- path: '/operator',
+const AppMaintainerRoute = AppMaintainerRouteImport.update({
+ id: '/maintainer',
+ path: '/maintainer',
getParentRoute: () => AppRoute,
} as any)
const AppMinerRoute = AppMinerRouteImport.update({
@@ -169,40 +148,61 @@ const AppMinerRoute = AppMinerRouteImport.update({
path: '/miner',
getParentRoute: () => AppRoute,
} as any)
-const AppMaintainerRoute = AppMaintainerRouteImport.update({
- id: '/maintainer',
- path: '/maintainer',
+const AppOperatorRoute = AppOperatorRouteImport.update({
+ id: '/operator',
+ path: '/operator',
getParentRoute: () => AppRoute,
} as any)
-const AppDigestRoute = AppDigestRouteImport.update({
- id: '/digest',
- path: '/digest',
+const AppOwnerRoute = AppOwnerRouteImport.update({
+ id: '/owner',
+ path: '/owner',
getParentRoute: () => AppRoute,
} as any)
-const AppConfigGeneratorRoute = AppConfigGeneratorRouteImport.update({
- id: '/config-generator',
- path: '/config-generator',
+const AppPlaygroundRoute = AppPlaygroundRouteImport.update({
+ id: '/playground',
+ path: '/playground',
getParentRoute: () => AppRoute,
} as any)
-const AppCommandsRoute = AppCommandsRouteImport.update({
- id: '/commands',
- path: '/commands',
+const AppReposRoute = AppReposRouteImport.update({
+ id: '/repos',
+ path: '/repos',
getParentRoute: () => AppRoute,
} as any)
-const AppAuditRoute = AppAuditRouteImport.update({
- id: '/audit',
- path: '/audit',
+const AppRunsRoute = AppRunsRouteImport.update({
+ id: '/runs',
+ path: '/runs',
getParentRoute: () => AppRoute,
} as any)
-const AppAnalyticsRoute = AppAnalyticsRouteImport.update({
- id: '/analytics',
- path: '/analytics',
+const AppWorkbenchRoute = AppWorkbenchRouteImport.update({
+ id: '/workbench',
+ path: '/workbench',
getParentRoute: () => AppRoute,
} as any)
-const ApiOpRoute = ApiOpRouteImport.update({
- id: '/$op',
- path: '/$op',
- getParentRoute: () => ApiRoute,
+const DocsIndexRoute = DocsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => DocsRoute,
+} as any)
+const DocsSlugRoute = DocsSlugRouteImport.update({
+ id: '/$slug',
+ path: '/$slug',
+ getParentRoute: () => DocsRoute,
+} as any)
+const DocsFumadocsSpikeApiReferenceRoute =
+ DocsFumadocsSpikeApiReferenceRouteImport.update({
+ id: '/fumadocs-spike-api-reference',
+ path: '/fumadocs-spike-api-reference',
+ getParentRoute: () => DocsRoute,
+ } as any)
+const InstallIndexRoute = InstallIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => InstallRoute,
+} as any)
+const InstallPermissionsRoute = InstallPermissionsRouteImport.update({
+ id: '/permissions',
+ path: '/permissions',
+ getParentRoute: () => InstallRoute,
} as any)
const ReposOwnerRepoQualityRoute = ReposOwnerRepoQualityRouteImport.update({
id: '/repos/$owner/$repo/quality',
@@ -433,39 +433,39 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
- '/roadmap': {
- id: '/roadmap'
- path: '/roadmap'
- fullPath: '/roadmap'
- preLoaderRoute: typeof RoadmapRouteImport
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/miners': {
- id: '/miners'
- path: '/miners'
- fullPath: '/miners'
- preLoaderRoute: typeof MinersRouteImport
+ '/agents': {
+ id: '/agents'
+ path: '/agents'
+ fullPath: '/agents'
+ preLoaderRoute: typeof AgentsRouteImport
parentRoute: typeof rootRouteImport
}
- '/maintainers': {
- id: '/maintainers'
- path: '/maintainers'
- fullPath: '/maintainers'
- preLoaderRoute: typeof MaintainersRouteImport
+ '/api': {
+ id: '/api'
+ path: '/api'
+ fullPath: '/api'
+ preLoaderRoute: typeof ApiRouteImport
parentRoute: typeof rootRouteImport
}
- '/install': {
- id: '/install'
- path: '/install'
- fullPath: '/install'
- preLoaderRoute: typeof InstallRouteImport
+ '/app': {
+ id: '/app'
+ path: '/app'
+ fullPath: '/app'
+ preLoaderRoute: typeof AppRouteImport
parentRoute: typeof rootRouteImport
}
- '/fairness': {
- id: '/fairness'
- path: '/fairness'
- fullPath: '/fairness'
- preLoaderRoute: typeof FairnessRouteImport
+ '/changelog': {
+ id: '/changelog'
+ path: '/changelog'
+ fullPath: '/changelog'
+ preLoaderRoute: typeof ChangelogRouteImport
parentRoute: typeof rootRouteImport
}
'/docs': {
@@ -475,54 +475,54 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsRouteImport
parentRoute: typeof rootRouteImport
}
- '/changelog': {
- id: '/changelog'
- path: '/changelog'
- fullPath: '/changelog'
- preLoaderRoute: typeof ChangelogRouteImport
+ '/fairness': {
+ id: '/fairness'
+ path: '/fairness'
+ fullPath: '/fairness'
+ preLoaderRoute: typeof FairnessRouteImport
parentRoute: typeof rootRouteImport
}
- '/app': {
- id: '/app'
- path: '/app'
- fullPath: '/app'
- preLoaderRoute: typeof AppRouteImport
+ '/install': {
+ id: '/install'
+ path: '/install'
+ fullPath: '/install'
+ preLoaderRoute: typeof InstallRouteImport
parentRoute: typeof rootRouteImport
}
- '/api': {
- id: '/api'
- path: '/api'
- fullPath: '/api'
- preLoaderRoute: typeof ApiRouteImport
+ '/maintainers': {
+ id: '/maintainers'
+ path: '/maintainers'
+ fullPath: '/maintainers'
+ preLoaderRoute: typeof MaintainersRouteImport
parentRoute: typeof rootRouteImport
}
- '/agents': {
- id: '/agents'
- path: '/agents'
- fullPath: '/agents'
- preLoaderRoute: typeof AgentsRouteImport
+ '/miners': {
+ id: '/miners'
+ path: '/miners'
+ fullPath: '/miners'
+ preLoaderRoute: typeof MinersRouteImport
parentRoute: typeof rootRouteImport
}
- '/': {
- id: '/'
- path: '/'
- fullPath: '/'
- preLoaderRoute: typeof IndexRouteImport
+ '/roadmap': {
+ id: '/roadmap'
+ path: '/roadmap'
+ fullPath: '/roadmap'
+ preLoaderRoute: typeof RoadmapRouteImport
parentRoute: typeof rootRouteImport
}
- '/install/': {
- id: '/install/'
+ '/api/': {
+ id: '/api/'
path: '/'
- fullPath: '/install/'
- preLoaderRoute: typeof InstallIndexRouteImport
- parentRoute: typeof InstallRoute
+ fullPath: '/api/'
+ preLoaderRoute: typeof ApiIndexRouteImport
+ parentRoute: typeof ApiRoute
}
- '/docs/': {
- id: '/docs/'
- path: '/'
- fullPath: '/docs/'
- preLoaderRoute: typeof DocsIndexRouteImport
- parentRoute: typeof DocsRoute
+ '/api/$op': {
+ id: '/api/$op'
+ path: '/$op'
+ fullPath: '/api/$op'
+ preLoaderRoute: typeof ApiOpRouteImport
+ parentRoute: typeof ApiRoute
}
'/app/': {
id: '/app/'
@@ -531,74 +531,46 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppIndexRouteImport
parentRoute: typeof AppRoute
}
- '/api/': {
- id: '/api/'
- path: '/'
- fullPath: '/api/'
- preLoaderRoute: typeof ApiIndexRouteImport
- parentRoute: typeof ApiRoute
- }
- '/install/permissions': {
- id: '/install/permissions'
- path: '/permissions'
- fullPath: '/install/permissions'
- preLoaderRoute: typeof InstallPermissionsRouteImport
- parentRoute: typeof InstallRoute
- }
- '/docs/fumadocs-spike-api-reference': {
- id: '/docs/fumadocs-spike-api-reference'
- path: '/fumadocs-spike-api-reference'
- fullPath: '/docs/fumadocs-spike-api-reference'
- preLoaderRoute: typeof DocsFumadocsSpikeApiReferenceRouteImport
- parentRoute: typeof DocsRoute
- }
- '/docs/$slug': {
- id: '/docs/$slug'
- path: '/$slug'
- fullPath: '/docs/$slug'
- preLoaderRoute: typeof DocsSlugRouteImport
- parentRoute: typeof DocsRoute
- }
- '/app/workbench': {
- id: '/app/workbench'
- path: '/workbench'
- fullPath: '/app/workbench'
- preLoaderRoute: typeof AppWorkbenchRouteImport
+ '/app/analytics': {
+ id: '/app/analytics'
+ path: '/analytics'
+ fullPath: '/app/analytics'
+ preLoaderRoute: typeof AppAnalyticsRouteImport
parentRoute: typeof AppRoute
}
- '/app/runs': {
- id: '/app/runs'
- path: '/runs'
- fullPath: '/app/runs'
- preLoaderRoute: typeof AppRunsRouteImport
+ '/app/audit': {
+ id: '/app/audit'
+ path: '/audit'
+ fullPath: '/app/audit'
+ preLoaderRoute: typeof AppAuditRouteImport
parentRoute: typeof AppRoute
}
- '/app/repos': {
- id: '/app/repos'
- path: '/repos'
- fullPath: '/app/repos'
- preLoaderRoute: typeof AppReposRouteImport
+ '/app/commands': {
+ id: '/app/commands'
+ path: '/commands'
+ fullPath: '/app/commands'
+ preLoaderRoute: typeof AppCommandsRouteImport
parentRoute: typeof AppRoute
}
- '/app/playground': {
- id: '/app/playground'
- path: '/playground'
- fullPath: '/app/playground'
- preLoaderRoute: typeof AppPlaygroundRouteImport
+ '/app/config-generator': {
+ id: '/app/config-generator'
+ path: '/config-generator'
+ fullPath: '/app/config-generator'
+ preLoaderRoute: typeof AppConfigGeneratorRouteImport
parentRoute: typeof AppRoute
}
- '/app/owner': {
- id: '/app/owner'
- path: '/owner'
- fullPath: '/app/owner'
- preLoaderRoute: typeof AppOwnerRouteImport
+ '/app/digest': {
+ id: '/app/digest'
+ path: '/digest'
+ fullPath: '/app/digest'
+ preLoaderRoute: typeof AppDigestRouteImport
parentRoute: typeof AppRoute
}
- '/app/operator': {
- id: '/app/operator'
- path: '/operator'
- fullPath: '/app/operator'
- preLoaderRoute: typeof AppOperatorRouteImport
+ '/app/maintainer': {
+ id: '/app/maintainer'
+ path: '/maintainer'
+ fullPath: '/app/maintainer'
+ preLoaderRoute: typeof AppMaintainerRouteImport
parentRoute: typeof AppRoute
}
'/app/miner': {
@@ -608,54 +580,82 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppMinerRouteImport
parentRoute: typeof AppRoute
}
- '/app/maintainer': {
- id: '/app/maintainer'
- path: '/maintainer'
- fullPath: '/app/maintainer'
- preLoaderRoute: typeof AppMaintainerRouteImport
+ '/app/operator': {
+ id: '/app/operator'
+ path: '/operator'
+ fullPath: '/app/operator'
+ preLoaderRoute: typeof AppOperatorRouteImport
parentRoute: typeof AppRoute
}
- '/app/digest': {
- id: '/app/digest'
- path: '/digest'
- fullPath: '/app/digest'
- preLoaderRoute: typeof AppDigestRouteImport
+ '/app/owner': {
+ id: '/app/owner'
+ path: '/owner'
+ fullPath: '/app/owner'
+ preLoaderRoute: typeof AppOwnerRouteImport
parentRoute: typeof AppRoute
}
- '/app/config-generator': {
- id: '/app/config-generator'
- path: '/config-generator'
- fullPath: '/app/config-generator'
- preLoaderRoute: typeof AppConfigGeneratorRouteImport
+ '/app/playground': {
+ id: '/app/playground'
+ path: '/playground'
+ fullPath: '/app/playground'
+ preLoaderRoute: typeof AppPlaygroundRouteImport
parentRoute: typeof AppRoute
}
- '/app/commands': {
- id: '/app/commands'
- path: '/commands'
- fullPath: '/app/commands'
- preLoaderRoute: typeof AppCommandsRouteImport
+ '/app/repos': {
+ id: '/app/repos'
+ path: '/repos'
+ fullPath: '/app/repos'
+ preLoaderRoute: typeof AppReposRouteImport
parentRoute: typeof AppRoute
}
- '/app/audit': {
- id: '/app/audit'
- path: '/audit'
- fullPath: '/app/audit'
- preLoaderRoute: typeof AppAuditRouteImport
+ '/app/runs': {
+ id: '/app/runs'
+ path: '/runs'
+ fullPath: '/app/runs'
+ preLoaderRoute: typeof AppRunsRouteImport
parentRoute: typeof AppRoute
}
- '/app/analytics': {
- id: '/app/analytics'
- path: '/analytics'
- fullPath: '/app/analytics'
- preLoaderRoute: typeof AppAnalyticsRouteImport
+ '/app/workbench': {
+ id: '/app/workbench'
+ path: '/workbench'
+ fullPath: '/app/workbench'
+ preLoaderRoute: typeof AppWorkbenchRouteImport
parentRoute: typeof AppRoute
}
- '/api/$op': {
- id: '/api/$op'
- path: '/$op'
- fullPath: '/api/$op'
- preLoaderRoute: typeof ApiOpRouteImport
- parentRoute: typeof ApiRoute
+ '/docs/': {
+ id: '/docs/'
+ path: '/'
+ fullPath: '/docs/'
+ preLoaderRoute: typeof DocsIndexRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/docs/$slug': {
+ id: '/docs/$slug'
+ path: '/$slug'
+ fullPath: '/docs/$slug'
+ preLoaderRoute: typeof DocsSlugRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/docs/fumadocs-spike-api-reference': {
+ id: '/docs/fumadocs-spike-api-reference'
+ path: '/fumadocs-spike-api-reference'
+ fullPath: '/docs/fumadocs-spike-api-reference'
+ preLoaderRoute: typeof DocsFumadocsSpikeApiReferenceRouteImport
+ parentRoute: typeof DocsRoute
+ }
+ '/install/': {
+ id: '/install/'
+ path: '/'
+ fullPath: '/install/'
+ preLoaderRoute: typeof InstallIndexRouteImport
+ parentRoute: typeof InstallRoute
+ }
+ '/install/permissions': {
+ id: '/install/permissions'
+ path: '/permissions'
+ fullPath: '/install/permissions'
+ preLoaderRoute: typeof InstallPermissionsRouteImport
+ parentRoute: typeof InstallRoute
}
'/repos/$owner/$repo/quality': {
id: '/repos/$owner/$repo/quality'
diff --git a/package.json b/package.json
index 0da383817c..09965efc6d 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,8 @@
"loadtest:worker": "node --experimental-strip-types scripts/load-test-worker.ts",
"mcp:tool-reference": "tsx scripts/gen-mcp-tool-reference.ts",
"mcp:tool-reference:check": "tsx scripts/gen-mcp-tool-reference.ts --check",
+ "mcp:client-config": "tsx scripts/gen-mcp-client-config.ts",
+ "mcp:client-config:check": "tsx scripts/gen-mcp-client-config.ts --check",
"contract:api-schemas": "tsx scripts/gen-contract-api-schemas.ts",
"contract:api-schemas:check": "tsx scripts/gen-contract-api-schemas.ts --check",
"command-reference": "node --experimental-strip-types scripts/gen-command-reference.ts",
@@ -76,6 +78,7 @@
"validate:no-hand-written-js": "node --experimental-strip-types scripts/validate-no-hand-written-js.ts",
"import-specifiers:check": "node --experimental-strip-types scripts/check-import-specifiers.ts",
"ui-derived-types:check": "node --experimental-strip-types scripts/check-ui-derived-types.ts",
+ "server-manifest:check": "node --experimental-strip-types scripts/check-server-manifest.ts",
"dead-source-files:check": "node --experimental-strip-types scripts/check-dead-source-files.ts",
"regate-sort-key:check": "node --experimental-strip-types scripts/check-regate-sort-key.ts",
"command-redelivery-guards:check": "node --experimental-strip-types scripts/check-command-redelivery-guards.ts",
@@ -128,7 +131,7 @@
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
"pretest:ci": "npm run check-node-version",
- "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
+ "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci",
"test:watch": "vitest",
diff --git a/packages/loopover-contract/package.json b/packages/loopover-contract/package.json
index 0897051fdc..89e8766ca0 100644
--- a/packages/loopover-contract/package.json
+++ b/packages/loopover-contract/package.json
@@ -59,6 +59,14 @@
"./api-schemas": {
"types": "./dist/api-schemas.d.ts",
"default": "./dist/api-schemas.js"
+ },
+ "./discovery": {
+ "types": "./dist/discovery.d.ts",
+ "default": "./dist/discovery.js"
+ },
+ "./client-config": {
+ "types": "./dist/client-config.d.ts",
+ "default": "./dist/client-config.js"
}
},
"files": [
diff --git a/packages/loopover-contract/src/client-config.ts b/packages/loopover-contract/src/client-config.ts
new file mode 100644
index 0000000000..fd2c93257c
--- /dev/null
+++ b/packages/loopover-contract/src/client-config.ts
@@ -0,0 +1,225 @@
+// The one description of how a client connects to LoopOver (#9526).
+//
+// Three connection modes -- the stdio gateway, the remote streamable-http endpoint, and the miner's own
+// stdio server -- times five client hosts, each with its own config file, language, and server-map shape.
+// That is a 14-cell grid, and before this it was written out by hand in four places: `clientSnippet()` in
+// the stdio bin, the mcp-clients docs page, and both package READMEs. Each copy drifted on its own
+// schedule; the docs page never mentioned the remote endpoint's auth at all.
+//
+// So the grid lives here, as data, and every surface renders it: `init-client --print` reads this, and
+// scripts/gen-mcp-client-config.ts writes the docs and README blocks from it under a --check drift guard.
+// Adding a host or a mode is one entry in this file, and every surface that describes it moves together.
+import { DEFAULT_LOOPOVER_API_URL } from "./cli-config.js";
+
+/** How a client reaches LoopOver. Ordered as a reader should consider them: local default first. */
+export const CONNECTION_MODES = ["stdio", "remote", "miner"] as const;
+export type ConnectionMode = (typeof CONNECTION_MODES)[number];
+
+/** The MCP client hosts `init-client` can print for. */
+export const CLIENT_HOSTS = ["codex", "claude", "cursor", "mcp", "vscode"] as const;
+export type ClientHost = (typeof CLIENT_HOSTS)[number];
+
+/**
+ * The server-map shape a host expects. Three genuinely different shapes, which is the whole reason a single
+ * hand-written snippet could never serve every host: Codex reads TOML tables, most JSON hosts read
+ * `mcpServers`, and VS Code reads `servers` with an explicit transport `type`.
+ */
+type HostShape = "toml" | "mcpServers" | "servers";
+
+export type ClientHostSpec = {
+ title: string;
+ /** Where the snippet goes. Rendered as the docs code block's filename. */
+ file: string;
+ /** Where a REMOTE entry goes, when that is a different file from the stdio one. */
+ remoteFile?: string;
+ lang: "toml" | "json";
+ shape: HostShape;
+ /** Hosts that cannot talk streamable-http are stdio-only; the grid must not print config that will fail. */
+ supportsRemote: boolean;
+ /** A caveat that applies only to this host's remote entry. */
+ remoteNote?: string;
+};
+
+export const CLIENT_HOST_SPEC: Record = {
+ codex: {
+ title: "Codex (OpenAI)",
+ file: "~/.codex/config.toml",
+ lang: "toml",
+ shape: "toml",
+ supportsRemote: true,
+ remoteNote: "Codex releases before its RMCP client became the default also need `experimental_use_rmcp_client = true` at the top level of config.toml.",
+ },
+ // Claude Desktop reaches remote servers through its connectors UI rather than a config file, so the
+ // remote snippet is Claude Code's project-scoped .mcp.json — the one place pasting it actually works.
+ claude: { title: "Claude Desktop / Claude Code", file: "claude_desktop_config.json", remoteFile: ".mcp.json", lang: "json", shape: "mcpServers", supportsRemote: true },
+ cursor: { title: "Cursor", file: ".cursor/mcp.json", lang: "json", shape: "mcpServers", supportsRemote: true },
+ // The generic `mcpServers` shape, for stdio hosts that expect it but are not one of the named three.
+ // STDIO-ONLY, deliberately: `mcpServers` is a de-facto stdio convention, and the remote dialects differ
+ // per host (Codex's `bearer_token_env_var` versus the JSON hosts' `headers`). Printing a remote block for
+ // an unnamed host would be guessing, and a snippet that fails on paste is worse than no snippet -- the
+ // stdio gateway serves the remote tools to those hosts anyway.
+ mcp: { title: "Other `mcpServers` hosts", file: "mcp.json", lang: "json", shape: "mcpServers", supportsRemote: false },
+ vscode: { title: "VS Code", file: ".vscode/mcp.json", lang: "json", shape: "servers", supportsRemote: true },
+};
+
+/** The config file a given pair's snippet belongs in. */
+export function clientConfigFile(host: ClientHost, mode: ConnectionMode): string {
+ const spec = CLIENT_HOST_SPEC[host];
+ return CONNECTION_MODE_SPEC[mode].transport === "http" ? (spec.remoteFile ?? spec.file) : spec.file;
+}
+
+export type ConnectionModeSpec = {
+ title: string;
+ /** One sentence a reader can choose by. */
+ summary: string;
+ /** The key the server appears under in the client's server map. */
+ serverKey: string;
+ transport: "stdio" | "http";
+ /** stdio modes only: the executable. Absent for a remote mode, which starts no process. */
+ command?: string;
+ /** Required, not optional: `args: []` is a real answer, and an absent one would make every consumer
+ * write the same `?? []` fallback -- a branch with nothing on the other side of it. */
+ args: readonly string[];
+ /** Anything a reader must do besides paste the snippet. */
+ notes: readonly string[];
+};
+
+/** The remote endpoint. Same origin the server card advertises, so the two can never disagree. */
+export const REMOTE_MCP_URL = `${DEFAULT_LOOPOVER_API_URL}/mcp`;
+
+/**
+ * The env var the remote endpoint's bearer token comes from.
+ *
+ * Deliberately an env-var REFERENCE, never a literal: a config file with a pasted token in it is a token
+ * in your shell history, your backups, and eventually a screenshot. `loopover-mcp login` stores a session
+ * for the stdio modes; a remote client reads this variable instead.
+ */
+export const REMOTE_TOKEN_ENV_VAR = "LOOPOVER_API_TOKEN";
+
+export const CONNECTION_MODE_SPEC: Record = {
+ stdio: {
+ title: "Local stdio (gateway)",
+ summary:
+ "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.",
+ serverKey: "loopover",
+ transport: "stdio",
+ command: "loopover-mcp",
+ args: ["--stdio"],
+ notes: [
+ "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.",
+ "Pass `--no-remote` to keep the server purely local and skip the remote mount entirely.",
+ "Assumes `loopover-mcp` is on your PATH; pass `--command /absolute/path/to/loopover-mcp` if your client does not inherit your shell PATH.",
+ ],
+ },
+ remote: {
+ title: "Remote streamable-http",
+ summary:
+ "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.",
+ serverKey: "loopover",
+ transport: "http",
+ args: [],
+ notes: [
+ `Authenticates with a bearer token read from \`${REMOTE_TOKEN_ENV_VAR}\` — the same variable the CLI honors. Set it in the environment your client starts in; never paste the token into the config file.`,
+ "Tools whose work is a local git operation are absent here by design. Use the stdio mode if you need them.",
+ ],
+ },
+ miner: {
+ title: "Miner stdio",
+ summary:
+ "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.",
+ serverKey: "loopover-miner",
+ transport: "stdio",
+ command: "loopover-miner-mcp",
+ args: [],
+ notes: [
+ "Takes no flags and needs no login — everything it reads is already on this machine.",
+ "A dual-role operator runs this alongside the stdio gateway; the two entries coexist in one client config.",
+ ],
+ },
+};
+
+/** A mode a host cannot actually speak is not printed — a snippet that fails on paste is worse than none. */
+export function supportsConnectionMode(host: ClientHost, mode: ConnectionMode): boolean {
+ return CONNECTION_MODE_SPEC[mode].transport === "stdio" || CLIENT_HOST_SPEC[host].supportsRemote;
+}
+
+/** Every (host, mode) pair that has a snippet, in a stable order for generated output. */
+export function clientConfigMatrix(): Array<{ host: ClientHost; mode: ConnectionMode }> {
+ return CLIENT_HOSTS.flatMap((host) => CONNECTION_MODES.filter((mode) => supportsConnectionMode(host, mode)).map((mode) => ({ host, mode })));
+}
+
+/** A TOML server entry is FLAT by construction -- strings and string arrays only, which is what lets
+ * tomlTable stay a dozen lines with no escape hatch for a shape it cannot render. */
+type TomlEntry = Record;
+
+/** The stdio entry both dialects share. */
+function stdioEntry(mode: ConnectionMode, command: string): { command: string; args: readonly string[] } {
+ return { command, args: CONNECTION_MODE_SPEC[mode].args };
+}
+
+export type ClientConfigOptions = {
+ /** Overrides the mode's default executable, for a client that does not inherit your shell PATH. */
+ command?: string;
+};
+
+/**
+ * The config snippet for one (host, mode) pair.
+ *
+ * Throws on a pair with no snippet rather than emitting a plausible-looking one — printing config that
+ * cannot work is the failure this whole module exists to prevent.
+ */
+export function clientConfigSnippet(host: ClientHost, mode: ConnectionMode, options: ClientConfigOptions = {}): string {
+ if (!supportsConnectionMode(host, mode)) {
+ throw new Error(`${CLIENT_HOST_SPEC[host].title} cannot connect over the ${CONNECTION_MODE_SPEC[mode].title} mode.`);
+ }
+ const spec = CONNECTION_MODE_SPEC[mode];
+ const hostSpec = CLIENT_HOST_SPEC[host];
+ const remote = spec.transport === "http";
+ const command = options.command ?? spec.command ?? "";
+
+ if (hostSpec.shape === "toml") {
+ // Codex names the token's environment variable in its own key and reads it itself.
+ const entry: TomlEntry = remote ? { url: REMOTE_MCP_URL, bearer_token_env_var: REMOTE_TOKEN_ENV_VAR } : stdioEntry(mode, command);
+ return tomlTable(`mcp_servers.${spec.serverKey}`, entry);
+ }
+
+ // The JSON hosts want the header spelled out instead. Same secret, same never-in-the-file rule.
+ // `servers` hosts require the transport type on stdio entries too; `mcpServers` hosts infer it from
+ // `command`, and adding it there would only be noise in a config people read.
+ const entry = remote
+ ? { type: "http", url: REMOTE_MCP_URL, headers: { Authorization: `Bearer \${${REMOTE_TOKEN_ENV_VAR}}` } }
+ : { ...(hostSpec.shape === "servers" ? { type: "stdio" } : {}), ...stdioEntry(mode, command) };
+ return compactStringArrays(JSON.stringify({ [hostSpec.shape === "servers" ? "servers" : "mcpServers"]: { [spec.serverKey]: entry } }, null, 2));
+}
+
+/**
+ * Collapse a short array of strings back onto one line.
+ *
+ * JSON.stringify's pretty printer gives every element its own row, which turns `"args": ["--stdio"]` into
+ * four lines of a block people read at least as often as they paste it. Done here rather than in the docs
+ * generator so the CLI's output and the docs' blocks stay byte-identical -- a formatting difference between
+ * the two is exactly the kind of drift this module exists to remove.
+ */
+export function compactStringArrays(json: string): string {
+ return json.replace(/\[\n(\s+)("(?:[^"\\]|\\.)*"(?:,\n\1"(?:[^"\\]|\\.)*")*)\n\s+\]/g, (whole, _indent: string, body: string) => {
+ const inline = `[${body.split(/,\n\s+/).join(", ")}]`;
+ return inline.length <= 72 ? inline : whole;
+ });
+}
+
+/**
+ * A TOML table for one server entry.
+ *
+ * Hand-rolled rather than pulled from a TOML library on purpose: @loopover/contract is a zod-only leaf
+ * package that both a Cloudflare Worker and a published CLI depend on, and it is not worth a dependency to
+ * serialize strings and string arrays. TomlEntry is what keeps that honest -- a nested table would need a
+ * second pass to place it after its parent's scalars, so the type forbids one rather than a runtime guard
+ * apologizing for one that can never arrive.
+ */
+function tomlTable(tableName: string, entry: TomlEntry): string {
+ const lines = [`[${tableName}]`];
+ for (const [key, value] of Object.entries(entry)) {
+ lines.push(typeof value === "string" ? `${key} = ${JSON.stringify(value)}` : `${key} = [${value.map((item) => JSON.stringify(item)).join(", ")}]`);
+ }
+ return lines.join("\n");
+}
diff --git a/packages/loopover-contract/src/discovery.ts b/packages/loopover-contract/src/discovery.ts
new file mode 100644
index 0000000000..c690f77405
--- /dev/null
+++ b/packages/loopover-contract/src/discovery.ts
@@ -0,0 +1,200 @@
+// The discovery projections every runtime serves (#9526).
+//
+// `/.well-known/mcp.json` and the `/.well-known/agent-tools/*` trio are COMPUTED AT REQUEST TIME from the
+// contract registry, never committed. metagraphed learned that the hard way: a committed server card made
+// every concurrent tool PR conflict on the same generated file. There is nothing here to commit and nothing
+// to regenerate — the registry is the artifact.
+//
+// Pure by construction: these take the tool list and a few facts and return plain objects, so the Worker,
+// the self-host app, and any test can call them identically. Availability filtering is the CALLER's job —
+// a self-host deployment passes its own filtered list, which is what makes the same route serve a truthful
+// answer on both deployments.
+import { z } from "zod";
+import type { McpToolDefinition } from "./tool-definition.js";
+
+/** Which deployment is answering; the card names it so a reader can tell the two apart. */
+export const DISCOVERY_DEPLOYMENTS = ["cloud", "selfhost"] as const;
+export type DiscoveryDeployment = (typeof DISCOVERY_DEPLOYMENTS)[number];
+
+/**
+ * The documents are ZOD SCHEMAS with their TypeScript types inferred, not types with a hand-written schema
+ * beside them (#9526).
+ *
+ * They are served by specced routes, so the published OpenAPI document needs a schema for each -- and a
+ * second, hand-kept declaration of the same shape is the drift this whole epic exists to remove. Declaring
+ * the schema and inferring the type means the builders below are type-checked against exactly what the API
+ * document promises.
+ */
+const JsonSchemaLikeSchema = z.looseObject({}).describe("JSON Schema (draft 2020-12) for a tool's arguments or result.");
+const ToolAnnotationsSchema = z.object({ readOnlyHint: z.boolean(), destructiveHint: z.boolean() });
+
+export const ServerCardSchema = z.object({
+ name: z.string(),
+ version: z.string(),
+ description: z.string(),
+ deployment: z.enum(DISCOVERY_DEPLOYMENTS),
+ generated_at: z.string(),
+ capabilities: z.object({ tools: z.object({ listChanged: z.boolean() }) }),
+ remotes: z.array(z.object({ type: z.literal("streamable-http"), url: z.string() })),
+ tools: z.array(z.object({ name: z.string(), title: z.string(), description: z.string(), category: z.string(), annotations: ToolAnnotationsSchema })),
+});
+
+export const ToolExecutorSchema = z.object({ transport: z.literal("streamable-http"), url: z.string(), method: z.literal("tools/call") });
+
+export const AgentToolsIndexSchema = z.object({
+ generated_at: z.string(),
+ executor: ToolExecutorSchema,
+ tools: z.array(
+ z.object({
+ name: z.string(),
+ title: z.string(),
+ description: z.string(),
+ category: z.string(),
+ annotations: ToolAnnotationsSchema,
+ input_schema: JsonSchemaLikeSchema,
+ output_schema: JsonSchemaLikeSchema,
+ }),
+ ),
+});
+
+export const OpenAiToolsSchema = z.object({
+ generated_at: z.string(),
+ executor: ToolExecutorSchema,
+ tools: z.array(z.object({ type: z.literal("function"), function: z.object({ name: z.string(), description: z.string(), parameters: JsonSchemaLikeSchema }) })),
+});
+
+export const AnthropicToolsSchema = z.object({
+ generated_at: z.string(),
+ executor: ToolExecutorSchema,
+ tools: z.array(z.object({ name: z.string(), description: z.string(), input_schema: JsonSchemaLikeSchema })),
+});
+
+export type ServerCard = z.infer;
+export type ToolExecutor = z.infer;
+export type AgentToolsIndex = z.infer;
+export type OpenAiTools = z.infer;
+export type AnthropicTools = z.infer;
+
+export type ServerCardInput = {
+ /** The server's semantic version. Sourced from @loopover/mcp's package.json — never hand-bumped here. */
+ version: string;
+ deployment: DiscoveryDeployment;
+ /** Absolute base URL this deployment answers on, e.g. `https://api.loopover.ai`. */
+ baseUrl: string;
+ tools: readonly McpToolDefinition[];
+ /**
+ * Timestamp for the card. DETERMINISTIC by contract: the caller passes a value derived from the deploy
+ * (the version), not `Date.now()` — a clock-derived field would change the ETag on every request and
+ * defeat the 304 path entirely.
+ */
+ generatedAt: string;
+};
+
+export const SERVER_CARD_NAME = "io.github.JSONbored/loopover";
+export const SERVER_CARD_DESCRIPTION =
+ "LoopOver's contribution-intelligence tools: issue ranking, branch and PR preflight, gate prediction, review explanation, and maintainer/operator management.";
+
+/** The MCP server card. Tool entries carry the catalog fields a client picks by, not the full schemas. */
+export function buildServerCard(input: ServerCardInput): ServerCard {
+ return {
+ name: SERVER_CARD_NAME,
+ version: input.version,
+ description: SERVER_CARD_DESCRIPTION,
+ deployment: input.deployment,
+ generated_at: input.generatedAt,
+ capabilities: { tools: { listChanged: true } },
+ remotes: [{ type: "streamable-http", url: `${trimTrailingSlash(input.baseUrl)}/mcp` }],
+ tools: input.tools.map((tool) => ({
+ name: tool.name,
+ title: tool.title,
+ description: tool.description,
+ category: tool.category,
+ annotations: tool.annotations,
+ })),
+ };
+}
+
+function trimTrailingSlash(value: string): string {
+ return value.replace(/\/+$/, "");
+}
+
+/**
+ * How a caller actually invokes one of these tools. Every agent-tools document carries it, because a tool
+ * catalog with no executor is a list a reader cannot act on.
+ */
+function executorFor(baseUrl: string): ToolExecutor {
+ return { transport: "streamable-http", url: `${trimTrailingSlash(baseUrl)}/mcp`, method: "tools/call" };
+}
+
+export type AgentToolsInput = { baseUrl: string; tools: readonly McpToolDefinition[]; generatedAt: string };
+
+/** The neutral index: every tool with both schemas, plus the executor. */
+export function buildAgentToolsIndex(input: AgentToolsInput): AgentToolsIndex {
+ return {
+ generated_at: input.generatedAt,
+ executor: executorFor(input.baseUrl),
+ tools: input.tools.map((tool) => ({
+ name: tool.name,
+ title: tool.title,
+ description: tool.description,
+ category: tool.category,
+ annotations: tool.annotations,
+ input_schema: tool.inputSchema,
+ output_schema: tool.outputSchema,
+ })),
+ };
+}
+
+/** OpenAI's function-tool shape. `parameters` is the input schema verbatim — no second translation. */
+export function buildOpenAiTools(input: AgentToolsInput): OpenAiTools {
+ return {
+ generated_at: input.generatedAt,
+ executor: executorFor(input.baseUrl),
+ tools: input.tools.map((tool) => ({
+ type: "function" as const,
+ function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
+ })),
+ };
+}
+
+/** Anthropic's tool shape. Same schemas, different key names; still one source. */
+export function buildAnthropicTools(input: AgentToolsInput): AnthropicTools {
+ return {
+ generated_at: input.generatedAt,
+ executor: executorFor(input.baseUrl),
+ tools: input.tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.inputSchema,
+ })),
+ };
+}
+
+/**
+ * A weak ETag over the document.
+ *
+ * Weak, not strong: these documents are semantically stable per deploy but their byte encoding is not
+ * guaranteed across runtimes. The hash is FNV-1a over the serialized body -- non-cryptographic on purpose,
+ * since this identifies a cache entry rather than authenticating anything, and it must run cheaply inside a
+ * request on the Workers runtime.
+ */
+export function weakETag(body: string): string {
+ let hash = 0x811c9dc5;
+ for (let index = 0; index < body.length; index += 1) {
+ hash ^= body.charCodeAt(index);
+ // FNV prime, via shifts so this stays in 32-bit integer math rather than overflowing to a float.
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
+ }
+ return `W/"${hash.toString(16)}"`;
+}
+
+/** True when the request's `if-none-match` already names this entity, so the caller may answer 304. */
+export function matchesETag(ifNoneMatch: string | null | undefined, etag: string): boolean {
+ if (!ifNoneMatch) return false;
+ // A client may send a list, and may drop the weak prefix; compare on the opaque value alone.
+ const wanted = etag.replace(/^W\//, "");
+ return ifNoneMatch
+ .split(",")
+ .map((candidate) => candidate.trim().replace(/^W\//, ""))
+ .some((candidate) => candidate === wanted || candidate === "*");
+}
diff --git a/packages/loopover-contract/src/telemetry.ts b/packages/loopover-contract/src/telemetry.ts
index 597e656a68..62ebbc6c39 100644
--- a/packages/loopover-contract/src/telemetry.ts
+++ b/packages/loopover-contract/src/telemetry.ts
@@ -43,6 +43,18 @@ export type McpTelemetryErrorCode = (typeof MCP_TELEMETRY_ERROR_CODES)[number];
export const MCP_TELEMETRY_SURFACES = ["remote", "stdio", "miner"] as const;
export type McpTelemetrySurface = (typeof MCP_TELEMETRY_SURFACES)[number];
+/**
+ * HOW the answering server executed the call (#9526).
+ *
+ * Orthogonal to `surface`, which says which server was asked. Since the stdio gateway mounts the remote
+ * tool set, one `surface: "stdio"` call may have run against the local checkout or been forwarded to the
+ * hosted server, and those are different products from an adoption standpoint: gateway uptake is exactly
+ * the count of `stdio` + `proxied`, and it is unmeasurable without this dimension because the tool name
+ * alone does not say which path a given release took.
+ */
+export const MCP_TELEMETRY_TRANSPORTS = ["local", "proxied"] as const;
+export type McpTelemetryTransport = (typeof MCP_TELEMETRY_TRANSPORTS)[number];
+
/**
* The COMPLETE set of property keys any MCP telemetry event may carry.
*
@@ -54,6 +66,7 @@ export const MCP_TELEMETRY_PROPERTY_KEYS = [
"tool",
"category",
"surface",
+ "transport",
"ok",
"duration_ms",
"error_code",
@@ -68,6 +81,10 @@ export const McpToolCallTelemetry = z.object({
tool: z.string().min(1),
category: z.string().min(1),
surface: z.enum(MCP_TELEMETRY_SURFACES),
+ // Optional at the seam, never optional on the wire: a sink that has no notion of proxying (the Worker
+ // and the miner both execute everything themselves) should not have to say so, but a breakdown by
+ // transport still needs both buckets populated, so buildUsageEventProperties defaults it.
+ transport: z.enum(MCP_TELEMETRY_TRANSPORTS).optional(),
ok: z.boolean(),
durationMs: z.number().int().min(0),
errorCode: z.enum(MCP_TELEMETRY_ERROR_CODES).optional(),
@@ -85,6 +102,7 @@ export function buildUsageEventProperties(call: McpToolCallTelemetry): Record [--json]
loopover-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json]
loopover-mcp telemetry enable|disable|status [--json]
-loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]
+loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--mode stdio|remote|miner] [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]
loopover-mcp decision-pack --login [--json]
loopover-mcp repo-decision --login --repo owner/repo [--json]
loopover-mcp contributor-profile [--login ] [--json]
@@ -334,7 +334,57 @@ The same capabilities are exposed to MCP clients as:
### Client config
-`init-client --print ` prints the stdio MCP config for a host: `codex` (TOML), `claude`, `cursor`, and `mcp` (the shared `mcpServers` JSON shape), and `vscode` (VS Code's native `servers` map with `"type": "stdio"`, for `.vscode/mcp.json`). It prints config only; it never edits client files.
+
+
+`init-client --print [--mode ]` prints the MCP config for a host: `codex`, `claude`, `cursor`, `mcp`, `vscode`. Modes are `stdio`, `remote`, `miner`, defaulting to `stdio`. It prints config only; it never edits client files.
+
+#### Local stdio (gateway)
+
+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.
+
+Claude Desktop / Claude Code, in `claude_desktop_config.json`:
+
+```json
+{
+ "mcpServers": {
+ "loopover": {
+ "command": "loopover-mcp",
+ "args": ["--stdio"]
+ }
+ }
+}
+```
+
+- 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.
+- Pass `--no-remote` to keep the server purely local and skip the remote mount entirely.
+- Assumes `loopover-mcp` is on your PATH; pass `--command /absolute/path/to/loopover-mcp` if your client does not inherit your shell PATH.
+
+#### Remote streamable-http
+
+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.
+
+Claude Desktop / Claude Code, in `.mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "loopover": {
+ "type": "http",
+ "url": "https://api.loopover.ai/mcp",
+ "headers": {
+ "Authorization": "Bearer ${LOOPOVER_API_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+- 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.
+- Tools whose work is a local git operation are absent here by design. Use the stdio mode if you need them.
+
+The remote endpoint is `https://api.loopover.ai/mcp` — the same URL `/.well-known/mcp.json` advertises.
+
+
### Agent profiles
diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts
index 47e3c8dfae..f76b753fa6 100644
--- a/packages/loopover-mcp/bin/loopover-mcp.ts
+++ b/packages/loopover-mcp/bin/loopover-mcp.ts
@@ -7,8 +7,28 @@ import { homedir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { CLI_RESPONSE_SCHEMAS, type ApiResponse, type ValidatedApiPath } from "@loopover/contract/api-schemas";
+import {
+ CLIENT_HOSTS,
+ CLIENT_HOST_SPEC,
+ CONNECTION_MODES,
+ CONNECTION_MODE_SPEC,
+ clientConfigFile,
+ clientConfigSnippet,
+ supportsConnectionMode,
+ type ClientHost,
+ type ConnectionMode,
+} from "@loopover/contract/client-config";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import {
+ GATEWAY_DISABLED_ADVISORY,
+ discoverRemoteTools,
+ gatewayAdvisoryResource,
+ gatewayDisabled,
+ type GatewayFetch,
+ type GatewayMountResult,
+ type RemoteToolDescriptor,
+} from "../lib/gateway.js";
import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions, buildPublicPrBodyDraft } from "@loopover/engine";
// #9537: the plan-DAG state machine, formerly hand-copied into this file, untyped.
import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "@loopover/engine";
@@ -237,7 +257,14 @@ const CLI_COMMAND_SPEC = {
tools: { subcommands: ["search"], usage: ["tools [--json]", "tools search [--json]"] },
doctor: { subcommands: [], usage: ["doctor [--profile name] [--cwd path] [--exit-code] [--json]"] },
telemetry: { subcommands: ["enable", "disable", "status"], usage: ["telemetry enable|disable|status [--json]"] },
- "init-client": { subcommands: [], usage: ["init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]"] },
+ "init-client": {
+ subcommands: [],
+ // Hosts and modes come from the @loopover/contract grid that also renders the snippets and the docs, so
+ // adding either can never leave the usage line describing a smaller surface than the command accepts.
+ usage: [
+ `init-client --print ${CLIENT_HOSTS.join("|")} [--mode ${CONNECTION_MODES.join("|")}] [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]`,
+ ],
+ },
"decision-pack": { subcommands: [], usage: ["decision-pack --login [--json]"] },
"repo-decision": { subcommands: [], usage: ["repo-decision --login --repo owner/repo [--json]"] },
"contributor-profile": { subcommands: [], usage: ["contributor-profile [--login ] [--json]"] },
@@ -832,6 +859,13 @@ export const server = new McpServer({
// Reads telemetryState() HERE on purpose: registerStdioTool's second parameter is the TOOL's config and
// shadows the module-level `config`, so a read inside a nested function would silently see the wrong object.
/* v8 ignore start -- thin registration glue; wrapStdioToolHandler covered by unit tests (#8690) */
+/**
+ * Every tool THIS server registers locally. Gateway mode reads it to skip a remote tool of the same name
+ * (#9526) -- the contract registry makes a collision impossible, but a duplicate registration would crash
+ * the server, so "local wins" is a cheaper failure than refusing to start.
+ */
+const locallyRegisteredToolNames = new Set();
+
function registerStdioTool(
name: string,
handler: (input: TInput, extra?: unknown) => unknown,
@@ -842,6 +876,7 @@ function registerStdioTool(
): void {
const contract = getToolContract(name);
if (!contract) throw new Error(`No @loopover/contract entry for stdio tool: ${name}`);
+ locallyRegisteredToolNames.add(name);
server.registerTool(
name,
{
@@ -2447,10 +2482,154 @@ server.registerPrompt(
}),
);
+/** The real transport for the discovery call; injected in tests so no test reaches the network. */
+const defaultGatewayFetch: GatewayFetch = async (url, init) => {
+ const response = await fetch(url, init as RequestInit);
+ return { ok: response.ok, status: response.status, json: () => response.json() };
+};
+
+/**
+ * Re-register one remote tool as a passthrough proxy.
+ *
+ * Schemas come from the CONTRACT REGISTRY, not from the wire. The SDK's registerTool takes zod, while
+ * tools/list carries JSON Schema, so the wire shape cannot be handed over as-is -- and reaching for the
+ * registry is the better answer anyway: it is the same source the remote registered from, so the proxy
+ * advertises exactly what the remote enforces without this package trusting a remote-supplied schema. A
+ * tool absent from the registry (a remote running ahead of this package) still mounts, with the remote as
+ * the only validator -- which is where validation belongs regardless.
+ *
+ * `_meta.transport` marks the tool so telemetry can tell a proxied call from a local one (#9526 requirement
+ * 6) without having to know which names are remote.
+ */
+function registerProxiedTool(tool: RemoteToolDescriptor): void {
+ const contract = getToolContract(tool.name);
+ server.registerTool(
+ tool.name,
+ {
+ ...(tool.title ? { title: tool.title } : {}),
+ ...(tool.description ? { description: tool.description } : {}),
+ // The schema OBJECTS, not their `.shape` -- a raw shape is re-wrapped in a plain `z.object` that
+ // silently drops the catchall, turning every extra field the remote allows into a -32602 here.
+ //
+ // A tool absent from the registry (a remote running ahead of this package) still gets an inputSchema,
+ // just a fully open one. That is NOT cosmetic: the SDK invokes a handler as `(args, extra)` only when
+ // an inputSchema is declared, and as `(extra)` alone when it is not -- so omitting it made the proxy
+ // forward the SDK's own `{ signal, requestId }` to the remote AS THE ARGUMENTS, silently dropping
+ // everything the caller passed. An open object keeps the remote the only validator, which is where
+ // validation belongs for a tool this package does not model.
+ inputSchema: contract?.input ?? z.looseObject({}),
+ ...(contract ? { outputSchema: contract.output } : {}),
+ ...(tool.annotations ? { annotations: tool.annotations as never } : {}),
+ _meta: { ...(tool._meta ?? {}), transport: "proxied" },
+ } as never,
+ // Wrapped by the same telemetry chokepoint local tools go through, tagged `proxied` -- gateway
+ // adoption is exactly the count of stdio calls that took this path (#9526 requirement 6).
+ wrapStdioToolHandler(
+ tool.name,
+ () => telemetryState().enabled,
+ (async (input: unknown) => {
+ // Forwarded verbatim to the remote's own tools/call: this layer routes, it does not interpret.
+ const payload = await apiPost("/mcp", { jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name: tool.name, arguments: input } });
+ const result = (payload as { result?: unknown }).result;
+ return result ?? payload;
+ }) as (...args: unknown[]) => Promise,
+ "proxied",
+ ) as never,
+ );
+}
+
+/** Registered at most once: a re-mount replaces the advisory's CONTENT, not the registration. */
+let gatewayAdvisoryState: { status: string; advisory: string } | null = null;
+let gatewayAdvisoryRegistered = false;
+
+/**
+ * The resource a client reads to learn why it does or does not have the remote tools.
+ *
+ * A resource, never an error -- see gateway.ts. Registered on EVERY outcome, including success: a mount
+ * that succeeded must not leave an earlier failure's advisory standing, and "why don't I have the remote
+ * tools" deserves an answer when the answer is "you do".
+ */
+function registerGatewayAdvisory(result: GatewayMountResult): void {
+ gatewayAdvisoryState = gatewayAdvisoryResource(result) ?? { status: "mounted", advisory: "Remote tools are mounted on this server." };
+ if (gatewayAdvisoryRegistered) return;
+ gatewayAdvisoryRegistered = true;
+ server.registerResource(
+ "loopover_gateway_status",
+ "loopover://gateway/status",
+ { title: "LoopOver gateway status", description: "Why remote tools are or are not mounted on this stdio server.", mimeType: "application/json" },
+ async () => ({ contents: [{ uri: "loopover://gateway/status", mimeType: "application/json", text: JSON.stringify(gatewayAdvisoryState, null, 2) }] }),
+ );
+}
+
+/**
+ * Gateway mode (#9526): mount the REMOTE server's tools onto this stdio server.
+ *
+ * One stdio entry, every tool the caller's session entitles them to -- rather than a local config for the
+ * local-git tools and a second, separate remote entry for the rest. Each proxied tool re-registers with the
+ * schemas that came across the wire, so nothing is duplicated here and a remote schema change needs no
+ * release of this package.
+ *
+ * Every failure is non-fatal by design. No session, no network, a 500 from the API -- all of them leave a
+ * working local server plus one advisory resource. An enhancement that can break startup is a liability, and
+ * a contributor on a plane still needs their local tools.
+ */
+export async function mountRemoteTools(options: { argv?: readonly string[]; fetchImpl?: GatewayFetch } = {}): Promise {
+ const argv = options.argv ?? cliArgs;
+ if (gatewayDisabled(argv)) {
+ registerGatewayAdvisory({ status: "unavailable", advisory: GATEWAY_DISABLED_ADVISORY });
+ return { status: "unavailable", advisory: GATEWAY_DISABLED_ADVISORY };
+ }
+
+ const result = await discoverRemoteTools({
+ apiUrl,
+ token: getApiToken() ?? null,
+ // Local wins on a collision. The contract registry makes one impossible -- a name is local-git OR
+ // remote, never both -- but a duplicate registration would crash the server, so this degrades instead.
+ localToolNames: locallyRegisteredToolNames,
+ fetchImpl: options.fetchImpl ?? (defaultGatewayFetch as GatewayFetch),
+ });
+
+ registerGatewayAdvisory(result);
+ if (result.status !== "mounted") return result;
+
+ const mounted: RemoteToolDescriptor[] = [];
+ const skipped = [...result.skipped];
+ for (const tool of result.tools) {
+ try {
+ registerProxiedTool(tool);
+ mounted.push(tool);
+ } catch {
+ // One descriptor the SDK refuses -- a repeated name in the same response, say -- must cost that tool
+ // and nothing else. Mounting is all-or-nothing only if you let it be.
+ skipped.push(tool.name);
+ }
+ }
+ return { status: "mounted", tools: mounted, skipped };
+}
+
// #7764: only bind the shared stdin/stdout transport when actually launched as the CLI/stdio process. An
// in-process unit-test importer holds the exported `server` and connects it to an in-memory transport instead.
-/* v8 ignore next -- only the launched stdio process binds the real transport; unit tests connect in-memory. */
-if (runAsCliEntrypoint) await server.connect(new StdioServerTransport());
+/* v8 ignore start -- only the launched stdio process binds the real transport and mounts remote tools;
+ unit tests call mountRemoteTools() directly and connect in-memory. */
+if (runAsCliEntrypoint) {
+ await server.connect(new StdioServerTransport());
+ // Mount AFTER connecting, and without awaiting it (#9526): a network round-trip in front of the transport
+ // bind would put seconds of dead air between a client launching this process and it answering anything.
+ // Registering a tool on a connected server emits notifications/tools/list_changed, so a client that
+ // listed early is told to re-list the moment the remote set lands -- which is the refresh path the
+ // stateless remote cannot push on its own.
+ //
+ // The .catch is the boundary belt: mountRemoteTools is total by construction (discoverRemoteTools guards
+ // its own I/O, and each registration is caught individually), but nothing here awaits it, and an unhandled
+ // rejection ends a Node process. A dead stdio server is not an acceptable cost for an enhancement.
+ void mountRemoteTools().catch((error: unknown) => {
+ registerGatewayAdvisory({
+ status: "unavailable",
+ advisory: `Remote tools are not mounted: the gateway failed to start (${error instanceof Error ? error.message : String(error)}). Local tools are available now.`,
+ });
+ });
+}
+/* v8 ignore stop */
async function withClientWorkspaceRoots(input: any) {
return withWorkspaceRoots(input, await clientWorkspaceRoots());
@@ -4736,20 +4915,30 @@ function shellArg(value: any) {
}
function initClient(options: any) {
- const client = String(options.print ?? options.client ?? "").toLowerCase();
- if (!client) throw new Error("Pass --print codex, --print claude, --print cursor, --print mcp, or --print vscode.");
- const command = options.command ?? "loopover-mcp";
- const snippet = clientSnippet(client, command);
+ const client = String(options.print ?? options.client ?? "").toLowerCase() as ClientHost;
+ if (!client) throw new Error(`Pass --print with one of: ${CLIENT_HOSTS.join(", ")}.`);
+ if (!CLIENT_HOSTS.includes(client)) throw new Error(`Unsupported client: ${client}. Use ${CLIENT_HOSTS.join(", ")}.`);
+ // #9526: the same host can be wired three ways, so the mode is explicit rather than assumed. `stdio` stays
+ // the default, which is why every pre-gateway invocation keeps printing exactly what it printed before.
+ const mode = String(options.mode ?? "stdio").toLowerCase() as ConnectionMode;
+ if (!CONNECTION_MODES.includes(mode)) throw new Error(`Unsupported mode: ${mode}. Use ${CONNECTION_MODES.join(", ")}.`);
+ if (!supportsConnectionMode(client, mode)) throw new Error(`${CLIENT_HOST_SPEC[client].title} cannot connect over the ${CONNECTION_MODE_SPEC[mode].title} mode.`);
+ const modeSpec = CONNECTION_MODE_SPEC[mode];
+ const command = options.command ?? modeSpec.command ?? "loopover-mcp";
+ const snippet = clientConfigSnippet(client, mode, { command });
const agentProfile = resolveAgentProfile(options.agentProfile);
+ const remoteNote = modeSpec.transport === "http" ? CLIENT_HOST_SPEC[client].remoteNote : undefined;
const payload = {
client,
- command,
- args: ["--stdio"],
+ mode,
+ file: clientConfigFile(client, mode),
+ command: modeSpec.transport === "http" ? null : command,
+ args: [...modeSpec.args],
snippet,
agentProfile,
notes: [
- "Run `loopover-mcp login` before starting the MCP client.",
- "Use an absolute command path if the client does not inherit your shell PATH.",
+ ...modeSpec.notes,
+ ...(remoteNote ? [remoteNote] : []),
"This command prints config only; it does not edit client files.",
...(agentProfile
? [
@@ -5150,42 +5339,6 @@ function redactPrivateValidationMetrics(text: any) {
);
}
-function clientSnippet(client: any, command: any) {
- if (client === "codex") return `[mcp_servers.loopover]\ncommand = ${JSON.stringify(command)}\nargs = ["--stdio"]`;
- if (client === "claude" || client === "cursor" || client === "mcp") {
- return JSON.stringify(
- {
- mcpServers: {
- loopover: {
- command,
- args: ["--stdio"],
- },
- },
- },
- null,
- 2,
- );
- }
- // VS Code's native MCP support uses a `servers` map with an explicit transport type, not the
- // `mcpServers` shape the other JSON hosts use, so it needs its own snippet (see .vscode/mcp.json).
- if (client === "vscode") {
- return JSON.stringify(
- {
- servers: {
- loopover: {
- type: "stdio",
- command,
- args: ["--stdio"],
- },
- },
- },
- null,
- 2,
- );
- }
- throw new Error(`Unsupported client: ${client}. Use codex, claude, cursor, mcp, or vscode.`);
-}
-
async function getDecisionPackWithCache(login: any) {
try {
const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/decision-pack`);
diff --git a/packages/loopover-mcp/lib/gateway.ts b/packages/loopover-mcp/lib/gateway.ts
new file mode 100644
index 0000000000..9dd763c3a7
--- /dev/null
+++ b/packages/loopover-mcp/lib/gateway.ts
@@ -0,0 +1,129 @@
+// Gateway mode: the stdio server mounts the REMOTE server's tools (#9526).
+//
+// One stdio entry, every tool a session entitles you to. Without this a contributor configures the stdio
+// server for local-git tools and separately points something at `https://api.loopover.ai/mcp` for the rest —
+// two configs for what is one product.
+//
+// Name collisions cannot happen by construction, and that is a property of #9518's registry rather than a
+// check here: post-migration a tool name is either locality `local-git` (served locally) or `remote`
+// (proxied), never both, because there is ONE contract registry and one entry per name. `validate:mcp`
+// asserts the invariant; this module relies on it and additionally skips anything already registered, so a
+// future violation degrades to "local wins" instead of a duplicate-registration crash.
+//
+// Offline or unauthenticated is NOT an error. A contributor with no session, or on a plane, gets the local
+// tools and one advisory — never a server that fails to start. That is the whole reason the mount is
+// best-effort: the gateway is an enhancement, and an enhancement that can break startup is a liability.
+
+/** The subset of a remote `tools/list` entry this needs. Schemas come across the wire; nothing is duplicated. */
+export type RemoteToolDescriptor = {
+ name: string;
+ title?: string;
+ description?: string;
+ inputSchema?: unknown;
+ outputSchema?: unknown;
+ annotations?: Record;
+ _meta?: Record;
+};
+
+export type GatewayFetch = (
+ url: string,
+ init: { method: string; headers: Record; body: string; signal?: AbortSignal },
+) => Promise<{ ok: boolean; status: number; json: () => Promise }>;
+
+export type GatewayMountInput = {
+ apiUrl: string;
+ /** The session token from `loopover-mcp login`. Absent ⇒ local tools only, no request attempted. */
+ token: string | null;
+ /** Names the local server already serves. A remote tool with one of these names is skipped, never replaced. */
+ localToolNames: ReadonlySet;
+ fetchImpl: GatewayFetch;
+ /** Bounds the discovery call so a slow or hanging remote cannot delay stdio startup. */
+ timeoutMs?: number;
+};
+
+export type GatewayMountResult =
+ | { status: "mounted"; tools: RemoteToolDescriptor[]; skipped: string[] }
+ | { status: "unauthenticated"; advisory: string }
+ | { status: "unavailable"; advisory: string };
+
+export const GATEWAY_UNAUTHENTICATED_ADVISORY =
+ "Remote tools are not mounted: no LoopOver session on this machine. Run `loopover-mcp login` and restart your client to use the full tool set. Local tools are available now.";
+
+export const GATEWAY_DISABLED_ADVISORY =
+ "Remote tools are not mounted: gateway mode was disabled with --no-remote. Local tools are available; drop the flag and restart your client to mount the remote tool set.";
+
+function unavailableAdvisory(reason: string): string {
+ return `Remote tools are not mounted: ${reason}. Local tools are available now; they will mount on the next start once the API is reachable.`;
+}
+
+const DEFAULT_TIMEOUT_MS = 5_000;
+
+/**
+ * Discover the remote tool set for this session.
+ *
+ * Every failure path lands on `unavailable` with a readable reason rather than throwing: an offline
+ * contributor must still get a working stdio server. A non-2xx is reported by status because the body is
+ * remote-controlled and has no business in a local advisory string.
+ */
+export async function discoverRemoteTools(input: GatewayMountInput): Promise {
+ if (!input.token) return { status: "unauthenticated", advisory: GATEWAY_UNAUTHENTICATED_ADVISORY };
+
+ let response: Awaited>;
+ try {
+ response = await input.fetchImpl(`${input.apiUrl.replace(/\/+$/, "")}/mcp`, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${input.token}`,
+ "content-type": "application/json",
+ accept: "application/json, text/event-stream",
+ },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
+ signal: AbortSignal.timeout(input.timeoutMs ?? DEFAULT_TIMEOUT_MS),
+ });
+ } catch (error) {
+ return { status: "unavailable", advisory: unavailableAdvisory(`the API was unreachable (${error instanceof Error ? error.message : String(error)})`) };
+ }
+
+ if (!response.ok) {
+ return { status: "unavailable", advisory: unavailableAdvisory(`the API answered ${response.status}`) };
+ }
+
+ const payload = (await response.json().catch(() => null)) as { result?: { tools?: unknown } } | null;
+ const tools = payload?.result?.tools;
+ if (!Array.isArray(tools)) {
+ return { status: "unavailable", advisory: unavailableAdvisory("the API returned no tool list") };
+ }
+
+ const mounted: RemoteToolDescriptor[] = [];
+ const skipped: string[] = [];
+ for (const candidate of tools) {
+ if (!candidate || typeof candidate !== "object") continue;
+ const tool = candidate as RemoteToolDescriptor;
+ if (typeof tool.name !== "string" || tool.name === "") continue;
+ // Local wins. The registry makes this unreachable, but a duplicate registration would crash the server,
+ // and degrading to "the local one" is strictly better than refusing to start.
+ if (input.localToolNames.has(tool.name)) {
+ skipped.push(tool.name);
+ continue;
+ }
+ mounted.push(tool);
+ }
+ return { status: "mounted", tools: mounted, skipped };
+}
+
+/** `--no-remote` gives byte-identical pre-gateway behavior, for anyone who wants only local tools. */
+export function gatewayDisabled(argv: readonly string[]): boolean {
+ return argv.includes("--no-remote");
+}
+
+/**
+ * The advisory a client sees when remote tools are not mounted, as a resource rather than an error.
+ *
+ * A missing session is a normal state, not a failure, so it must never surface as a tool error or a
+ * non-zero exit — the client would render a broken server where the honest answer is "you have the local
+ * half, here is how to get the rest".
+ */
+export function gatewayAdvisoryResource(result: GatewayMountResult): { status: string; advisory: string } | null {
+ if (result.status === "mounted") return null;
+ return { status: result.status, advisory: result.advisory };
+}
diff --git a/packages/loopover-mcp/lib/telemetry.ts b/packages/loopover-mcp/lib/telemetry.ts
index 09ac91f2c8..2a56445bc2 100644
--- a/packages/loopover-mcp/lib/telemetry.ts
+++ b/packages/loopover-mcp/lib/telemetry.ts
@@ -9,6 +9,7 @@ import {
resolveErrorCode,
toolExcludesPayloads,
UNKNOWN_TOOL_CATEGORY,
+ type McpTelemetryTransport,
type McpToolCallTelemetry,
} from "@loopover/contract";
@@ -111,6 +112,9 @@ export function wrapStdioToolHandler(
name: string,
getTelemetryEnabled: () => boolean,
handler: StdioToolHandler,
+ // #9526: "local" means this process did the work; "proxied" means it forwarded the call to the hosted
+ // server through gateway mode. Defaulted so every pre-gateway registration keeps reporting what it did.
+ transport: McpTelemetryTransport = "local",
): StdioToolHandler {
return async (...args) => {
const startedAt = Date.now();
@@ -124,6 +128,7 @@ export function wrapStdioToolHandler(
tool: name,
ok,
durationMs: Date.now() - startedAt,
+ transport,
args: args[0],
result: result?.structuredContent,
});
@@ -134,6 +139,7 @@ export function wrapStdioToolHandler(
tool: name,
ok: false,
durationMs: Date.now() - startedAt,
+ transport,
args: args[0],
error,
});
@@ -155,7 +161,7 @@ export function wrapStdioToolHandler(
*/
export async function recordStdioDispatchTelemetry(
telemetryEnabled: boolean,
- call: { tool: string; ok: boolean; durationMs: number; args?: unknown; result?: unknown; error?: unknown },
+ call: { tool: string; ok: boolean; durationMs: number; transport?: McpTelemetryTransport; args?: unknown; result?: unknown; error?: unknown },
): Promise {
if (telemetryEnabled !== true) return;
const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY);
@@ -166,6 +172,7 @@ export async function recordStdioDispatchTelemetry(
tool: call.tool,
category: contract?.category ?? UNKNOWN_TOOL_CATEGORY,
surface: "stdio",
+ transport: call.transport ?? "local",
ok: call.ok,
durationMs: call.durationMs,
...(call.ok ? {} : { errorCode: resolveErrorCode(call.error) }),
diff --git a/packages/loopover-miner/README.md b/packages/loopover-miner/README.md
index 9210a4ced1..d8cdd4772f 100644
--- a/packages/loopover-miner/README.md
+++ b/packages/loopover-miner/README.md
@@ -287,7 +287,9 @@ It exposes these read-only tools, generated from the `@loopover/contract` regist
### Client config
-`loopover-mcp` (ORB's hosted contributor-workflow tools) and `loopover-miner-mcp` (AMS's own local state-visibility tools above) can run as two separate stdio servers in the same MCP client session — useful for a dual-role operator running both ORB and AMS on the same box. Generate ORB's half with `loopover-mcp init-client --print claude` (see the [`@loopover/mcp` README](../loopover-mcp/README.md#client-config)); `loopover-miner-mcp` takes no flags, so its entry is just the bin name. Combined, a Claude Desktop / Claude Code style config looks like:
+
+
+`loopover-mcp` (ORB's hosted contributor-workflow tools) and `loopover-miner-mcp` (AMS's own local state-visibility tools above) run as two separate stdio servers in the same MCP client session — the dual-role case for an operator running both ORB and AMS on one box. Generate ORB's half with `loopover-mcp init-client --print claude` (see the [`@loopover/mcp` README](../loopover-mcp/README.md#client-config)); `loopover-miner-mcp` takes no flags, so its entry is just the bin name. Combined:
```json
{
@@ -304,7 +306,11 @@ It exposes these read-only tools, generated from the `@loopover/contract` regist
}
```
-`loopover` exposes ORB's hosted contributor-workflow tools (issue ranking, PR packet prep, decision packs). `loopover-miner` exposes AMS's own local state-visibility tools listed above (portfolio dashboard, claims, audit feed, run state, plans, calibration) — a fully separate, 100% local tool surface with no shared code or network calls between the two. Both follow the same `loopover_*` tool-naming convention (`loopover_...` vs. `loopover_miner_...`), but back onto different stores: ORB's tools read the hosted loopover backend, AMS's tools read this machine's own local SQLite files (see [Local storage](#local-storage)) — a handful of AMS tools even name the ORB tool they mirror (e.g. `loopover_miner_get_run_state` is the read-only analog of `loopover_get_automation_state`) so the relationship is explicit at the point of use, not just here.
+`loopover` exposes ORB's hosted contributor-workflow tools (issue ranking, PR packet prep, decision packs) and, once you have run `loopover-mcp login`, the remote tool set it mounts. `loopover-miner` exposes AMS's own local state-visibility tools listed above (portfolio dashboard, claims, audit feed, run state, plans, calibration) — a fully separate, 100% local tool surface with no shared code or network calls between the two.
+
+Both follow the same `loopover_*` naming convention (`loopover_...` vs. `loopover_miner_...`), but back onto different stores: ORB's tools read the hosted loopover backend, AMS's tools read this machine's own local SQLite files (see [Local storage](#local-storage)) — a handful of AMS tools even name the ORB tool they mirror (e.g. `loopover_miner_get_run_state` is the read-only analog of `loopover_get_automation_state`) so the relationship is explicit at the point of use, not just here.
+
+
## Version check
diff --git a/scripts/check-server-manifest.ts b/scripts/check-server-manifest.ts
new file mode 100644
index 0000000000..c08182ae5f
--- /dev/null
+++ b/scripts/check-server-manifest.ts
@@ -0,0 +1,125 @@
+// server.json validation + the ANTI-ROT guard (#9526).
+//
+// metagraphed's version-sync workflow rotted silently for months because it watched a file path that no
+// longer existed: the workflow kept passing while doing nothing. That is the failure this script exists to
+// make impossible here, so it does two jobs:
+//
+// 1. Field-by-field validation of server.json against the live sources — the version must equal
+// @loopover/mcp's package.json version, the npm identifier must be that package's real name, and the
+// remote must point at the deployment the server card advertises.
+// 2. WATCHED-PATH existence. Every path this check and the publish workflow depend on is listed here and
+// asserted to exist in the working tree. A rename that leaves the list behind fails loudly instead of
+// quietly watching nothing.
+//
+// Wired into test:ci, so a stale manifest cannot reach main and the publish workflow cannot be pointed at a
+// file that has moved.
+import { existsSync, readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+/**
+ * Every path the manifest check and the publish workflow read. Listed once, asserted to exist. Adding a
+ * watched path here without creating it is the same loud failure as deleting one.
+ */
+export const WATCHED_PATHS = [
+ "server.json",
+ "packages/loopover-mcp/package.json",
+ ".github/workflows/publish-mcp-registry.yml",
+] as const;
+
+export type ManifestProblem = { field: string; detail: string };
+
+type ServerManifest = {
+ name?: unknown;
+ version?: unknown;
+ description?: unknown;
+ repository?: { url?: unknown; source?: unknown };
+ remotes?: Array<{ type?: unknown; url?: unknown }>;
+ packages?: Array<{ registry_type?: unknown; identifier?: unknown; version?: unknown; transport?: { type?: unknown } }>;
+};
+
+export const EXPECTED_SERVER_NAME = "io.github.JSONbored/loopover";
+export const EXPECTED_REMOTE_URL = "https://api.loopover.ai/mcp";
+export const EXPECTED_PACKAGE_NAME = "@loopover/mcp";
+
+export function checkWatchedPaths(deps: { exists?: (path: string) => boolean } = {}): ManifestProblem[] {
+ const exists = deps.exists ?? existsSync;
+ return WATCHED_PATHS.filter((path) => !exists(path)).map((path) => ({
+ field: "watched-path",
+ detail: `${path} is watched by the manifest check or the publish workflow but does not exist — a rename left this list behind`,
+ }));
+}
+
+/**
+ * Validate the manifest against the package that actually ships.
+ *
+ * The version is the point: it is NOT hand-maintained here. It must equal `@loopover/mcp`'s version, which
+ * the existing release automation bumps, so publishing can never advertise a version nobody released.
+ */
+export function checkServerManifest(manifestJson: string, mcpPackageJson: string): ManifestProblem[] {
+ const problems: ManifestProblem[] = [];
+ let manifest: ServerManifest;
+ try {
+ manifest = JSON.parse(manifestJson) as ServerManifest;
+ } catch (error) {
+ return [{ field: "server.json", detail: `is not valid JSON: ${error instanceof Error ? error.message : String(error)}` }];
+ }
+ const mcpPackage = JSON.parse(mcpPackageJson) as { name?: string; version?: string };
+
+ if (manifest.name !== EXPECTED_SERVER_NAME) {
+ problems.push({ field: "name", detail: `expected ${EXPECTED_SERVER_NAME}, got ${String(manifest.name)}` });
+ }
+ if (typeof manifest.description !== "string" || manifest.description.trim() === "") {
+ problems.push({ field: "description", detail: "must be a non-empty string — registry listings show it verbatim" });
+ }
+ if (manifest.repository?.url !== "https://github.com/JSONbored/loopover" || manifest.repository?.source !== "github") {
+ problems.push({ field: "repository", detail: "must point at github.com/JSONbored/loopover with source: github" });
+ }
+ if (manifest.version !== mcpPackage.version) {
+ problems.push({
+ field: "version",
+ detail: `must equal @loopover/mcp's ${String(mcpPackage.version)} (release automation owns it), got ${String(manifest.version)}`,
+ });
+ }
+
+ const remote = manifest.remotes?.[0];
+ if (!remote || remote.type !== "streamable-http" || remote.url !== EXPECTED_REMOTE_URL) {
+ problems.push({ field: "remotes[0]", detail: `must be a streamable-http remote at ${EXPECTED_REMOTE_URL}` });
+ }
+ if ((manifest.remotes?.length ?? 0) !== 1) {
+ problems.push({ field: "remotes", detail: "must declare exactly one remote — a second entry is a second front door nobody tests" });
+ }
+
+ const npmPackage = manifest.packages?.[0];
+ if (!npmPackage || npmPackage.registry_type !== "npm" || npmPackage.identifier !== (mcpPackage.name ?? EXPECTED_PACKAGE_NAME)) {
+ problems.push({ field: "packages[0]", detail: `must be the npm package ${String(mcpPackage.name)}` });
+ }
+ if (npmPackage && npmPackage.version !== mcpPackage.version) {
+ problems.push({ field: "packages[0].version", detail: `must equal @loopover/mcp's ${String(mcpPackage.version)}, got ${String(npmPackage.version)}` });
+ }
+ if (npmPackage?.transport?.type !== "stdio") {
+ problems.push({ field: "packages[0].transport", detail: "the npm package is the stdio entry point" });
+ }
+
+ return problems;
+}
+
+export function collectProblems(deps: { readFile?: (path: string) => string; exists?: (path: string) => boolean } = {}): ManifestProblem[] {
+ const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf8"));
+ const pathProblems = checkWatchedPaths(deps);
+ // A missing file makes every field check meaningless, so report the rot and stop.
+ if (pathProblems.length > 0) return pathProblems;
+ return checkServerManifest(readFile("server.json"), readFile("packages/loopover-mcp/package.json"));
+}
+
+function main(): void {
+ const problems = collectProblems();
+ if (problems.length === 0) {
+ process.stdout.write(`server.json: OK (${WATCHED_PATHS.length} watched paths present)\n`);
+ return;
+ }
+ process.stderr.write(`server.json has ${problems.length} problem(s) (#9526):\n`);
+ for (const problem of problems) process.stderr.write(` ${problem.field}: ${problem.detail}\n`);
+ process.exit(1);
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) main();
diff --git a/scripts/gen-mcp-client-config.ts b/scripts/gen-mcp-client-config.ts
new file mode 100644
index 0000000000..202e93eab6
--- /dev/null
+++ b/scripts/gen-mcp-client-config.ts
@@ -0,0 +1,173 @@
+#!/usr/bin/env node
+// Generate every client-config surface from the @loopover/contract grid (#9526).
+//
+// Same one-source pattern as gen-mcp-tool-reference.ts, applied to the other half of the docs: how you
+// CONNECT, rather than what you get once connected. Four places used to spell out the same server-map
+// snippets by hand -- `clientSnippet()` in the stdio bin, apps/loopover-ui/content/docs/mcp-clients.mdx, and
+// both package READMEs -- and they had already drifted: the docs page documented no remote auth at all, and
+// nothing described the gateway's remote mount because the gateway did not exist when the prose was written.
+//
+// Surfaces:
+// 1. apps/loopover-ui/content/docs/mcp-clients.mdx -- a section per host, one code block per mode
+// 2. packages/loopover-mcp/README.md -- the stdio + remote config blocks
+// 3. packages/loopover-miner/README.md -- the dual-role (gateway + miner) combined block
+//
+// `--check` regenerates in memory and diffs, exiting 1 on drift; test:ci runs it.
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+import {
+ CLIENT_HOSTS,
+ CLIENT_HOST_SPEC,
+ CONNECTION_MODES,
+ CONNECTION_MODE_SPEC,
+ REMOTE_MCP_URL,
+ clientConfigFile,
+ clientConfigSnippet,
+ compactStringArrays,
+ supportsConnectionMode,
+ type ClientHost,
+ type ConnectionMode,
+} from "@loopover/contract/client-config";
+
+const CHECK = process.argv.includes("--check");
+const ROOT = process.cwd();
+
+// MDX is not HTML: an `` comment is parsed as JSX and blows up the page, so the docs markers use the
+// expression form. The READMEs are plain markdown and keep the HTML form the other generators use.
+const MDX_BEGIN = "{/* GENERATED:MCP-CLIENT-CONFIG:BEGIN — edit packages/loopover-contract/src/client-config.ts, then `npm run mcp:client-config` */}";
+const MDX_END = "{/* GENERATED:MCP-CLIENT-CONFIG:END */}";
+const MD_BEGIN = "";
+const MD_END = "";
+
+/** Backticks and `${` would both terminate or interpolate inside an MDX `code={\`...\`}` template literal. */
+function escapeForMdxTemplate(text: string): string {
+ return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
+}
+
+function mdxCodeBlock(filename: string, lang: string, code: string): string {
+ const attributes = [...(filename ? [` filename=${JSON.stringify(filename)}`] : []), ` lang=${JSON.stringify(lang)}`];
+ return ``;
+}
+
+/** The docs page body: choose a mode, then a host, with each host's caveats where the reader will hit them. */
+export function docsSection(): string {
+ const lines: string[] = [
+ "## Generate config",
+ "",
+ "Every block on this page is what `init-client` prints. It prints config only — it never edits your client files.",
+ "",
+ mdxCodeBlock(
+ "",
+ "bash",
+ CONNECTION_MODES.flatMap((mode) =>
+ CLIENT_HOSTS.filter((host) => supportsConnectionMode(host, mode)).map((host) => `loopover-mcp init-client --print ${host}${mode === "stdio" ? "" : ` --mode ${mode}`}`),
+ ).join("\n"),
+ ),
+ "",
+ ];
+ for (const mode of CONNECTION_MODES) {
+ const spec = CONNECTION_MODE_SPEC[mode];
+ lines.push(`## ${spec.title}`, "", spec.summary, "");
+ for (const note of spec.notes) lines.push(`- ${note}`);
+ lines.push("");
+ for (const host of CLIENT_HOSTS.filter((candidate) => supportsConnectionMode(candidate, mode))) {
+ const hostSpec = CLIENT_HOST_SPEC[host];
+ // The mode goes in the heading, not just the section, so the anchors stay unique across the page.
+ lines.push(`### ${hostSpec.title} (${mode})`, "");
+ lines.push(mdxCodeBlock(clientConfigFile(host, mode), hostSpec.lang, clientConfigSnippet(host, mode)), "");
+ const remoteNote = spec.transport === "http" ? hostSpec.remoteNote : undefined;
+ if (remoteNote) lines.push(remoteNote, "");
+ }
+ }
+ lines.push("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.");
+ return lines.join("\n");
+}
+
+function fence(lang: string, code: string): string {
+ return "```" + lang + "\n" + code + "\n```";
+}
+
+/** The stdio README: how to wire the gateway, and the remote endpoint for anyone who cannot run a process. */
+export function stdioReadmeSection(): string {
+ const lines: string[] = [];
+ lines.push(
+ `\`init-client --print [--mode ]\` prints the MCP config for a host: ${CLIENT_HOSTS.map((host) => `\`${host}\``).join(", ")}. Modes are ${CONNECTION_MODES.map((mode) => `\`${mode}\``).join(", ")}, defaulting to \`stdio\`. It prints config only; it never edits client files.`,
+ "",
+ );
+ for (const mode of ["stdio", "remote"] as const) {
+ const spec = CONNECTION_MODE_SPEC[mode];
+ lines.push(`#### ${spec.title}`, "", spec.summary, "");
+ lines.push(`${CLIENT_HOST_SPEC.claude.title}, in \`${clientConfigFile("claude", mode)}\`:`, "");
+ lines.push(fence("json", clientConfigSnippet("claude", mode)), "");
+ for (const note of spec.notes) lines.push(`- ${note}`);
+ lines.push("");
+ }
+ lines.push(`The remote endpoint is \`${REMOTE_MCP_URL}\` — the same URL \`/.well-known/mcp.json\` advertises.`);
+ return lines.join("\n");
+}
+
+/**
+ * The miner README's dual-role block: the gateway and the miner server in ONE client config.
+ *
+ * Built by merging the two modes' entries rather than by writing a third snippet, because "run both" is
+ * exactly the case a hand-written third copy gets wrong first.
+ */
+export function minerReadmeSection(): string {
+ const merged = { mcpServers: {} as Record };
+ for (const mode of ["stdio", "miner"] as const) {
+ Object.assign(merged.mcpServers, (JSON.parse(clientConfigSnippet("claude", mode)) as { mcpServers: Record }).mcpServers);
+ }
+ return [
+ `\`${CONNECTION_MODE_SPEC.stdio.command}\` (ORB's hosted contributor-workflow tools) and \`${CONNECTION_MODE_SPEC.miner.command}\` (AMS's own local state-visibility tools above) run as two separate stdio servers in the same MCP client session — the dual-role case for an operator running both ORB and AMS on one box. Generate ORB's half with \`loopover-mcp init-client --print claude\` (see the [\`@loopover/mcp\` README](../loopover-mcp/README.md#client-config)); \`${CONNECTION_MODE_SPEC.miner.command}\` takes no flags, so its entry is just the bin name. Combined:`,
+ "",
+ fence("json", compactStringArrays(JSON.stringify(merged, null, 2))),
+ "",
+ `\`${CONNECTION_MODE_SPEC.stdio.serverKey}\` exposes ORB's hosted contributor-workflow tools (issue ranking, PR packet prep, decision packs) and, once you have run \`loopover-mcp login\`, the remote tool set it mounts. \`${CONNECTION_MODE_SPEC.miner.serverKey}\` exposes AMS's own local state-visibility tools listed above (portfolio dashboard, claims, audit feed, run state, plans, calibration) — a fully separate, 100% local tool surface with no shared code or network calls between the two.`,
+ "",
+ `Both follow the same \`loopover_*\` naming convention (\`loopover_...\` vs. \`loopover_miner_...\`), but back onto different stores: ORB's tools read the hosted loopover backend, AMS's tools read this machine's own local SQLite files (see [Local storage](#local-storage)) — a handful of AMS tools even name the ORB tool they mirror (e.g. \`loopover_miner_get_run_state\` is the read-only analog of \`loopover_get_automation_state\`) so the relationship is explicit at the point of use, not just here.`,
+ ].join("\n");
+}
+
+export function replaceBetweenMarkers(source: string, replacement: string, begin: string, end: string, file: string): string {
+ const beginAt = source.indexOf(begin);
+ const endAt = source.indexOf(end);
+ if (beginAt === -1 || endAt === -1) throw new Error(`${file} is missing the GENERATED:MCP-CLIENT-CONFIG markers.`);
+ return source.slice(0, beginAt + begin.length) + "\n\n" + replacement + "\n\n" + source.slice(endAt);
+}
+
+const TARGETS: Array<{ file: string; next: (current: string) => string }> = [
+ {
+ file: "apps/loopover-ui/content/docs/mcp-clients.mdx",
+ next: (current) => replaceBetweenMarkers(current, docsSection(), MDX_BEGIN, MDX_END, "apps/loopover-ui/content/docs/mcp-clients.mdx"),
+ },
+ {
+ file: "packages/loopover-mcp/README.md",
+ next: (current) => replaceBetweenMarkers(current, stdioReadmeSection(), MD_BEGIN, MD_END, "packages/loopover-mcp/README.md"),
+ },
+ {
+ file: "packages/loopover-miner/README.md",
+ next: (current) => replaceBetweenMarkers(current, minerReadmeSection(), MD_BEGIN, MD_END, "packages/loopover-miner/README.md"),
+ },
+];
+
+function main(): void {
+ const drifted: string[] = [];
+ for (const target of TARGETS) {
+ const path = join(ROOT, target.file);
+ const current = readFileSync(path, "utf8");
+ const next = target.next(current);
+ if (next === current) continue;
+ if (CHECK) drifted.push(target.file);
+ else writeFileSync(path, next);
+ }
+ if (CHECK && drifted.length > 0) {
+ process.stderr.write(`gen-mcp-client-config: stale generated output in ${drifted.join(", ")} -- run \`npm run mcp:client-config\`.\n`);
+ process.exit(1);
+ }
+ const pairs = CLIENT_HOSTS.flatMap((host) => CONNECTION_MODES.filter((mode) => supportsConnectionMode(host as ClientHost, mode as ConnectionMode)));
+ process.stdout.write(`gen-mcp-client-config: ${CHECK ? "checked" : "wrote"} ${TARGETS.length} surface(s) from ${pairs.length} host/mode pairs.\n`);
+}
+
+// Importable for tests without running the file's I/O (the same guard gen-command-reference.ts uses).
+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
diff --git a/scripts/mcp-package-allowlist.ts b/scripts/mcp-package-allowlist.ts
index da2fbedd3f..5752ef87e2 100644
--- a/scripts/mcp-package-allowlist.ts
+++ b/scripts/mcp-package-allowlist.ts
@@ -1,14 +1,30 @@
// Canonical MCP published-tarball allowlist (#6291). Shared by check-mcp-package.ts and
// mcp-release-candidate-core.ts so the dry-run gate and the release-candidate tarball check
// cannot drift (the previous duplicated lists already missed shipped lib/*.js files).
+//
+// The dist/ half is DERIVED from the committed TypeScript sources rather than typed out (#9526). Every
+// version of this list has failed the same way -- by UNDER-listing, so a legitimately shipped lib/*.js
+// tripped the gate that exists to catch strays -- and a list you must remember to extend is a list that
+// eventually lags the thing it describes. Reading the source tree keeps the property that matters: the
+// tarball may contain the compiled form of committed source and nothing else, so a stray file, a copied
+// node_modules artifact, or a build that emits somewhere unexpected still fails loudly.
+import { readdirSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const PACKAGE_ROOT = join(fileURLToPath(new URL(".", import.meta.url).href), "..", "packages", "loopover-mcp");
+
+/** `dist//.js` for every committed `/.ts`, as anchored exact-match patterns. */
+function compiledOutputPatterns(dir: "bin" | "lib"): RegExp[] {
+ return readdirSync(join(PACKAGE_ROOT, dir))
+ .filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts"))
+ .sort()
+ .map((file) => new RegExp(`^dist/${dir}/${file.replace(/\.ts$/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.js$`));
+}
export const MCP_PACKAGE_ALLOWED_FILE_PATTERNS: RegExp[] = [
- /^dist\/bin\/loopover-mcp\.js$/,
- /^dist\/lib\/cli-error\.js$/,
- /^dist\/lib\/local-branch\.js$/,
- /^dist\/lib\/format-table\.js$/,
- /^dist\/lib\/redact-local-path\.js$/,
- /^dist\/lib\/telemetry\.js$/,
+ ...compiledOutputPatterns("bin"),
+ ...compiledOutputPatterns("lib"),
/^scripts\/gittensor-score-preview\.(mjs|py)$/,
/^package\.json$/,
/^README\.md$/,
diff --git a/server.json b/server.json
new file mode 100644
index 0000000000..77e933cf61
--- /dev/null
+++ b/server.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
+ "name": "io.github.JSONbored/loopover",
+ "description": "LoopOver's contribution-intelligence tools: issue ranking, branch and PR preflight, gate prediction, review explanation, and maintainer/operator management.",
+ "repository": {
+ "url": "https://github.com/JSONbored/loopover",
+ "source": "github"
+ },
+ "version": "3.15.2",
+ "remotes": [
+ {
+ "type": "streamable-http",
+ "url": "https://api.loopover.ai/mcp"
+ }
+ ],
+ "packages": [
+ {
+ "registry_type": "npm",
+ "identifier": "@loopover/mcp",
+ "version": "3.15.2",
+ "transport": {
+ "type": "stdio"
+ }
+ }
+ ]
+}
diff --git a/src/api/routes.ts b/src/api/routes.ts
index 57aff8ebe8..54a1be3db7 100644
--- a/src/api/routes.ts
+++ b/src/api/routes.ts
@@ -179,6 +179,8 @@ import { computeFleetAnalytics } from "../orb/analytics";
import { handleMcpRequest } from "../mcp/server";
import { simulateOpenPrPressureShape } from "../mcp/server";
import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios";
+import { DISCOVERY_PATHS, discoveryDocumentsFor, respondWithDocument, toolsForDeployment } from "../mcp/discovery-routes";
+import { isSelfHostedReviewRuntime } from "../selfhost/review-runtime";
import { buildOpenApiSpec } from "../openapi/spec";
import { COMMAND_RATE_LIMIT_EVENT_TYPE, generateSignalSnapshots } from "../queue/processors";
import { generateChatQaAnswer } from "../services/ai-chat-qa";
@@ -1251,6 +1253,27 @@ export function createApp() {
// enums/analyzer metadata (no DB/env/private data); excluded from requiresApiToken below.
app.get("/v1/mcp/finding-taxonomy", (c) => c.json(buildFindingTaxonomyDocument()));
app.get("/v1/mcp/enrichment-analyzers", (c) => c.json(buildEnrichmentAnalyzersTaxonomyDocument()));
+ // #9526: the MCP discovery surfaces, computed at request time from the contract registry (never a
+ // committed artifact -- a committed card is what made every concurrent tool PR conflict in metagraphed).
+ // Unauthenticated public metadata: tool names, descriptions, and schemas are already public, so these are
+ // excluded from requiresApiToken alongside the other unauthenticated document routes.
+ for (const path of DISCOVERY_PATHS) {
+ app.get(path, (c) => {
+ // The SAME routes on both deployments, scoped to what each actually serves. A self-host card that
+ // advertised the cloud's tool set would be a list of calls that 404 -- and this app IS the self-host
+ // app (src/server.ts serves this very Hono instance), so the deployment has to be read at request
+ // time rather than assumed.
+ const deployment = isSelfHostedReviewRuntime(c.env) ? "selfhost" : "cloud";
+ const documents = discoveryDocumentsFor({
+ version: LATEST_RECOMMENDED_MCP_VERSION,
+ deployment,
+ baseUrl: c.env.PUBLIC_API_ORIGIN ?? new URL(c.req.url).origin,
+ tools: toolsForDeployment(deployment),
+ });
+ return respondWithDocument(documents[path]!, c.req.header("if-none-match") ?? null);
+ });
+ }
+
app.get("/openapi.json", (c) => c.json(buildOpenApiSpec()));
app.all("/mcp", handleMcpRequest);
diff --git a/src/auth/route-auth.ts b/src/auth/route-auth.ts
index d19456c762..8df42732f9 100644
--- a/src/auth/route-auth.ts
+++ b/src/auth/route-auth.ts
@@ -68,6 +68,10 @@ export function requiresApiToken(path: string): boolean {
if (/^\/v1\/public\/decision-records\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return false;
if (path === "/openapi.json") return false;
if (path === "/mcp") return false;
+ // #9526: the MCP discovery surfaces. Every field they carry -- tool names, descriptions, and JSON
+ // schemas -- is already public via /mcp's own tools/list and the published OpenAPI document, so gating
+ // them would only stop a registry crawler from finding a server that advertises itself on purpose.
+ if (path === "/.well-known/mcp.json" || path.startsWith("/.well-known/agent-tools/")) return false;
// Public OAuth draft-submission flow (LOOPOVER_REVIEW_DRAFT): the submission entry points are unauthenticated
// by design. The handlers themselves 404 when the flag is off, so this exemption is inert flag-OFF.
if (path === "/v1/drafts" || path.startsWith("/v1/drafts/")) return false;
diff --git a/src/mcp/discovery-routes.ts b/src/mcp/discovery-routes.ts
new file mode 100644
index 0000000000..819c5de702
--- /dev/null
+++ b/src/mcp/discovery-routes.ts
@@ -0,0 +1,123 @@
+// The `.well-known` discovery surfaces (#9526).
+//
+// COMPUTED AT REQUEST TIME from the contract registry — never a committed artifact. metagraphed learned
+// that the hard way: a 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.
+//
+// One handler factory, two callers: the Worker mounts these on the cloud deployment, and the self-host app
+// mounts the SAME routes over its own availability-filtered tool list. That is what makes a self-hosted
+// card truthful rather than a copy of the cloud one — a `cloud`-only tool is absent from a self-host card
+// because it is absent from that deployment's list, not because a second implementation remembered to
+// exclude it.
+import {
+ buildAgentToolsIndex,
+ buildAnthropicTools,
+ buildOpenAiTools,
+ buildServerCard,
+ matchesETag,
+ weakETag,
+ type DiscoveryDeployment,
+} from "@loopover/contract/discovery";
+import { listToolDefinitions } from "@loopover/contract/tools";
+import type { McpToolDefinition } from "@loopover/contract";
+
+export type DiscoveryContext = {
+ version: string;
+ deployment: DiscoveryDeployment;
+ baseUrl: string;
+ tools: readonly McpToolDefinition[];
+};
+
+/**
+ * The tools a deployment truthfully serves: `both` plus its own kind.
+ *
+ * Locality is deliberately NOT filtered here. A `local-git` tool is still part of the catalog a client
+ * discovers — the remote simply expects the caller to supply the branch metadata rather than reading a
+ * checkout — so hiding it would under-describe the server.
+ */
+export function toolsForDeployment(deployment: DiscoveryDeployment): McpToolDefinition[] {
+ // `both` is not listed: the registry's filter treats it as the ABSENCE of a restriction, so it already
+ // satisfies either constraint. Naming it here would read as if it were a third deployment.
+ return listToolDefinitions({ availability: [deployment] });
+}
+
+/**
+ * `generated_at` is derived from the VERSION, not the clock.
+ *
+ * A wall-clock timestamp would change the body on every request, changing the ETag with it and making the
+ * 304 path dead code — the cache would never hit. Deriving it from the deploy means the document is stable
+ * for as long as the deployment is, which is exactly the cache lifetime a reader wants.
+ */
+export function deterministicGeneratedAt(version: string): string {
+ return `version:${version}`;
+}
+
+export type DiscoveryDocument = { body: string; etag: string };
+
+function serialize(payload: unknown): DiscoveryDocument {
+ const body = JSON.stringify(payload);
+ return { body, etag: weakETag(body) };
+}
+
+export function buildDiscoveryDocuments(context: DiscoveryContext): Record {
+ const generatedAt = deterministicGeneratedAt(context.version);
+ const agentToolsInput = { baseUrl: context.baseUrl, tools: context.tools, generatedAt };
+ return {
+ "/.well-known/mcp.json": serialize(buildServerCard({ ...context, generatedAt })),
+ "/.well-known/agent-tools/index.json": serialize(buildAgentToolsIndex(agentToolsInput)),
+ "/.well-known/agent-tools/openai.json": serialize(buildOpenAiTools(agentToolsInput)),
+ "/.well-known/agent-tools/anthropic.json": serialize(buildAnthropicTools(agentToolsInput)),
+ };
+}
+
+/**
+ * Answer one discovery route: 304 when the caller already has this entity, else the document.
+ *
+ * `public` caching with a short max-age plus the ETag: these change only on deploy, but a stale card is
+ * misleading rather than merely old, so the window stays small and revalidation is cheap.
+ */
+export function respondWithDocument(document: DiscoveryDocument, ifNoneMatch: string | null): Response {
+ const headers = {
+ "content-type": "application/json; charset=utf-8",
+ etag: document.etag,
+ "cache-control": "public, max-age=300, must-revalidate",
+ };
+ if (matchesETag(ifNoneMatch, document.etag)) {
+ // 304 carries no body by definition; the validators still travel so the cache can extend its entry.
+ return new Response(null, { status: 304, headers: { etag: document.etag, "cache-control": headers["cache-control"] } });
+ }
+ return new Response(document.body, { status: 200, headers });
+}
+
+/**
+ * Documents memoized per (deployment, baseUrl, version).
+ *
+ * The bodies depend only on the registry and those three facts, all fixed for the life of an isolate, so
+ * building them once per origin keeps the per-request cost at a Map lookup. Keyed by baseUrl because a
+ * deployment can legitimately answer on more than one origin (the configured public origin and whatever the
+ * request actually arrived on), and each must advertise its OWN `/mcp` rather than the other's.
+ */
+const DOCUMENT_CACHE = new Map>();
+
+export function discoveryDocumentsFor(context: DiscoveryContext): Record {
+ const key = `${context.deployment}|${context.version}|${context.baseUrl}`;
+ let documents = DOCUMENT_CACHE.get(key);
+ if (!documents) {
+ documents = buildDiscoveryDocuments(context);
+ DOCUMENT_CACHE.set(key, documents);
+ }
+ return documents;
+}
+
+/** Every path these routes answer, so a caller can register them without hardcoding the list. */
+export const DISCOVERY_PATHS = [
+ "/.well-known/mcp.json",
+ "/.well-known/agent-tools/index.json",
+ "/.well-known/agent-tools/openai.json",
+ "/.well-known/agent-tools/anthropic.json",
+] as const;
+
+/** Test-only: drop the memo so one test's origin cannot leak into the next. */
+export function resetDiscoveryCacheForTesting(): void {
+ DOCUMENT_CACHE.clear();
+}
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 736a5bdbc9..82ce2d30e8 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -311,6 +311,7 @@ import {
isMcpReadUnscoped,
type AuthIdentity,
} from "../auth/security";
+import { LATEST_RECOMMENDED_MCP_VERSION } from "../services/mcp-compatibility";
import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles";
import {
countOpenIssues,
@@ -1228,7 +1229,10 @@ export class LoopoverMcp {
createServer(): McpServer {
const server = new McpServer({
name: "loopover",
- version: "0.1.0",
+ // #9526: derived, not a hand-bumped constant. LATEST_RECOMMENDED_MCP_VERSION already reads
+ // @loopover/mcp's package.json, which the release automation owns -- so serverInfo, the compatibility
+ // metadata, the server card, and server.json all report the one version that actually shipped.
+ version: LATEST_RECOMMENDED_MCP_VERSION,
});
// #6301 — register every tool through this thin wrapper so its category rides along as MCP
diff --git a/src/openapi/discovery-route-specs.ts b/src/openapi/discovery-route-specs.ts
new file mode 100644
index 0000000000..934c43553e
--- /dev/null
+++ b/src/openapi/discovery-route-specs.ts
@@ -0,0 +1,68 @@
+// Spec entries for the `.well-known` discovery surfaces (#9526).
+//
+// SPEC-ONLY, the same split `orb-and-control-route-specs.ts` records: `registerRouteSpec` contributes the
+// operation while the handler stays where it is registered, because the app and the OpenAPI registry are
+// built by two different functions (`createApp` and `buildOpenApiSpec`) and `defineRoute` needs both at
+// once. Nothing is lost here -- these routes take no request body and no query, so the validating wrapper
+// would have nothing to validate.
+//
+// The response schemas come from @loopover/contract, which is also what the handlers build their bodies
+// with, so the published document describes exactly the object served rather than a second description of
+// it that can drift.
+import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
+import { AgentToolsIndexSchema, AnthropicToolsSchema, OpenAiToolsSchema, ServerCardSchema } from "@loopover/contract/discovery";
+import { DISCOVERY_PATHS } from "../mcp/discovery-routes";
+import { registerRouteSpec } from "./define-route";
+import type { z } from "zod";
+
+/**
+ * One entry per discovery path, keyed BY the path constant.
+ *
+ * Keyed rather than listed so the type system enforces exhaustiveness: adding a document to
+ * DISCOVERY_PATHS without describing it here is a compile error, not a ratchet failure discovered in CI.
+ */
+const DISCOVERY_SPECS: Record<(typeof DISCOVERY_PATHS)[number], { operationId: string; summary: string; schema: z.ZodTypeAny }> = {
+ "/.well-known/mcp.json": {
+ operationId: "getMcpServerCard",
+ summary: "MCP server card for this deployment",
+ schema: ServerCardSchema,
+ },
+ "/.well-known/agent-tools/index.json": {
+ operationId: "getAgentToolsIndex",
+ summary: "Neutral agent-tools catalog with both schemas per tool",
+ schema: AgentToolsIndexSchema,
+ },
+ "/.well-known/agent-tools/openai.json": {
+ operationId: "getAgentToolsOpenAi",
+ summary: "Agent-tools catalog in OpenAI's function-tool shape",
+ schema: OpenAiToolsSchema,
+ },
+ "/.well-known/agent-tools/anthropic.json": {
+ operationId: "getAgentToolsAnthropic",
+ summary: "Agent-tools catalog in Anthropic's tool shape",
+ schema: AnthropicToolsSchema,
+ },
+};
+
+export function registerDiscoveryRouteSpecs(registry: OpenAPIRegistry): void {
+ for (const path of DISCOVERY_PATHS) {
+ const spec = DISCOVERY_SPECS[path];
+ registerRouteSpec(registry, {
+ method: "get",
+ path,
+ operationId: spec.operationId,
+ tags: ["Discovery"],
+ summary: spec.summary,
+ // Unauthenticated on purpose: a registry crawler has no token, and everything here -- tool names,
+ // descriptions, schemas -- is already public through /mcp's own tools/list.
+ auth: "public",
+ description:
+ "Computed at request time from the tool contract registry, filtered to what this deployment serves. " +
+ "Carries a weak ETag; send `if-none-match` to get a 304 instead of the body.",
+ responses: {
+ 200: { description: spec.summary, schema: spec.schema },
+ 304: { description: "The caller's `if-none-match` already names this document" },
+ },
+ });
+ }
+}
diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts
index 698a8b6624..2641c0d111 100644
--- a/src/openapi/spec.ts
+++ b/src/openapi/spec.ts
@@ -1,5 +1,6 @@
import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
import { requiresApiToken } from "../auth/route-auth";
+import { registerDiscoveryRouteSpecs } from "./discovery-route-specs";
import { registerOrbAndControlRouteSpecs } from "./orb-and-control-route-specs";
import { registerInternalAndPublicRouteSpecs } from "./internal-and-public-route-specs";
import { z } from "zod";
@@ -174,7 +175,12 @@ function loopOperationMeta(method: string, path: string, tag: string): { operati
* that had to restate this list would go quiet the moment a new registrar was added, which is the rot the
* anti-rot guards in this repo keep finding.
*/
-export const SPEC_REGISTRARS: ReadonlyArray<(registry: OpenAPIRegistry) => void> = [registerOrbAndControlRouteSpecs, registerInternalAndPublicRouteSpecs];
+export const SPEC_REGISTRARS: ReadonlyArray<(registry: OpenAPIRegistry) => void> = [
+ registerOrbAndControlRouteSpecs,
+ // #9526: served by this app and computed at request time from the contract registry.
+ registerDiscoveryRouteSpecs,
+ registerInternalAndPublicRouteSpecs,
+];
export function buildOpenApiSpec() {
const registry = new OpenAPIRegistry();
diff --git a/test/contract/validate-mcp.test.ts b/test/contract/validate-mcp.test.ts
index 9b29ddb670..a23f8d7aa3 100644
--- a/test/contract/validate-mcp.test.ts
+++ b/test/contract/validate-mcp.test.ts
@@ -240,6 +240,24 @@ describe("MCP contract validator (#9520)", () => {
}
}, 180_000);
+ it("REGRESSION: one tool name has ONE locality, which is what makes gateway collisions impossible", () => {
+ // #9526's gateway mounts every `remote` tool onto the stdio server, which serves the `local-git` ones.
+ // That is only safe because a NAME belongs to exactly one entry in the one registry — the same name
+ // registered at both localities would mean the gateway tries to register a tool the stdio server
+ // already has, and the SDK throws on a duplicate. The registry structurally prevents it (one entry per
+ // name); this asserts nothing has introduced a second.
+ const byName = new Map();
+ for (const tool of listToolDefinitions()) {
+ byName.set(tool.name, [...(byName.get(tool.name) ?? []), tool.locality]);
+ }
+ const ambiguous = [...byName.entries()].filter(([, localities]) => new Set(localities).size > 1);
+ expect(ambiguous.map(([name]) => name)).toEqual([]);
+
+ // And no name is declared twice at all, whatever its locality.
+ const duplicated = [...byName.entries()].filter(([, localities]) => localities.length > 1);
+ expect(duplicated.map(([name]) => name)).toEqual([]);
+ });
+
it("locks the published MCP version across the three places it appears", () => {
const packageVersion = (JSON.parse(readFileSync(join(process.cwd(), "packages/loopover-mcp/package.json"), "utf8")) as { version: string }).version;
expect(
diff --git a/test/integration/mcp-discovery-routes.test.ts b/test/integration/mcp-discovery-routes.test.ts
new file mode 100644
index 0000000000..4f98cfc224
--- /dev/null
+++ b/test/integration/mcp-discovery-routes.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { createApp } from "../../src/api/routes";
+import { DISCOVERY_PATHS, resetDiscoveryCacheForTesting } from "../../src/mcp/discovery-routes";
+import { SERVER_CARD_NAME } from "@loopover/contract/discovery";
+import { createTestEnv } from "../helpers/d1";
+
+// #9526: the discovery routes served by the real app — unauthenticated, cacheable, and describing the
+// deployment that answered. The projections themselves are unit-tested; this covers the wiring: routing,
+// the auth exemption, and conditional-request handling through Hono.
+
+const app = createApp();
+
+function request(path: string, headers: Record = {}) {
+ return app.fetch(new Request(`https://api.loopover.ai${path}`, { headers }), createTestEnv());
+}
+
+beforeEach(() => {
+ resetDiscoveryCacheForTesting();
+});
+
+describe("discovery routes (#9526)", () => {
+ it.each(DISCOVERY_PATHS)("%s is reachable WITHOUT a credential", async (path) => {
+ // A registry crawler has no token; gating these would only hide a server that advertises itself.
+ const response = await request(path);
+ expect(response.status, `${path} must not require auth`).toBe(200);
+ expect(response.headers.get("content-type")).toContain("application/json");
+ });
+
+ it("the server card names the registry identity and this deployment's own /mcp", async () => {
+ const card = (await (await request("/.well-known/mcp.json")).json()) as {
+ name: string;
+ deployment: string;
+ remotes: Array<{ url: string }>;
+ tools: unknown[];
+ };
+ expect(card.name).toBe(SERVER_CARD_NAME);
+ expect(card.remotes[0]!.url).toBe("https://api.loopover.ai/mcp");
+ expect(card.tools.length).toBeGreaterThan(100);
+ });
+
+ it("names the deployment that ANSWERED, and scopes the catalog to what it serves", async () => {
+ // src/server.ts serves this very Hono app, so the same route answers on both deployments. A self-host
+ // card advertising the cloud's tool set would be a list of calls that 404 there.
+ const selfhostEnv = createTestEnv();
+ expect(selfhostEnv.SELFHOST_TRANSIENT_CACHE, "the test env is the self-host runtime").toBeTruthy();
+ const selfhost = (await (await app.fetch(new Request("https://api.loopover.ai/.well-known/mcp.json"), selfhostEnv)).json()) as {
+ deployment: string;
+ tools: Array<{ name: string }>;
+ };
+ expect(selfhost.deployment).toBe("selfhost");
+
+ resetDiscoveryCacheForTesting();
+ const cloud = (await (
+ await app.fetch(new Request("https://api.loopover.ai/.well-known/mcp.json"), { ...selfhostEnv, SELFHOST_TRANSIENT_CACHE: undefined } as unknown as Env)
+ ).json()) as { deployment: string; tools: Array<{ name: string }> };
+ expect(cloud.deployment).toBe("cloud");
+
+ // Not merely a different label: the registry's cloud-only entries are absent from the self-host card.
+ const cloudOnly = cloud.tools.filter((tool) => !selfhost.tools.some((entry) => entry.name === tool.name));
+ expect(cloudOnly.length, "a cloud-only tool must not appear on a self-host card").toBeGreaterThan(0);
+ });
+
+ it("does not hardcode a version — the card reports what the shipped package says", async () => {
+ const card = (await (await request("/.well-known/mcp.json")).json()) as { version: string };
+ // The old serverInfo said "0.1.0" forever; this must track the real release instead.
+ expect(card.version).not.toBe("0.1.0");
+ expect(card.version).toMatch(/^\d+\.\d+\.\d+/);
+ });
+
+ it.each(DISCOVERY_PATHS)("%s answers 304 for a matching if-none-match", async (path) => {
+ const first = await request(path);
+ const etag = first.headers.get("etag")!;
+ expect(etag).toMatch(/^W\//);
+
+ const second = await request(path, { "if-none-match": etag });
+ expect(second.status).toBe(304);
+ expect(await second.text()).toBe("");
+ });
+
+ it("answers 200 again when the caller's tag is stale", async () => {
+ const response = await request("/.well-known/mcp.json", { "if-none-match": 'W/"stale"' });
+ expect(response.status).toBe(200);
+ });
+
+ it("the agent-tools trio all describe the same catalog the card does", async () => {
+ const card = (await (await request("/.well-known/mcp.json")).json()) as { tools: Array<{ name: string }> };
+ const index = (await (await request("/.well-known/agent-tools/index.json")).json()) as { tools: Array<{ name: string }> };
+ const openai = (await (await request("/.well-known/agent-tools/openai.json")).json()) as { tools: Array<{ function: { name: string } }> };
+ const anthropic = (await (await request("/.well-known/agent-tools/anthropic.json")).json()) as { tools: Array<{ name: string }> };
+
+ const expected = card.tools.map((tool) => tool.name);
+ expect(index.tools.map((tool) => tool.name)).toEqual(expected);
+ expect(openai.tools.map((tool) => tool.function.name)).toEqual(expected);
+ expect(anthropic.tools.map((tool) => tool.name)).toEqual(expected);
+ });
+
+ it("every agent-tools document points a caller at /mcp tools/call", async () => {
+ for (const path of DISCOVERY_PATHS.filter((candidate) => candidate.includes("agent-tools"))) {
+ const document = (await (await request(path)).json()) as { executor: { url: string; method: string } };
+ expect(document.executor.url).toBe("https://api.loopover.ai/mcp");
+ expect(document.executor.method).toBe("tools/call");
+ }
+ });
+
+ it("advertises the origin the request ARRIVED on when no public origin is configured", async () => {
+ // A self-host deployment answers on its own hostname; the card must not advertise the cloud's. The test
+ // env presets PUBLIC_API_ORIGIN, so this clears it to reach the request-origin fallback.
+ const response = await app.fetch(new Request("https://selfhost.example/.well-known/mcp.json"), {
+ ...createTestEnv(),
+ PUBLIC_API_ORIGIN: undefined,
+ } as unknown as Env);
+ const card = (await response.json()) as { remotes: Array<{ url: string }> };
+ expect(card.remotes[0]!.url).toBe("https://selfhost.example/mcp");
+ });
+
+ it("prefers the configured public origin over the request's", async () => {
+ const response = await app.fetch(
+ new Request("https://internal.example/.well-known/mcp.json"),
+ createTestEnv({ PUBLIC_API_ORIGIN: "https://api.loopover.ai" }),
+ );
+ const card = (await response.json()) as { remotes: Array<{ url: string }> };
+ expect(card.remotes[0]!.url).toBe("https://api.loopover.ai/mcp");
+ });
+});
diff --git a/test/unit/check-server-manifest-script.test.ts b/test/unit/check-server-manifest-script.test.ts
new file mode 100644
index 0000000000..b67e03e591
--- /dev/null
+++ b/test/unit/check-server-manifest-script.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import { WATCHED_PATHS, checkServerManifest, checkWatchedPaths, collectProblems } from "../../scripts/check-server-manifest";
+
+// #9526: the manifest check, and specifically its ANTI-ROT half.
+//
+// metagraphed's version-sync workflow watched a path that had been renamed away and kept passing for months
+// while doing nothing. A workflow that can quietly watch nothing is worse than no workflow, so the guard
+// itself needs a test that proves it fails when a watched path disappears.
+
+const MCP_PACKAGE = JSON.stringify({ name: "@loopover/mcp", version: "3.15.2" });
+
+function manifest(overrides: Record = {}): string {
+ return JSON.stringify({
+ name: "io.github.JSONbored/loopover",
+ description: "LoopOver's contribution-intelligence tools.",
+ repository: { url: "https://github.com/JSONbored/loopover", source: "github" },
+ version: "3.15.2",
+ remotes: [{ type: "streamable-http", url: "https://api.loopover.ai/mcp" }],
+ packages: [{ registry_type: "npm", identifier: "@loopover/mcp", version: "3.15.2", transport: { type: "stdio" } }],
+ ...overrides,
+ });
+}
+
+describe("the anti-rot path guard (#9526)", () => {
+ it("passes when every watched path exists", () => {
+ expect(checkWatchedPaths({ exists: () => true })).toEqual([]);
+ });
+
+ it.each(WATCHED_PATHS)("FAILS LOUDLY when %s has been renamed away", (missing) => {
+ const problems = checkWatchedPaths({ exists: (path) => path !== missing });
+ expect(problems).toHaveLength(1);
+ expect(problems[0]!.detail).toContain(missing);
+ expect(problems[0]!.detail, "the message must say WHY this matters").toContain("rename");
+ });
+
+ it("reports the rot and STOPS, rather than validating fields against a file that is not there", () => {
+ const problems = collectProblems({
+ exists: () => false,
+ readFile: () => {
+ throw new Error("must not read a file the guard already reported missing");
+ },
+ });
+ expect(problems.length).toBe(WATCHED_PATHS.length);
+ expect(problems.every((problem) => problem.field === "watched-path")).toBe(true);
+ });
+
+ it("the real repository satisfies both halves", () => {
+ expect(collectProblems()).toEqual([]);
+ });
+});
+
+describe("manifest field validation (#9526)", () => {
+ it("accepts a manifest that agrees with the shipped package", () => {
+ expect(checkServerManifest(manifest(), MCP_PACKAGE)).toEqual([]);
+ });
+
+ it("REJECTS a version that disagrees with @loopover/mcp — release automation owns it", () => {
+ // The whole point: publishing must never advertise a version nobody released.
+ const problems = checkServerManifest(manifest({ version: "9.9.9" }), MCP_PACKAGE);
+ expect(problems.map((problem) => problem.field)).toContain("version");
+ expect(problems.find((problem) => problem.field === "version")!.detail).toContain("3.15.2");
+ });
+
+ it("REJECTS an npm package version that drifts from the top-level one", () => {
+ const drifted = manifest({ packages: [{ registry_type: "npm", identifier: "@loopover/mcp", version: "3.0.0", transport: { type: "stdio" } }] });
+ expect(checkServerManifest(drifted, MCP_PACKAGE).map((problem) => problem.field)).toContain("packages[0].version");
+ });
+
+ it.each([
+ ["a wrong server name", { name: "io.github.someone/else" }, "name"],
+ ["an empty description", { description: " " }, "description"],
+ ["a wrong repository", { repository: { url: "https://example.com", source: "gitlab" } }, "repository"],
+ ["a non-streamable remote", { remotes: [{ type: "sse", url: "https://api.loopover.ai/mcp" }] }, "remotes[0]"],
+ ["a remote pointing elsewhere", { remotes: [{ type: "streamable-http", url: "https://evil.example/mcp" }] }, "remotes[0]"],
+ ["a non-npm package entry", { packages: [{ registry_type: "pypi", identifier: "@loopover/mcp", version: "3.15.2", transport: { type: "stdio" } }] }, "packages[0]"],
+ ["a non-stdio transport", { packages: [{ registry_type: "npm", identifier: "@loopover/mcp", version: "3.15.2", transport: { type: "http" } }] }, "packages[0].transport"],
+ ])("rejects %s", (_label, overrides, field) => {
+ expect(checkServerManifest(manifest(overrides), MCP_PACKAGE).map((problem) => problem.field)).toContain(field);
+ });
+
+ it("rejects a SECOND remote — a second front door is one nobody tests", () => {
+ const twoRemotes = manifest({
+ remotes: [
+ { type: "streamable-http", url: "https://api.loopover.ai/mcp" },
+ { type: "streamable-http", url: "https://staging.loopover.ai/mcp" },
+ ],
+ });
+ expect(checkServerManifest(twoRemotes, MCP_PACKAGE).map((problem) => problem.field)).toContain("remotes");
+ });
+
+ it("reports malformed JSON as one clear problem rather than throwing", () => {
+ const problems = checkServerManifest("{not json", MCP_PACKAGE);
+ expect(problems).toHaveLength(1);
+ expect(problems[0]!.field).toBe("server.json");
+ });
+
+ it("reports a manifest missing its sections entirely", () => {
+ const problems = checkServerManifest("{}", MCP_PACKAGE);
+ expect(problems.map((problem) => problem.field).sort()).toEqual(
+ ["description", "name", "packages[0]", "packages[0].transport", "remotes", "remotes[0]", "repository", "version"].sort(),
+ );
+ });
+});
diff --git a/test/unit/contract-client-config.test.ts b/test/unit/contract-client-config.test.ts
new file mode 100644
index 0000000000..37bdce77a9
--- /dev/null
+++ b/test/unit/contract-client-config.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from "vitest";
+import {
+ CLIENT_HOSTS,
+ CLIENT_HOST_SPEC,
+ CONNECTION_MODES,
+ CONNECTION_MODE_SPEC,
+ REMOTE_MCP_URL,
+ REMOTE_TOKEN_ENV_VAR,
+ clientConfigFile,
+ clientConfigMatrix,
+ clientConfigSnippet,
+ compactStringArrays,
+ supportsConnectionMode,
+} from "@loopover/contract/client-config";
+
+// #9526: the client-config grid. Its whole job is that a snippet WORKS when pasted, so these assertions are
+// about the shapes real clients read -- not about the strings matching some other copy of themselves.
+
+describe("the grid covers three connection modes across every host (#9526)", () => {
+ it("names exactly the three modes the issue records, stdio first", () => {
+ expect(CONNECTION_MODES).toEqual(["stdio", "remote", "miner"]);
+ });
+
+ it("every (host, mode) pair either has a snippet or is refused — never a plausible-looking wrong one", () => {
+ for (const host of CLIENT_HOSTS) {
+ for (const mode of CONNECTION_MODES) {
+ if (supportsConnectionMode(host, mode)) expect(clientConfigSnippet(host, mode)).toBeTruthy();
+ else expect(() => clientConfigSnippet(host, mode)).toThrow(/cannot connect/i);
+ }
+ }
+ });
+
+ it("the matrix is every supported pair, and only those", () => {
+ const matrix = clientConfigMatrix();
+ expect(matrix.every((pair) => supportsConnectionMode(pair.host, pair.mode))).toBe(true);
+ // Every stdio pair, plus remote for the hosts whose remote dialect this repo can actually vouch for.
+ const remoteHosts = CLIENT_HOSTS.filter((host) => CLIENT_HOST_SPEC[host].supportsRemote);
+ expect(matrix).toHaveLength(CLIENT_HOSTS.length * 2 + remoteHosts.length);
+ expect(remoteHosts.length).toBeLessThan(CLIENT_HOSTS.length);
+ });
+
+ it("REFUSES a pair it cannot vouch for rather than printing a snippet that fails on paste", () => {
+ // The generic `mcpServers` bucket is an unnamed host; its remote dialect is a guess, and the stdio
+ // gateway already serves it the remote tools.
+ expect(supportsConnectionMode("mcp", "remote")).toBe(false);
+ expect(() => clientConfigSnippet("mcp", "remote")).toThrow(/cannot connect over/);
+ });
+});
+
+describe("each host gets the shape it actually reads (#9526)", () => {
+ it("Codex gets a TOML table, not JSON", () => {
+ const snippet = clientConfigSnippet("codex", "stdio");
+ expect(snippet).toContain("[mcp_servers.loopover]");
+ expect(snippet).toContain('args = ["--stdio"]');
+ expect(snippet).not.toContain("{");
+ });
+
+ it.each(["claude", "cursor", "mcp"] as const)("%s gets the mcpServers shape with no transport type", (host) => {
+ const config = JSON.parse(clientConfigSnippet(host, "stdio")) as { mcpServers: Record };
+ expect(config.mcpServers.loopover).toEqual({ command: "loopover-mcp", args: ["--stdio"] });
+ // `mcpServers` hosts infer stdio from `command`; spelling it out would be noise in a file people read.
+ expect(config.mcpServers.loopover!.type).toBeUndefined();
+ });
+
+ it("VS Code gets a `servers` map WITH the explicit transport type it requires", () => {
+ const config = JSON.parse(clientConfigSnippet("vscode", "stdio")) as { servers: Record; mcpServers?: unknown };
+ expect(config.servers.loopover!.type).toBe("stdio");
+ expect(config.mcpServers).toBeUndefined();
+ });
+
+ it("an overridden command lands in the snippet, for a client that does not inherit PATH", () => {
+ expect(clientConfigSnippet("claude", "stdio", { command: "/opt/bin/loopover-mcp" })).toContain("/opt/bin/loopover-mcp");
+ });
+});
+
+describe("the remote mode never puts a secret in a config file (#9526)", () => {
+ it.each(CLIENT_HOSTS.filter((host) => CLIENT_HOST_SPEC[host].supportsRemote))("%s references the env var rather than a token value", (host) => {
+ const snippet = clientConfigSnippet(host, "remote");
+ expect(snippet).toContain(REMOTE_TOKEN_ENV_VAR);
+ expect(snippet).toContain(REMOTE_MCP_URL);
+ // A literal token would be in shell history, backups, and eventually a screenshot.
+ expect(snippet).not.toMatch(/github_pat_|gh[pousr]_|Bearer [A-Za-z0-9]{8}/);
+ });
+
+ it("Codex names the variable in its own key; the JSON hosts spell out the header", () => {
+ expect(clientConfigSnippet("codex", "remote")).toContain(`bearer_token_env_var = "${REMOTE_TOKEN_ENV_VAR}"`);
+ const config = JSON.parse(clientConfigSnippet("vscode", "remote")) as { servers: Record }> };
+ expect(config.servers.loopover).toEqual({ type: "http", url: REMOTE_MCP_URL, headers: { Authorization: `Bearer \${${REMOTE_TOKEN_ENV_VAR}}` } });
+ });
+
+ it("points a remote Claude entry at .mcp.json — Claude Desktop has no config file for this", () => {
+ expect(clientConfigFile("claude", "remote")).toBe(".mcp.json");
+ expect(clientConfigFile("claude", "stdio")).toBe("claude_desktop_config.json");
+ });
+
+ it("falls back to the host's one file when it has no separate remote location", () => {
+ expect(clientConfigFile("cursor", "remote")).toBe(CLIENT_HOST_SPEC.cursor.file);
+ });
+});
+
+describe("the miner mode stays its own server (#9526)", () => {
+ it("registers under a different key, with no flags and no login", () => {
+ const config = JSON.parse(clientConfigSnippet("claude", "miner")) as { mcpServers: Record };
+ expect(config.mcpServers["loopover-miner"]).toEqual({ command: "loopover-miner-mcp", args: [] });
+ expect(config.mcpServers.loopover).toBeUndefined();
+ });
+
+ it("its keys never collide with the gateway's, so both fit in one client config", () => {
+ expect(CONNECTION_MODE_SPEC.miner.serverKey).not.toBe(CONNECTION_MODE_SPEC.stdio.serverKey);
+ });
+});
+
+describe("every mode explains itself (#9526)", () => {
+ it.each(CONNECTION_MODES)("%s carries a summary and at least one actionable note", (mode) => {
+ const spec = CONNECTION_MODE_SPEC[mode];
+ expect(spec.summary.length).toBeGreaterThan(40);
+ expect(spec.notes.length).toBeGreaterThan(0);
+ });
+
+ it("the stdio mode tells a reader how to log in AND how to opt out of the remote mount", () => {
+ const notes = CONNECTION_MODE_SPEC.stdio.notes.join("\n");
+ expect(notes).toContain("loopover-mcp login");
+ expect(notes).toContain("--no-remote");
+ });
+});
+
+describe("snippet formatting (#9526)", () => {
+ it("keeps a short string array on one line so the block reads as config, not as a list", () => {
+ expect(compactStringArrays('{\n "args": [\n "--stdio"\n ]\n}')).toBe('{\n "args": ["--stdio"]\n}');
+ });
+
+ it("leaves a long array expanded rather than producing an unreadable line", () => {
+ const long = JSON.stringify({ args: Array.from({ length: 12 }, (_, index) => `--flag-number-${index}`) }, null, 2);
+ expect(compactStringArrays(long)).toBe(long);
+ });
+
+ it("leaves a non-string array alone", () => {
+ const numeric = JSON.stringify({ ports: [1, 2, 3] }, null, 2);
+ expect(compactStringArrays(numeric)).toBe(numeric);
+ });
+});
diff --git a/test/unit/gen-mcp-client-config-script.test.ts b/test/unit/gen-mcp-client-config-script.test.ts
new file mode 100644
index 0000000000..9f235e699e
--- /dev/null
+++ b/test/unit/gen-mcp-client-config-script.test.ts
@@ -0,0 +1,98 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { CLIENT_HOSTS, CONNECTION_MODES, CONNECTION_MODE_SPEC, clientConfigSnippet, supportsConnectionMode } from "@loopover/contract/client-config";
+import { docsSection, minerReadmeSection, replaceBetweenMarkers, stdioReadmeSection } from "../../scripts/gen-mcp-client-config";
+
+// #9526: the client-config generator. The point of generating these blocks is that the docs and the CLI can
+// no longer disagree, so the assertions here are about that property -- every snippet the docs show is
+// byte-identical to one the grid produces -- rather than about the prose around them.
+
+function read(file: string): string {
+ return readFileSync(join(process.cwd(), file), "utf8");
+}
+
+describe("the generated docs page (#9526)", () => {
+ const section = docsSection();
+
+ it.each(CONNECTION_MODES)("gives %s its own section with its summary and notes", (mode) => {
+ const spec = CONNECTION_MODE_SPEC[mode];
+ expect(section).toContain(`## ${spec.title}`);
+ expect(section).toContain(spec.summary);
+ for (const note of spec.notes) expect(section).toContain(note);
+ });
+
+ it("shows every supported host/mode pair's snippet VERBATIM", () => {
+ for (const mode of CONNECTION_MODES) {
+ for (const host of CLIENT_HOSTS.filter((candidate) => supportsConnectionMode(candidate, mode))) {
+ // Escaped exactly as the MDX template literal needs; anything else renders as broken JSX.
+ const escaped = clientConfigSnippet(host, mode).replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
+ expect(section, `${host}/${mode} must appear as the grid produces it`).toContain(escaped);
+ }
+ }
+ });
+
+ it("escapes the `${VAR}` in a remote snippet so MDX does not interpolate it away", () => {
+ // Unescaped, MDX evaluates ${LOOPOVER_API_TOKEN} as JS and the published page shows a broken header.
+ expect(section).toContain("Bearer \\${LOOPOVER_API_TOKEN}");
+ expect(section).not.toContain('"Bearer ${LOOPOVER_API_TOKEN}"');
+ });
+
+ it("prints one init-client command per pair, so the page and the CLI enumerate the same surface", () => {
+ for (const mode of CONNECTION_MODES) {
+ for (const host of CLIENT_HOSTS.filter((candidate) => supportsConnectionMode(candidate, mode))) {
+ expect(section).toContain(`loopover-mcp init-client --print ${host}${mode === "stdio" ? "" : ` --mode ${mode}`}`);
+ }
+ }
+ });
+});
+
+describe("the generated README sections (#9526)", () => {
+ it("the stdio README documents both the gateway and the remote endpoint", () => {
+ const section = stdioReadmeSection();
+ expect(section).toContain(CONNECTION_MODE_SPEC.stdio.title);
+ expect(section).toContain(CONNECTION_MODE_SPEC.remote.title);
+ expect(section).toContain("https://api.loopover.ai/mcp");
+ });
+
+ it("the miner README's combined block registers BOTH servers and stays valid JSON", () => {
+ const block = minerReadmeSection().match(/```json\r?\n([\s\S]*?)\r?\n```/)![1]!;
+ const config = JSON.parse(block) as { mcpServers: Record };
+ expect(config.mcpServers).toEqual({
+ loopover: { command: "loopover-mcp", args: ["--stdio"] },
+ "loopover-miner": { command: "loopover-miner-mcp", args: [] },
+ });
+ });
+});
+
+describe("the marker contract (#9526)", () => {
+ it("replaces only what is between the markers, keeping both in place", () => {
+ const replaced = replaceBetweenMarkers("head\nBEGIN\nold\nEND\ntail", "new", "BEGIN", "END", "probe");
+ expect(replaced).toBe("head\nBEGIN\n\nnew\n\nEND\ntail");
+ });
+
+ it.each([
+ ["a missing BEGIN", "END only"],
+ ["a missing END", "BEGIN only"],
+ ])("FAILS on %s rather than silently generating nothing", (_label, source) => {
+ // A generator that quietly writes nowhere is the exact rot this whole family of checks exists to catch.
+ expect(() => replaceBetweenMarkers(source, "new", "BEGIN", "END", "probe")).toThrow(/missing the GENERATED:MCP-CLIENT-CONFIG markers/);
+ });
+});
+
+describe("the committed surfaces are up to date (#9526)", () => {
+ it.each([
+ ["apps/loopover-ui/content/docs/mcp-clients.mdx", docsSection],
+ ["packages/loopover-mcp/README.md", stdioReadmeSection],
+ ["packages/loopover-miner/README.md", minerReadmeSection],
+ ])("%s contains the current generated block", (file, build) => {
+ expect(read(file)).toContain(build());
+ });
+
+ it("the docs page has no hand-written config block left outside the markers", () => {
+ const source = read("apps/loopover-ui/content/docs/mcp-clients.mdx");
+ const outside = source.slice(0, source.indexOf("{/* GENERATED:MCP-CLIENT-CONFIG:BEGIN")) + source.slice(source.indexOf("{/* GENERATED:MCP-CLIENT-CONFIG:END */}"));
+ expect(outside).not.toContain("mcpServers");
+ expect(outside).not.toContain("mcp_servers");
+ });
+});
diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts
index 3e236e7815..ce5616b828 100644
--- a/test/unit/mcp-cli-basics.test.ts
+++ b/test/unit/mcp-cli-basics.test.ts
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { closeFixtureServer, createPacketRepo, run, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness";
import mcpPackageJson from "../../packages/loopover-mcp/package.json";
+import { clientConfigSnippet } from "@loopover/contract/client-config";
// #8587: JSON/plain business-payload cases call the exported runCli in-process (the same dispatcher the
// spawned bin runs; pattern from mcp-cli-contributor-profile-inprocess.test.ts). Cases that assert process
@@ -92,6 +93,65 @@ describe("loopover-mcp CLI — basics", () => {
expect(vscode.snippet).not.toContain('"mcpServers"');
});
+ it("prints the remote and miner modes, byte-identical to the docs' own blocks (#9526)", async () => {
+ const remote = JSON.parse(await runInProcess(["init-client", "--print", "vscode", "--mode", "remote", "--json"])) as {
+ mode: string;
+ file: string;
+ command: string | null;
+ snippet: string;
+ notes: string[];
+ };
+ expect(remote.mode).toBe("remote");
+ expect(remote.snippet).toBe(clientConfigSnippet("vscode", "remote"));
+ // A remote entry has no local process, so reporting a command would be a lie a reader could act on.
+ expect(remote.command).toBeNull();
+ // The token is named, never carried.
+ expect(remote.snippet).toContain("${LOOPOVER_API_TOKEN}");
+ expect(remote.notes.join("\n")).toContain("never paste the token into the config file");
+
+ const miner = JSON.parse(await runInProcess(["init-client", "--print", "claude", "--mode", "miner", "--json"])) as { snippet: string; args: string[] };
+ expect(miner.snippet).toBe(clientConfigSnippet("claude", "miner"));
+ expect(miner.args).toEqual([]);
+ });
+
+ it("defaults to the stdio mode, so a pre-gateway invocation prints what it always did (#9526)", async () => {
+ const explicit = JSON.parse(await runInProcess(["init-client", "--print", "codex", "--mode", "stdio", "--json"])) as { snippet: string };
+ const implicit = JSON.parse(await runInProcess(["init-client", "--print", "codex", "--json"])) as { snippet: string; mode: string };
+ expect(implicit.mode).toBe("stdio");
+ expect(implicit.snippet).toBe(explicit.snippet);
+ });
+
+ it("rejects an unsupported mode by naming the ones that exist (#9526)", async () => {
+ await expect(runInProcess(["init-client", "--print", "claude", "--mode", "carrier-pigeon"])).rejects.toThrow(/Unsupported mode.*stdio, remote, miner/);
+ });
+
+ it("names the hosts it accepts when told nothing, or something it does not know (#9526)", async () => {
+ // The error has to enumerate: "unsupported client" without the list leaves a reader guessing at a
+ // five-value set they cannot see from the outside.
+ await expect(runInProcess(["init-client"])).rejects.toThrow(/Pass --print with one of: codex, claude, cursor, mcp, vscode/);
+ await expect(runInProcess(["init-client", "--print", "emacs"])).rejects.toThrow(/Unsupported client: emacs.*codex, claude, cursor, mcp, vscode/);
+ });
+
+ it("accepts --client as well as --print, since both spellings reached this command (#9526)", async () => {
+ const viaClient = JSON.parse(await runInProcess(["init-client", "--client", "cursor", "--json"])) as { client: string; snippet: string };
+ expect(viaClient.client).toBe("cursor");
+ expect(viaClient.snippet).toBe(clientConfigSnippet("cursor", "stdio"));
+ });
+
+ it("refuses a host/mode pair it cannot vouch for, naming both (#9526)", async () => {
+ // The generic `mcpServers` bucket is an unnamed host; guessing its remote dialect would print config
+ // that fails on paste, and the stdio gateway already serves it the remote tools.
+ await expect(runInProcess(["init-client", "--print", "mcp", "--mode", "remote"])).rejects.toThrow(/cannot connect over the Remote streamable-http mode/);
+ });
+
+ it("carries the host's own remote caveat when it has one, and nothing when it does not (#9526)", async () => {
+ const codex = JSON.parse(await runInProcess(["init-client", "--print", "codex", "--mode", "remote", "--json"])) as { notes: string[] };
+ expect(codex.notes.join("\n")).toContain("experimental_use_rmcp_client");
+
+ const cursor = JSON.parse(await runInProcess(["init-client", "--print", "cursor", "--mode", "remote", "--json"])) as { notes: string[] };
+ expect(cursor.notes.join("\n")).not.toContain("experimental_use_rmcp_client");
+ });
+
it("prints human-approved agent profile instructions for supported MCP clients", async () => {
const payload = JSON.parse(await runInProcess(["init-client", "--print", "codex", "--agent-profile", "miner-planner", "--json"])) as {
agentProfile: {
diff --git a/test/unit/mcp-discovery-surfaces.test.ts b/test/unit/mcp-discovery-surfaces.test.ts
new file mode 100644
index 0000000000..b31555086c
--- /dev/null
+++ b/test/unit/mcp-discovery-surfaces.test.ts
@@ -0,0 +1,252 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ AgentToolsIndexSchema,
+ AnthropicToolsSchema,
+ OpenAiToolsSchema,
+ SERVER_CARD_NAME,
+ ServerCardSchema,
+ buildAgentToolsIndex,
+ buildAnthropicTools,
+ buildOpenAiTools,
+ buildServerCard,
+ matchesETag,
+ weakETag,
+} from "@loopover/contract/discovery";
+import { listToolDefinitions } from "@loopover/contract/tools";
+import {
+ DISCOVERY_PATHS,
+ buildDiscoveryDocuments,
+ deterministicGeneratedAt,
+ discoveryDocumentsFor,
+ resetDiscoveryCacheForTesting,
+ respondWithDocument,
+ toolsForDeployment,
+} from "../../src/mcp/discovery-routes";
+import { buildOpenApiSpec } from "../../src/openapi/spec";
+
+// #9526: the discovery surfaces are COMPUTED, never committed — metagraphed's committed server card made
+// every concurrent tool PR conflict on one generated file. So there is no golden fixture to compare here;
+// what these pin are the properties that make the computed answer trustworthy: it describes the deployment
+// that is answering, it is stable enough to cache, and it never under- or over-states the tool set.
+
+const TOOLS = listToolDefinitions({ availability: ["cloud"] });
+const CONTEXT = { version: "3.15.2", deployment: "cloud" as const, baseUrl: "https://api.loopover.ai", tools: TOOLS };
+
+beforeEach(() => {
+ resetDiscoveryCacheForTesting();
+});
+
+describe("availability filtering (#9526)", () => {
+ it("a cloud card excludes selfhost-only tools, and a selfhost card excludes cloud-only ones", () => {
+ const cloud = new Set(toolsForDeployment("cloud").map((tool) => tool.name));
+ const selfhost = new Set(toolsForDeployment("selfhost").map((tool) => tool.name));
+
+ // The registry's availability filter is INCLUSIVE (`both` satisfies any constraint), so "only" has to
+ // be derived from the raw field rather than by filtering.
+ const cloudOnly = listToolDefinitions().filter((tool) => tool.availability === "cloud").map((tool) => tool.name);
+ const selfhostOnly = listToolDefinitions().filter((tool) => tool.availability === "selfhost").map((tool) => tool.name);
+ expect(cloudOnly.length, "the fixture needs at least one cloud-only tool to be meaningful").toBeGreaterThan(0);
+ expect(selfhostOnly.length).toBeGreaterThan(0);
+
+ for (const name of cloudOnly) expect(selfhost.has(name), `${name} is cloud-only`).toBe(false);
+ for (const name of selfhostOnly) expect(cloud.has(name), `${name} is selfhost-only`).toBe(false);
+ });
+
+ it("both deployments carry every `both` tool", () => {
+ const shared = listToolDefinitions().filter((tool) => tool.availability === "both").map((tool) => tool.name);
+ const cloud = new Set(toolsForDeployment("cloud").map((tool) => tool.name));
+ const selfhost = new Set(toolsForDeployment("selfhost").map((tool) => tool.name));
+ for (const name of shared) {
+ expect(cloud.has(name)).toBe(true);
+ expect(selfhost.has(name)).toBe(true);
+ }
+ });
+
+ it("does NOT filter by locality — a local-git tool is still part of the catalog a client discovers", () => {
+ // The remote serves them too; it just expects the caller to supply the branch metadata rather than
+ // reading a checkout. Hiding them would under-describe the server.
+ const localGit = listToolDefinitions({ locality: ["local-git"] }).filter((tool) => tool.availability === "both").map((tool) => tool.name);
+ expect(localGit.length).toBeGreaterThan(0);
+ const cloud = new Set(toolsForDeployment("cloud").map((tool) => tool.name));
+ for (const name of localGit) expect(cloud.has(name)).toBe(true);
+ });
+});
+
+describe("the server card (#9526)", () => {
+ it("names the registry identity, the version, and the streamable-http remote", () => {
+ const card = buildServerCard({ ...CONTEXT, generatedAt: deterministicGeneratedAt(CONTEXT.version) });
+ expect(card.name).toBe(SERVER_CARD_NAME);
+ expect(card.version).toBe("3.15.2");
+ expect(card.remotes).toEqual([{ type: "streamable-http", url: "https://api.loopover.ai/mcp" }]);
+ expect(card.deployment).toBe("cloud");
+ });
+
+ it("trims a trailing slash off the base URL rather than emitting a doubled path", () => {
+ const card = buildServerCard({ ...CONTEXT, baseUrl: "https://api.loopover.ai/", generatedAt: "x" });
+ expect(card.remotes[0]!.url).toBe("https://api.loopover.ai/mcp");
+ });
+
+ it("lists every tool with the catalog fields a client picks by", () => {
+ const card = buildServerCard({ ...CONTEXT, generatedAt: "x" });
+ expect(card.tools.length).toBe(TOOLS.length);
+ const first = card.tools[0]!;
+ expect(Object.keys(first).sort()).toEqual(["annotations", "category", "description", "name", "title"]);
+ });
+
+ it("declares listChanged, since the gateway re-mounts on that notification", () => {
+ expect(buildServerCard({ ...CONTEXT, generatedAt: "x" }).capabilities.tools.listChanged).toBe(true);
+ });
+});
+
+describe("the agent-tools trio (#9526)", () => {
+ const input = { baseUrl: "https://api.loopover.ai", tools: TOOLS, generatedAt: "x" };
+
+ it("every document carries an executor, so the catalog is actionable rather than a list", () => {
+ for (const document of [buildAgentToolsIndex(input), buildOpenAiTools(input), buildAnthropicTools(input)]) {
+ expect(document.executor).toEqual({ transport: "streamable-http", url: "https://api.loopover.ai/mcp", method: "tools/call" });
+ }
+ });
+
+ it("the index carries BOTH schemas verbatim from the registry", () => {
+ const index = buildAgentToolsIndex(input);
+ expect(index.tools.length).toBe(TOOLS.length);
+ expect(index.tools[0]!.input_schema).toEqual(TOOLS[0]!.inputSchema);
+ expect(index.tools[0]!.output_schema).toEqual(TOOLS[0]!.outputSchema);
+ });
+
+ it("the OpenAI projection is the registry's input schema under `parameters` — no second translation", () => {
+ const openai = buildOpenAiTools(input);
+ expect(openai.tools[0]!.type).toBe("function");
+ expect(openai.tools[0]!.function.parameters).toEqual(TOOLS[0]!.inputSchema);
+ expect(openai.tools[0]!.function.name).toBe(TOOLS[0]!.name);
+ });
+
+ it("the Anthropic projection is the same schema under `input_schema`", () => {
+ const anthropic = buildAnthropicTools(input);
+ expect(anthropic.tools[0]!.input_schema).toEqual(TOOLS[0]!.inputSchema);
+ expect(anthropic.tools[0]!.name).toBe(TOOLS[0]!.name);
+ });
+
+ it("all three describe the SAME tool set — one contract, three shapes", () => {
+ const names = (list: Array<{ name?: string; function?: { name: string } }>) => list.map((entry) => entry.name ?? entry.function!.name);
+ expect(names(buildOpenAiTools(input).tools)).toEqual(names(buildAgentToolsIndex(input).tools));
+ expect(names(buildAnthropicTools(input).tools)).toEqual(names(buildAgentToolsIndex(input).tools));
+ });
+});
+
+describe("caching (#9526)", () => {
+ it("generated_at is derived from the VERSION, not the clock", () => {
+ // A wall-clock value would change the body every request, changing the ETag with it and making the 304
+ // path dead code.
+ expect(deterministicGeneratedAt("3.15.2")).toBe("version:3.15.2");
+ const first = buildDiscoveryDocuments(CONTEXT);
+ const second = buildDiscoveryDocuments(CONTEXT);
+ for (const path of DISCOVERY_PATHS) expect(second[path]!.etag).toBe(first[path]!.etag);
+ });
+
+ it("a version bump changes every ETag, so a deploy invalidates the caches", () => {
+ const before = buildDiscoveryDocuments(CONTEXT);
+ const after = buildDiscoveryDocuments({ ...CONTEXT, version: "3.16.0" });
+ for (const path of DISCOVERY_PATHS) expect(after[path]!.etag).not.toBe(before[path]!.etag);
+ });
+
+ it("weakETag is stable for equal bodies and differs for different ones", () => {
+ expect(weakETag("abc")).toBe(weakETag("abc"));
+ expect(weakETag("abc")).not.toBe(weakETag("abd"));
+ expect(weakETag("")).toMatch(/^W\/"[0-9a-f]+"$/);
+ });
+
+ it.each([
+ ['the exact tag', (etag: string) => etag],
+ ['the tag without its weak prefix', (etag: string) => etag.replace(/^W\//, "")],
+ ['a list containing it', (etag: string) => `W/"other", ${etag}`],
+ ['a wildcard', () => "*"],
+ ])("matchesETag accepts %s", (_label, build) => {
+ const etag = weakETag("body");
+ expect(matchesETag(build(etag), etag)).toBe(true);
+ });
+
+ it.each([
+ ["a different tag", 'W/"deadbeef"'],
+ ["an empty header", ""],
+ ["no header at all", null],
+ ])("matchesETag rejects %s", (_label, header) => {
+ expect(matchesETag(header, weakETag("body"))).toBe(false);
+ });
+});
+
+describe("the HTTP response (#9526)", () => {
+ it("answers 200 with the document, its ETag, and a revalidating cache policy", async () => {
+ const documents = buildDiscoveryDocuments(CONTEXT);
+ const response = respondWithDocument(documents["/.well-known/mcp.json"]!, null);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("etag")).toBe(documents["/.well-known/mcp.json"]!.etag);
+ expect(response.headers.get("cache-control")).toContain("must-revalidate");
+ expect(JSON.parse(await response.text()).name).toBe(SERVER_CARD_NAME);
+ });
+
+ it("answers 304 with NO body when the caller already has the entity", async () => {
+ const document = buildDiscoveryDocuments(CONTEXT)["/.well-known/mcp.json"]!;
+ const response = respondWithDocument(document, document.etag);
+ expect(response.status).toBe(304);
+ expect(await response.text()).toBe("");
+ // The validators still travel so the cache can extend its entry.
+ expect(response.headers.get("etag")).toBe(document.etag);
+ });
+
+ it("answers 200 when the caller's tag is stale", () => {
+ const document = buildDiscoveryDocuments(CONTEXT)["/.well-known/mcp.json"]!;
+ expect(respondWithDocument(document, 'W/"stale"').status).toBe(200);
+ });
+});
+
+describe("the per-origin memo (#9526)", () => {
+ it("returns the same object for a repeat request on one origin", () => {
+ expect(discoveryDocumentsFor(CONTEXT)).toBe(discoveryDocumentsFor(CONTEXT));
+ });
+
+ it("keeps origins separate, so each advertises its OWN /mcp", () => {
+ const primary = discoveryDocumentsFor(CONTEXT);
+ const other = discoveryDocumentsFor({ ...CONTEXT, baseUrl: "https://self.example" });
+ expect(other).not.toBe(primary);
+ expect(JSON.parse(other["/.well-known/mcp.json"]!.body).remotes[0].url).toBe("https://self.example/mcp");
+ });
+
+ it("keeps deployments separate, so a self-host card is not a copy of the cloud one", () => {
+ const cloud = JSON.parse(discoveryDocumentsFor(CONTEXT)["/.well-known/mcp.json"]!.body);
+ const selfhost = JSON.parse(
+ discoveryDocumentsFor({ ...CONTEXT, deployment: "selfhost", tools: toolsForDeployment("selfhost") })["/.well-known/mcp.json"]!.body,
+ );
+ expect(selfhost.deployment).toBe("selfhost");
+ expect(selfhost.tools.length).not.toBe(cloud.tools.length);
+ });
+});
+
+describe("the documents are what the API document PROMISES (#9526)", () => {
+ const tools = listToolDefinitions();
+ const context = { version: "1.2.3", deployment: "cloud" as const, baseUrl: "https://api.loopover.ai", tools };
+ const agentToolsInput = { baseUrl: context.baseUrl, tools, generatedAt: deterministicGeneratedAt(context.version) };
+
+ it.each([
+ ["/.well-known/mcp.json", ServerCardSchema, () => buildServerCard({ ...context, generatedAt: agentToolsInput.generatedAt })],
+ ["/.well-known/agent-tools/index.json", AgentToolsIndexSchema, () => buildAgentToolsIndex(agentToolsInput)],
+ ["/.well-known/agent-tools/openai.json", OpenAiToolsSchema, () => buildOpenAiTools(agentToolsInput)],
+ ["/.well-known/agent-tools/anthropic.json", AnthropicToolsSchema, () => buildAnthropicTools(agentToolsInput)],
+ ])("%s validates against the schema the spec publishes", (_path, schema, build) => {
+ // The builders' return types are INFERRED from these schemas, so a mismatch is normally a compile
+ // error -- but the JSON Schema in the document is generated from the same object, and this is what
+ // proves the runtime value satisfies it rather than merely type-checking against it.
+ expect(schema.safeParse(build()).success).toBe(true);
+ });
+
+ it("every discovery path has a specced GET operation with a 200 schema and a 304", () => {
+ const paths = buildOpenApiSpec().paths as Record } }>;
+ for (const path of DISCOVERY_PATHS) {
+ const operation = paths[path]?.get;
+ expect(operation?.operationId, `${path} must be described in the published document`).toBeTruthy();
+ expect(operation!.responses!["200"]!.content, `${path}'s 200 must carry a schema, not just a description`).toBeTruthy();
+ // Without the 304 in the document, a generated client has no reason to send if-none-match at all.
+ expect(operation!.responses!["304"]).toBeTruthy();
+ }
+ });
+});
diff --git a/test/unit/mcp-dispatch-telemetry.test.ts b/test/unit/mcp-dispatch-telemetry.test.ts
index 4efe12c81a..dcd9650390 100644
--- a/test/unit/mcp-dispatch-telemetry.test.ts
+++ b/test/unit/mcp-dispatch-telemetry.test.ts
@@ -29,10 +29,17 @@ const call: McpToolCallTelemetry = { tool: "loopover_get_repo_context", category
describe("MCP telemetry event shapes (#9525)", () => {
it("omits error_code on success rather than sending it as null", () => {
const properties = buildUsageEventProperties(call);
- expect(properties).toEqual({ tool: call.tool, category: "maintainer", surface: "remote", ok: true, duration_ms: 12 });
+ expect(properties).toEqual({ tool: call.tool, category: "maintainer", surface: "remote", transport: "local", ok: true, duration_ms: 12 });
expect("error_code" in properties).toBe(false);
});
+ it("defaults transport to local for a sink with no notion of proxying, and reports it when there is one (#9526)", () => {
+ // Always emitted, never conditional: a breakdown by transport with an empty `local` bucket would read
+ // as "nothing runs locally" rather than "most sinks do not set this".
+ expect(buildUsageEventProperties(call).transport).toBe("local");
+ expect(buildUsageEventProperties({ ...call, surface: "stdio", transport: "proxied" }).transport).toBe("proxied");
+ });
+
it("carries the closed error code on failure", () => {
expect(buildUsageEventProperties({ ...call, ok: false, errorCode: "not_found" })).toMatchObject({ ok: false, error_code: "not_found" });
});
@@ -66,7 +73,7 @@ describe("MCP telemetry event shapes (#9525)", () => {
it("keeps span attributes a strict subset -- never arguments or the excluded marker", () => {
const attributes = buildMcpToolSpanAttributes({ ...call, ok: false, errorCode: "timeout" });
- expect(Object.keys(attributes).sort()).toEqual(["category", "duration_ms", "error_code", "ok", "surface", "tool"]);
+ expect(Object.keys(attributes).sort()).toEqual(["category", "duration_ms", "error_code", "ok", "surface", "tool", "transport"]);
expect(mcpToolSpanName("loopover_x")).toBe("mcp.tool/loopover_x");
});
});
diff --git a/test/unit/mcp-gateway-mount-inprocess.test.ts b/test/unit/mcp-gateway-mount-inprocess.test.ts
new file mode 100644
index 0000000000..20a7ce7a83
--- /dev/null
+++ b/test/unit/mcp-gateway-mount-inprocess.test.ts
@@ -0,0 +1,266 @@
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
+import type { GatewayFetch, GatewayMountResult } from "../../packages/loopover-mcp/lib/gateway";
+
+// #9526: gateway mounting against the REAL stdio server, in-process.
+//
+// gateway.ts's discovery is unit-tested on its own; this covers the wiring the bin owns — that a mounted
+// remote tool is actually registered and callable, that it is tagged as proxied so telemetry can tell it
+// from a local call, and that the failure paths leave a server which still lists its local tools.
+
+const MODULE = "../../packages/loopover-mcp/bin/loopover-mcp.ts";
+
+type BinModule = {
+ server: { connect: (transport: unknown) => Promise };
+ mountRemoteTools: (options?: { argv?: readonly string[]; fetchImpl?: GatewayFetch }) => Promise;
+};
+
+let tempDir = "";
+let mod: BinModule;
+
+beforeAll(async () => {
+ tempDir = mkdtempSync(join(tmpdir(), "loopover-gateway-mount-"));
+ const apiUrl = await startFixtureServer();
+ process.env.LOOPOVER_API_URL = apiUrl;
+ process.env.LOOPOVER_API_TOKEN = "in-process-token";
+ process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
+ process.env.LOOPOVER_CONFIG_DIR = tempDir;
+ process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
+ mod = (await import(MODULE)) as unknown as BinModule;
+}, 120_000);
+
+afterAll(async () => {
+ await closeFixtureServer();
+ if (tempDir) rmSync(tempDir, { recursive: true, force: true });
+ for (const key of ["LOOPOVER_API_URL", "LOOPOVER_API_TOKEN", "LOOPOVER_API_TIMEOUT_MS", "LOOPOVER_CONFIG_DIR", "LOOPOVER_SKIP_NPM_VERSION_CHECK"]) {
+ delete process.env[key];
+ }
+});
+
+async function connect(name: string) {
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await mod.server.connect(serverTransport);
+ const client = new Client({ name, version: "0.1.0" }, { capabilities: {} });
+ await client.connect(clientTransport);
+ return client;
+}
+
+function remoteToolsFetch(tools: unknown[]): GatewayFetch {
+ return vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ result: { tools } }) }));
+}
+
+describe("mountRemoteTools against the real server (#9526)", () => {
+ it("--no-remote mounts nothing and never calls out", async () => {
+ const fetchImpl = remoteToolsFetch([]);
+ const result = await mod.mountRemoteTools({ argv: ["--stdio", "--no-remote"], fetchImpl });
+ expect(result.status).toBe("unavailable");
+ expect(fetchImpl, "the opt-out must be byte-identical to pre-gateway behavior").not.toHaveBeenCalled();
+ });
+
+ it("mounts a remote tool and makes it callable and discoverable", async () => {
+ const result = await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([
+ {
+ name: "loopover_gateway_probe",
+ title: "Gateway probe",
+ description: "A remote-only tool, mounted through the gateway.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ },
+ ]),
+ });
+ expect(result.status).toBe("mounted");
+
+ const client = await connect("gateway-mount-test");
+ try {
+ const { tools } = await client.listTools();
+ const proxied = tools.find((tool) => tool.name === "loopover_gateway_probe");
+ expect(proxied, "a mounted tool must appear in tools/list").toBeDefined();
+ // Tagged so telemetry can distinguish a proxied call from a local one (#9526 requirement 6) without
+ // having to know which names are remote.
+ expect((proxied!._meta as { transport?: string } | undefined)?.transport).toBe("proxied");
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("SKIPS a remote tool the local server already serves, leaving the local one in place", async () => {
+ const before = await connect("gateway-collision-before");
+ const localNames = new Set((await before.listTools()).tools.map((tool) => tool.name));
+ await before.close();
+ const collidingName = [...localNames].find((name) => name.startsWith("loopover_"))!;
+
+ const result = await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([{ name: collidingName, description: "remote impostor" }]),
+ });
+ // The registry makes a real collision impossible; degrading beats crashing on duplicate registration.
+ expect(result.status).toBe("mounted");
+ expect(result.status === "mounted" && result.skipped).toContain(collidingName);
+ });
+
+ it("mounts a REGISTRY-KNOWN tool with the contract's schemas rather than the wire's", async () => {
+ // The remote registered from the same contract registry, so the proxy can advertise exactly what the
+ // remote enforces without this package trusting a schema a remote handed it.
+ const contractName = "loopover_refresh_repo_focus_manifest";
+ const result = await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([{ name: contractName, description: "remote-only in a later release" }]),
+ });
+ expect(result.status === "mounted" && result.tools).toHaveLength(1);
+
+ const client = await connect("gateway-contract-schema");
+ try {
+ const proxied = (await client.listTools()).tools.find((tool) => tool.name === contractName)!;
+ // The wire descriptor carried NO inputSchema; the registry supplied one.
+ expect(Object.keys(proxied.inputSchema.properties ?? {}), "the contract's shape, not the wire's silence").toEqual(["owner", "repo"]);
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("a descriptor the SDK refuses costs that tool and nothing else", async () => {
+ // A remote that repeats a name in one response would otherwise crash the whole mount on the duplicate
+ // registration — mounting is all-or-nothing only if you let it be.
+ const result = await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([
+ { name: "loopover_gateway_twice", description: "first" },
+ { name: "loopover_gateway_twice", description: "a repeat of the same name" },
+ { name: "loopover_gateway_survivor", description: "must still mount" },
+ ]),
+ });
+ expect(result.status === "mounted" && result.tools.map((tool) => tool.name)).toEqual([
+ "loopover_gateway_twice",
+ "loopover_gateway_survivor",
+ ]);
+ expect(result.status === "mounted" && result.skipped).toEqual(["loopover_gateway_twice"]);
+ });
+
+ it("CALLS a proxied tool and forwards it to the remote's own tools/call", async () => {
+ // Listing a proxied tool proves registration; calling one proves the proxy actually routes. The fixture
+ // server answers /mcp, so this exercises the whole path the way a client would.
+ await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([{ name: "loopover_gateway_call_probe", description: "callable through the gateway" }]),
+ });
+
+ const client = await connect("gateway-call-test");
+ try {
+ const result = (await client.callTool({ name: "loopover_gateway_call_probe", arguments: { owner: "acme" } })) as {
+ isError?: boolean;
+ structuredContent?: { calledTool?: string; echoedArguments?: unknown };
+ };
+ // The remote's `result` is handed back VERBATIM -- this layer routes, it does not interpret -- and the
+ // arguments reached the remote untouched. Asserting the payload rather than merely "it resolved":
+ // callTool resolves for a failed call too, so a weaker assertion passes even when nothing was proxied.
+ expect(result.isError).toBeFalsy();
+ expect(result.structuredContent?.calledTool).toBe("loopover_gateway_call_probe");
+ expect(result.structuredContent?.echoedArguments).toEqual({ owner: "acme" });
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("hands back the WHOLE payload when the remote's envelope carries no `result`", async () => {
+ // The `??` fallback, and a real posture rather than a defensive shrug: a remote that answers a shape
+ // this package does not model must still reach the caller intact, so the caller can see what came back
+ // instead of an empty success.
+ await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([{ name: "loopover_gateway_resultless" }]),
+ });
+ const client = await connect("gateway-resultless");
+ try {
+ const raw = (await client.callTool({ name: "loopover_gateway_resultless", arguments: {} })) as { note?: string; isError?: boolean };
+ expect(raw.isError).toBeFalsy();
+ expect(raw.note, "the envelope itself reaches the caller when it carries no result").toBe("no result member");
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("mounts a tool carrying annotations, and one carrying neither description nor annotations", async () => {
+ const result = await mod.mountRemoteTools({
+ argv: ["--stdio"],
+ fetchImpl: remoteToolsFetch([
+ { name: "loopover_gateway_annotated", description: "has hints", annotations: { readOnlyHint: true, destructiveHint: false } },
+ // No description, no annotations: the remote is free to send a minimal descriptor and it must mount.
+ { name: "loopover_gateway_bare" },
+ ]),
+ });
+ expect(result.status === "mounted" && result.tools).toHaveLength(2);
+
+ const client = await connect("gateway-annotations-test");
+ try {
+ const tools = (await client.listTools()).tools;
+ expect(tools.find((tool) => tool.name === "loopover_gateway_annotated")?.annotations?.readOnlyHint).toBe(true);
+ const bare = tools.find((tool) => tool.name === "loopover_gateway_bare");
+ expect(bare, "a descriptor with only a name must still mount").toBeDefined();
+ expect(bare!.description).toBeUndefined();
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("publishes an advisory resource a client can READ, and updates it on a later mount", async () => {
+ await mod.mountRemoteTools({ argv: ["--stdio", "--no-remote"] });
+ const client = await connect("gateway-advisory-read");
+ try {
+ const disabled = await client.readResource({ uri: "loopover://gateway/status" });
+ expect(String((disabled.contents[0] as { text?: string }).text)).toContain("--no-remote");
+
+ // A successful mount must not leave the earlier failure's advisory standing.
+ await mod.mountRemoteTools({ argv: ["--stdio"], fetchImpl: remoteToolsFetch([]) });
+ const after = await client.readResource({ uri: "loopover://gateway/status" });
+ expect(JSON.parse(String((after.contents[0] as { text?: string }).text)).status).toBe("mounted");
+ } finally {
+ await client.close();
+ }
+ });
+
+ it("uses a REAL fetch when the caller injects none", async () => {
+ // The default transport, exercised against the loopback fixture server rather than stubbed away -- so
+ // the one line that actually talks to the network is covered by talking to a network.
+ const result = await mod.mountRemoteTools({ argv: ["--stdio"] });
+ // The fixture answers tools/call, not tools/list, so discovery finds no tool array and degrades --
+ // which is the point: the real transport ran, and a shape it did not expect did not break the server.
+ expect(["mounted", "unavailable"]).toContain(result.status);
+ });
+
+ it("defaults argv to the process's own, and the transport to a real fetch, without touching the network", async () => {
+ // Both defaults on one call. No session means discoverRemoteTools returns before it ever calls the
+ // transport, so the real fetch default is selected and never invoked -- which is the point: an
+ // unauthenticated contributor makes no outbound request at all.
+ const token = process.env.LOOPOVER_API_TOKEN;
+ delete process.env.LOOPOVER_API_TOKEN;
+ try {
+ const result = await mod.mountRemoteTools();
+ expect(result.status).toBe("unauthenticated");
+ } finally {
+ process.env.LOOPOVER_API_TOKEN = token;
+ }
+ });
+
+ it("an unreachable API leaves a server that still lists its LOCAL tools", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new Error("ECONNREFUSED");
+ }) as unknown as GatewayFetch;
+ const result = await mod.mountRemoteTools({ argv: ["--stdio"], fetchImpl });
+ expect(result.status).toBe("unavailable");
+
+ const client = await connect("gateway-offline-test");
+ try {
+ const { tools } = await client.listTools();
+ // The whole posture: an enhancement that can break startup is a liability.
+ expect(tools.length).toBeGreaterThan(10);
+ } finally {
+ await client.close();
+ }
+ });
+});
diff --git a/test/unit/mcp-gateway.test.ts b/test/unit/mcp-gateway.test.ts
new file mode 100644
index 0000000000..7981114a26
--- /dev/null
+++ b/test/unit/mcp-gateway.test.ts
@@ -0,0 +1,178 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ GATEWAY_DISABLED_ADVISORY,
+ GATEWAY_UNAUTHENTICATED_ADVISORY,
+ discoverRemoteTools,
+ gatewayAdvisoryResource,
+ gatewayDisabled,
+ type GatewayFetch,
+ type RemoteToolDescriptor,
+} from "../../packages/loopover-mcp/lib/gateway";
+
+// #9526: gateway discovery. The governing rule is that NONE of this may break a stdio server — a missing
+// session, a dead network, or a hostile response all leave the local tools working plus one advisory. An
+// enhancement that can break startup is a liability, so every failure path here is asserted to be
+// non-throwing and to say something a contributor can act on.
+
+const REMOTE_TOOLS: RemoteToolDescriptor[] = [
+ { name: "loopover_get_decision_pack", title: "Decision pack", description: "…", inputSchema: { type: "object" } },
+ { name: "loopover_preflight_pr", title: "Preflight", description: "…", inputSchema: { type: "object" } },
+];
+
+function respondingWith(payload: unknown, init: { ok?: boolean; status?: number } = {}): GatewayFetch {
+ return vi.fn(async () => ({ ok: init.ok ?? true, status: init.status ?? 200, json: async () => payload }));
+}
+
+function input(overrides: Partial[0]> = {}) {
+ return {
+ apiUrl: "https://api.loopover.ai",
+ token: "session-token",
+ localToolNames: new Set(),
+ fetchImpl: respondingWith({ result: { tools: REMOTE_TOOLS } }),
+ ...overrides,
+ };
+}
+
+describe("discovery requires a session (#9526)", () => {
+ it("reports unauthenticated WITHOUT making a request", async () => {
+ const fetchImpl = respondingWith({ result: { tools: [] } });
+ const result = await discoverRemoteTools(input({ token: null, fetchImpl }));
+ expect(result.status).toBe("unauthenticated");
+ expect(fetchImpl, "no session means nothing to authenticate with — do not call out").not.toHaveBeenCalled();
+ });
+
+ it("the unauthenticated advisory tells a contributor exactly what to do", async () => {
+ const result = await discoverRemoteTools(input({ token: null }));
+ expect(result.status === "unauthenticated" && result.advisory).toBe(GATEWAY_UNAUTHENTICATED_ADVISORY);
+ expect(GATEWAY_UNAUTHENTICATED_ADVISORY).toContain("loopover-mcp login");
+ expect(GATEWAY_UNAUTHENTICATED_ADVISORY, "and must say the local half still works").toContain("Local tools are available");
+ });
+});
+
+describe("a successful mount (#9526)", () => {
+ it("returns the remote tools and calls the remote's own tools/list with the session bearer", async () => {
+ const fetchImpl = respondingWith({ result: { tools: REMOTE_TOOLS } });
+ const result = await discoverRemoteTools(input({ fetchImpl }));
+ expect(result.status).toBe("mounted");
+ expect(result.status === "mounted" && result.tools.map((tool) => tool.name)).toEqual([
+ "loopover_get_decision_pack",
+ "loopover_preflight_pr",
+ ]);
+
+ const [url, init] = (fetchImpl as unknown as { mock: { calls: [string, { headers: Record; body: string }][] } }).mock.calls[0]!;
+ expect(url).toBe("https://api.loopover.ai/mcp");
+ expect(init.headers.authorization).toBe("Bearer session-token");
+ expect(JSON.parse(init.body).method).toBe("tools/list");
+ });
+
+ it("trims a trailing slash rather than requesting a doubled path", async () => {
+ const fetchImpl = respondingWith({ result: { tools: [] } });
+ await discoverRemoteTools(input({ apiUrl: "https://api.loopover.ai/", fetchImpl }));
+ expect((fetchImpl as unknown as { mock: { calls: [string][] } }).mock.calls[0]![0]).toBe("https://api.loopover.ai/mcp");
+ });
+
+ it("SKIPS a remote tool whose name the local server already serves — local wins", async () => {
+ // The registry makes this unreachable, but a duplicate registration would crash the stdio server, and
+ // degrading to the local tool is strictly better than refusing to start.
+ const result = await discoverRemoteTools(input({ localToolNames: new Set(["loopover_preflight_pr"]) }));
+ expect(result.status === "mounted" && result.tools.map((tool) => tool.name)).toEqual(["loopover_get_decision_pack"]);
+ expect(result.status === "mounted" && result.skipped).toEqual(["loopover_preflight_pr"]);
+ });
+
+ it("ignores malformed entries instead of mounting a tool with no name", async () => {
+ const fetchImpl = respondingWith({ result: { tools: [...REMOTE_TOOLS, null, "nope", { title: "no name" }, { name: "" }] } });
+ const result = await discoverRemoteTools(input({ fetchImpl }));
+ expect(result.status === "mounted" && result.tools).toHaveLength(2);
+ });
+});
+
+describe("every failure degrades to a working local server (#9526)", () => {
+ it("a transport error reports unavailable, naming the cause", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new Error("ECONNREFUSED");
+ }) as unknown as GatewayFetch;
+ const result = await discoverRemoteTools(input({ fetchImpl }));
+ expect(result.status).toBe("unavailable");
+ expect(result.status === "unavailable" && result.advisory).toContain("ECONNREFUSED");
+ });
+
+ it("names a non-Error rejection too, rather than rendering it as [object Object]", async () => {
+ // fetch implementations reject with plenty of things that are not Errors; an advisory reading
+ // "the API was unreachable ([object Object])" tells a contributor nothing.
+ const fetchImpl = vi.fn(async () => {
+ throw "ETIMEDOUT from a bare string";
+ }) as unknown as GatewayFetch;
+ const result = await discoverRemoteTools(input({ fetchImpl }));
+ expect(result.status === "unavailable" && result.advisory).toContain("ETIMEDOUT from a bare string");
+ });
+
+ it("a non-2xx reports the STATUS and does not echo the remote body", async () => {
+ // The body is remote-controlled; it has no business in a local advisory string.
+ const fetchImpl = respondingWith({ error: "internal", secretish: "do-not-echo" }, { ok: false, status: 503 });
+ const result = await discoverRemoteTools(input({ fetchImpl }));
+ expect(result.status === "unavailable" && result.advisory).toContain("503");
+ expect(result.status === "unavailable" && result.advisory).not.toContain("do-not-echo");
+ });
+
+ it.each([
+ ["a body with no result", {}],
+ ["a result with no tools", { result: {} }],
+ ["tools that are not an array", { result: { tools: "everything" } }],
+ ["a null body", null],
+ ])("reports unavailable for %s rather than throwing", async (_label, payload) => {
+ const result = await discoverRemoteTools(input({ fetchImpl: respondingWith(payload) }));
+ expect(result.status).toBe("unavailable");
+ });
+
+ it("a body that will not parse is unavailable, not a crash", async () => {
+ const fetchImpl = vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => {
+ throw new Error("not json");
+ },
+ })) as unknown as GatewayFetch;
+ expect((await discoverRemoteTools(input({ fetchImpl }))).status).toBe("unavailable");
+ });
+
+ it("every unavailable advisory says the local tools still work", async () => {
+ const result = await discoverRemoteTools(input({ fetchImpl: respondingWith({}) }));
+ expect(result.status === "unavailable" && result.advisory).toContain("Local tools are available");
+ });
+
+ it("bounds the call so a hanging remote cannot delay stdio startup", async () => {
+ const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ result: { tools: [] } }) }));
+ await discoverRemoteTools(input({ fetchImpl: fetchImpl as unknown as GatewayFetch, timeoutMs: 25 }));
+ const [, init] = (fetchImpl as unknown as { mock: { calls: [string, { signal?: AbortSignal }][] } }).mock.calls[0]!;
+ expect(init.signal, "an unbounded discovery call would hang the server's start").toBeDefined();
+ });
+});
+
+describe("--no-remote (#9526)", () => {
+ it.each([
+ [["--stdio", "--no-remote"], true],
+ [["--no-remote"], true],
+ [["--stdio"], false],
+ [[], false],
+ ])("gatewayDisabled(%j) is %s", (argv, expected) => {
+ expect(gatewayDisabled(argv)).toBe(expected);
+ });
+
+ it("its advisory explains how to turn the gateway back on", () => {
+ expect(GATEWAY_DISABLED_ADVISORY).toContain("--no-remote");
+ expect(GATEWAY_DISABLED_ADVISORY).toContain("Local tools are available");
+ });
+});
+
+describe("the advisory resource (#9526)", () => {
+ it("is null when tools mounted — there is nothing to advise about", () => {
+ expect(gatewayAdvisoryResource({ status: "mounted", tools: [], skipped: [] })).toBeNull();
+ });
+
+ it.each([
+ ["unauthenticated" as const, GATEWAY_UNAUTHENTICATED_ADVISORY],
+ ["unavailable" as const, "some reason"],
+ ])("carries the %s status and its advisory", (status, advisory) => {
+ expect(gatewayAdvisoryResource({ status, advisory })).toEqual({ status, advisory });
+ });
+});
diff --git a/test/unit/mcp-local-telemetry-chokepoint.test.ts b/test/unit/mcp-local-telemetry-chokepoint.test.ts
index 98ab0090b8..ed0e2953c3 100644
--- a/test/unit/mcp-local-telemetry-chokepoint.test.ts
+++ b/test/unit/mcp-local-telemetry-chokepoint.test.ts
@@ -146,11 +146,11 @@ describe("loopover-mcp local telemetry chokepoint (#6238)", () => {
// two separate sets so a future field of OURS can never hide among the vendor's.
const allowedByEvent: Record = {
mcp_tool_call: ["caller_type", "duration_ms", "ok", "tool"],
- usage_event: ["category", "duration_ms", "ok", "surface", "tool"],
+ usage_event: ["category", "duration_ms", "ok", "surface", "tool", "transport"],
// No `arguments`/`result`: payloads are excluded for every tool by default (#9525). This test
// is what established that -- the first design included them, and this assertion found a real
// commit message on the wire.
- $mcp_tool_call: ["category", "duration_ms", "ok", "payloads_excluded", "surface", "tool"],
+ $mcp_tool_call: ["category", "duration_ms", "ok", "payloads_excluded", "surface", "tool", "transport"],
};
for (const event of received) {
const properties = Object.keys(event.properties ?? {});
diff --git a/test/unit/mcp-local-telemetry.test.ts b/test/unit/mcp-local-telemetry.test.ts
index 4d27f4fb95..7e8fa12a6c 100644
--- a/test/unit/mcp-local-telemetry.test.ts
+++ b/test/unit/mcp-local-telemetry.test.ts
@@ -361,6 +361,42 @@ describe("recordStdioToolTelemetry / wrapStdioToolHandler (#8690)", () => {
expect(h.captureExceptionSpy).toHaveBeenCalledOnce();
});
+ // #9526: gateway adoption is measurable only if a proxied call is distinguishable from a local one on the
+ // wire. `surface` says which server was asked; only `transport` says which path the call actually took.
+ it("wrapStdioToolHandler tags a PROXIED call so gateway adoption is measurable", async () => {
+ vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
+ const wrapped = wrapStdioToolHandler("loopover_lint_pr_text", () => true, async () => ({ structuredContent: { ok: true } }), "proxied");
+ await wrapped({});
+
+ const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!;
+ expect(usage.properties).toMatchObject({ surface: "stdio", transport: "proxied" });
+ });
+
+ it("wrapStdioToolHandler tags a local call `local` without the caller saying so", async () => {
+ vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
+ const wrapped = wrapStdioToolHandler("loopover_lint_pr_text", () => true, async () => ({ structuredContent: { ok: true } }));
+ await wrapped({});
+
+ const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!;
+ expect(usage.properties).toMatchObject({ transport: "local" });
+ });
+
+ it("carries the transport through the THROW path too — a failed proxy is still a proxied call", async () => {
+ vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
+ const wrapped = wrapStdioToolHandler(
+ "loopover_lint_pr_text",
+ () => true,
+ async () => {
+ throw new Error("the remote refused");
+ },
+ "proxied",
+ );
+ await expect(wrapped({})).rejects.toThrow("the remote refused");
+
+ const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!;
+ expect(usage.properties).toMatchObject({ ok: false, transport: "proxied" });
+ });
+
it("wrapStdioToolHandler is a no-op for PostHog when telemetry is disabled", async () => {
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
const wrapped = wrapStdioToolHandler("loopover_demo", () => false, async () => ({ ok: true }));
diff --git a/test/unit/publish-mcp-registry-workflow.test.ts b/test/unit/publish-mcp-registry-workflow.test.ts
new file mode 100644
index 0000000000..08e134ab66
--- /dev/null
+++ b/test/unit/publish-mcp-registry-workflow.test.ts
@@ -0,0 +1,95 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { parse } from "yaml";
+
+// #9526: the registry-publish workflow's own safety properties.
+//
+// This is the one job in the repo that holds `id-token: write` and writes to a PUBLIC registry under this
+// repository's namespace. Everything asserted here is a property whose absence is invisible until it
+// matters -- a workflow that publishes from a branch, or under a binary nobody reviewed, looks exactly like
+// one that does not, right up until it does.
+
+const WORKFLOW_PATH = ".github/workflows/publish-mcp-registry.yml";
+
+type Step = { name?: string; run?: string; uses?: string; env?: Record };
+type Workflow = {
+ on?: Record;
+ permissions?: Record;
+ jobs?: Record;
+};
+
+const raw = readFileSync(WORKFLOW_PATH, "utf8");
+const workflow = parse(raw) as Workflow;
+const publish = workflow.jobs?.publish;
+const steps = publish?.steps ?? [];
+
+describe("the registry publish workflow cannot fire on its own (#9526)", () => {
+ it("is dispatch-only — no push, schedule, or pull_request trigger", () => {
+ // Publishing announces a version to a public registry. A merge must not do that as a side effect.
+ expect(Object.keys(workflow.on ?? {})).toEqual(["workflow_dispatch"]);
+ });
+
+ it("refuses any ref but main, since a dispatch can name a branch", () => {
+ expect(publish?.if).toContain("refs/heads/main");
+ });
+
+ it("runs in a deployment environment, so the OIDC-holding job can carry protection rules", () => {
+ // Without one, anyone who can dispatch a workflow can publish. The rules themselves live in repo
+ // settings; what the workflow owes is the hook they attach to.
+ expect(publish?.environment).toBeTruthy();
+ });
+
+ it("takes id-token write and nothing else beyond read", () => {
+ expect(workflow.permissions).toEqual({ contents: "read", "id-token": "write" });
+ });
+});
+
+describe("the publisher binary is pinned to bytes, not to a tag (#9526)", () => {
+ const install = steps.find((step) => step.run?.includes("mcp-publisher.tar.gz"));
+
+ it("verifies a sha256 checksum before extracting anything", () => {
+ // A release tag is mutable -- it can be moved to a different commit after review. The checksum is what
+ // makes "pinned" true rather than merely stated, and #9526 called for exactly this.
+ expect(install?.run).toContain("sha256sum --check --strict");
+ expect(install?.env?.MCP_PUBLISHER_SHA256).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("checks the download BEFORE running it, not after", () => {
+ const run = install!.run!;
+ expect(run.indexOf("sha256sum")).toBeLessThan(run.indexOf("tar -xzf"));
+ expect(run).toContain("set -euo pipefail");
+ });
+
+ it("downloads the asset name the release actually publishes", () => {
+ // The first cut asked for `mcp-publisher_${VERSION}_linux_amd64.tar.gz`, which 404s -- the release
+ // asset carries no version in its name. A publish workflow that cannot even fetch its tool is a
+ // workflow nobody has run.
+ expect(install?.run).toContain("mcp-publisher_linux_amd64.tar.gz");
+ expect(install?.run).not.toMatch(/mcp-publisher_\$\{[^}]+\}_linux/);
+ expect(install?.env?.MCP_PUBLISHER_VERSION).toMatch(/^v\d+\.\d+\.\d+$/);
+ });
+
+ it("pins every action it uses to a commit sha", () => {
+ for (const step of steps.filter((entry) => entry.uses)) {
+ expect(step.uses, `${step.uses} must be sha-pinned`).toMatch(/@[0-9a-f]{40}(\s|$)/);
+ }
+ });
+});
+
+describe("the anti-rot guard runs before anything is announced (#9526)", () => {
+ it("validates server.json and its watched paths before login or publish", () => {
+ const validateAt = steps.findIndex((step) => step.run?.includes("check-server-manifest"));
+ const loginAt = steps.findIndex((step) => step.run?.includes("mcp-publisher login"));
+ const publishAt = steps.findIndex((step) => step.run?.includes("mcp-publisher publish"));
+ expect(validateAt).toBeGreaterThanOrEqual(0);
+ // metagraphed's version-sync workflow watched a renamed-away path and kept passing while doing nothing
+ // for months. The guard is only worth having if it runs before the irreversible step.
+ expect(validateAt).toBeLessThan(loginAt);
+ expect(validateAt).toBeLessThan(publishAt);
+ });
+
+ it("offers a dry run that stops short of publishing", () => {
+ const publishStep = steps.find((step) => step.run?.includes("mcp-publisher publish"));
+ expect(JSON.stringify(publishStep)).toContain("dry_run");
+ });
+});
diff --git a/test/unit/selfhost-pg-queue.test.ts b/test/unit/selfhost-pg-queue.test.ts
index aa7a7d8ffe..c92d1d7041 100644
--- a/test/unit/selfhost-pg-queue.test.ts
+++ b/test/unit/selfhost-pg-queue.test.ts
@@ -3853,15 +3853,20 @@ describe("createPgQueue (durable #977)", () => {
const windowMs = 15 * 60 * 1000;
const before = Date.now();
expect(await q.recentDeadCount(windowMs)).toBe(3);
+ const after = Date.now();
const calls = (m.fn as unknown as ReturnType).mock.calls;
const call = calls.find((c: unknown[]) => String(c[0]).includes("status='dead' AND dead_at IS NOT NULL"));
expect(call).toBeDefined();
const bound = call![1] as number[];
expect(bound).toEqual([expect.any(Number)]);
- // The bound cutoff is "now - windowMs", not a fixed constant -- sanity-check it's in the right ballpark.
- expect(bound[0]).toBeLessThanOrEqual(before - windowMs);
- expect(bound[0]).toBeGreaterThan(before - windowMs - 5_000);
+ // The bound cutoff is "now - windowMs", not a fixed constant -- so it must fall inside the window the
+ // call itself spanned. Bracketing on BOTH sides is what makes that assertion honest: the previous
+ // version compared only against `before`, and since the implementation reads the clock strictly after
+ // that line, a single millisecond ticking between the two failed the test. That is not hypothetical --
+ // it flaked CI on both attempts (1785302335173 <= 1785302335172).
+ expect(bound[0]).toBeGreaterThanOrEqual(before - windowMs);
+ expect(bound[0]).toBeLessThanOrEqual(after - windowMs);
});
it("stats() returns persisted queue metric counts", async () => {
diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts
index bd9a41722b..d91f6cd0dc 100644
--- a/test/unit/support/mcp-cli-harness.ts
+++ b/test/unit/support/mcp-cli-harness.ts
@@ -257,6 +257,35 @@ export async function startFixtureServer(
response.end(JSON.stringify({ version: options.latestVersion ?? "0.4.0" }));
return;
}
+ // #9526: the remote MCP endpoint the stdio gateway proxies tools/call to. Answers the JSON-RPC envelope
+ // a real server does, so a proxied call can be asserted end to end rather than only up to the request.
+ if (new URL(request.url ?? "/", "http://localhost").pathname === "/mcp") {
+ let raw = "";
+ request.on("data", (chunk) => {
+ raw += chunk;
+ });
+ request.on("end", () => {
+ const parsed = JSON.parse(raw || "{}") as { id?: unknown; params?: { name?: string; arguments?: unknown } };
+ // A tool named `*_resultless` gets an envelope with NO `result` member, so the gateway's
+ // "hand back the whole payload" fallback can be exercised against a real response rather than a
+ // hand-built object. Keyed on the name because one fixture serves every gateway test.
+ if (parsed.params?.name?.endsWith("_resultless")) {
+ response.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id ?? 1, note: "no result member" }));
+ return;
+ }
+ response.end(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: parsed.id ?? 1,
+ result: {
+ content: [{ type: "text", text: `remote answered ${parsed.params?.name ?? "unknown"}` }],
+ structuredContent: { calledTool: parsed.params?.name, echoedArguments: parsed.params?.arguments ?? null },
+ },
+ }),
+ );
+ });
+ return;
+ }
if (request.url === "/v1/mcp/compatibility") {
if (options.compatibilityStatus && options.compatibilityStatus >= 400) {
response.statusCode = options.compatibilityStatus;
diff --git a/vitest.config.ts b/vitest.config.ts
index 0a7f8d290e..fa983b972f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,7 +1,35 @@
+import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";
const junitPath = process.env.VITEST_JUNIT_PATH;
+/**
+ * The @loopover/contract source aliases, DERIVED from that package's own "exports" map (#9526).
+ *
+ * This list used to be typed out by hand, one line per subpath, and adding an export without also adding
+ * its alias produced a "Cannot find package" that names the subpath but not the reason -- a trap that costs
+ * whoever hits it a confused half hour. The exports map already enumerates every subpath, so read it.
+ *
+ * Longest specifier FIRST. Vite's string-`find` alias matcher treats a plain string as matching both the
+ * exact specifier and anything starting with `find + "/"`, first-match-wins in declaration order -- so with
+ * the bare "@loopover/contract" entry first it silently intercepted "@loopover/contract/tools" too and
+ * rewrote it to a "/tools" that resolved nowhere.
+ */
+function contractSourceAliases(): Record {
+ const manifest = JSON.parse(readFileSync(new URL("./packages/loopover-contract/package.json", import.meta.url).pathname, "utf8")) as {
+ exports: Record;
+ };
+ const entries: Array<[string, string]> = [];
+ for (const [subpath, target] of Object.entries(manifest.exports)) {
+ const dist = typeof target === "string" ? target : target.default;
+ // "./package.json" maps to a literal file, not a module; nothing to point at source.
+ if (!dist?.endsWith(".js")) continue;
+ const source = dist.replace(/^\.\/dist\//, "./packages/loopover-contract/src/").replace(/\.js$/, ".ts");
+ entries.push([`@loopover/contract${subpath.replace(/^\./, "")}`, new URL(source, import.meta.url).pathname]);
+ }
+ return Object.fromEntries(entries.sort(([a], [b]) => b.length - a.length));
+}
+
export default defineConfig({
ssr: {
noExternal: ["agents", "partyserver"],
@@ -21,19 +49,8 @@ export default defineConfig({
// for free from their relative-import resolution; this closes the same gap for a package
// that's actually installed as a workspace dependency.
//
- // "/tools" MUST be listed before the bare "@loopover/contract" entry: Vite's string-`find`
- // alias matcher treats a plain string as matching BOTH the exact specifier and anything
- // starting with `find + "/"`, first-match-wins in declaration order -- so with the bare entry
- // first, it silently intercepted "@loopover/contract/tools" too and rewrote it to a bogus
- // "/tools" that resolved nowhere. Confirmed by reproducing the failure with a
- // throwaway probe test before reordering, not assumed from reading Vite's docs alone.
- "@loopover/contract/enums": new URL("./packages/loopover-contract/src/enums.ts", import.meta.url).pathname,
- "@loopover/contract/tools": new URL("./packages/loopover-contract/src/tools/index.ts", import.meta.url).pathname,
- "@loopover/contract/cli-config": new URL("./packages/loopover-contract/src/cli-config.ts", import.meta.url).pathname,
- "@loopover/contract/orb-broker": new URL("./packages/loopover-contract/src/orb-broker.ts", import.meta.url).pathname,
- "@loopover/contract/api-schemas": new URL("./packages/loopover-contract/src/api-schemas.ts", import.meta.url).pathname,
- "@loopover/contract/public-api": new URL("./packages/loopover-contract/src/public-api.ts", import.meta.url).pathname,
- "@loopover/contract": new URL("./packages/loopover-contract/src/index.ts", import.meta.url).pathname,
+ // Ordering and derivation both live in contractSourceAliases() above.
+ ...contractSourceAliases(),
},
},
test: {