fix(gemini): prune required entries missing from properties in tool schemas

Port from Kilo-Org/kilocode#11955: Gemini validates every object schema's
required list strictly against the same node's properties and fails the
ENTIRE GenerateContentRequest with HTTP 400 'required[0]: property is not
defined' when a name has no matching property. MCP servers (e.g. the GitHub
remote MCP) routinely emit array item schemas carrying required without
properties, which made every request on the native Gemini path fail before
any model output.

sanitize_gemini_schema() now filters required to names present in the node's
properties and drops the keyword when nothing valid remains. Applies
recursively (properties / items / anyOf). Tool handlers still validate
required fields at execution time, so nothing the model could actually use
is lost.

Scoped to the Gemini-facing sanitizer only — the universal
tools/schema_sanitizer.py already prunes typed object nodes, and its
remaining gap (untyped nodes) is contested by open PR #20151.
This commit is contained in:
Teknium 2026-07-13 17:28:07 -07:00
parent 17485cbcd2
commit b78ff50d8d
2 changed files with 111 additions and 0 deletions

View file

@ -87,6 +87,30 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
if any(not isinstance(item, str) for item in enum_val):
cleaned.pop("enum", None)
# Gemini validates ``required`` strictly against the same node's
# ``properties`` — GenerateContentRequest fails with HTTP 400
# "...items.required[0]: property is not defined" when a required name
# has no matching property in that node. MCP servers routinely emit
# this shape (e.g. the GitHub remote MCP's array item schemas carry
# ``required`` without ``properties``), and one bad tool schema fails
# the ENTIRE request before any model output. Filter ``required`` to
# names that exist in this node's ``properties`` and drop it when
# nothing valid remains. The tool handler still validates required
# fields at execution time, so this only removes what Gemini couldn't
# accept anyway. (Port of Kilo-Org/kilocode#11955.)
required_val = cleaned.get("required")
if isinstance(required_val, list):
props_val = cleaned.get("properties")
prop_names = set(props_val.keys()) if isinstance(props_val, dict) else set()
valid_required = [
name for name in required_val
if isinstance(name, str) and name in prop_names
]
if not valid_required:
cleaned.pop("required", None)
elif len(valid_required) != len(required_val):
cleaned["required"] = valid_required
return cleaned

View file

@ -108,6 +108,93 @@ class TestSanitizeGeminiSchema:
assert sanitize_gemini_schema([1, 2, 3]) == {}
class TestRequiredPropertyPruning:
"""Gemini rejects ``required`` names missing from the node's ``properties``.
Regression for the Kilo-Org/kilocode#11955 bug class: MCP servers (e.g.
the GitHub remote MCP) emit array item schemas whose ``required`` lists
reference properties that don't exist in the same node — Google fails the
entire GenerateContentRequest with HTTP 400 "property is not defined".
"""
def test_drops_required_when_node_has_no_properties(self):
schema = {"type": "object", "required": ["a", "b"]}
cleaned = sanitize_gemini_schema(schema)
assert "required" not in cleaned
def test_filters_ghost_required_entries(self):
schema = {
"type": "object",
"properties": {"x": {"type": "string"}},
"required": ["x", "ghost"],
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["required"] == ["x"]
def test_prunes_inside_array_items(self):
"""The exact shape from the GitHub MCP report — nested in items."""
schema = {
"type": "object",
"properties": {
"issue_fields": {
"type": "array",
"items": {
"type": "object",
"required": ["field_id", "value"],
},
},
},
"required": ["issue_fields"],
}
cleaned = sanitize_gemini_schema(schema)
items = cleaned["properties"]["issue_fields"]["items"]
assert "required" not in items
# Top-level required is valid and survives.
assert cleaned["required"] == ["issue_fields"]
def test_prunes_node_without_explicit_type(self):
"""Nodes carrying properties+required but no ``type`` key still prune."""
schema = {
"properties": {"x": {"type": "string"}},
"required": ["x", "ghost"],
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["required"] == ["x"]
def test_valid_required_untouched(self):
schema = {
"type": "object",
"properties": {"a": {"type": "string"}, "b": {"type": "integer"}},
"required": ["a", "b"],
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["required"] == ["a", "b"]
def test_drops_non_string_required_entries(self):
schema = {
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a", 42, None],
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["required"] == ["a"]
def test_prunes_inside_anyof_branches(self):
schema = {
"anyOf": [
{
"type": "object",
"properties": {"x": {"type": "string"}},
"required": ["x", "ghost"],
},
{"type": "object", "required": ["orphan"]},
]
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["anyOf"][0]["required"] == ["x"]
assert "required" not in cleaned["anyOf"][1]
class TestSanitizeGeminiToolParameters:
def test_empty_parameters_return_valid_object_schema(self):
"""Gemini requires ``parameters`` to be a valid object schema."""