Skip to content

fix: handle circular JSON Pointer $ref in dereference_refs#3896

Open
lawrence3699 wants to merge 1 commit intoPrefectHQ:mainfrom
lawrence3699:fix/circular-json-pointer-ref-crash
Open

fix: handle circular JSON Pointer $ref in dereference_refs#3896
lawrence3699 wants to merge 1 commit intoPrefectHQ:mainfrom
lawrence3699:fix/circular-json-pointer-ref-crash

Conversation

@lawrence3699
Copy link
Copy Markdown

Closes #3893.

dereference_refs crashes with RecursionError when schemas contain circular $ref using JSON Pointer paths (e.g. #/properties/nodes/items) instead of $defs-based paths. This is the format emitted by .NET/C# MCP servers using System.Text.Json. The existing _defs_have_cycles check only detects cycles within $defs, so pointer-style cycles reach jsonref.replace_refs uncaught.

dereference_refs({
    "type": "object",
    "properties": {
        "nodes": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "value": {"type": "string"},
                    "children": {
                        "type": "array",
                        "items": {"$ref": "#/properties/nodes/items"},
                    },
                },
            },
        },
    },
})
# Before: RecursionError
# After:  returns schema with circular $ref preserved (same fallback as $defs cycles)

The fix catches RecursionError alongside JsonRefError and falls back to resolve_root_ref, matching the existing behavior for $defs-based circular schemas. A regression test verifies the crash is gone and the circular $ref is preserved in the output.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 13, 2026 09:17
@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 Apr 13, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a crash in dereference_refs when encountering circular $ref expressed as JSON Pointer paths (common in .NET System.Text.Json-generated schemas), aligning behavior with the existing fallback used for $defs-based cycles.

Changes:

  • Catch RecursionError during jsonref.replace_refs and fall back to resolve_root_ref() instead of crashing.
  • Add a regression test covering circular JSON Pointer $ref (non-$defs) to ensure dereference_refs doesn’t raise and preserves the circular reference.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/fastmcp/utilities/json_schema.py Expands exception handling to include RecursionError and triggers the same fallback behavior used for circular $defs schemas.
tests/utilities/test_json_schema.py Adds a regression test reproducing the JSON Pointer circular $ref case seen from .NET schema generators.

Comment on lines +149 to +153
# The circular $ref should be preserved (not inlined)
children_items = result["properties"]["nodes"]["items"]["properties"][
"children"
]["items"]
assert "$ref" in children_items
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The regression test only asserts that a $ref key exists in children_items, but it doesn’t verify that the pointer-style circular reference is preserved exactly (e.g. that the value remains #/properties/nodes/items). Strengthen the assertion to check the $ref value so the test will fail if dereferencing rewrites or partially inlines the reference.

Suggested change
# The circular $ref should be preserved (not inlined)
children_items = result["properties"]["nodes"]["items"]["properties"][
"children"
]["items"]
assert "$ref" in children_items
# The circular $ref should be preserved exactly (not inlined or rewritten)
children_items = result["properties"]["nodes"]["items"]["properties"][
"children"
]["items"]
assert children_items["$ref"] == "#/properties/nodes/items"

Copilot uses AI. Check for mistakes.
Comment on lines +175 to 180
# Self-referencing/circular schemas can't be fully dereferenced.
# RecursionError covers circular $ref using JSON Pointer paths
# (e.g. "#/properties/nodes/items") that bypass $defs-based cycle
# detection — common in schemas from .NET/System.Text.Json.
# Fall back to resolving only root-level $ref (for MCP spec compliance)
return resolve_root_ref(schema)
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The exception-block comment says the fallback “resolv[es] only root-level $ref”, but resolve_root_ref() is a no-op when there is no root $ref (as in the JSON Pointer cycle case). Consider rewording this comment to reflect the actual behavior: fall back to resolve_root_ref() (which may leave the schema unchanged) rather than implying root refs will always be resolved.

Copilot uses AI. Check for mistakes.
@jlowin
Copy link
Copy Markdown
Member

jlowin commented Apr 13, 2026

Thanks @lawrence3699 — clean diagnosis and nicely scoped fix. Root cause is right: _defs_have_cycles only walks $defs-based references, so a JSON Pointer cycle like #/properties/nodes/items bypasses the proactive check and reaches jsonref.replace_refs, which recurses until Python's stack limit raises RecursionError rather than JsonRefError. Adding RecursionError to the existing exception tuple and reusing the same resolve_root_ref fallback matches the behavior path for $defs-style cycles — surgical and consistent.

One small ask before merging — Copilot's first inline suggestion is worth taking: the regression test currently asserts "$ref" in children_items, but doesn't verify the pointer value is preserved verbatim. Strengthening it to:

assert children_items["$ref"] == "#/properties/nodes/items"

catches any future regression where we might accidentally rewrite the pointer during fallback. One-line change, strictly better.

Optional (feel free to skip) — the linked issue notes that .NET/System.Text.Json also emits JSON Pointer $ref for shared non-circular types. A second test locking in that non-circular pointer $ref still dereferences correctly through replace_refs would guard against future regressions on that path. Not a blocker, just a nice-to-have.

Copilot's second comment (about the existing "resolving only root-level $ref" wording) is a fair observation but it's about a pre-existing comment, not something this PR introduced — out of scope here.

Once the test assertion is tightened I'll get this merged.

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.

See my comment above — one small ask: strengthen the regression test assertion to check the pointer value is preserved verbatim (assert children_items["$ref"] == "#/properties/nodes/items"). Optional second test for non-circular JSON Pointer $ref would be nice but not blocking.

The core fix is correct and merge-ready once the assertion is tightened.

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.

DereferenceRefsMiddleware crashes with RecursionError on JSON Pointer style $ref (non-$defs)

3 participants