From 8fbd5c39d8221d4facf59a3581c7827995f64c18 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:34:39 -0700 Subject: [PATCH 1/2] mcp: enforce that a server may only NARROW the contract, and pin the last four restatements (#9662) `registerStdioTool`'s override is documented as one-way -- "a server may serve LESS than the contract when its own route cannot honour a field... never used to widen" -- and nothing enforced it. `diffToolSets` compares names, `checkAdvertisedShape` checks only that the input is object-typed, and the smoke arguments are synthesized FROM the advertised schema, so a widened schema simply got widened arguments and passed. `checkInputNarrowing` now runs for all three surfaces. What it found on its first run was not a hypothetical: FIVE hand-written shapes in src/mcp/server.ts, feeding TEN tools that advertised eight input properties their contract never declared, plus two that demanded a field the catalog calls optional. So `listToolDefinitions()` -- and with it the OpenAI/Anthropic spec builders and the `.well-known` catalogs -- described a tool that rejects what the remote accepts, and a caller following the published schema got a -32602. The fix is the rule the epic already states: the contract holds the wider surface, and each server declares its own narrowing THERE rather than in a literal next to its registration. - `LocalBranchAnalysisInput` replaces `localBranchAnalysisShape` (8 tools). The stdio server keeps `CurrentBranchInput`, because it reads the shas, the diff and the scorer probe off the checkout rather than taking them from the caller. - `LocalScorePreviewInput` is the union of `scorePreviewShape` and `LocalScoreInput`; `RemoteLocalScorePreviewInput` is the remote's declared narrowing of it (no cwd, no scorer command -- it has no checkout to run them against). - `MarkNotificationsReadInput.login` and `WatchIssuesInput.login` become REQUIRED, matching the two servers that cannot infer them, with `Stdio*` narrowings for the one that resolves them from the active session. Five shapes, `linkedIssueContextShape`, `callerBranchEligibilitySchema`, `branchEligibilityShape`, `focusManifestInputSchema` and `isJsonByteLengthWithinLimit` are all deleted from the server. Also closes #9661 and #9660: - The version tri-lock's third leg was asserted against ITSELF -- `serverInfoVersion: packageVersion`, the same expression, for the one leg `checkVersionLock`'s own doc calls the drift-prone one. Both legs are read off a connected client now, the miner gains its own lock, and an ABSENT version is its own failure rather than being reported as a mismatch. The `RFAIL` debug loop and the duplicated comment paragraph that shipped with #9520 are gone. - The four unpinned restatements (`TEST_FRAMEWORKS`, `QUEUE_STATUSES`, `CLAIM_STATUSES`, `MINER_RUN_STATES`) move to enums.ts and gain pins against their live sources. Pinning the run states required the miner's own list to become the source of its `RunState` type -- the list and the union were written out separately, so adding a state to one and not the other compiled. And `FEASIBILITY_VERDICTS` is imported at the two places it was re-inlined inside the very package whose purpose is removing hand-copies. --- packages/loopover-contract/src/api-schemas.ts | 1 + packages/loopover-contract/src/enums.ts | 19 ++ packages/loopover-contract/src/tools/agent.ts | 6 +- .../loopover-contract/src/tools/branch.ts | 4 +- .../src/tools/local-branch.ts | 125 +++++++++++-- packages/loopover-contract/src/tools/miner.ts | 5 +- packages/loopover-mcp/bin/loopover-mcp.ts | 6 + packages/loopover-miner/lib/run-state.ts | 16 +- scripts/lib/validate-mcp/invariants.ts | 58 +++++- src/mcp/server.ts | 173 ++++-------------- test/contract/validate-mcp.test.ts | 48 +++-- test/unit/contract-registry.test.ts | 28 +++ test/unit/validate-mcp-helpers.test.ts | 72 +++++++- 13 files changed, 369 insertions(+), 192 deletions(-) diff --git a/packages/loopover-contract/src/api-schemas.ts b/packages/loopover-contract/src/api-schemas.ts index bce04d9344..2664a44c33 100644 --- a/packages/loopover-contract/src/api-schemas.ts +++ b/packages/loopover-contract/src/api-schemas.ts @@ -452,6 +452,7 @@ export const RepositorySettingsSchema = z // the compile-time parity assertion in test/unit/openapi-settings-schema-parity.test.ts. expectedCiContexts: z.array(z.string()).readonly().nullable().optional(), advisoryCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(), + ignoredCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(), copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(), copycatGateMinScore: z.number().nullable().optional(), gateDryRun: z.boolean().optional(), diff --git a/packages/loopover-contract/src/enums.ts b/packages/loopover-contract/src/enums.ts index 6c520af808..62b5c2fe31 100644 --- a/packages/loopover-contract/src/enums.ts +++ b/packages/loopover-contract/src/enums.ts @@ -194,3 +194,22 @@ export type TenantProduct = (typeof TENANT_PRODUCTS)[number]; /** One doctor check's verdict (#9522). `warn` exists so a degraded-but-working instance is not reported as broken. */ export const INSTANCE_CHECK_STATUSES = ["pass", "warn", "fail"] as const; export type InstanceCheckStatus = (typeof INSTANCE_CHECK_STATUSES)[number]; + +/** + * The AMS miner's own vocabularies (#9660). + * + * Restated here for the usual reason -- this package cannot import the miner -- and pinned against the live + * lists by test/unit/contract-registry.test.ts. They lived in tools/miner.ts until now, which put them + * outside even the convention that a shared vocabulary lives in this file, and two of them appear in OUTPUT + * schemas: a new queue status or run state would make a tool's real `structuredContent` fail the schema that + * same tool advertises. + */ +/** packages/loopover-miner/lib/portfolio-queue.ts */ +export const QUEUE_STATUSES = ["queued", "in_progress", "done"] as const; +export type QueueStatus = (typeof QUEUE_STATUSES)[number]; +/** packages/loopover-miner/lib/claim-ledger.ts */ +export const CLAIM_STATUSES = ["active", "released", "expired"] as const; +export type ClaimStatus = (typeof CLAIM_STATUSES)[number]; +/** packages/loopover-miner/lib/run-state.ts */ +export const MINER_RUN_STATES = ["idle", "discovering", "planning", "preparing"] as const; +export type MinerRunState = (typeof MINER_RUN_STATES)[number]; diff --git a/packages/loopover-contract/src/tools/agent.ts b/packages/loopover-contract/src/tools/agent.ts index af73ae6f9d..3ed4b43f84 100644 --- a/packages/loopover-contract/src/tools/agent.ts +++ b/packages/loopover-contract/src/tools/agent.ts @@ -16,7 +16,7 @@ // Descriptions are relocated verbatim, same discipline as the other batches. import { z } from "zod"; import { defineTool } from "../tool-definition.js"; -import { AUTONOMY_LEVELS, MAINTAIN_ACTION_CLASSES, PLAN_STEP_STATUSES, PROPOSE_ACTION_CLASSES, TEST_FRAMEWORKS } from "../enums.js"; +import { AUTONOMY_LEVELS, FEASIBILITY_VERDICTS, MAINTAIN_ACTION_CLASSES, PLAN_STEP_STATUSES, PROPOSE_ACTION_CLASSES, TEST_FRAMEWORKS } from "../enums.js"; import { SCENARIO_LIMITS, WRITE_TOOL_LIMITS } from "../limits.js"; import { AgentRunBundleOutput } from "./branch.js"; import { ownerRepoInput } from "../shared.js"; @@ -45,7 +45,7 @@ export const IntakeIdeaInput = z.object({ * a zod message the caller cannot act on. */ export const IntakeIdeaOutput = z.looseObject({ ok: z.boolean(), - verdict: z.enum(["go", "raise", "avoid"]).optional(), + verdict: z.enum(FEASIBILITY_VERDICTS).optional(), taskGraph: z.unknown().optional(), errors: z.array(z.string()).optional(), }); @@ -64,7 +64,7 @@ export const intakeIdeaTool = defineTool({ export const PlanIdeaClaimsOutput = z.looseObject({ ok: z.boolean(), - verdict: z.enum(["go", "raise", "avoid"]).optional(), + verdict: z.enum(FEASIBILITY_VERDICTS).optional(), claimPlan: z.unknown().optional(), errors: z.array(z.string()).optional(), }); diff --git a/packages/loopover-contract/src/tools/branch.ts b/packages/loopover-contract/src/tools/branch.ts index b79f814afa..544bc178d0 100644 --- a/packages/loopover-contract/src/tools/branch.ts +++ b/packages/loopover-contract/src/tools/branch.ts @@ -28,7 +28,7 @@ import { PreflightPrInput, PreflightPrOutput } from "./preflight-pr.js"; * `content` gets a rejected call, not a silently-stripped one, so it cannot believe it uploaded * source and be wrong about it. */ -const changedFileSchema = z.strictObject({ +export const changedFileSchema = z.strictObject({ path: z.string().min(1), previousPath: z.string().min(1).optional(), additions: z.number().int().min(0).optional(), @@ -38,7 +38,7 @@ const changedFileSchema = z.strictObject({ }); /** One locally-executed validation command and its result. Strict for the same reason. */ -const validationEntrySchema = z.strictObject({ +export const validationEntrySchema = z.strictObject({ command: z.string().min(1), status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().optional(), diff --git a/packages/loopover-contract/src/tools/local-branch.ts b/packages/loopover-contract/src/tools/local-branch.ts index eea8a1759a..b7cffb5f0e 100644 --- a/packages/loopover-contract/src/tools/local-branch.ts +++ b/packages/loopover-contract/src/tools/local-branch.ts @@ -23,7 +23,7 @@ // constructed with, so requiring the caller to restate it was always redundant. import { z } from "zod"; import { defineTool } from "../tool-definition.js"; -import { PREFLIGHT_LIMITS, SCENARIO_LIMITS } from "../limits.js"; +import { PREFLIGHT_LIMITS, SCENARIO_LIMITS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS } from "../limits.js"; import { FEASIBILITY_VERDICTS } from "../enums.js"; import { AgentRunBundleOutput, @@ -46,6 +46,8 @@ import { WatchIssuesOutput, } from "./discovery-utility.js"; import { callerBranchEligibilitySchema } from "./review.js"; +import { changedFileSchema, validationEntrySchema } from "./branch.js"; +import { focusManifestInputSchema } from "../api-requests.js"; /** One locally-executed validation command and its result, as the local-branch surfaces accept it. */ const localValidationEntry = z.object({ @@ -87,6 +89,47 @@ export const CurrentBranchInput = z.object({ scorePreviewCommand: z.string().optional(), }); +/** + * The FULL current-branch vocabulary: everything a caller may supply about a branch (#9662). + * + * Previously this lived as a 40-line `localBranchAnalysisShape` literal inside `src/mcp/server.ts` -- the + * hand-written kind of declaration this package exists to remove -- and the ten tools below advertised a + * strictly NARROWER `CurrentBranchInput` in the registry. So `listToolDefinitions()`, and with it the + * OpenAI/Anthropic spec builders and the `.well-known` catalogs, described a tool that rejected eight + * fields the remote server accepts, and nothing could see the gap. + * + * The contract is the wider surface, per the rule a server narrows FROM: the remote accepts this whole + * shape because a caller may supply the metadata itself, and the stdio server narrows to `CurrentBranchInput` + * because it reads the shas, the diff and the scorer probe off the local checkout instead of taking them + * from the caller -- serving less, and saying so, rather than advertising a field it ignores. + */ +export const LocalBranchAnalysisInput = CurrentBranchInput.extend({ + login: z.string().min(1).max(SCENARIO_LIMITS.branchRefChars), + repoFullName: z.string().min(3).max(SCENARIO_LIMITS.repoFullNameChars), + baseSha: z.string().min(1).optional(), + headSha: z.string().min(1).optional(), + mergeBaseSha: z.string().min(1).optional(), + remoteTrackingSha: z.string().min(1).optional(), + commitMessages: z.array(z.string()).max(30).optional(), + changedFiles: z.array(changedFileSchema).max(500).optional(), + validation: z.array(validationEntrySchema).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), + focusManifest: focusManifestInputSchema.optional(), + /** What a local scoring run reported, when the caller ran one. */ + localScorer: z + .object({ + mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), + activeModel: z.string().optional(), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + warnings: z.array(z.string()).optional(), + }) + .optional(), +}); + export const preflightCurrentBranchTool = defineTool({ name: "loopover_preflight_current_branch", title: "Preflight current branch", @@ -96,7 +139,7 @@ export const preflightCurrentBranchTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: PreflightCurrentBranchOutput, }); @@ -109,7 +152,7 @@ export const previewCurrentBranchScoreTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: PreviewCurrentBranchScoreOutput, }); @@ -122,7 +165,7 @@ export const rankLocalNextActionsTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: RankLocalNextActionsOutput, }); @@ -135,7 +178,7 @@ export const explainLocalBlockersTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: ExplainLocalBlockersOutput, }); @@ -148,7 +191,7 @@ export const remediationPlanTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: RemediationPlanOutput, }); @@ -161,7 +204,7 @@ export const preparePrPacketTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: PrepareLocalPrPacketOutput, }); @@ -176,7 +219,7 @@ export const agentPreparePrPacketTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: CurrentBranchInput, + input: LocalBranchAnalysisInput, output: AgentRunBundleOutput, }); @@ -193,7 +236,7 @@ export const reviewPrBeforePushTool = defineTool({ output: PreflightCurrentBranchOutput, }); -export const DraftPrBodyInput = CurrentBranchInput.extend({ +export const DraftPrBodyInput = LocalBranchAnalysisInput.extend({ format: z.enum(["json", "markdown"]).optional(), }); export const draftPrBodyTool = defineTool({ @@ -265,6 +308,52 @@ export const LocalScoreInput = z.object({ scorePreviewCommand: z.string().optional(), }); +/** + * The score-preview family's full vocabulary (#9662), and each server's declared narrowing of it. + * + * Same story as `LocalBranchAnalysisInput` above: the remote registered from a hand-written + * `scorePreviewShape` in `src/mcp/server.ts` carrying eight scenario knobs the registry never described, + * while `LocalScoreInput` carries nine fields only a server with a checkout can honour. Neither is the + * contract on its own -- the union is, and each server states which part of it it serves rather than + * advertising a field it silently overrides. + */ +/** The linked-issue provenance a caller may supply, as the remote has always accepted it. */ +const linkedIssueContextSchema = z.strictObject({ + status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), + source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), + issueNumbers: z.array(z.number().int().positive()).max(50).optional(), + solvedByPullRequests: z.array(z.number().int().positive()).max(50).optional(), + reason: z.string().optional(), + warnings: z.array(z.string()).max(20).optional(), +}); + +export const LocalScorePreviewInput = LocalScoreInput.extend({ + targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("local_diff"), + linkedIssueContext: linkedIssueContextSchema.optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + existingContributorTokenScore: z.number().min(0).optional(), + prAgeHours: z.number().min(0).optional(), + duplicateRiskCount: z.number().int().min(0).optional(), + metadataOnly: z.boolean().default(true), +}); + +/** + * What the REMOTE serves: everything except the fields that only mean something to a server holding the + * checkout. It cannot read a working directory, run a scorer command, or diff a branch, so it says so. + */ +export const RemoteLocalScorePreviewInput = LocalScorePreviewInput.omit({ + cwd: true, + baseRef: true, + title: true, + body: true, + linkedIssues: true, + tests: true, + authorAssociation: true, + commitMessage: true, + scorePreviewCommand: true, +}); + export const previewLocalPrScoreTool = defineTool({ name: "loopover_preview_local_pr_score", title: "Preview local PR score", @@ -274,7 +363,7 @@ export const previewLocalPrScoreTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: LocalScoreInput, + input: LocalScorePreviewInput, output: PreviewLocalPrScoreOutput, }); @@ -287,7 +376,7 @@ export const getEligibilityPlanTool = defineTool({ auth: "token", locality: "local-git", availability: "both", - input: LocalScoreInput, + input: LocalScorePreviewInput, output: GetEligibilityPlanOutput, }); @@ -342,9 +431,15 @@ export const feasibilityGateTool = defineTool({ // ── remote-proxying tools whose inputs converge here ──────────────────────────────────────────── export const MarkNotificationsReadInput = z.object({ - login: z.string().min(1).optional(), + // REQUIRED (#9662): the notifications are per-contributor, and a server with no session cannot infer + // whose they are. The stdio server, which does have one, narrows it away below -- the catalog told a + // caller this was optional while the remote rejected the call, which is the drift that check exists for. + login: z.string().min(1), ids: z.array(z.string().min(1).max(128)).max(100).optional(), }); + +/** What the STDIO server serves: it resolves the login from the active profile's session. */ +export const StdioMarkNotificationsReadInput = MarkNotificationsReadInput.partial({ login: true }); export const markNotificationsReadTool = defineTool({ name: "loopover_mark_notifications_read", title: "Mark notifications read", @@ -360,7 +455,8 @@ export const markNotificationsReadTool = defineTool({ }); export const WatchIssuesInput = z.object({ - login: z.string().min(1).optional(), + /** REQUIRED for the same reason as `MarkNotificationsReadInput.login`; the stdio server narrows it. */ + login: z.string().min(1), // `.default("list")` is a runtime coercion the emitted JSON Schema cannot round-trip, so it is // expressed as an optional field with the default stated in the description instead. Both servers // already treat an omitted action as "list". @@ -368,6 +464,9 @@ export const WatchIssuesInput = z.object({ repoFullName: z.string().min(3).max(SCENARIO_LIMITS.repoFullNameChars).optional(), labels: z.array(z.string().min(1).max(100)).max(50).optional(), }); +/** What the STDIO server serves: same session-filled `login` narrowing as its notifications sibling. */ +export const StdioWatchIssuesInput = WatchIssuesInput.partial({ login: true }); + export const watchIssuesTool = defineTool({ name: "loopover_watch_issues", title: "Watch issues", diff --git a/packages/loopover-contract/src/tools/miner.ts b/packages/loopover-contract/src/tools/miner.ts index bad607d962..723f4904d9 100644 --- a/packages/loopover-contract/src/tools/miner.ts +++ b/packages/loopover-contract/src/tools/miner.ts @@ -12,13 +12,11 @@ import { z } from "zod"; import { defineTool } from "../tool-definition.js"; import { toolErrorFields } from "../shared.js"; -import { PLAN_STEP_STATUSES } from "../enums.js"; +import { CLAIM_STATUSES, MINER_RUN_STATES, PLAN_STEP_STATUSES, QUEUE_STATUSES } from "../enums.js"; /** Statuses a portfolio-queue entry can hold. */ -export const QUEUE_STATUSES = ["queued", "in_progress", "done"] as const; /** Statuses a local claim-ledger row can hold. */ -export const CLAIM_STATUSES = ["active", "released", "expired"] as const; /** Per-status counts, repeated at both the global and per-repo level of the dashboard. */ const queueStatusCounts = z.looseObject({ @@ -231,7 +229,6 @@ export const minerAuditFeedTool = defineTool({ // ── run state ─────────────────────────────────────────────────────────────────────────────────── /** `RunState` (packages/loopover-miner/lib/run-state.ts). */ -export const MINER_RUN_STATES = ["idle", "discovering", "planning", "preparing"] as const; export const MinerGetRunStateInput = z.object({ repoFullName: z.string().min(1).optional(), diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 75e0ee0b34..188874f61f 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -142,6 +142,7 @@ import { LocalStatusInput, LocalStatusStructuredInput, MarkNotificationsReadInput, + StdioMarkNotificationsReadInput, MonitorOpenPrsInput, OpenPrInput, PlanRepoIssuesInput, @@ -164,6 +165,7 @@ import { ValidateConfigInput, ValidateLinkedIssueInput, WatchIssuesInput, + StdioWatchIssuesInput, getToolContract, projectToolDefinition, ListPendingActionsStdioInput, @@ -1597,6 +1599,9 @@ registerStdioTool( if (!contributorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); return toolResult(`Marked LoopOver notifications read for ${contributorLogin}.`, await postMarkNotificationsRead(contributorLogin, ids)); }, + // #9662: this server resolves the login from the active session, so it accepts a call without one -- + // stated as a declared narrowing rather than left as a difference nothing could see. + { input: StdioMarkNotificationsReadInput }, ); // #7763: stdio mirror of the remote loopover_watch_issues + the `watch` CLI. Reuses the shared @@ -1610,6 +1615,7 @@ registerStdioTool( if ((action === "watch" || action === "unwatch") && !repoFullName) throw new Error(`action "${action}" requires repoFullName.`); return toolResult(`Issue-watch subscriptions for ${contributorLogin}.`, await watchIssuesRequest(contributorLogin, action, repoFullName, labels)); }, + { input: StdioWatchIssuesInput }, ); registerStdioTool( diff --git a/packages/loopover-miner/lib/run-state.ts b/packages/loopover-miner/lib/run-state.ts index 2538f62865..e1ab431f0e 100644 --- a/packages/loopover-miner/lib/run-state.ts +++ b/packages/loopover-miner/lib/run-state.ts @@ -5,7 +5,7 @@ import { isValidRepoSegment } from "./repo-clone.js"; import { applySchemaMigrations } from "./schema-version.js"; import { RUN_STATE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; -export type RunState = "idle" | "discovering" | "planning" | "preparing"; +export type RunState = (typeof RUN_STATES)[number]; export type RunStateWrite = { apiBaseUrl: string; @@ -30,12 +30,14 @@ export type RunStateStore = { close(): void; }; -export const RUN_STATES = Object.freeze([ - "idle", - "discovering", - "planning", - "preparing", -]) as readonly RunState[]; +/** + * Every state a run can be in, and the source of the `RunState` type above (#9660). + * + * The list and the union used to be written out separately, so adding a state to one and not the other + * compiled fine. @loopover/contract restates this vocabulary too and is pinned against THIS list, which is + * only meaningful while the list is what the type is made of. + */ +export const RUN_STATES = Object.freeze(["idle", "discovering", "planning", "preparing"] as const); const runStateSet = new Set(RUN_STATES); const defaultDbFileName = "run-state.sqlite3"; diff --git a/scripts/lib/validate-mcp/invariants.ts b/scripts/lib/validate-mcp/invariants.ts index df634c7d36..278bdb4858 100644 --- a/scripts/lib/validate-mcp/invariants.ts +++ b/scripts/lib/validate-mcp/invariants.ts @@ -10,7 +10,7 @@ export type ListedTool = { title?: string | undefined; description?: string | undefined; annotations?: { readOnlyHint?: boolean | undefined; destructiveHint?: boolean | undefined } | undefined; - inputSchema?: { type?: string } | undefined; + inputSchema?: { type?: string; properties?: Record; required?: string[] } | undefined; outputSchema?: { type?: string } | undefined; }; @@ -78,6 +78,40 @@ export function checkAdvertisedMetadata(expected: readonly McpToolDefinition[], return failures; } +/** + * An advertised input may only NARROW the contract's, never widen it (#9662). + * + * `registerStdioTool`'s override is documented as one-way -- "a server may serve LESS than the contract + * when its own route cannot honour a field... never used to widen" -- and nothing enforced it. The + * override is typed as any `z.ZodObject` at all, and no existing check could see a widening: + * `diffToolSets` compares names, `checkAdvertisedShape` asks only whether the schema is object-typed, + * and the smoke arguments are synthesized FROM the advertised schema, so a widened schema simply gets + * widened arguments and passes. + * + * Narrowing is defined mechanically, which is all a schema comparison can honestly do here: every + * advertised property exists in the contract's, and every advertised requirement is one the contract + * also requires. Making an optional contract field required is therefore a widening of the caller's + * obligations and fails -- which is the case a hand-written override is most likely to get wrong. + */ +export function checkInputNarrowing(expected: readonly McpToolDefinition[], listed: readonly ListedTool[]): string[] { + const listedByName = new Map(listed.map((tool) => [tool.name, tool])); + const failures: string[] = []; + for (const tool of expected) { + const advertised = listedByName.get(tool.name); + // Absent is diffToolSets' finding; a schema-less advertisement is checkAdvertisedShape's. + if (!advertised?.inputSchema) continue; + const contractProperties = new Set(Object.keys((tool.inputSchema as { properties?: Record }).properties ?? {})); + const contractRequired = new Set((tool.inputSchema as { required?: string[] }).required ?? []); + for (const property of Object.keys(advertised.inputSchema.properties ?? {})) { + if (!contractProperties.has(property)) failures.push(`${tool.name} advertises input property ${property}, which its contract does not declare`); + } + for (const property of advertised.inputSchema.required ?? []) { + if (!contractRequired.has(property)) failures.push(`${tool.name} requires input property ${property}, which its contract does not require`); + } + } + return failures; +} + /** * Every registered tool must have been smoke-called. * @@ -92,8 +126,14 @@ export function checkEveryToolCalled(listed: readonly ListedTool[], called: Read export type VersionLockInput = { packageVersion: string; - advertisedLatestVersion: string; - serverInfoVersion: string; + /** The compatibility constant, where one exists. Only @loopover/mcp has one; the miner's lock is + * the two-way one between its package and what its server advertises. */ + advertisedLatestVersion?: string | undefined; + /** Read off a CONNECTED client, never re-read from the package.json this is compared against + * (#9661) -- hence `undefined` is a case: a server that advertised no version at all. */ + serverInfoVersion: string | undefined; + /** Which server's `serverInfo` this is, so one helper can lock more than one of them. */ + serverLabel?: string; }; /** @@ -105,11 +145,17 @@ export type VersionLockInput = { */ export function checkVersionLock(input: VersionLockInput): string[] { const failures: string[] = []; - if (input.advertisedLatestVersion !== input.packageVersion) { + if (input.advertisedLatestVersion !== undefined && input.advertisedLatestVersion !== input.packageVersion) { failures.push(`compatibility advertises ${input.advertisedLatestVersion} but @loopover/mcp is ${input.packageVersion}`); } - if (input.serverInfoVersion !== input.packageVersion) { - failures.push(`stdio serverInfo reports ${input.serverInfoVersion} but @loopover/mcp is ${input.packageVersion}`); + const label = input.serverLabel ?? "stdio"; + // #9661: absent is its own failure. `undefined !== packageVersion` would report it as a mismatch, + // but the two are not the same problem -- one server drifted, the other advertised nothing at all, + // and an empty string would otherwise have to be read as a version. + if (input.serverInfoVersion === undefined || input.serverInfoVersion.trim() === "") { + failures.push(`${label} serverInfo advertises no version`); + } else if (input.serverInfoVersion !== input.packageVersion) { + failures.push(`${label} serverInfo reports ${input.serverInfoVersion} but its package is ${input.packageVersion}`); } return failures; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2f6e836ce8..ef0080829b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -23,6 +23,10 @@ import { AdminListConfigBackupsInput, AdminListConfigBackupsOutput, AdminTriggerRedeployInput, + LocalBranchAnalysisInput, + RemoteLocalScorePreviewInput, + MarkNotificationsReadInput, + WatchIssuesInput, AdminRotateSecretInput, AdminRotateSecretOutput, AdminTriggerRedeployOutput, @@ -335,8 +339,6 @@ import { listRepoSyncSegments, listRepoSyncStates, listRepositories, - MAX_NOTIFICATION_DELIVERY_ID_LENGTH, - MAX_NOTIFICATION_MARK_READ_IDS, markNotificationDeliveriesRead, recordAuditEvent, recordPostMergeIncidentReport, @@ -466,7 +468,7 @@ import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePl import { buildFocusManifestValidation } from "../services/focus-manifest-validation"; import { resolveRepositorySettings } from "../settings/repository-settings"; import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode"; -import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; +import { compileFocusManifestPolicy } from "../signals/focus-manifest"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVerdict } from "../rules/predicted-gate"; export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate"; @@ -480,7 +482,7 @@ import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; 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 { SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; import { authoritativeGateOverride, @@ -601,18 +603,7 @@ const localDiffPreflightShape = { commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), }; -const branchEligibilityShape = { - status: z.enum(["eligible", "ineligible", "unknown"]), - source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied"]).optional(), - reason: z.string().optional(), - checkedAt: z.string().optional(), - stale: z.boolean().optional(), -}; -const callerBranchEligibilitySchema = z - .object(branchEligibilityShape) - .strict() - .transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const })); // Changed-file metadata + local validation results — shared by the local-branch analysis and the #782 local // scorer. METADATA ONLY (paths + line counts), never source content, so the no-upload boundary holds. @@ -697,62 +688,11 @@ const planRepoIssuesShape = { // #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo. -const focusManifestInputSchema = z - .record(z.string(), z.unknown()) - .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { - message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, - }); -function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { - try { - return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; - } catch { - return false; - } -} -const localBranchAnalysisShape = { - login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), - repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), - baseRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), - headRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), - branchName: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), - baseSha: z.string().min(1).optional(), - headSha: z.string().min(1).optional(), - mergeBaseSha: z.string().min(1).optional(), - remoteTrackingSha: z.string().min(1).optional(), - commitMessages: z.array(z.string()).max(30).optional(), - changedFiles: z.array(changedFileSchema).max(500).optional(), - validation: z.array(validationEntrySchema).max(50).optional(), - linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), - labels: z.array(z.string()).optional(), - title: z.string().min(1).optional(), - body: z.string().optional(), - pendingMergedPrCount: z.number().int().min(0).optional(), - pendingClosedPrCount: z.number().int().min(0).optional(), - approvedPrCount: z.number().int().min(0).optional(), - expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), - projectedCredibility: z.number().min(0).max(1).optional(), - scenarioNotes: z.array(z.string()).max(20).optional(), - focusManifest: focusManifestInputSchema.optional(), - branchEligibility: callerBranchEligibilitySchema.optional(), - localScorer: z - .object({ - mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), - activeModel: z.string().optional(), - sourceTokenScore: z.number().min(0).optional(), - totalTokenScore: z.number().min(0).optional(), - sourceLines: z.number().min(0).optional(), - testTokenScore: z.number().min(0).optional(), - nonCodeTokenScore: z.number().min(0).optional(), - warnings: z.array(z.string()).optional(), - }) - .strict() - .optional(), -}; const localBranchVariantsShape = { - variants: z.array(z.object(localBranchAnalysisShape).strict()).min(1).max(10), + variants: z.array(LocalBranchAnalysisInput.strict()).min(1).max(10), }; @@ -761,46 +701,10 @@ function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: return issues.filter((issue) => issue.repoFullName.toLowerCase() === targetRepo && issue.state === "open").length; } -const linkedIssueContextShape = { - status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), - source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), - issueNumbers: z.array(z.number().int().positive()).max(50).optional(), - solvedByPullRequests: z.array(z.number().int().positive()).max(50).optional(), - reason: z.string().optional(), - warnings: z.array(z.string()).max(20).optional(), -}; -const scorePreviewShape = { - repoFullName: z.string().min(3), - targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("local_diff"), - targetKey: z.string().optional(), - contributorLogin: z.string().min(1).optional(), - labels: z.array(z.string()).optional(), - linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), - linkedIssueContext: z.object(linkedIssueContextShape).strict().optional(), - sourceTokenScore: z.number().min(0).optional(), - totalTokenScore: z.number().min(0).optional(), - sourceLines: z.number().min(0).optional(), - testTokenScore: z.number().min(0).optional(), - nonCodeTokenScore: z.number().min(0).optional(), - existingContributorTokenScore: z.number().min(0).optional(), - prAgeHours: z.number().min(0).optional(), - openPrCount: z.number().int().min(0).optional(), - credibility: z.number().min(0).max(1).optional(), - changesRequestedCount: z.number().int().min(0).optional(), - duplicateRiskCount: z.number().int().min(0).optional(), - metadataOnly: z.boolean().default(true), - pendingMergedPrCount: z.number().int().min(0).optional(), - pendingClosedPrCount: z.number().int().min(0).optional(), - approvedPrCount: z.number().int().min(0).optional(), - expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), - projectedCredibility: z.number().min(0).max(1).optional(), - scenarioNotes: z.array(z.string()).max(20).optional(), - branchEligibility: callerBranchEligibilitySchema.optional(), -}; const variantsShape = { - variants: z.array(z.object(scorePreviewShape)).min(1).max(10), + variants: z.array(RemoteLocalScorePreviewInput).min(1).max(10), }; @@ -963,22 +867,9 @@ const suggestBoundaryTestsShape = { -const markNotificationsReadShape = { - login: z.string().min(1), - ids: z - .array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)) - .max(MAX_NOTIFICATION_MARK_READ_IDS) - .optional(), -}; // #699 path B: a miner's self-scoped issue-watch subscriptions. `action` defaults to `list`; `watch`/`unwatch` // require repoFullName. `labels` ([]/omitted = any) filters which new issues notify. -const watchIssuesShape = { - login: z.string().min(1), - action: z.enum(["watch", "unwatch", "list"]).default("list"), - repoFullName: z.string().min(3).max(200).optional(), - labels: z.array(z.string().min(1).max(100)).max(50).optional(), -}; @@ -1589,7 +1480,7 @@ export class LoopoverMcp { register( "loopover_mark_notifications_read", { - inputSchema: markNotificationsReadShape, + inputSchema: MarkNotificationsReadInput, outputSchema: MarkNotificationsReadOutput, }, async (input) => this.toolResult(await this.markNotificationsRead(input.login, input.ids)), @@ -1598,7 +1489,7 @@ export class LoopoverMcp { register( "loopover_watch_issues", { - inputSchema: watchIssuesShape, + inputSchema: WatchIssuesInput, outputSchema: WatchIssuesOutput, }, async (input) => this.toolResult(await this.watchIssues(input)), @@ -1809,7 +1700,7 @@ export class LoopoverMcp { register( "loopover_preview_local_pr_score", { - inputSchema: scorePreviewShape, + inputSchema: RemoteLocalScorePreviewInput, outputSchema: PreviewLocalPrScoreOutput, }, async (input) => this.toolResult(await this.previewScore(input)), @@ -1818,7 +1709,7 @@ export class LoopoverMcp { register( "loopover_get_eligibility_plan", { - inputSchema: scorePreviewShape, + inputSchema: RemoteLocalScorePreviewInput, outputSchema: GetEligibilityPlanOutput, }, async (input) => this.toolResult(await this.getEligibilityPlan(input)), @@ -2019,7 +1910,7 @@ export class LoopoverMcp { // may send; relocating the input would have silently dropped that downgrade. The contract's // ExplainScoreBreakdownInput documents the pre-transform wire shape, which is what the // advertised inputSchema should say. - inputSchema: scorePreviewShape, + inputSchema: RemoteLocalScorePreviewInput, outputSchema: ExplainScoreBreakdownOutput, }, async (input) => this.toolResult(await this.explainScoreBreakdown(input)), @@ -2074,7 +1965,7 @@ export class LoopoverMcp { register( "loopover_preflight_current_branch", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: PreflightCurrentBranchOutput, }, async (input) => this.toolResult(await this.localBranchSlice(input, "preflight")), @@ -2083,7 +1974,7 @@ export class LoopoverMcp { register( "loopover_preview_current_branch_score", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: PreviewCurrentBranchScoreOutput, }, async (input) => this.toolResult(await this.localBranchSlice(input, "scorePreview")), @@ -2092,7 +1983,7 @@ export class LoopoverMcp { register( "loopover_rank_local_next_actions", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: RankLocalNextActionsOutput, }, async (input) => this.toolResult(await this.localBranchSlice(input, "nextActions")), @@ -2101,7 +1992,7 @@ export class LoopoverMcp { register( "loopover_explain_local_blockers", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: ExplainLocalBlockersOutput, }, async (input) => this.toolResult(await this.localBranchSlice(input, "scoreBlockers")), @@ -2110,7 +2001,7 @@ export class LoopoverMcp { register( "loopover_remediation_plan", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: RemediationPlanOutput, }, async (input) => this.toolResult(await this.remediationPlan(input)), @@ -2119,7 +2010,7 @@ export class LoopoverMcp { register( "loopover_prepare_pr_packet", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: PrepareLocalPrPacketOutput, }, async (input) => this.toolResult(await this.localBranchSlice(input, "prPacket")), @@ -2128,7 +2019,7 @@ export class LoopoverMcp { register( "loopover_draft_pr_body", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: DraftPrBodyOutput, }, async (input) => this.toolResult(await this.draftPrBody(input)), @@ -2182,7 +2073,7 @@ export class LoopoverMcp { register( "loopover_agent_prepare_pr_packet", { - inputSchema: localBranchAnalysisShape, + inputSchema: LocalBranchAnalysisInput, outputSchema: AgentRunBundleOutput, }, async (input) => this.toolResult(await this.agentPreparePrPacket(input)), @@ -4528,7 +4419,7 @@ export class LoopoverMcp { } // #699 path B: manage a miner's issue-watch subscriptions. Self-scoped; watch/unwatch need repoFullName. - private async watchIssues(input: z.infer>): Promise { + private async watchIssues(input: z.infer): Promise { this.requireContributorAccess(input.login); let changed: string | undefined; if (input.action === "watch" || input.action === "unwatch") { @@ -4673,7 +4564,7 @@ export class LoopoverMcp { }; } - private async previewScore(input: z.infer>): Promise { + private async previewScore(input: z.infer): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ @@ -4692,7 +4583,7 @@ export class LoopoverMcp { }; } - private async getEligibilityPlan(input: z.infer>): Promise { + private async getEligibilityPlan(input: z.infer): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ @@ -5047,7 +4938,7 @@ export class LoopoverMcp { }; } - private async explainScoreBreakdown(input: z.infer>): Promise { + private async explainScoreBreakdown(input: z.infer): Promise { if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); @@ -5088,7 +4979,7 @@ export class LoopoverMcp { }; } - private async comparePrVariants(variants: Array>>): Promise { + private async comparePrVariants(variants: Array>): Promise { const previews = []; for (const variant of variants) previews.push((await this.previewScore({ ...variant, targetType: "variant" })).data); previews.sort((left, right) => { @@ -5102,7 +4993,7 @@ export class LoopoverMcp { }; } - private async localBranchSlice(input: z.infer>, slice: "preflight" | "scorePreview" | "nextActions" | "scoreBlockers" | "prPacket"): Promise { + private async localBranchSlice(input: z.infer, slice: "preflight" | "scorePreview" | "nextActions" | "scoreBlockers" | "prPacket"): Promise { const analysis = await this.analyzeLocalBranch(input); return { summary: `${analysis.summary} (${slice}).`, @@ -5120,7 +5011,7 @@ export class LoopoverMcp { }; } - private async compareLocalVariants(variants: Array>>): Promise { + private async compareLocalVariants(variants: Array>): Promise { const analyses = []; for (const variant of variants) analyses.push(await this.analyzeLocalBranch(variant)); analyses.sort( @@ -5227,7 +5118,7 @@ export class LoopoverMcp { }; } - private async agentPreparePrPacket(input: z.infer>): Promise { + private async agentPreparePrPacket(input: z.infer): Promise { this.requireContributorAccess(input.login); const bundle = await preparePrPacketWithAgent(this.env, input, "mcp"); return { @@ -5236,7 +5127,7 @@ export class LoopoverMcp { }; } - private async remediationPlan(input: z.infer>): Promise { + private async remediationPlan(input: z.infer): Promise { const analysis = await this.analyzeLocalBranch(input); const plan = buildRemediationPlan({ login: analysis.login, @@ -5253,7 +5144,7 @@ export class LoopoverMcp { }; } - private async draftPrBody(input: z.infer>): Promise { + private async draftPrBody(input: z.infer): Promise { const analysis = await this.analyzeLocalBranch(input); const draft = buildPublicPrBodyDraft(analysis); // Human-readable summary carries the rendered markdown body; structured draft is returned as JSON. @@ -5263,7 +5154,7 @@ export class LoopoverMcp { }; } - private async analyzeLocalBranch(input: z.infer>) { + private async analyzeLocalBranch(input: z.infer) { this.requireContributorAccess(input.login); await this.requireRepoAccess(input.repoFullName); const [context, repo, issues, pullRequests, recentMergedPullRequests, bounties, snapshot, issueQuality, repoManifest] = await Promise.all([ diff --git a/test/contract/validate-mcp.test.ts b/test/contract/validate-mcp.test.ts index 31a607e111..c03250925f 100644 --- a/test/contract/validate-mcp.test.ts +++ b/test/contract/validate-mcp.test.ts @@ -31,6 +31,7 @@ import { createTestEnv } from "../helpers/d1"; import { checkAdvertisedMetadata, checkAdvertisedShape, + checkInputNarrowing, checkEveryToolCalled, checkVersionLock, checkWatchedPathsExist, @@ -160,7 +161,7 @@ async function validateSurface( expected: readonly McpToolDefinition[], ): Promise<{ failures: string[]; tools: number; validated: number; declined: number }> { const listed = (await client.listTools()).tools as unknown as ListedTool[]; - const failures = [...diffToolSets(expected, listed), ...checkAdvertisedShape(listed), ...checkAdvertisedMetadata(expected, listed)]; + const failures = [...diffToolSets(expected, listed), ...checkAdvertisedShape(listed), ...checkAdvertisedMetadata(expected, listed), ...checkInputNarrowing(expected, listed)]; const { validators, failures: compileFailures } = compileOutputSchemas(listed); failures.push(...compileFailures); const { called, failures: callFailures, validated, declined } = await smokeCallAll(client, listed, validators); @@ -181,10 +182,6 @@ describe("MCP contract validator (#9520)", () => { it("enforces the remote server's advertised contract", async () => { const client = await connect(new LoopoverMcp(createTestEnv()).createServer()); try { - // Set equality against a locality filter would be false precision: the remote server also - // serves several tools the registry marks `local-git`, because a caller may supply the branch - // metadata itself instead of having it read off a checkout. The strict direction asserted here - // is the one that matters -- nothing is registered without a contract entry. // Set equality against a locality filter would be false precision in BOTH directions: the // remote server also serves tools the registry marks `local-git` (a caller may supply the // branch metadata itself instead of having it read off a checkout), and it does NOT serve the @@ -195,8 +192,6 @@ describe("MCP contract validator (#9520)", () => { const registered = new Set(((await client.listTools()).tools as unknown as ListedTool[]).map((tool) => tool.name)); const result = await validateSurface(client, listToolDefinitions().filter((tool) => registered.has(tool.name))); report("remote", result); - for (const f of result.failures) process.stdout.write(`RFAIL ${f} -`); expect(result.failures).toEqual([]); expect(result.tools).toBeGreaterThan(100); @@ -282,15 +277,38 @@ describe("MCP contract validator (#9520)", () => { expect(duplicated.map(([name]) => name)).toEqual([]); }); - it("locks the published MCP version across the three places it appears", () => { + it("locks the published MCP version across the three places it appears", async () => { + // #9661: `serverInfoVersion` used to be the SAME EXPRESSION as `packageVersion`, so the one leg + // `checkVersionLock`'s own doc calls "the one that can drift" was asserted against itself. It is + // read off a connected client now -- the value a real client actually sees. const packageVersion = (JSON.parse(readFileSync(join(process.cwd(), "packages/loopover-mcp/package.json"), "utf8")) as { version: string }).version; - expect( - checkVersionLock({ - packageVersion, - advertisedLatestVersion: LATEST_RECOMMENDED_MCP_VERSION, - serverInfoVersion: packageVersion, - }), - ).toEqual([]); + const stdio = await import("../../packages/loopover-mcp/bin/loopover-mcp"); + const client = await connect(stdio.server); + try { + expect( + checkVersionLock({ + packageVersion, + advertisedLatestVersion: LATEST_RECOMMENDED_MCP_VERSION, + serverInfoVersion: client.getServerVersion()?.version, + }), + ).toEqual([]); + } finally { + await client.close().catch(() => undefined); + } + }, 180_000); + + it("locks the miner server's advertised version to its own package", async () => { + // The second server was unlocked entirely: it reads its version from its own package.json at + // construction and nothing compared the two. No compatibility constant exists for the miner, so this + // is the two-way lock. (The REMOTE server's `version: "0.1.0"` is hardcoded and stays out of the lock + // deliberately -- making it derivable is #9526's, not this one's.) + const packageVersion = (JSON.parse(readFileSync(join(process.cwd(), "packages/loopover-miner/package.json"), "utf8")) as { version: string }).version; + const client = await connect(createMinerMcpServer({})); + try { + expect(checkVersionLock({ packageVersion, serverInfoVersion: client.getServerVersion()?.version, serverLabel: "miner" })).toEqual([]); + } finally { + await client.close().catch(() => undefined); + } }); it("fails if a path the release automation reads has been moved or deleted", () => { diff --git a/test/unit/contract-registry.test.ts b/test/unit/contract-registry.test.ts index 5dddc7f208..9ee016d2d5 100644 --- a/test/unit/contract-registry.test.ts +++ b/test/unit/contract-registry.test.ts @@ -26,7 +26,15 @@ import { SCENARIO_LIMITS, PLAN_STEP_STATUSES, PUBLIC_SURFACE_SKIP_REASONS, + TEST_FRAMEWORKS, + QUEUE_STATUSES, + CLAIM_STATUSES, + MINER_RUN_STATES, } from "@loopover/contract"; +import { TEST_FRAMEWORKS as ENGINE_TEST_FRAMEWORKS } from "../../packages/loopover-engine/src/signals/test-evidence"; +import { QUEUE_STATUSES as MINER_QUEUE_STATUSES } from "../../packages/loopover-miner/lib/portfolio-queue"; +import { CLAIM_STATUSES as MINER_CLAIM_STATUSES } from "../../packages/loopover-miner/lib/claim-ledger"; +import { RUN_STATES as LIVE_MINER_RUN_STATES } from "../../packages/loopover-miner/lib/run-state"; import { LocalStatusStructuredInput } from "@loopover/contract/tools"; import { GetRepoContextInput } from "@loopover/contract/tools"; import { PREFLIGHT_LIMITS as ENGINE_PREFLIGHT_LIMITS } from "../../packages/loopover-engine/src/signals/preflight-limits"; @@ -253,6 +261,26 @@ describe("contract enums", () => { expect([...PUBLIC_SURFACE_SKIP_REASONS]).toEqual([...SERVER_PUBLIC_SURFACE_SKIP_REASONS]); }); + // #9660: the four restatements that had no pin at all. Three of them sat in tools/miner.ts rather than + // enums.ts, outside even the convention that a shared vocabulary lives in one file -- and two appear in + // OUTPUT schemas, where a new value makes a tool's real structuredContent fail the schema that same tool + // advertises. `validate:mcp` only notices if the cold fixture environment happens to produce it. + it("pins the test-framework vocabulary against the engine's live list", () => { + expect([...TEST_FRAMEWORKS]).toEqual([...ENGINE_TEST_FRAMEWORKS]); + }); + + it("pins the miner's queue and claim vocabularies against their live stores", () => { + expect([...QUEUE_STATUSES]).toEqual([...MINER_QUEUE_STATUSES]); + expect([...CLAIM_STATUSES]).toEqual([...MINER_CLAIM_STATUSES]); + }); + + it("pins the miner run states against the live run-state store", () => { + // RUN_STATES was a `readonly RunState[]` cast over a literal, with the union written out separately -- + // so the list could gain a state the type did not have. The type is derived from the list now, which is + // what makes this pin mean anything. + expect([...MINER_RUN_STATES]).toEqual([...LIVE_MINER_RUN_STATES]); + }); + it("pins the preflight input bounds against the engine's live limits", () => { // The contract restates PREFLIGHT_LIMITS because it cannot import the engine (zod-only leaf). // Without this pin, raising a bound on one side only would produce a schema that either rejects diff --git a/test/unit/validate-mcp-helpers.test.ts b/test/unit/validate-mcp-helpers.test.ts index 0625ddb93b..9ec831947e 100644 --- a/test/unit/validate-mcp-helpers.test.ts +++ b/test/unit/validate-mcp-helpers.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { checkAdvertisedMetadata, checkAdvertisedShape, + checkInputNarrowing, checkEveryToolCalled, checkVersionLock, checkWatchedPathsExist, @@ -90,6 +91,75 @@ describe("validate-mcp invariants", () => { }); }); + describe("an advertised input may only narrow the contract's (#9662)", () => { + const projected = (properties: string[], required: string[] = []): McpToolDefinition => + ({ name: "a", inputSchema: { type: "object", properties: Object.fromEntries(properties.map((k) => [k, {}])), required } }) as unknown as McpToolDefinition; + const advertised = (properties: string[], required: string[] = []) => ({ + name: "a", + inputSchema: { type: "object", properties: Object.fromEntries(properties.map((k) => [k, {}])), required }, + }); + + it("passes when the advertised schema is the contract's", () => { + expect(checkInputNarrowing([projected(["login", "repo"], ["repo"])], [advertised(["login", "repo"], ["repo"])])).toEqual([]); + }); + + it("passes when the server serves strictly less", () => { + // The sanctioned direction: a server whose route cannot honour a field says so by not advertising it. + expect(checkInputNarrowing([projected(["login", "repo"], ["repo"])], [advertised(["repo"], ["repo"])])).toEqual([]); + }); + + it("reports a property the contract never declared", () => { + expect(checkInputNarrowing([projected(["login"])], [advertised(["login", "invented"])])).toEqual([ + "a advertises input property invented, which its contract does not declare", + ]); + }); + + it("reports an optional contract field the server demands", () => { + // Not a widening of the accepted SET, but a widening of the caller's obligations -- and the catalog + // says the field is optional, so a caller following it gets rejected. + expect(checkInputNarrowing([projected(["login"])], [advertised(["login"], ["login"])])).toEqual([ + "a requires input property login, which its contract does not require", + ]); + }); + + it("stays quiet about a tool the server never registered, or one advertising no input schema", () => { + expect(checkInputNarrowing([projected(["login"])], [])).toEqual([]); + expect(checkInputNarrowing([projected(["login"])], [{ name: "a" }])).toEqual([]); + }); + }); + + describe("the version lock's serverInfo leg (#9661)", () => { + it("passes when all three agree", () => { + expect(checkVersionLock({ packageVersion: "3.16.0", advertisedLatestVersion: "3.16.0", serverInfoVersion: "3.16.0" })).toEqual([]); + }); + + it("reports a serverInfo that has drifted from its package", () => { + expect(checkVersionLock({ packageVersion: "3.16.0", advertisedLatestVersion: "3.16.0", serverInfoVersion: "3.15.2" })).toEqual([ + "stdio serverInfo reports 3.15.2 but its package is 3.16.0", + ]); + }); + + it("reports a compatibility constant that has drifted", () => { + expect(checkVersionLock({ packageVersion: "3.16.0", advertisedLatestVersion: "3.15.2", serverInfoVersion: "3.16.0" })).toEqual([ + "compatibility advertises 3.15.2 but @loopover/mcp is 3.16.0", + ]); + }); + + it("reports an ABSENT serverInfo version rather than reading it as a mismatch", () => { + // The case the old signature could not express: `undefined !== packageVersion` is true, but "the + // server advertised no version at all" and "the server advertised a different one" are not the same + // problem, and an empty string must not read as a version either. + expect(checkVersionLock({ packageVersion: "3.16.0", serverInfoVersion: undefined })).toEqual(["stdio serverInfo advertises no version"]); + expect(checkVersionLock({ packageVersion: "3.16.0", serverInfoVersion: " " })).toEqual(["stdio serverInfo advertises no version"]); + }); + + it("names the server it is locking, so one helper can cover more than one", () => { + expect(checkVersionLock({ packageVersion: "1.2.3", serverInfoVersion: "1.0.0", serverLabel: "miner" })).toEqual([ + "miner serverInfo reports 1.0.0 but its package is 1.2.3", + ]); + }); + }); + it("requires a description and object-typed input and output schemas", () => { const failures = checkAdvertisedShape([ { name: "ok", description: "d", inputSchema: { type: "object" }, outputSchema: { type: "object" } }, @@ -119,7 +189,7 @@ describe("validate-mcp invariants", () => { "compatibility advertises 1.2.2 but @loopover/mcp is 1.2.3", ]); expect(checkVersionLock({ packageVersion: "1.2.3", advertisedLatestVersion: "1.2.3", serverInfoVersion: "0.9.0" })).toEqual([ - "stdio serverInfo reports 0.9.0 but @loopover/mcp is 1.2.3", + "stdio serverInfo reports 0.9.0 but its package is 1.2.3", ]); }); From b077b0f736b9175baf57f0a52013fcd46e9fc8dd Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:53:51 -0700 Subject: [PATCH 2/2] mcp: delete the last hand-written tool shapes from the remote server (#9662) `grep -cE "^const \w+Shape = \{" src/mcp/server.ts` returns **0**. Four of them were still a registration's `inputSchema` -- `issueRagShape`, `findOpportunitiesShape`, `localBranchVariantsShape`, `variantsShape` -- agreeing with their contract entries only by history, with nothing to keep them agreeing. The other fifteen had stopped being registered but survived as the TYPE of their handler (`z.infer>`), which is the same defect one step removed: a handler typed off a hand-written shape can drift from the contract the tool actually registers with, and the compiler is satisfied either way. Both variant tools took the narrower element type: `ComparePrVariantsInput.variants` was an array of the STDIO server's `LocalScoreInput` while the remote's own handler expected the wider one, so the remote accepted variants its registry entry rejected. The element is the union now, with `StdioComparePrVariantsInput` / `StdioCompareLocalVariantsInput` as the declared narrowings -- the same treatment their singular siblings got. Gone with them: `changedFileSchema`, `validationEntrySchema` and `planRepoIssuesMilestoneShape`, which existed only to build the shapes above; the contract already declares all three. --- .../src/tools/local-branch.ts | 10 + packages/loopover-mcp/bin/loopover-mcp.ts | 10 +- src/mcp/server.ts | 324 ++---------------- 3 files changed, 43 insertions(+), 301 deletions(-) diff --git a/packages/loopover-contract/src/tools/local-branch.ts b/packages/loopover-contract/src/tools/local-branch.ts index b7cffb5f0e..f2e6ac4a0c 100644 --- a/packages/loopover-contract/src/tools/local-branch.ts +++ b/packages/loopover-contract/src/tools/local-branch.ts @@ -253,6 +253,10 @@ export const draftPrBodyTool = defineTool({ }); export const CompareLocalVariantsInput = z.object({ + variants: z.array(LocalBranchAnalysisInput).min(1).max(10), +}); +/** What the STDIO server serves: each variant is analysed from the checkout, not from caller-supplied shas. */ +export const StdioCompareLocalVariantsInput = z.object({ variants: z.array(CurrentBranchInput).min(1).max(10), }); export const compareLocalVariantsTool = defineTool({ @@ -381,6 +385,12 @@ export const getEligibilityPlanTool = defineTool({ }); export const ComparePrVariantsInput = z.object({ + // #9662: the same union each variant's SINGULAR tool takes, for the same reason -- the element type was + // the stdio server's narrower one, so the remote's own handler accepted variants the registry rejected. + variants: z.array(LocalScorePreviewInput).min(1).max(10), +}); +/** What the STDIO server serves: each variant goes through its local preview, which supplies the rest. */ +export const StdioComparePrVariantsInput = z.object({ variants: z.array(LocalScoreInput).min(1).max(10), }); export const comparePrVariantsTool = defineTool({ diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 188874f61f..f7f4d0b018 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -90,8 +90,6 @@ import { CheckTestEvidenceInput, ClearSelftuneOverrideInput, ClosePrInput, - CompareLocalVariantsInput, - ComparePrVariantsInput, CreateBranchInput, CurrentBranchInput, DecidePendingActionInput, @@ -166,6 +164,8 @@ import { ValidateLinkedIssueInput, WatchIssuesInput, StdioWatchIssuesInput, + StdioCompareLocalVariantsInput, + StdioComparePrVariantsInput, getToolContract, projectToolDefinition, ListPendingActionsStdioInput, @@ -1620,13 +1620,14 @@ registerStdioTool( registerStdioTool( "loopover_compare_pr_variants", - async ({ variants }: z.infer) => { + async ({ variants }: z.infer) => { const roots = await clientWorkspaceRoots(); const previews = []; for (const variant of variants) previews.push(await previewLocalScore(withWorkspaceRoots({ ...variant, targetKey: variant.targetKey ?? `variant:${previews.length + 1}` }, roots))); previews.sort((left, right) => Number(right?.remotePreview?.result?.effectiveEstimatedScore ?? right?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left?.remotePreview?.result?.effectiveEstimatedScore ?? left?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0)); return toolResult("LoopOver PR variant comparison.", { variants: previews }); }, + { input: StdioComparePrVariantsInput }, ); registerStdioTool( @@ -1757,7 +1758,7 @@ registerStdioTool( registerStdioTool( "loopover_compare_local_variants", - async ({ variants }: z.infer) => { + async ({ variants }: z.infer) => { const roots = await clientWorkspaceRoots(); const analyses = []; for (const variant of variants) analyses.push(await analyzeCurrentBranch(withWorkspaceRoots(variant, roots))); @@ -1776,6 +1777,7 @@ registerStdioTool( })), }); }, + { input: StdioCompareLocalVariantsInput }, ); registerStdioTool( diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ef0080829b..d279e6854a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -111,6 +111,8 @@ import { PreflightLocalDiffOutput, RunLocalScorerInput, RunLocalScorerOutput, + CompareLocalVariantsInput, + ComparePrVariantsInput, CompareVariantsOutput, PreviewLocalPrScoreOutput, PreflightCurrentBranchOutput, @@ -148,7 +150,9 @@ import { ValidateLinkedIssueOutput, CheckBeforeStartInput, CheckBeforeStartOutput, + FindOpportunitiesInput, FindOpportunitiesOutput, + RetrieveIssueContextInput, RetrieveIssueContextOutput, GetEligibilityPlanOutput, ListNotificationsInput, @@ -270,19 +274,12 @@ import { } from "@loopover/contract/tools"; import { TOOL_CATEGORIES, type ToolCategory } from "@loopover/contract"; import { - MAX_FIND_OPPORTUNITIES_LANGUAGE_LENGTH, - MAX_FIND_OPPORTUNITIES_LANGUAGES, - MAX_FIND_OPPORTUNITIES_OWNER_LENGTH, - MAX_FIND_OPPORTUNITIES_REPO_LENGTH, - MAX_FIND_OPPORTUNITIES_TARGETS, runFindOpportunities, validateFindOpportunitiesInput, } from "./find-opportunities"; import { loadPrAiReviewFindings, assertContributorOwnsPullRequest } from "./pr-ai-review-findings"; import { sanitizeUntrustedMcpText } from "./untrusted-text"; import { - MAX_ISSUE_RAG_OWNER_LENGTH, - MAX_ISSUE_RAG_REPO_LENGTH, runIssueRagRetrieval, validateIssueRagInput, } from "./issue-rag"; @@ -481,7 +478,6 @@ import { evaluateEscalation } from "../loop-escalation"; import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { attachDataQuality, buildRepoDataQuality } from "../signals/data-quality"; -import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; import { @@ -513,70 +509,12 @@ function decisionPackSummary(login: string, freshness: string, rebuildEnqueued: } -const ownerRepoPullShape = { - owner: z.string().min(1), - repo: z.string().min(1), - number: z.number().int().positive(), -}; - - -// (#8660) write-side mirror of DELETE /v1/repos/:owner/:repo/selftune/overrides. `confirm` is the required -// confirmation field this destructive reset must carry, matching the sibling maintainer-mutation tools' -// deliberate action params (loopover_set_agent_paused's `paused`, loopover_set_action_autonomy's action/level) -// and the REST route's own "an optional body is treated as a confirmation of the override being cleared" intent. -const clearSelftuneOverrideShape = { - owner: z.string().min(1), - repo: z.string().min(1), - confirm: z.literal(true), -}; - -// (#9298) owner/repo/pull (mirrors ownerRepoPullShape) plus the same body fields the REST route's -// postMergeIncidentReportSchema validates. Declared inline rather than spread from that schema's `.shape` -// because src/api/routes.ts imports this module before it defines postMergeIncidentReportSchema, so reading -// `.shape` at module-init time would dereference `undefined` (circular-import temporal dead zone). -const fileIncidentReportShape = { - ...ownerRepoPullShape, - description: z.string().min(1).max(4000), - severity: z.enum(["low", "medium", "high", "critical"]), - mergedSha: z - .string() - .regex(/^[0-9a-f]{7,40}$/i) - .optional(), -}; -const issueRagShape = { - owner: z.string().max(MAX_ISSUE_RAG_OWNER_LENGTH), - repo: z.string().max(MAX_ISSUE_RAG_REPO_LENGTH), - title: z.string().max(PREFLIGHT_LIMITS.titleChars), - body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), - labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), - topK: z.number().int().min(1).max(12).optional(), -}; -const findOpportunitiesShape = { - targets: z - .array( - z.object({ - owner: z.string().min(1).max(MAX_FIND_OPPORTUNITIES_OWNER_LENGTH), - repo: z.string().min(1).max(MAX_FIND_OPPORTUNITIES_REPO_LENGTH), - }), - ) - .max(MAX_FIND_OPPORTUNITIES_TARGETS) - .optional(), - searchQuery: z.string().min(1).max(500).optional(), - goalSpec: z - .object({ - lane: z.string().min(1).optional(), - minRankScore: z.number().min(0).max(100).optional(), - languages: z.array(z.string().min(1).max(MAX_FIND_OPPORTUNITIES_LANGUAGE_LENGTH)).max(MAX_FIND_OPPORTUNITIES_LANGUAGES).optional(), - }) - .optional(), - limit: z.number().int().min(1).max(50).optional(), -}; // #7721 admin tools — self-hosted-instance-only, gated behind LOOPOVER_MCP_ADMIN_ENABLED at @@ -584,105 +522,13 @@ const findOpportunitiesShape = { // Schemas for all four (#9518) come from @loopover/contract/tools -- AdminGetConfigInput, // AdminWriteConfigInput, AdminListConfigBackupsInput, AdminTriggerRedeployInput. -const preflightShape = { - repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), - contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), - title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars), - body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), - labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), - changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), - linkedIssues: z.array(z.number().int().positive()).max(PREFLIGHT_LIMITS.linkedIssues).optional(), - tests: z.array(z.string().max(PREFLIGHT_LIMITS.testChars)).max(PREFLIGHT_LIMITS.tests).optional(), - authorAssociation: z.string().max(PREFLIGHT_LIMITS.authorAssociationChars).optional(), -}; - -const localDiffPreflightShape = { - ...preflightShape, - changedLineCount: z.number().int().min(0).optional(), - testFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), - commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), -}; - - - -// Changed-file metadata + local validation results — shared by the local-branch analysis and the #782 local -// scorer. METADATA ONLY (paths + line counts), never source content, so the no-upload boundary holds. -const changedFileSchema = z - .object({ - path: z.string().min(1), - previousPath: z.string().min(1).optional(), - additions: z.number().int().min(0).optional(), - deletions: z.number().int().min(0).optional(), - status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), - binary: z.boolean().optional(), - }) - .strict(); - -const validationEntrySchema = z - .object({ - command: z.string().min(1), - status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), - summary: z.string().optional(), - durationMs: z.number().int().min(0).optional(), - exitCode: z.number().int().min(0).optional(), - }) - .strict(); - -// #782 run_local_scorer input — changed-file metadata + the local validation results. -const runLocalScorerShape = { - changedFiles: z.array(changedFileSchema).min(1).max(500), - validation: z.array(validationEntrySchema).max(50).optional(), -}; - // GitHub permissions that imply real write access to a repo. Cached PR author_association can report // MEMBER/COLLABORATOR for users without push permission, so write-capable MCP surfaces must verify live. const REPO_WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]); -// #3003 (part of #2993) — on-demand repo-doc refresh, the manual counterpart to the scheduled sweep -// (src/queue/processors.ts's "repo-doc-refresh-sweep"). Both call the SAME performRepoDocRefresh runner, which -// itself calls openRepoDocPullRequest -- the one place enable/scope/eligibility/diffing is decided. -const refreshRepoDocsShape = { - owner: z.string().min(1), - repo: z.string().min(1), -}; - - -// #6757: dryRun/create/limit mirror the REST route's contributorIssueDraftGenerateSchema EXACTLY (same -// defaults, same bounds) so the two surfaces cannot drift. `create` alone does not open issues — the handler -// re-applies the route's explicit_create_requires_dry_run_false guard, so a caller must pass BOTH create:true -// and dryRun:false, and can never silently create. -const generateContributorIssueDraftsShape = { - owner: z.string().min(1), - repo: z.string().min(1), - dryRun: z.boolean().optional().default(true), - create: z.boolean().optional().default(false), - limit: z.number().int().min(1).max(20).optional().default(5), -}; - -// #7426: dryRun/create/limit mirror generateContributorIssueDraftsShape's own bounds/defaults (create alone is -// rejected -- the handler re-applies the explicit_create_requires_dry_run_false guard). `limit` is capped lower -// (10, not 20): every draft here costs real LLM spend, unlike that tool's zero-cost static signals. -// #7427: title/description/dueOn are the CALLER's own input, never model-generated -- milestone metadata is -// maintainer-authored/approved by design. Only ever consulted when actually creating; a dry-run preview makes -// no milestone-related GitHub calls at all. -const planRepoIssuesMilestoneShape = z.object({ - title: z.string().min(1).max(200), - description: z.string().max(2000).optional(), - dueOn: z.string().datetime({ offset: true }).optional(), -}); - -const planRepoIssuesShape = { - owner: z.string().min(1), - repo: z.string().min(1), - goal: z.string().min(1).max(2000), - dryRun: z.boolean().optional().default(true), - create: z.boolean().optional().default(false), - limit: z.number().int().min(1).max(10).optional().default(5), - milestone: planRepoIssuesMilestoneShape.optional(), -}; // #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo. @@ -691,9 +537,6 @@ const planRepoIssuesShape = { -const localBranchVariantsShape = { - variants: z.array(LocalBranchAnalysisInput.strict()).min(1).max(10), -}; function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: string }>, repoFullName: string): number { @@ -703,9 +546,6 @@ function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: -const variantsShape = { - variants: z.array(RemoteLocalScorePreviewInput).min(1).max(10), -}; @@ -733,34 +573,6 @@ export const gatePrecisionOutputSchema = { -const predictGateShape = { - login: z.string().min(1), - owner: z.string().min(1), - repo: z.string().min(1), - title: z.string().min(1), - body: z.string().optional(), - labels: z.array(z.string()).optional(), - linkedIssues: z.array(z.number().int().positive()).optional(), - // The PR's changed file PATHS (metadata only — paths, never source content). Supplying them lets the predictor - // also evaluate the focus-manifest path policy + path-gated pre-merge checks, matching the live gate (#11-13/#18). - changedPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(500).optional(), -}; - -// Pure local-metadata computation (no repo data, no secrets) — the agent supplies its own diff metadata -// (paths + line counts, never source), so there is nothing to scope. Mirrors the other local-* tools. -const checkSlopRiskShape = { - changedFiles: z - .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) - .max(2000), - description: z.string().max(20000).optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), - testFiles: z.array(z.string().max(400)).max(2000).optional(), - commitMessages: z.array(z.string().max(2000)).max(200).optional(), - hasLinkedIssue: z.boolean().optional(), - issueDiscoveryLane: z.boolean().optional(), -}; - - // Idea-intake bridge input (#4798, spec #4779). Fields are loose here so the engine's validateIdeaSubmission // owns the real bounds/format checks and returns the actionable error list — an empty/malformed submission // reaches the handler rather than being rejected upstream by the schema. `decomposition` is the optional @@ -779,91 +591,9 @@ const checkSlopRiskShape = { // Loop escalation evaluator input (#4806): an already-computed loop outcome + health tier + operator signals. -// Deterministic structural-improvement counterpart to checkSlopRiskShape (#4746, sub-issue I of epic #4737): -// the positive-axis mirror of checkSlopRisk, same pure local-metadata contract. changedFiles/tests/testFiles -// are reused verbatim (same shape as checkSlopRiskShape) so the two signals never disagree about what counts -// as test evidence. complexityDeltas/duplicationDeltas mirror ComplexityDeltaLike/DuplicationDeltaLike -// (src/signals/improvement.ts) as already-derived structured deltas — the calling agent computes them from -// its own local working tree (real before/after content, no reconstructOldContent trick needed) and supplies -// them here; this tool never reads file content or diffs itself. Every field is optional: -// buildStructuralImprovementAssessment degrades cleanly to "insufficient-signal" when nothing is supplied -// (see its own tests), so there is no synthetic "at least one field required" check to duplicate here. No -// auth required — same choice as checkSlopRisk: a pure function over caller-supplied structured data with no -// owner/repo/login to scope, and improvementScore carries no gate/blocker power (advisory-only; see -// improvement.ts's header comment), so there is nothing to gate. -const checkImprovementPotentialShape = { - changedFiles: z - .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) - .max(2000) - .optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), - testFiles: z.array(z.string().max(400)).max(2000).optional(), - patchCoverageDeltaPercent: z.number().optional(), - complexityDeltas: z - .array( - z.object({ - file: z.string().min(1).max(400), - line: z.number().int().min(1), - name: z.string().min(1).max(400), - before: z.number().int().min(0), - after: z.number().int().min(0), - delta: z.number().int(), - }), - ) - .max(2000) - .optional(), - duplicationDeltas: z - .array( - z.object({ - file: z.string().min(1).max(400), - line: z.number().int().min(1), - duplicateOfLine: z.number().int().min(1), - lines: z.number().int().min(1), - }), - ) - .max(2000) - .optional(), -}; - -// Coverage-gap self-check (#2235): pure local-metadata, like checkSlopRisk — the agent supplies its changed -// paths (plus any test paths) and asks whether the change carries enough test evidence, no source uploaded. -const checkTestEvidenceShape = { - changedPaths: z.array(z.string().min(1).max(400)).max(2000), - testFiles: z.array(z.string().min(1).max(400)).max(2000).optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), -}; -// Issue-side slop triage (#533): pure local-metadata, like checkSlopRisk — the agent supplies the issue -// title + body, nothing to scope. Advisory-only; issues never block. -const checkIssueSlopShape = { - title: z.string().max(500).optional(), - body: z.string().max(40000).optional(), -}; - - -// Boundary-safe test-generation suggestion (#1972): pure local-metadata, like checkSlopRisk — the agent -// supplies changed-file paths plus precomputed boundary-touch metadata from its local diff scan. The remote MCP -// boundary never accepts patch/source text. Advisory-only; this tool never blocks or writes anything — it only -// returns criteria/hints for the caller's OWN agent to scaffold tests from. -const suggestBoundaryTestsShape = { - changedFiles: z.array(z.object({ path: z.string().min(1).max(400) }).strict()).max(500), - boundaryTouches: z - .array( - z - .object({ - path: z.string().min(1).max(400), - kind: z.enum(["array_index_bounds", "null_or_undefined_branch", "empty_collection_check"]), - }) - .strict(), - ) - .max(20) - .optional(), - tests: z.array(z.string().max(400)).max(2000).optional(), - testFiles: z.array(z.string().max(400)).max(2000).optional(), -}; - @@ -1655,7 +1385,7 @@ export class LoopoverMcp { register( "loopover_find_opportunities", { - inputSchema: findOpportunitiesShape, + inputSchema: FindOpportunitiesInput, outputSchema: FindOpportunitiesOutput, }, async (input) => this.toolResult(await this.findOpportunities(input)), @@ -1664,7 +1394,7 @@ export class LoopoverMcp { register( "loopover_retrieve_issue_context", { - inputSchema: issueRagShape, + inputSchema: RetrieveIssueContextInput, outputSchema: RetrieveIssueContextOutput, }, async (input) => this.toolResult(await this.retrieveIssueContext(input)), @@ -1928,7 +1658,7 @@ export class LoopoverMcp { register( "loopover_compare_pr_variants", { - inputSchema: variantsShape, + inputSchema: ComparePrVariantsInput, outputSchema: CompareVariantsOutput, }, async (input) => this.toolResult(await this.comparePrVariants(input.variants)), @@ -2028,7 +1758,7 @@ export class LoopoverMcp { register( "loopover_compare_local_variants", { - inputSchema: localBranchVariantsShape, + inputSchema: CompareLocalVariantsInput, outputSchema: CompareVariantsOutput, }, async (input) => this.toolResult(await this.compareLocalVariants(input.variants)), @@ -3008,7 +2738,7 @@ export class LoopoverMcp { }; } - private async findOpportunities(input: z.infer>): Promise { + private async findOpportunities(input: z.infer): Promise { const validated = validateFindOpportunitiesInput(input); if (!validated.ok) { return { @@ -3036,7 +2766,7 @@ export class LoopoverMcp { }; } - private async retrieveIssueContext(input: z.infer>): Promise { + private async retrieveIssueContext(input: z.infer): Promise { const validated = validateIssueRagInput(input); if (!validated.ok) { return { @@ -3921,7 +3651,7 @@ export class LoopoverMcp { // gate as the sibling write tools (loopover_set_agent_paused/loopover_set_action_autonomy) — stricter than the // audit tool's read gate — and calls the exact deleteLiveOverride the REST route already uses, returning the // route's { repoFullName, cleared: true } shape. Branch-free: `confirm` is enforced by the input schema. - private async clearSelftuneOverride(input: z.infer>): Promise { + private async clearSelftuneOverride(input: z.infer): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoManageAccess(fullName); await deleteLiveOverride(this.env as unknown as StorageEnv, fullName); @@ -3935,7 +3665,7 @@ export class LoopoverMcp { // gate, then the REST route's exact PR-must-exist-and-be-merged validation, then the same // recordPostMergeIncidentReport persistence (reporterKind "customer", the calling actor) and response shape. // Missing/unmerged PRs return the route's 404/409 error codes as a normal `{ ok: false, error }` tool result. - private async fileIncidentReport(input: z.infer>): Promise { + private async fileIncidentReport(input: z.infer): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoManageAccess(fullName); const pullRequest = await getPullRequest(this.env, fullName, input.number); @@ -4215,7 +3945,7 @@ export class LoopoverMcp { }; } - private async checkSlopRisk(input: z.infer>): Promise { + private async checkSlopRisk(input: z.infer): Promise { await this.enforceToolRateLimit("loopover_check_slop_risk"); const assessment = buildSlopAssessment(input); // Return band + findings only — omit the exact numeric score and rubric thresholds to prevent @@ -4227,7 +3957,7 @@ export class LoopoverMcp { } private async checkImprovementPotential( - input: z.infer>, + input: z.infer, ): Promise { await this.enforceToolRateLimit("loopover_check_improvement_potential"); const assessment = buildStructuralImprovementAssessment(input); @@ -4241,7 +3971,7 @@ export class LoopoverMcp { }; } - private async checkTestEvidence(input: z.infer>): Promise { + private async checkTestEvidence(input: z.infer): Promise { await this.enforceToolRateLimit("loopover_check_test_evidence"); // #6749: the classification/guidance logic now lives in the engine's buildTestEvidenceReport, shared with // POST /v1/lint/test-evidence and the local CLI mirror so all three surfaces agree by construction. @@ -4252,7 +3982,7 @@ export class LoopoverMcp { }; } - private async checkIssueSlop(input: z.infer>): Promise { + private async checkIssueSlop(input: z.infer): Promise { await this.enforceToolRateLimit("loopover_check_issue_slop"); const assessment = buildIssueSlopAssessment(input); return { @@ -4261,7 +3991,7 @@ export class LoopoverMcp { }; } - private suggestBoundaryTests(input: z.infer>): ToolPayload { + private suggestBoundaryTests(input: z.infer): ToolPayload { const changedPaths = new Set(input.changedFiles.map((file) => file.path)); const touches = (input.boundaryTouches ?? []).filter((touch) => changedPaths.has(touch.path)); const finding = buildBoundaryTestGenerationFinding({ touches, tests: input.tests, testFiles: input.testFiles }); @@ -4276,7 +4006,7 @@ export class LoopoverMcp { * (#2234): resolves the repo's public data + config and runs the SAME deterministic predictor, so the two tools * can never diverge (one returns the top-line verdict, the other the itemized per-rule dispositions). */ private async computePredictedGateVerdict( - input: z.infer>, + input: z.infer, ): Promise<{ repoFullName: string; verdict: PredictedGateVerdict }> { // #9138: shared by both loopover_predict_gate and loopover_explain_gate_disposition -- neither previously // called enforceToolRateLimit, so the only ceiling was the shared /mcp route class (120/min), well above @@ -4332,7 +4062,7 @@ export class LoopoverMcp { return { repoFullName, verdict }; } - private async predictGate(input: z.infer>): Promise { + private async predictGate(input: z.infer): Promise { const { repoFullName, verdict } = await this.computePredictedGateVerdict(input); return { summary: `Predicted LoopOver gate for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion}.`, @@ -4343,7 +4073,7 @@ export class LoopoverMcp { /** #2234: the itemized per-rule dispositions behind predict_gate's verdict — which specific gate rules would * block vs advise, and why. Reuses computePredictedGateVerdict (identical prediction), then reshapes it via the * pure buildGateDispositions. Read-only reasoning surface — no merge/close decision. */ - private async explainGateDisposition(input: z.infer>): Promise { + private async explainGateDisposition(input: z.infer): Promise { const { repoFullName, verdict } = await this.computePredictedGateVerdict(input); const dispositions = buildGateDispositions(verdict); const blocking = dispositions.filter((disposition) => disposition.status === "block").length; @@ -4534,7 +4264,7 @@ export class LoopoverMcp { }; } - private async preflightPr(input: z.infer>): Promise { + private async preflightPr(input: z.infer): Promise { await this.requireRepoAccess(input.repoFullName); const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ getRepository(this.env, input.repoFullName), @@ -4549,7 +4279,7 @@ export class LoopoverMcp { }; } - private async preflightLocalDiff(input: z.infer>): Promise { + private async preflightLocalDiff(input: z.infer): Promise { await this.requireRepoAccess(input.repoFullName); const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ getRepository(this.env, input.repoFullName), @@ -4604,7 +4334,7 @@ export class LoopoverMcp { // #782 — pure deterministic token scorer over caller-supplied changed-file metadata. No repo/contributor // access required: it reveals nothing beyond a computation on the caller's own diff stats. - private runLocalScorer(input: z.infer>): ToolPayload { + private runLocalScorer(input: z.infer): ToolPayload { const tokenScores = computeLocalScorerTokens({ changedFiles: input.changedFiles, validation: input.validation }); return { summary: `Local token scores — ${tokenScores.sourceTokenScore} source / ${tokenScores.testTokenScore} test / ${tokenScores.nonCodeTokenScore} non-code (total ${tokenScores.totalTokenScore}).`, @@ -4826,7 +4556,7 @@ export class LoopoverMcp { // directly), so -- unlike propose/decide's stage-then-accept pattern for genuinely destructive actions -- // executing it synchronously in one call is appropriately safe. requireRepoManageAccess is checked FIRST, // before performRepoDocRefresh touches anything. - private async refreshRepoDocs(input: z.infer>): Promise { + private async refreshRepoDocs(input: z.infer): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoManageAccess(fullName); const result = await performRepoDocRefresh(this.env, fullName); @@ -4846,7 +4576,7 @@ export class LoopoverMcp { // kill-switch. The result strips the per-draft `drafts[]` (title/body text) from the public-safe tool data, // surfacing only the counts + posture, like getAgentAuditFeed's scrub. private async generateContributorIssueDrafts( - input: z.infer>, + input: z.infer, ): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoManageAccess(fullName); @@ -4881,7 +4611,7 @@ export class LoopoverMcp { // still overlays the global agent kill-switch on top). Unlike generateContributorIssueDrafts, the response // includes each draft's full title/body/labels -- see planRepoIssuesOutputSchema's doc comment for why that's // safe here (no loopover-internal signal to scrub). - private async planRepoIssues(input: z.infer>): Promise { + private async planRepoIssues(input: z.infer): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoManageAccess(fullName); if (input.create && input.dryRun !== false) { @@ -4959,7 +4689,7 @@ export class LoopoverMcp { }; } - private async explainReviewRisk(input: z.infer>): Promise { + private async explainReviewRisk(input: z.infer): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); const [repo, issues, pullRequests, bounties] = await Promise.all([