⚠️ 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
#9518's requirement 4 said, verbatim: "Same for the remote's MCP_TOOL_CATEGORIES map — derived,
not hand-listed (its sync test converts to a registry meta-test)." That did not land. The map is
still at src/mcp/server.ts:1068, still 112 hand-typed entries, still typed
Record<string, McpToolCategory> — an index signature, so MCP_TOOL_CATEGORIES[name] at
src/mcp/server.ts:1217 yields undefined for an unlisted name and the tool ships
_meta: { category: undefined } with no compile error. That is exactly the anti-pattern
packages/loopover-contract/src/tool-definition.ts:6-8 was written to condemn ("metagraphed keeps
its output schemas in a TOOL_OUTPUT_SCHEMAS[name] lookup, where a typo'd key silently drops the
schema and nothing fails"). test/unit/mcp-tool-categories.test.ts pins the map against the
registered tool set but never against the contract's own category field.
The same file also hand-declares a description literal at every register(...) call site, while
the contract carries a description for each of those tools. 35 of them have already diverged.
Some are cosmetic (em dash vs --); many are materially different claims about what the tool does:
| tool |
remote (src/mcp/server.ts) |
contract |
loopover_preflight_pr |
"Preflight a planned PR for lane correctness, duplicate risk, linked issues, and review burden." |
"Preflight planned pull-request metadata against the repo's lane, duplicate clusters, linked-issue policy, test evidence..." |
loopover_simulate_open_pr_pressure |
"Simulate how opening another PR affects a repo's review-queue pressure..." |
"Rank what-if scenarios for easing a repo's open-PR pressure from already-computed queue-health metadata..." |
loopover_check_test_evidence |
"Classify whether a planned change's changed files carry enough test evidence, from path metadata alone..." |
"Classify how well a change's tests actually cover it, from changed PATHS and test names only..." |
loopover_preflight_current_branch |
"Analyze current-branch metadata supplied by a local MCP wrapper and return PR readiness." |
"Analyze the current git branch and return PR readiness. Sends metadata only." |
This matters because the two texts reach different audiences: the remote server's tools/list serves
the inline literal, while listToolDefinitions() — which feeds buildOpenAIToolSpecs /
buildAnthropicToolSpecs / buildAgentToolsIndex
(packages/loopover-contract/src/agent-specs.ts:25-80) and, per #9526, the .well-known catalogs —
serves the contract's. The stdio server already serves the contract's
(packages/loopover-mcp/bin/loopover-mcp.ts:771), so the same tool name is described two different
ways depending on which LoopOver server you asked.
There is also a third category vocabulary: McpToolCategory (src/mcp/server.ts:1059) and
MCP_TOOL_CATEGORY_IDS (:1063) restate the contract's TOOL_CATEGORIES
(packages/loopover-contract/src/tool-definition.ts:16) with the same seven members in a different
order.
Requirements
src/mcp/server.ts's register wrapper takes description and _meta.category from the tool's
contract entry. Every inline description: literal in a register(...) config is deleted.
MCP_TOOL_CATEGORIES is deleted. Consumers that import it move to the contract
(getToolContract(name).category or a projection over listToolDefinitions()).
test/unit/mcp-tool-categories.test.ts is rewritten to assert the wire _meta.category of every
registered tool equals its contract category.
McpToolCategory becomes an alias of the contract's ToolCategory; MCP_TOOL_CATEGORY_IDS stays
(display order is not in the contract) but is asserted by test to be a permutation of
TOOL_CATEGORIES with no extra and no missing member.
- The 35 divergences are resolved by editing the contract entry, not by deleting the better text.
For each of the 35, the surviving description must be the one that states more about what the tool
reads and returns; where the remote's literal is the richer one, its text moves into the contract
so the stdio server and the agent-spec projections gain it too. No tool ends this PR with a
description shorter or less specific than the longer of its two current texts.
- A new exported pure check in
scripts/lib/validate-mcp/invariants.ts —
checkAdvertisedDescriptions(expected, listed) — returns a failure per tool whose advertised
description differs from the projection, and validateSurface in
test/contract/validate-mcp.test.ts runs it for all three servers.
⚠️ Required pattern: mirror registerStdioTool
(packages/loopover-mcp/bin/loopover-mcp.ts:752-778), which looks the contract up by name, throws
if there is no entry, and fills every advertised field from it. Keeping MCP_TOOL_CATEGORIES as a
"derived" object literal generated at module load, introducing a second name-keyed description map,
or resolving the 35 divergences by silently taking whichever text is shorter, 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 deleting the inline descriptions so the servers agree, while leaving 35 tools described by
the shorter of the two texts — does not resolve this issue.
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, applies: src/mcp/server.ts and
packages/loopover-contract/src/** are both in coverage.include (vitest.config.ts:55-100). Both
arms of every conditional you add to the register wrapper (contract found vs missing) need a test,
including a named regression test proving a tool registered without a contract entry fails loudly
rather than advertising _meta: { category: undefined }. scripts/lib/validate-mcp/invariants.ts is
outside coverage.include — cover its new branches in test/unit/validate-mcp-helpers.test.ts
anyway.
Expected Outcome
One description and one category per tool, stored once in @loopover/contract, served identically by
the remote server's tools/list, the stdio server's tools/list, the loopover-mcp tools CLI and
the OpenAI/Anthropic agent-spec projections — and validate:mcp fails the moment any of them drifts.
Links & Resources
Context
#9518's requirement 4 said, verbatim: "Same for the remote's
MCP_TOOL_CATEGORIESmap — derived,not hand-listed (its sync test converts to a registry meta-test)." That did not land. The map is
still at
src/mcp/server.ts:1068, still 112 hand-typed entries, still typedRecord<string, McpToolCategory>— an index signature, soMCP_TOOL_CATEGORIES[name]atsrc/mcp/server.ts:1217yieldsundefinedfor an unlisted name and the tool ships_meta: { category: undefined }with no compile error. That is exactly the anti-patternpackages/loopover-contract/src/tool-definition.ts:6-8was written to condemn ("metagraphed keepsits output schemas in a
TOOL_OUTPUT_SCHEMAS[name]lookup, where a typo'd key silently drops theschema and nothing fails").
test/unit/mcp-tool-categories.test.tspins the map against theregistered tool set but never against the contract's own
categoryfield.The same file also hand-declares a
descriptionliteral at everyregister(...)call site, whilethe contract carries a description for each of those tools. 35 of them have already diverged.
Some are cosmetic (em dash vs
--); many are materially different claims about what the tool does:src/mcp/server.ts)loopover_preflight_prloopover_simulate_open_pr_pressureloopover_check_test_evidenceloopover_preflight_current_branchThis matters because the two texts reach different audiences: the remote server's
tools/listservesthe inline literal, while
listToolDefinitions()— which feedsbuildOpenAIToolSpecs/buildAnthropicToolSpecs/buildAgentToolsIndex(
packages/loopover-contract/src/agent-specs.ts:25-80) and, per #9526, the.well-knowncatalogs —serves the contract's. The stdio server already serves the contract's
(
packages/loopover-mcp/bin/loopover-mcp.ts:771), so the same tool name is described two differentways depending on which LoopOver server you asked.
There is also a third category vocabulary:
McpToolCategory(src/mcp/server.ts:1059) andMCP_TOOL_CATEGORY_IDS(:1063) restate the contract'sTOOL_CATEGORIES(
packages/loopover-contract/src/tool-definition.ts:16) with the same seven members in a differentorder.
Requirements
src/mcp/server.ts'sregisterwrapper takesdescriptionand_meta.categoryfrom the tool'scontract entry. Every inline
description:literal in aregister(...)config is deleted.MCP_TOOL_CATEGORIESis deleted. Consumers that import it move to the contract(
getToolContract(name).categoryor a projection overlistToolDefinitions()).test/unit/mcp-tool-categories.test.tsis rewritten to assert the wire_meta.categoryof everyregistered tool equals its contract
category.McpToolCategorybecomes an alias of the contract'sToolCategory;MCP_TOOL_CATEGORY_IDSstays(display order is not in the contract) but is asserted by test to be a permutation of
TOOL_CATEGORIESwith no extra and no missing member.For each of the 35, the surviving description must be the one that states more about what the tool
reads and returns; where the remote's literal is the richer one, its text moves into the contract
so the stdio server and the agent-spec projections gain it too. No tool ends this PR with a
description shorter or less specific than the longer of its two current texts.
scripts/lib/validate-mcp/invariants.ts—checkAdvertisedDescriptions(expected, listed)— returns a failure per tool whose advertiseddescription differs from the projection, and
validateSurfaceintest/contract/validate-mcp.test.tsruns it for all three servers.Deliverables
description:literals remain insideregister(...)configs insrc/mcp/server.tsMCP_TOOL_CATEGORIESand its export are gone;src/mcp/server.ts:1217reads the category fromthe contract;
test/unit/mcp-tool-categories.test.tsasserts wire_meta.categoryequals thecontract
categoryfor every registered toolMcpToolCategoryisToolCategoryre-exported; a test assertsMCP_TOOL_CATEGORY_IDSis apermutation of
TOOL_CATEGORIES@loopover/contract, each keeping the morespecific of the two texts
checkAdvertisedDescriptionsexported fromscripts/lib/validate-mcp/invariants.ts,unit-tested for a match, a whitespace-only difference, and a materially different string
validateSurfaceruns it for remote, stdio and miner andnpm run validate:mcpis greenAll Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example deleting the inline descriptions so the servers agree, while leaving 35 tools described by
the shorter of the two texts — does not resolve this issue.
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, applies:
src/mcp/server.tsandpackages/loopover-contract/src/**are both incoverage.include(vitest.config.ts:55-100). Botharms of every conditional you add to the
registerwrapper (contract found vs missing) need a test,including a named regression test proving a tool registered without a contract entry fails loudly
rather than advertising
_meta: { category: undefined }.scripts/lib/validate-mcp/invariants.tsisoutside
coverage.include— cover its new branches intest/unit/validate-mcp-helpers.test.tsanyway.
Expected Outcome
One description and one category per tool, stored once in
@loopover/contract, served identically bythe remote server's
tools/list, the stdio server'stools/list, theloopover-mcp toolsCLI andthe OpenAI/Anthropic agent-spec projections — and
validate:mcpfails the moment any of them drifts.Links & Resources
src/mcp/server.ts:1050-1181(McpToolCategory,MCP_TOOL_CATEGORY_IDS,MCP_TOOL_CATEGORIES),:1212-1219(the register wrapper)packages/loopover-contract/src/tool-definition.ts:1-16,packages/loopover-contract/src/tools/*.tspackages/loopover-mcp/bin/loopover-mcp.ts:752-778packages/loopover-contract/src/agent-specs.ts:25-80test/unit/mcp-tool-categories.test.ts,test/contract/validate-mcp.test.tscheckAdvertisedMetadata.