diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index 21d69b78d..fc4e069c6 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -73,6 +73,33 @@ def _strip_remote_refs(obj: Any) -> Any: return obj +def _strip_discriminator(obj: Any) -> Any: + """Recursively remove OpenAPI ``discriminator`` keys from a schema. + + Pydantic emits ``discriminator.mapping`` with values like + ``#/$defs/ClassName``. After ``$defs`` are inlined and removed by + ``dereference_refs``, those mapping entries dangle. The keyword is an + OpenAPI extension — the ``anyOf`` variants already carry ``const`` on + the discriminant field, so the mapping is redundant. + + Only strips ``discriminator`` when it appears alongside ``anyOf`` or + ``oneOf``, which is where the OpenAPI keyword lives. A property + *named* ``discriminator`` (inside ``properties``) is left alone. + """ + if isinstance(obj, dict): + skip = "discriminator" in obj and ("anyOf" in obj or "oneOf" in obj) + # Keys that hold instance data, not sub-schemas — don't recurse. + _DATA_KEYS = {"default", "const", "examples", "enum"} + return { + k: (v if k in _DATA_KEYS else _strip_discriminator(v)) + for k, v in obj.items() + if not (k == "discriminator" and skip) + } + if isinstance(obj, list): + return [_strip_discriminator(item) for item in obj] + return obj + + def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: """Resolve all $ref references in a JSON schema by inlining definitions. @@ -135,6 +162,13 @@ 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"} + # Strip `discriminator` keys — they contain `mapping` values that + # point at `#/$defs/...` entries we just removed. `discriminator` + # is an OpenAPI extension; after inlining, the `anyOf` variants + # already carry `const` on the discriminant field, making the + # mapping redundant. + dereferenced = _strip_discriminator(dereferenced) + return dereferenced except JsonRefError: diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 9c208f5d8..8802d69a4 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -197,6 +197,67 @@ class TestDereferenceRefs: assert country["default"] == "US" assert "$defs" not in result + def test_strips_discriminator_mapping_after_inlining(self): + """Discriminator.mapping refs dangle after $defs are inlined (#3679).""" + schema = { + "$defs": { + "IdentifyPerson": { + "type": "object", + "properties": { + "action": {"const": "identify", "type": "string"}, + "name": {"type": "string"}, + }, + "required": ["action", "name"], + }, + "PersonDelete": { + "type": "object", + "properties": { + "action": {"const": "delete", "type": "string"}, + }, + "required": ["action"], + }, + }, + "anyOf": [ + {"$ref": "#/$defs/IdentifyPerson"}, + {"$ref": "#/$defs/PersonDelete"}, + ], + "discriminator": { + "mapping": { + "identify": "#/$defs/IdentifyPerson", + "delete": "#/$defs/PersonDelete", + }, + "propertyName": "action", + }, + } + result = dereference_refs(schema) + + assert "$defs" not in result + assert "discriminator" not in result + # The anyOf variants should be inlined with their const values intact + assert len(result["anyOf"]) == 2 + actions = {v["properties"]["action"]["const"] for v in result["anyOf"]} + assert actions == {"identify", "delete"} + + def test_preserves_property_named_discriminator(self): + """A field *named* 'discriminator' inside properties must survive.""" + schema = { + "$defs": { + "Inner": { + "type": "object", + "properties": { + "discriminator": {"type": "string"}, + }, + }, + }, + "properties": { + "item": {"$ref": "#/$defs/Inner"}, + }, + } + result = dereference_refs(schema) + + assert "$defs" not in result + assert "discriminator" in result["properties"]["item"]["properties"] + class TestCompressSchema: """Tests for the compress_schema function."""