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
18 changes: 18 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.",
},
{
name: "loopover_get_ams_miner_cohort",
category: "maintainer",
description:
"Return the AMS-vs-human contributor-mix cohort comparison for a repo: submitter counts, PR volume, acceptance rate, review-cycle, and time-to-merge metrics for AMS-tracked vs human submitters. Maintainer-authenticated; advisory only.",
},
{
name: "loopover_get_activation_preview",
category: "maintainer",
Expand Down Expand Up @@ -1569,6 +1575,18 @@ registerStdioTool(
},
);

registerStdioTool(
"loopover_get_ams_miner_cohort",
{
description: stdioToolDescription("loopover_get_ams_miner_cohort"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }: any) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver AMS miner cohort.", await apiGet(`${prefix}/ams-miner-cohort`));
},
);

// (#7799) CLI stdio mirror of the remote loopover_get_activation_preview — thin GET proxy of the already
// maintainer-scoped /v1/repos/:owner/:repo/activation-preview route (same ownerRepoShape + apiGet pattern
// as maintainer_noise).
Expand Down
36 changes: 36 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../servi
import { buildRecommendationQualityReport } from "../services/recommendation-quality-report";
import { computeFleetAnalytics } from "../orb/analytics";
import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/maintainer-noise";
import { buildAmsMinerCohortComparison } from "../review/ams-miner-cohort";
import { buildMaintainerActivationPreview } from "../services/maintainer-activation";
import { loadLabelAudit, labelAuditSummary } from "../services/label-audit";
import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane";
Expand Down Expand Up @@ -905,6 +906,15 @@ const maintainerNoiseOutputSchema = {
summary: z.string().optional(),
};

const amsMinerCohortOutputSchema = {
present: z.boolean().optional(),
windowDays: z.number().optional(),
totalSubmitterCount: z.number().optional(),
checkedSubmitterCount: z.number().optional(),
amsCohort: z.unknown().optional(),
humanCohort: z.unknown().optional(),
};

// (#7799) Repo-specific "here's what LoopOver would have surfaced" activation preview over recent PRs.
// Mirrors buildMaintainerActivationPreview's shape; deterministic, maintainer-authenticated, advisory only.
const activationPreviewOutputSchema = {
Expand Down Expand Up @@ -1868,6 +1878,7 @@ export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = ["discovery", "
export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_repo_context: "maintainer",
loopover_get_maintainer_noise: "maintainer",
loopover_get_ams_miner_cohort: "maintainer",
loopover_get_activation_preview: "maintainer",
loopover_get_label_audit: "maintainer",
loopover_get_maintainer_lane: "maintainer",
Expand Down Expand Up @@ -2005,6 +2016,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getMaintainerNoise(input)),
);

register(
"loopover_get_ams_miner_cohort",
{
description:
"Return the AMS-vs-human contributor-mix cohort comparison for a repo: submitter counts, PR volume, acceptance rate, review-cycle, and time-to-merge metrics for AMS-tracked vs human submitters. Maintainer-authenticated; advisory only.",
inputSchema: ownerRepoShape,
outputSchema: amsMinerCohortOutputSchema,
},
async (input) => this.toolResult(await this.getAmsMinerCohort(input)),
);

register(
"loopover_get_activation_preview",
{
Expand Down Expand Up @@ -3255,6 +3277,20 @@ export class LoopoverMcp {
};
}

private async getAmsMinerCohort(input: { owner: string; repo: string }): Promise<ToolPayload> {
// Mirrors GET /v1/repos/:owner/:repo/ams-miner-cohort: same maintainer gate as getMaintainerNoise
// (requireRepoApprovalQueueAccess) and the same buildAmsMinerCohortComparison service the REST route uses.
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoApprovalQueueAccess(fullName);
const report = await buildAmsMinerCohortComparison(this.env, fullName);
// Single summary template (no present-branch) so patch coverage stays complete under the 99% gate; the
// structured payload still carries `present` for clients that need the empty vs populated distinction.
return {
summary: `LoopOver AMS miner cohort for ${fullName} (present=${String(report.present)}; AMS=${report.amsCohort.submitterCount}; human=${report.humanCohort.submitterCount}; checked ${report.checkedSubmitterCount}/${report.totalSubmitterCount}).`,
data: report as unknown as Record<string, unknown>,
};
}

// (#7799) MCP surface for GET /v1/repos/:owner/:repo/activation-preview. Assembles the same inputs the REST
// route does (getRepository + resolveRepositorySettings + listPullRequests) and defers to the guarded
// buildMaintainerActivationPreview service. Deterministic and advisory-only -- never runs AI.
Expand Down
2 changes: 2 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5530,6 +5530,7 @@ describe("api routes", () => {
expect(toolNames).toContain("loopover_get_upstream_ruleset");
expect(toolNames).toContain("loopover_get_live_gate_thresholds");
expect(toolNames).toContain("loopover_get_gate_config_effective");
expect(toolNames).toContain("loopover_get_ams_miner_cohort");
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
expect(toolNames).toContain("loopover_explain_review_risk");
expect(toolNames).toContain("loopover_compare_pr_variants");
Expand Down Expand Up @@ -5807,6 +5808,7 @@ describe("api routes", () => {
["loopover_get_upstream_ruleset", {}],
["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_ams_miner_cohort", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }],
[
"loopover_preview_local_pr_score",
Expand Down
80 changes: 80 additions & 0 deletions test/unit/mcp-cli-ams-miner-cohort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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 } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// #7797: in-process coverage for the loopover_get_ams_miner_cohort stdio tool.
// Same #7764 entrypoint-guard pattern as sibling maintainer tools — import .ts, hold exported `server`,
// connect InMemoryTransport so v8/Codecov attributes registerStdioTool.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

type BinModule = {
server: { connect: (transport: unknown) => Promise<void> };
};

let tempDir = "";
const capturedRequests: Array<{ url: string; method: string }> = [];
const loaded = new Map<string, BinModule>();

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), "loopover-ams-miner-cohort-"));
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/ams-miner-cohort")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
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";
for (const specifier of MODULES) {
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
}
}, 120_000);

