Skip to content

Commit 000de24

Browse files
committed
refactor: use Zod schema with .default(false) for additionalProperties
- Rename StrictJsonSchemaSchema to ToolInputSchema - Remove unused JsonSchemaSchema - Uses type-only import from zod/v4 to avoid type instantiation issues
1 parent 76e81ea commit 000de24

File tree

3 files changed

+50
-185
lines changed

3 files changed

+50
-185
lines changed

src/core/prompts/tools/native-tools/mcp_server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type OpenAI from "openai"
22
import { McpHub } from "../../../../services/mcp/McpHub"
33
import { buildMcpToolName } from "../../../../utils/mcp-name"
4-
import { StrictJsonSchemaSchema, type JsonSchema } from "../../../../utils/json-schema"
4+
import { ToolInputSchema, type JsonSchema } from "../../../../utils/json-schema"
55

66
/**
77
* Dynamically generates native tool definitions for all enabled tools across connected MCP servers.
@@ -43,14 +43,14 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
4343

4444
const originalSchema = tool.inputSchema as Record<string, unknown> | undefined
4545

46-
// Parse with StrictJsonSchemaSchema
46+
// Parse with ToolInputSchema to ensure additionalProperties: false is set recursively
4747
let parameters: JsonSchema
4848
if (originalSchema) {
49-
const result = StrictJsonSchemaSchema.safeParse(originalSchema)
49+
const result = ToolInputSchema.safeParse(originalSchema)
5050
parameters = result.success ? result.data : (originalSchema as JsonSchema)
5151
} else {
5252
// No schema provided - create a minimal valid schema
53-
parameters = StrictJsonSchemaSchema.parse({ type: "object" })
53+
parameters = ToolInputSchema.parse({ type: "object" })
5454
}
5555

5656
const toolDefinition: OpenAI.Chat.ChatCompletionTool = {

src/utils/__tests__/json-schema.spec.ts

Lines changed: 14 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,6 @@
1-
import { JsonSchemaSchema, StrictJsonSchemaSchema } from "../json-schema"
1+
import { ToolInputSchema } from "../json-schema"
22

3-
describe("JsonSchemaSchema", () => {
4-
it("should validate a simple schema", () => {
5-
const schema = { type: "object", properties: { name: { type: "string" } } }
6-
7-
const result = JsonSchemaSchema.safeParse(schema)
8-
9-
expect(result.success).toBe(true)
10-
if (result.success) {
11-
expect(result.data.type).toBe("object")
12-
}
13-
})
14-
15-
it("should reject invalid type values", () => {
16-
const schema = { type: "invalid-type" }
17-
18-
const result = JsonSchemaSchema.safeParse(schema)
19-
20-
expect(result.success).toBe(false)
21-
})
22-
23-
it("should validate nested schemas", () => {
24-
const schema = {
25-
type: "object",
26-
properties: {
27-
user: {
28-
type: "object",
29-
properties: {
30-
name: { type: "string" },
31-
age: { type: "integer" },
32-
},
33-
},
34-
},
35-
}
36-
37-
const result = JsonSchemaSchema.safeParse(schema)
38-
39-
expect(result.success).toBe(true)
40-
})
41-
42-
it("should validate array schemas", () => {
43-
const schema = {
44-
type: "array",
45-
items: {
46-
type: "object",
47-
properties: {
48-
id: { type: "number" },
49-
},
50-
},
51-
}
52-
53-
const result = JsonSchemaSchema.safeParse(schema)
54-
55-
expect(result.success).toBe(true)
56-
})
57-
58-
it("should validate schemas with anyOf/oneOf/allOf", () => {
59-
const schema = {
60-
anyOf: [{ type: "string" }, { type: "number" }],
61-
}
62-
63-
const result = JsonSchemaSchema.safeParse(schema)
64-
65-
expect(result.success).toBe(true)
66-
})
67-
68-
it("should pass through unknown properties", () => {
69-
const schema = {
70-
type: "object",
71-
customProperty: "custom value",
72-
properties: {
73-
name: { type: "string" },
74-
},
75-
}
76-
77-
const result = JsonSchemaSchema.safeParse(schema)
78-
79-
expect(result.success).toBe(true)
80-
if (result.success) {
81-
expect(result.data.customProperty).toBe("custom value")
82-
}
83-
})
84-
85-
it("should accept empty object (valid JSON Schema)", () => {
86-
const result = JsonSchemaSchema.safeParse({})
87-
88-
expect(result.success).toBe(true)
89-
})
90-
91-
it("should NOT add additionalProperties (validation only)", () => {
92-
const schema = {
93-
type: "object",
94-
properties: { name: { type: "string" } },
95-
}
96-
97-
const result = JsonSchemaSchema.parse(schema)
98-
99-
expect(result.additionalProperties).toBeUndefined()
100-
})
101-
})
102-
103-
describe("StrictJsonSchemaSchema", () => {
3+
describe("ToolInputSchema", () => {
1044
it("should validate and default additionalProperties to false", () => {
1055
const schema = {
1066
type: "object",
@@ -109,7 +9,7 @@ describe("StrictJsonSchemaSchema", () => {
1099
},
11010
}
11111

112-
const result = StrictJsonSchemaSchema.parse(schema)
12+
const result = ToolInputSchema.parse(schema)
11313

11414
expect(result.type).toBe("object")
11515
expect(result.additionalProperties).toBe(false)
@@ -128,7 +28,7 @@ describe("StrictJsonSchemaSchema", () => {
12828
},
12929
}
13030

131-
const result = StrictJsonSchemaSchema.parse(schema)
31+
const result = ToolInputSchema.parse(schema)
13232

13333
expect(result.additionalProperties).toBe(false)
13434
expect((result.properties as any).user.additionalProperties).toBe(false)
@@ -150,7 +50,7 @@ describe("StrictJsonSchemaSchema", () => {
15050
},
15151
}
15252

153-
const result = StrictJsonSchemaSchema.parse(schema)
53+
const result = ToolInputSchema.parse(schema)
15454

15555
expect(result.additionalProperties).toBe(false)
15656
expect((result.properties as any).items.items.additionalProperties).toBe(false)
@@ -159,13 +59,13 @@ describe("StrictJsonSchemaSchema", () => {
15959
it("should throw on invalid schema", () => {
16060
const invalidSchema = { type: "invalid-type" }
16161

162-
expect(() => StrictJsonSchemaSchema.parse(invalidSchema)).toThrow()
62+
expect(() => ToolInputSchema.parse(invalidSchema)).toThrow()
16363
})
16464

16565
it("should use safeParse for error handling", () => {
16666
const invalidSchema = { type: "invalid-type" }
16767

168-
const result = StrictJsonSchemaSchema.safeParse(invalidSchema)
68+
const result = ToolInputSchema.safeParse(invalidSchema)
16969

17070
expect(result.success).toBe(false)
17171
})
@@ -175,7 +75,7 @@ describe("StrictJsonSchemaSchema", () => {
17575
anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }],
17676
}
17777

178-
const result = StrictJsonSchemaSchema.parse(schema)
78+
const result = ToolInputSchema.parse(schema)
17979

18080
expect((result.anyOf as any)[0].additionalProperties).toBe(false)
18181
expect((result.anyOf as any)[1].additionalProperties).toBe(false)
@@ -186,7 +86,7 @@ describe("StrictJsonSchemaSchema", () => {
18686
oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }],
18787
}
18888

189-
const result = StrictJsonSchemaSchema.parse(schema)
89+
const result = ToolInputSchema.parse(schema)
19090

19191
expect((result.oneOf as any)[0].additionalProperties).toBe(false)
19292
expect((result.oneOf as any)[1].additionalProperties).toBe(false)
@@ -200,7 +100,7 @@ describe("StrictJsonSchemaSchema", () => {
200100
],
201101
}
202102

203-
const result = StrictJsonSchemaSchema.parse(schema)
103+
const result = ToolInputSchema.parse(schema)
204104

205105
expect((result.allOf as any)[0].additionalProperties).toBe(false)
206106
expect((result.allOf as any)[1].additionalProperties).toBe(false)
@@ -220,7 +120,7 @@ describe("StrictJsonSchemaSchema", () => {
220120
},
221121
}
222122

223-
const result = StrictJsonSchemaSchema.parse(schema)
123+
const result = ToolInputSchema.parse(schema)
224124

225125
const tupleItems = (result.properties as any).tuple.items
226126
expect(tupleItems[0].additionalProperties).toBe(false)
@@ -236,7 +136,7 @@ describe("StrictJsonSchemaSchema", () => {
236136
additionalProperties: false,
237137
}
238138

239-
const result = StrictJsonSchemaSchema.parse(schema)
139+
const result = ToolInputSchema.parse(schema)
240140

241141
expect(result.additionalProperties).toBe(false)
242142
})
@@ -267,7 +167,7 @@ describe("StrictJsonSchemaSchema", () => {
267167
},
268168
}
269169

270-
const result = StrictJsonSchemaSchema.parse(schema)
170+
const result = ToolInputSchema.parse(schema)
271171

272172
expect(result.additionalProperties).toBe(false)
273173
expect((result.properties as any).level1.additionalProperties).toBe(false)
@@ -303,7 +203,7 @@ describe("StrictJsonSchemaSchema", () => {
303203
required: ["entities"],
304204
}
305205

306-
const result = StrictJsonSchemaSchema.parse(schema)
206+
const result = ToolInputSchema.parse(schema)
307207

308208
// Top-level object should have additionalProperties: false
309209
expect(result.additionalProperties).toBe(false)

src/utils/json-schema.ts

Lines changed: 32 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { z } from "zod/v4"
1+
import type { z as z4 } from "zod/v4"
2+
import { z } from "zod"
23

34
/**
45
* Re-export Zod v4's JSONSchema type for convenience
56
*/
6-
export type JsonSchema = z.core.JSONSchema.JSONSchema
7+
export type JsonSchema = z4.core.JSONSchema.JSONSchema
78

