Skip to content

fix(openapi): keep media-type $defs beside component schemas - #5709

Merged
zzstoatzz merged 2 commits into
PrefectHQ:mainfrom
BlueX888:fix/prep-openapi-inline-defs-clobbered
Nov 2, 2026
Merged

zzstoatzz merged 2 commits into
PrefectHQ:mainfrom
BlueX888:fix/prep-openapi-inline-defs-clobbered

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Description

Fixes #5708

An OpenAPI 3.1 media-type schema can declare its own $defs and reference them with #/$defs/.... When components/schemas was non-empty, the generated tool schema dropped those inline definitions: the input schema's $defs came from route.request_schemas alone, and the output schema's $defs were overwritten with the component-derived ones. The media-type schema's #/$defs/... references then dangled, so a tool call failed with jsonschema's PointerToNowhere when the result was validated against the advertised output schema, even though the API returned a valid body.

The media-type schema's own $defs now merge with the component definitions on both the input and the output path.

Why

In fastmcp_slim/fastmcp/utilities/openapi/schemas.py:

  • _combine_schemas_and_map_params assigned schema_defs = route.request_schemas (pre-fix schemas.py:716), never consulting the request body schema's own $defs.
  • extract_output_schema_from_responses assigned output_schema["$defs"] = processed_defs (pre-fix schemas.py:913), replacing whatever $defs the response media-type schema carried.

#/$defs/... references resolve against the schema root, so once the inline definitions are absent from the root $defs, they dangle.

Changes

  • _combine_schemas_and_map_params collects the body schema's own $defs (schemas.py:538) and merges them with route.request_schemas (schemas.py:726). Refs inside those inline definitions are rewritten to #/$defs/... like every other definition.
  • extract_output_schema_from_responses captures the response schema's own $defs before the non-object wrapping hides them one level down (schemas.py:900) and merges them with the component-derived definitions (schemas.py:935); same-named inline definitions win, since the schema's #/$defs/... references are local to it.

Testing

  • uv run pytest tests/utilities/openapi/test_inline_defs.py → on unpatched main: 6 failed (5 × KeyError: 'Extra'; the tool-call test fails with PointerToNowhere: '/$defs/Extra' does not exist ...); with the fix: 6 passed
  • uv run pytest tests/utilities/openapi → 293 passed
  • uv run pytest tests/server/providers/openapi → 470 passed
  • uv run pytest -n auto → 9382 passed, 9 skipped, 16 xfailed; 10 failures in proxy/connection-error tests, each reproduced identically with the changed file reverted to main (pre-existing, unrelated)
  • uv run prek run --all-files → prettier, ruff check, ruff format, ty check, codespell, gitleaks all pass
  • The MRE from from_openapi: a media-type schema's own $defs are dropped from the tool schema when components/schemas is non-empty #5708 now keeps Extra in both schemas' $defs

Contribution type

  • Bug fix (simple, well-scoped fix for a clearly broken behavior)
  • Documentation improvement
  • Enhancement (maintainers typically implement enhancements — see CONTRIBUTING.md)

Checklist

  • This PR addresses an existing issue (or fixes a self-evident bug)
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

An OpenAPI 3.1 media-type schema may declare its own $defs and reference
them with #/$defs/... . When components/schemas was non-empty, the
combined input schema dropped those inline definitions and the output
schema replaced them with the component-derived ones, so the media-type
schema's references dangled and tool calls failed validation with
PointerToNowhere even though the API returned a valid body.

Merge the media-type schema's own $defs with the component definitions
instead: the body schema's $defs join the combined input schema, and the
response schema's $defs survive beside the extracted output schema's
component definitions. Refs inside those inline definitions are
rewritten to #/$defs/... like every other ref.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T16:01:24.564757Z 0d15b2d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

This comment has been minimized.

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. openapi Related to OpenAPI integration, parsing, or code generation features. labels Oct 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d15b2d6ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
# Add schema definitions if available
schema_defs = route.request_schemas
schema_defs = {**route.request_schemas, **body_defs}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve component refs when request $defs reuse a name

When an OpenAPI 3.1 request body declares $defs.Item while a body or non-body parameter references the distinct #/components/schemas/Item, both references are normalized to #/$defs/Item, but this right-biased merge installs only the inline definition. Even an unused inline definition now makes the generated input schema validate the component-backed value against the wrong type, so valid tool calls can be rejected (or invalid payloads admitted); collisions need separate generated names with the corresponding references rewritten.