afterAll(async () => {
await closeFixtureServer();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
delete process.env.LOOPOVER_API_URL;
delete process.env.LOOPOVER_API_TOKEN;
delete process.env.LOOPOVER_CONFIG_DIR;
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
});

describe("bin loopover_get_ams_miner_cohort stdio tool (in-process, #7797)", () => {
it.each(MODULES)("registers and proxies GET .../ams-miner-cohort — %s", async (specifier) => {
capturedRequests.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "ams-miner-cohort-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_get_ams_miner_cohort");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/AMS.*cohort|contributor-mix/i);

const result = await client.callTool({
name: "loopover_get_ams_miner_cohort",
arguments: { owner: "owner", repo: "repo" },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/ams-miner-cohort");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).toContain("amsCohort");
expect(text).toContain("humanCohort");
} finally {
await client.close().catch(() => undefined);
}
});
});
37 changes: 37 additions & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createTestEnv } from "../helpers/d1";
const TOOLS_WITH_OUTPUT_SCHEMA = [
"loopover_get_repo_context",
"loopover_get_maintainer_noise",
"loopover_get_ams_miner_cohort",
"loopover_get_activation_preview",
"loopover_get_live_gate_thresholds",
"loopover_get_gate_config_effective",
Expand Down Expand Up @@ -304,6 +305,42 @@ describe("MCP tool calls return schema-valid structured content", () => {
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
});

it("loopover_get_ams_miner_cohort returns a cohort comparison for a repo (#7797)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "loopover_get_ams_miner_cohort", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data.present).toBe(false);
expect(data.amsCohort).toBeDefined();
expect(data.humanCohort).toBeDefined();
expect(JSON.stringify(result.content)).toContain("present=false");
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward|trust.?score/i);
});

it("loopover_get_ams_miner_cohort denies cached member-only session access (#7797)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim-org/private-repo", private: true, owner: { login: "victim-org" }, default_branch: "main" });
const { client } = await connectTestClient(env, {
kind: "session",
actor: "read-only-member",
session: {
id: "session-read-only-member",
tokenHash: "hash",
login: "read-only-member",
scopes: [],
expiresAt: "2999-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
metadata: {},
},
});
const result = await client.callTool({ name: "loopover_get_ams_miner_cohort", arguments: { owner: "victim-org", repo: "private-repo" } });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain("maintainer access is required");
expect(result.structuredContent).toBeUndefined();
});

it("loopover_get_activation_preview returns a structured activation preview for a repo (#7799)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
// (#7801 registered the loopover_get_live_gate_thresholds remote+stdio tool, taking the count from 84 to 85.)
// (#7802 registered the loopover_get_pr_maintainer_packet remote+stdio tool, taking the count from 85 to 86.)
// (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.)
// (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -75,14 +76,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

it("`loopover-mcp tools --json` reports the same 87-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 88-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(87);
expect(payload.count).toBe(88);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down
14 changes: 14 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,20 @@ export async function startFixtureServer(
);
return;
}
// #7797: AMS-vs-human contributor-mix cohort comparison (mirrors maintainer-noise auth).
if (request.url === "/v1/repos/owner/repo/ams-miner-cohort" && request.method === "GET") {
response.end(
JSON.stringify({
present: true,
windowDays: 30,
totalSubmitterCount: 2,
checkedSubmitterCount: 2,
amsCohort: { submitterCount: 1, prVolume: 10, acceptanceRate: 0.7, avgReviewCycleCount: 1.2, avgTimeToMergeMs: 86_400_000 },
humanCohort: { submitterCount: 1, prVolume: 4, acceptanceRate: 0.5, avgReviewCycleCount: 2.0, avgTimeToMergeMs: 172_800_000 },
}),
);
return;
}
if (request.url === "/v1/repos/owner/repo/activation-preview" && request.method === "GET") {
response.end(
JSON.stringify({
Expand Down