89
/**
910
* Zod schema for JSON Schema primitive types
@@ -16,83 +17,47 @@ const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "bo
1617
const JsonSchemaEnumValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
1718

1819
/**
19-
* Zod schema for validating JSON Schema structures (without transformation).
20-
* Uses z.lazy for recursive definition.
21-
*
22-
* @example
23-
* ```typescript
24-
* const result = JsonSchemaSchema.safeParse(schema)
25-
* if (result.success) {
26-
* // schema is valid
27-
* }
28-
* ```
29-
*/
30-
export const JsonSchemaSchema: z.ZodType<JsonSchema> = z.lazy(() =>
31-
z.looseObject({
32-
type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(),
33-
properties: z.record(z.string(), JsonSchemaSchema).optional(),
34-
items: z.union([JsonSchemaSchema, z.array(JsonSchemaSchema)]).optional(),
35-
required: z.array(z.string()).optional(),
36-
additionalProperties: z.union([z.boolean(), JsonSchemaSchema]).optional(),
37-
description: z.string().optional(),
38-
default: z.unknown().optional(),
39-
enum: z.array(JsonSchemaEnumValueSchema).optional(),
40-
const: JsonSchemaEnumValueSchema.optional(),
41-
anyOf: z.array(JsonSchemaSchema).optional(),
42-
oneOf: z.array(JsonSchemaSchema).optional(),
43-
allOf: z.array(JsonSchemaSchema).optional(),
44-
$ref: z.string().optional(),
45-
minimum: z.number().optional(),
46-
maximum: z.number().optional(),
47-
minLength: z.number().optional(),
48-
maxLength: z.number().optional(),
49-
pattern: z.string().optional(),
50-
minItems: z.number().optional(),
51-
maxItems: z.number().optional(),
52-
uniqueItems: z.boolean().optional(),
53-
}),
54-
)
55-
56-
/**
57-
* Zod schema that validates JSON Schema and sets `additionalProperties: false` by default.
20+
* Zod schema that validates tool input JSON Schema and sets `additionalProperties: false` by default.
5821
* Uses recursive parsing so the default applies to all nested schemas automatically.
5922
*
6023
* This is required by some API providers (e.g., OpenAI) for strict function calling.
6124
*
6225
* @example
6326
* ```typescript
6427
* // Validates and applies defaults in one pass - throws on invalid
65-
* const strictSchema = StrictJsonSchemaSchema.parse(schema)
28+
* const validatedSchema = ToolInputSchema.parse(schema)
6629
*
6730
* // Or use safeParse for error handling
68-
* const result = StrictJsonSchemaSchema.safeParse(schema)
31+
* const result = ToolInputSchema.safeParse(schema)
6932
* if (result.success) {
7033
* // result.data has additionalProperties: false by default
7134
* }
7235
* ```
7336
*/
74-
export const StrictJsonSchemaSchema: z.ZodType<JsonSchema> = z.lazy(() =>
75-
z.looseObject({
76-
type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(),
77-
properties: z.record(z.string(), StrictJsonSchemaSchema).optional(),
78-
items: z.union([StrictJsonSchemaSchema, z.array(StrictJsonSchemaSchema)]).optional(),
79-
required: z.array(z.string()).optional(),
80-
additionalProperties: z.union([z.boolean(), StrictJsonSchemaSchema]).default(false),
81-
description: z.string().optional(),
82-
default: z.unknown().optional(),
83-
enum: z.array(JsonSchemaEnumValueSchema).optional(),
84-
const: JsonSchemaEnumValueSchema.optional(),
85-
anyOf: z.array(StrictJsonSchemaSchema).optional(),
86-
oneOf: z.array(StrictJsonSchemaSchema).optional(),
87-
allOf: z.array(StrictJsonSchemaSchema).optional(),
88-
$ref: z.string().optional(),
89-
minimum: z.number().optional(),
90-
maximum: z.number().optional(),
91-
minLength: z.number().optional(),
92-
maxLength: z.number().optional(),
93-
pattern: z.string().optional(),
94-
minItems: z.number().optional(),
95-
maxItems: z.number().optional(),
96-
uniqueItems: z.boolean().optional(),
97-
}),
37+
export const ToolInputSchema: z.ZodType<JsonSchema> = z.lazy(() =>
38+
z
39+
.object({
40+
type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(),
41+
properties: z.record(z.string(), ToolInputSchema).optional(),
42+
items: z.union([ToolInputSchema, z.array(ToolInputSchema)]).optional(),
43+
required: z.array(z.string()).optional(),
44+
additionalProperties: z.union([z.boolean(), ToolInputSchema]).default(false),
45+
description: z.string().optional(),
46+
default: z.unknown().optional(),
47+
enum: z.array(JsonSchemaEnumValueSchema).optional(),
48+
const: JsonSchemaEnumValueSchema.optional(),
49+
anyOf: z.array(ToolInputSchema).optional(),
50+
oneOf: z.array(ToolInputSchema).optional(),
51+
allOf: z.array(ToolInputSchema).optional(),
52+
$ref: z.string().optional(),
53+
minimum: z.number().optional(),
54+
maximum: z.number().optional(),
55+
minLength: z.number().optional(),
56+
maxLength: z.number().optional(),
57+
pattern: z.string().optional(),
58+
minItems: z.number().optional(),
59+
maxItems: z.number().optional(),
60+
uniqueItems: z.boolean().optional(),
61+
})
62+
.passthrough(),
9863
)

0 commit comments

Comments
 (0)