diff --git a/hyperdx/server/lib/coerce.test.ts b/hyperdx/server/lib/coerce.test.ts new file mode 100644 index 00000000..9a3602be --- /dev/null +++ b/hyperdx/server/lib/coerce.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { arr, num } from "./coerce.ts"; +import { queryChartDataInputSchema } from "./types.ts"; + +describe("arr", () => { + const schema = arr(z.array(z.string())); + + test("passes arrays through unchanged", () => { + expect(schema.parse(["a", "b"])).toEqual(["a", "b"]); + }); + + test("parses JSON-encoded array string", () => { + expect(schema.parse('["a","b"]')).toEqual(["a", "b"]); + }); + + test("tolerates whitespace around JSON string", () => { + expect(schema.parse(' ["a","b"] ')).toEqual(["a", "b"]); + }); + + test("non-JSON-looking string falls through to inner schema (errors)", () => { + expect(() => schema.parse("not-an-array")).toThrow(); + }); + + test("malformed JSON string falls through to inner schema (errors)", () => { + expect(() => schema.parse("[a, b]")).toThrow(); + }); + + test("respects optional + default on inner schema", () => { + const optionalSchema = arr(z.array(z.string()).optional().default(["x"])); + expect(optionalSchema.parse(undefined)).toEqual(["x"]); + expect(optionalSchema.parse('["a"]')).toEqual(["a"]); + }); + + test("parses nested object arrays", () => { + const objSchema = arr(z.array(z.object({ k: z.string() }))); + expect(objSchema.parse('[{"k":"v"}]')).toEqual([{ k: "v" }]); + }); +}); + +describe("num", () => { + test("passes numbers through", () => { + expect(num().parse(42)).toBe(42); + }); + + test("coerces numeric string", () => { + expect(num().parse("42")).toBe(42); + }); + + test("rejects non-numeric string", () => { + expect(() => num().parse("abc")).toThrow(); + }); +}); + +describe("queryChartDataInputSchema with stringified series", () => { + test("accepts a JSON-encoded series array", () => { + const result = queryChartDataInputSchema.parse({ + series: + '[{"dataSource":"events","aggFn":"count","where":"","groupBy":["service"]}]', + }); + expect(result.series).toEqual([ + { + dataSource: "events", + aggFn: "count", + where: "", + groupBy: ["service"], + }, + ]); + }); + + test("accepts JSON-encoded groupBy inside series", () => { + const result = queryChartDataInputSchema.parse({ + series: [ + { + dataSource: "events", + aggFn: "count", + where: "", + groupBy: '["service","level"]', + }, + ], + }); + expect(result.series[0]?.groupBy).toEqual(["service", "level"]); + }); +}); diff --git a/hyperdx/server/lib/coerce.ts b/hyperdx/server/lib/coerce.ts new file mode 100644 index 00000000..62ce1c6d --- /dev/null +++ b/hyperdx/server/lib/coerce.ts @@ -0,0 +1,33 @@ +/** + * Input coercion helpers. + * + * Some MCP transports JSON-encode structured params (arrays, objects) as + * strings before delivering them to the server, and stringify numeric params. + * These helpers make input schemas tolerant of that quirk so calls like + * `{ groupBy: '["service","level"]' }` or `{ limit: "100" }` succeed. + */ + +import { z } from "zod"; + +const parseJsonString = (value: unknown): unknown => { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if ( + !( + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + (trimmed.startsWith("{") && trimmed.endsWith("}")) + ) + ) { + return value; + } + try { + return JSON.parse(trimmed); + } catch { + return value; + } +}; + +export const arr = (schema: T) => + z.preprocess(parseJsonString, schema); + +export const num = () => z.coerce.number(); diff --git a/hyperdx/server/lib/types.ts b/hyperdx/server/lib/types.ts index a5a195d9..5507ac03 100644 --- a/hyperdx/server/lib/types.ts +++ b/hyperdx/server/lib/types.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; +import { arr } from "./coerce.ts"; import { TIME_INPUT_DESCRIPTION, TimeInputSchema } from "./time.ts"; // ============================================================================ @@ -122,7 +123,7 @@ const SerieSchema = z.object({ .describe( "Search query filter (e.g., 'level:error service:\"my-service\"').", ), - groupBy: z.array(z.string()).describe("Fields to group results by."), + groupBy: arr(z.array(z.string())).describe("Fields to group results by."), metricDataType: z .enum(["Sum", "Gauge", "Histogram"]) .optional() @@ -141,7 +142,7 @@ export const queryChartDataInputSchema = z.object({ granularity: GranularitySchema.optional() .default("1 minute") .describe("Time bucket granularity for aggregation. Defaults to 1 minute."), - series: z.array(SerieSchema).describe("Array of series to query."), + series: arr(z.array(SerieSchema)).describe("Array of series to query."), seriesReturnType: z .enum(["column", "ratio"]) .optional() diff --git a/hyperdx/server/tools/alerts.ts b/hyperdx/server/tools/alerts.ts index 041901ea..ce77ad09 100644 --- a/hyperdx/server/tools/alerts.ts +++ b/hyperdx/server/tools/alerts.ts @@ -9,6 +9,7 @@ import { createTool } from "@decocms/runtime/tools"; import { z } from "zod"; import type { Env } from "../main.ts"; import { createHyperDXClient } from "../lib/client.ts"; +import { arr, num } from "../lib/coerce.ts"; import { getHyperDXApiKey } from "../lib/env.ts"; // ============================================================================ @@ -29,7 +30,7 @@ const AlertIntervalSchema = z.enum([ const AlertChannelSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("email"), - recipients: z.array(z.string()).describe("Email addresses to notify."), + recipients: arr(z.array(z.string())).describe("Email addresses to notify."), }), z.object({ type: z.literal("slack"), @@ -125,9 +126,9 @@ export const createCreateAlertTool = (_env: Env) => interval: AlertIntervalSchema.describe( "How often to evaluate the alert condition.", ), - threshold: z - .number() - .describe("Numeric threshold value that triggers the alert."), + threshold: num().describe( + "Numeric threshold value that triggers the alert.", + ), threshold_type: z .enum(["above", "below"]) .describe("Fire when the value is 'above' or 'below' the threshold."), @@ -196,7 +197,7 @@ export const createUpdateAlertTool = (_env: Env) => inputSchema: z.object({ id: z.string().describe("The alert ID to update."), interval: AlertIntervalSchema.optional(), - threshold: z.number().optional(), + threshold: num().optional(), threshold_type: z.enum(["above", "below"]).optional(), source: z.enum(["chart", "search"]).optional(), channel: AlertChannelSchema.optional(), diff --git a/hyperdx/server/tools/dashboards.ts b/hyperdx/server/tools/dashboards.ts index 88f50e54..4d500f9c 100644 --- a/hyperdx/server/tools/dashboards.ts +++ b/hyperdx/server/tools/dashboards.ts @@ -8,6 +8,7 @@ import { createTool } from "@decocms/runtime/tools"; import { z } from "zod"; import type { Env } from "../main.ts"; import { createHyperDXClient } from "../lib/client.ts"; +import { arr, num } from "../lib/coerce.ts"; import { getHyperDXApiKey } from "../lib/env.ts"; // ============================================================================ @@ -48,7 +49,7 @@ const DashboardSeriesSchema = z.object({ aggFn: DashboardAggFnSchema.describe("Aggregation function."), field: z.string().optional().describe("Field to aggregate."), where: z.string().describe("Search filter query."), - groupBy: z.array(z.string()).describe("Fields to group by."), + groupBy: arr(z.array(z.string())).describe("Fields to group by."), metricDataType: z .enum(["Sum", "Gauge", "Histogram"]) .optional() @@ -58,13 +59,13 @@ const DashboardSeriesSchema = z.object({ const DashboardChartSchema = z.object({ id: z.string().optional().describe("Chart ID (auto-generated on create)."), name: z.string().describe("Chart display name."), - x: z.number().describe("Grid column position (0-based)."), - y: z.number().describe("Grid row position (0-based)."), - w: z.number().describe("Width in grid units."), - h: z.number().describe("Height in grid units."), - series: z - .array(DashboardSeriesSchema) - .describe("Data series for this chart (up to 5)."), + x: num().describe("Grid column position (0-based)."), + y: num().describe("Grid row position (0-based)."), + w: num().describe("Width in grid units."), + h: num().describe("Height in grid units."), + series: arr(z.array(DashboardSeriesSchema)).describe( + "Data series for this chart (up to 5).", + ), }); // ============================================================================ @@ -128,16 +129,12 @@ export const createCreateDashboardTool = (_env: Env) => .describe( "Global filter applied to all charts on the dashboard (e.g., 'env:production').", ), - tags: z - .array(z.string()) - .optional() - .default([]) - .describe("Organizational tags for the dashboard."), - charts: z - .array(DashboardChartSchema) - .describe( - "Array of charts to include. Each chart needs a name, grid position (x/y/w/h), and series. Charts are placed on a grid — typical width is 12 units total.", - ), + tags: arr(z.array(z.string()).optional().default([])).describe( + "Organizational tags for the dashboard.", + ), + charts: arr(z.array(DashboardChartSchema)).describe( + "Array of charts to include. Each chart needs a name, grid position (x/y/w/h), and series. Charts are placed on a grid — typical width is 12 units total.", + ), }), outputSchema: z.record(z.string(), z.unknown()), execute: async ({ context, runtimeContext }) => { @@ -164,10 +161,10 @@ export const createUpdateDashboardTool = (_env: Env) => .optional() .default("") .describe("Global filter applied to all charts."), - tags: z.array(z.string()).optional().default([]), - charts: z - .array(DashboardChartSchema) - .describe("Full replacement chart array."), + tags: arr(z.array(z.string()).optional().default([])), + charts: arr(z.array(DashboardChartSchema)).describe( + "Full replacement chart array.", + ), }), outputSchema: z.record(z.string(), z.unknown()), execute: async ({ context, runtimeContext }) => { diff --git a/hyperdx/server/tools/hyperdx.ts b/hyperdx/server/tools/hyperdx.ts index 63330c37..c4c91f2c 100644 --- a/hyperdx/server/tools/hyperdx.ts +++ b/hyperdx/server/tools/hyperdx.ts @@ -9,6 +9,7 @@ import { createTool } from "@decocms/runtime/tools"; import { z } from "zod"; import type { Env } from "../main.ts"; import { createHyperDXClient } from "../lib/client.ts"; +import { arr, num } from "../lib/coerce.ts"; import { getHyperDXApiKey } from "../lib/env.ts"; import { resolveTime, @@ -43,8 +44,7 @@ export const createSearchLogsTool = (_env: Env) => endTime: TimeInputSchema.optional() .default(() => Date.now()) .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), - limit: z - .number() + limit: num() .optional() .default(50) .describe("Max number of distinct messages to return. Defaults to 50."), @@ -112,13 +112,11 @@ export const createGetLogDetailsTool = (_env: Env) => query: z .string() .describe("Search query (e.g., 'level:error service:admin')."), - groupBy: z - .array(z.string()) - .optional() - .default(DEFAULT_GROUP_BY) - .describe( - "Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.", - ), + groupBy: arr( + z.array(z.string()).optional().default(DEFAULT_GROUP_BY), + ).describe( + "Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.", + ), startTime: TimeInputSchema.optional() .default(() => Date.now() - 15 * 60 * 1000) .describe( @@ -127,8 +125,7 @@ export const createGetLogDetailsTool = (_env: Env) => endTime: TimeInputSchema.optional() .default(() => Date.now()) .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), - limit: z - .number() + limit: num() .optional() .default(20) .describe("Max entries to return. Defaults to 20."), @@ -264,13 +261,11 @@ export const createQuerySpansTool = (_env: Env) => .describe( "Field to aggregate. Defaults to 'duration' (span duration in ms). Use 'duration' for latency analysis.", ), - groupBy: z - .array(z.string()) - .optional() - .default(["span_name", "service"]) - .describe( - "Fields to group by. Defaults to ['span_name', 'service']. Other useful fields: http.method, http.status_code, db.system.", - ), + groupBy: arr( + z.array(z.string()).optional().default(["span_name", "service"]), + ).describe( + "Fields to group by. Defaults to ['span_name', 'service']. Other useful fields: http.method, http.status_code, db.system.", + ), granularity: z .enum([ "30 second", @@ -296,8 +291,7 @@ export const createQuerySpansTool = (_env: Env) => endTime: TimeInputSchema.optional() .default(() => Date.now()) .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), - limit: z - .number() + limit: num() .optional() .default(20) .describe("Max entries to return. Defaults to 20."), @@ -388,13 +382,9 @@ export const createQueryMetricsTool = (_env: Env) => .describe( "Filter query (e.g., 'host:web-01', 'k8s.namespace:production'). Leave empty to query all.", ), - groupBy: z - .array(z.string()) - .optional() - .default([]) - .describe( - "Fields to group by (e.g., ['host'], ['k8s.pod.name'], ['service']). Leave empty for a single aggregated series.", - ), + groupBy: arr(z.array(z.string()).optional().default([])).describe( + "Fields to group by (e.g., ['host'], ['k8s.pod.name'], ['service']). Leave empty for a single aggregated series.", + ), granularity: z .enum([ "30 second", @@ -571,11 +561,9 @@ export const createCompareTimeRangesTool = (_env: Env) => priorEnd: TimeInputSchema.describe( `End of the prior/baseline period. ${TIME_INPUT_DESCRIPTION}`, ), - groupBy: z - .array(z.string()) - .optional() - .default([]) - .describe("Fields to group the comparison by (e.g., ['service'])."), + groupBy: arr(z.array(z.string()).optional().default([])).describe( + "Fields to group the comparison by (e.g., ['service']).", + ), }), outputSchema: z.object({ description: z.string(),