mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: flatten top-level anyOf/oneOf/allOf in MCP tool schemas (#10726)
This commit is contained in:
parent
3a884ee01e
commit
df42655fc9
2 changed files with 218 additions and 15 deletions
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue