From 58a5275aa2247b961cb249ccbe4a98adbb706934 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:20:36 -0700 Subject: [PATCH] fix(mcp): advertise reduced array elements with Gemini-compatible item types (#7107) Reduced compact input schemas gave every array property an any-value item schema, a six-member type list beside nested `items`. Gemini function declarations accept `items` only beside the single `array` type, so every request through a Gemini-backed client failed with `properties[functions].items.items: field predicate failed: $type == Type.ARRAY`. Reduced array properties now advertise the element types their canonical item schemas accept. Unknown, tuple, and mixed array elements use the empty item schema, and properties that may be arrays or another type list typed anyOf branches. An inline `not: {}` keeps the empty schema. The compact catalog test now rejects `items` beside a non-array type as well as untyped item unions. Fixes #5201 Co-authored-by: Claude Opus 5.5 --- docs/mcp-contracts.md | 10 + src/contracts/compactInputSchema.test.ts | 198 ++++++++++++++++-- src/contracts/compactInputSchema.ts | 152 +++++++++++++- .../mcp/compactInputSchemaMcp.test.ts | 71 ++++--- tests/fixtures/mcpClientSchemaConstraints.ts | 44 ++++ 5 files changed, 420 insertions(+), 55 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index d0e581671b..7ba2b247fb 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1269,6 +1269,16 @@ This addresses the emitted shape reported through a Gemini-backed gateway in [#5201](https://github.com/morluto/rea/issues/5201). Gateway conversion and live provider acceptance have not been verified for this projection. +Gemini function declarations accept `items` only beside the single `array` +type. Reduced array properties therefore advertise the element types their +canonical item schemas accept, such as `{"type": "object"}` for a union of +object actions, instead of an any-value type list with nested `items`. An +element whose type is unknown, a tuple, or an array alongside other types uses +the empty item schema, and a property that may be an array or another type +lists typed `anyOf` branches. A boundary test rejects any compact +advertisement that places `items` beside another type or uses an untyped item +union. + The compact profile renders the same tool set, output schemas, annotations, and canonical server-side validation. It changes only the advertised input JSON Schema: diff --git a/src/contracts/compactInputSchema.test.ts b/src/contracts/compactInputSchema.test.ts index 3a1f3b9541..323397d206 100644 --- a/src/contracts/compactInputSchema.test.ts +++ b/src/contracts/compactInputSchema.test.ts @@ -410,24 +410,27 @@ describe("compact input schema budget and reduction", () => { expect(presentation).toBe("reduced"); expect(schema.properties).toEqual({ capture: { type: "object", description: "Complete capture." }, - observations: { - type: "array", - items: { - type: ["null", "boolean", "object", "array", "number", "string"], - items: {}, - }, - }, + observations: { type: "array", items: { type: "object" } }, label: { type: ["string", "null"] }, }); const validate = new Ajv2020({ strict: false }).compile(schema); expect(validate({ capture: {}, observations: [], label: null })).toBe(true); + const observation = { + captured_url: "https://example.test/", + captured_at: "2026-10-25T00:00:00Z", + status_code: 200, + }; + const canonicalObservations = new Ajv2020({ strict: false }).compile( + original.$defs.observations, + ); + expect(canonicalObservations([observation])).toBe(true); + expect(canonicalObservations(["text"])).toBe(false); expect( - validate({ - capture: {}, - observations: [[[null, true, 42, "nested"], { values: [[], [false]] }]], - label: null, - }), + validate({ capture: {}, observations: [observation], label: null }), ).toBe(true); + expect(validate({ capture: {}, observations: ["text"], label: null })).toBe( + false, + ); expect(validate({ capture: {}, observations: [], label: "capture" })).toBe( true, ); @@ -697,3 +700,174 @@ it("includes restored root identity in the compact byte budget", () => { expect(serializedBytes(result.schema)).toBeLessThanOrEqual(512); expect(result.schema.properties).toEqual({ value: { type: "number" } }); }); + +// An untyped primitive enum is gateway-incompatible, so these schemas take +// the reduced projection independently of the budget. +const reducedPresentation = ( + properties: Record, + $defs: Record = {}, +) => { + const canonical = { + type: "object", + $defs, + properties: { mode: { enum: ["fast", "safe"] }, ...properties }, + }; + const result = compactAdvertisedInputSchema(canonical, 13 * 1024); + expect(result.presentation).toBe("reduced"); + const { mode: _mode, ...advertised } = result.schema.properties as Record< + string, + unknown + >; + return { + advertised, + validateCanonical: new Ajv2020({ strict: false }).compile(canonical), + validateAdvertised: new Ajv2020({ strict: false }).compile(result.schema), + }; +}; + +const reducedArrayCases = [ + [ + "string elements", + { type: "array", items: { type: "string", minLength: 1 } }, + { type: "array", items: { type: "string" } }, + [["a", "b"]], + ], + [ + "object union elements", + { + type: "array", + minItems: 1, + items: { + oneOf: [ + { + type: "object", + properties: { kind: { const: "click" } }, + required: ["kind"], + }, + { + type: "object", + properties: { kind: { const: "type" } }, + required: ["kind"], + }, + ], + }, + }, + { type: "array", items: { type: "object" } }, + [[{ kind: "click" }, { kind: "type" }]], + ], + [ + "referenced elements", + { type: "array", items: { $ref: "#/$defs/ordinal" } }, + { type: "array", items: { type: "integer" } }, + [[0, 7]], + ], + [ + "nested array elements", + { + type: "array", + items: { type: "array", items: { type: "number" } }, + }, + { type: "array", items: { type: "array", items: { type: "number" } } }, + [[[1.5], []]], + ], + [ + "mixed scalar and object elements", + { + type: "array", + items: { anyOf: [{ type: "string" }, { type: "object" }] }, + }, + { type: "array", items: { type: ["string", "object"] } }, + [["text", {}]], + ], + [ + "elements that may be arrays or scalars", + { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + }, + { type: "array", items: {} }, + [["text", ["nested"]]], + ], + [ + "tuple elements", + { + type: "array", + prefixItems: [{ type: "integer" }], + items: { type: "string" }, + }, + { type: "array", items: {} }, + [[1, "rest"]], + ], + [ + "nullable arrays", + { + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], + }, + { + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], + }, + [null, ["text"]], + ], +] as const; + +describe("reduced array item presentation", () => { + it.each(reducedArrayCases)( + "advertises %s without placing items beside a non-array type", + (_label, property, expected, canonicalValues) => { + const { advertised, validateCanonical, validateAdvertised } = + reducedPresentation( + { values: property }, + { ordinal: { type: "integer", minimum: 0 } }, + ); + expect(advertised).toEqual({ values: expected }); + for (const values of canonicalValues) { + expect(validateCanonical({ values })).toBe(true); + expect(validateAdvertised({ values })).toBe(true); + } + }, + ); + + it("stops deriving element types at the nested array depth bound", () => { + let property: Record = { type: "string" }; + for (let level = 0; level < 5; level += 1) + property = { type: "array", items: property }; + const { advertised, validateAdvertised } = reducedPresentation({ + values: property, + }); + expect(advertised).toEqual({ + values: { + type: "array", + items: { + type: "array", + items: { type: "array", items: { type: "array", items: {} } }, + }, + }, + }); + expect(validateAdvertised({ values: [[[[["deep"]]]]] })).toBe(true); + }); + + it("keeps an inline forbidden value as the empty schema under not", () => { + const canonical = { + type: "object", + properties: { view: { type: "string" } }, + if: { properties: { view: { const: "x" } }, required: ["view"] }, + then: { required: ["view"] }, + else: { properties: { view: { not: {} } } }, + }; + const { schema, presentation } = compactAdvertisedInputSchema( + canonical, + 13 * 1024, + ); + expect(presentation).toBe("inline"); + expect(schema).toHaveProperty(["else", "properties", "view", "not"], {}); + const canonicalValidate = new Ajv2020({ strict: false }).compile(canonical); + const advertisedValidate = new Ajv2020({ strict: false }).compile(schema); + for (const input of [{}, { view: "x" }, { view: "y" }]) + expect(advertisedValidate(input)).toBe(canonicalValidate(input)); + }); +}); diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts index 3df9af7bce..fc7f41e023 100644 --- a/src/contracts/compactInputSchema.ts +++ b/src/contracts/compactInputSchema.ts @@ -28,6 +28,8 @@ const jsonValueTypes = [ ]; // An empty item schema accepts every JSON value, including arbitrarily nested // arrays, while satisfying clients that require `items` for array types. +// Gemini rejects `items` beside a type list; the compact catalog test fails +// if a tool would advertise this presentation. const jsonValueSchema = () => ({ type: [...jsonValueTypes], items: {} }); const enumValueTypes = (values: readonly unknown[]): readonly string[] => [ @@ -240,6 +242,18 @@ const inlineWithinBudget = ( if (!first) yield ","; first = false; yield JSON.stringify(key) + ":"; + // `not: {}` forbids a value; no client infers a type for a value it + // must omit, and the any-value list would place `items` beside a + // non-array type, which Gemini function declarations reject. + if ( + position === "schema" && + key === "not" && + isSchemaObject(child) && + Object.keys(child).length === 0 + ) { + yield "{}"; + continue; + } const childPosition = position === "map" ? "schema" @@ -323,6 +337,106 @@ const detectedTypes = ( return [...new Set(types.flatMap((type) => type ?? []))]; }; +interface ScopedSchema { + readonly schema: Record; + readonly scope: JsonSchemaScope; +} + +/** + * Collect the item schema of every array alternative. Tuples, boolean item + * schemas and unresolved references are unknown because one item type would + * not describe every accepted element. + */ +const arrayItemAlternatives = ( + schema: unknown, + references: JsonSchemaReferences, + inherited: JsonSchemaScope | undefined, + active: ReadonlySet = new Set(), +): readonly ScopedSchema[] | undefined => { + if (!isSchemaObject(schema)) return undefined; + const scope = references.enter(schema, inherited); + if (scope === undefined) return undefined; + if (typeof schema.$ref === "string") { + if (active.has(schema)) return undefined; + const target = references.resolve(scope, schema.$ref); + return target.kind === "target" + ? arrayItemAlternatives( + target.value, + references, + scope, + new Set([...active, schema]), + ) + : undefined; + } + const types = + typeof schema.type === "string" + ? [schema.type] + : Array.isArray(schema.type) + ? schema.type + : undefined; + if (types !== undefined) { + if (!types.includes("array")) return []; + return isSchemaObject(schema.items) && schema.prefixItems === undefined + ? [{ schema: schema.items, scope }] + : undefined; + } + const alternatives = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : []; + if (alternatives.length === 0) return undefined; + const nested = alternatives.map((branch) => + arrayItemAlternatives(branch, references, scope, active), + ); + return nested.some((items) => items === undefined) + ? undefined + : nested.flatMap((items) => items ?? []); +}; + +/** Nested arrays beyond this level advertise an unconstrained item schema. */ +const REDUCED_ITEM_MAX_DEPTH = 3; + +/** + * Advertise the element types the canonical item schemas accept. Gemini + * function declarations permit `items` only beside the single `array` type, + * so an element that may be an array alongside other types, or whose type is + * unknown, uses the empty schema: it accepts every element and still gives + * clients that require `items` an item schema. + */ +const reducedItemSchema = ( + alternatives: readonly ScopedSchema[] | undefined, + references: JsonSchemaReferences, + depth = 1, +): Record => { + if ( + alternatives === undefined || + alternatives.length === 0 || + depth > REDUCED_ITEM_MAX_DEPTH + ) + return {}; + const types: string[] = []; + for (const { schema, scope } of alternatives) { + const detected = detectedTypes(schema, references, scope); + if (detected === undefined || detected.length === 0) return {}; + types.push(...detected); + } + const unique = [...new Set(types)]; + if (!unique.includes("array")) + return { type: unique.length === 1 ? unique[0] : unique }; + if (unique.length > 1) return {}; + const nested: ScopedSchema[] = []; + for (const { schema, scope } of alternatives) { + const items = arrayItemAlternatives(schema, references, scope); + if (items === undefined) return { type: "array", items: {} }; + nested.push(...items); + } + return { + type: "array", + items: reducedItemSchema(nested, references, depth + 1), + }; +}; + /** Give homogeneous array-item unions the type required by strict gateways. */ const typeArrayItemUnions = ( schema: Record, @@ -406,6 +520,34 @@ const schemaChildrenAndMaps = ( return []; }); +/** + * Present a reduced property's reference-resolved types. Reduced inputs omit + * nested validation, but clients still require an item schema for an array + * declaration, and Gemini accepts `items` only beside the single `array` type, + * so a property that may be an array or another type lists typed branches. + */ +const reducedPropertyTypes = ( + value: unknown, + references: JsonSchemaReferences, +): Record => { + const types = detectedTypes(value, references, references.rootScope); + if (types === undefined || types.length === 0) return {}; + if (!types.includes("array")) + return { type: types.length === 1 ? types[0] : types }; + const array = { + type: "array", + items: reducedItemSchema( + arrayItemAlternatives(value, references, references.rootScope), + references, + ), + }; + return types.length === 1 + ? array + : { + anyOf: types.map((type) => (type === "array" ? array : { type })), + }; +}; + const REDUCED_ANNOTATION = "Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs."; @@ -441,21 +583,13 @@ const reduceToShallowProperties = ( const references = new JsonSchemaReferences(schema); const shallow = Object.fromEntries( Object.entries(properties).map(([name, value]) => { - const types = detectedTypes(value, references, references.rootScope); - const type = types?.length === 1 ? types[0] : types; const description = isSchemaObject(value) && typeof value.description === "string" ? { description: value.description } : {}; return [ name, - { - ...(type === undefined ? {} : { type }), - // Reduced inputs intentionally omit nested validation, but clients - // still require an item schema to accept an array declaration. - ...(types?.includes("array") ? { items: jsonValueSchema() } : {}), - ...description, - }, + { ...reducedPropertyTypes(value, references), ...description }, ]; }), ); diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 86a1b15b87..6c5650469a 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -27,6 +27,7 @@ import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; import { arrayItemSchemaError, + strictGatewayItemSchemaErrors, unsupportedInputLiteralSchemaError, } from "../../fixtures/mcpClientSchemaConstraints.js"; @@ -79,39 +80,6 @@ const unsupportedDependencyKeywords = ( }); }; -const untypedUnionItems = (schema: unknown, path = "$"): readonly string[] => { - if (typeof schema !== "object" || schema === null || Array.isArray(schema)) - return []; - const node = schema as Record; - const items = node.items; - const here = - node.type === "array" && - typeof items === "object" && - items !== null && - !Array.isArray(items) && - (Array.isArray((items as Record).oneOf) || - Array.isArray((items as Record).anyOf)) && - (items as Record).type === undefined - ? [`${path}.items`] - : []; - return [ - ...here, - ...Object.entries(node).flatMap(([key, value]) => { - if (schemaMaps.has(key) && typeof value === "object" && value !== null) - return Object.entries(value).flatMap(([name, child]) => - untypedUnionItems(child, `${path}.${key}.${name}`), - ); - if (schemaArrays.has(key) && Array.isArray(value)) - return value.flatMap((child, index) => - untypedUnionItems(child, `${path}.${key}[${index}]`), - ); - if (schemaChildren.has(key)) - return untypedUnionItems(value, `${path}.${key}`); - return []; - }), - ]; -}; - const listToolsThroughTransport = async ( server: EvidenceMcpServer, ): Promise => { @@ -235,7 +203,7 @@ it("advertises every tool input schema within the provider budget", async () => ).toEqual([]); expect( tools.flatMap(({ name, inputSchema }) => - untypedUnionItems(inputSchema).map((path) => `${name}${path.slice(1)}`), + strictGatewayItemSchemaErrors(inputSchema, name), ), ).toEqual([]); }); @@ -279,6 +247,41 @@ it("detects missing client-required items in nested array alternatives", () => { ); }); +it("detects item schemas Gemini declarations reject in schema positions only", () => { + // The #5201 report: `properties[functions].items.items: field predicate + // failed: $type == Type.ARRAY` for an any-value element type list. + expect( + strictGatewayItemSchemaErrors( + { + type: "object", + properties: { + functions: { + type: "array", + items: { + type: ["null", "boolean", "object", "array", "number", "string"], + items: {}, + }, + }, + actions: { + type: "array", + items: { oneOf: [{ type: "object" }, { type: "string" }] }, + }, + nested: { + type: "array", + items: { type: "array", items: { type: "string" } }, + }, + }, + default: { items: {} }, + examples: [{ items: {} }], + }, + "injected", + ), + ).toEqual([ + 'injected.properties.functions.items.items is beside type ["null","boolean","object","array","number","string"]', + "injected.properties.actions.items is an untyped union", + ]); +}); + it("preserves arbitrary nested JSON values at the converted SDK boundary", async () => { const contract = { ...toolContract("binary_session"), diff --git a/tests/fixtures/mcpClientSchemaConstraints.ts b/tests/fixtures/mcpClientSchemaConstraints.ts index c43c03d1f5..42b496c93a 100644 --- a/tests/fixtures/mcpClientSchemaConstraints.ts +++ b/tests/fixtures/mcpClientSchemaConstraints.ts @@ -78,3 +78,47 @@ export const unsupportedInputLiteralSchemaError = ( } return undefined; }; + +/** + * Gemini function declarations accept `items` only on a schema whose type is + * the single `array` type (`items: field predicate failed: $type == + * Type.ARRAY`), and strict gateways drop untyped item unions. Return every + * advertised position with either shape so a catalog test names them all. + */ +export const strictGatewayItemSchemaErrors = ( + schema: unknown, + path = "$", +): readonly string[] => { + if (!isRecord(schema)) return []; + const here: string[] = []; + if (schema.items !== undefined && schema.type !== "array") + here.push(`${path}.items is beside type ${JSON.stringify(schema.type)}`); + const items = schema.items; + if ( + schema.type === "array" && + isRecord(items) && + items.type === undefined && + (Array.isArray(items.oneOf) || Array.isArray(items.anyOf)) + ) + here.push(`${path}.items is an untyped union`); + return [ + ...here, + ...Object.entries(schema).flatMap(([key, value]) => { + if (schemaMaps.has(key) && isRecord(value)) + return Object.entries(value).flatMap(([name, child]) => + strictGatewayItemSchemaErrors(child, `${path}.${key}.${name}`), + ); + if (schemaArrays.has(key) && Array.isArray(value)) + return value.flatMap((child: unknown, index) => + strictGatewayItemSchemaErrors(child, `${path}.${key}[${index}]`), + ); + if (schemaChildren.has(key)) + return Array.isArray(value) + ? value.flatMap((child: unknown, index) => + strictGatewayItemSchemaErrors(child, `${path}.${key}[${index}]`), + ) + : strictGatewayItemSchemaErrors(value, `${path}.${key}`); + return []; + }), + ]; +};