Skip to content

Commit 6dea256

Browse files
authored
🤖 fix: sanitize MCP tool schemas for OpenAI Responses API compatibility (#1220)
## Problem OpenAI chats were unable to use MCP tools because OpenAI's Responses API has stricter JSON Schema validation than other providers. MCP servers may define tool schemas with properties like `minLength`, `maximum`, `default`, etc. which are valid JSON Schema but cause 400 errors with OpenAI: ``` Invalid schema for function 'addResource': In context=('properties', 'content'), 'minLength' is not permitted. ``` ## Solution Added a schema sanitizer that strips unsupported JSON Schema properties from MCP tool schemas when using OpenAI models. The sanitization is only applied to: - MCP tools (not built-in tools) - OpenAI provider models ### Properties stripped: - **String validation**: minLength, maxLength, pattern, format - **Number validation**: minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf - **Array validation**: minItems, maxItems, uniqueItems - **Object validation**: minProperties, maxProperties - **General**: default, examples, deprecated, readOnly, writeOnly The sanitization preserves the core schema structure (type, properties, required, etc.) and handles nested schemas including items, oneOf, anyOf, allOf, and definitions. ## Testing - Added comprehensive unit tests for the schema sanitizer - All 1890 tests pass - Lint and typecheck pass --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent e9d5a93 commit 6dea256

File tree

3 files changed

+464
-1
lines changed

3 files changed

+464
-1
lines changed
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
// Test helper to access tool inputSchema (MCP tools)
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
function getInputSchema(tool: Tool): any {
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
const inputSchema = (tool as any).inputSchema;
17+
// inputSchema has a jsonSchema getter
18+
return inputSchema?.jsonSchema;
19+
}
20+
21+
describe("schemaSanitizer", () => {
22+
describe("sanitizeToolSchemaForOpenAI", () => {
23+
it("should strip minLength from string properties", () => {
24+
const tool = {
25+
description: "Test tool",
26+
parameters: {
27+
type: "object",
28+
properties: {
29+
content: { type: "string", minLength: 1 },
30+
},
31+
},
32+
} as unknown as Tool;
33+
34+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
35+
const params = getParams(sanitized);
36+
37+
expect(params.properties.content).toEqual({ type: "string" });
38+
expect(params.properties.content.minLength).toBeUndefined();
39+
});
40+
41+
it("should strip multiple unsupported properties", () => {
42+
const tool = {
43+
description: "Test tool",
44+
parameters: {
45+
type: "object",
46+
properties: {
47+
name: { type: "string", minLength: 1, maxLength: 100, pattern: "^[a-z]+$" },
48+
age: { type: "number", minimum: 0, maximum: 150, default: 25 },
49+
},
50+
},
51+
} as unknown as Tool;
52+
53+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
54+
const params = getParams(sanitized);
55+
56+
expect(params.properties.name).toEqual({ type: "string" });
57+
expect(params.properties.age).toEqual({ type: "number" });
58+
});
59+
60+
it("should handle nested objects", () => {
61+
const tool = {
62+
description: "Test tool",
63+
parameters: {
64+
type: "object",
65+
properties: {
66+
user: {
67+
type: "object",
68+
properties: {
69+
email: { type: "string", format: "email", minLength: 5 },
70+
},
71+
},
72+
},
73+
},
74+
} as unknown as Tool;
75+
76+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
77+
const params = getParams(sanitized);
78+
79+
expect(params.properties.user.properties.email).toEqual({ type: "string" });
80+
});
81+
82+
it("should handle array items", () => {
83+
const tool = {
84+
description: "Test tool",
85+
parameters: {
86+
type: "object",
87+
properties: {
88+
tags: {
89+
type: "array",
90+
items: { type: "string", minLength: 1 },
91+
minItems: 1,
92+
maxItems: 10,
93+
},
94+
},
95+
},
96+
} as unknown as Tool;
97+
98+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
99+
const params = getParams(sanitized);
100+
101+
expect(params.properties.tags.items).toEqual({ type: "string" });
102+
expect(params.properties.tags.minItems).toBeUndefined();
103+
expect(params.properties.tags.maxItems).toBeUndefined();
104+
});
105+
106+
it("should handle anyOf/oneOf schemas", () => {
107+
const tool = {
108+
description: "Test tool",
109+
parameters: {
110+
type: "object",
111+
properties: {
112+
value: {
113+
oneOf: [
114+
{ type: "string", minLength: 1 },
115+
{ type: "number", minimum: 0 },
116+
],
117+
},
118+
},
119+
},
120+
} as unknown as Tool;
121+
122+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
123+
const params = getParams(sanitized);
124+
125+
expect(params.properties.value.oneOf[0]).toEqual({ type: "string" });
126+
expect(params.properties.value.oneOf[1]).toEqual({ type: "number" });
127+
});
128+
129+
it("should preserve required and type properties", () => {
130+
const tool = {
131+
description: "Test tool",
132+
parameters: {
133+
type: "object",
134+
properties: {
135+
content: { type: "string", minLength: 1 },
136+
},
137+
required: ["content"],
138+
},
139+
} as unknown as Tool;
140+
141+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
142+
const params = getParams(sanitized);
143+
144+
expect(params.type).toBe("object");
145+
expect(params.required).toEqual(["content"]);
146+
});
147+
148+
it("should return tool as-is if no parameters", () => {
149+
const tool = {
150+
description: "Test tool",
151+
} as unknown as Tool;
152+
153+
const sanitized = sanitizeToolSchemaForOpenAI(tool);
154+
155+
expect(sanitized).toEqual(tool);
156+
});
157+
158+
it("should not mutate the original tool", () => {
159+
const tool = {
160+
description: "Test tool",
161+
parameters: {
162+
type: "object",
163+
properties: {
164+
content: { type: "string", minLength: 1 },
165+
},
166+
},
167+
} as unknown as Tool;
168+
169+
sanitizeToolSchemaForOpenAI(tool);
170+
const params = getParams(tool);
171+
172+
// Original should still have minLength
173+
expect(params.properties.content.minLength).toBe(1);
174+
});
175+
176+
it("should sanitize MCP tools with inputSchema", () => {
177+
// MCP tools use inputSchema with a jsonSchema getter instead of parameters
178+
const jsonSchema = {
179+
type: "object",
180+
properties: {
181+
content: { type: "string", minLength: 1, maxLength: 100 },
182+
count: { type: "number", minimum: 0, maximum: 10 },
183+
},
184+
required: ["content"],
185+
};
186+
187+
const mcpTool = {
188+
type: "dynamic",
189+
description: "MCP test tool",
190+
inputSchema: {
191+
// Simulate the jsonSchema getter that @ai-sdk/mcp creates
192+
get jsonSchema() {
193+
return jsonSchema;
194+
},
195+
},
196+
execute: () => Promise.resolve({}),
197+
} as unknown as Tool;
198+
199+
const sanitized = sanitizeToolSchemaForOpenAI(mcpTool);
200+
const schema = getInputSchema(sanitized);
201+
202+
// Unsupported properties should be stripped
203+
expect(schema.properties.content).toEqual({ type: "string" });
204+
expect(schema.properties.count).toEqual({ type: "number" });
205+
// Supported properties should be preserved
206+
expect(schema.type).toBe("object");
207+
expect(schema.required).toEqual(["content"]);
208+
});
209+
210+
it("should not mutate the original MCP tool inputSchema", () => {
211+
const jsonSchema = {
212+
type: "object",
213+
properties: {
214+
content: { type: "string", minLength: 1 },
215+
},
216+
};
217+
218+
const mcpTool = {
219+
type: "dynamic",
220+
description: "MCP test tool",
221+
inputSchema: {
222+
get jsonSchema() {
223+
return jsonSchema;
224+
},
225+
},
226+
execute: () => Promise.resolve({}),
227+
} as unknown as Tool;
228+
229+
sanitizeToolSchemaForOpenAI(mcpTool);
230+
231+
// Original should still have minLength
232+
expect(jsonSchema.properties.content.minLength).toBe(1);
233+
});
234+
});
235+
236+
describe("sanitizeMCPToolsForOpenAI", () => {
237+
it("should sanitize all tools in a record", () => {
238+
const tools = {
239+
tool1: {
240+
description: "Tool 1",
241+
parameters: {
242+
type: "object",
243+
properties: {
244+
content: { type: "string", minLength: 1 },
245+
},
246+
},
247+
},
248+
tool2: {
249+
description: "Tool 2",
250+
parameters: {
251+
type: "object",
252+
properties: {
253+
count: { type: "number", minimum: 0 },
254+
},
255+
},
256+
},
257+
} as unknown as Record<string, Tool>;
258+
259+
const sanitized = sanitizeMCPToolsForOpenAI(tools);
260+
261+
expect(getParams(sanitized.tool1).properties.content).toEqual({
262+
type: "string",
263+
});
264+
expect(getParams(sanitized.tool2).properties.count).toEqual({
265+
type: "number",
266+
});
267+
});
268+
});
269+
});

0 commit comments

Comments
 (0)