Skip to content

Commit fecbfff

Browse files
fix: flatten MCP query tool schema so SDK serializes inputSchema correctly (#2124)
## Summary During code review on the original MCP PR (#2030), the `hyperdxQuerySchema` was refactored from a flat object into a `z.discriminatedUnion()` to reduce duplication and improve type safety. This inadvertently broke the tool's parameter schema — the MCP SDK's `normalizeObjectSchema()` only recognizes plain `z.object()` schemas and silently replaces anything else (discriminated unions, `ZodEffects` from `.superRefine`/`.refine`/`.transform`) with an empty `{}` in the `tools/list` response, making the tool unusable for MCP clients. Changes: - Replaced `z.discriminatedUnion()` with a single flat `z.object()` that the SDK can serialize - Moved cross-field validation (e.g. "sourceId required for builder types", "sql required for sql type") into a standalone `validateQueryInput()` function called at runtime in the handler - Added a regression test that calls `tools/list` and asserts all expected properties (`displayType`, `sourceId`, `select`, `where`, `sql`, `connectionId`, etc.) are present in the serialized `inputSchema` ### How to test locally or on Vercel 1. Start the dev stack with `yarn dev` 2. Connect any MCP client and call `tools/list` — verify `hyperdx_query` exposes all properties (`displayType`, `sourceId`, `select`, `where`, `sql`, etc.) instead of an empty schema 3. Run the query tool test suite: `cd packages/api && yarn ci:unit src/mcp/__tests__/queryTool.test.ts` ### References - Related PRs: #2030
1 parent 24699cd commit fecbfff

4 files changed

Lines changed: 146 additions & 54 deletions

File tree

‎.changeset/fix-mcp-query-schema.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
fix: flatten MCP query tool schema so SDK serializes inputSchema correctly

‎packages/api/src/mcp/__tests__/queryTool.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,33 @@ describe('MCP Query Tool', () => {
6767
await server.stop();
6868
});
6969

70+
describe('schema serialization', () => {
71+
it('should expose inputSchema with all expected properties via tools/list', async () => {
72+
const { tools } = await client.listTools();
73+
const queryTool = tools.find(t => t.name === 'hyperdx_query');
74+
expect(queryTool).toBeDefined();
75+
76+
const schema = queryTool!.inputSchema;
77+
expect(schema.type).toBe('object');
78+
expect(schema.properties).toBeDefined();
79+
80+
// Verify key properties are present (not silently stripped by the SDK)
81+
const props = Object.keys(schema.properties ?? {});
82+
expect(props).toContain('displayType');
83+
expect(props).toContain('sourceId');
84+
expect(props).toContain('select');
85+
expect(props).toContain('where');
86+
expect(props).toContain('sql');
87+
expect(props).toContain('connectionId');
88+
expect(props).toContain('startTime');
89+
expect(props).toContain('endTime');
90+
expect(props).toContain('groupBy');
91+
92+
// displayType should be required
93+
expect(schema.required).toContain('displayType');
94+
});
95+
});
96+
7097
describe('builder queries', () => {
7198
it('should execute a number query', async () => {
7299
const result = await callTool(client, 'hyperdx_query', {

‎packages/api/src/mcp/tools/query/index.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { externalDashboardTileSchemaWithId } from '@/utils/zod';
66
import { withToolTracing } from '../../utils/tracing';
77
import type { ToolDefinition } from '../types';
88
import { parseTimeRange, runConfigTile } from './helpers';
9-
import { hyperdxQuerySchema } from './schemas';
9+
import { hyperdxQuerySchema, validateQueryInput } from './schemas';
1010

1111
// ─── Tool definition ─────────────────────────────────────────────────────────
1212

@@ -33,6 +33,17 @@ const queryTools: ToolDefinition = (server, context) => {
3333
inputSchema: hyperdxQuerySchema,
3434
},
3535
withToolTracing('hyperdx_query', context, async input => {
36+
// Cross-field validation (kept out of the Zod schema to avoid
37+
// .superRefine() wrapping in ZodEffects, which the MCP SDK's
38+
// normalizeObjectSchema() cannot serialize to JSON Schema).
39+
const validationError = validateQueryInput(input);
40+
if (validationError) {
41+
return {
42+
isError: true,
43+
content: [{ type: 'text' as const, text: validationError }],
44+
};
45+
}
46+
3647
const timeRange = parseTimeRange(input.startTime, input.endTime);
3748
if ('error' in timeRange) {
3849
return {
@@ -86,7 +97,7 @@ const queryTools: ToolDefinition = (server, context) => {
8697
config: {
8798
displayType: input.displayType,
8899
sourceId: input.sourceId,
89-
select: input.select.map(s => ({
100+
select: input.select!.map(s => ({
90101
aggFn: s.aggFn,
91102
where: s.where ?? '',
92103
whereLanguage: s.whereLanguage ?? 'lucene',

‎packages/api/src/mcp/tools/query/schemas.ts‎

Lines changed: 101 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,47 @@ const mcpSelectItemSchema = z.object({
6161
),
6262
});
6363

64-
const mcpTimeRangeSchema = z.object({
64+
// ─── Display type groups (used for validation) ──────────────────────────────
65+
66+
const BUILDER_DISPLAY_TYPES = [
67+
'line',
68+
'stacked_bar',
69+
'table',
70+
'number',
71+
'pie',
72+
] as const;
73+
74+
type BuilderDisplayType = (typeof BUILDER_DISPLAY_TYPES)[number];
75+
76+
function isBuilderDisplayType(dt: string): dt is BuilderDisplayType {
77+
return (BUILDER_DISPLAY_TYPES as readonly string[]).includes(dt);
78+
}
79+
80+
// ─── Flat object schema for hyperdx_query ───────────────────────────────────
81+
// Uses a single z.object() instead of z.discriminatedUnion() so the MCP SDK
82+
// can serialize it to JSON Schema correctly. The SDK's normalizeObjectSchema()
83+
// only recognizes z.object() schemas — discriminated unions and ZodEffects
84+
// (from .superRefine/.refine/.transform) are silently replaced with an empty
85+
// schema in the tools/list response.
86+
//
87+
// To work around this, the schema is split into two parts:
88+
// 1. hyperdxQuerySchema — plain z.object() used as inputSchema for the SDK
89+
// 2. validateQueryInput — cross-field validation applied at runtime
90+
91+
export const hyperdxQuerySchema = z.object({
92+
// ── Shared fields (all display types) ──
93+
displayType: z
94+
.enum(['line', 'stacked_bar', 'table', 'number', 'pie', 'search', 'sql'])
95+
.describe(
96+
'How to query and visualize the data:\n' +
97+
' line – time-series line chart (builder)\n' +
98+
' stacked_bar – time-series stacked bar chart (builder)\n' +
99+
' table – grouped aggregation as rows (builder)\n' +
100+
' number – single aggregate scalar (builder)\n' +
101+
' pie – pie chart, one metric grouped (builder)\n' +
102+
' search – browse individual log/event rows\n' +
103+
' sql – ADVANCED: raw ClickHouse SQL query',
104+
),
65105
startTime: z
66106
.string()
67107
.optional()
@@ -73,48 +113,41 @@ const mcpTimeRangeSchema = z.object({
73113
.string()
74114
.optional()
75115
.describe('End of the query window as ISO 8601. Default: now.'),
76-
});
77116

78-
// ─── Discriminated union schema for hyperdx_query ───────────────────────────
79-
80-
const builderQuerySchema = mcpTimeRangeSchema.extend({
81-
displayType: z
82-
.enum(['line', 'stacked_bar', 'table', 'number', 'pie'])
83-
.describe(
84-
'How to visualize the query results:\n' +
85-
' line – time-series line chart\n' +
86-
' stacked_bar – time-series stacked bar chart\n' +
87-
' table – grouped aggregation as rows\n' +
88-
' number – single aggregate scalar\n' +
89-
' pie – pie chart (one metric, grouped)',
90-
),
117+
// ── Builder + search fields ──
91118
sourceId: z
92119
.string()
120+
.optional()
93121
.describe(
94-
'Source ID. Call hyperdx_list_sources to find available sources.',
122+
'Source ID — required for builder display types (line, stacked_bar, table, number, pie) ' +
123+
'and for "search". Call hyperdx_list_sources to find available sources.',
95124
),
96125
select: z
97126
.array(mcpSelectItemSchema)
98127
.min(1)
99128
.max(10)
129+
.optional()
100130
.describe(
101-
'Metrics to compute. Each item defines an aggregation. ' +
131+
'Metrics to compute — required for builder display types (line, stacked_bar, table, number, pie). ' +
132+
'Each item defines an aggregation. ' +
102133
'For "number" display, provide exactly 1 item. ' +
103134
'Example: [{ aggFn: "count" }, { aggFn: "avg", valueExpression: "Duration" }]',
104135
),
105136
groupBy: z
106137
.string()
107138
.optional()
108139
.describe(
109-
'Column to group/split by. ' +
140+
'Column to group/split by (builder display types only). ' +
110141
'Top-level columns use PascalCase (e.g. "SpanName", "StatusCode"). ' +
111142
"Span attributes: SpanAttributes['key'] (e.g. SpanAttributes['http.method']). " +
112143
"Resource attributes: ResourceAttributes['key'] (e.g. ResourceAttributes['service.name']).",
113144
),
114145
orderBy: z
115146
.string()
116147
.optional()
117-
.describe('Column to sort results by (table display only).'),
148+
.describe(
149+
'Column to sort results by (builder display types only, mainly "table").',
150+
),
118151
granularity: z
119152
.string()
120153
.optional()
@@ -124,36 +157,30 @@ const builderQuerySchema = mcpTimeRangeSchema.extend({
124157
'Examples: "1 minute", "5 minute", "1 hour", "1 day". ' +
125158
'Omit to let HyperDX pick automatically based on the time range.',
126159
),
127-
});
128160

129-
const searchQuerySchema = mcpTimeRangeSchema.extend({
130-
displayType: z
131-
.literal('search')
132-
.describe('Search and filter individual log/event rows'),
133-
sourceId: z
134-
.string()
135-
.describe(
136-
'Source ID. Call hyperdx_list_sources to find available sources.',
137-
),
161+
// ── Search-only fields ──
138162
where: z
139163
.string()
140164
.optional()
141165
.default('')
142166
.describe(
143-
'Row filter. Examples: "level:error", "service.name:api AND duration:>500"',
167+
'Row filter for "search" display type. ' +
168+
'Examples: "level:error", "service.name:api AND duration:>500"',
144169
),
145170
whereLanguage: z
146171
.enum(['lucene', 'sql'])
147172
.optional()
148173
.default('lucene')
149-
.describe('Query language for the where filter. Default: lucene'),
174+
.describe(
175+
'Query language for the "where" filter ("search" display type only). Default: lucene',
176+
),
150177
columns: z
151178
.string()
152179
.optional()
153180
.default('')
154181
.describe(
155-
'Comma-separated columns to include. Leave empty for defaults. ' +
156-
'Example: "body,service.name,duration"',
182+
'Comma-separated columns to include in search results ("search" display type only). ' +
183+
'Leave empty for defaults. Example: "body,service.name,duration"',
157184
),
158185
maxResults: z
159186
.number()
@@ -162,30 +189,23 @@ const searchQuerySchema = mcpTimeRangeSchema.extend({
162189
.optional()
163190
.default(50)
164191
.describe(
165-
'Maximum number of rows to return (1–200). Default: 50. ' +
192+
'Maximum number of rows to return for "search" display type (1–200). Default: 50. ' +
166193
'Use smaller values to reduce response size.',
167194
),
168-
});
169195

170-
const sqlQuerySchema = mcpTimeRangeSchema.extend({
171-
displayType: z
172-
.literal('sql')
173-
.describe(
174-
'ADVANCED: Execute raw SQL directly against ClickHouse. ' +
175-
'Only use this when the builder query types (line, stacked_bar, table, number, pie, search) ' +
176-
'cannot express the query you need — e.g. complex JOINs, sub-queries, CTEs, or ' +
177-
'querying tables not registered as sources. ' +
178-
'Prefer the builder display types for standard queries as they are safer and easier to use.',
179-
),
196+
// ── SQL-only fields ──
180197
connectionId: z
181198
.string()
199+
.optional()
182200
.describe(
183-
'Connection ID (not sourceId). Call hyperdx_list_sources to find available connections.',
201+
'Connection ID — required for "sql" display type (not sourceId). ' +
202+
'Call hyperdx_list_sources to find available connections.',
184203
),
185204
sql: z
186205
.string()
206+
.optional()
187207
.describe(
188-
'Raw ClickHouse SQL query to execute. ' +
208+
'Raw ClickHouse SQL query — required for "sql" display type. ' +
189209
'Always include a LIMIT clause to avoid returning excessive data.\n\n' +
190210
'QUERY PARAMETERS (ClickHouse native parameterized syntax):\n' +
191211
' {startDateMilliseconds:Int64} — start of date range in ms since epoch\n' +
@@ -213,8 +233,37 @@ const sqlQuerySchema = mcpTimeRangeSchema.extend({
213233
),
214234
});
215235

216-
export const hyperdxQuerySchema = z.discriminatedUnion('displayType', [
217-
builderQuerySchema,
218-
searchQuerySchema,
219-
sqlQuerySchema,
220-
]);
236+
/**
237+
* Cross-field validation for the query schema. Applied at runtime in the
238+
* handler rather than via .superRefine() so the base z.object() stays intact
239+
* for the MCP SDK's schema serialization.
240+
*
241+
* Returns a user-facing error string if validation fails, or null if valid.
242+
*/
243+
export function validateQueryInput(
244+
data: z.infer<typeof hyperdxQuerySchema>,
245+
): string | null {
246+
const { displayType } = data;
247+
248+
if (isBuilderDisplayType(displayType)) {
249+
if (!data.sourceId) {
250+
return `sourceId is required when displayType is "${displayType}"`;
251+
}
252+
if (!data.select || data.select.length === 0) {
253+
return `select is required when displayType is "${displayType}"`;
254+
}
255+
} else if (displayType === 'search') {
256+
if (!data.sourceId) {
257+
return 'sourceId is required when displayType is "search"';
258+
}
259+
} else if (displayType === 'sql') {
260+
if (!data.connectionId) {
261+
return 'connectionId is required when displayType is "sql"';
262+
}
263+
if (!data.sql) {
264+
return 'sql is required when displayType is "sql"';
265+
}
266+
}
267+
268+
return null;
269+
}

0 commit comments

Comments
 (0)