From 6476e00c38bf5c5017128a8571b18c8e33c7f874 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 14 Jan 2026 11:49:12 -0500 Subject: [PATCH] fix: flatten top-level anyOf/oneOf/allOf in MCP tool schemas MCP servers can define tool schemas with anyOf, oneOf, or allOf at the top level. When passed to OpenRouter/Claude, the API rejects them with: 'input_schema does not support oneOf, allOf, or anyOf at the top level' This extends normalizeToolSchema() to flatten top-level composition keywords: - Extracts the first object-type variant from the composition - Merges with top-level metadata (description, $schema) - Falls back to generic object schema if no object variant exists Closes COM-485 --- src/utils/__tests__/json-schema.spec.ts | 177 ++++++++++++++++++++++-- src/utils/json-schema.ts | 56 +++++++- 2 files changed, 218 insertions(+), 15 deletions(-) diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index 5a1510be43b..c939095340a 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -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).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).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> + 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") + }) + }) }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 8059c2ee0df..cbcd3486d2e 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -230,14 +230,61 @@ const NormalizedToolSchemaInternal: z.ZodType, 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): Record { + 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[] | 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): Record