diff --git a/.changeset/mcp-directory-tool-metadata.md b/.changeset/mcp-directory-tool-metadata.md new file mode 100644 index 0000000000..425137bc8d --- /dev/null +++ b/.changeset/mcp-directory-tool-metadata.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': patch +--- + +fix: give `clickstack_save_source`'s `highlightedRowAttributeExpressions` parameter a real type in the MCP tool schema (it rendered as an untyped `$ref`), and set `annotations.title` on every MCP tool diff --git a/packages/api/src/mcp/__tests__/toolAnnotations.test.ts b/packages/api/src/mcp/__tests__/toolAnnotations.test.ts index 66ea92464f..4fddeae092 100644 --- a/packages/api/src/mcp/__tests__/toolAnnotations.test.ts +++ b/packages/api/src/mcp/__tests__/toolAnnotations.test.ts @@ -23,4 +23,15 @@ describe('MCP tool annotations', () => { expect(missing).toEqual([]); }); + + it('mirrors the tool title into annotations.title', async () => { + const client = await createTestClient(context); + const { tools } = await client.listTools(); + + const mismatched = tools + .filter(tool => !tool.title || tool.annotations?.title !== tool.title) + .map(tool => tool.name); + + expect(mismatched).toEqual([]); + }); }); diff --git a/packages/api/src/mcp/__tests__/toolSchemas.test.ts b/packages/api/src/mcp/__tests__/toolSchemas.test.ts index 24cef4892d..9e2b256899 100644 --- a/packages/api/src/mcp/__tests__/toolSchemas.test.ts +++ b/packages/api/src/mcp/__tests__/toolSchemas.test.ts @@ -98,6 +98,24 @@ describe('MCP tool input schemas', () => { expect(missing).toEqual([]); }); + + // zod-to-json-schema emits a reused Zod schema instance as a bare `$ref` to + // its first use. The MCP directory flags a top-level parameter rendered that + // way as having no type, so build reused schemas with a factory instead. + it('do not render a top-level parameter as a bare $ref', async () => { + const client = await createTestClient(context); + const { tools } = await client.listTools(); + + expect(tools.length).toBeGreaterThan(0); + + const offenders = tools.flatMap(tool => + Object.entries(tool.inputSchema.properties ?? {}) + .filter(([, prop]) => isRecord(prop) && '$ref' in prop) + .map(([key]) => `${tool.name}.${key}`), + ); + + expect(offenders).toEqual([]); + }); }); function isRecord(value: unknown): value is Record { diff --git a/packages/api/src/mcp/tools/sources/schemas.ts b/packages/api/src/mcp/tools/sources/schemas.ts index f2cc515173..45b51863e3 100644 --- a/packages/api/src/mcp/tools/sources/schemas.ts +++ b/packages/api/src/mcp/tools/sources/schemas.ts @@ -28,26 +28,31 @@ const KIND_DESCRIPTION = // Advanced nested config, modeled loosely (SourceSchemaNoId does the strict // validation) so it round-trips through describe_source -> save_source. // Granularity fields use the internal " " form (e.g. "5 minute"). -const highlightedAttributeExpressionsSchema = z.array( - z.object({ - sqlExpression: z - .string() - .describe( - "ClickHouse SQL expression for the attribute, e.g. ResourceAttributes['http.host'].", - ), - luceneExpression: z - .string() - .optional() - .describe( - 'Lucene version of sqlExpression, e.g. ResourceAttributes.http.host. ' + - 'When set, it is used when searching for this attribute value.', - ), - alias: z - .string() - .optional() - .describe('Display name for the attribute (rendered as AS ).'), - }), -); +// +// A factory, not a shared constant: zod-to-json-schema emits a reused schema +// instance as a bare `$ref` to its first use, and MCP directory validators +// flag that property as untyped. +const highlightedAttributeExpressionsSchema = () => + z.array( + z.object({ + sqlExpression: z + .string() + .describe( + "ClickHouse SQL expression for the attribute, e.g. ResourceAttributes['http.host'].", + ), + luceneExpression: z + .string() + .optional() + .describe( + 'Lucene version of sqlExpression, e.g. ResourceAttributes.http.host. ' + + 'When set, it is used when searching for this attribute value.', + ), + alias: z + .string() + .optional() + .describe('Display name for the attribute (rendered as AS ).'), + }), + ); const aggregatedColumnSchema = z.object({ sourceColumn: z @@ -377,13 +382,13 @@ const mcpSaveSourceSchema = z.object({ ), // ── Highlighted attribute expressions (advanced) ── - highlightedTraceAttributeExpressions: highlightedAttributeExpressionsSchema + highlightedTraceAttributeExpressions: highlightedAttributeExpressionsSchema() .optional() .describe( 'Log and trace sources. Trace-level attributes displayed in the trace ' + 'view for the selected trace.', ), - highlightedRowAttributeExpressions: highlightedAttributeExpressionsSchema + highlightedRowAttributeExpressions: highlightedAttributeExpressionsSchema() .optional() .describe( 'Log and trace sources. Row-level attributes displayed in the row side ' + diff --git a/packages/api/src/mcp/utils/registerTool.ts b/packages/api/src/mcp/utils/registerTool.ts index c5a93be739..2c80c7c5e4 100644 --- a/packages/api/src/mcp/utils/registerTool.ts +++ b/packages/api/src/mcp/utils/registerTool.ts @@ -29,6 +29,15 @@ export function createRegisterTool( context, handler, ); - server.registerTool(name, config, traced); + // Directory listings read the display name from annotations.title, not + // the top-level title, so mirror it there. + server.registerTool( + name, + { + ...config, + annotations: { title: config.title, ...config.annotations }, + }, + traced, + ); }; }