From 83a8ed6eeff52369fb2b06279b7689bb5221885b Mon Sep 17 00:00:00 2001 From: Aaron Oh Date: Thu, 23 Jul 2026 09:25:00 +0900 Subject: [PATCH 1/2] 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. --- examples/stories/schema_validators/README.md | 14 +++++-- examples/stories/schema_validators/client.py | 14 +++++-- examples/stories/schema_validators/server.py | 38 +++++++++++++++++-- .../schema_validators/server_lowlevel.py | 4 +- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index 984f1595ba..a15dc443f1 100644 --- a/examples/stories/schema_validators/README.md +++ b/examples/stories/schema_validators/README.md @@ -1,9 +1,10 @@ # schema-validators -Four ways to type a tool parameter so `MCPServer` derives the JSON-Schema +Five ways to type a tool parameter so `MCPServer` derives the JSON-Schema `inputSchema` and validates arguments before your handler runs: a pydantic -`BaseModel`, a `TypedDict`, a `@dataclass`, and a bare `dict[str, Any]`. The -client lists the tools, resolves each `who` schema, and round-trips a call. +`BaseModel`, a `TypedDict`, a `@dataclass`, a bare `dict[str, Any]`, and a +pydantic model built at runtime with `create_model`. The client lists the +tools, resolves each `who` schema, and round-trips a call. ## Run it @@ -25,6 +26,13 @@ uv run python -m stories.schema_validators.client --http --server server_lowleve - `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` arrive as plain dicts. +- `server.py` `greet_dynamic` — the parameter type is not written in the source + at all: `create_model` builds it at runtime from a JSON Schema dict (the shape + you'd get from OpenAPI, a config file, or a DB row), then hands it to + `@mcp.tool()` like any `BaseModel`; the published schema is identical to the + `greet_pydantic` variant. A `create_model` result is opaque to static type + checkers, so a `TYPE_CHECKING` branch aliases it to a declared model of the + same shape — the runtime still uses the dynamic class. - `client.py` — the listed `inputSchema` for the three typed variants nests a `$defs`/`$ref` object with a `name` property; `greet_dict` publishes only `{"type": "object", "additionalProperties": true}` — no field validation. diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py index 8f6794eddc..ee1fa30fd9 100644 --- a/examples/stories/schema_validators/client.py +++ b/examples/stories/schema_validators/client.py @@ -10,9 +10,17 @@ async def main(target: Target, *, mode: str = "auto") -> None: async with Client(target, mode=mode) as client: listed = await client.list_tools() by_name = {t.name: t for t in listed.tools} - assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"} - - for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"): + assert set(by_name) == { + "greet_pydantic", + "greet_typeddict", + "greet_dataclass", + "greet_dict", + "greet_dynamic", + } + + # greet_dynamic's parameter is a runtime create_model() class, but MCPServer + # reflects over it exactly like the hand-written BaseModel — same $defs/$ref shape. + for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dynamic"): schema = by_name[name].input_schema assert schema["required"] == ["who"], schema # MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either. diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py index 8648e211df..16e7d92971 100644 --- a/examples/stories/schema_validators/server.py +++ b/examples/stories/schema_validators/server.py @@ -1,9 +1,9 @@ -"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" +"""Five ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any -from pydantic import BaseModel +from pydantic import BaseModel, create_model # pydantic requires typing_extensions.TypedDict (not typing.TypedDict) on Python < 3.12 # when a TypedDict is used as a field/parameter type. @@ -29,6 +29,28 @@ class PersonDC: title: str = "friend" +# The four types above are declared in source. This fifth one is not: a caller +# holding an external JSON Schema (from OpenAPI, a config file, a DB row) builds +# the pydantic model at runtime with create_model, then hands it to @mcp.tool() +# exactly like a hand-written BaseModel. MCPServer reflects over it the same way. +PERSON_JSON_SCHEMA: dict[str, Any] = { + "properties": {"name": {"type": "string"}, "title": {"type": "string", "default": "friend"}}, + "required": ["name"], +} +if TYPE_CHECKING: + # A create_model() result is opaque to static tools: its fields don't exist + # until runtime, and a runtime variable can't appear in a type annotation. + # Alias it to a declared model of the same shape so type checkers can see + # `name`/`title`; at runtime the dynamic class below is what @mcp.tool() sees. + PersonDynamic = PersonModel +else: + _dynamic_fields: dict[str, Any] = { + field_name: (str, ... if field_name in PERSON_JSON_SCHEMA["required"] else field_schema.get("default")) + for field_name, field_schema in PERSON_JSON_SCHEMA["properties"].items() + } + PersonDynamic = create_model("PersonDynamic", **_dynamic_fields) + + def build_server() -> MCPServer: mcp = MCPServer("schema-validators-example") @@ -52,6 +74,16 @@ def greet_dict(who: dict[str, Any]) -> str: """`who` is a free-form object — any dict passes; the handler must check it.""" return f"Hello {who['name']}, my {who.get('title', 'friend')}" + @mcp.tool() + def greet_dynamic(who: PersonDynamic) -> str: + """`who`'s type was built at runtime by create_model, not declared in source. + + It validates and behaves like the ``PersonModel`` variant; the only + difference is that its class is assembled from a JSON Schema dict at import + time rather than written out as a ``class`` statement. + """ + return f"Hello {who.name}, my {who.title}" + return mcp diff --git a/examples/stories/schema_validators/server_lowlevel.py b/examples/stories/schema_validators/server_lowlevel.py index 02dca8d162..2051742afd 100644 --- a/examples/stories/schema_validators/server_lowlevel.py +++ b/examples/stories/schema_validators/server_lowlevel.py @@ -1,4 +1,4 @@ -"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema.""" +"""Same five tools via lowlevel.Server — inputSchema is hand-written JSON Schema.""" from typing import Any @@ -21,7 +21,7 @@ description=f"Greet ({variant} input shape)", input_schema={"type": "object", "properties": {"who": PERSON_SCHEMA}, "required": ["who"]}, ) - for variant in ("pydantic", "typeddict", "dataclass") + for variant in ("pydantic", "typeddict", "dataclass", "dynamic") ] TOOLS.append( types.Tool( From 230f71837e38a1f8f731e2da00049f1cc9412a6f Mon Sep 17 00:00:00 2001 From: Aaron Oh Date: Thu, 23 Jul 2026 16:07:03 +0900 Subject: [PATCH 2/2] Address review: default missing 'required' and fix variant count - server.py: JSON Schema 'required' is optional, so default it to an empty list before membership testing. External schemas with only optional properties no longer raise KeyError at import time. - README.md: greet_dynamic publishes the same schema as greet_pydantic, so the client bullet now says four typed variants, not three. --- examples/stories/schema_validators/README.md | 3 ++- examples/stories/schema_validators/server.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index a15dc443f1..42c80f096f 100644 --- a/examples/stories/schema_validators/README.md +++ b/examples/stories/schema_validators/README.md @@ -33,7 +33,8 @@ uv run python -m stories.schema_validators.client --http --server server_lowleve `greet_pydantic` variant. A `create_model` result is opaque to static type checkers, so a `TYPE_CHECKING` branch aliases it to a declared model of the same shape — the runtime still uses the dynamic class. -- `client.py` — the listed `inputSchema` for the three typed variants nests a +- `client.py` — the listed `inputSchema` for the four typed variants (including + the runtime `create_model` one, whose schema matches `greet_pydantic`) nests a `$defs`/`$ref` object with a `name` property; `greet_dict` publishes only `{"type": "object", "additionalProperties": true}` — no field validation. - `server_lowlevel.py` — the same schemas written by hand. There is no diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py index 16e7d92971..473d9c08d4 100644 --- a/examples/stories/schema_validators/server.py +++ b/examples/stories/schema_validators/server.py @@ -44,8 +44,11 @@ class PersonDC: # `name`/`title`; at runtime the dynamic class below is what @mcp.tool() sees. PersonDynamic = PersonModel else: + # `required` is optional in JSON Schema — a schema of all-optional properties + # omits it — so default to an empty list rather than indexing it directly. + _required = PERSON_JSON_SCHEMA.get("required", []) _dynamic_fields: dict[str, Any] = { - field_name: (str, ... if field_name in PERSON_JSON_SCHEMA["required"] else field_schema.get("default")) + field_name: (str, ... if field_name in _required else field_schema.get("default")) for field_name, field_schema in PERSON_JSON_SCHEMA["properties"].items() } PersonDynamic = create_model("PersonDynamic", **_dynamic_fields)