Skip to content

mcp(stdio): enforce that a registerStdioTool input override can only narrow the contract's #9662

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

registerStdioTool (packages/loopover-mcp/bin/loopover-mcp.ts:752-778) takes an optional
overrides.input and describes it as a one-way escape hatch:

Narrowing escape hatch, used exactly once: a server may serve LESS than the contract when its own
route cannot honour a field, and must then say so rather than advertise a no-op. Never used to
widen
-- a wider input belongs in the contract where both servers see it.

The one real use is honest and well-modelled: ListPendingActionsStdioInput
(packages/loopover-contract/src/tools/agent.ts:610) is
ListPendingActionsInput.omit({ status: true }), because GET /agent/pending-actions hardcodes
status: "pending".

Nothing enforces the rule. overrides.input is typed ToolContract["input"] — any z.ZodObject at
all. A future override could add a field the contract has never heard of, or make an optional
contract field required, and the stdio server would advertise an input schema that no contract entry
describes. The contract validator cannot catch it: diffToolSets compares name sets,
checkAdvertisedShape checks only that the advertised inputSchema.type is "object", and the
smoke-call arguments are synthesized from the advertised schema itself
(scripts/lib/validate-mcp/synthesize-input.ts:119-123), so a widened schema simply gets widened
smoke arguments and still passes.

The divergence is also invisible to catalog consumers: listToolDefinitions() publishes the
contract's ListPendingActionsInput (with status), while the stdio server advertises the narrowed
one — so the OpenAI/Anthropic spec projections and, per #9526, the .well-known catalogs already
describe a parameter the stdio server does not accept, and there is no mechanism that would tell you
if that gap ever grew.

Requirements

  1. A new exported pure check in scripts/lib/validate-mcp/invariants.ts
    checkInputNarrowing(expected: readonly McpToolDefinition[], listed: readonly ListedTool[])
    returning one failure string per tool whose advertised input schema is not a narrowing of the
    contract's. "Narrowing" is defined mechanically as: every advertised properties key exists in the
    contract's properties, and every advertised required entry exists in the contract's required.
  2. ListedTool is widened so inputSchema carries properties and required, and
    checkAdvertisedShape's existing type === "object" assertion is unchanged.
  3. validateSurface in test/contract/validate-mcp.test.ts runs the new check for all three servers.
  4. registerStdioTool's overrides parameter keeps its { input?: ToolContract["input"] } type and
    gains a code comment naming checkInputNarrowing as the guard, so the documented rule names its
    enforcer.
  5. No tool's advertised input changes in this PR.

⚠️ Required pattern: diffToolSets / checkAdvertisedShape in
scripts/lib/validate-mcp/invariants.ts:23-45 — a pure function returning a list of
human-readable failure strings, unit-tested in test/unit/validate-mcp-helpers.test.ts, invoked
from validateSurface. Throwing from inside registerStdioTool at registration time instead of
adding the validator invariant, or hardcoding an allowlist of tools permitted to override, do NOT
satisfy this issue.

Deliverables

  • checkInputNarrowing exported from scripts/lib/validate-mcp/invariants.ts, with
    ListedTool.inputSchema widened to carry properties and required
  • Unit tests in test/unit/validate-mcp-helpers.test.ts for: identical schemas (no failures), a
    legitimately narrowed schema (no failures), an advertised property absent from the contract
    (one failure), and an advertised required entry that is optional in the contract (one failure)
  • validateSurface in test/contract/validate-mcp.test.ts calls it for remote, stdio and miner,
    and npm run validate:mcp is green with today's single ListPendingActionsStdioInput override
  • A comment on registerStdioTool's overrides parameter naming checkInputNarrowing as the
    check that enforces the stated rule

All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example adding checkInputNarrowing without wiring it into validateSurface, so it never runs —
does not resolve this issue.

Test Coverage Requirements

99%+ Codecov patch coverage, branch-counted, applies to
packages/loopover-mcp/bin/loopover-mcp.ts, which vitest.config.ts:88-94 lists in
coverage.include and calls out as deliberately graded. Any conditional you add there needs both
arms tested. scripts/lib/validate-mcp/invariants.ts and test/** are outside coverage.include
cover the four checkInputNarrowing cases in test/unit/validate-mcp-helpers.test.ts regardless, one
per failure string.

Expected Outcome

The stdio server's documented "narrow only" escape hatch is a checked rule rather than a comment: a
future override that widens the advertised input, or makes an optional contract field required, fails
npm run validate:mcp instead of shipping an input schema no contract entry describes.

Links & Resources

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