diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index 984f1595ba..42c80f096f 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,7 +26,15 @@ 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. -- `client.py` — the listed `inputSchema` for the three typed variants nests a +- `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 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/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..473d9c08d4 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,31 @@ 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: + # `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 _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 +77,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(