Skip to content

Commit 75aa643

Browse files
committed
🤖 fix: sanitize MCP tool schemas for OpenAI Responses API compatibility
OpenAI's Responses API has stricter JSON Schema validation than other providers like Anthropic. Certain schema properties that are valid JSON Schema but not supported by OpenAI cause 400 errors when MCP tools are used. This change adds a schema sanitizer that strips unsupported properties from MCP tool schemas when using OpenAI models: - String validation: minLength, maxLength, pattern, format - Number validation: minimum, maximum, exclusiveMinimum, exclusiveMaximum - Array validation: minItems, maxItems, uniqueItems - Object validation: minProperties, maxProperties - General: default, examples, deprecated, readOnly, writeOnly The sanitization is applied only to MCP tools and only for OpenAI models, preserving the full schema for other providers.
1 parent e9d5a93 commit 75aa643

File tree

3 files changed

+361
-1
lines changed

3 files changed

+361
-1
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
2+
import { sanitizeToolSchemaForOpenAI, sanitizeMCPToolsForOpenAI } from "./schemaSanitizer";
3+
import type { Tool } from "ai";
4+
5+
// Test helper to access tool parameters
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
function getParams(tool: Tool): any {
8+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9+
return (tool as any).parameters;
10+
}
11+
12+
describe("schemaSanitizer", () => {
13+
describe("sanitizeToolSchemaForOpenAI", () => {
14+
it("should strip minLength from string properties", () => {
15+
const tool = {
16+
description: "Test tool",
17+
parameters: {
18+
type: "object",
19+
properties: {
20+
content: { type: "string", minLength: 1 },
21+
},
22+
},
23+
} as unknown as Tool;
24+
25+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
26+
const params = getParams(sanitized);
27+
28+
expect(params.properties.content).toEqual({ type: "string" });
29+
expect(params.properties.content.minLength).toBeUndefined();
30+
});
31+
32+
it("should strip multiple unsupported properties", () => {
33+
const tool = {
34+
description: "Test tool",
35+
parameters: {
36+
type: "object",
37+
properties: {
38+
name: { type: "string", minLength: 1, maxLength: 100, pattern: "^[a-z]+$" },
39+
age: { type: "number", minimum: 0, maximum: 150, default: 25 },
40+
},
41+
},
42+
} as unknown as Tool;
43+
44+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
45+
const params = getParams(sanitized);
46+
47+
expect(params.properties.name).toEqual({ type: "string" });
48+
expect(params.properties.age).toEqual({ type: "number" });
49+
});
50+
51+
it("should handle nested objects", () => {
52+
const tool = {
53+
description: "Test tool",
54+
parameters: {
55+
type: "object",
56+
properties: {
57+
user: {
58+
type: "object",
59+
properties: {
60+
email: { type: "string", format: "email", minLength: 5 },
61+
},
62+
},
63+
},
64+
},
65+
} as unknown as Tool;
66+
67+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
68+
const params = getParams(sanitized);
69+
70+
expect(params.properties.user.properties.email).toEqual({ type: "string" });
71+
});
72+
73+
it("should handle array items", () => {
74+
const tool = {
75+
description: "Test tool",
76+
parameters: {
77+
type: "object",
78+
properties: {
79+
tags: {
80+
type: "array",
81+
items: { type: "string", minLength: 1 },
82+
minItems: 1,
83+
maxItems: 10,
84+
},
85+
},
86+
},
87+
} as unknown as Tool;
88+
89+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
90+
const params = getParams(sanitized);
91+
92+
expect(params.properties.tags.items).toEqual({ type: "string" });
93+
expect(params.properties.tags.minItems).toBeUndefined();
94+
expect(params.properties.tags.maxItems).toBeUndefined();
95+
});
96+
97+
it("should handle anyOf/oneOf schemas", () => {
98+
const tool = {
99+
description: "Test tool",
100+
parameters: {
101+
type: "object",
102+
properties: {
103+
value: {
104+
oneOf: [
105+
{ type: "string", minLength: 1 },
106+
{ type: "number", minimum: 0 },
107+
],
108+
},
109+
},
110+
},
111+
} as unknown as Tool;
112+
113+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
114+
const params = getParams(sanitized);
115+
116+
expect(params.properties.value.oneOf[0]).toEqual({ type: "string" });
117+
expect(params.properties.value.oneOf[1]).toEqual({ type: "number" });
118+
});
119+
120+
it("should preserve required and type properties", () => {
121+
const tool = {
122+
description: "Test tool",
123+
parameters: {
124+
type: "object",
125+
properties: {
126+
content: { type: "string", minLength: 1 },
127+
},
128+
required: ["content"],
129+
},
130+
} as unknown as Tool;
131+
132+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
133+
const params = getParams(sanitized);
134+
135+
expect(params.type).toBe("object");
136+
expect(params.required).toEqual(["content"]);
137+
});
138+
139+
it("should return tool as-is if no parameters", () => {
140+
const tool = {
141+
description: "Test tool",
142+
} as unknown as Tool;
143+
144+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
145+
146+
expect(sanitized).toEqual(tool);
147+
});
148+
149+
it("should not mutate the original tool", () => {
150+
const tool = {
151+
description: "Test tool",
152+
parameters: {
153+
type: "object",
154+
properties: {
155+
content: { type: "string", minLength: 1 },
156+
},
157+
},
158+
} as unknown as Tool;
159+
160+
sanitizeToolSchemaForOpenAI(tool);
161+
const params = getParams(tool);
162+
163+
// Original should still have minLength
164+
expect(params.properties.content.minLength).toBe(1);
165+
});
166+
});
167+
168+
describe("sanitizeMCPToolsForOpenAI", () => {
169+
it("should sanitize all tools in a record", () => {
170+
const tools = {
171+
tool1: {
172+
description: "Tool 1",
173+
parameters: {
174+
type: "object",
175+
properties: {
176+
content: { type: "string", minLength: 1 },
177+
},
178+
},
179+
},
180+
tool2: {
181+
description: "Tool 2",
182+
parameters: {
183+
type: "object",
184+
properties: {
185+
count: { type: "number", minimum: 0 },
186+
},
187+
},
188+
},
189+
} as unknown as Record<string, Tool>;
190+
191+
const sanitized = sanitizeMCPToolsForOpenAI(tools);
192+
193+
expect(getParams(sanitized.tool1).properties.content).toEqual({
194+
type: "string",
195+
});
196+
expect(getParams(sanitized.tool2).properties.count).toEqual({
197+
type: "number",
198+
});
199+
});
200+
});
201+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { type Tool } from "ai";
2+
3+
/**
4+
* JSON Schema properties that are not permitted by OpenAI's Responses API.
5+
*
6+
* OpenAI's Structured Outputs has stricter JSON Schema validation than other providers.
7+
* MCP tools may have schemas with these properties which work fine with Anthropic
8+
* but fail with OpenAI. We strip these properties to ensure compatibility.
9+
*
10+
* @see https://platform.openai.com/docs/guides/structured-outputs
11+
* @see https://github.com/vercel/ai/discussions/5164
12+
*/
13+
const OPENAI_UNSUPPORTED_SCHEMA_PROPERTIES = new Set([
14+
// String validation
15+
"minLength",
16+
"maxLength",
17+
"pattern",
18+
"format",
19+
// Number validation
20+
"minimum",
21+
"maximum",
22+
"exclusiveMinimum",
23+
"exclusiveMaximum",
24+
"multipleOf",
25+
// Array validation
26+
"minItems",
27+
"maxItems",
28+
"uniqueItems",
29+
// Object validation
30+
"minProperties",
31+
"maxProperties",
32+
// General
33+
"default",
34+
"examples",
35+
"deprecated",
36+
"readOnly",
37+
"writeOnly",
38+
// Composition (partially supported - strip from items/properties)
39+
// Note: oneOf/anyOf at root level may work, but not in nested contexts
40+
]);
41+
42+
/**
43+
* Recursively strip unsupported schema properties for OpenAI compatibility.
44+
* This mutates the schema in place for efficiency.
45+
*/
46+
function stripUnsupportedProperties(schema: unknown): void {
47+
if (typeof schema !== "object" || schema === null) {
48+
return;
49+
}
50+
51+
const obj = schema as Record<string, unknown>;
52+
53+
// Remove unsupported properties at this level
54+
for (const prop of OPENAI_UNSUPPORTED_SCHEMA_PROPERTIES) {
55+
if (prop in obj) {
56+
delete obj[prop];
57+
}
58+
}
59+
60+
// Recursively process nested schemas
61+
if (obj.properties && typeof obj.properties === "object") {
62+
for (const propSchema of Object.values(obj.properties as Record<string, unknown>)) {
63+
stripUnsupportedProperties(propSchema);
64+
}
65+
}
66+
67+
if (obj.items) {
68+
if (Array.isArray(obj.items)) {
69+
for (const itemSchema of obj.items) {
70+
stripUnsupportedProperties(itemSchema);
71+
}
72+
} else {
73+
stripUnsupportedProperties(obj.items);
74+
}
75+
}
76+
77+
if (obj.additionalProperties && typeof obj.additionalProperties === "object") {
78+
stripUnsupportedProperties(obj.additionalProperties);
79+
}
80+
81+
// Handle anyOf/oneOf/allOf
82+
for (const keyword of ["anyOf", "oneOf", "allOf"]) {
83+
if (Array.isArray(obj[keyword])) {
84+
for (const subSchema of obj[keyword] as unknown[]) {
85+
stripUnsupportedProperties(subSchema);
86+
}
87+
}
88+
}
89+
90+
// Handle definitions/defs (JSON Schema draft-07 and later)
91+
for (const defsKey of ["definitions", "$defs"]) {
92+
if (obj[defsKey] && typeof obj[defsKey] === "object") {
93+
for (const defSchema of Object.values(obj[defsKey] as Record<string, unknown>)) {
94+
stripUnsupportedProperties(defSchema);
95+
}
96+
}
97+
}
98+
}
99+
100+
/**
101+
* Sanitize a tool's parameter schema for OpenAI Responses API compatibility.
102+
*
103+
* OpenAI's Responses API has stricter JSON Schema validation than other providers.
104+
* This function creates a new tool with sanitized parameters that strips
105+
* unsupported schema properties like minLength, maximum, default, etc.
106+
*
107+
* @param tool - The original tool to sanitize
108+
* @returns A new tool with sanitized parameter schema
109+
*/
110+
export function sanitizeToolSchemaForOpenAI(tool: Tool): Tool {
111+
// Access tool internals - the AI SDK tool structure has parameters
112+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
113+
const toolRecord = tool as any as Record<string, unknown>;
114+
115+
// If no parameters, return as-is
116+
if (!toolRecord.parameters) {
117+
return tool;
118+
}
119+
120+
// Deep clone the parameters to avoid mutating the original
121+
const clonedParams = JSON.parse(JSON.stringify(toolRecord.parameters)) as unknown;
122+
123+
// Strip unsupported properties
124+
stripUnsupportedProperties(clonedParams);
125+
126+
// Create a new tool with sanitized parameters
127+
return {
128+
...tool,
129+
parameters: clonedParams,
130+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
131+
} as any as Tool;
132+
}
133+
134+
/**
135+
* Sanitize all MCP tools for OpenAI compatibility.
136+
*
137+
* @param mcpTools - Record of MCP tools to sanitize
138+
* @returns Record of sanitized tools
139+
*/
140+
export function sanitizeMCPToolsForOpenAI(mcpTools: Record<string, Tool>): Record<string, Tool> {
141+
const sanitized: Record<string, Tool> = {};
142+
for (const [name, tool] of Object.entries(mcpTools)) {
143+
sanitized[name] = sanitizeToolSchemaForOpenAI(tool);
144+
}
145+
return sanitized;
146+
}

src/common/utils/tools/tools.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createTodoWriteTool, createTodoReadTool } from "@/node/services/tools/t
1313
import { createStatusSetTool } from "@/node/services/tools/status_set";
1414
import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait";
1515
import { log } from "@/node/services/log";
16+
import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer";
1617

1718
import type { Runtime } from "@/node/runtime/Runtime";
1819
import type { InitStateManager } from "@/node/services/initStateManager";
@@ -157,17 +158,29 @@ export async function getToolsForModel(
157158
}
158159

159160
case "openai": {
161+
// Sanitize MCP tools for OpenAI's stricter JSON Schema validation.
162+
// OpenAI's Responses API doesn't support certain schema properties like
163+
// minLength, maximum, default, etc. that are valid JSON Schema but not
164+
// accepted by OpenAI's Structured Outputs implementation.
165+
const sanitizedMcpTools = mcpTools ? sanitizeMCPToolsForOpenAI(mcpTools) : {};
166+
160167
// Only add web search for models that support it
161168
if (modelId.includes("gpt-5") || modelId.includes("gpt-4")) {
162169
const { openai } = await import("@ai-sdk/openai");
163170
allTools = {
164171
...baseTools,
165-
...(mcpTools ?? {}),
172+
...sanitizedMcpTools,
166173
// Provider-specific tool types are compatible with Tool at runtime
167174
web_search: openai.tools.webSearch({
168175
searchContextSize: "high",
169176
}) as Tool,
170177
};
178+
} else {
179+
// For other OpenAI models (o1, o3, etc.), still use sanitized MCP tools
180+
allTools = {
181+
...baseTools,
182+
...sanitizedMcpTools,
183+
};
171184
}
172185
break;
173186
}

0 commit comments

Comments
 (0)