Skip to content

Commit 9972c21

Browse files
authored
Replace RootModel wrappers with type aliases and TypeAdapter validation (#3470)
1 parent fd66270 commit 9972c21

11 files changed

Lines changed: 872 additions & 1127 deletions

File tree

docs/servers/structured-output.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ result.structured_content # {"London": 16.2, "Reykjavik": 4.4}
169169

170170
The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper.
171171

172+
Dictionary results use Pydantic's `TypeAdapter` for validation and serialization. If you inspect a tool's `FuncMetadata.output_model`, it holds the dictionary type annotation with its schema title.
173+
172174
## Validation
173175

174176
`output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server.

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ ignore = ["PERF203"]
219219

220220
[tool.ruff.lint.flake8-tidy-imports.banned-api]
221221
"pydantic.RootModel".msg = "Use `pydantic.TypeAdapter` instead."
222+
"pydantic.root_model.RootModel".msg = "Use `pydantic.TypeAdapter` instead."
222223

223224

224225
[tool.ruff.lint.mccabe]
@@ -228,8 +229,8 @@ max-complexity = 24 # Default is 10
228229
"__init__.py" = ["F401"]
229230
# The mcp.types package is an alias that mirrors mcp_types namespaces by design.
230231
"src/mcp/types/*.py" = ["F403"]
231-
# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators).
232-
"src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"]
232+
# Generated by scripts/gen_surface_types.py.
233+
"src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "UP007", "UP037"]
233234
"tests/server/mcpserver/test_func_metadata.py" = ["E501"]
234235
# Inline snapshots of the translation tool's output carry long status/prompt lines verbatim.
235236
"tests/docs/test_translations.py" = ["E501"]

scripts/gen_surface_types.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
underscore marks these as internal validators, not public API) with only
66
the fixes the raw output needs: a small JSON pre-patch for the known
77
`number`-as-`integer` schema.json defect, a header, full URLs for the spec's
8-
site-absolute doc links, and per-version epilogue aliases. Run with
8+
site-absolute doc links, plain type aliases, and per-version epilogue aliases. Run with
99
`uv run --frozen --group codegen python scripts/gen_surface_types.py [--check]`.
1010
"""
1111

1212
from __future__ import annotations
1313

1414
import argparse
15+
import ast
1516
import difflib
1617
import hashlib
1718
import json
@@ -195,6 +196,7 @@ def run_codegen(schema_path: Path, output_path: Path) -> None:
195196
"--use-annotated", "--use-field-description", "--use-schema-description",
196197
"--enum-field-as-literal", "all",
197198
"--use-union-operator", "--use-double-quotes",
199+
"--use-type-alias", "--skip-root-model",
198200
"--extra-fields", "ignore",
199201
# JSON Schema `format` is annotation-only; codegen's defaults
200202
# (Base64Str, AnyUrl) over-assert and reject valid wire data.
@@ -237,6 +239,10 @@ def build(entry: dict[str, str]) -> str:
237239
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8"))
238240
patch_schema(schema, SCHEMA_PATCHES.get(version, []))
239241
make_server_info_opaque(schema)
242+
if "JSONValue" in schema["$defs"]:
243+
# A single recursive alias avoids mutually recursive alias evaluation in type checkers.
244+
assert schema["$defs"]["JSONValue"]["anyOf"][0] == {"$ref": "#/$defs/JSONObject"}
245+
schema["$defs"]["JSONValue"]["anyOf"][0] = schema["$defs"]["JSONObject"]
240246

241247
with tempfile.TemporaryDirectory() as tmp:
242248
patched = Path(tmp) / "schema.json"
@@ -246,7 +252,27 @@ def build(entry: dict[str, str]) -> str:
246252
source = raw.read_text(encoding="utf-8")
247253

248254
source = re.sub(r"\A# generated by datamodel-codegen:\n#[^\n]*\n", "", source)
249-
source = re.sub(r"^class Model\(RootModel\[Any\]\):\n {4}root: Any\n+", "", source, count=1, flags=re.MULTILINE)
255+
# Keep named aliases only for recursive types; other aliases remain ordinary Python types and unions.
256+
for node in reversed(ast.parse(source).body):
257+
if not (
258+
isinstance(node, ast.Assign)
259+
and isinstance(node.value, ast.Call)
260+
and isinstance(node.value.func, ast.Name)
261+
and node.value.func.id == "TypeAliasType"
262+
):
263+
continue
264+
value = node.value.args[1]
265+
if any(
266+
isinstance(part, ast.Constant) and isinstance(part.value, str) and part.value in schema["$defs"]
267+
for part in ast.walk(value)
268+
):
269+
continue
270+
original = ast.get_source_segment(source, node.value)
271+
replacement = ast.get_source_segment(source, value)
272+
assert original is not None and replacement is not None
273+
source = source.replace(original, f"({replacement})", 1)
274+
if "= TypeAliasType(" not in source:
275+
source = source.replace("from typing_extensions import TypeAliasType\n", "")
250276
# Codegen appends `| None` to forward refs of nullable models, which is a
251277
# runtime TypeError on a string ref and redundant since `JSONValue` includes None.
252278
source = source.replace('"JSONValue" | None', '"JSONValue"')
@@ -256,8 +282,7 @@ def build(entry: dict[str, str]) -> str:
256282
source = source.replace("](/", "](https://modelcontextprotocol.io/")
257283
source = allow_open_class_extras(source, OPEN_CLASSES[version])
258284
if epilogue := EPILOGUES.get(version, ""):
259-
# Insert before the trailing model_rebuild() block: pyright's evaluation
260-
# order for the recursive RootModel block is sensitive to placement.
285+
# Resolve aliases before rebuilding models with forward references.
261286
match = re.search(r"^\w+\.model_rebuild\(\)$", source, flags=re.MULTILINE)
262287
cut = match.start() if match else len(source)
263288
source = f"{source[:cut]}{epilogue}\n\n{source[cut:]}"

0 commit comments

Comments
 (0)