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
20 changes: 20 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"Return the repo's cached maintainer burden forecast (projected review load, queue-growth risk, and stale-PR signals) with a freshness marker, from the private LoopOver API.",
},
{
name: "loopover_get_repo_outcome_patterns",
category: "maintainer",
description:
"Return cached or freshly-computed per-repo accepted/rejected PR outcome patterns: what maintainers actually merge or close, separated from maintainer-lane activity, with a freshness marker and explicit evidence-completeness.",
},
{
name: "loopover_preview_local_pr_score",
category: "branch",
Expand Down Expand Up @@ -1893,6 +1899,20 @@ registerStdioTool(
},
);

// #6734: CLI stdio mirror of loopover_get_repo_outcome_patterns — thin GET proxy of the already-public
// /v1/repos/:owner/:repo/outcome-patterns route (same ownerRepoShape + apiGet pattern as maintainer_noise).
registerStdioTool(
"loopover_get_repo_outcome_patterns",
{
description: stdioToolDescription("loopover_get_repo_outcome_patterns"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver repo outcome patterns.", await apiGet(`${prefix}/outcome-patterns`));
},
);

registerStdioTool(
"loopover_preview_local_pr_score",
{
Expand Down
97 changes: 97 additions & 0 deletions test/unit/mcp-cli-repo-outcome-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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,
run,
startFixtureServer,
} from "./support/mcp-cli-harness";

// #6734: CLI stdio mirror of loopover_get_repo_outcome_patterns — thin GET proxy of the public
// /v1/repos/:owner/:repo/outcome-patterns route (same ownerRepoShape + apiGet pattern as maintainer_noise).
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(), "loopover-outcome-patterns-"));
capturedRequests = [];
apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/outcome-patterns")) {
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: "outcome-patterns-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_repo_outcome_patterns stdio proxy (#6734)", () => {
beforeEach(connect);
afterEach(disconnect);

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

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

it("lists the tool via loopover-mcp tools", () => {
const payload = JSON.parse(run(["tools", "--json"])) as {
tools: Array<{ name: string; description: string }>;
};
const tool = payload.tools.find(
(entry) => entry.name === "loopover_get_repo_outcome_patterns",
);
expect(tool?.description).toMatch(/outcome patterns/i);
expect(tool?.description.trim().length).toBeGreaterThan(0);
});
});
41 changes: 28 additions & 13 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@
// (#6753 registered the loopover_build_progress_snapshot CLI mirror, taking the count from 70 to 71.)
// (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.)
// (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.)
// (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.)
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, run, startFixtureServer } from "./support/mcp-cli-harness";
import {
closeFixtureServer,
run,
startFixtureServer,
} from "./support/mcp-cli-harness";

const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");

Expand Down Expand Up @@ -59,29 +64,36 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
const { tools } = await client.listTools();
for (const tool of tools) {
expect(tool.description ?? "", `${tool.name} description`).not.toMatch(/deprecated/i);
expect(tool.description ?? "", `${tool.name} description`).not.toMatch(
/deprecated/i,
);
}
});

it("`loopover-mcp tools --json` reports the same 74-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 75-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 }> };
const payload = JSON.parse(run(["tools", "--json"])) as {
count: number;
tools: Array<{ name: string }>;
};
expect(payload.count).toBe(tools.length);
expect(payload.count).toBe(74);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
expect(payload.count).toBe(75);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
});
});

Expand All @@ -105,8 +117,11 @@ describe("MCP legacy alias retirement (#4777) — old names no longer resolve",
"gittensory_local_status",
];

it.each(retiredNames)("calling the retired alias %s errors instead of falling through to the handler", async (oldName) => {
const result = await client.callTool({ name: oldName, arguments: {} });
expect(result.isError).toBe(true);
});
it.each(retiredNames)(
"calling the retired alias %s errors instead of falling through to the handler",
async (oldName) => {
const result = await client.callTool({ name: oldName, arguments: {} });
expect(result.isError).toBe(true);
},
);
});
20 changes: 20 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,26 @@ export async function startFixtureServer(
);
return;
}
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
response.end(
JSON.stringify({
status: "ready",
source: "snapshot",
repoFullName: "owner/repo",
generatedAt: "2026-06-01T00:00:00.000Z",
ageSeconds: 120,
freshness: "fresh",
patterns: {
repoFullName: "owner/repo",
generatedAt: "2026-06-01T00:00:00.000Z",
accepted: { count: 3, themes: ["tests"] },
rejected: { count: 1, themes: ["scope"] },
evidenceCompleteness: "partial",
},
}),
);
return;
}
if (request.url?.startsWith("/v1/repos/owner/repo/agent/pending-actions/") && request.method === "POST") {
const accepted = request.url.endsWith("/accept");
response.end(JSON.stringify(accepted ? { status: "accepted", executionOutcome: "completed" } : { status: "rejected" }));
Expand Down