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 @@ -970,6 +970,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "review",
description: "Return the reviewability report for an open PR: how ready it is to review/merge, the blocking or advisory signals against it, and its lane/duplicate/linked-issue context. Metadata-only, no GitHub writes.",
},
{
name: "loopover_get_pr_maintainer_packet",
category: "review",
description:
"Return the full maintainer packet for an open PR: triage context assembled from cached repo/PR/issue/review/check metadata, wrapped with data-quality. Metadata-only; takes owner, repo, and pull number.",
},
{
name: "loopover_get_pr_ai_review_findings",
category: "review",
Expand Down Expand Up @@ -1513,6 +1519,18 @@ registerStdioTool(
},
);

registerStdioTool(
"loopover_get_pr_maintainer_packet",
{
description: stdioToolDescription("loopover_get_pr_maintainer_packet"),
inputSchema: ownerRepoPullShape,
},
async ({ owner, repo, number }: any) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver PR maintainer packet.", await apiGet(`${prefix}/pulls/${number}/maintainer-packet`));
},
);

// #6619: CLI mirror of the remote server's loopover_get_pr_ai_review_findings. The route is the single source
// of truth (it delegates to the same loadPrAiReviewFindings the MCP server uses); this tool only resolves the
// author login and proxies. Self-scoped: the route's requireContributorAccess rejects another login's PR.
Expand Down
57 changes: 56 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import {
buildPreflightResult,
buildPreStartCheck,
buildPrTextLint,
buildPullRequestMaintainerPacket,
buildQueueHealth,
buildRegistryChangeReport,
} from "../signals/engine";
Expand Down Expand Up @@ -181,7 +182,7 @@ import { buildProgressSnapshot } from "../loop-progress";
import { evaluateEscalation } from "../loop-escalation";
import { buildStructuralImprovementAssessment } from "../signals/improvement";
import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation";
import { buildRepoDataQuality } from "../signals/data-quality";
import { attachDataQuality, buildRepoDataQuality } from "../signals/data-quality";
import { PREFLIGHT_LIMITS } from "../signals/preflight-limits";
import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model";
import { loadUpstreamStatus } from "../upstream/ruleset";
Expand Down Expand Up @@ -1903,6 +1904,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_upstream_ruleset: "utility",
loopover_get_issue_quality: "maintainer",
loopover_get_pr_reviewability: "review",
loopover_get_pr_maintainer_packet: "review",
loopover_get_live_gate_thresholds: "maintainer",
loopover_validate_linked_issue: "discovery",
loopover_check_before_start: "discovery",
Expand Down Expand Up @@ -2455,6 +2457,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getPrReviewability(input)),
);

register(
"loopover_get_pr_maintainer_packet",
{
description:
"Return the full maintainer packet for an open PR: triage context assembled from cached repo/PR/issue/review/check metadata, wrapped with data-quality. Metadata-only, repo-scoped, no GitHub writes.",
inputSchema: ownerRepoPullShape,
outputSchema: freshnessResponseOutputSchema,
},
async (input) => this.toolResult(await this.getPrMaintainerPacket(input)),
);

