Skip to content

Commit d2cb76c

Browse files
committed
feat(mcp): register the pilot tools from the shared contract, and fix three drifted OpenAPI schemas
Both MCP servers now take the six pilot tools' input AND output schemas from @loopover/contract instead of declaring them locally. The stdio server's hand-mirrored shapes are gone for these tools, and four of them gain a real outputSchema for the first time -- previously only loopover_local_status_structured declared one anywhere in that package. Fixes found while modelling the real payloads, all cases of the published spec disagreeing with what the code actually returns: - CollisionItemSchema's `type` enum omitted "recent_merged_pull_request", which buildCollisionReport genuinely emits (it is handed recent merged PRs). A client validating a real response against the spec rejected it. Its labels, linkedIssues, linkedIssueClaimedAt, changedFiles and body fields were missing too. - QueueHealthSchema omitted draftPullRequests, slopFlaggedPullRequests and duplicateFlaggedPullRequests -- all required on the QueueHealth type and always emitted -- plus the optional rankedPullRequests array. openapi.json is regenerated to match. Refs #9517
1 parent 6ef01c9 commit d2cb76c

7 files changed

Lines changed: 148 additions & 32 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1417,15 +1417,27 @@
14171417
"sampled_cache",
14181418
"authoritative"
14191419
]
1420+
},
1421+
"draftPullRequests": {
1422+
"type": "number"
1423+
},
1424+
"slopFlaggedPullRequests": {
1425+
"type": "number"
1426+
},
1427+
"duplicateFlaggedPullRequests": {
1428+
"type": "number"
14201429
}
14211430
},
14221431
"required": [
14231432
"openIssues",
14241433
"openPullRequests",
14251434
"unlinkedPullRequests",
14261435
"stalePullRequests",
1436+
"draftPullRequests",
14271437
"maintainerAuthoredPullRequests",
14281438
"collisionClusters",
1439+
"slopFlaggedPullRequests",
1440+
"duplicateFlaggedPullRequests",
14291441
"ageBuckets",
14301442
"likelyReviewablePullRequests"
14311443
]
@@ -1435,6 +1447,32 @@
14351447
"items": {
14361448
"$ref": "#/components/schemas/Finding"
14371449
}
1450+
},
1451+
"rankedPullRequests": {
1452+
"type": "array",
1453+
"items": {
1454+
"type": "object",
1455+
"properties": {
1456+
"number": {
1457+
"type": "number"
1458+
},
1459+
"title": {
1460+
"type": "string"
1461+
},
1462+
"authorLogin": {
1463+
"type": "string"
1464+
},
1465+
"recommendation": {
1466+
"type": "string"
1467+
}
1468+
},
1469+
"required": [
1470+
"number",
1471+
"title",
1472+
"authorLogin",
1473+
"recommendation"
1474+
]
1475+
}
14381476
}
14391477
},
14401478
"required": [
@@ -1527,7 +1565,8 @@
15271565
"type": "string",
15281566
"enum": [
15291567
"issue",
1530-
"pull_request"
1568+
"pull_request",
1569+
"recent_merged_pull_request"
15311570
]
15321571
},
15331572
"number": {
@@ -1543,6 +1582,32 @@
15431582
"htmlUrl": {
15441583
"type": "string",
15451584
"nullable": true
1585+
},
1586+
"labels": {
1587+
"type": "array",
1588+
"items": {
1589+
"type": "string"
1590+
}
1591+
},
1592+
"linkedIssues": {
1593+
"type": "array",
1594+
"items": {
1595+
"type": "number"
1596+
}
1597+
},
1598+
"linkedIssueClaimedAt": {
1599+
"type": "string",
1600+
"nullable": true
1601+
},
1602+
"changedFiles": {
1603+
"type": "array",
1604+
"items": {
1605+
"type": "string"
1606+
}
1607+
},
1608+
"body": {
1609+
"type": "string",
1610+
"nullable": true
15461611
}
15471612
},
15481613
"required": [

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"@asteasolutions/zod-to-openapi": "^8.5.0",
125125
"@cloudflare/puppeteer": "^1.1.0",
126126
"@hono/node-server": "^2.0.11",
127+
"@loopover/contract": "^0.1.0",
127128
"@loopover/engine": "*",
128129
"@modelcontextprotocol/sdk": "1.29.0",
129130
"@octokit/core": "^7.0.6",

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ import { buildProgressSnapshot } from "@loopover/engine";
4040
// #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call.
4141
import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "@loopover/engine";
4242
import { z } from "zod";
43+
// #9517: pilot-tool schemas come from the shared contract, replacing shapes this file previously
44+
// hand-mirrored from src/mcp/server.ts. Output schemas arrive with them, so these tools stop
45+
// returning unschematized structured content.
46+
import {
47+
GetPrReviewabilityInput,
48+
GetPrReviewabilityOutput,
49+
GetRepoContextInput,
50+
GetRepoContextOutput,
51+
LocalStatusStructuredInput,
52+
LocalStatusStructuredOutput,
53+
PredictGateInput,
54+
PredictGateOutput,
55+
PreflightPrInput,
56+
PreflightPrOutput,
57+
} from "@loopover/contract/tools";
4358
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
4459
import { formatTable } from "../lib/format-table.js";
4560
import { argsWantJson, describeCliError, reportCliFailure } from "../lib/cli-error.js";
@@ -1747,7 +1762,8 @@ registerStdioTool(
17471762
"loopover_get_repo_context",
17481763
{
17491764
description: stdioToolDescription("loopover_get_repo_context"),
1750-
inputSchema: ownerRepoShape,
1765+
inputSchema: GetRepoContextInput.shape,
1766+
outputSchema: GetRepoContextOutput.shape,
17511767
},
17521768
async ({ owner, repo }: any) => {
17531769
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
@@ -1759,7 +1775,8 @@ registerStdioTool(
17591775
"loopover_get_pr_reviewability",
17601776
{
17611777
description: stdioToolDescription("loopover_get_pr_reviewability"),
1762-
inputSchema: ownerRepoPullShape,
1778+
inputSchema: GetPrReviewabilityInput.shape,
1779+
outputSchema: GetPrReviewabilityOutput.shape,
17631780
},
17641781
async ({ owner, repo, number }: any) => {
17651782
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
@@ -1987,7 +2004,8 @@ registerStdioTool(
19872004
"loopover_preflight_pr",
19882005
{
19892006
description: stdioToolDescription("loopover_preflight_pr"),
1990-
inputSchema: preflightShape,
2007+
inputSchema: PreflightPrInput.shape,
2008+
outputSchema: PreflightPrOutput.shape,
19912009
},
19922010
async (input: any) => toolResult("LoopOver PR preflight.", await apiPost("/v1/preflight/pr", input)),
19932011
);
@@ -2294,7 +2312,8 @@ registerStdioTool(
22942312
"loopover_predict_gate",
22952313
{
22962314
description: stdioToolDescription("loopover_predict_gate"),
2297-
inputSchema: predictGateShape,
2315+
inputSchema: PredictGateInput.shape,
2316+
outputSchema: PredictGateOutput.shape,
22982317
},
22992318
async (input: any) => {
23002319
const body = {
@@ -2962,22 +2981,8 @@ registerStdioTool(
29622981
"loopover_local_status_structured",
29632982
{
29642983
description: stdioToolDescription("loopover_local_status_structured"),
2965-
inputSchema: {
2966-
cwd: z.string().optional(),
2967-
baseRef: z.string().optional(),
2968-
repoFullName: z.string().min(3).optional(),
2969-
},
2970-
outputSchema: z.object({
2971-
apiUrl: z.string(),
2972-
package: z.object({ name: z.string(), version: z.string() }),
2973-
hasToken: z.boolean(),
2974-
profile: z.record(z.string(), z.unknown()),
2975-
authLogin: z.string().nullable(),
2976-
sessionExpiresAt: z.string().nullable(),
2977-
sourceUploadDefault: z.boolean(),
2978-
sourceUploadSupported: z.boolean(),
2979-
git: z.record(z.string(), z.unknown()),
2980-
}),
2984+
inputSchema: LocalStatusStructuredInput.shape,
2985+
outputSchema: LocalStatusStructuredOutput.shape,
29812986
},
29822987
async (input: any) => {
29832988
let git = null;

packages/loopover-mcp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"build:verify": "node scripts/check-syntax.mjs"
4646
},
4747
"dependencies": {
48+
"@loopover/contract": "^0.1.0",
4849
"@loopover/engine": "^3.15.2",
4950
"@modelcontextprotocol/sdk": "1.29.0",
5051
"posthog-node": "^5.46.1",

src/mcp/server.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
55
import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js";
66
import { z } from "zod";
7+
// #9517: the pilot tools' schemas come from the shared contract instead of being declared here.
8+
// `.shape` is what the MCP SDK's registerTool wants; the same ZodObject also drives the JSON Schema
9+
// the agent-tool specs and the contract validator read, so there is one definition per tool rather
10+
// than one per server.
11+
import {
12+
AdminGetConfigInput,
13+
AdminGetConfigOutput,
14+
GetPrReviewabilityInput,
15+
GetPrReviewabilityOutput,
16+
GetRepoContextInput,
17+
GetRepoContextOutput,
18+
PredictGateInput,
19+
PredictGateOutput,
20+
PreflightPrInput,
21+
PreflightPrOutput,
22+
} from "@loopover/contract/tools";
723
import {
824
MAX_FIND_OPPORTUNITIES_LANGUAGE_LENGTH,
925
MAX_FIND_OPPORTUNITIES_LANGUAGES,
@@ -2180,8 +2196,8 @@ export class LoopoverMcp {
21802196
"loopover_get_repo_context",
21812197
{
21822198
description: "Return LoopOver repo context: registration, lane, queue health, collisions, and config quality.",
2183-
inputSchema: ownerRepoShape,
2184-
outputSchema: repoContextOutputSchema,
2199+
inputSchema: GetRepoContextInput.shape,
2200+
outputSchema: GetRepoContextOutput.shape,
21852201
},
21862202
async (input) => this.toolResult(await this.getRepoContext(input)),
21872203
);
@@ -2454,8 +2470,8 @@ export class LoopoverMcp {
24542470
{
24552471
description:
24562472
"Predict whether a planned PR would pass the repo's LoopOver gate, from its PUBLIC .loopover.yml only — an agent-native pre-submission self-check that works on ANY repo (no Gittensor account). Under the oss-anti-slop pack the verdict applies to any author; self-scoped to the authenticated login.",
2457-
inputSchema: predictGateShape,
2458-
outputSchema: predictGateOutputSchema,
2473+
inputSchema: PredictGateInput.shape,
2474+
outputSchema: PredictGateOutput.shape,
24592475
},
24602476
async (input) => this.toolResult(await this.predictGate(input)),
24612477
);
@@ -2650,8 +2666,8 @@ export class LoopoverMcp {
26502666
"loopover_preflight_pr",
26512667
{
26522668
description: "Preflight a planned PR for lane correctness, duplicate risk, linked issues, and review burden.",
2653-
inputSchema: preflightShape,
2654-
outputSchema: preflightResultOutputSchema,
2669+
inputSchema: PreflightPrInput.shape,
2670+
outputSchema: PreflightPrOutput.shape,
26552671
},
26562672
async (input) => this.toolResult(await this.preflightPr(input)),
26572673
);
@@ -2742,8 +2758,8 @@ export class LoopoverMcp {
27422758
{
27432759
description:
27442760
"Return the cached or freshly-computed 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, repo-scoped, no GitHub writes.",
2745-
inputSchema: ownerRepoPullShape,
2746-
outputSchema: freshnessResponseOutputSchema,
2761+
inputSchema: GetPrReviewabilityInput.shape,
2762+
outputSchema: GetPrReviewabilityOutput.shape,
27472763
},
27482764
async (input) => this.toolResult(await this.getPrReviewability(input)),
27492765
);
@@ -3298,8 +3314,8 @@ export class LoopoverMcp {
32983314
{
32993315
description:
33003316
"Self-hosted-operator only. Read this instance's own private .loopover.yml config: the merged effective config for a repo (shared base + global default + per-repo override), or just the raw global-default layer, or just the raw per-repo layer. Requires LOOPOVER_MCP_ADMIN_TOKEN. Returns configured=false if LOOPOVER_REPO_CONFIG_DIR is unset.",
3301-
inputSchema: adminConfigScopeShape,
3302-
outputSchema: adminGetConfigOutputSchema,
3317+
inputSchema: AdminGetConfigInput.shape,
3318+
outputSchema: AdminGetConfigOutput.shape,
33033319
},
33043320
async (input) => this.toolResult(await this.adminGetConfig(input)),
33053321
);

src/openapi/schemas.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,22 @@ export const LaneAdviceSchema = z
265265
})
266266
.openapi("LaneAdvice");
267267

268+
// #9517: `recent_merged_pull_request` was missing from `type` even though buildCollisionReport is
269+
// handed recent merged PRs and emits items with exactly that discriminant -- so a client validating
270+
// a real response against the published spec rejected it. The remaining fields were absent too,
271+
// which understated what this endpoint actually returns.
268272
export const CollisionItemSchema = z
269273
.object({
270-
type: z.enum(["issue", "pull_request"]),
274+
type: z.enum(["issue", "pull_request", "recent_merged_pull_request"]),
271275
number: z.number(),
272276
title: z.string(),
273277
authorLogin: z.string().nullable().optional(),
274278
htmlUrl: z.string().nullable().optional(),
279+
labels: z.array(z.string()).optional(),
280+
linkedIssues: z.array(z.number()).optional(),
281+
linkedIssueClaimedAt: z.string().nullable().optional(),
282+
changedFiles: z.array(z.string()).optional(),
283+
body: z.string().nullable().optional(),
275284
})
276285
.openapi("CollisionItem");
277286

@@ -304,13 +313,20 @@ export const QueueHealthSchema = z
304313
burdenScore: z.number(),
305314
level: z.enum(["low", "medium", "high", "critical"]),
306315
summary: z.string(),
316+
// #9517: draftPullRequests, slopFlaggedPullRequests and duplicateFlaggedPullRequests are all
317+
// REQUIRED on the QueueHealth type and always emitted by buildQueueHealth, but were missing
318+
// here -- the published spec understated the response. The two flagged counts are deliberately
319+
// public-safe counts, carrying no score or ranking detail.
307320
signals: z.object({
308321
openIssues: z.number(),
309322
openPullRequests: z.number(),
310323
unlinkedPullRequests: z.number(),
311324
stalePullRequests: z.number(),
325+
draftPullRequests: z.number(),
312326
maintainerAuthoredPullRequests: z.number(),
313327
collisionClusters: z.number(),
328+
slopFlaggedPullRequests: z.number(),
329+
duplicateFlaggedPullRequests: z.number(),
314330
ageBuckets: z.object({
315331
under7Days: z.number(),
316332
days7To30: z.number(),
@@ -321,6 +337,16 @@ export const QueueHealthSchema = z
321337
likelyReviewablePullRequestsSource: z.enum(["cache", "sampled_cache", "authoritative"]).optional(),
322338
}),
323339
findings: z.array(FindingSchema),
340+
rankedPullRequests: z
341+
.array(
342+
z.object({
343+
number: z.number(),
344+
title: z.string(),
345+
authorLogin: z.string(),
346+
recommendation: z.string(),
347+
}),
348+
)
349+
.optional(),
324350
})
325351
.openapi("QueueHealth");
326352

0 commit comments

Comments
 (0)