|
18 | 18 |
|
19 | 19 | from __future__ import annotations |
20 | 20 |
|
21 | | -import json |
| 21 | +import copy |
22 | 22 | import logging |
23 | 23 | from collections.abc import Callable, Iterable |
24 | 24 | from typing import Any |
@@ -1033,6 +1033,93 @@ def validate_discovery_set(tools: Iterable[str]) -> None: |
1033 | 1033 | # ============================================================================ |
1034 | 1034 |
|
1035 | 1035 |
|
| 1036 | +def _inline_refs(schema: dict[str, Any]) -> dict[str, Any]: |
| 1037 | + """Resolve every local ``$ref`` into the referenced ``$defs`` body. |
| 1038 | +
|
| 1039 | + Pydantic emits nested models as ``{"$ref": "#/$defs/Name"}`` with the |
| 1040 | + actual shape under ``$defs``. That's spec-valid JSON Schema, but the |
| 1041 | + MCP client ecosystem is mixed — several popular consumers (including |
| 1042 | + some of the cheaper agent runtimes we see in validation runs) don't |
| 1043 | + implement ``$ref`` resolution. Tool discovery that looks correct in |
| 1044 | + MCP Inspector shows up as ``{}`` to those clients, producing silent |
| 1045 | + "this tool takes no params" confusion. |
| 1046 | +
|
| 1047 | + The inliner walks the schema tree and replaces each ``$ref`` with a |
| 1048 | + deep copy of the referenced definition. Sibling keys on the ``$ref`` |
| 1049 | + node (``description``, ``title``) are merged on top of the resolved |
| 1050 | + body. Note: this is an annotation-level override that matches what |
| 1051 | + Pydantic actually emits at reference sites — it is NOT spec §8.2 |
| 1052 | + merge semantics (which would evaluate siblings as an implicit |
| 1053 | + ``allOf``). If a future Pydantic version starts emitting |
| 1054 | + assertion-level siblings (``type``, ``enum``, etc.) the merge |
| 1055 | + would silently change validation; today it doesn't. |
| 1056 | +
|
| 1057 | + Only handles local refs (``#/$defs/X``). External refs are left in |
| 1058 | + place — Pydantic doesn't emit them for our request models, but if |
| 1059 | + one ever appears it surfaces to the caller rather than being |
| 1060 | + silently stripped. |
| 1061 | +
|
| 1062 | + Cycles are protected by a ``seen`` set threaded through recursion. |
| 1063 | + Pydantic request models don't generate cyclic refs today; the guard |
| 1064 | + exists so a future schema shape can't turn inlining into a |
| 1065 | + RecursionError. When the walk leaves at least one ``$ref`` |
| 1066 | + unresolved (cycle or dangling), ``$defs`` is kept in place so a |
| 1067 | + spec-compliant client can still resolve what we couldn't. |
| 1068 | + """ |
| 1069 | + defs = schema.get("$defs", {}) |
| 1070 | + # Track whether we emitted any $ref in the output — tells the |
| 1071 | + # caller whether it's safe to drop $defs. Avoids a |
| 1072 | + # stringify-the-whole-tree scan post-walk, and sidesteps false |
| 1073 | + # positives from legitimate ``"$ref"`` values inside enum / const |
| 1074 | + # / description strings. |
| 1075 | + unresolved = [False] |
| 1076 | + |
| 1077 | + def _resolve(node: Any, seen: frozenset[str]) -> Any: |
| 1078 | + if isinstance(node, dict): |
| 1079 | + ref = node.get("$ref") |
| 1080 | + if isinstance(ref, str): |
| 1081 | + if not ref.startswith("#/$defs/"): |
| 1082 | + # External ref (http://…, relative path). Pydantic |
| 1083 | + # doesn't emit these for our request models; leave |
| 1084 | + # untouched rather than risk silent corruption. |
| 1085 | + unresolved[0] = True |
| 1086 | + return {k: _resolve(v, seen) for k, v in node.items()} |
| 1087 | + def_name = ref[len("#/$defs/") :] |
| 1088 | + if def_name in seen: |
| 1089 | + # Cycle — leave the $ref intact so a spec-compliant |
| 1090 | + # client can still resolve via $defs. |
| 1091 | + unresolved[0] = True |
| 1092 | + return {k: _resolve(v, seen) for k, v in node.items()} |
| 1093 | + body = defs.get(def_name) |
| 1094 | + if body is None: |
| 1095 | + # Dangling ref — nothing in $defs matches. Leave |
| 1096 | + # the $ref for consumers to error on; preserving |
| 1097 | + # the shape is safer than silently stripping. |
| 1098 | + unresolved[0] = True |
| 1099 | + return {k: _resolve(v, seen) for k, v in node.items()} |
| 1100 | + resolved = _resolve(copy.deepcopy(body), seen | {def_name}) |
| 1101 | + # Annotation-level merge — sibling description/title |
| 1102 | + # on the $ref node wins over the resolved body's |
| 1103 | + # same-named keys. |
| 1104 | + merged = dict(resolved) if isinstance(resolved, dict) else resolved |
| 1105 | + if isinstance(merged, dict): |
| 1106 | + for k, v in node.items(): |
| 1107 | + if k == "$ref": |
| 1108 | + continue |
| 1109 | + merged[k] = _resolve(v, seen) |
| 1110 | + return merged |
| 1111 | + return {k: _resolve(v, seen) for k, v in node.items()} |
| 1112 | + if isinstance(node, list): |
| 1113 | + return [_resolve(item, seen) for item in node] |
| 1114 | + return node |
| 1115 | + |
| 1116 | + result = _resolve(schema, frozenset()) |
| 1117 | + if isinstance(result, dict) and not unresolved[0]: |
| 1118 | + result.pop("$defs", None) |
| 1119 | + assert isinstance(result, dict) |
| 1120 | + return result |
| 1121 | + |
| 1122 | + |
1036 | 1123 | def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]: |
1037 | 1124 | """Generate JSON schemas from Pydantic request models. |
1038 | 1125 |
|
@@ -1207,11 +1294,11 @@ def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]: |
1207 | 1294 | if "anyOf" in schema or "$ref" in schema: |
1208 | 1295 | continue |
1209 | 1296 |
|
1210 | | - # Only strip $defs if no $ref references exist in the schema. |
1211 | | - # If nested properties use $ref, keep $defs so references resolve. |
1212 | | - schema_str = json.dumps(schema) |
1213 | | - if '"$ref"' not in schema_str: |
1214 | | - schema.pop("$defs", None) |
| 1297 | + # Inline every $ref into its $defs body so MCP clients that |
| 1298 | + # don't resolve JSON-Schema references (a surprisingly large |
| 1299 | + # slice of the ecosystem) still see the full tool surface. |
| 1300 | + # Spec-wise the schema is equivalent — just flat. |
| 1301 | + schema = _inline_refs(schema) |
1215 | 1302 |
|
1216 | 1303 | schemas[tool_name] = schema |
1217 | 1304 | except Exception: |
|
0 commit comments