Skip to content

Commit 7f4b006

Browse files
committed
test(mcp): add tests for jsonPreprocess and extract to module
Extract jsonPreprocess into its own module for testability. Add vitest with 8 tests covering: - native objects passed through unchanged - JSON strings parsed before validation - "all" literal preserved - invalid JSON rejected by schema - nested filter schemas - sort schemas
1 parent 7612b00 commit 7f4b006

File tree

4 files changed

+108
-16
lines changed

4 files changed

+108
-16
lines changed

helicone-mcp/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"dev": "tsx src/index.ts",
2020
"format": "biome format --write",
2121
"lint:fix": "biome lint --fix",
22+
"test": "vitest run",
2223
"type-check": "tsc --noEmit",
2324
"flatten-types": "tsx flatten-types.ts",
2425
"generate-zod": "npm run flatten-types && ts-to-zod src/types/flat.ts src/types/generated-zod.ts && npm run build"
@@ -45,8 +46,9 @@
4546
"devDependencies": {
4647
"@biomejs/biome": "^2.2.5",
4748
"@types/node": "^24.9.2",
49+
"tsx": "^4.7.0",
4850
"typescript": "5.9.3",
49-
"tsx": "^4.7.0"
51+
"vitest": "^4.0.18"
5052
},
5153
"publishConfig": {
5254
"access": "public"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect } from "vitest";
2+
import { z } from "zod";
3+
import { jsonPreprocess } from "../lib/json-preprocess.js";
4+
5+
describe("jsonPreprocess", () => {
6+
const objectSchema = z.object({
7+
name: z.string(),
8+
value: z.number(),
9+
});
10+
11+
it("passes through native objects unchanged", () => {
12+
const schema = jsonPreprocess(objectSchema);
13+
const result = schema.parse({ name: "test", value: 42 });
14+
expect(result).toEqual({ name: "test", value: 42 });
15+
});
16+
17+
it("parses a JSON string into an object before validation", () => {
18+
const schema = jsonPreprocess(objectSchema);
19+
const result = schema.parse('{"name": "test", "value": 42}');
20+
expect(result).toEqual({ name: "test", value: 42 });
21+
});
22+
23+
it("rejects a parsed JSON string that doesn't match the schema", () => {
24+
const schema = jsonPreprocess(objectSchema);
25+
expect(() => schema.parse('{"name": 123}')).toThrow();
26+
});
27+
28+
it("preserves the 'all' literal string without parsing", () => {
29+
const unionSchema = z.union([objectSchema, z.literal("all")]);
30+
const schema = jsonPreprocess(unionSchema);
31+
const result = schema.parse("all");
32+
expect(result).toBe("all");
33+
});
34+
35+
it("passes through invalid JSON strings for the schema to reject", () => {
36+
const schema = jsonPreprocess(objectSchema);
37+
expect(() => schema.parse("not valid json")).toThrow();
38+
});
39+
40+
it("passes through non-string values (numbers, booleans, null)", () => {
41+
const numberSchema = jsonPreprocess(z.number());
42+
expect(numberSchema.parse(42)).toBe(42);
43+
44+
const boolSchema = jsonPreprocess(z.boolean());
45+
expect(boolSchema.parse(true)).toBe(true);
46+
});
47+
48+
it("works with nested filter schemas", () => {
49+
const filterSchema = z.union([
50+
z.object({
51+
request_response_rmt: z.object({
52+
request_id: z.object({
53+
equals: z.string(),
54+
}).optional(),
55+
}).optional(),
56+
}),
57+
z.literal("all"),
58+
]);
59+
60+
const schema = jsonPreprocess(filterSchema);
61+
62+
// JSON string with nested filter
63+
const jsonInput = '{"request_response_rmt": {"request_id": {"equals": "abc-123"}}}';
64+
const result = schema.parse(jsonInput);
65+
expect(result).toEqual({
66+
request_response_rmt: {
67+
request_id: { equals: "abc-123" },
68+
},
69+
});
70+
71+
// "all" literal still works
72+
expect(schema.parse("all")).toBe("all");
73+
74+
// Native object still works
75+
const nativeResult = schema.parse({ request_response_rmt: { request_id: { equals: "xyz" } } });
76+
expect(nativeResult).toEqual({ request_response_rmt: { request_id: { equals: "xyz" } } });
77+
});
78+
79+
it("works with sort schemas", () => {
80+
const sortSchema = z.object({
81+
created_at: z.union([z.literal("asc"), z.literal("desc")]).optional(),
82+
});
83+
84+
const schema = jsonPreprocess(sortSchema);
85+
const result = schema.parse('{"created_at": "desc"}');
86+
expect(result).toEqual({ created_at: "desc" });
87+
});
88+
});

helicone-mcp/src/index.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
44
import { z } from "zod";
55
import { fetchRequests, fetchSessions, useAiGateway } from "./lib/helicone-client.js";
66
import { requestFilterNodeSchema, sortLeafRequestSchema, sessionFilterNodeSchema } from "./types/generated-zod.js";
7-
8-
/**
9-
* Wraps a Zod schema with a preprocessing step that parses JSON strings.
10-
* Some MCP clients (e.g. Claude Code) serialize complex object parameters
11-
* as JSON strings rather than native objects. This ensures validation
12-
* still works regardless of how the client sends the data.
13-
*/
14-
function jsonPreprocess<T extends z.ZodTypeAny>(schema: T) {
15-
return z.preprocess((val) => {
16-
if (typeof val === "string" && val !== "all") {
17-
try { return JSON.parse(val); } catch { return val; }
18-
}
19-
return val;
20-
}, schema);
21-
}
7+
import { jsonPreprocess } from "./lib/json-preprocess.js";
228

239
const HELICONE_API_KEY = process.env.HELICONE_API_KEY;
2410

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Wraps a Zod schema with a preprocessing step that parses JSON strings.
5+
* Some MCP clients (e.g. Claude Code) serialize complex object parameters
6+
* as JSON strings rather than native objects. This ensures validation
7+
* still works regardless of how the client sends the data.
8+
*/
9+
export function jsonPreprocess<T extends z.ZodTypeAny>(schema: T) {
10+
return z.preprocess((val) => {
11+
if (typeof val === "string" && val !== "all") {
12+
try { return JSON.parse(val); } catch { return val; }
13+
}
14+
return val;
15+
}, schema);
16+
}

0 commit comments

Comments
 (0)