Skip to content

Commit 7f9dde1

Browse files
vibeguiclaude
andauthored
fix(hyperdx): tolerate JSON-encoded array/number params from MCP transport (#432)
Some MCP transport bridges JSON-stringify structured params (arrays, objects) and numeric params before delivering them to the server. Real session reports: - QUERY_CHART_DATA: series rejected (expected array, received string) — 6+ failures - GET_LOG_DETAILS: groupBy rejected (expected array, received string) — 2-3 failures - SEARCH_LOGS: limit rejected (expected number, received string) Add a small coerce helper (arr() / num()) and wrap every array and number input field across the HyperDX tools so they accept either the raw type or a JSON/numeric string. Mirrors the same pattern already in vtex/server/tools/custom/reorder-collection.ts. Adds unit tests for the helper and the queryChartDataInputSchema. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ded0ce1 commit 7f9dde1

6 files changed

Lines changed: 165 additions & 61 deletions

File tree

hyperdx/server/lib/coerce.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { z } from "zod";
3+
import { arr, num } from "./coerce.ts";
4+
import { queryChartDataInputSchema } from "./types.ts";
5+
6+
describe("arr", () => {
7+
const schema = arr(z.array(z.string()));
8+
9+
test("passes arrays through unchanged", () => {
10+
expect(schema.parse(["a", "b"])).toEqual(["a", "b"]);
11+
});
12+
13+
test("parses JSON-encoded array string", () => {
14+
expect(schema.parse('["a","b"]')).toEqual(["a", "b"]);
15+
});
16+
17+
test("tolerates whitespace around JSON string", () => {
18+
expect(schema.parse(' ["a","b"] ')).toEqual(["a", "b"]);
19+
});
20+
21+
test("non-JSON-looking string falls through to inner schema (errors)", () => {
22+
expect(() => schema.parse("not-an-array")).toThrow();
23+
});
24+
25+
test("malformed JSON string falls through to inner schema (errors)", () => {
26+
expect(() => schema.parse("[a, b]")).toThrow();
27+
});
28+
29+
test("respects optional + default on inner schema", () => {
30+
const optionalSchema = arr(z.array(z.string()).optional().default(["x"]));
31+
expect(optionalSchema.parse(undefined)).toEqual(["x"]);
32+
expect(optionalSchema.parse('["a"]')).toEqual(["a"]);
33+
});
34+
35+
test("parses nested object arrays", () => {
36+
const objSchema = arr(z.array(z.object({ k: z.string() })));
37+
expect(objSchema.parse('[{"k":"v"}]')).toEqual([{ k: "v" }]);
38+
});
39+
});
40+
41+
describe("num", () => {
42+
test("passes numbers through", () => {
43+
expect(num().parse(42)).toBe(42);
44+
});
45+
46+
test("coerces numeric string", () => {
47+
expect(num().parse("42")).toBe(42);
48+
});
49+
50+
test("rejects non-numeric string", () => {
51+
expect(() => num().parse("abc")).toThrow();
52+
});
53+
});
54+
55+
describe("queryChartDataInputSchema with stringified series", () => {
56+
test("accepts a JSON-encoded series array", () => {
57+
const result = queryChartDataInputSchema.parse({
58+
series:
59+
'[{"dataSource":"events","aggFn":"count","where":"","groupBy":["service"]}]',
60+
});
61+
expect(result.series).toEqual([
62+
{
63+
dataSource: "events",
64+
aggFn: "count",
65+
where: "",
66+
groupBy: ["service"],
67+
},
68+
]);
69+
});
70+
71+
test("accepts JSON-encoded groupBy inside series", () => {
72+
const result = queryChartDataInputSchema.parse({
73+
series: [
74+
{
75+
dataSource: "events",
76+
aggFn: "count",
77+
where: "",
78+
groupBy: '["service","level"]',
79+
},
80+
],
81+
});
82+
expect(result.series[0]?.groupBy).toEqual(["service", "level"]);
83+
});
84+
});

hyperdx/server/lib/coerce.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Input coercion helpers.
3+
*
4+
* Some MCP transports JSON-encode structured params (arrays, objects) as
5+
* strings before delivering them to the server, and stringify numeric params.
6+
* These helpers make input schemas tolerant of that quirk so calls like
7+
* `{ groupBy: '["service","level"]' }` or `{ limit: "100" }` succeed.
8+
*/
9+
10+
import { z } from "zod";
11+
12+
const parseJsonString = (value: unknown): unknown => {
13+
if (typeof value !== "string") return value;
14+
const trimmed = value.trim();
15+
if (
16+
!(
17+
(trimmed.startsWith("[") && trimmed.endsWith("]")) ||
18+
(trimmed.startsWith("{") && trimmed.endsWith("}"))
19+
)
20+
) {
21+
return value;
22+
}
23+
try {
24+
return JSON.parse(trimmed);
25+
} catch {
26+
return value;
27+
}
28+
};
29+
30+
export const arr = <T extends z.ZodTypeAny>(schema: T) =>
31+
z.preprocess(parseJsonString, schema);
32+
33+
export const num = () => z.coerce.number();

hyperdx/server/lib/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { z } from "zod";
6+
import { arr } from "./coerce.ts";
67
import { TIME_INPUT_DESCRIPTION, TimeInputSchema } from "./time.ts";
78

89
// ============================================================================
@@ -122,7 +123,7 @@ const SerieSchema = z.object({
122123
.describe(
123124
"Search query filter (e.g., 'level:error service:\"my-service\"').",
124125
),
125-
groupBy: z.array(z.string()).describe("Fields to group results by."),
126+
groupBy: arr(z.array(z.string())).describe("Fields to group results by."),
126127
metricDataType: z
127128
.enum(["Sum", "Gauge", "Histogram"])
128129
.optional()
@@ -141,7 +142,7 @@ export const queryChartDataInputSchema = z.object({
141142
granularity: GranularitySchema.optional()
142143
.default("1 minute")
143144
.describe("Time bucket granularity for aggregation. Defaults to 1 minute."),
144-
series: z.array(SerieSchema).describe("Array of series to query."),
145+
series: arr(z.array(SerieSchema)).describe("Array of series to query."),
145146
seriesReturnType: z
146147
.enum(["column", "ratio"])
147148
.optional()

hyperdx/server/tools/alerts.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createTool } from "@decocms/runtime/tools";
99
import { z } from "zod";
1010
import type { Env } from "../main.ts";
1111
import { createHyperDXClient } from "../lib/client.ts";
12+
import { arr, num } from "../lib/coerce.ts";
1213
import { getHyperDXApiKey } from "../lib/env.ts";
1314

1415
// ============================================================================
@@ -29,7 +30,7 @@ const AlertIntervalSchema = z.enum([
2930
const AlertChannelSchema = z.discriminatedUnion("type", [
3031
z.object({
3132
type: z.literal("email"),
32-
recipients: z.array(z.string()).describe("Email addresses to notify."),
33+
recipients: arr(z.array(z.string())).describe("Email addresses to notify."),
3334
}),
3435
z.object({
3536
type: z.literal("slack"),
@@ -125,9 +126,9 @@ export const createCreateAlertTool = (_env: Env) =>
125126
interval: AlertIntervalSchema.describe(
126127
"How often to evaluate the alert condition.",
127128
),
128-
threshold: z
129-
.number()
130-
.describe("Numeric threshold value that triggers the alert."),
129+
threshold: num().describe(
130+
"Numeric threshold value that triggers the alert.",
131+
),
131132
threshold_type: z
132133
.enum(["above", "below"])
133134
.describe("Fire when the value is 'above' or 'below' the threshold."),
@@ -196,7 +197,7 @@ export const createUpdateAlertTool = (_env: Env) =>
196197
inputSchema: z.object({
197198
id: z.string().describe("The alert ID to update."),
198199
interval: AlertIntervalSchema.optional(),
199-
threshold: z.number().optional(),
200+
threshold: num().optional(),
200201
threshold_type: z.enum(["above", "below"]).optional(),
201202
source: z.enum(["chart", "search"]).optional(),
202203
channel: AlertChannelSchema.optional(),

hyperdx/server/tools/dashboards.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createTool } from "@decocms/runtime/tools";
88
import { z } from "zod";
99
import type { Env } from "../main.ts";
1010
import { createHyperDXClient } from "../lib/client.ts";
11+
import { arr, num } from "../lib/coerce.ts";
1112
import { getHyperDXApiKey } from "../lib/env.ts";
1213

1314
// ============================================================================
@@ -48,7 +49,7 @@ const DashboardSeriesSchema = z.object({
4849
aggFn: DashboardAggFnSchema.describe("Aggregation function."),
4950
field: z.string().optional().describe("Field to aggregate."),
5051
where: z.string().describe("Search filter query."),
51-
groupBy: z.array(z.string()).describe("Fields to group by."),
52+
groupBy: arr(z.array(z.string())).describe("Fields to group by."),
5253
metricDataType: z
5354
.enum(["Sum", "Gauge", "Histogram"])
5455
.optional()
@@ -58,13 +59,13 @@ const DashboardSeriesSchema = z.object({
5859
const DashboardChartSchema = z.object({
5960
id: z.string().optional().describe("Chart ID (auto-generated on create)."),
6061
name: z.string().describe("Chart display name."),
61-
x: z.number().describe("Grid column position (0-based)."),
62-
y: z.number().describe("Grid row position (0-based)."),
63-
w: z.number().describe("Width in grid units."),
64-
h: z.number().describe("Height in grid units."),
65-
series: z
66-
.array(DashboardSeriesSchema)
67-
.describe("Data series for this chart (up to 5)."),
62+
x: num().describe("Grid column position (0-based)."),
63+
y: num().describe("Grid row position (0-based)."),
64+
w: num().describe("Width in grid units."),
65+
h: num().describe("Height in grid units."),
66+
series: arr(z.array(DashboardSeriesSchema)).describe(
67+
"Data series for this chart (up to 5).",
68+
),
6869
});
6970

7071
// ============================================================================
@@ -128,16 +129,12 @@ export const createCreateDashboardTool = (_env: Env) =>
128129
.describe(
129130
"Global filter applied to all charts on the dashboard (e.g., 'env:production').",
130131
),
131-
tags: z
132-
.array(z.string())
133-
.optional()
134-
.default([])
135-
.describe("Organizational tags for the dashboard."),
136-
charts: z
137-
.array(DashboardChartSchema)
138-
.describe(
139-
"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.",
140-
),
132+
tags: arr(z.array(z.string()).optional().default([])).describe(
133+
"Organizational tags for the dashboard.",
134+
),
135+
charts: arr(z.array(DashboardChartSchema)).describe(
136+
"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.",
137+
),
141138
}),
142139
outputSchema: z.record(z.string(), z.unknown()),
143140
execute: async ({ context, runtimeContext }) => {
@@ -164,10 +161,10 @@ export const createUpdateDashboardTool = (_env: Env) =>
164161
.optional()
165162
.default("")
166163
.describe("Global filter applied to all charts."),
167-
tags: z.array(z.string()).optional().default([]),
168-
charts: z
169-
.array(DashboardChartSchema)
170-
.describe("Full replacement chart array."),
164+
tags: arr(z.array(z.string()).optional().default([])),
165+
charts: arr(z.array(DashboardChartSchema)).describe(
166+
"Full replacement chart array.",
167+
),
171168
}),
172169
outputSchema: z.record(z.string(), z.unknown()),
173170
execute: async ({ context, runtimeContext }) => {

hyperdx/server/tools/hyperdx.ts

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createTool } from "@decocms/runtime/tools";
99
import { z } from "zod";
1010
import type { Env } from "../main.ts";
1111
import { createHyperDXClient } from "../lib/client.ts";
12+
import { arr, num } from "../lib/coerce.ts";
1213
import { getHyperDXApiKey } from "../lib/env.ts";
1314
import {
1415
resolveTime,
@@ -43,8 +44,7 @@ export const createSearchLogsTool = (_env: Env) =>
4344
endTime: TimeInputSchema.optional()
4445
.default(() => Date.now())
4546
.describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`),
46-
limit: z
47-
.number()
47+
limit: num()
4848
.optional()
4949
.default(50)
5050
.describe("Max number of distinct messages to return. Defaults to 50."),
@@ -112,13 +112,11 @@ export const createGetLogDetailsTool = (_env: Env) =>
112112
query: z
113113
.string()
114114
.describe("Search query (e.g., 'level:error service:admin')."),
115-
groupBy: z
116-
.array(z.string())
117-
.optional()
118-
.default(DEFAULT_GROUP_BY)
119-
.describe(
120-
"Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.",
121-
),
115+
groupBy: arr(
116+
z.array(z.string()).optional().default(DEFAULT_GROUP_BY),
117+
).describe(
118+
"Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.",
119+
),
122120
startTime: TimeInputSchema.optional()
123121
.default(() => Date.now() - 15 * 60 * 1000)
124122
.describe(
@@ -127,8 +125,7 @@ export const createGetLogDetailsTool = (_env: Env) =>
127125
endTime: TimeInputSchema.optional()
128126
.default(() => Date.now())
129127
.describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`),
130-
limit: z
131-
.number()
128+
limit: num()
132129
.optional()
133130
.default(20)
134131
.describe("Max entries to return. Defaults to 20."),
@@ -264,13 +261,11 @@ export const createQuerySpansTool = (_env: Env) =>
264261
.describe(
265262
"Field to aggregate. Defaults to 'duration' (span duration in ms). Use 'duration' for latency analysis.",
266263
),
267-
groupBy: z
268-
.array(z.string())
269-
.optional()
270-
.default(["span_name", "service"])
271-
.describe(
272-
"Fields to group by. Defaults to ['span_name', 'service']. Other useful fields: http.method, http.status_code, db.system.",
273-
),
264+
groupBy: arr(
265+
z.array(z.string()).optional().default(["span_name", "service"]),
266+
).describe(
267+
"Fields to group by. Defaults to ['span_name', 'service']. Other useful fields: http.method, http.status_code, db.system.",
268+
),
274269
granularity: z
275270
.enum([
276271
"30 second",
@@ -296,8 +291,7 @@ export const createQuerySpansTool = (_env: Env) =>
296291
endTime: TimeInputSchema.optional()
297292
.default(() => Date.now())
298293
.describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`),
299-
limit: z
300-
.number()
294+
limit: num()
301295
.optional()
302296
.default(20)
303297
.describe("Max entries to return. Defaults to 20."),
@@ -388,13 +382,9 @@ export const createQueryMetricsTool = (_env: Env) =>
388382
.describe(
389383
"Filter query (e.g., 'host:web-01', 'k8s.namespace:production'). Leave empty to query all.",
390384
),
391-
groupBy: z
392-
.array(z.string())
393-
.optional()
394-
.default([])
395-
.describe(
396-
"Fields to group by (e.g., ['host'], ['k8s.pod.name'], ['service']). Leave empty for a single aggregated series.",
397-
),
385+
groupBy: arr(z.array(z.string()).optional().default([])).describe(
386+
"Fields to group by (e.g., ['host'], ['k8s.pod.name'], ['service']). Leave empty for a single aggregated series.",
387+
),
398388
granularity: z
399389
.enum([
400390
"30 second",
@@ -571,11 +561,9 @@ export const createCompareTimeRangesTool = (_env: Env) =>
571561
priorEnd: TimeInputSchema.describe(
572562
`End of the prior/baseline period. ${TIME_INPUT_DESCRIPTION}`,
573563
),
574-
groupBy: z
575-
.array(z.string())
576-
.optional()
577-
.default([])
578-
.describe("Fields to group the comparison by (e.g., ['service'])."),
564+
groupBy: arr(z.array(z.string()).optional().default([])).describe(
565+
"Fields to group the comparison by (e.g., ['service']).",
566+
),
579567
}),
580568
outputSchema: z.object({
581569
description: z.string(),

0 commit comments

Comments
 (0)