Skip to content

Commit 6bcc156

Browse files
committed
fix: add additionalProperties: false to nested MCP tool schemas
OpenAI's API requires additionalProperties: false on all object schemas, including nested ones. MCP tools like 'create_entities' have array items that are objects, which were missing this property. Created a recursive utility function that transforms JSON schemas to ensure all nested object schemas have additionalProperties: false.
1 parent 1d4fc52 commit 6bcc156

File tree

3 files changed

+369
-1
lines changed

3 files changed

+369
-1
lines changed

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

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

56
/**
67
* Dynamically generates native tool definitions for all enabled tools across connected MCP servers.
@@ -27,9 +28,13 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
2728
}
2829

2930
const originalSchema = tool.inputSchema as Record<string, any> | undefined
30-
const toolInputProps = originalSchema?.properties ?? {}
3131
const toolInputRequired = (originalSchema?.required ?? []) as string[]
3232

33+
// Transform the schema to ensure all nested object schemas have additionalProperties: false
34+
// This is required by some API providers (e.g., OpenAI) for strict function calling
35+
const transformedSchema = originalSchema ? addAdditionalPropertiesFalse(originalSchema) : {}
36+
const toolInputProps = (transformedSchema as Record<string, any>)?.properties ?? {}
37+
3338
// Build parameters directly from the tool's input schema.
3439
// The server_name and tool_name are encoded in the function name itself
3540
// (e.g., mcp_serverName_toolName), so they don't need to be in the arguments.
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { addAdditionalPropertiesFalse } from "../json-schema"
2+
3+
describe("addAdditionalPropertiesFalse", () => {
4+
it("should add additionalProperties: false to a simple object schema", () => {
5+
const schema = {
6+
type: "object",
7+
properties: {
8+
name: { type: "string" },
9+
},
10+
}
11+
12+
const result = addAdditionalPropertiesFalse(schema)
13+
14+
expect(result).toEqual({
15+
type: "object",
16+
properties: {
17+
name: { type: "string" },
18+
},
19+
additionalProperties: false,
20+
})
21+
})
22+
23+
it("should add additionalProperties: false to nested object schemas", () => {
24+
const schema = {
25+
type: "object",
26+
properties: {
27+
user: {
28+
type: "object",
29+
properties: {
30+
name: { type: "string" },
31+
address: {
32+
type: "object",
33+
properties: {
34+
street: { type: "string" },
35+
},
36+
},
37+
},
38+
},
39+
},
40+
}
41+
42+
const result = addAdditionalPropertiesFalse(schema)
43+
44+
expect(result).toEqual({
45+
type: "object",
46+
properties: {
47+
user: {
48+
type: "object",
49+
properties: {
50+
name: { type: "string" },
51+
address: {
52+
type: "object",
53+
properties: {
54+
street: { type: "string" },
55+
},
56+
additionalProperties: false,
57+
},
58+
},
59+
additionalProperties: false,
60+
},
61+
},
62+
additionalProperties: false,
63+
})
64+
})
65+
66+
it("should add additionalProperties: false to array items that are objects", () => {
67+
const schema = {
68+
type: "object",
69+
properties: {
70+
entities: {
71+
type: "array",
72+
items: {
73+
type: "object",
74+
properties: {
75+
name: { type: "string" },
76+
entityType: { type: "string" },
77+
},
78+
},
79+
},
80+
},
81+
}
82+
83+
const result = addAdditionalPropertiesFalse(schema)
84+
85+
expect(result).toEqual({
86+
type: "object",
87+
properties: {
88+
entities: {
89+
type: "array",
90+
items: {
91+
type: "object",
92+
properties: {
93+
name: { type: "string" },
94+
entityType: { type: "string" },
95+
},
96+
additionalProperties: false,
97+
},
98+
},
99+
},
100+
additionalProperties: false,
101+
})
102+
})
103+
104+
it("should handle tuple-style array items", () => {
105+
const schema = {
106+
type: "object",
107+
properties: {
108+
tuple: {
109+
type: "array",
110+
items: [
111+
{ type: "object", properties: { a: { type: "string" } } },
112+
{ type: "object", properties: { b: { type: "number" } } },
113+
],
114+
},
115+
},
116+
}
117+
118+
const result = addAdditionalPropertiesFalse(schema)
119+
120+
expect(result).toEqual({
121+
type: "object",
122+
properties: {
123+
tuple: {
124+
type: "array",
125+
items: [
126+
{ type: "object", properties: { a: { type: "string" } }, additionalProperties: false },
127+
{ type: "object", properties: { b: { type: "number" } }, additionalProperties: false },
128+
],
129+
},
130+
},
131+
additionalProperties: false,
132+
})
133+
})
134+
135+
it("should preserve existing additionalProperties: false", () => {
136+
const schema = {
137+
type: "object",
138+
properties: {
139+
name: { type: "string" },
140+
},
141+
additionalProperties: false,
142+
}
143+
144+
const result = addAdditionalPropertiesFalse(schema)
145+
146+
expect(result).toEqual({
147+
type: "object",
148+
properties: {
149+
name: { type: "string" },
150+
},
151+
additionalProperties: false,
152+
})
153+
})
154+
155+
it("should handle anyOf schemas", () => {
156+
const schema = {
157+
anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }],
158+
}
159+
160+
const result = addAdditionalPropertiesFalse(schema)
161+
162+
expect(result).toEqual({
163+
anyOf: [
164+
{ type: "object", properties: { a: { type: "string" } }, additionalProperties: false },
165+
{ type: "string" },
166+
],
167+
})
168+
})
169+
170+
it("should handle oneOf schemas", () => {
171+
const schema = {
172+
oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }],
173+
}
174+
175+
const result = addAdditionalPropertiesFalse(schema)
176+
177+
expect(result).toEqual({
178+
oneOf: [
179+
{ type: "object", properties: { a: { type: "string" } }, additionalProperties: false },
180+
{ type: "number" },
181+
],
182+
})
183+
})
184+
185+
it("should handle allOf schemas", () => {
186+
const schema = {
187+
allOf: [
188+
{ type: "object", properties: { a: { type: "string" } } },
189+
{ type: "object", properties: { b: { type: "number" } } },
190+
],
191+
}
192+
193+
const result = addAdditionalPropertiesFalse(schema)
194+
195+
expect(result).toEqual({
196+
allOf: [
197+
{ type: "object", properties: { a: { type: "string" } }, additionalProperties: false },
198+
{ type: "object", properties: { b: { type: "number" } }, additionalProperties: false },
199+
],
200+
})
201+
})
202+
203+
it("should not mutate the original schema", () => {
204+
const schema = {
205+
type: "object",
206+
properties: {
207+
name: { type: "string" },
208+
},
209+
}
210+
211+
const original = JSON.parse(JSON.stringify(schema))
212+
addAdditionalPropertiesFalse(schema)
213+
214+
expect(schema).toEqual(original)
215+
})
216+
217+
it("should return non-object values as-is", () => {
218+
expect(addAdditionalPropertiesFalse(null as any)).toBeNull()
219+
expect(addAdditionalPropertiesFalse("string" as any)).toBe("string")
220+
})
221+
222+
it("should handle deeply nested complex schemas", () => {
223+
const schema = {
224+
type: "object",
225+
properties: {
226+
level1: {
227+
type: "object",
228+
properties: {
229+
level2: {
230+
type: "array",
231+
items: {
232+
type: "object",
233+
properties: {
234+
level3: {
235+
type: "object",
236+
properties: {
237+
value: { type: "string" },
238+
},
239+
},
240+
},
241+
},
242+
},
243+
},
244+
},
245+
},
246+
}
247+
248+
const result = addAdditionalPropertiesFalse(schema)
249+
250+
expect(result.additionalProperties).toBe(false)
251+
expect((result.properties as any).level1.additionalProperties).toBe(false)
252+
expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false)
253+
expect((result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties).toBe(
254+
false,
255+
)
256+
})
257+
258+
it("should handle the real-world MCP memory create_entities schema", () => {
259+
// This is based on the actual schema that caused the error
260+
const schema = {
261+
type: "object",
262+
properties: {
263+
entities: {
264+
type: "array",
265+
items: {
266+
type: "object",
267+
properties: {
268+
name: { type: "string", description: "The name of the entity" },
269+
entityType: { type: "string", description: "The type of the entity" },
270+
observations: {
271+
type: "array",
272+
items: { type: "string" },
273+
description: "An array of observation contents",
274+
},
275+
},
276+
required: ["name", "entityType", "observations"],
277+
},
278+
description: "An array of entities to create",
279+
},
280+
},
281+
required: ["entities"],
282+
}
283+
284+
const result = addAdditionalPropertiesFalse(schema)
285+
286+
// Top-level object should have additionalProperties: false
287+
expect(result.additionalProperties).toBe(false)
288+
// Items in the entities array should have additionalProperties: false
289+
expect((result.properties as any).entities.items.additionalProperties).toBe(false)
290+
})
291+
})

src/utils/json-schema.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Recursively adds `additionalProperties: false` to all object schemas in a JSON Schema.
3+
* This is required by some API providers (e.g., OpenAI) for strict function calling.
4+
*
5+
* @param schema - The JSON Schema object to transform
6+
* @returns A new schema object with `additionalProperties: false` added to all object schemas
7+
*/
8+
export function addAdditionalPropertiesFalse(schema: Record<string, unknown>): Record<string, unknown> {
9+
if (typeof schema !== "object" || schema === null) {
10+
return schema
11+
}
12+
13+
// Create a shallow copy to avoid mutating the original
14+
const result: Record<string, unknown> = { ...schema }
15+
16+
// If this is an object schema, add additionalProperties: false
17+
if (result.type === "object") {
18+
result.additionalProperties = false
19+
}
20+
21+
// Recursively process properties
22+
if (result.properties && typeof result.properties === "object") {
23+
const properties = result.properties as Record<string, unknown>
24+
const newProperties: Record<string, unknown> = {}
25+
for (const key of Object.keys(properties)) {
26+
const value = properties[key]
27+
if (typeof value === "object" && value !== null) {
28+
newProperties[key] = addAdditionalPropertiesFalse(value as Record<string, unknown>)
29+
} else {
30+
newProperties[key] = value
31+
}
32+
}
33+
result.properties = newProperties
34+
}
35+
36+
// Recursively process items (for arrays)
37+
if (result.items && typeof result.items === "object") {
38+
if (Array.isArray(result.items)) {
39+
result.items = result.items.map((item) =>
40+
typeof item === "object" && item !== null
41+
? addAdditionalPropertiesFalse(item as Record<string, unknown>)
42+
: item,
43+
)
44+
} else {
45+
result.items = addAdditionalPropertiesFalse(result.items as Record<string, unknown>)
46+
}
47+
}
48+
49+
// Recursively process anyOf, oneOf, allOf
50+
for (const keyword of ["anyOf", "oneOf", "allOf"]) {
51+
if (Array.isArray(result[keyword])) {
52+
result[keyword] = (result[keyword] as unknown[]).map((subSchema) =>
53+
typeof subSchema === "object" && subSchema !== null
54+
? addAdditionalPropertiesFalse(subSchema as Record<string, unknown>)
55+
: subSchema,
56+
)
57+
}
58+
}
59+
60+
// Recursively process additionalProperties if it's a schema (not just true/false)
61+
if (
62+
result.additionalProperties &&
63+
typeof result.additionalProperties === "object" &&
64+
result.additionalProperties !== null
65+
) {
66+
result.additionalProperties = addAdditionalPropertiesFalse(
67+
result.additionalProperties as Record<string, unknown>,
68+
)
69+
}
70+
71+
return result
72+
}

0 commit comments

Comments
 (0)