fix: wire up dereference_refs() in tool schema pipeline (#3170)

This commit is contained in:
Jeremiah Lowin 2026-02-12 16:50:59 -05:00 committed by GitHub
commit 280885db59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 69 additions and 37 deletions

View file

@ -497,7 +497,10 @@ class ParsedFunction:
# Compress and handle exclude_args
prune_params = list(exclude_args) if exclude_args else None
input_schema = compress_schema(
input_schema, prune_params=prune_params, prune_titles=True
input_schema,
prune_params=prune_params,
prune_titles=True,
dereference=True,
)
output_schema = None
@ -557,7 +560,9 @@ class ParsedFunction:
else:
output_schema = base_schema
output_schema = compress_schema(output_schema, prune_titles=True)
output_schema = compress_schema(
output_schema, prune_titles=True, dereference=True
)
# Resolve root-level $ref to meet MCP spec requirement for type: object
# Self-referential Pydantic models generate schemas with $ref at root

View file

@ -53,6 +53,9 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
if "$defs" in dereferenced:
dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"}
# Remove discriminator.mapping entries that referenced $defs
_strip_discriminator_mappings(dereferenced)
return dereferenced
except JsonRefError:
@ -61,6 +64,26 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
return resolve_root_ref(schema)
def _strip_discriminator_mappings(schema: Any, depth: int = 0) -> None:
"""Remove discriminator.mapping entries whose values are $defs references.
Pydantic emits discriminator.mapping with plain-string references like
``"#/$defs/Cat"`` that become dangling after $defs are removed by
dereference_refs(). The oneOf/anyOf variants already carry their own
const fields, so the mapping is redundant once refs are inlined.
"""
if depth > 50 or not isinstance(schema, dict):
return
if "discriminator" in schema and isinstance(schema["discriminator"], dict):
schema["discriminator"].pop("mapping", None)
for value in schema.values():
if isinstance(value, dict):
_strip_discriminator_mappings(value, depth + 1)
elif isinstance(value, list):
for item in value:
_strip_discriminator_mappings(item, depth + 1)
def _merge_ref_siblings(
original: Any,
dereferenced: Any,
@ -367,6 +390,7 @@ def compress_schema(
prune_defs: bool = True,
prune_additional_properties: bool = True,
prune_titles: bool = False,
dereference: bool = False,
) -> dict[str, Any]:
"""
Remove the given parameters from the schema.
@ -377,6 +401,7 @@ def compress_schema(
prune_defs: Whether to remove unused definitions
prune_additional_properties: Whether to remove additionalProperties: false
prune_titles: Whether to remove title fields from the schema
dereference: Whether to inline $ref references for client compatibility
"""
# Remove specific parameters if requested
for param in prune_params or []:
@ -391,4 +416,8 @@ def compress_schema(
prune_defs=prune_defs,
)
# Inline $ref references for MCP clients that don't handle them
if dereference:
schema = dereference_refs(schema)
return schema

View file

@ -194,18 +194,15 @@ class TestToolFromFunction:
"tags": set(),
"enabled": True,
"parameters": {
"$defs": {
"UserInput": {
"properties": {
"user": {
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
"type": "object",
}
},
"properties": {
"user": {"$ref": "#/$defs/UserInput"},
},
"flag": {"type": "boolean"},
},
"required": ["user", "flag"],

View file

@ -75,8 +75,9 @@ class TestAddTools:
assert tool is not None
assert tool.name == "create_user"
assert tool.description == "Create a new user."
assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
# $refs are dereferenced so UserInput is inlined
assert "name" in tool.parameters["properties"]["user"]["properties"]
assert "age" in tool.parameters["properties"]["user"]["properties"]
assert "flag" in tool.parameters["properties"]
async def test_callable_object(self):

View file

@ -216,10 +216,14 @@ async def test_hidden_param_prunes_defs():
schema = new_tool.parameters
# Only 'a' should be visible
assert list(schema["properties"].keys()) == ["a"]
# $defs should only contain VisibleType, not HiddenType
defs = schema.get("$defs", {})
assert "VisibleType" in defs
assert "HiddenType" not in defs
# $refs are dereferenced so VisibleType is inlined, not in $defs
assert "$defs" not in schema
# VisibleType's structure should be inlined into the property
assert schema["properties"]["a"] == {
"properties": {"x": {"type": "integer"}},
"required": ["x"],
"type": "object",
}
async def test_forward_with_argument_mapping(add_tool):
@ -427,7 +431,8 @@ def test_transform_args_with_parent_defaults():
new_tool = Tool.from_tool(tool)
assert new_tool.parameters["$defs"] == tool.parameters["$defs"]
# $refs are dereferenced, so schemas should match (both inlined)
assert new_tool.parameters == tool.parameters
def test_transform_args_validation_unknown_arg(add_tool):
@ -1567,20 +1572,20 @@ class TestInputSchema:
complex_tool, transform_args={"unused_param": ArgTransform(hide=True)}
)
assert "UnusedType" not in transformed_tool.parameters["$defs"]
# $refs are dereferenced, so no $defs section
assert "$defs" not in transformed_tool.parameters
assert transformed_tool.parameters == snapshot(
{
"type": "object",
"properties": {"used_param": {"$ref": "#/$defs/UsedType"}},
"required": ["used_param"],
"$defs": {
"UsedType": {
"properties": {
"used_param": {
"properties": {"value": {"type": "string"}},
"required": ["value"],
"type": "object",
}
},
"required": ["used_param"],
}
)
@ -1610,15 +1615,14 @@ class TestInputSchema:
assert transformed.parameters == snapshot(
{
"type": "object",
"properties": {"renamed_input": {"$ref": "#/$defs/InputType"}},
"required": ["renamed_input"],
"$defs": {
"InputType": {
"properties": {
"renamed_input": {
"properties": {"data": {"type": "string"}},
"required": ["data"],
"type": "object",
}
},
"required": ["renamed_input"],
}
)
@ -1648,26 +1652,23 @@ class TestInputSchema:
{
"type": "object",
"properties": {
"param_a": {"$ref": "#/$defs/TypeA"},
"param_b": {"$ref": "#/$defs/TypeB"},
},
"required": IsList("param_b", "param_a", check_order=False),
"$defs": {
"TypeA": {
"param_a": {
"properties": {"a": {"type": "string"}},
"required": ["a"],
"type": "object",
},
"TypeB": {
"param_b": {
"properties": {"b": {"type": "integer"}},
"required": ["b"],
"type": "object",
},
},
"required": IsList("param_b", "param_a", check_order=False),
}
)
assert "TypeA" in transform1.parameters["$defs"]
# $refs are dereferenced, so TypeA is inlined
assert "$defs" not in transform1.parameters
# Second transform: hide param_b
transform2 = Tool.from_tool(
@ -1675,19 +1676,18 @@ class TestInputSchema:
transform_args={"param_b": ArgTransform(hide=True, default=TypeB(b=42))},
)
assert "TypeB" not in transform2.parameters["$defs"]
assert "$defs" not in transform2.parameters
assert transform2.parameters == snapshot(
{
"type": "object",
"properties": {"param_a": {"$ref": "#/$defs/TypeA"}},
"required": ["param_a"],
"$defs": {
"TypeA": {
"properties": {
"param_a": {
"properties": {"a": {"type": "string"}},
"required": ["a"],
"type": "object",
}
},
"required": ["param_a"],
}
)