Skip to content

Commit f2dc3f8

Browse files
authored
feat: refactor JsonSchemaConverter to use dereference function from fastmcp (#158)
1 parent 67bf874 commit f2dc3f8

File tree

4 files changed

+450
-83
lines changed

4 files changed

+450
-83
lines changed
Lines changed: 28 additions & 80 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,56 @@
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], name: str | None = None
8451
) -> _ConfigurableSchema:
8552
"""
8653
Build and return a ConfigurableSchema* instance from a single property/items definition.
8754
This centralizes the handling for simple types, objects and arrays (including nested arrays).
8855
"""
89-
# If there's a $ref, resolve it against root_schema (if provided).
9056
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
57+
raise ValueError(
58+
f"Unresolved $ref '{def_dict['$ref']}' in property {name!r}. "
59+
"Schema must be dereferenced before conversion."
60+
)
9761

9862
# Handle anyOf / oneOf by selecting a representative variant
9963
if "anyOf" in def_dict or "oneOf" in def_dict:
10064
variants = def_dict.get("anyOf") or def_dict.get("oneOf")
10165
if not isinstance(variants, list) or not variants:
10266
raise ValueError("anyOf/oneOf must be a non-empty list")
103-
picked = JsonSchemaConverter._pick_variant_from_anyof(variants, root_schema or {})
67+
picked = JsonSchemaConverter._pick_variant_from_anyof(variants)
10468
# Merge picked with def_dict to preserve top-level default/description etc.
10569
merged = {
10670
**picked,
@@ -109,7 +73,7 @@ def _build_schema_from_definition(
10973
def_dict = merged
11074

11175
raw_type = def_dict.get("type")
112-
prop_type, is_nullable = JsonSchemaConverter._normalize_type(raw_type)
76+
prop_type = JsonSchemaConverter._normalize_type(raw_type)
11377

11478
description = def_dict.get("description")
11579
default_value = def_dict.get("default")
@@ -123,19 +87,14 @@ def _build_schema_from_definition(
12387
prop_type = "string"
12488

12589
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.
12890
return ConfigurableSchemaSimpleType(
12991
type=getattr(JsonTypeEnum, prop_type),
13092
description=description,
13193
enum=enum_values,
13294
default=default_value,
13395
)
13496
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-
)
97+
nested_properties = JsonSchemaConverter.convert_schema_to_properties(def_dict)
13998
return ConfigurableSchemaObject(
14099
type=JsonTypeEnum.object,
141100
properties=nested_properties,
@@ -145,10 +104,7 @@ def _build_schema_from_definition(
145104
)
146105
elif prop_type == "array":
147106
items_def = def_dict.get("items", {})
148-
# recursively build items schema (handles array of arrays, objects, simple types)
149-
items_schema = JsonSchemaConverter._build_schema_from_definition(
150-
items_def, name=name, root_schema=root_schema
151-
)
107+
items_schema = JsonSchemaConverter._build_schema_from_definition(items_def, name=name)
152108
return ConfigurableSchemaArray(
153109
type=JsonTypeEnum.array,
154110
items=items_schema,
@@ -160,46 +116,38 @@ def _build_schema_from_definition(
160116

161117
@staticmethod
162118
def convert_schema_to_properties(
163-
schema_dict: dict[str, Any], root_schema: dict[str, Any] | None = None
119+
schema_dict: dict[str, Any],
164120
) -> dict[str, _ConfigurableSchema]:
165121
"""
166122
Convert a JSON schema dictionary to ConfigurableSchema* properties.
167123
124+
Resolves any $ref pointers via dereference_refs() before conversion.
125+
168126
Args:
169127
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.
172128
173129
Returns:
174130
Dictionary of converted properties compatible with OpenAiToolFunctionParameters
175131
"""
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"}}
132+
schema_dict = dereference_refs(schema_dict)
186133

187134
# Handle top-level anyOf/oneOf
188135
if "anyOf" in schema_dict or "oneOf" in schema_dict:
189136
variants = schema_dict.get("anyOf") or schema_dict.get("oneOf")
190137
if not isinstance(variants, list) or not variants:
191138
raise ValueError("anyOf/oneOf must be a non-empty list")
192-
picked = JsonSchemaConverter._pick_variant_from_anyof(variants, original_root)
139+
picked = JsonSchemaConverter._pick_variant_from_anyof(variants)
193140
schema_dict = {
194141
**picked,
195142
**{k: v for k, v in schema_dict.items() if k not in ("anyOf", "oneOf")},
196143
}
197144

198145
schema_properties = schema_dict.get("properties", {})
199146

147+
properties: dict[str, _ConfigurableSchema] = {}
200148
for prop_name, prop_def in schema_properties.items():
201149
properties[prop_name] = JsonSchemaConverter._build_schema_from_definition(
202-
prop_def, name=prop_name, root_schema=original_root
150+
prop_def, name=prop_name
203151
)
204152

205153
return properties

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)