Skip to content

Commit 236907c

Browse files
authored
fix(mcp): advertise _meta.category on all three servers (#10162)
registerStdioTool and registerMinerTool were the only two tool-registration wrappers that never attached _meta, so the stdio server's locally-registered tools and the miner's entire surface carried no category on the wire even though the contract's projection has always had one. Both now spread _meta: { category } from the same projected definition they already use for title/description/annotations. checkAdvertisedMetadata gains the matching branch so a server that stops sending it fails validate:mcp instead of drifting silently again. Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 505eda3 commit 236907c

5 files changed

Lines changed: 54 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,7 @@ function registerStdioTool<TInput>(
923923
inputSchema: overrides?.input ?? contract.input,
924924
outputSchema: contract.output,
925925
annotations: advertised.annotations,
926+
_meta: { category: advertised.category },
926927
},
927928
wrapStdioToolHandler(name, () => telemetryState().enabled, handler as (...args: unknown[]) => Promise<unknown>),
928929
);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ function registerMinerTool<TInput extends z.ZodObject, TOutput extends z.ZodObje
138138
// The schema OBJECTS, never their `.shape` -- see above.
139139
inputSchema: contract.input,
140140
outputSchema: contract.output,
141+
_meta: { category: advertised.category },
141142
},
142143
handler,
143144
);

scripts/lib/validate-mcp/invariants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type ListedTool = {
1212
annotations?: { readOnlyHint?: boolean | undefined; destructiveHint?: boolean | undefined } | undefined;
1313
inputSchema?: { type?: string; properties?: Record<string, unknown>; required?: string[] } | undefined;
1414
outputSchema?: { type?: string } | undefined;
15+
_meta?: { category?: string | undefined } | undefined;
1516
};
1617

1718
/**
@@ -74,6 +75,9 @@ export function checkAdvertisedMetadata(expected: readonly McpToolDefinition[],
7475
failures.push(`${tool.name} advertises ${hint}=${String(advertised.annotations?.[hint])}, registry says ${String(tool.annotations[hint])}`);
7576
}
7677
}
78+
if (advertised._meta?.category !== tool.category) {
79+
failures.push(`${tool.name} advertises _meta.category=${String(advertised._meta?.category)}, registry says ${String(tool.category)}`);
80+
}
7781
}
7882
return failures;
7983
}

test/contract/validate-mcp.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,37 @@ describe("MCP contract validator (#9520)", () => {
259259
}
260260
}, 180_000);
261261

262+
it("REGRESSION (#10038): the stdio and miner servers advertise _meta.category for every listed tool", async () => {
263+
// registerStdioTool and registerMinerTool used to omit `_meta` entirely, so half of the stdio
264+
// server's tools/list (its locally-registered tools, as opposed to the proxied ones that inherit
265+
// the remote's `_meta`) and all of the miner's carried no category, even though checkAdvertisedMetadata
266+
// above already re-runs on every surface and would have caught it once the field was modelled.
267+
const categoryByName = new Map(listToolDefinitions().map((tool) => [tool.name, tool.category]));
268+
269+
const stdio = await import("../../packages/loopover-mcp/bin/loopover-mcp");
270+
const stdioClient = await connect(stdio.server);
271+
try {
272+
const listed = (await stdioClient.listTools()).tools as unknown as ListedTool[];
273+
expect(listed.length).toBeGreaterThan(0);
274+
for (const tool of listed) {
275+
expect(tool._meta?.category).toBe(categoryByName.get(tool.name));
276+
}
277+
} finally {
278+
await stdioClient.close().catch(() => undefined);
279+
}
280+
281+
const minerClient = await connect(createMinerMcpServer({}));
282+
try {
283+
const listed = (await minerClient.listTools()).tools as unknown as ListedTool[];
284+
expect(listed.length).toBeGreaterThan(0);
285+
for (const tool of listed) {
286+
expect(tool._meta?.category).toBe(categoryByName.get(tool.name));
287+
}
288+
} finally {
289+
await minerClient.close().catch(() => undefined);
290+
}
291+
}, 180_000);
292+
262293
it("REGRESSION: one tool name has ONE locality, which is what makes gateway collisions impossible", () => {
263294
// #9526's gateway mounts every `remote` tool onto the stdio server, which serves the `local-git` ones.
264295
// That is only safe because a NAME belongs to exactly one entry in the one registry — the same name

test/unit/validate-mcp-helpers.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ describe("validate-mcp invariants", () => {
3939

4040
describe("advertised metadata matches the registry's projection (#9655)", () => {
4141
const projected = (name: string, overrides: Partial<McpToolDefinition> = {}): McpToolDefinition =>
42-
({ name, title: `${name} title`, description: `${name} description`, annotations: { readOnlyHint: true, destructiveHint: false }, ...overrides }) as McpToolDefinition;
42+
({ name, title: `${name} title`, description: `${name} description`, annotations: { readOnlyHint: true, destructiveHint: false }, category: "utility", ...overrides }) as McpToolDefinition;
4343
const advertised = (name: string) => ({
4444
name,
4545
title: `${name} title`,
4646
description: `${name} description`,
4747
annotations: { readOnlyHint: true, destructiveHint: false },
48+
_meta: { category: "utility" },
4849
});
4950

5051
it("passes when every advertised field is the projected one", () => {
@@ -83,6 +84,21 @@ describe("validate-mcp invariants", () => {
8384
expect(checkAdvertisedMetadata([projected("a")], [{ name: "a", title: "a title", description: "a description" }])).toEqual([
8485
"a advertises readOnlyHint=undefined, registry says true",
8586
"a advertises destructiveHint=undefined, registry says false",
87+
"a advertises _meta.category=undefined, registry says utility",
88+
]);
89+
});
90+
91+
it("reports a tool advertising no _meta at all (#10038)", () => {
92+
// Stdio's locally-registered half and the miner server sent title/description/annotations but
93+
// no `_meta`, so half a server's tools/list was uncategorised while the other half (proxied, or
94+
// the remote server) was not.
95+
const { _meta: _dropped, ...noMeta } = advertised("a");
96+
expect(checkAdvertisedMetadata([projected("a")], [noMeta])).toEqual(["a advertises _meta.category=undefined, registry says utility"]);
97+
});
98+
99+
it("reports a _meta.category that disagrees with the registry's (#10038)", () => {
100+
expect(checkAdvertisedMetadata([projected("a")], [{ ...advertised("a"), _meta: { category: "admin" } }])).toEqual([
101+
"a advertises _meta.category=admin, registry says utility",
86102
]);
87103
});
88104

0 commit comments

Comments
 (0)