Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions examples/stories/schema_validators/README.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
tools, resolves each `who` schema, and round-trips a call.

## Run it

Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions examples/stories/schema_validators/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 38 additions & 3 deletions examples/stories/schema_validators/server.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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")

Expand All @@ -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


Expand Down
4 changes: 2 additions & 2 deletions examples/stories/schema_validators/server_lowlevel.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(
Expand Down
Loading