fix: flatten top-level anyOf/oneOf/allOf in MCP tool schemas (#10726)

This commit is contained in:
Daniel 2026-01-15 23:05:00 -05:00 committed by GitHub
parent 3a884ee01e
commit df42655fc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 218 additions and 15 deletions

View file

@ -150,7 +150,9 @@ describe("normalizeToolSchema", () => {
])
})
it("should recursively transform anyOf arrays", () => {
it("should flatten top-level anyOf and recursively transform nested schemas", () => {
// Top-level anyOf is flattened for provider compatibility (OpenRouter/Claude)
// but nested anyOf inside properties is preserved
const input = {
anyOf: [
{
@ -165,18 +167,14 @@ describe("normalizeToolSchema", () => {
const result = normalizeToolSchema(input)
// additionalProperties: false should ONLY be on object types, not on null or primitive types
// Top-level anyOf should be flattened to the object variant
// Nested type array should be converted to anyOf
expect(result).toEqual({
anyOf: [
{
type: "object",
properties: {
optional: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
},
{ type: "null" },
],
type: "object",
properties: {
optional: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
})
})
@ -459,5 +457,160 @@ describe("normalizeToolSchema", () => {
expect(props.url.type).toBe("string")
expect(props.url.description).toBe("URL to fetch")
})
describe("top-level anyOf/oneOf/allOf flattening", () => {
it("should flatten top-level anyOf to object schema", () => {
// This is the type of schema that caused the OpenRouter error:
// "input_schema does not support oneOf, allOf, or anyOf at the top level"
const input = {
anyOf: [
{
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
{ type: "null" },
],
}
const result = normalizeToolSchema(input)
// Should flatten to the object variant
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
expect(result.properties).toBeDefined()
expect((result.properties as Record<string, unknown>).name).toEqual({ type: "string" })
expect(result.additionalProperties).toBe(false)
})
it("should flatten top-level oneOf to object schema", () => {
const input = {
oneOf: [
{
type: "object",
properties: {
url: { type: "string" },
},
},
{
type: "object",
properties: {
path: { type: "string" },
},
},
],
}
const result = normalizeToolSchema(input)
// Should use the first object variant
expect(result.oneOf).toBeUndefined()
expect(result.type).toBe("object")
expect((result.properties as Record<string, unknown>).url).toBeDefined()
})
it("should flatten top-level allOf to object schema", () => {
const input = {
allOf: [
{
type: "object",
properties: {
base: { type: "string" },
},
},
{
properties: {
extra: { type: "number" },
},
},
],
}
const result = normalizeToolSchema(input)
// Should use the first object variant
expect(result.allOf).toBeUndefined()
expect(result.type).toBe("object")
})
it("should preserve description when flattening top-level anyOf", () => {
const input = {
description: "Input for the tool",
anyOf: [
{
type: "object",
properties: {
data: { type: "string" },
},
},
{ type: "null" },
],
}
const result = normalizeToolSchema(input)
expect(result.description).toBe("Input for the tool")
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
})
it("should create generic object schema if no object variant found", () => {
const input = {
anyOf: [{ type: "string" }, { type: "number" }],
}
const result = normalizeToolSchema(input)
// Should create a fallback object schema
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
expect(result.additionalProperties).toBe(false)
})
it("should NOT flatten nested anyOf (only top-level)", () => {
const input = {
type: "object",
properties: {
field: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
}
const result = normalizeToolSchema(input)
// Nested anyOf should be preserved
const props = result.properties as Record<string, Record<string, unknown>>
expect(props.field.anyOf).toBeDefined()
})
it("should handle MCP server schema with top-level anyOf", () => {
// Real-world example: some MCP servers define optional nullable root schemas
const input = {
$schema: "http://json-schema.org/draft-07/schema#",
anyOf: [
{
type: "object",
additionalProperties: false,
properties: {
issueId: { type: "string", description: "The issue ID" },
body: { type: "string", description: "The content" },
},
required: ["issueId", "body"],
},
],
}
const result = normalizeToolSchema(input)
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
expect(result.properties).toBeDefined()
expect(result.required).toContain("issueId")
expect(result.required).toContain("body")
})
})
})
})

View file

@ -230,14 +230,61 @@ const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodType
}),
)
/**
* Flattens a schema with top-level anyOf/oneOf/allOf to a simple object schema.
* This is needed because some providers (OpenRouter, Claude) don't support
* schema composition keywords at the top level of tool input schemas.
*
* @param schema - The schema to flatten
* @returns A flattened schema without top-level composition keywords
*/
function flattenTopLevelComposition(schema: Record<string, unknown>): Record<string, unknown> {
const { anyOf, oneOf, allOf, ...rest } = schema
// If no top-level composition keywords, return as-is
if (!anyOf && !oneOf && !allOf) {
return schema
}
// Get the composition array to process (prefer anyOf, then oneOf, then allOf)
const compositionArray = (anyOf || oneOf || allOf) as Record<string, unknown>[] | undefined
if (!compositionArray || !Array.isArray(compositionArray) || compositionArray.length === 0) {
return schema
}
// Find the first non-null object type variant to use as the base
// This preserves the most information while making the schema compatible
const objectVariant = compositionArray.find(
(variant) =>
typeof variant === "object" &&
variant !== null &&
(variant.type === "object" || variant.properties !== undefined),
)
if (objectVariant) {
// Merge remaining properties with the object variant
return { ...rest, ...objectVariant }
}
// If no object variant found, create a generic object schema
// This is a fallback that allows any object structure
return {
type: "object",
additionalProperties: false,
...rest,
}
}
/**
* Normalizes a tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
*
* This function performs three key transformations:
* This function performs four 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)
* 3. Strips unsupported `format` values (e.g., "uri") for OpenAI Structured Outputs compatibility
* 4. Flattens top-level anyOf/oneOf/allOf (required by OpenRouter/Claude which don't support
* schema composition keywords at the top level)
*
* Uses recursive parsing so transformations apply to all nested schemas automatically.
*
@ -249,6 +296,9 @@ export function normalizeToolSchema(schema: Record<string, unknown>): Record<str
return schema
}
const result = NormalizedToolSchemaInternal.safeParse(schema)
return result.success ? result.data : schema
// First, flatten any top-level composition keywords before normalizing
const flattenedSchema = flattenTopLevelComposition(schema)
const result = NormalizedToolSchemaInternal.safeParse(flattenedSchema)
return result.success ? result.data : flattenedSchema
}