fix: normalize MCP tool schemas for Bedrock and OpenAI strict mode (#10148)

This commit is contained in:
Daniel 2025-12-17 16:01:48 -05:00 committed by GitHub
parent 0b86796b8f
commit aa3b4ae9cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 407 additions and 178 deletions

View file

@ -91,21 +91,90 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
const bedrockTools = convertToolsForBedrock(testTools)
expect(bedrockTools).toHaveLength(2)
expect(bedrockTools[0]).toEqual({
toolSpec: {
name: "read_file",
description: "Read a file from the filesystem",
inputSchema: {
json: {
// Check structure and key properties (normalizeToolSchema adds additionalProperties: false)
const tool = bedrockTools[0]
expect(tool.toolSpec.name).toBe("read_file")
expect(tool.toolSpec.description).toBe("Read a file from the filesystem")
expect(tool.toolSpec.inputSchema.json.type).toBe("object")
expect(tool.toolSpec.inputSchema.json.properties.path.type).toBe("string")
expect(tool.toolSpec.inputSchema.json.properties.path.description).toBe("The path to the file")
expect(tool.toolSpec.inputSchema.json.required).toEqual(["path"])
// normalizeToolSchema adds additionalProperties: false by default
expect(tool.toolSpec.inputSchema.json.additionalProperties).toBe(false)
})
it("should transform type arrays to anyOf for JSON Schema 2020-12 compliance", () => {
const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler)
// Tools with type: ["string", "null"] syntax (valid in draft-07 but not 2020-12)
const toolsWithNullableTypes = [
{
type: "function" as const,
function: {
name: "execute_command",
description: "Execute a command",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "The path to the file" },
command: { type: "string", description: "The command to execute" },
cwd: {
type: ["string", "null"],
description: "Working directory (optional)",
},
},
required: ["path"],
required: ["command", "cwd"],
},
},
},
})
{
type: "function" as const,
function: {
name: "read_file",
description: "Read files",
parameters: {
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
line_ranges: {
type: ["array", "null"],
items: { type: "integer" },
description: "Optional line ranges",
},
},
required: ["path", "line_ranges"],
},
},
},
required: ["files"],
},
},
},
]
const bedrockTools = convertToolsForBedrock(toolsWithNullableTypes)
expect(bedrockTools).toHaveLength(2)
// First tool: cwd should be transformed from type: ["string", "null"] to anyOf
const executeCommandSchema = bedrockTools[0].toolSpec.inputSchema.json as any
expect(executeCommandSchema.properties.cwd.anyOf).toEqual([{ type: "string" }, { type: "null" }])
expect(executeCommandSchema.properties.cwd.type).toBeUndefined()
expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)")
// Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf
const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any
const lineRanges = readFileSchema.properties.files.items.properties.line_ranges
expect(lineRanges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
expect(lineRanges.type).toBeUndefined()
// items also gets additionalProperties: false from normalization
expect(lineRanges.items.type).toBe("integer")
expect(lineRanges.description).toBe("Optional line ranges")
})
it("should filter non-function tools", () => {

View file

@ -43,6 +43,7 @@ import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types"
import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format"
import { getModelParams } from "../transform/model-params"
import { shouldUseReasoningBudget } from "../../shared/api"
import { normalizeToolSchema } from "../../utils/json-schema"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
/************************************************************************************
@ -1212,6 +1213,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
/**
* Convert OpenAI tool definitions to Bedrock Converse format
* Transforms JSON Schema to draft 2020-12 compliant format required by Claude models.
* @param tools Array of OpenAI ChatCompletionTool definitions
* @returns Array of Bedrock Tool definitions
*/
@ -1225,7 +1227,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
name: tool.function.name,
description: tool.function.description,
inputSchema: {
json: tool.function.parameters as Record<string, unknown>,
// Normalize schema to JSON Schema draft 2020-12 compliant format
// This converts type: ["T", "null"] to anyOf: [{type: "T"}, {type: "null"}]
json: normalizeToolSchema(tool.function.parameters as Record<string, unknown>),
},
},
}) as Tool,

View file

