Skip to content

Commit bb028ec

Browse files
authored
feat(mcp): REST route + CLI mirror for loopover_get_eligibility_plan (#6692)
loopover_get_eligibility_plan existed only on the remote MCP server — the one tool in the preview/breakdown/eligibility trio with no REST route or local CLI mirror. This adds both, following loopover_explain_score_breakdown's established pair exactly. - POST /v1/scoring/eligibility-plan (src/api/routes.ts), placed after explain-breakdown and structured identically: same scorePreviewSchema, same repo/snapshot/evidence fetch, same buildScorePreview. It returns deriveEligibilityPlan(preview) (reused as-is from services/eligibility-plan) and — like /v1/scoring/preview, and matching the tool's own handler — treats contributorLogin as optional rather than unconditionally required. - loopover_get_eligibility_plan stdio tool + a STDIO_TOOL_DESCRIPTORS entry (category discovery, matching the server's MCP_TOOL_CATEGORIES). The local branch-metadata-to-request-body assembly it shares with loopover_explain_score_breakdown is factored into buildLocalScoreRequestBody so the two never drift; only the apiPost path differs. Tests: route-level coverage for an authorized plan (contributorLogin present), the anonymous/optional-login path, an invalid body (400), and the contributor-gate 403 (routes-errors.test.ts, mirroring victimScorePreview). CLI registration + descriptor + category are covered by the existing mcp-cli-tools stdio-server tests. Closes #6621
1 parent 2d493cd commit bb028ec

5 files changed

Lines changed: 136 additions & 39 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,11 @@ const STDIO_TOOL_DESCRIPTORS = [
901901
category: "review",
902902
description: "Explain a private score preview multiplier-by-multiplier with plain-English levers and the highest-impact improvement.",
903903
},
904+
{
905+
name: "loopover_get_eligibility_plan",
906+
category: "discovery",
907+
description: "Derive a structured eligibility plan from local score-preview metadata: whether the branch/PR is eligible now, public-safe blockers, and cleanup paths. Advisory dry-run only — no GitHub writes.",
908+
},
904909
{
905910
name: "loopover_get_decision_pack",
906911
category: "discovery",
@@ -1542,6 +1547,46 @@ registerStdioTool(
15421547
async (input) => toolResult("LoopOver private local PR scoring preview.", await previewLocalScore(await withClientWorkspaceRoots(input))),
15431548
);
15441549

1550+
// Shared by loopover_explain_score_breakdown and loopover_get_eligibility_plan (#6621): both resolve the same
1551+
// local branch/diff metadata into the /v1/scoring request body — only the endpoint they POST it to differs, so
1552+
// the assembly lives here once rather than in two drifting copies.
1553+
function buildLocalScoreRequestBody(workspaceInput, contributorLogin) {
1554+
const workspace = resolveWorkspaceCwd(workspaceInput);
1555+
const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots);
1556+
const branchPayload = buildBranchAnalysisPayload({
1557+
...workspaceInput,
1558+
login: contributorLogin,
1559+
cwd: workspace.cwd,
1560+
repoFullName: workspaceInput.repoFullName,
1561+
baseRef: workspaceInput.baseRef,
1562+
});
1563+
const upstreamPreview = branchPayload.localScorerStatus;
1564+
const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length);
1565+
return {
1566+
repoFullName: workspaceInput.repoFullName,
1567+
targetType: "local_diff",
1568+
targetKey: workspaceInput.targetKey ?? localDiffTargetKey(branchPayload, workspaceInput.baseRef),
1569+
contributorLogin,
1570+
labels: workspaceInput.labels,
1571+
linkedIssueMode: workspaceInput.linkedIssueMode,
1572+
sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines,
1573+
sourceLines: estimatedSourceLines,
1574+
totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount,
1575+
testTokenScore: diff.testFiles.length,
1576+
openPrCount: workspaceInput.openPrCount,
1577+
credibility: workspaceInput.credibility,
1578+
changesRequestedCount: workspaceInput.changesRequestedCount,
1579+
pendingMergedPrCount: workspaceInput.pendingMergedPrCount,
1580+
pendingClosedPrCount: workspaceInput.pendingClosedPrCount,
1581+
approvedPrCount: workspaceInput.approvedPrCount,
1582+
expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge,
1583+
projectedCredibility: workspaceInput.projectedCredibility,
1584+
scenarioNotes: workspaceInput.scenarioNotes,
1585+
branchEligibility: workspaceInput.branchEligibility,
1586+
metadataOnly: !upstreamPreview.ok,
1587+
};
1588+
}
1589+
15451590
registerStdioTool(
15461591
"loopover_explain_score_breakdown",
15471592
{
@@ -1552,44 +1597,26 @@ registerStdioTool(
15521597
const workspaceInput = await withClientWorkspaceRoots(input);
15531598
const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login;
15541599
if (!contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
1555-
const workspace = resolveWorkspaceCwd(workspaceInput);
1556-
const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots);
1557-
const branchPayload = buildBranchAnalysisPayload({
1558-
...workspaceInput,
1559-
login: contributorLogin,
1560-
cwd: workspace.cwd,
1561-
repoFullName: workspaceInput.repoFullName,
1562-
baseRef: workspaceInput.baseRef,
1563-
});
1564-
const upstreamPreview = branchPayload.localScorerStatus;
1565-
const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length);
1566-
const body = {
1567-
repoFullName: workspaceInput.repoFullName,
1568-
targetType: "local_diff",
1569-
targetKey: workspaceInput.targetKey ?? localDiffTargetKey(branchPayload, workspaceInput.baseRef),
1570-
contributorLogin,
1571-
labels: workspaceInput.labels,
1572-
linkedIssueMode: workspaceInput.linkedIssueMode,
1573-
sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines,
1574-
sourceLines: estimatedSourceLines,
1575-
totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount,
1576-
testTokenScore: diff.testFiles.length,
1577-
openPrCount: workspaceInput.openPrCount,
1578-
credibility: workspaceInput.credibility,
1579-
changesRequestedCount: workspaceInput.changesRequestedCount,
1580-
pendingMergedPrCount: workspaceInput.pendingMergedPrCount,
1581-
pendingClosedPrCount: workspaceInput.pendingClosedPrCount,
1582-
approvedPrCount: workspaceInput.approvedPrCount,
1583-
expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge,
1584-
projectedCredibility: workspaceInput.projectedCredibility,
1585-
scenarioNotes: workspaceInput.scenarioNotes,
1586-
branchEligibility: workspaceInput.branchEligibility,
1587-
metadataOnly: !upstreamPreview.ok,
1588-
};
1600+
const body = buildLocalScoreRequestBody(workspaceInput, contributorLogin);
15891601
return toolResult("LoopOver private score breakdown.", await apiPost("/v1/scoring/explain-breakdown", body));
15901602
},
15911603
);
15921604

1605+
registerStdioTool(
1606+
"loopover_get_eligibility_plan",
1607+
{
1608+
description: stdioToolDescription("loopover_get_eligibility_plan"),
1609+
inputSchema: localScoreShape,
1610+
},
1611+
async (input) => {
1612+
const workspaceInput = await withClientWorkspaceRoots(input);
1613+
const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login;
1614+
if (!contributorLogin) throw new Error("contributorLogin is required for the eligibility plan.");
1615+
const body = buildLocalScoreRequestBody(workspaceInput, contributorLogin);
1616+
return toolResult("LoopOver private eligibility plan.", await apiPost("/v1/scoring/eligibility-plan", body));
1617+
},
1618+
);
1619+
15931620
registerStdioTool(
15941621
"loopover_get_decision_pack",
15951622
{

src/api/routes.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ import { buildRemediationPlan } from "../services/remediation-plan";
169169
import { handleDraftCreate, handleDraftOAuthCallback, handleDraftStatus } from "../services/draft";
170170
import { decidePendingAgentAction } from "../services/agent-approval-queue";
171171
import { explainScoreBreakdown } from "../services/score-breakdown";
172+
import { deriveEligibilityPlan } from "../services/eligibility-plan";
172173
import { buildMcpClientTelemetry } from "../services/client-telemetry";
173174
import {
174175
authoritativeContributorRepoStats,
@@ -2115,6 +2116,29 @@ export function createApp() {
21152116
return c.json(explainScoreBreakdown(preview));
21162117
});
21172118

2119+
app.post("/v1/scoring/eligibility-plan", async (c) => {
2120+
const body = await c.req.json().catch(() => null);
2121+
const parsed = scorePreviewSchema.safeParse(body);
2122+
if (!parsed.success) return c.json({ error: "invalid_scoring_preview_request", issues: parsed.error.issues }, 400);
2123+
// Like /v1/scoring/preview (and loopover_get_eligibility_plan's own MCP handler), the contributor gate is
2124+
// conditional on contributorLogin being supplied — not unconditionally required as in explain-breakdown.
2125+
if (parsed.data.contributorLogin) {
2126+
const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin);
2127+
if (unauthorized) return unauthorized;
2128+
}
2129+
const [repo, snapshot, evidence, contributorIssues] = await Promise.all([
2130+
getRepository(c.env, parsed.data.repoFullName),
2131+
getOrCreateScoringModelSnapshot(c.env),
2132+
parsed.data.contributorLogin ? getContributorEvidence(c.env, parsed.data.contributorLogin) : Promise.resolve(null),
2133+
parsed.data.contributorLogin ? listContributorIssues(c.env, parsed.data.contributorLogin) : Promise.resolve([]),
2134+
]);
2135+
const openIssueCount = contributorOpenIssueCount(contributorIssues, parsed.data.repoFullName);
2136+
// Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable).
2137+
const input = { ...parsed.data, openIssueCount, applyTimeDecay: isTimeDecayEnabled(c.env) };
2138+
const preview = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence });
2139+
return c.json(deriveEligibilityPlan(preview));
2140+
});
2141+
21182142
app.get("/v1/sync/status", async (c) => {
21192143
const [snapshot, scoringSnapshot, repositories, segments, totals, detailStates, installations, rateLimits, signalSnapshots, bounties, upstreamDrift] = await Promise.all([
21202144
getLatestRegistrySnapshot(c.env),

test/integration/api.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,6 +2012,37 @@ describe("api routes", () => {
20122012
expect(missingContributorBreakdown.status).toBe(400);
20132013
await expect(missingContributorBreakdown.json()).resolves.toMatchObject({ error: "contributor_login_required" });
20142014

2015+
// #6621: /v1/scoring/eligibility-plan reuses the same fetch/build as explain-breakdown but returns a
2016+
// deriveEligibilityPlan verdict, and — like /v1/scoring/preview — treats contributorLogin as optional.
2017+
const eligibilityPlan = await app.request(
2018+
"/v1/scoring/eligibility-plan",
2019+
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify(agedScoreInput) },
2020+
env,
2021+
);
2022+
expect(eligibilityPlan.status).toBe(200);
2023+
const eligibilityPlanBody = (await eligibilityPlan.json()) as {
2024+
eligible: boolean;
2025+
branchEligibilityStatus: string;
2026+
blockers: string[];
2027+
cleanupPaths: string[];
2028+
};
2029+
expect(eligibilityPlanBody).toMatchObject({
2030+
eligible: expect.any(Boolean),
2031+
branchEligibilityStatus: expect.any(String),
2032+
blockers: expect.any(Array),
2033+
cleanupPaths: expect.any(Array),
2034+
});
2035+
2036+
// Unlike explain-breakdown (which 400s without a contributorLogin), the eligibility plan omits the
2037+
// contributor gate when no login is supplied — the conditional path shared with /v1/scoring/preview.
2038+
const anonymousEligibilityPlan = await app.request(
2039+
"/v1/scoring/eligibility-plan",
2040+
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({ repoFullName: "entrius/allways-ui", sourceTokenScore: 42 }) },
2041+
env,
2042+
);
2043+
expect(anonymousEligibilityPlan.status).toBe(200);
2044+
await expect(anonymousEligibilityPlan.json()).resolves.toMatchObject({ eligible: expect.any(Boolean), blockers: expect.any(Array) });
2045+
20152046
for (const [signalType, payload] of [
20162047
["queue-health", { repoFullName: "entrius/allways-ui", signals: { openPullRequests: 2 } }],
20172048
["config-quality", { repoFullName: "entrius/allways-ui", notObservedConfiguredLabels: ["refactor"] }],
@@ -4537,6 +4568,7 @@ describe("api routes", () => {
45374568

45384569
for (const [path, error] of [
45394570
["/v1/scoring/preview", "invalid_scoring_preview_request"],
4571+
["/v1/scoring/eligibility-plan", "invalid_scoring_preview_request"],
45404572
["/v1/agent/runs", "invalid_agent_run_request"],
45414573
["/v1/agent/plan-next-work", "invalid_agent_plan_request"],
45424574
["/v1/agent/preflight-branch", "invalid_agent_preflight_branch_request"],

test/integration/routes-errors.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,19 @@ describe("api route guards and error branches", () => {
253253
expect(victimScorePreview.status).toBe(403);
254254
await expect(victimScorePreview.json()).resolves.toMatchObject({ error: "forbidden_contributor" });
255255

256+
// #6621: /v1/scoring/eligibility-plan applies the same contributor gate as /v1/scoring/preview.
257+
const victimEligibilityPlan = await app.request(
258+
"/v1/scoring/eligibility-plan",
259+
{
260+
method: "POST",
261+
headers: sessionHeaders,
262+
body: JSON.stringify({ repoFullName: "owner/private-repo", contributorLogin: "victim", metadataOnly: true }),
263+
},
264+
env,
265+
);
266+
expect(victimEligibilityPlan.status).toBe(403);
267+
await expect(victimEligibilityPlan.json()).resolves.toMatchObject({ error: "forbidden_contributor" });
268+
256269
const victimBranchPayload = {
257270
login: "victim",
258271
repoFullName: "owner/private-repo",

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.)
77
// (#6150 registered the local-scorer and plan-DAG/predict-gate tools, taking the count from 55 to 60.)
88
// (#6619 registered the pr-ai-review-findings CLI mirror, taking the count from 60 to 61.)
9+
// (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.)
910
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1011
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
1112
import { mkdtempSync, rmSync } from "node:fs";
@@ -49,14 +50,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
4950
});
5051
afterEach(disconnect);
5152

52-
it("lists exactly 61 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
53+
it("lists exactly 62 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
5354
const { tools } = await client.listTools();
5455
const names = tools.map((t) => t.name);
5556
const primary = names.filter((n) => n.startsWith("loopover_"));
5657
const legacy = names.filter((n) => n.startsWith("gittensory_"));
57-
expect(primary.length).toBe(61);
58+
expect(primary.length).toBe(62);
5859
expect(legacy.length).toBe(0);
59-
expect(names.length).toBe(61);
60+
expect(names.length).toBe(62);
6061
});
6162

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

69-
it("`loopover-mcp tools --json` reports the same 61-tool count the live server registers", async () => {
70+
it("`loopover-mcp tools --json` reports the same 62-tool count the live server registers", async () => {
7071
const { tools } = await client.listTools();
7172
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
7273
expect(payload.count).toBe(tools.length);
73-
expect(payload.count).toBe(61);
74+
expect(payload.count).toBe(62);
7475
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
7576
});
7677
});

0 commit comments

Comments
 (0)