Skip to content

Commit 4b1f5e2

Browse files
authored
mcp: advertise the registry's projection on all three servers (#9655) (#9812)
One contract entry produced three differently-advertised tools, and nothing could see it: `diffToolSets` compares name sets, and `checkAdvertisedShape` only asks whether a description is non-empty. - The REMOTE server wrote a description literal at every `register(...)` call site -- 35 had drifted from the contract's, which is the text `listToolDefinitions()` serves to the agent-spec builders and the `.well-known` catalogs, so one tool was described two different ways depending on which LoopOver surface you asked. It advertised no tool `title` and no `annotations` at all, so a client that gates confirmation on `destructiveHint` saw nothing for the server that performs the writes. The `register` wrapper now fills all three from `getToolDefinition(name)`, spread after the call site's config so a stray literal cannot win, and throws on a name with no contract entry. - The STDIO server passed `contract.annotations` raw. That field is a `Partial` stating only what differs from the default posture, so the five tools declaring one field advertised it without a `destructiveHint`, and the ~95 declaring none advertised no annotations -- both disagreeing with what the registry publishes for the same tool. - The MINER server passed neither, and passed every schema as `SomeSchema.shape`. The SDK re-wraps a raw shape in a plain `z.object` that DISCARDS the catchall, and every miner output is a `looseObject`: all 21 were advertised and enforced as `additionalProperties: false`, so any field a payload carried beyond the modelled set came back as a -32602 the caller could do nothing about. #9762 fixed this class elsewhere and added a guard, but its roots omitted `packages/loopover-miner/bin` -- the guard reported none while the server it did not look at had 21. A `registerMinerTool` helper replaces all of them, and the guard now proves its own roots cover every file that registers a tool. Also closes #9656 and #9657, which are the same registration path: the remote's descriptions come from the contract now (its `MCP_TOOL_CATEGORIES` was already derived), and `loopover_admin_rotate_secret` -- the one tool still registering from schemas declared in `src/mcp/server.ts` -- reads its four siblings' contract entry instead. The reason it survived is fixed too: every case booted a server without `LOOPOVER_MCP_ADMIN_ENABLED`, so the admin category was never diffed, compiled, smoke-called or output-validated, and the validator's own "nothing is registered without a contract entry" assertion was structurally unable to see the tool that violated it. A second remote surface with the flag set now runs the full pass over all five. `ToolContract` becomes generic in its input/output schemas so a server registering from an entry gets that tool's real argument type in its handler rather than the erased `z.ZodObject`. New invariant `checkAdvertisedMetadata` compares advertised title/description/posture against the projection, and runs for all three surfaces. No tool's declared posture changed.
1 parent 9d7f567 commit 4b1f5e2

13 files changed

Lines changed: 371 additions & 432 deletions

File tree

packages/loopover-contract/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
TOOL_LOCALITIES,
1010
TOOL_AVAILABILITIES,
1111
defineTool,
12+
projectToolDefinition,
1213
projectToolDefinitions,
1314
toJsonSchema,
1415
type ToolCategory,

