Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ const ownerRepoShape = {
repo: z.string().min(1),
};

const skippedPrAuditShape = {
repoFullName: z.string().trim().min(1).max(200).optional(),
reason: z.string().trim().min(1).max(64).optional(),
since: z.string().trim().min(1).max(64).optional(),
limit: z.number().int().positive().optional(),
};

const loginShape = {
login: z.string().min(1),
};
Expand Down Expand Up @@ -579,6 +586,22 @@ const STDIO_TOOL_DESCRIPTORS = [
name: "loopover_feasibility_gate",
description: "Pure local go/raise/avoid feasibility verdict from claim status, duplicate-cluster risk, and issue quality/lifecycle status — the same discriminants the analyze-phase feasibility gate branches on. When repoFullName/issueNumber are supplied and a local loopover-miner install's claim ledger is present, claimStatus is read from that ledger instead of the caller-supplied value; otherwise falls back to the caller-supplied claimStatus unchanged. Advisory-only — never blocks, cancels, or overrides a claim or attempt; real claim-conflict resolution authority stays with the maintainer-only path. No API round-trip.",
},
{
name: "loopover_get_issue_quality",
description: "Return the cached or freshly-computed issue-quality report for a repo, ranking which open issues are actionable, need proof, are stale/duplicate-prone, or already solved.",
},
{
name: "loopover_get_registration_readiness",
description: "Preview-only registration-readiness report for a repository: what's missing/present before/after registering with LoopOver (direct-PR and issue-discovery lane readiness, label policy, maintainer-cut readiness, queue health, docs, and the GitHub App install state). Advisory only, not a registration action.",
},
{
name: "loopover_get_config_recommendation",
description: "Return recommended .loopover.yml additions for a repository, derived from the repo's live, currently-active configured behavior (the raw dashboard/API-configured settings, not a yml-merged view — so the recommendation never compares itself against an override that already exists). Advisory only, not a write action.",
},
{
name: "loopover_get_skipped_pr_audit",
description: "Return the skipped-PR audit trail: pull requests LoopOver's automated reviewer intentionally stayed quiet on, each with a reason code and a remediation hint. Optionally filter by repoFullName, reason, or since. Maintainer-authenticated; read-only measurement, not a moderation or override action.",
},
];

function stdioToolDescription(name) {
Expand Down Expand Up @@ -631,6 +654,59 @@ registerStdioTool(
},
);