register(
"loopover_get_live_gate_thresholds",
{
Expand Down Expand Up @@ -3416,6 +3429,48 @@ export class LoopoverMcp {
};
}

private async getPrMaintainerPacket(input: { owner: string; repo: string; number: number }): Promise<ToolPayload> {
// Mirrors GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet: same data-assembly path as the REST
// route (buildPullRequestMaintainerPacket → attachDataQuality), with the reviewability-style mcp allowlist
// gate so the shared static mcp token stays repo-scoped.
const fullName = `${input.owner}/${input.repo}`;
if (!(await this.canAccessRepo(fullName))) {
return {
summary: `Forbidden: session cannot access PR maintainer packet for ${fullName}.`,
data: { status: "forbidden", repoFullName: fullName },
};
}
const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([
getRepository(this.env, fullName),
getPullRequest(this.env, fullName, input.number),
listIssues(this.env, fullName),
listPullRequests(this.env, fullName),
listPullRequestFiles(this.env, fullName, input.number),
listPullRequestReviews(this.env, fullName, input.number),
listCheckSummaries(this.env, fullName, input.number),
listRecentMergedPullRequests(this.env, fullName),
]);
const packet = attachDataQuality(
buildPullRequestMaintainerPacket({
repo,
pullRequest,
issues,
pullRequests,
files,
reviews,
checks,
recentMergedPullRequests,
repoFullName: fullName,
pullNumber: input.number,
}) as unknown as Record<string, unknown>,
await this.loadRepoDataQuality(fullName),
);
return {
summary: `LoopOver PR maintainer packet for ${fullName}#${input.number}.`,
data: packet as unknown as Record<string, unknown>,
};
}

private async getLiveGateThresholds(input: { owner: string; repo: string }): Promise<ToolPayload> {
// Mirrors GET /v1/repos/:owner/:repo/live-gate-thresholds: same mcp allowlist gate as reviewability,
// same authoritative live/shadow projection, and a normal not-found result (never throw) when neither
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 @@ -5529,6 +5529,7 @@ describe("api routes", () => {
expect(toolNames).toContain("loopover_get_upstream_drift");
expect(toolNames).toContain("loopover_get_upstream_ruleset");
expect(toolNames).toContain("loopover_get_live_gate_thresholds");
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
expect(toolNames).toContain("loopover_explain_review_risk");
expect(toolNames).toContain("loopover_compare_pr_variants");
expect(toolNames).toContain("loopover_local_status");
Expand Down Expand Up @@ -5804,6 +5805,7 @@ describe("api routes", () => {
["loopover_get_upstream_drift", {}],
["loopover_get_upstream_ruleset", {}],
["loopover_get_live_gate_thresholds", { 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-pr-maintainer-packet.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";

// #7802: in-process coverage for the loopover_get_pr_maintainer_packet stdio tool.
// Same #7764 entrypoint-guard pattern as mcp-cli-live-gate-thresholds — import .ts, hold exported `server`,
// connect InMemoryTransport so v8/Codecov attributes registerStdioTool (subprocess spawn alone does not).
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-maintainer-packet-"));
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/maintainer-packet")) {
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_pr_maintainer_packet stdio tool (in-process, #7802)", () => {
it.each(MODULES)("registers and proxies GET .../maintainer-packet — %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: "maintainer-packet-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_pr_maintainer_packet");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/maintainer packet/i);

const result = await client.callTool({
name: "loopover_get_pr_maintainer_packet",
arguments: { owner: "owner", repo: "repo", number: 7 },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/pulls/7/maintainer-packet");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).toContain("owner/repo");
expect(text).toContain("maintainer packet");
} finally {
await client.close().catch(() => undefined);
}
});
});
61 changes: 61 additions & 0 deletions test/unit/mcp-pr-maintainer-packet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { LoopoverMcp } from "../../src/mcp/server";
import { upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories";
import { createTestEnv } from "../helpers/d1";

async function connect(env: Env) {
const server = new LoopoverMcp(env).createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "loopover-maintainer-packet-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return client;
}

function prPayload(overrides: Record<string, unknown> = {}) {
return {
number: 7,
title: "Add retry to the upload client",
state: "open",
user: { login: "contributor" },
author_association: "CONTRIBUTOR",
head: { sha: "abc123", ref: "contributor/attempt-1" },
base: { ref: "main" },
html_url: "https://github.com/owner/repo/pull/7",
merged_at: null,
draft: false,
mergeable: true,
body: "Closes #1",
created_at: "2026-07-03T00:00:00Z",
updated_at: "2026-07-03T00:00:00Z",
closed_at: null,
labels: [{ name: "enhancement" }],
...overrides,
};
}

describe("MCP loopover_get_pr_maintainer_packet (#7802)", () => {
it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => {
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
const client = await connect(env);
const result = await client.callTool({ name: "loopover_get_pr_maintainer_packet", arguments: { owner: "owner", repo: "repo", number: 7 } });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "owner/repo" });
});

it("returns the maintainer packet assembled from cached metadata", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" });
await upsertPullRequestFromGitHub(env, "owner/repo", prPayload());
const client = await connect(env);
const result = await client.callTool({ name: "loopover_get_pr_maintainer_packet", arguments: { owner: "owner", repo: "repo", number: 7 } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data.repoFullName).toBe("owner/repo");
expect(data.pullNumber).toBe(7);
expect(data.dataQuality).toBeDefined();
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
});
});
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 @@ -27,6 +27,7 @@
// (#7803 registered the loopover_get_registry_snapshot remote+stdio tool, taking the count from 82 to 83.)
// (#7807 registered the loopover_get_upstream_ruleset remote+stdio tool, taking the count from 83 to 84.)
// (#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.)
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 @@ -73,14 +74,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

it("`loopover-mcp tools --json` reports the same 85-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 86-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(85);
expect(payload.count).toBe(86);
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 @@ -921,6 +921,20 @@ export async function startFixtureServer(
);
return;
}
// #7802: maintainer packet sibling of reviewability.
if (request.url === "/v1/repos/owner/repo/pulls/7/maintainer-packet" && request.method === "GET") {
response.end(
JSON.stringify({
repoFullName: "owner/repo",
pullNumber: 7,
generatedAt: "2026-05-30T00:00:00.000Z",
summary: "PR 7 maintainer packet.",
actions: ["review_now"],
dataQuality: { status: "ok" },
}),
);
return;
}
// #6619: the route carries the author login as a query param, so match on the path prefix.
if (request.url?.startsWith("/v1/repos/owner/repo/pulls/7/ai-review-findings") && request.method === "GET") {
response.end(
Expand Down