Skip to content

Commit 83a8ed6

Browse files
committed
Add a runtime create_model variant to the schema_validators example
The story showed four ways to type a tool parameter (BaseModel, TypedDict, dataclass, dict). Add a fifth: a pydantic model built at runtime with create_model from an external JSON Schema dict, then handed to @mcp.tool() like any BaseModel. Covers the doc gap behind issues #323, #761, #772. A create_model() result is opaque to static type checkers, so a TYPE_CHECKING branch aliases it to a same-shape declared model while the runtime uses the dynamic class. server_lowlevel.py, client.py and README.md are updated to include the new variant.
1 parent 3a6f299 commit 83a8ed6

4 files changed

Lines changed: 59 additions & 11 deletions

File tree

examples/stories/schema_validators/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# schema-validators
22

3-
Four ways to type a tool parameter so `MCPServer` derives the JSON-Schema
3+
Five ways to type a tool parameter so `MCPServer` derives the JSON-Schema
44
`inputSchema` and validates arguments before your handler runs: a pydantic
5-
`BaseModel`, a `TypedDict`, a `@dataclass`, and a bare `dict[str, Any]`. The
6-
client lists the tools, resolves each `who` schema, and round-trips a call.
5+
`BaseModel`, a `TypedDict`, a `@dataclass`, a bare `dict[str, Any]`, and a
6+
pydantic model built at runtime with `create_model`. The client lists the
7+
tools, resolves each `who` schema, and round-trips a call.
78

89
## Run it
910

@@ -25,6 +26,13 @@ uv run python -m stories.schema_validators.client --http --server server_lowleve
2526
- `server.py``who.name` vs `who["name"]`: pydantic and dataclass parameters
2627
arrive as **instances** (attribute access); TypedDict and `dict[str, Any]`
2728
arrive as plain dicts.
29+
- `server.py` `greet_dynamic` — the parameter type is not written in the source
30+
at all: `create_model` builds it at runtime from a JSON Schema dict (the shape
31+
you'd get from OpenAPI, a config file, or a DB row), then hands it to
32+
`@mcp.tool()` like any `BaseModel`; the published schema is identical to the
33+
`greet_pydantic` variant. A `create_model` result is opaque to static type
34+
checkers, so a `TYPE_CHECKING` branch aliases it to a declared model of the
35+
same shape — the runtime still uses the dynamic class.
2836
- `client.py` — the listed `inputSchema` for the three typed variants nests a
2937
`$defs`/`$ref` object with a `name` property; `greet_dict` publishes only
3038
`{"type": "object", "additionalProperties": true}` — no field validation.

examples/stories/schema_validators/client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@ async def main(target: Target, *, mode: str = "auto") -> None:
1010
async with Client(target, mode=mode) as client:
1111
listed = await client.list_tools()
1212
by_name = {t.name: t for t in listed.tools}
13-
assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"}
14-
15-
for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"):
13+
assert set(by_name) == {
14+
"greet_pydantic",
15+
"greet_typeddict",
16+
"greet_dataclass",
17+
"greet_dict",
18+
"greet_dynamic",
19+
}
20+
21+
# greet_dynamic's parameter is a runtime create_model() class, but MCPServer
22+
# reflects over it exactly like the hand-written BaseModel — same $defs/$ref shape.
23+
for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dynamic"):
1624
schema = by_name[name].input_schema
1725
assert schema["required"] == ["who"], schema
1826
# MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either.

examples/stories/schema_validators/server.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema."""
1+
"""Five ways to type a tool parameter so MCPServer derives and enforces inputSchema."""
22

33
from dataclasses import dataclass
4-
from typing import Any
4+
from typing import TYPE_CHECKING, Any
55

6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, create_model
77

88
# pydantic requires typing_extensions.TypedDict (not typing.TypedDict) on Python < 3.12
99
# when a TypedDict is used as a field/parameter type.
@@ -29,6 +29,28 @@ class PersonDC:
2929
title: str = "friend"
3030

3131

32+
# The four types above are declared in source. This fifth one is not: a caller
33+
# holding an external JSON Schema (from OpenAPI, a config file, a DB row) builds
34+
# the pydantic model at runtime with create_model, then hands it to @mcp.tool()
35+
# exactly like a hand-written BaseModel. MCPServer reflects over it the same way.
36+
PERSON_JSON_SCHEMA: dict[str, Any] = {
37+
"properties": {"name": {"type": "string"}, "title": {"type": "string", "default": "friend"}},
38+
"required": ["name"],
39+
}
40+
if TYPE_CHECKING:
41+
# A create_model() result is opaque to static tools: its fields don't exist
42+
# until runtime, and a runtime variable can't appear in a type annotation.
43+
# Alias it to a declared model of the same shape so type checkers can see
44+
# `name`/`title`; at runtime the dynamic class below is what @mcp.tool() sees.
45+
PersonDynamic = PersonModel
46+
else:
47+
_dynamic_fields: dict[str, Any] = {
48+
field_name: (str, ... if field_name in PERSON_JSON_SCHEMA["required"] else field_schema.get("default"))
49+
for field_name, field_schema in PERSON_JSON_SCHEMA["properties"].items()
50+
}
51+
PersonDynamic = create_model("PersonDynamic", **_dynamic_fields)
52+
53+
3254
def build_server() -> MCPServer:
3355
mcp = MCPServer("schema-validators-example")
3456

@@ -52,6 +74,16 @@ def greet_dict(who: dict[str, Any]) -> str:
5274
"""`who` is a free-form object — any dict passes; the handler must check it."""
5375
return f"Hello {who['name']}, my {who.get('title', 'friend')}"
5476

77+
@mcp.tool()
78+
def greet_dynamic(who: PersonDynamic) -> str:
79+
"""`who`'s type was built at runtime by create_model, not declared in source.
80+
81+
It validates and behaves like the ``PersonModel`` variant; the only
82+
difference is that its class is assembled from a JSON Schema dict at import
83+
time rather than written out as a ``class`` statement.
84+
"""
85+
return f"Hello {who.name}, my {who.title}"
86+
5587
return mcp
5688

5789

examples/stories/schema_validators/server_lowlevel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema."""
1+
"""Same five tools via lowlevel.Server — inputSchema is hand-written JSON Schema."""
22

33
from typing import Any
44

@@ -21,7 +21,7 @@
2121
description=f"Greet ({variant} input shape)",
2222
input_schema={"type": "object", "properties": {"who": PERSON_SCHEMA}, "required": ["who"]},
2323
)
24-
for variant in ("pydantic", "typeddict", "dataclass")
24+
for variant in ("pydantic", "typeddict", "dataclass", "dynamic")
2525
]
2626
TOOLS.append(
2727
types.Tool(

0 commit comments

Comments
 (0)