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 []; + }), + ]; +};