11from typing import Any
22
3+ from fastmcp .utilities .json_schema import dereference_refs
4+
35from quickapp .config .tools .base import (
46 ConfigurableSchemaArray ,
57 ConfigurableSchemaObject ,
1315
1416
1517class JsonSchemaConverter :
16- """Utility class for converting JSON schema dictionaries to ConfigurableSchema objects."""
18+ """Utility class for converting JSON schema dictionaries to ConfigurableSchema objects.
19+
20+ Handles $ref resolution internally via fastmcp.utilities.json_schema.dereference_refs().
21+ """
1722
1823 @staticmethod
19- def _normalize_type (type_field : str | list [str ] | None ) -> tuple [ str | None , bool ] :
24+ def _normalize_type (type_field : str | list [str ] | None ) -> str | None :
2025 """
2126 Normalize the 'type' field which can be a str or a list (e.g. ['string', 'null']).
22- Returns (primary_type_or_None, is_nullable) .
27+ Returns the primary non-null type, or None if no non-null type is present .
2328 """
2429 if isinstance (type_field , list ):
25- is_nullable = 'null' in type_field
2630 non_null = [t for t in type_field if t != 'null' ]
27- primary = non_null [0 ] if non_null else None
28- return primary , is_nullable
29- return (type_field , False ) if type_field is not None else (None , False )
30-
31- @staticmethod
32- def _resolve_ref (ref : str , root_schema : dict [str , Any ]) -> dict [str , Any ]:
33- """
34- Resolve an internal JSON Pointer ref like '#/$defs/Name' or '#/definitions/Name'
35- by traversing the root_schema. Returns the referenced dictionary.
36- """
37- if not ref .startswith ('#/' ):
38- raise ValueError (f"Only local refs are supported in this converter: { ref !r} " )
39- pointer = ref [2 :].split ('/' ) # remove leading '#/'
40- node : Any = root_schema
41- for token in pointer :
42- # JSON Pointer uses ~1 for '/' and ~0 for '~' (not expected here but handle minimally)
43- token = token .replace ('~1' , '/' ).replace ('~0' , '~' )
44- if isinstance (node , dict ) and token in node :
45- node = node [token ]
46- else :
47- raise KeyError (f"Could not resolve ref { ref !r} at token { token !r} " )
48- if not isinstance (node , dict ):
49- raise ValueError (f"Referenced ref { ref !r} does not point to an object" )
50- return node
31+ return non_null [0 ] if non_null else None
32+ return type_field
5133
5234 @staticmethod
53- def _pick_variant_from_anyof (
54- variants : list [dict [str , Any ]], root_schema : dict [str , Any ]
55- ) -> dict [str , Any ]:
35+ def _pick_variant_from_anyof (variants : list [dict [str , Any ]]) -> dict [str , Any ]:
5636 """
5737 Pick the most relevant variant from anyOf/oneOf:
5838 - prefer a non-null typed variant
59- - resolve $ref variants
6039 - fallback to the first variant
6140 """
6241 for v in variants :
6342 if v .get ("type" ) == "null" :
6443 continue
65- # if it's a ref, try to resolve and return resolved (prefer resolved non-null)
66- if "$ref" in v :
67- try :
68- resolved = JsonSchemaConverter ._resolve_ref (v ["$ref" ], root_schema )
69- # if resolved is not null, return it
70- if resolved .get ("type" ) != "null" :
71- return {** resolved , ** {k : vv for k , vv in v .items () if k != "$ref" }}
72- except Exception :
73- # fall through to treat v as-is
74- return v
75- # otherwise return the first non-null typed variant
76- if v .get ("type" ) and v .get ("type" ) != "null" :
44+ if v .get ("type" ):
7745 return v
78- # fallback: return first variant (could be null)
7946 return variants [0 ]
8047
8148 @staticmethod
8249 def _build_schema_from_definition (
83- def_dict : dict [str , Any ], name : str | None = None , root_schema : dict [str , Any ] | None = None
50+ def_dict : dict [str , Any ],
51+ name : str | None = None ,
52+ seen : set [int ] | None = None ,
8453 ) -> _ConfigurableSchema :
8554 """
8655 Build and return a ConfigurableSchema* instance from a single property/items definition.
8756 This centralizes the handling for simple types, objects and arrays (including nested arrays).
57+
58+ ``seen`` tracks ``id()`` of dicts already on the call stack to break
59+ circular Python references produced by ``dereference_refs``.
8860 """
89- # If there's a $ref, resolve it against root_schema (if provided).
9061 if "$ref" in def_dict :
91- if root_schema is None :
92- raise ValueError ("Root schema is required to resolve $ref" )
93- resolved = JsonSchemaConverter ._resolve_ref (def_dict ["$ref" ], root_schema )
94- # Merge resolved with local overrides (local keys take precedence)
95- merged = {** resolved , ** {k : v for k , v in def_dict .items () if k != "$ref" }}
96- def_dict = merged
62+ raise ValueError (
63+ f"Unresolved $ref '{ def_dict ['$ref' ]} ' in property { name !r} . "
64+ "Schema must be dereferenced before conversion."
65+ )
9766
9867 # Handle anyOf / oneOf by selecting a representative variant
9968 if "anyOf" in def_dict or "oneOf" in def_dict :
10069 variants = def_dict .get ("anyOf" ) or def_dict .get ("oneOf" )
10170 if not isinstance (variants , list ) or not variants :
10271 raise ValueError ("anyOf/oneOf must be a non-empty list" )
103- picked = JsonSchemaConverter ._pick_variant_from_anyof (variants , root_schema or {} )
72+ picked = JsonSchemaConverter ._pick_variant_from_anyof (variants )
10473 # Merge picked with def_dict to preserve top-level default/description etc.
10574 merged = {
10675 ** picked ,
@@ -109,7 +78,7 @@ def _build_schema_from_definition(
10978 def_dict = merged
11079
11180 raw_type = def_dict .get ("type" )
112- prop_type , is_nullable = JsonSchemaConverter ._normalize_type (raw_type )
81+ prop_type = JsonSchemaConverter ._normalize_type (raw_type )
11382
11483 description = def_dict .get ("description" )
11584 default_value = def_dict .get ("default" )
@@ -123,19 +92,14 @@ def _build_schema_from_definition(
12392 prop_type = "string"
12493
12594 if prop_type in ["string" , "number" , "integer" , "boolean" ]:
126- # ConfigurableSchemaSimpleType doesn't track nullable explicitly;
127- # default_value may already be None when schema allowed null.
12895 return ConfigurableSchemaSimpleType (
12996 type = getattr (JsonTypeEnum , prop_type ),
13097 description = description ,
13198 enum = enum_values ,
13299 default = default_value ,
133100 )
134101 elif prop_type == "object" :
135- # For nested objects, pass the original root_schema so nested refs can be resolved
136- nested_properties = JsonSchemaConverter .convert_schema_to_properties (
137- def_dict , root_schema = root_schema or def_dict
138- )
102+ nested_properties = JsonSchemaConverter ._convert_properties (def_dict , seen )
139103 return ConfigurableSchemaObject (
140104 type = JsonTypeEnum .object ,
141105 properties = nested_properties ,
@@ -145,9 +109,8 @@ def _build_schema_from_definition(
145109 )
146110 elif prop_type == "array" :
147111 items_def = def_dict .get ("items" , {})
148- # recursively build items schema (handles array of arrays, objects, simple types)
149112 items_schema = JsonSchemaConverter ._build_schema_from_definition (
150- items_def , name = name , root_schema = root_schema
113+ items_def , name = name , seen = seen
151114 )
152115 return ConfigurableSchemaArray (
153116 type = JsonTypeEnum .array ,
@@ -159,47 +122,60 @@ def _build_schema_from_definition(
159122 raise ValueError (f"Unsupported property type: { prop_type !r} for property { name !r} " )
160123
161124 @staticmethod
162- def convert_schema_to_properties (
163- schema_dict : dict [str , Any ], root_schema : dict [str , Any ] | None = None
125+ def _convert_properties (
126+ schema_dict : dict [str , Any ],
127+ seen : set [int ] | None = None ,
164128 ) -> dict [str , _ConfigurableSchema ]:
165- """
166- Convert a JSON schema dictionary to ConfigurableSchema* properties.
167-
168- Args:
169- schema_dict: The JSON schema dictionary containing properties definition
170- root_schema: optional root schema used to resolve internal $ref pointers.
171- If not provided, schema_dict is used as root.
172-
173- Returns:
174- Dictionary of converted properties compatible with OpenAiToolFunctionParameters
175- """
176- properties : dict [str , _ConfigurableSchema ] = {}
177-
178- # Preserve the original root for resolving internal refs
179- original_root = root_schema or schema_dict
180-
181- # Resolve top-level $ref if present
182- if "$ref" in schema_dict :
183- resolved = JsonSchemaConverter ._resolve_ref (schema_dict ["$ref" ], original_root )
184- # Merge resolved with local overrides (local keys take precedence)
185- schema_dict = {** resolved , ** {k : v for k , v in schema_dict .items () if k != "$ref" }}
129+ """Internal recursive conversion with cycle detection (no ref resolution)."""
130+ if seen is None :
131+ seen = set ()
186132
187133 # Handle top-level anyOf/oneOf
188134 if "anyOf" in schema_dict or "oneOf" in schema_dict :
189135 variants = schema_dict .get ("anyOf" ) or schema_dict .get ("oneOf" )
190136 if not isinstance (variants , list ) or not variants :
191137 raise ValueError ("anyOf/oneOf must be a non-empty list" )
192- picked = JsonSchemaConverter ._pick_variant_from_anyof (variants , original_root )
138+ picked = JsonSchemaConverter ._pick_variant_from_anyof (variants )
193139 schema_dict = {
194140 ** picked ,
195141 ** {k : v for k , v in schema_dict .items () if k not in ("anyOf" , "oneOf" )},
196142 }
197143
198144 schema_properties = schema_dict .get ("properties" , {})
199145
146+ properties : dict [str , _ConfigurableSchema ] = {}
200147 for prop_name , prop_def in schema_properties .items ():
148+ dict_id = id (prop_def )
149+ if dict_id in seen :
150+ # Circular reference — represent as an opaque object
151+ properties [prop_name ] = ConfigurableSchemaObject (
152+ type = JsonTypeEnum .object ,
153+ properties = {},
154+ description = prop_def .get ("description" , "" ),
155+ )
156+ continue
157+ seen .add (dict_id )
201158 properties [prop_name ] = JsonSchemaConverter ._build_schema_from_definition (
202- prop_def , name = prop_name , root_schema = original_root
159+ prop_def , name = prop_name , seen = seen
203160 )
204161
205162 return properties
163+
164+ @staticmethod
165+ def convert_schema_to_properties (
166+ schema_dict : dict [str , Any ],
167+ ) -> dict [str , _ConfigurableSchema ]:
168+ """
169+ Convert a JSON schema dictionary to ConfigurableSchema* properties.
170+
171+ Resolves any $ref pointers via dereference_refs() before conversion.
172+
173+ Args:
174+ schema_dict: The JSON schema dictionary containing properties definition
175+
176+ Returns:
177+ Dictionary of converted properties compatible with OpenAiToolFunctionParameters
178+ """
179+ schema_dict = dereference_refs (schema_dict )
180+
181+ return JsonSchemaConverter ._convert_properties (schema_dict )
0 commit comments