registerStdioTool(
"loopover_get_issue_quality",
{
description: stdioToolDescription("loopover_get_issue_quality"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver issue-quality report.", await apiGet(`${prefix}/issue-quality`));
},
);

registerStdioTool(
"loopover_get_registration_readiness",
{
description: stdioToolDescription("loopover_get_registration_readiness"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver registration-readiness report.", await apiGet(`${prefix}/registration-readiness`));
},
);

registerStdioTool(
"loopover_get_config_recommendation",
{
description: stdioToolDescription("loopover_get_config_recommendation"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver config recommendation.", await apiGet(`${prefix}/gittensor-config-recommendation`));
},
);

registerStdioTool(
"loopover_get_skipped_pr_audit",
{
description: stdioToolDescription("loopover_get_skipped_pr_audit"),
inputSchema: skippedPrAuditShape,
},
async ({ repoFullName, reason, since, limit }) => {
const query = new URLSearchParams();
if (repoFullName) query.set("repoFullName", repoFullName);
if (reason) query.set("reason", reason);
if (since) query.set("since", since);
if (limit != null) query.set("limit", String(limit));
const qs = query.toString();
return toolResult("LoopOver skipped-PR audit trail.", await apiGet(`/v1/app/skipped-pr-audit${qs ? `?${qs}` : ""}`));
},
);

registerStdioTool(
"loopover_preflight_pr",
{
Expand Down
122 changes: 122 additions & 0 deletions test/unit/mcp-cli-intake-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness";

const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
// #6151: the maintainer-triage / repo-owner-intake profiles recommend these 4 tools; assert none leak
// miner-private reward internals through the local stdio proxy.
const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i;

let client: Client | null = null;
let transport: StdioClientTransport | null = null;
let configDir: string | null = null;
let capturedRequests: Array<{ url: string; method: string }>;

async function connect(options: { intakeStatus?: number } = {}) {
configDir = mkdtempSync(join(tmpdir(), "loopover-intake-tools-"));
capturedRequests = [];
const apiUrl = await startFixtureServer({
...options,
onApiRequest: (request) => {
const url = request.url ?? "";
if (/issue-quality|registration-readiness|gittensor-config-recommendation|skipped-pr-audit/.test(url)) {
capturedRequests.push({ url, method: request.method ?? "GET" });
}
},
});
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: {
...process.env,
LOOPOVER_CONFIG_DIR: configDir,
LOOPOVER_API_URL: apiUrl,
LOOPOVER_TOKEN: "session-token",
LOOPOVER_API_TIMEOUT_MS: "5000",
},
});
client = new Client({ name: "intake-tools-test", version: "0.0.1" });
await client.connect(transport);
}

afterEach(async () => {
await client?.close().catch(() => undefined);
client = null;
transport = null;
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
configDir = null;
});

const INTAKE_TOOLS = [
{
name: "loopover_get_issue_quality",
args: { owner: "owner", repo: "repo" },
endpoint: "/v1/repos/owner/repo/issue-quality",
contains: "actionability",
},
{
name: "loopover_get_registration_readiness",
args: { owner: "owner", repo: "repo" },
endpoint: "/v1/repos/owner/repo/registration-readiness",
contains: "directPrLaneReady",
},
{
name: "loopover_get_config_recommendation",
args: { owner: "owner", repo: "repo" },
endpoint: "/v1/repos/owner/repo/gittensor-config-recommendation",
contains: "privateOnly",
},
{
name: "loopover_get_skipped_pr_audit",
args: {},
endpoint: "/v1/app/skipped-pr-audit",
contains: "remediation",
},
] as const;

describe("loopover-mcp intake stdio proxies (#6151)", () => {
it("registers all 4 intake tools in the stdio server tool list", async () => {
await connect();
const { tools } = await client!.listTools();
const names = tools.map((tool) => tool.name);
for (const tool of INTAKE_TOOLS) expect(names).toContain(tool.name);
});

it("lists all 4 intake tools via `loopover-mcp tools --json` with non-empty descriptions", async () => {
await connect();
const payload = JSON.parse(run(["tools", "--json"])) as {
tools: Array<{ name: string; description: string }>;
};
for (const tool of INTAKE_TOOLS) {
const entry = payload.tools.find((t) => t.name === tool.name);
expect(entry, `missing descriptor for ${tool.name}`).toBeTruthy();
expect(entry!.description.trim().length).toBeGreaterThan(0);
}
});

for (const tool of INTAKE_TOOLS) {
it(`${tool.name} proxies to its REST endpoint and returns the payload`, async () => {
await connect();
const result = await client!.callTool({ name: tool.name, arguments: tool.args });
expect(result.isError).toBeFalsy();
expect(capturedRequests.length).toBe(1);
expect(capturedRequests[0]!.url).toContain(tool.endpoint);
expect(capturedRequests[0]!.method).toBe("GET");
const text = JSON.stringify(result);
expect(text).toContain(tool.contains);
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});

it(`${tool.name} surfaces an API failure as a tool error`, async () => {
await connect({ intakeStatus: 503 });
const result = await client!.callTool({ name: tool.name, arguments: tool.args });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/503/);
});
}
});
12 changes: 6 additions & 6 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// #4777: retire every gittensory_-prefixed deprecated alias that #4775 left in place for one
// minor-version deprecation cycle. This suite pins the post-retirement shape: exactly the 37
// minor-version deprecation cycle. This suite pins the post-retirement shape: exactly the 41
// canonical loopover_-prefixed stdio tools are registered, none of their old gittensory_-prefixed
// alias names resolve anymore, no description carries a stale deprecation notice, and the CLI's
// `tools --json` listing stays in lockstep with what the live server actually registers.
Expand Down Expand Up @@ -46,14 +46,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 37 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 41 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name);
const primary = names.filter((n) => n.startsWith("loopover_"));
const legacy = names.filter((n) => n.startsWith("gittensory_"));
expect(primary.length).toBe(37);
expect(primary.length).toBe(41);
expect(legacy.length).toBe(0);
expect(names.length).toBe(37);
expect(names.length).toBe(41);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -63,11 +63,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 37-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 41-tool count the live server registers", async () => {
const { tools } = await client.listTools();
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
expect(payload.count).toBe(tools.length);
expect(payload.count).toBe(37);
expect(payload.count).toBe(41);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});
Expand Down
37 changes: 37 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export async function startFixtureServer(
onPacketRequest?: (body: unknown) => void;
onApiRequest?: (request: IncomingMessage) => void;
validateConfigWarnings?: string[];
intakeStatus?: number;
} = {},
) {
server = createServer(async (request, response) => {
Expand Down Expand Up @@ -440,6 +441,42 @@ export async function startFixtureServer(
);
return;
}
if (request.url === "/v1/repos/owner/repo/issue-quality" && request.method === "GET") {
if (options.intakeStatus && options.intakeStatus >= 400) {
response.statusCode = options.intakeStatus;
response.end(JSON.stringify({ error: "issue_quality_unavailable" }));
return;
}
response.end(JSON.stringify({ repoFullName: "owner/repo", generatedAt: "2026-05-30T00:00:00.000Z", issues: [{ number: 12, actionability: "high" }] }));
return;
}
if (request.url === "/v1/repos/owner/repo/registration-readiness" && request.method === "GET") {
if (options.intakeStatus && options.intakeStatus >= 400) {
response.statusCode = options.intakeStatus;
response.end(JSON.stringify({ error: "registration_readiness_unavailable" }));
return;
}
response.end(JSON.stringify({ repoFullName: "owner/repo", registered: false, directPrLaneReady: true, appInstalled: false }));
return;
}
if (request.url === "/v1/repos/owner/repo/gittensor-config-recommendation" && request.method === "GET") {
if (options.intakeStatus && options.intakeStatus >= 400) {
response.statusCode = options.intakeStatus;
response.end(JSON.stringify({ error: "config_recommendation_unavailable" }));
return;
}
response.end(JSON.stringify({ repoFullName: "owner/repo", privateOnly: true, recommendations: [] }));
return;
}
if (request.url?.startsWith("/v1/app/skipped-pr-audit") && request.method === "GET") {
if (options.intakeStatus && options.intakeStatus >= 400) {
response.statusCode = options.intakeStatus;
response.end(JSON.stringify({ error: "skipped_pr_audit_unavailable" }));
return;
}
response.end(JSON.stringify({ generatedAt: "2026-05-30T00:00:00.000Z", limit: 20, hasMore: false, items: [{ prNumber: 7, reason: "duplicate", remediation: "link the canonical PR" }] }));
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: "not_found" }));
});
Expand Down