packages/loopover-contract/src/tool-definition.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export type ToolAnnotations = {
6262
* the wire-compatibility constraint metagraphed hit head-on when it tried to reuse its strict REST
6363
* schemas for MCP output and found the tighter contract was a regression.
6464
*/
65-
export type ToolContract = {
65+
export type ToolContract<TInput extends z.ZodObject = z.ZodObject, TOutput extends z.ZodObject = z.ZodObject> = {
6666
name: string;
6767
title: string;
6868
description: string;
@@ -71,8 +71,11 @@ export type ToolContract = {
7171
locality: ToolLocality;
7272
availability: ToolAvailability;
7373
annotations?: Partial<ToolAnnotations>;
74-
input: z.ZodObject;
75-
output: z.ZodObject;
74+
/** Generic so a server registering from a contract gets that tool's REAL argument type in its
75+
* handler, rather than the erased `z.ZodObject`. `TOOL_CONTRACTS` erases them again on the way
76+
* into the registry array, where only the common shape matters. */
77+
input: TInput;
78+
output: TOutput;
7679
};
7780

7881
/** JSON Schema as emitted by `z.toJSONSchema` -- structurally open because draft-2020-12 allows
@@ -133,7 +136,18 @@ function matchesFilter(contract: ToolContract, filter: ToolFilter): boolean {
133136
* derives from this and never from the raw contract array.
134137
*/
135138
export function projectToolDefinitions(contracts: readonly ToolContract[], filter: ToolFilter = {}): McpToolDefinition[] {
136-
return contracts.filter((contract) => matchesFilter(contract, filter)).map((contract) => ({
139+
return contracts.filter((contract) => matchesFilter(contract, filter)).map(projectToolDefinition);
140+
}
141+
142+
/**
143+
* The same projection for ONE contract, as a total function (#9655).
144+
*
145+
* A server registering a tool it has already resolved needs the defaulted `annotations` without a second
146+
* lookup that can fail -- and the defaulting must not be re-implemented at the registration site, which is
147+
* how the three servers came to advertise three different postures for one entry.
148+
*/
149+
export function projectToolDefinition(contract: ToolContract): McpToolDefinition {
150+
return {
137151
name: contract.name,
138152
title: contract.title,
139153
description: contract.description,
@@ -144,7 +158,7 @@ export function projectToolDefinitions(contracts: readonly ToolContract[], filte
144158
annotations: { ...DEFAULT_ANNOTATIONS, ...contract.annotations },
145159
inputSchema: toJsonSchema(contract.input),
146160
outputSchema: toJsonSchema(contract.output),
147-
}));
161+
};
148162
}
149163

150164
/**
@@ -155,6 +169,8 @@ export function projectToolDefinitions(contracts: readonly ToolContract[], filte
155169
* of those heterogeneous types collapses to an unsatisfiable intersection the moment anything
156170
* maps over it. metagraphed documents the same lesson on its own registry array.
157171
*/
158-
export function defineTool(contract: ToolContract): ToolContract {
172+
export function defineTool<TInput extends z.ZodObject, TOutput extends z.ZodObject>(
173+
contract: ToolContract<TInput, TOutput>,
174+
): ToolContract<TInput, TOutput> {
159175
return contract;
160176
}

packages/loopover-contract/src/tools/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Every MCP tool LoopOver serves, from any of its three servers, has exactly one entry here. A
44
// runtime registers the slice it can actually serve by filtering on locality/availability -- it
55
// does not keep its own list.
6-
import { projectToolDefinitions, type McpToolDefinition, type ToolContract, type ToolFilter } from "../tool-definition.js";
6+
import { projectToolDefinition, projectToolDefinitions, type McpToolDefinition, type ToolContract, type ToolFilter } from "../tool-definition.js";
77
import { getRepoContextTool } from "./repo-context.js";
88
import { getPrReviewabilityTool } from "./pr-reviewability.js";
99
import { predictGateTool } from "./predict-gate.js";
@@ -303,6 +303,24 @@ export function getToolContract(name: string): ToolContract | undefined {
303303
return CONTRACTS_BY_NAME.get(name);
304304
}
305305

306+
/**
307+
* One tool's PROJECTED definition -- what a server must advertise for it (#9655).
308+
*
309+
* The raw contract is not what goes on the wire: `annotations` there is a `Partial` stating only what
310+
* differs from the default posture, so a server reading it directly advertises `{ readOnlyHint: false }`
311+
* with no `destructiveHint` for a tool that declares one field, and nothing at all for a tool that
312+
* declares none. Three servers each doing that produced three different advertised tools from one entry.
313+
* This is `listToolDefinitions()` for a single name, so the defaults are applied in exactly one place.
314+
*/
315+
export function getToolDefinition(name: string): McpToolDefinition | undefined {
316+
const contract = CONTRACTS_BY_NAME.get(name);
317+
return contract ? projectToolDefinition(contract) : undefined;
318+
}
319+
320+
// The projection helpers, re-exported so a server registering from this entry point does not have to
321+
// import the root one as well just to apply the annotation defaults.
322+
export { projectToolDefinition, projectToolDefinitions, type McpToolDefinition, type ToolContract } from "../tool-definition.js";
323+
306324
// Re-export each family wholesale so consumers can reach the individual input/output schemas (and
307325
// the shared sub-shapes like laneAdviceSchema) without importing deep paths.
308326
export * from "./repo-context.js";

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ import {
165165
ValidateLinkedIssueInput,
166166
WatchIssuesInput,
167167
getToolContract,
168+
projectToolDefinition,
168169
ListPendingActionsStdioInput,
169170
} from "@loopover/contract/tools";
170171
import { AUTONOMY_LEVELS as MAINTAIN_AUTONOMY_LEVELS, MAINTAIN_ACTION_CLASSES, PROPOSE_ACTION_CLASSES, type ToolContract } from "@loopover/contract";
@@ -897,20 +898,25 @@ function registerStdioTool<TInput>(
897898
): void {
898899
const contract = getToolContract(name);
899900
if (!contract) throw new Error(`No @loopover/contract entry for stdio tool: ${name}`);
901+
// The PROJECTED definition for everything advertised, so the defaults land once (#9655). Reading
902+
// `contract.annotations` directly advertised the raw `Partial`: a tool declaring only
903+
// `readOnlyHint: false` shipped without a `destructiveHint`, and a tool declaring none shipped with
904+
// no annotations at all -- both disagreeing with what `listToolDefinitions()` publishes for it.
905+
const advertised = projectToolDefinition(contract);
900906
locallyRegisteredToolNames.add(name);
901907
server.registerTool(
902908
name,
903909
{
904-
title: contract.title,
905-
description: contract.description,
910+
title: advertised.title,
911+
description: advertised.description,
906912
// The SCHEMA OBJECTS, not their `.shape`. The SDK accepts either, but a raw shape is
907913
// re-wrapped in a plain `z.object`, which silently discards the catchall -- so every output
908914
// modelled as a `looseObject` would be advertised and enforced as `additionalProperties:
909915
// false`, and any field the payload carries beyond the modelled set becomes a -32602 the
910916
// caller cannot do anything about. Passing the object preserves what the contract declared.
911917
inputSchema: overrides?.input ?? contract.input,
912918
outputSchema: contract.output,
913-
...(contract.annotations ? { annotations: contract.annotations } : {}),
919+
annotations: advertised.annotations,
914920
},
915921
wrapStdioToolHandler(name, () => telemetryState().enabled, handler as (...args: unknown[]) => Promise<unknown>),
916922
);

0 commit comments

Comments
 (0)