Skip to content

Fix: resolve root-level $ref in outputSchema for MCP spec compliance#2720

Merged
jlowin merged 1 commit intoPrefectHQ:mainfrom
majiayu000:fix-2455-outputschema-ref-at-root
Dec 25, 2025
Merged

Fix: resolve root-level $ref in outputSchema for MCP spec compliance#2720
jlowin merged 1 commit intoPrefectHQ:mainfrom
majiayu000:fix-2455-outputschema-ref-at-root

Conversation

@majiayu000
Copy link
Copy Markdown
Contributor

Summary

  • Fixes issue where self-referential Pydantic models generate outputSchema with $ref at root level
  • MCP specification requires outputSchema to have "type": "object" at root level
  • Added resolve_root_ref() function to inline referenced definitions while preserving $defs for nested references

Test plan

  • Added unit tests for resolve_root_ref function
  • Added integration tests for self-referential Pydantic models
  • All existing tests pass

Closes #2455

🤖 Generated with Claude Code

MCP specification requires outputSchema to have "type": "object" at root.
Pydantic generates schemas with $ref at root for self-referential models,
which violates this requirement and causes MCP clients to reject tools.

Added resolve_root_ref() to inline referenced definitions at root level
while preserving $defs for nested references.

Closes PrefectHQ#2455

🤖 Generated with Claude Code

Signed-off-by: majiayu000 <1835304752@qq.com>
@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. labels Dec 25, 2025
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Dec 25, 2025

Walkthrough

This PR implements a utility function resolve_root_ref in the JSON schema utilities module that detects and inlines root-level $ref references while preserving nested $defs. The function is then applied in two locations within tool.py—during Tool.from_function and FunctionTool.from_function paths—to post-process output schemas after compression. This ensures root schemas contain a type: object structure with properties directly accessible at the root level, conforming to MCP specification requirements.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix: resolve root-level $ref in outputSchema for MCP spec compliance' is clear, specific, and directly summarizes the main change—resolving root-level $ref to ensure MCP compliance.
Description check ✅ Passed The PR description includes a clear summary, test plan checklist, and closes #2455. It follows the expected format with sufficient detail about the fix and testing approach.
Linked Issues check ✅ Passed The code changes fully address #2455 requirements: resolve_root_ref() function is implemented, integrated into Tool.from_function paths, unit and integration tests are added, and MCP compliance is achieved.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing #2455: adding resolve_root_ref utility and applying it to output schema normalization. No unrelated modifications detected.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 125f79f and 0c203ce.

⛔ Files ignored due to path filters (2)
  • tests/tools/test_tool.py is excluded by none and included by none
  • tests/utilities/test_json_schema.py is excluded by none and included by none
📒 Files selected for processing (2)
  • src/fastmcp/tools/tool.py
  • src/fastmcp/utilities/json_schema.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Use Python ≥ 3.10 with full type annotations
Never use bare except - be specific with exception types

Files:

  • src/fastmcp/tools/tool.py
  • src/fastmcp/utilities/json_schema.py
🧬 Code graph analysis (1)
src/fastmcp/tools/tool.py (1)
src/fastmcp/utilities/json_schema.py (2)
  • compress_schema (240-270)
  • resolve_root_ref (7-43)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Run tests: Python 3.10 on windows-latest
  • GitHub Check: Run tests: Python 3.13 on ubuntu-latest
  • GitHub Check: Run tests: Python 3.10 on ubuntu-latest
  • GitHub Check: Run tests with lowest-direct dependencies
🔇 Additional comments (3)
src/fastmcp/utilities/json_schema.py (1)

4-5: LGTM! Type import added for annotations.

The Any type import properly supports the type annotations in the new resolve_root_ref function.

src/fastmcp/tools/tool.py (2)

36-36: LGTM! Import addition for root reference resolution.

The import of resolve_root_ref is correctly added alongside the existing compress_schema import.


639-641: LGTM! Root reference resolution correctly positioned.

The call to resolve_root_ref is placed exactly where specified in the PR objectives—after compress_schema and before returning the parsed function. This ensures that:

  1. Self-referential Pydantic models have their root-level $ref inlined
  2. The resulting output_schema conforms to MCP spec with type: "object" at the root
  3. Nested $defs are preserved for internal references

The placement ensures downstream validation in FunctionTool.from_function (lines 430-434) receives a fully resolved schema.

Comment on lines +7 to +43
def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve $ref at root level to meet MCP spec requirements.

MCP specification requires outputSchema to have "type": "object" at the root level.
When Pydantic generates schemas for self-referential models, it uses $ref at the
root level pointing to $defs. This function resolves such references by inlining
the referenced definition while preserving $defs for nested references.

Args:
schema: JSON schema dict that may have $ref at root level

Returns:
A new schema dict with root-level $ref resolved, or the original schema
if no resolution is needed

