Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-directory-tool-metadata.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions packages/api/src/mcp/__tests__/toolAnnotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
18 changes: 18 additions & 0 deletions packages/api/src/mcp/__tests__/toolSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
Expand Down
49 changes: 27 additions & 22 deletions packages/api/src/mcp/tools/sources/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<n> <unit>" 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 <alias>).'),
}),
);
//
// 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 <alias>).'),
}),
);

const aggregatedColumnSchema = z.object({
sourceColumn: z
Expand Down Expand Up @@ -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 ' +
Expand Down
11 changes: 10 additions & 1 deletion packages/api/src/mcp/utils/registerTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export function createRegisterTool(
context,
handler,
);
server.registerTool<AnyZodObject, AnyZodObject>(name, config, traced);
// Directory listings read the display name from annotations.title, not
// the top-level title, so mirror it there.
server.registerTool<AnyZodObject, AnyZodObject>(
name,
{
...config,
annotations: { title: config.title, ...config.annotations },
},
traced,
);
};
}
Loading