mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
fix: wire up dereference_refs() in tool schema pipeline (#3170)
This commit is contained in:
parent
392d38326d
commit
280885db59
5 changed files with 69 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue