diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 8bc4a1bb4d..18b51a1dae 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -923,6 +923,7 @@ function registerStdioTool( inputSchema: overrides?.input ?? contract.input, outputSchema: contract.output, annotations: advertised.annotations, + _meta: { category: advertised.category }, }, wrapStdioToolHandler(name, () => telemetryState().enabled, handler as (...args: unknown[]) => Promise), ); diff --git a/packages/loopover-miner/bin/loopover-miner-mcp.ts b/packages/loopover-miner/bin/loopover-miner-mcp.ts index 7b42373bbe..eb0da36fc9 100755 --- a/packages/loopover-miner/bin/loopover-miner-mcp.ts +++ b/packages/loopover-miner/bin/loopover-miner-mcp.ts @@ -138,6 +138,7 @@ function registerMinerTool; required?: string[] } | undefined; outputSchema?: { type?: string } | undefined; + _meta?: { category?: string | undefined } | undefined; }; /** @@ -74,6 +75,9 @@ export function checkAdvertisedMetadata(expected: readonly McpToolDefinition[], failures.push(`${tool.name} advertises ${hint}=${String(advertised.annotations?.[hint])}, registry says ${String(tool.annotations[hint])}`); } } + if (advertised._meta?.category !== tool.category) { + failures.push(`${tool.name} advertises _meta.category=${String(advertised._meta?.category)}, registry says ${String(tool.category)}`); + } } return failures; } diff --git a/test/contract/validate-mcp.test.ts b/test/contract/validate-mcp.test.ts index c03250925f..cd598f102c 100644 --- a/test/contract/validate-mcp.test.ts +++ b/test/contract/validate-mcp.test.ts @@ -259,6 +259,37 @@ describe("MCP contract validator (#9520)", () => { } }, 180_000); + it("REGRESSION (#10038): the stdio and miner servers advertise _meta.category for every listed tool", async () => { + // registerStdioTool and registerMinerTool used to omit `_meta` entirely, so half of the stdio + // server's tools/list (its locally-registered tools, as opposed to the proxied ones that inherit + // the remote's `_meta`) and all of the miner's carried no category, even though checkAdvertisedMetadata + // above already re-runs on every surface and would have caught it once the field was modelled. + const categoryByName = new Map(listToolDefinitions().map((tool) => [tool.name, tool.category])); + + const stdio = await import("../../packages/loopover-mcp/bin/loopover-mcp"); + const stdioClient = await connect(stdio.server); + try { + const listed = (await stdioClient.listTools()).tools as unknown as ListedTool[]; + expect(listed.length).toBeGreaterThan(0); + for (const tool of listed) { + expect(tool._meta?.category).toBe(categoryByName.get(tool.name)); + } + } finally { + await stdioClient.close().catch(() => undefined); + } + + const minerClient = await connect(createMinerMcpServer({})); + try { + const listed = (await minerClient.listTools()).tools as unknown as ListedTool[]; + expect(listed.length).toBeGreaterThan(0); + for (const tool of listed) { + expect(tool._meta?.category).toBe(categoryByName.get(tool.name)); + } + } finally { + await minerClient.close().catch(() => undefined); + } + }, 180_000); + it("REGRESSION: one tool name has ONE locality, which is what makes gateway collisions impossible", () => { // #9526's gateway mounts every `remote` tool onto the stdio server, which serves the `local-git` ones. // That is only safe because a NAME belongs to exactly one entry in the one registry — the same name diff --git a/test/unit/validate-mcp-helpers.test.ts b/test/unit/validate-mcp-helpers.test.ts index d5437872e6..8f9bce9f0d 100644 --- a/test/unit/validate-mcp-helpers.test.ts +++ b/test/unit/validate-mcp-helpers.test.ts @@ -39,12 +39,13 @@ describe("validate-mcp invariants", () => { describe("advertised metadata matches the registry's projection (#9655)", () => { const projected = (name: string, overrides: Partial = {}): McpToolDefinition => - ({ name, title: `${name} title`, description: `${name} description`, annotations: { readOnlyHint: true, destructiveHint: false }, ...overrides }) as McpToolDefinition; + ({ name, title: `${name} title`, description: `${name} description`, annotations: { readOnlyHint: true, destructiveHint: false }, category: "utility", ...overrides }) as McpToolDefinition; const advertised = (name: string) => ({ name, title: `${name} title`, description: `${name} description`, annotations: { readOnlyHint: true, destructiveHint: false }, + _meta: { category: "utility" }, }); it("passes when every advertised field is the projected one", () => { @@ -83,6 +84,21 @@ describe("validate-mcp invariants", () => { expect(checkAdvertisedMetadata([projected("a")], [{ name: "a", title: "a title", description: "a description" }])).toEqual([ "a advertises readOnlyHint=undefined, registry says true", "a advertises destructiveHint=undefined, registry says false", + "a advertises _meta.category=undefined, registry says utility", + ]); + }); + + it("reports a tool advertising no _meta at all (#10038)", () => { + // Stdio's locally-registered half and the miner server sent title/description/annotations but + // no `_meta`, so half a server's tools/list was uncategorised while the other half (proxied, or + // the remote server) was not. + const { _meta: _dropped, ...noMeta } = advertised("a"); + expect(checkAdvertisedMetadata([projected("a")], [noMeta])).toEqual(["a advertises _meta.category=undefined, registry says utility"]); + }); + + it("reports a _meta.category that disagrees with the registry's (#10038)", () => { + expect(checkAdvertisedMetadata([projected("a")], [{ ...advertised("a"), _meta: { category: "admin" } }])).toEqual([ + "a advertises _meta.category=admin, registry says utility", ]); });