Skip to content

Commit 307f5f6

Browse files
authored
Merge branch 'development' into chore/bump-poetry
2 parents be45991 + 0b30832 commit 307f5f6

File tree

4 files changed

+604
-91
lines changed

4 files changed

+604
-91
lines changed
Lines changed: 64 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Any
22

3+
from fastmcp.utilities.json_schema import dereference_refs
4+
35
from quickapp.config.tools.base import (
46
ConfigurableSchemaArray,
57
ConfigurableSchemaObject,
@@ -13,94 +15,61 @@
1315

1416

1517
class 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)

src/quickapp/mcp_tooling/_mcp_tool_initializer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ def __init__(
7979

8080
@staticmethod
8181
# todo add Title to config so that we could use it in stage name
82-
def _convert_to_openai_tool(name: str, description: str | None, input_schema: dict[str, Any]):
82+
def _convert_to_openai_tool(
83+
name: str, description: str | None, input_schema: dict[str, Any]
84+
) -> OpenAiToolConfig:
8385
return OpenAiToolConfig(
8486
function=OpenAiToolFunction.model_construct( # model_construct to prevent double @model_validator execution for name
8587
name=name,

0 commit comments

Comments
 (0)