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
26 changes: 26 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"Return the repo's label-policy audit (configured-vs-live labels, missing configured labels, suspicious status/source-style labels, and trusted-label-pipeline readiness) from the private LoopOver API.",
},
{
name: "loopover_get_maintainer_lane",
category: "maintainer",
description:
"Return the repo's maintainer-lane triage report (the lane recommendation alongside the configured maintainer cut, queue health, config quality, and contributor-intake health) from the private LoopOver API. Advisory only.",
},
{
name: "loopover_get_burden_forecast",
category: "maintainer",
Expand Down Expand Up @@ -1764,6 +1770,26 @@ registerStdioTool(
},
);

// #6739: CLI mirror of the remote server's loopover_get_maintainer_lane. maintainerLane ships in the same
// buildRepoIntelligenceResponse payload the sibling loopover_get_label_audit already GETs, so this is a thin
// extraction over that identical route rather than a new fetch shape.
registerStdioTool(
"loopover_get_maintainer_lane",
{
description: stdioToolDescription("loopover_get_maintainer_lane"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const intelligence = await apiGet(`${prefix}/intelligence`);
return toolResult("LoopOver maintainer lane.", {
repoFullName: intelligence?.repoFullName ?? `${owner}/${repo}`,
generatedAt: intelligence?.generatedAt,
maintainerLane: intelligence?.maintainerLane ?? null,
});
},
);

registerStdioTool(
"loopover_get_burden_forecast",
{
Expand Down
91 changes: 91 additions & 0 deletions test/unit/mcp-cli-maintainer-lane.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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, beforeEach, describe, expect, it } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
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;
let transport: StdioClientTransport;
let configDir: string;
let apiUrl: string;
let capturedRequests: Array<{ url: string; method: string }>;

async function connect() {
configDir = mkdtempSync(join(tmpdir(), "gittensory-maintainer-lane-"));
capturedRequests = [];
apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/intelligence")) {
capturedRequests.push({ url: request.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: "maintainer-lane-test", version: "0.0.1" });
await client.connect(transport);
}

async function disconnect() {
await client.close().catch(() => undefined);
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
}

describe("loopover_get_maintainer_lane stdio proxy", () => {
beforeEach(connect);
afterEach(disconnect);

it("registers the tool in the stdio server tool list", async () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).toContain("loopover_get_maintainer_lane");
});

it("proxies owner/repo to /v1/repos/:owner/:repo/intelligence via apiGet and returns the maintainer lane", async () => {
const result = await client.callTool({ name: "loopover_get_maintainer_lane", arguments: { owner: "owner", repo: "repo" } });
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/intelligence");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
expect(text).toContain("maintainerLane");
expect(text).toContain("owner/repo");
});

it("hits the SAME /intelligence route its sibling loopover_get_label_audit uses, extracting only its own field", async () => {
// maintainerLane sits beside labelAudit in one buildRepoIntelligenceResponse payload; both tools are thin
// extractions over the identical GET, so the mirrored surfaces must agree on the route and repo identity.
const lane = await client.callTool({ name: "loopover_get_maintainer_lane", arguments: { owner: "owner", repo: "repo" } });
const audit = await client.callTool({ name: "loopover_get_label_audit", arguments: { owner: "owner", repo: "repo" } });

expect(capturedRequests.map((request) => request.url)).toEqual([
expect.stringContaining("/v1/repos/owner/repo/intelligence"),
expect.stringContaining("/v1/repos/owner/repo/intelligence"),
]);
const laneContent = lane.structuredContent as Record<string, unknown> | undefined;
const auditContent = audit.structuredContent as Record<string, unknown> | undefined;
expect(laneContent?.repoFullName).toEqual(auditContent?.repoFullName);
expect(laneContent?.generatedAt).toEqual(auditContent?.generatedAt);
// Each tool surfaces only its own slice of the shared response.
expect(laneContent).toHaveProperty("maintainerLane");
expect(laneContent).not.toHaveProperty("labelAudit");
expect(auditContent).toHaveProperty("labelAudit");
expect(auditContent).not.toHaveProperty("maintainerLane");
});
});
10 changes: 5 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 70 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 71 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(70);
expect(primary.length).toBe(71);
expect(legacy.length).toBe(0);
expect(names.length).toBe(70);
expect(names.length).toBe(71);
});

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

it("`loopover-mcp tools --json` reports the same 70-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 71-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(70);
expect(payload.count).toBe(71);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});
Expand Down