⚠️ 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
- 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.
ListedTool is widened so inputSchema carries properties and required, and
checkAdvertisedShape's existing type === "object" assertion is unchanged.
validateSurface in test/contract/validate-mcp.test.ts runs the new check for all three servers.
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.
- 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
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
Context
registerStdioTool(packages/loopover-mcp/bin/loopover-mcp.ts:752-778) takes an optionaloverrides.inputand describes it as a one-way escape hatch:The one real use is honest and well-modelled:
ListPendingActionsStdioInput(
packages/loopover-contract/src/tools/agent.ts:610) isListPendingActionsInput.omit({ status: true }), becauseGET /agent/pending-actionshardcodesstatus: "pending".Nothing enforces the rule.
overrides.inputis typedToolContract["input"]— anyz.ZodObjectatall. 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:
diffToolSetscompares name sets,checkAdvertisedShapechecks only that the advertisedinputSchema.typeis"object", and thesmoke-call arguments are synthesized from the advertised schema itself
(
scripts/lib/validate-mcp/synthesize-input.ts:119-123), so a widened schema simply gets widenedsmoke arguments and still passes.
The divergence is also invisible to catalog consumers:
listToolDefinitions()publishes thecontract's
ListPendingActionsInput(withstatus), while the stdio server advertises the narrowedone — so the OpenAI/Anthropic spec projections and, per #9526, the
.well-knowncatalogs alreadydescribe a parameter the stdio server does not accept, and there is no mechanism that would tell you
if that gap ever grew.
Requirements
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
propertieskey exists in thecontract's
properties, and every advertisedrequiredentry exists in the contract'srequired.ListedToolis widened soinputSchemacarriespropertiesandrequired, andcheckAdvertisedShape's existingtype === "object"assertion is unchanged.validateSurfaceintest/contract/validate-mcp.test.tsruns the new check for all three servers.registerStdioTool'soverridesparameter keeps its{ input?: ToolContract["input"] }type andgains a code comment naming
checkInputNarrowingas the guard, so the documented rule names itsenforcer.
Deliverables
checkInputNarrowingexported fromscripts/lib/validate-mcp/invariants.ts, withListedTool.inputSchemawidened to carrypropertiesandrequiredtest/unit/validate-mcp-helpers.test.tsfor: identical schemas (no failures), alegitimately narrowed schema (no failures), an advertised property absent from the contract
(one failure), and an advertised
requiredentry that is optional in the contract (one failure)validateSurfaceintest/contract/validate-mcp.test.tscalls it for remote, stdio and miner,and
npm run validate:mcpis green with today's singleListPendingActionsStdioInputoverrideregisterStdioTool'soverridesparameter namingcheckInputNarrowingas thecheck 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
checkInputNarrowingwithout wiring it intovalidateSurface, 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, whichvitest.config.ts:88-94lists incoverage.includeand calls out as deliberately graded. Any conditional you add there needs botharms tested.
scripts/lib/validate-mcp/invariants.tsandtest/**are outsidecoverage.include—cover the four
checkInputNarrowingcases intest/unit/validate-mcp-helpers.test.tsregardless, oneper 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:mcpinstead of shipping an input schema no contract entry describes.Links & Resources
packages/loopover-mcp/bin/loopover-mcp.ts:752-778,:1719-1726packages/loopover-contract/src/tools/agent.ts:593-611scripts/lib/validate-mcp/invariants.ts:8-45,scripts/lib/validate-mcp/synthesize-input.ts:118-123test/contract/validate-mcp.test.ts:157-169,test/unit/validate-mcp-helpers.test.tsfile.