Flatten OpenAPI discriminator subtypes into request bodies (#4677)

* Flatten OpenAPI discriminator subtypes into request bodies

* Resolve schema-name discriminator mappings and union conflicting variant fields

* Advertise discriminator values for propertyless variants and document the behavior
This commit is contained in:
Jeremiah Lowin 2026-07-27 16:06:54 -04:00 committed by GitHub
commit 7674645761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 459 additions and 11 deletions

View file

@ -452,4 +452,21 @@ FastMCP handles array parameters according to OpenAPI specifications:
### Headers
Header parameters are automatically converted to strings and included in the HTTP request.
Header parameters are automatically converted to strings and included in the HTTP request.
### Composed Request Bodies
A request body becomes a flat set of tool arguments, which is the shape LLM tool-calling APIs fill in most reliably. Schemas composed with `allOf` are resolved first, following `$ref` members, so fields inherited from a parent schema appear alongside the ones a schema declares itself.
Schemas that use a `discriminator` are flattened the same way. FastMCP merges in the fields of every subtype named in the discriminator's `mapping`, marks them optional, and names the accepted values on the discriminator's own description. Given a `Pet` body discriminated by `petType` and mapped onto `Cat` and `Dog`, the tool takes the discriminator plus whichever fields that variant uses:
```python
await client.call_tool("create_pet", {
"petType": "cat",
"meowVolume": 11,
})
```
The discriminator stays required; every variant field is optional, because only one variant applies to any given call.
This trades local strictness for a schema models complete accurately. The generated schema permits any combination of variant fields, so sending `packSize` with `petType: "cat"` passes FastMCP's validation and is rejected by the API itself, exactly as it would be for any other HTTP client. Where two variants declare the same field differently, the declarations are combined with `anyOf` so that neither variant's constraints are advertised as applying to both.

View file

@ -36,6 +36,7 @@ from .models import (
)
from .schemas import (
_combine_schemas_and_map_params,
_discriminator_target_name,
_replace_ref_with_defs,
)
@ -543,6 +544,7 @@ class OpenAPIParser(
schema: dict,
all_schemas: dict[str, Any],
collected: set[str] | None = None,
follow_discriminator: bool = False,
) -> set[str]:
"""
Extract all schema names referenced by a schema (including transitive dependencies).
@ -551,6 +553,10 @@ class OpenAPIParser(
schema: The schema to analyze
all_schemas: All available schema definitions
collected: Set of already collected schema names (for recursion)
follow_discriminator: Also collect the subtypes named by a
`discriminator.mapping`. Those values are bare strings rather
than `$ref` objects, so they are invisible to ordinary ref
collection.
Returns:
Set of schema names that are referenced
@ -558,6 +564,12 @@ class OpenAPIParser(
if collected is None:
collected = set()
def collect(schema_name: str) -> None:
"""Collect a schema by name and recurse into its dependencies."""
if schema_name not in collected and schema_name in all_schemas:
collected.add(schema_name)
find_refs(all_schemas[schema_name])
def find_refs(obj):
"""Recursively find all $ref references."""
if isinstance(obj, dict):
@ -570,14 +582,19 @@ class OpenAPIParser(
return
# Add this schema and recursively find its dependencies
if (
collected is not None
and schema_name not in collected
and schema_name in all_schemas
):
collected.add(schema_name)
# Recursively find dependencies of this schema
find_refs(all_schemas[schema_name])
collect(schema_name)
if follow_discriminator:
discriminator = obj.get("discriminator")
if isinstance(discriminator, dict):
mapping = discriminator.get("mapping")
if isinstance(mapping, dict):
for target in mapping.values():
if not isinstance(target, str):
continue
name = _discriminator_target_name(target)
if name:
collect(name)
# Continue searching in all values
for value in obj.values():
@ -614,10 +631,15 @@ class OpenAPIParser(
deps = self._extract_schema_dependencies(param.schema_, all_schemas)
needed_schemas.update(deps)
# Check request body for schema references
# Check request body for schema references. Request bodies are flattened
# into a single object, so discriminated subtypes need to come along.
if request_body and request_body.content_schema:
for content_schema in request_body.content_schema.values():
deps = self._extract_schema_dependencies(content_schema, all_schemas)
deps = self._extract_schema_dependencies(
content_schema,
all_schemas,
follow_discriminator=True,
)
needed_schemas.update(deps)
# Return only the needed input schemas

View file

@ -258,6 +258,118 @@ def _allof_members(
return [schema]
def _discriminator_target_name(target: str) -> str | None:
"""Resolve a ``discriminator.mapping`` value to a local schema name.
Mapping values hold "schema names or references", so a bare ``"Cat"`` means
the ``Cat`` component just as ``"#/components/schemas/Cat"`` does. Anything
else a remote URL, a pointer outside the component schemas has no local
definition to flatten.
"""
for prefix in ("#/$defs/", "#/components/schemas/"):
if target.startswith(prefix):
return target.removeprefix(prefix) or None
if target.startswith("#") or "/" in target:
return None
return target or None
def _flatten_discriminator_subtypes(
schema: dict[str, Any],
schema_defs: dict[str, Any],
) -> dict[str, Any] | None:
"""Flatten the subtypes named by an OpenAPI ``discriminator.mapping``.
A parent schema carrying a discriminator describes its children only
through ``mapping``, so the child fields are unreachable from the parent's
own ``properties``. Rather than emitting a branch per subtype, the fields
are merged in as optional and the variants are spelled out on the
discriminator property's description. Top-level ``oneOf`` is filled in
poorly by LLM tool-calling APIs, and the upstream API remains the real
validator either way: a field from the wrong variant is rejected there
rather than locally.
Only the mapping on *schema* itself is expanded. A subtype carrying its own
discriminator is left alone, which also keeps the parent/child reference
cycle from recursing.
Returns replacement ``properties`` for *schema*, or None when there is no
usable discriminator mapping to flatten.
"""
discriminator = schema.get("discriminator")
if not isinstance(discriminator, dict):
return None
property_name = discriminator.get("propertyName")
mapping = discriminator.get("mapping")
if not isinstance(property_name, str) or not isinstance(mapping, dict):
return None
own_props = schema.get("properties", {})
# Variants that disagree about a property are unioned rather than resolved:
# keeping whichever came first would advertise one variant's constraint
# (a `const` tag, say) while claiming to accept all of them.
alternatives: dict[str, list[Any]] = {}
values: list[str] = []
variants: list[str] = []
for value, target in mapping.items():
if not isinstance(target, str):
continue
name = _discriminator_target_name(target)
subtype = schema_defs.get(name) if name else None
if not isinstance(subtype, dict):
continue
# Fields the parent already declares are shared, not variant-specific.
variant_fields: list[str] = []
for member in _allof_members(subtype, schema_defs):
for prop_name, prop_schema in member.get("properties", {}).items():
if prop_name in own_props:
continue
if prop_name not in variant_fields:
variant_fields.append(prop_name)
seen = alternatives.setdefault(prop_name, [])
if prop_schema not in seen:
seen.append(prop_schema)
values.append(repr(value))
if variant_fields:
variants.append(f"{value!r} uses {', '.join(variant_fields)}")
# Every resolved variant is a legal tag even when it adds no fields of its
# own, so the accepted values are worth advertising on their own.
if not values:
return None
subtype_props = {
prop_name: schemas[0] if len(schemas) == 1 else {"anyOf": schemas}
for prop_name, schemas in alternatives.items()
}
properties = {**own_props, **subtype_props}
note = f"Selects the variant. Accepted values: {', '.join(values)}."
if variants:
note += (
f" {'; '.join(variants)}."
" Send only the fields belonging to the selected variant."
)
# A discriminator names a property of the payload, so give it a schema even
# when the parent left it undeclared — it is otherwise required and unusable.
tag_schema = properties.get(property_name)
if not isinstance(tag_schema, dict):
tag_schema = {"type": "string"}
existing = tag_schema.get("description")
properties[property_name] = {
**tag_schema,
"description": f"{existing} {note}" if existing else note,
}
return properties
def _combine_schemas_and_map_params(
route: HTTPRoute,
convert_refs: bool = True,
@ -329,6 +441,17 @@ def _combine_schemas_and_map_params(
# Remove the allOf since we've merged it
body_schema.pop("allOf", None)
# Merge discriminated subtype fields in as optional. The discriminator
# itself is dropped: its mapping points at definitions that are pruned
# from $defs once nothing references them, which would leave the
# emitted schema with dangling refs.
flattened_props = _flatten_discriminator_subtypes(
body_schema, route.request_schemas
)
if flattened_props is not None:
body_schema["properties"] = flattened_props
body_schema.pop("discriminator", None)
body_props = body_schema.get("properties", {})
# Detect collisions: parameters that exist in multiple non-body locations

View file

@ -0,0 +1,286 @@
"""Tests for OpenAPI discriminator handling in OpenAPIProvider."""
import json
from typing import Any
import httpx2
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
def create_openapi_server(openapi_spec: dict, client) -> FastMCP:
"""Helper to create a FastMCP server with OpenAPIProvider."""
mcp = FastMCP("OpenAPI Server")
mcp.add_provider(OpenAPIProvider(openapi_spec=openapi_spec, client=client))
return mcp
def discriminator_spec(
mapping: dict[str, str] | None = None,
body_ref: str = "Pet",
) -> dict[str, Any]:
"""A parent schema with a discriminator mapping onto two allOf subtypes."""
if mapping is None:
mapping = {
"cat": "#/components/schemas/Cat",
"dog": "#/components/schemas/Dog",
}
return {
"openapi": "3.1.0",
"info": {"title": "Pet API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/pets": {
"post": {
"operationId": "create_pet",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {"$ref": f"#/components/schemas/{body_ref}"}
}
},
},
"responses": {"200": {"description": "Created"}},
}
}
},
"components": {
"schemas": {
"Pet": {
"type": "object",
"properties": {"petType": {"type": "string"}},
"required": ["petType"],
"discriminator": {
"propertyName": "petType",
"mapping": mapping,
},
},
"Cat": {
"allOf": [
{"$ref": "#/components/schemas/Pet"},
{
"type": "object",
"properties": {"meowVolume": {"type": "integer"}},
"required": ["meowVolume"],
},
]
},
"Dog": {
"allOf": [
{"$ref": "#/components/schemas/Pet"},
{
"type": "object",
"properties": {"packSize": {"type": "integer"}},
"required": ["packSize"],
},
]
},
}
},
}
def colliding_variant_spec() -> dict[str, Any]:
"""Subtypes that disagree about the shape of the discriminator property.
The parent marks ``kind`` required without declaring it, so each subtype's
own ``const`` is the only schema available for that field.
"""
return {
"openapi": "3.1.0",
"info": {"title": "Pet API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/pets": {
"post": {
"operationId": "create_pet",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Pet"}
}
},
},
"responses": {"200": {"description": "Created"}},
}
}
},
"components": {
"schemas": {
"Pet": {
"type": "object",
"required": ["kind"],
"discriminator": {
"propertyName": "kind",
"mapping": {"cat": "Cat", "dog": "Dog"},
},
},
"Cat": {
"allOf": [
{"$ref": "#/components/schemas/Pet"},
{
"type": "object",
"properties": {
"kind": {"const": "cat"},
"meowVolume": {"type": "integer"},
},
},
]
},
"Dog": {
"allOf": [
{"$ref": "#/components/schemas/Pet"},
{
"type": "object",
"properties": {
"kind": {"const": "dog"},
"packSize": {"type": "integer"},
},
},
]
},
}
},
}
def propertyless_variant_spec() -> dict[str, Any]:
"""Subtypes that add nothing beyond the parent they compose."""
spec = discriminator_spec()
for name in ("Cat", "Dog"):
spec["components"]["schemas"][name] = {
"allOf": [{"$ref": "#/components/schemas/Pet"}]
}
return spec
async def tool_schema(spec: dict[str, Any]) -> dict[str, Any]:
"""Build the server and return the generated input schema for create_pet."""
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(
lambda request: httpx2.Response(200, json={"ok": True})
),
base_url="https://api.example.com",
) as client:
server = create_openapi_server(spec, client)
async with Client(server) as mcp_client:
tools = await mcp_client.list_tools()
return next(t for t in tools if t.name == "create_pet").input_schema
class TestDiscriminatorRequestBodies:
"""Subtypes named by a discriminator mapping are flattened in as optional."""
async def test_subtype_fields_are_advertised(self):
"""Fields reachable only through discriminator.mapping reach the schema."""
schema = await tool_schema(discriminator_spec())
assert schema["properties"].keys() >= {"petType", "meowVolume", "packSize"}
async def test_subtype_fields_are_optional(self):
"""Only the discriminator is required; variant fields never are."""
schema = await tool_schema(discriminator_spec())
assert schema["required"] == ["petType"]
async def test_discriminator_property_describes_the_variants(self):
"""The discriminator names which fields belong to which variant."""
schema = await tool_schema(discriminator_spec())
description = schema["properties"]["petType"]["description"]
assert "meowVolume" in description
assert "packSize" in description
async def test_discriminator_keyword_is_dropped(self):
"""The mapping points at $defs that get pruned, so it cannot survive."""
schema = await tool_schema(discriminator_spec())
assert "discriminator" not in schema
assert "discriminator" not in schema["properties"]["petType"]
@pytest.mark.parametrize(
"mapping",
[
pytest.param({"cat": "#/components/schemas/Missing"}, id="missing_ref"),
pytest.param({"cat": "Missing"}, id="missing_name"),
pytest.param({"cat": "https://example.com/Cat"}, id="remote_target"),
pytest.param({"cat": "#/definitions/Cat"}, id="unsupported_pointer"),
],
)
async def test_unresolvable_mapping_is_ignored(self, mapping: dict[str, str]):
"""An unusable mapping leaves the parent schema as it was."""
schema = await tool_schema(discriminator_spec(mapping=mapping))
assert set(schema["properties"]) == {"petType"}
async def test_bare_schema_name_mapping_resolves(self):
"""Mapping values may be schema names, not just references."""
schema = await tool_schema(
discriminator_spec(mapping={"cat": "Cat", "dog": "Dog"})
)
assert schema["properties"].keys() >= {"petType", "meowVolume", "packSize"}
async def test_selected_variant_field_reaches_the_request_body(self):
"""The reported failure: meowVolume must reach the upstream API."""
received: dict[str, object] = {}
def handler(request):
received["body"] = json.loads(request.content)
return httpx2.Response(200, json={"ok": True})
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(handler),
base_url="https://api.example.com",
) as client:
server = create_openapi_server(discriminator_spec(), client)
async with Client(server) as mcp_client:
result = await mcp_client.call_tool(
"create_pet", {"petType": "cat", "meowVolume": 11}
)
assert result.structured_content == {"ok": True}
assert received["body"] == {"petType": "cat", "meowVolume": 11}
async def test_accepted_values_are_advertised(self):
"""The legal tags are named even when no variant adds a field."""
schema = await tool_schema(propertyless_variant_spec())
description = schema["properties"]["petType"]["description"]
assert "'cat'" in description
assert "'dog'" in description
async def test_propertyless_variant_is_still_named(self):
"""A variant adding no fields remains a legal discriminator value."""
spec = discriminator_spec()
spec["components"]["schemas"]["Dog"] = {
"allOf": [{"$ref": "#/components/schemas/Pet"}]
}
schema = await tool_schema(spec)
description = schema["properties"]["petType"]["description"]
assert "'dog'" in description
assert "meowVolume" in description
async def test_conflicting_variant_schemas_are_unioned(self):
"""No variant's constraint may be advertised as if it applied to all."""
schema = await tool_schema(colliding_variant_spec())
kind = schema["properties"]["kind"]
assert [alternative.get("const") for alternative in kind["anyOf"]] == [
"cat",
"dog",
]
async def test_subtype_body_is_unaffected(self):
"""A body referencing the child still resolves through allOf only."""
schema = await tool_schema(discriminator_spec(body_ref="Cat"))
assert set(schema["properties"]) == {"petType", "meowVolume"}
assert sorted(schema["required"]) == ["meowVolume", "petType"]