Skip to content

Commit ffa58e5

Browse files
authored
Merge pull request #244 from adcontextprotocol/bokelley/mcp-schema-inline-defs
feat(mcp): inline $defs in generated inputSchema (closes #208)
2 parents dd2b961 + 6af9a7d commit ffa58e5

2 files changed

Lines changed: 434 additions & 6 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from __future__ import annotations
2020

21-
import json
21+
import copy
2222
import logging
2323
from collections.abc import Callable, Iterable
2424
from typing import Any
@@ -1033,6 +1033,93 @@ def validate_discovery_set(tools: Iterable[str]) -> None:
10331033
# ============================================================================
10341034

10351035

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+
10361123
def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]:
10371124
"""Generate JSON schemas from Pydantic request models.
10381125
@@ -1207,11 +1294,11 @@ def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]:
12071294
if "anyOf" in schema or "$ref" in schema:
12081295
continue
12091296

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)
12151302

12161303
schemas[tool_name] = schema
12171304
except Exception:

0 commit comments

Comments
 (0)