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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-25 05:20:36 -07:00
committed by GitHub
parent 0eb3919d69
commit 58a5275aa2
5 changed files with 420 additions and 55 deletions
+10
View File
@@ -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:
+186 -12
View File
@@ -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<string, unknown>,
$defs: Record<string, unknown> = {},
) => {
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<string, unknown> = { 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));
});
});
+143 -9
View File
@@ -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<string, unknown>;
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<object> = 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<string, unknown> => {
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<string, unknown>,
@@ -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<string, unknown> => {
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 },
];
}),
);
@@ -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<string, unknown>;
const items = node.items;
const here =
node.type === "array" &&
typeof items === "object" &&
items !== null &&
!Array.isArray(items) &&
(Array.isArray((items as Record<string, unknown>).oneOf) ||
Array.isArray((items as Record<string, unknown>).anyOf)) &&
(items as Record<string, unknown>).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<readonly AdvertisedTool[]> => {
@@ -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"),
+44
View File
@@ -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 [];
}),
];
};