@ -1,7 +1,7 @@
import type OpenAI from "openai"
import { McpHub } from "../../../../services/mcp/McpHub"
import { buildMcpToolName } from "../../../../utils/mcp-name"
import { ToolInputSchema, type JsonSchema } from "../../../../utils/json-schema"
import { normalizeToolSchema, type JsonSchema } from "../../../../utils/json-schema"
/**
* Dynamically generates native tool definitions for all enabled tools across connected MCP servers.
@ -43,14 +43,13 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
const originalSchema = tool.inputSchema as Record<string, unknown> | undefined
// Parse with ToolInputSchema to ensure additionalProperties: false is set recursively
// Normalize schema for JSON Schema 2020-12 compliance (type arrays → anyOf)
let parameters: JsonSchema
if (originalSchema) {
const result = ToolInputSchema.safeParse(originalSchema)
parameters = result.success ? result.data : (originalSchema as JsonSchema)
parameters = normalizeToolSchema(originalSchema) as JsonSchema
} else {
// No schema provided - create a minimal valid schema
parameters = ToolInputSchema.parse({ type: "object" })
parameters = { type: "object", additionalProperties: false } as JsonSchema
}
const toolDefinition: OpenAI.Chat.ChatCompletionTool = {

View file

@ -1,213 +1,268 @@
import { ToolInputSchema } from "../json-schema"
import { describe, it, expect } from "vitest"
import { normalizeToolSchema } from "../json-schema"
describe("ToolInputSchema", () => {
it("should validate and default additionalProperties to false", () => {
const schema = {
describe("normalizeToolSchema", () => {
it("should convert type array to anyOf for nullable string", () => {
const input = {
type: ["string", "null"],
description: "Optional field",
}
const result = normalizeToolSchema(input)
expect(result).toEqual({
anyOf: [{ type: "string" }, { type: "null" }],
description: "Optional field",
additionalProperties: false,
})
})
it("should convert type array to anyOf for nullable array", () => {
const input = {
type: ["array", "null"],
items: { type: "string" },
description: "Optional array",
}
const result = normalizeToolSchema(input)
expect(result).toEqual({
anyOf: [{ type: "array" }, { type: "null" }],
items: { type: "string", additionalProperties: false },
description: "Optional array",
additionalProperties: false,
})
})
it("should preserve single type values", () => {
const input = {
type: "string",
description: "Required field",
}
const result = normalizeToolSchema(input)
expect(result).toEqual({
type: "string",
description: "Required field",
additionalProperties: false,
})
})
it("should recursively transform nested properties", () => {
const input = {
type: "object",
properties: {
name: { type: "string" },
optional: {
type: ["string", "null"],
description: "Optional nested field",
},
},
required: ["name"],
}
const result = ToolInputSchema.parse(schema)
const result = normalizeToolSchema(input)
expect(result.type).toBe("object")
expect(result.additionalProperties).toBe(false)
})
it("should recursively apply defaults to nested schemas", () => {
const schema = {
expect(result).toEqual({
type: "object",
properties: {
user: {
type: "object",
properties: {
name: { type: "string" },
name: { type: "string", additionalProperties: false },
optional: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Optional nested field",
additionalProperties: false,
},
},
required: ["name"],
additionalProperties: false,
})
})
it("should recursively transform items in arrays", () => {
const input = {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
line_ranges: {
type: ["array", "null"],
items: { type: "integer" },
},
},
},
}
const result = ToolInputSchema.parse(schema)
const result = normalizeToolSchema(input)
expect(result.additionalProperties).toBe(false)
expect((result.properties as any).user.additionalProperties).toBe(false)
expect(result).toEqual({
type: "array",
items: {
type: "object",
properties: {
path: { type: "string", additionalProperties: false },
line_ranges: {
anyOf: [{ type: "array" }, { type: "null" }],
items: { type: "integer", additionalProperties: false },
additionalProperties: false,
},
},
additionalProperties: false,
},
additionalProperties: false,
})
})
it("should apply defaults to object schemas in array items", () => {
const schema = {
it("should handle deeply nested structures", () => {
const input = {
type: "object",
properties: {
items: {
files: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "number" },
},
},
},
},
}
const result = ToolInputSchema.parse(schema)
expect(result.additionalProperties).toBe(false)
expect((result.properties as any).items.items.additionalProperties).toBe(false)
})
it("should throw on invalid schema", () => {
const invalidSchema = { type: "invalid-type" }
expect(() => ToolInputSchema.parse(invalidSchema)).toThrow()
})
it("should use safeParse for error handling", () => {
const invalidSchema = { type: "invalid-type" }
const result = ToolInputSchema.safeParse(invalidSchema)
expect(result.success).toBe(false)
})
it("should apply defaults in anyOf schemas", () => {
const schema = {
anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }],
}
const result = ToolInputSchema.parse(schema)
expect((result.anyOf as any)[0].additionalProperties).toBe(false)
expect((result.anyOf as any)[1].additionalProperties).toBe(false)
})
it("should apply defaults in oneOf schemas", () => {
const schema = {
oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }],
}
const result = ToolInputSchema.parse(schema)
expect((result.oneOf as any)[0].additionalProperties).toBe(false)
expect((result.oneOf as any)[1].additionalProperties).toBe(false)
})
it("should apply defaults in allOf schemas", () => {
const schema = {
allOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "number" } } },
],
}
const result = ToolInputSchema.parse(schema)
expect((result.allOf as any)[0].additionalProperties).toBe(false)
expect((result.allOf as any)[1].additionalProperties).toBe(false)
})
it("should apply defaults to tuple-style array items", () => {
const schema = {
type: "object",
properties: {
tuple: {
type: "array",
items: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "number" } } },
],
},
},
}
const result = ToolInputSchema.parse(schema)
const tupleItems = (result.properties as any).tuple.items
expect(tupleItems[0].additionalProperties).toBe(false)
expect(tupleItems[1].additionalProperties).toBe(false)
})
it("should preserve explicit additionalProperties: false", () => {
const schema = {
type: "object",
properties: {
name: { type: "string" },
},
additionalProperties: false,
}
const result = ToolInputSchema.parse(schema)
expect(result.additionalProperties).toBe(false)
})
it("should handle deeply nested complex schemas", () => {
const schema = {
type: "object",
properties: {
level1: {
type: "object",
properties: {
level2: {
type: "array",
items: {
type: "object",
properties: {
level3: {
type: "object",
properties: {
value: { type: "string" },
},
},
path: { type: "string" },
line_ranges: {
type: ["array", "null"],
items: {
type: "array",
items: { type: "integer" },
},
},
},
required: ["path", "line_ranges"],
},
},
},
}
const result = ToolInputSchema.parse(schema)
const result = normalizeToolSchema(input)
expect(result.additionalProperties).toBe(false)
expect((result.properties as any).level1.additionalProperties).toBe(false)
expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false)
expect((result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties).toBe(
false,
)
expect(result.properties).toBeDefined()
const properties = result.properties as Record<string, Record<string, unknown>>
const filesItems = properties.files.items as Record<string, unknown>
const filesItemsProps = filesItems.properties as Record<string, Record<string, unknown>>
expect(filesItemsProps.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
})
it("should handle the real-world MCP memory create_entities schema", () => {
// This is based on the actual schema that caused the OpenAI error
const schema = {
it("should recursively transform anyOf arrays", () => {
const input = {
anyOf: [
{
type: "object",
properties: {
optional: { type: ["string", "null"] },
},
},
{ type: "null" },
],
}
const result = normalizeToolSchema(input)
expect(result).toEqual({
anyOf: [
{
type: "object",
properties: {
optional: { anyOf: [{ type: "string" }, { type: "null" }], additionalProperties: false },
},
additionalProperties: false,
},
{ type: "null", additionalProperties: false },
],
additionalProperties: false,
})
})
it("should handle null or non-object input", () => {
expect(normalizeToolSchema(null as any)).toBeNull()
expect(normalizeToolSchema("string" as any)).toBe("string")
expect(normalizeToolSchema(123 as any)).toBe(123)
})
it("should transform additionalProperties when it is a schema object", () => {
const input = {
type: "object",
additionalProperties: {
type: ["string", "null"],
},
}
const result = normalizeToolSchema(input)
expect(result).toEqual({
type: "object",
properties: {},
additionalProperties: {
anyOf: [{ type: "string" }, { type: "null" }],
additionalProperties: false,
},
})
})
it("should preserve additionalProperties when it is a boolean", () => {
const input = {
type: "object",
additionalProperties: false,
}
const result = normalizeToolSchema(input)
expect(result).toEqual({
type: "object",
properties: {},
additionalProperties: false,
})
})
it("should handle the read_file tool schema structure", () => {
// This is the actual structure used in read_file tool
const input = {
type: "object",
properties: {
entities: {
files: {
type: "array",
description: "List of files to read",
items: {
type: "object",
properties: {
name: { type: "string", description: "The name of the entity" },
entityType: { type: "string", description: "The type of the entity" },
observations: {
type: "array",
items: { type: "string" },
description: "An array of observation contents",
path: {
type: "string",
description: "Path to the file",
},
line_ranges: {
type: ["array", "null"],
description: "Optional line ranges",
items: {
type: "array",
items: { type: "integer" },
minItems: 2,
maxItems: 2,
},
},
},
required: ["name", "entityType", "observations"],
required: ["path", "line_ranges"],
additionalProperties: false,
},
description: "An array of entities to create",
minItems: 1,
},
},
required: ["entities"],
required: ["files"],
additionalProperties: false,
}
const result = ToolInputSchema.parse(schema)
const result = normalizeToolSchema(input)
// Top-level object should have additionalProperties: false
expect(result.additionalProperties).toBe(false)
// Items in the entities array should have additionalProperties: false
expect((result.properties as any).entities.items.additionalProperties).toBe(false)
// Verify the line_ranges was transformed
const files = (result.properties as Record<string, unknown>).files as Record<string, unknown>
const items = files.items as Record<string, unknown>
const props = items.properties as Record<string, Record<string, unknown>>
expect(props.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
// Verify other properties are preserved
expect(props.line_ranges.items).toBeDefined()
expect(props.line_ranges.description).toBe("Optional line ranges")
})
})

View file

@ -11,6 +11,11 @@ export type JsonSchema = z4.core.JSONSchema.JSONSchema
*/
const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"])
/**
* All valid JSON Schema type values including object and array
*/
const JsonSchemaTypeSchema = z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")])
/**
* Zod schema for JSON Schema enum values
*/
@ -37,7 +42,7 @@ const JsonSchemaEnumValueSchema = z.union([z.string(), z.number(), z.boolean(),
export const ToolInputSchema: z.ZodType<JsonSchema> = z.lazy(() =>
z
.object({
type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(),
type: JsonSchemaTypeSchema.optional(),
properties: z.record(z.string(), ToolInputSchema).optional(),
items: z.union([ToolInputSchema, z.array(ToolInputSchema)]).optional(),
required: z.array(z.string()).optional(),
@ -61,3 +66,100 @@ export const ToolInputSchema: z.ZodType<JsonSchema> = z.lazy(() =>
})
.passthrough(),
)
/**
* Schema for type field that accepts both single types and array types (draft-07 nullable syntax).
* Array types like ["string", "null"] are transformed to anyOf format for 2020-12 compliance.
*/
const TypeFieldSchema = z.union([JsonSchemaTypeSchema, z.array(JsonSchemaTypeSchema)])
/**
* Internal Zod schema that normalizes tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
*
* This schema performs two key transformations:
* 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode)
* 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format
* (required by Claude on Bedrock which enforces JSON Schema draft 2020-12)
*
* Uses recursive parsing so transformations apply to all nested schemas automatically.
*/
const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodTypeDef, Record<string, unknown>> = z.lazy(
() =>
z
.object({
// Accept both single type and array of types, transform array to anyOf
type: TypeFieldSchema.optional(),
properties: z.record(z.string(), NormalizedToolSchemaInternal).optional(),
items: z.union([NormalizedToolSchemaInternal, z.array(NormalizedToolSchemaInternal)]).optional(),
required: z.array(z.string()).optional(),
additionalProperties: z.union([z.boolean(), NormalizedToolSchemaInternal]).default(false),
description: z.string().optional(),
default: z.unknown().optional(),
enum: z.array(JsonSchemaEnumValueSchema).optional(),
const: JsonSchemaEnumValueSchema.optional(),
anyOf: z.array(NormalizedToolSchemaInternal).optional(),
oneOf: z.array(NormalizedToolSchemaInternal).optional(),
allOf: z.array(NormalizedToolSchemaInternal).optional(),
$ref: z.string().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
pattern: z.string().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
})
.passthrough()
.transform((schema) => {
const { type, required, properties, ...rest } = schema
const result: Record<string, unknown> = { ...rest }
// If type is an array, convert to anyOf format (JSON Schema 2020-12)
if (Array.isArray(type)) {
result.anyOf = type.map((t) => ({ type: t }))
} else if (type !== undefined) {
result.type = type
}
// Handle properties and required for strict mode
if (properties) {
result.properties = properties
if (required) {
const propertyKeys = Object.keys(properties)
const filteredRequired = required.filter((key) => propertyKeys.includes(key))
if (filteredRequired.length > 0) {
result.required = filteredRequired
}
}
} else if (result.type === "object" || (Array.isArray(type) && type.includes("object"))) {
// For type: "object" without properties, add empty properties
// This is required by OpenAI strict mode
result.properties = {}
}
return result
}),
)
/**
* Normalizes a tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
*
* This function performs two key transformations:
* 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode)
* 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format
* (required by Claude on Bedrock which enforces JSON Schema draft 2020-12)
*
* Uses recursive parsing so transformations apply to all nested schemas automatically.
*
* @param schema - The JSON Schema to normalize
* @returns A normalized schema object that is JSON Schema draft 2020-12 compliant
*/
export function normalizeToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (typeof schema !== "object" || schema === null) {
return schema
}
const result = NormalizedToolSchemaInternal.safeParse(schema)
return result.success ? result.data : schema
}