Example:
>>> schema = {
... "$defs": {"Node": {"type": "object", "properties": {...}}},
... "$ref": "#/$defs/Node"
... }
>>> resolved = resolve_root_ref(schema)
>>> # Result: {"type": "object", "properties": {...}, "$defs": {...}}
"""
# Only resolve if we have $ref at root level with $defs but no explicit type
if "$ref" in schema and "$defs" in schema and "type" not in schema:
ref = schema["$ref"]
# Only handle local $defs references
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.split("/")[-1]
defs = schema["$defs"]
if def_name in defs:
# Create a new schema by copying the referenced definition
resolved = dict(defs[def_name])
# Preserve $defs for nested references (other fields may still use them)
resolved["$defs"] = defs
return resolved
return schema
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Step 1: Find _is_object_schema function and its implementation
rg -nP -A10 'def _is_object_schema' --type=py

# Step 2: Find where output schemas are validated
rg -nP -C5 'Output schemas must represent object types' --type=py

# Step 3: Find external $ref patterns in tests
rg -nP '\$ref.*http' --type=py

Repository: jlowin/fastmcp

Length of output: 1658


🏁 Script executed:

#!/bin/bash
# Step 4: Find FunctionTool.from_function to understand where validation happens
rg -nP -B5 -A15 'class FunctionTool' --type=py | head -100

# Step 5: Find all usages of resolve_root_ref
rg -nP 'resolve_root_ref' --type=py

# Step 6: Check for validation of non-local references
rg -nP 'startswith.*http|external.*ref' --type=py

Repository: jlowin/fastmcp

Length of output: 3241


🏁 Script executed:

#!/bin/bash
# Step 7: Get the full context of resolve_root_ref function usage
cat -n src/fastmcp/utilities/json_schema.py | head -60

Repository: jlowin/fastmcp

Length of output: 2834


🏁 Script executed:

#!/bin/bash
# Step 8: Look for validation tests related to JSON schema
fd -e py -path '*/test*' | xargs grep -l 'resolve_root_ref\|_is_object_schema' 2>/dev/null || echo "No test files found with these functions"

Repository: jlowin/fastmcp

Length of output: 284


🏁 Script executed:

#!/bin/bash
# Get the full _is_object_schema function
rg -nP -A20 'def _is_object_schema' src/fastmcp/tools/tool.py

Repository: jlowin/fastmcp

Length of output: 769


🏁 Script executed:

#!/bin/bash
# Get context around line 641 where resolve_root_ref is called
sed -n '630,650p' src/fastmcp/tools/tool.py

Repository: jlowin/fastmcp

Length of output: 1043


🏁 Script executed:

#!/bin/bash
# Understand the full validation context in from_function
sed -n '420,445p' src/fastmcp/tools/tool.py

Repository: jlowin/fastmcp

Length of output: 1224


🏁 Script executed:

#!/bin/bash
# Check if there are any tests or other validation for non-local $ref
rg -nP 'http.*\$ref|external.*ref|non.*local' --type=py src/

Repository: jlowin/fastmcp

Length of output: 1988


🏁 Script executed:

#!/bin/bash
# Look for any other schema validation beyond _is_object_schema
rg -nP 'validate.*schema|schema.*validat' --type=py src/fastmcp/tools/tool.py | head -20

Repository: jlowin/fastmcp

Length of output: 40


Non-local $ref references bypass validation, violating MCP spec requirements.

The implementation correctly handles local #/$defs/ references. However, _is_object_schema (line 509 in tool.py) returns True for ANY schema with both "$ref" and "$defs" present, regardless of whether the reference is local or external. When resolve_root_ref returns an unresolved schema (lines 34-43), the validation check passes even though the schema still has an unresolved $ref at root instead of the required "type": "object".

Schemas with non-local $ref (external URLs or invalid pointers) should either:

  1. Be explicitly rejected in resolve_root_ref with a clear error message, or
  2. Have _is_object_schema validate that any $ref was actually resolved before accepting it

Without this safeguard, downstream code may receive schemas that violate the MCP spec requirement for "type": "object" at the root level.

@majiayu000
Copy link
Copy Markdown
Contributor Author

The Windows test failure appears to be a flaky test issue unrelated to this PR's changes. The failing tests (test_oidc_proxy.py and test_azure.py) crashed due to pytest-xdist worker issues, not due to any changes in this PR which only modifies json_schema.py and tool.py.

Could you please re-run the CI or confirm if this is a known flaky test?

Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

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

Thanks for fixing!

@jlowin jlowin merged commit 8d80299 into PrefectHQ:main Dec 25, 2025
16 of 17 checks passed
jlowin added a commit that referenced this pull request Dec 25, 2025
@majiayu000
Copy link
Copy Markdown
Contributor Author

Happy to help!

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. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

outputSchema with $ref at root violates MCP spec requirement for type: object

2 participants