Skip to content

api(discovery): validate /v1/opportunities/find and /v1/issue-rag/retrieve against the schemas their spec publishes #10040

Description

@JSONbored

⚠️ 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:

$ grep -rn "FindOpportunitiesRequestSchema\|IssueRagRetrieveRequestSchema" src test scripts
src/openapi/spec.ts:50, 52, 1791, 1812
src/openapi/schemas.ts:2035, 2092

The handlers validate with hand-rolled imperative validators instead —
src/api/routes.ts:3587:

const parsed = validateFindOpportunitiesInput((body ?? {}) as FindOpportunitiesInput);

and src/api/routes.ts:3612:

const parsed = validateIssueRagInput((body ?? {}) as IssueRagInput);

The two descriptions of the same operation disagree, in both directions, on values a client actually sends:

body published schema says handler does
POST /v1/opportunities/find {"targets":[...],"limit":7.9} 400 — limit is integer 200, normalizeFindOpportunitiesLimit truncates to 7 (src/mcp/find-opportunities.ts:74)
POST /v1/opportunities/find {"targets":[...],"limit":999} 400 — maximum: 50 200, clamped to 50
POST /v1/opportunities/find {"targets":[...],"searchQuery":""} 400 — minLength: 1 200, empty query treated as absent (find-opportunities.ts:83)
POST /v1/opportunities/find {} 200 — every field is optional 400 targets_or_search_query_required (find-opportunities.ts:87)
POST /v1/issue-rag/retrieve {...,"topK":3.5} 400 — topK is integer 200, Math.trunc to 3 (src/review/issue-rag-retrieval.ts:55)
POST /v1/issue-rag/retrieve with 40 labels 400 — maxItems is PREFLIGHT_LIMITS.labels 200, silently truncated by .slice(0, PREFLIGHT_LIMITS.labels) (src/mcp/issue-rag.ts:31)
POST /v1/issue-rag/retrieve with an over-long body 400 — maxLength 200, silently truncated by .slice(0, PREFLIGHT_LIMITS.bodyChars) (src/mcp/issue-rag.ts:46)
POST /v1/issue-rag/retrieve {"owner":"","repo":"x","title":"t"} 200 — no minLength on owner 400 owner_and_repo_required (src/mcp/issue-rag.ts:41)

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.ZodObjectToolContract (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:2035FindOpportunitiesRequestSchema, published but never used to validate
  • src/openapi/schemas.ts:2092IssueRagRetrieveRequestSchema, likewise
  • src/openapi/spec.ts:1788 / :1804 — the two operations
  • src/api/routes.ts:3582 / :3607 — the handlers and their hand-rolled validators
  • src/mcp/find-opportunities.ts:74 / :79normalizeFindOpportunitiesLimit, validateFindOpportunitiesInput
  • src/mcp/issue-rag.ts:31 / :35 — label/body truncation, validateIssueRagInput
  • packages/loopover-contract/src/tools/local-branch.ts:495 / :524 — the contract schemas the MCP tools enforce

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions