@@ -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