Useful? React with 👍 / 👎.

output_schema["$defs"] = processed_defs
# Same-named inline $defs win: the schema's #/$defs/... references
# are local to it, not to the component schemas.
output_schema["$defs"] = {**processed_defs, **inline_defs}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve component refs when response $defs reuse a name

When an OpenAPI 3.1 response schema declares $defs.Item but an output property references the distinct #/components/schemas/Item, the component reference has already been normalized to #/$defs/Item, and this merge replaces its processed component definition with the inline one. Merely adding an unused same-named inline definition therefore changes output validation and can reject a valid API response; the two source namespaces must remain distinguishable rather than unconditionally letting the inline entry win.

Useful? React with 👍 / 👎.

@BrickerP BrickerP left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Confirmed the root cause from #5708 and that this fix addresses it.

Root cause. _replace_ref_with_defs rewrites #/components/schemas/X → #/$defs/X but does not walk a media-type schema's own $defs. On main, the combined input schema took $defs only from route.request_schemas, and extract_output_schema_from_responses did output_schema["$defs"] = processed_defs, discarding any inline definitions. Once those are gone, #/$defs/... refs in the advertised tool schema resolve against a root that no longer has them → PointerToNowhere on output validation even when the HTTP body is fine.

Input path. Collecting body_defs from the body schema and merging {**route.request_schemas, **body_defs} is the right shape. Applying _replace_ref_with_defs to each inline def is necessary because the earlier pass on body_schema does not recurse into $defs. Merge order (inline wins) matches how those local #/$defs/... refs were authored. Also correctly handles empty request_schemas + non-empty body $defs, since if schema_defs: still fires.

Output path. Capturing inline_defs before the non-object wrap is important: after wrap, $defs would sit under properties.result while #/$defs/... still resolves at the document root. Merging {**processed_defs, **inline_defs} restores both namespaces at the root for the reported case (non-empty components).

Tests. The new file covers the MRE well: input + output unit paths, ref rewriting inside inline defs, parser/flat_param_schema, and an end-to-end call_tool that would have failed with PointerToNowhere. Green CI across the matrix matches that.

Non-blocking caveats

  1. Output / empty components + wrap. The merge is still gated on if schema_definitions:. If there are no component schemas and the response schema is non-object (so it gets wrapped), inline_defs is captured but never reattached at the root — same dangling-ref failure, different trigger. Input path already handles the empty-components case via {**request_schemas, **body_defs}. Worth a small follow-up: always set root $defs from inline_defs (and processed_defs when present), especially after wrap. Not in the #5708 MRE.

  2. Name collisions. Flat #/$defs/<name> collapses the component and media-type namespaces. Same-named inline vs component defs: inline wins by design here, which is correct for local #/$defs/... refs but can re-point a rewritten #/components/schemas/<same> ref at the wrong definition. Disambiguation (generated names + ref rewrite) would be a larger change; calling it out as a known edge rather than a blocker for this bugfix. No collision coverage in the new tests.

  3. Coverage gaps (optional). Non-object response wrap with inline $defs, and an explicit same-name collision case, would lock in the caveats above.

Looks good to merge for the reported bug. Thanks for the clear repro, the before/after pytest notes, and the scoped change.

@BlueX888

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Caveat 1 is fixed in c24b4f8: extract_output_schema_from_responses now always reattaches the root $defs from processed_defs/inline_defs (the OpenAPI 3.x def conversion stays gated on schema_definitions), so a wrapped non-object response keeps resolving its inline definitions even when no component schemas are present. Added a regression test for that wrap case with empty components, which also covers the testable half of caveat 3. Caveat 2 (same-name collision disambiguation, also raised by the bot's two P1 comments) is deliberately left out of this PR per your read that it is a larger, non-blocking change; happy to follow up separately if maintainers want it.

zzstoatzz added a commit that referenced this pull request Nov 2, 2026
@zzstoatzz
zzstoatzz merged commit ccc8602 into PrefectHQ:main Nov 2, 2026
21 checks passed
zzstoatzz pushed a commit that referenced this pull request Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. openapi Related to OpenAPI integration, parsing, or code generation features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

from_openapi: a media-type schema's own $defs are dropped from the tool schema when components/schemas is non-empty

3 participants