From 75aa6431e91ef00bd685d899a603b2832bf8dd16 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:20:45 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20sanitize=20MCP=20tool?= =?UTF-8?q?=20schemas=20for=20OpenAI=20Responses=20API=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../utils/tools/schemaSanitizer.test.ts | 201 ++++++++++++++++++ src/common/utils/tools/schemaSanitizer.ts | 146 +++++++++++++ src/common/utils/tools/tools.ts | 15 +- 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 src/common/utils/tools/schemaSanitizer.test.ts create mode 100644 src/common/utils/tools/schemaSanitizer.ts diff --git a/src/common/utils/tools/schemaSanitizer.test.ts b/src/common/utils/tools/schemaSanitizer.test.ts new file mode 100644 index 0000000000..2b7502192f --- /dev/null +++ b/src/common/utils/tools/schemaSanitizer.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +import { sanitizeToolSchemaForOpenAI, sanitizeMCPToolsForOpenAI } from "./schemaSanitizer"; +import type { Tool } from "ai"; + +// Test helper to access tool parameters +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getParams(tool: Tool): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (tool as any).parameters; +} + +describe("schemaSanitizer", () => { + describe("sanitizeToolSchemaForOpenAI", () => { + it("should strip minLength from string properties", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.content).toEqual({ type: "string" }); + expect(params.properties.content.minLength).toBeUndefined(); + }); + + it("should strip multiple unsupported properties", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100, pattern: "^[a-z]+$" }, + age: { type: "number", minimum: 0, maximum: 150, default: 25 }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.name).toEqual({ type: "string" }); + expect(params.properties.age).toEqual({ type: "number" }); + }); + + it("should handle nested objects", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + user: { + type: "object", + properties: { + email: { type: "string", format: "email", minLength: 5 }, + }, + }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.user.properties.email).toEqual({ type: "string" }); + }); + + it("should handle array items", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + maxItems: 10, + }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.tags.items).toEqual({ type: "string" }); + expect(params.properties.tags.minItems).toBeUndefined(); + expect(params.properties.tags.maxItems).toBeUndefined(); + }); + + it("should handle anyOf/oneOf schemas", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + value: { + oneOf: [ + { type: "string", minLength: 1 }, + { type: "number", minimum: 0 }, + ], + }, + }, + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.properties.value.oneOf[0]).toEqual({ type: "string" }); + expect(params.properties.value.oneOf[1]).toEqual({ type: "number" }); + }); + + it("should preserve required and type properties", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + required: ["content"], + }, + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + const params = getParams(sanitized); + + expect(params.type).toBe("object"); + expect(params.required).toEqual(["content"]); + }); + + it("should return tool as-is if no parameters", () => { + const tool = { + description: "Test tool", + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(tool); + + expect(sanitized).toEqual(tool); + }); + + it("should not mutate the original tool", () => { + const tool = { + description: "Test tool", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + } as unknown as Tool; + + sanitizeToolSchemaForOpenAI(tool); + const params = getParams(tool); + + // Original should still have minLength + expect(params.properties.content.minLength).toBe(1); + }); + }); + + describe("sanitizeMCPToolsForOpenAI", () => { + it("should sanitize all tools in a record", () => { + const tools = { + tool1: { + description: "Tool 1", + parameters: { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }, + }, + tool2: { + description: "Tool 2", + parameters: { + type: "object", + properties: { + count: { type: "number", minimum: 0 }, + }, + }, + }, + } as unknown as Record; + + const sanitized = sanitizeMCPToolsForOpenAI(tools); + + expect(getParams(sanitized.tool1).properties.content).toEqual({ + type: "string", + }); + expect(getParams(sanitized.tool2).properties.count).toEqual({ + type: "number", + }); + }); + }); +}); diff --git a/src/common/utils/tools/schemaSanitizer.ts b/src/common/utils/tools/schemaSanitizer.ts new file mode 100644 index 0000000000..5468282b99 --- /dev/null +++ b/src/common/utils/tools/schemaSanitizer.ts @@ -0,0 +1,146 @@ +import { type Tool } from "ai"; + +/** + * JSON Schema properties that are not permitted by OpenAI's Responses API. + * + * OpenAI's Structured Outputs has stricter JSON Schema validation than other providers. + * MCP tools may have schemas with these properties which work fine with Anthropic + * but fail with OpenAI. We strip these properties to ensure compatibility. + * + * @see https://platform.openai.com/docs/guides/structured-outputs + * @see https://github.com/vercel/ai/discussions/5164 + */ +const OPENAI_UNSUPPORTED_SCHEMA_PROPERTIES = new Set([ + // 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", + // Composition (partially supported - strip from items/properties) + // Note: oneOf/anyOf at root level may work, but not in nested contexts +]); + +/** + * Recursively strip unsupported schema properties for OpenAI compatibility. + * This mutates the schema in place for efficiency. + */ +function stripUnsupportedProperties(schema: unknown): void { + if (typeof schema !== "object" || schema === null) { + return; + } + + const obj = schema as Record; + + // Remove unsupported properties at this level + for (const prop of OPENAI_UNSUPPORTED_SCHEMA_PROPERTIES) { + if (prop in obj) { + delete obj[prop]; + } + } + + // Recursively process nested schemas + if (obj.properties && typeof obj.properties === "object") { + for (const propSchema of Object.values(obj.properties as Record)) { + stripUnsupportedProperties(propSchema); + } + } + + if (obj.items) { + if (Array.isArray(obj.items)) { + for (const itemSchema of obj.items) { + stripUnsupportedProperties(itemSchema); + } + } else { + stripUnsupportedProperties(obj.items); + } + } + + if (obj.additionalProperties && typeof obj.additionalProperties === "object") { + stripUnsupportedProperties(obj.additionalProperties); + } + + // Handle anyOf/oneOf/allOf + for (const keyword of ["anyOf", "oneOf", "allOf"]) { + if (Array.isArray(obj[keyword])) { + for (const subSchema of obj[keyword] as unknown[]) { + stripUnsupportedProperties(subSchema); + } + } + } + + // Handle definitions/defs (JSON Schema draft-07 and later) + for (const defsKey of ["definitions", "$defs"]) { + if (obj[defsKey] && typeof obj[defsKey] === "object") { + for (const defSchema of Object.values(obj[defsKey] as Record)) { + stripUnsupportedProperties(defSchema); + } + } + } +} + +/** + * Sanitize a tool's parameter schema for OpenAI Responses API compatibility. + * + * OpenAI's Responses API has stricter JSON Schema validation than other providers. + * This function creates a new tool with sanitized parameters that strips + * unsupported schema properties like minLength, maximum, default, etc. + * + * @param tool - The original tool to sanitize + * @returns A new tool with sanitized parameter schema + */ +export function sanitizeToolSchemaForOpenAI(tool: Tool): Tool { + // Access tool internals - the AI SDK tool structure has parameters + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const toolRecord = tool as any as Record; + + // If no parameters, return as-is + if (!toolRecord.parameters) { + return tool; + } + + // Deep clone the parameters to avoid mutating the original + const clonedParams = JSON.parse(JSON.stringify(toolRecord.parameters)) as unknown; + + // Strip unsupported properties + stripUnsupportedProperties(clonedParams); + + // Create a new tool with sanitized parameters + return { + ...tool, + parameters: clonedParams, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as Tool; +} + +/** + * Sanitize all MCP tools for OpenAI compatibility. + * + * @param mcpTools - Record of MCP tools to sanitize + * @returns Record of sanitized tools + */ +export function sanitizeMCPToolsForOpenAI(mcpTools: Record): Record { + const sanitized: Record = {}; + for (const [name, tool] of Object.entries(mcpTools)) { + sanitized[name] = sanitizeToolSchemaForOpenAI(tool); + } + return sanitized; +} diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index e3fb536adb..55b0d8f887 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -13,6 +13,7 @@ import { createTodoWriteTool, createTodoReadTool } from "@/node/services/tools/t import { createStatusSetTool } from "@/node/services/tools/status_set"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { log } from "@/node/services/log"; +import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer"; import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -157,17 +158,29 @@ export async function getToolsForModel( } case "openai": { + // Sanitize MCP tools for OpenAI's stricter JSON Schema validation. + // OpenAI's Responses API doesn't support certain schema properties like + // minLength, maximum, default, etc. that are valid JSON Schema but not + // accepted by OpenAI's Structured Outputs implementation. + const sanitizedMcpTools = mcpTools ? sanitizeMCPToolsForOpenAI(mcpTools) : {}; + // Only add web search for models that support it if (modelId.includes("gpt-5") || modelId.includes("gpt-4")) { const { openai } = await import("@ai-sdk/openai"); allTools = { ...baseTools, - ...(mcpTools ?? {}), + ...sanitizedMcpTools, // Provider-specific tool types are compatible with Tool at runtime web_search: openai.tools.webSearch({ searchContextSize: "high", }) as Tool, }; + } else { + // For other OpenAI models (o1, o3, etc.), still use sanitized MCP tools + allTools = { + ...baseTools, + ...sanitizedMcpTools, + }; } break; } From a43ada2d8397402dd64f774d9108408f5bc478f1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:33:49 +0100 Subject: [PATCH 2/2] fix: handle inputSchema for MCP tools in schema sanitizer MCP tools from @ai-sdk/mcp use inputSchema with a jsonSchema getter, not parameters like regular AI SDK tools. The sanitizer now handles both cases to properly strip unsupported properties for OpenAI. --- .../utils/tools/schemaSanitizer.test.ts | 68 +++++++++++++++++++ src/common/utils/tools/schemaSanitizer.ts | 39 ++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/common/utils/tools/schemaSanitizer.test.ts b/src/common/utils/tools/schemaSanitizer.test.ts index 2b7502192f..1fef411dcc 100644 --- a/src/common/utils/tools/schemaSanitizer.test.ts +++ b/src/common/utils/tools/schemaSanitizer.test.ts @@ -9,6 +9,15 @@ function getParams(tool: Tool): any { return (tool as any).parameters; } +// Test helper to access tool inputSchema (MCP tools) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getInputSchema(tool: Tool): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputSchema = (tool as any).inputSchema; + // inputSchema has a jsonSchema getter + return inputSchema?.jsonSchema; +} + describe("schemaSanitizer", () => { describe("sanitizeToolSchemaForOpenAI", () => { it("should strip minLength from string properties", () => { @@ -163,6 +172,65 @@ describe("schemaSanitizer", () => { // Original should still have minLength expect(params.properties.content.minLength).toBe(1); }); + + it("should sanitize MCP tools with inputSchema", () => { + // MCP tools use inputSchema with a jsonSchema getter instead of parameters + const jsonSchema = { + type: "object", + properties: { + content: { type: "string", minLength: 1, maxLength: 100 }, + count: { type: "number", minimum: 0, maximum: 10 }, + }, + required: ["content"], + }; + + const mcpTool = { + type: "dynamic", + description: "MCP test tool", + inputSchema: { + // Simulate the jsonSchema getter that @ai-sdk/mcp creates + get jsonSchema() { + return jsonSchema; + }, + }, + execute: () => Promise.resolve({}), + } as unknown as Tool; + + const sanitized = sanitizeToolSchemaForOpenAI(mcpTool); + const schema = getInputSchema(sanitized); + + // Unsupported properties should be stripped + expect(schema.properties.content).toEqual({ type: "string" }); + expect(schema.properties.count).toEqual({ type: "number" }); + // Supported properties should be preserved + expect(schema.type).toBe("object"); + expect(schema.required).toEqual(["content"]); + }); + + it("should not mutate the original MCP tool inputSchema", () => { + const jsonSchema = { + type: "object", + properties: { + content: { type: "string", minLength: 1 }, + }, + }; + + const mcpTool = { + type: "dynamic", + description: "MCP test tool", + inputSchema: { + get jsonSchema() { + return jsonSchema; + }, + }, + execute: () => Promise.resolve({}), + } as unknown as Tool; + + sanitizeToolSchemaForOpenAI(mcpTool); + + // Original should still have minLength + expect(jsonSchema.properties.content.minLength).toBe(1); + }); }); describe("sanitizeMCPToolsForOpenAI", () => { diff --git a/src/common/utils/tools/schemaSanitizer.ts b/src/common/utils/tools/schemaSanitizer.ts index 5468282b99..a5a7ad2c79 100644 --- a/src/common/utils/tools/schemaSanitizer.ts +++ b/src/common/utils/tools/schemaSanitizer.ts @@ -104,15 +104,50 @@ function stripUnsupportedProperties(schema: unknown): void { * This function creates a new tool with sanitized parameters that strips * unsupported schema properties like minLength, maximum, default, etc. * + * Tools can have schemas in two places: + * - `parameters`: Used by tools created with ai SDK's `tool()` function + * - `inputSchema`: Used by MCP tools created with `dynamicTool()` from @ai-sdk/mcp + * * @param tool - The original tool to sanitize * @returns A new tool with sanitized parameter schema */ export function sanitizeToolSchemaForOpenAI(tool: Tool): Tool { - // Access tool internals - the AI SDK tool structure has parameters + // Access tool internals - the AI SDK tool structure varies: + // - Regular tools have `parameters` (Zod schema) + // - MCP/dynamic tools have `inputSchema` (JSON Schema wrapper with getter) // eslint-disable-next-line @typescript-eslint/no-explicit-any const toolRecord = tool as any as Record; - // If no parameters, return as-is + // Check for inputSchema first (MCP tools use this) + // The inputSchema is a wrapper object with a jsonSchema getter + if (toolRecord.inputSchema && typeof toolRecord.inputSchema === "object") { + const inputSchemaWrapper = toolRecord.inputSchema as Record; + + // Get the actual JSON Schema - it's exposed via a getter + const rawJsonSchema = inputSchemaWrapper.jsonSchema; + if (rawJsonSchema && typeof rawJsonSchema === "object") { + // Deep clone and sanitize + const clonedSchema = JSON.parse(JSON.stringify(rawJsonSchema)) as Record; + stripUnsupportedProperties(clonedSchema); + + // Create a new inputSchema wrapper that returns our sanitized schema + const sanitizedInputSchema = { + ...inputSchemaWrapper, + // Override the jsonSchema getter with our sanitized version + get jsonSchema() { + return clonedSchema; + }, + }; + + return { + ...tool, + inputSchema: sanitizedInputSchema, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as Tool; + } + } + + // Fall back to parameters (regular AI SDK tools) if (!toolRecord.parameters) { return tool; }