You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
Two routes publish a zod request schema in the OpenAPI document that nothing ever validates against.
src/openapi/spec.ts:1788 and :1804 register the operations with FindOpportunitiesRequestSchema and IssueRagRetrieveRequestSchema as their request bodies. Those two
constants (src/openapi/schemas.ts:2035 and :2092) are referenced from exactly two files — src/openapi/schemas.ts where they are declared and src/openapi/spec.ts where they are emitted:
A generated client built from /openapi.json therefore rejects requests the server accepts and sends
requests the server rejects. The MCP tools of the same name make it a three-way split: the remote server
registers loopover_find_opportunities and loopover_retrieve_issue_context with the CONTRACT schemas
(packages/loopover-contract/src/tools/local-branch.ts:495 and :524), which the MCP SDK enforces before
the handler runs — so limit: 7.9 is a -32602 over MCP and a 200 over REST, for the same computation.
Requirements
Both routes must validate their request body against the same zod object the OpenAPI document publishes
for them, before any hand-rolled validation runs.
The published request schemas must be single-sourced from @loopover/contract's existing FindOpportunitiesInput (packages/loopover-contract/src/tools/local-branch.ts:495) and RetrieveIssueContextInput (:524) rather than remaining independent restatements in src/openapi/schemas.ts. After this change there must be exactly one zod declaration per operation,
serving the MCP tool, the REST handler, and the document.
The hand-rolled validators keep the checks zod cannot express — the cross-field targets_or_search_query_required rule, target de-duplication, and the trimming/normalisation — and
drop the bound and type checks the schema now enforces ahead of them. They must not be deleted: they
still run, on already-parsed input.
The 400 response body must not change shape. /v1/opportunities/find keeps returning { status: "invalid_request", ranked: [], totalCandidates: 0, reason } and /v1/issue-rag/retrieve
keeps returning { status: "invalid_request", repoFullName: "", reason, telemetry: {...} }; a schema
rejection must map onto the same shape with a reason, not onto invalidRequestBody's { error, issues }.
Silent truncation must stop being silent where the published schema says the value is rejected: an
over-long labels array or body on /v1/issue-rag/retrieve is a 400, matching both the document and
the MCP tool.
RetrieveIssueContextInput (packages/loopover-contract/src/tools/local-branch.ts:524-531) must stop
publishing values every handler refuses. It declares owner, repo and title with a max and no min, so the catalog says owner: "" is valid while validateIssueRagInput
(src/mcp/issue-rag.ts:41-42) always returns owner_and_repo_required / title_required for it. Add .min(1) to all three, matching the shared ownerRepoInput
(packages/loopover-contract/src/shared.ts:11) every other repo-scoped tool uses. The result must stay a z.ZodObject — ToolContract (packages/loopover-contract/src/tool-definition.ts:65) requires it and
both servers pass the object itself to registerTool.
What must NOT change: MAX_ISSUE_RAG_OWNER_LENGTH / MAX_ISSUE_RAG_REPO_LENGTH / MAX_FIND_OPPORTUNITIES_* and the PREFLIGHT_LIMITS values they read; the authorization order at src/api/routes.ts:3591-3600 and :3616 (validation first, then repo/discovery access, then the run);
and the 200 response schemas.
What must NOT change: the MCP tools' behaviour for input the contract already accepts.
⚠️ Required pattern: src/openapi/schemas.ts already documents this exact discipline for a sibling
operation — see the comment at src/openapi/schemas.ts:2123 ("Field-level parity with EvaluateEscalationInput in @loopover/contract"). Make that parity structural by importing the contract
schema instead of restating it. What does NOT satisfy this issue: (a) editing FindOpportunitiesRequestSchema/IssueRagRetrieveRequestSchema to match the handlers' looseness, which
publishes a weaker contract than the MCP tool enforces and keeps two declarations; (b) wiring the schemas
into the handlers while leaving them as separate copies in src/openapi/schemas.ts; (c) deleting the
hand-rolled validators along with their cross-field rules.
Deliverables
FindOpportunitiesRequestSchema and IssueRagRetrieveRequestSchema in src/openapi/schemas.ts are
derived from FindOpportunitiesInput and RetrieveIssueContextInput in @loopover/contract, not
restated.
src/api/routes.ts:3582 and :3607 parse the body with that schema before calling validateFindOpportunitiesInput / validateIssueRagInput, returning each route's existing 400 body
shape with a reason on a schema failure.
validateFindOpportunitiesInput (src/mcp/find-opportunities.ts:79) and validateIssueRagInput
(src/mcp/issue-rag.ts:35) no longer silently truncate labels or body, and no longer accept a
non-integer limit/topK.
A regression test at test/integration/api.test.ts named for this bug covering every row of the
table above. Seven of the eight rows must end up matching the published schema (400 for a
non-integer/out-of-range limit or topK, for an empty searchQuery, for an over-long labels
array or body, and for an empty owner). The {} row is the one cross-field rule JSON Schema
cannot express: it stays a 400, and the operation's description in src/openapi/spec.ts:1788 must
state that rule in prose so the document is not silently wrong about it.
A test asserting the MCP tool and the REST route now agree — loopover_find_opportunities with { targets: [...], limit: 7.9 } and POST /v1/opportunities/find with the same body both refuse it.
RetrieveIssueContextInput declares .min(1) on owner, repo and title, with a test in test/unit/contract-registry.test.ts asserting {owner:"",repo:"x",title:"t"}, {owner:"o",repo:"",title:"t"} and {owner:"o",repo:"r",title:""} all fail to parse, and that getToolContract("loopover_retrieve_issue_context")!.input is still a z.ZodObject.
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example wiring the schemas into the handlers but leaving src/openapi/schemas.ts's copies as
independent declarations, or fixing /v1/opportunities/find alone — does not resolve this issue.
Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's coverage.include covers src/**/*.ts (line 78) and packages/loopover-contract/src/**/*.ts (line 108),
so every touched path is measured and gated. Both arms of each new branch need a test: the schema safeParse success/failure arms on both routes, and each early-return in the two hand-rolled validators
that changes from truncation to rejection. The existing Number.isFinite guards in normalizeFindOpportunitiesLimit (src/mcp/find-opportunities.ts:74) and normalizeIssueRagTopK
(src/review/issue-rag-retrieval.ts:53) stay and must keep both arms covered.
Expected Outcome
A client generated from /openapi.json sends bodies these two routes accept and stops sending bodies they
reject, and the same request is accepted or refused identically whether it arrives over REST or as an MCP
tool call — from one zod declaration instead of three.
Links & Resources
src/openapi/schemas.ts:2035 — FindOpportunitiesRequestSchema, published but never used to validate
Context
Two routes publish a zod request schema in the OpenAPI document that nothing ever validates against.
src/openapi/spec.ts:1788and:1804register the operations withFindOpportunitiesRequestSchemaandIssueRagRetrieveRequestSchemaas their request bodies. Those twoconstants (
src/openapi/schemas.ts:2035and:2092) are referenced from exactly two files —src/openapi/schemas.tswhere they are declared andsrc/openapi/spec.tswhere they are emitted:The handlers validate with hand-rolled imperative validators instead —
src/api/routes.ts:3587:and
src/api/routes.ts:3612:The two descriptions of the same operation disagree, in both directions, on values a client actually sends:
POST /v1/opportunities/find{"targets":[...],"limit":7.9}limitisintegernormalizeFindOpportunitiesLimittruncates to 7 (src/mcp/find-opportunities.ts:74)POST /v1/opportunities/find{"targets":[...],"limit":999}maximum: 50POST /v1/opportunities/find{"targets":[...],"searchQuery":""}minLength: 1find-opportunities.ts:83)POST /v1/opportunities/find{}targets_or_search_query_required(find-opportunities.ts:87)POST /v1/issue-rag/retrieve{...,"topK":3.5}topKisintegerMath.truncto 3 (src/review/issue-rag-retrieval.ts:55)POST /v1/issue-rag/retrievewith 40labelsmaxItemsisPREFLIGHT_LIMITS.labels.slice(0, PREFLIGHT_LIMITS.labels)(src/mcp/issue-rag.ts:31)POST /v1/issue-rag/retrievewith an over-longbodymaxLength.slice(0, PREFLIGHT_LIMITS.bodyChars)(src/mcp/issue-rag.ts:46)POST /v1/issue-rag/retrieve{"owner":"","repo":"x","title":"t"}minLengthonownerowner_and_repo_required(src/mcp/issue-rag.ts:41)A generated client built from
/openapi.jsontherefore rejects requests the server accepts and sendsrequests the server rejects. The MCP tools of the same name make it a three-way split: the remote server
registers
loopover_find_opportunitiesandloopover_retrieve_issue_contextwith the CONTRACT schemas(
packages/loopover-contract/src/tools/local-branch.ts:495and:524), which the MCP SDK enforces beforethe handler runs — so
limit: 7.9is a-32602over MCP and a 200 over REST, for the same computation.Requirements
for them, before any hand-rolled validation runs.
@loopover/contract's existingFindOpportunitiesInput(packages/loopover-contract/src/tools/local-branch.ts:495) andRetrieveIssueContextInput(:524) rather than remaining independent restatements insrc/openapi/schemas.ts. After this change there must be exactly one zod declaration per operation,serving the MCP tool, the REST handler, and the document.
targets_or_search_query_requiredrule, target de-duplication, and the trimming/normalisation — anddrop the bound and type checks the schema now enforces ahead of them. They must not be deleted: they
still run, on already-parsed input.
/v1/opportunities/findkeeps returning{ status: "invalid_request", ranked: [], totalCandidates: 0, reason }and/v1/issue-rag/retrievekeeps returning
{ status: "invalid_request", repoFullName: "", reason, telemetry: {...} }; a schemarejection must map onto the same shape with a
reason, not ontoinvalidRequestBody's{ error, issues }.over-long
labelsarray orbodyon/v1/issue-rag/retrieveis a 400, matching both the document andthe MCP tool.
RetrieveIssueContextInput(packages/loopover-contract/src/tools/local-branch.ts:524-531) must stoppublishing values every handler refuses. It declares
owner,repoandtitlewith amaxand nomin, so the catalog saysowner: ""is valid whilevalidateIssueRagInput(
src/mcp/issue-rag.ts:41-42) always returnsowner_and_repo_required/title_requiredfor it. Add.min(1)to all three, matching the sharedownerRepoInput(
packages/loopover-contract/src/shared.ts:11) every other repo-scoped tool uses. The result must stay az.ZodObject—ToolContract(packages/loopover-contract/src/tool-definition.ts:65) requires it andboth servers pass the object itself to
registerTool.MAX_ISSUE_RAG_OWNER_LENGTH/MAX_ISSUE_RAG_REPO_LENGTH/MAX_FIND_OPPORTUNITIES_*and thePREFLIGHT_LIMITSvalues they read; the authorization order atsrc/api/routes.ts:3591-3600and:3616(validation first, then repo/discovery access, then the run);and the 200 response schemas.
Deliverables
FindOpportunitiesRequestSchemaandIssueRagRetrieveRequestSchemainsrc/openapi/schemas.tsarederived from
FindOpportunitiesInputandRetrieveIssueContextInputin@loopover/contract, notrestated.
src/api/routes.ts:3582and:3607parse the body with that schema before callingvalidateFindOpportunitiesInput/validateIssueRagInput, returning each route's existing 400 bodyshape with a
reasonon a schema failure.validateFindOpportunitiesInput(src/mcp/find-opportunities.ts:79) andvalidateIssueRagInput(
src/mcp/issue-rag.ts:35) no longer silently truncatelabelsorbody, and no longer accept anon-integer
limit/topK.test/integration/api.test.tsnamed for this bug covering every row of thetable above. Seven of the eight rows must end up matching the published schema (400 for a
non-integer/out-of-range
limitortopK, for an emptysearchQuery, for an over-longlabelsarray or
body, and for an emptyowner). The{}row is the one cross-field rule JSON Schemacannot express: it stays a 400, and the operation's
descriptioninsrc/openapi/spec.ts:1788muststate that rule in prose so the document is not silently wrong about it.
loopover_find_opportunitieswith{ targets: [...], limit: 7.9 }andPOST /v1/opportunities/findwith the same body both refuse it.RetrieveIssueContextInputdeclares.min(1)onowner,repoandtitle, with a test intest/unit/contract-registry.test.tsasserting{owner:"",repo:"x",title:"t"},{owner:"o",repo:"",title:"t"}and{owner:"o",repo:"r",title:""}all fail to parse, and thatgetToolContract("loopover_retrieve_issue_context")!.inputis still az.ZodObject.All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example wiring the schemas into the handlers but leaving
src/openapi/schemas.ts's copies asindependent declarations, or fixing
/v1/opportunities/findalone — does not resolve this issue.Test Coverage Requirements
This repo enforces 99%+ Codecov patch coverage, branch-counted.
vitest.config.ts'scoverage.includecoverssrc/**/*.ts(line 78) andpackages/loopover-contract/src/**/*.ts(line 108),so every touched path is measured and gated. Both arms of each new branch need a test: the schema
safeParsesuccess/failure arms on both routes, and each early-return in the two hand-rolled validatorsthat changes from truncation to rejection. The existing
Number.isFiniteguards innormalizeFindOpportunitiesLimit(src/mcp/find-opportunities.ts:74) andnormalizeIssueRagTopK(
src/review/issue-rag-retrieval.ts:53) stay and must keep both arms covered.Expected Outcome
A client generated from
/openapi.jsonsends bodies these two routes accept and stops sending bodies theyreject, and the same request is accepted or refused identically whether it arrives over REST or as an MCP
tool call — from one zod declaration instead of three.
Links & Resources
src/openapi/schemas.ts:2035—FindOpportunitiesRequestSchema, published but never used to validatesrc/openapi/schemas.ts:2092—IssueRagRetrieveRequestSchema, likewisesrc/openapi/spec.ts:1788/:1804— the two operationssrc/api/routes.ts:3582/:3607— the handlers and their hand-rolled validatorssrc/mcp/find-opportunities.ts:74/:79—normalizeFindOpportunitiesLimit,validateFindOpportunitiesInputsrc/mcp/issue-rag.ts:31/:35— label/body truncation,validateIssueRagInputpackages/loopover-contract/src/tools/local-branch.ts:495/:524— the contract schemas the MCP tools enforce