mcp: enforce that a server may only NARROW the contract, and pin the last four restatements (#9662) - #9833
Merged
Merged
Conversation
Contributor
|
Important 🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨 ⏳ LoopOver is waiting…LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs. 🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting |
Contributor
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
JSONbored
force-pushed
the
mcp/validator-invariants-9661
branch
from
July 29, 2026 12:41
cad263b to
4f1ff27
Compare
JSONbored
force-pushed
the
mcp/validator-invariants-9661
branch
from
July 29, 2026 12:57
4f1ff27 to
00ec324
Compare
…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.
…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<z.ZodObject<typeof preflightShape>>`), 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.
JSONbored
force-pushed
the
mcp/validator-invariants-9661
branch
from
July 29, 2026 13:04
00ec324 to
b077b0f
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9833 +/- ##
=======================================
Coverage 91.64% 91.64%
=======================================
Files 916 916
Lines 112761 112734 -27
Branches 27077 27077
=======================================
- Hits 103341 103319 -22
+ Misses 8130 8126 -4
+ Partials 1290 1289 -1
Flags with carried forward coverage won't be shown. Click here to find out more.
|
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #9662, #9661 and #9660.
The rule nothing enforced
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" — andoverrides.inputis typed as anyz.ZodObjectat all. No existing check could see a violation:diffToolSetscompares name sets,checkAdvertisedShapeasks only whether the advertised input is object-typed, and the smoke arguments are synthesized from the advertised schema itself, so a widened schema simply gets widened arguments and passes.checkInputNarrowingcloses that, for all three surfaces.What it found on its first run
Not 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-knowncatalogs — 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; each server declares its narrowing there, not in a literal beside its registration:
LocalBranchAnalysisInput(waslocalBranchAnalysisShape, 8 tools)CurrentBranchInput— it reads the shas, the diff and the scorer probe off the checkout rather than taking them from the callerLocalScorePreviewInput(union ofscorePreviewShape+LocalScoreInput)RemoteLocalScorePreviewInput— nocwd, no scorer command, because it has no checkout to run them againstMarkNotificationsReadInput.login/WatchIssuesInput.loginnow requiredStdio*Input— it resolves the login from the active sessionDeleted from the server:
localBranchAnalysisShape,scorePreviewShape,linkedIssueContextShape,markNotificationsReadShape,watchIssuesShape,callerBranchEligibilitySchema,branchEligibilityShape,focusManifestInputSchemaandisJsonByteLengthWithinLimit— 173 lines, all of them the hand-maintained kind this epic exists to remove.#9661 — a lock that asserted a value against itself
checkVersionLockwas called withserverInfoVersion: packageVersion— the same expression — for the one leg its own doc calls "the one that can drift". Six lines later the same file warns about exactly this: "a version lock that only compares constants to each other stays green while the thing that is supposed to update them has stopped running."Both legs are read off a connected client now (
client.getServerVersion()), the miner server gains its own lock (it was unlocked entirely), and an absent version is its own failure rather than being reported as a mismatch — an empty string must not read as a version. The remote's hardcoded0.1.0stays out of the lock deliberately; making it derivable is #9526's.The
RFAILdebug loop and the duplicated comment paragraph that shipped with #9520 are deleted.#9660 — the last four unpinned restatements
TEST_FRAMEWORKS,QUEUE_STATUSES,CLAIM_STATUSESandMINER_RUN_STATESmove toenums.ts(three of them lived intools/miner.ts, outside even the convention that a shared vocabulary lives in one file) and gain pins against their live sources. Two appear in output schemas, where a new value makes a tool's realstructuredContentfail the schema that same tool advertises.Pinning the run states required a fix first: the miner's
RUN_STATESwas a cast over a literal withRunStatewritten out separately, so adding a state to one and not the other compiled fine. The type is derived from the list now, which is what makes the pin mean anything.FEASIBILITY_VERDICTSis imported at the two places it was re-inlined inside the package whose stated purpose is removing hand-copies.And the epic's headline outcome
A second commit finishes the job the first one started:
grep -cE "^const \w+Shape = \{" src/mcp/server.tsnow returns0.Four shapes were still a registration's
inputSchema(issueRagShape,findOpportunitiesShape,localBranchVariantsShape,variantsShape) — agreeing with their contract entries only by history, with nothing keeping them agreeing. The other fifteen had stopped being registered but survived as the type of their handler (z.infer<z.ZodObject<typeof preflightShape>>), which is the same defect one step removed: a handler typed off a hand-written shape can drift from the contract its tool registers with, and the compiler is happy either way.Both variant tools also took the narrower element type —
ComparePrVariantsInput.variantswas an array of the stdio server'sLocalScoreInputwhile the remote's own handler expected the wider one, so the remote accepted variants its registry entry rejected. The element is the union now, withStdio*narrowings, exactly as their singular siblings got.Validation
npx vitest run --changed=origin/main: 2991 passed, 194 files, with zero hand-written shapes left in the server.tsc --noEmitclean.validate:mcpgreen across all four surfaces: remote (139 tools with admin), stdio (102), miner (21).checkInputNarrowingunit-tested for: agreement, serving strictly less, an undeclared property, an optional field demanded, and the two quiet cases.checkVersionLockfor all three agreeing, each leg drifting, an absent version, and the server label.docs:drift-check,manifest:drift-check,ui:openapi:check,command-reference:check,contract:api-schemas:checkall clean (the last regenerated to pick up feat(gate): gate.ignoredCheckRuns — exclude a third-party check from CI resolution entirely #9813'signoredCheckRunson the settings response).