chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# schema-validators
|
||||
|
||||
Four 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.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# stdio (default — the client spawns the server as a subprocess)
|
||||
uv run python -m stories.schema_validators.client
|
||||
|
||||
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
|
||||
uv run python -m stories.schema_validators.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.schema_validators.client --http --server server_lowlevel
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — the body opens with `async with Client(target, mode=mode)
|
||||
as client:`. `target` is anything `Client` accepts (an in-process server, a
|
||||
transport, or an HTTP URL); the entry point picks it, the story constructs it.
|
||||
- `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
|
||||
`$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
|
||||
reflection layer at this tier; you author JSON Schema and unpack
|
||||
`params.arguments` yourself.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Pydantic emits local `#/$defs/` references for nested models. The SDK does
|
||||
not dereference network `$ref`s (SEP-2106 MUST NOT); only same-document refs
|
||||
are resolved during validation.
|
||||
- `PersonTD` is `total=True`, so its nested schema requires both `name` and
|
||||
`title`; the `BaseModel` and `@dataclass` variants default `title="friend"`,
|
||||
so only `name` is required there. Use `typing.NotRequired[...]` to mark
|
||||
optional TypedDict fields.
|
||||
|
||||
## Spec
|
||||
|
||||
[Tools — input schema](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#input-schema)
|
||||
|
||||
## See also
|
||||
|
||||
`tools/` (output schema → `structuredContent`), `error_handling/` (what
|
||||
happens when validation fails).
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Asserts each variant publishes a `who` object schema and the call round-trips."""
|
||||
|
||||
from mcp_types import TextContent
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
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"):
|
||||
schema = by_name[name].input_schema
|
||||
assert schema["required"] == ["who"], schema
|
||||
# MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either.
|
||||
who = schema["properties"]["who"]
|
||||
if "$ref" in who:
|
||||
who = schema["$defs"][who["$ref"].rsplit("/", 1)[-1]]
|
||||
assert "name" in who["properties"], who
|
||||
|
||||
result = await client.call_tool(name, {"who": {"name": "Ada", "title": "colleague"}})
|
||||
assert not result.is_error, result
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello Ada, my colleague"
|
||||
|
||||
# dict[str, Any] → free-form object schema, no nested `properties` required.
|
||||
dict_who = by_name["greet_dict"].input_schema["properties"]["who"]
|
||||
assert dict_who["type"] == "object" and "$ref" not in dict_who
|
||||
result = await client.call_tool("greet_dict", {"who": {"name": "Ada"}})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello Ada, my friend"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# pydantic requires typing_extensions.TypedDict (not typing.TypedDict) on Python < 3.12
|
||||
# when a TypedDict is used as a field/parameter type.
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
title: str = "friend"
|
||||
|
||||
|
||||
class PersonTD(TypedDict):
|
||||
name: str
|
||||
title: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonDC:
|
||||
name: str
|
||||
title: str = "friend"
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer("schema-validators-example")
|
||||
|
||||
@mcp.tool()
|
||||
def greet_pydantic(who: PersonModel) -> str:
|
||||
"""`who` arrives as a validated PersonModel instance."""
|
||||
return f"Hello {who.name}, my {who.title}"
|
||||
|
||||
@mcp.tool()
|
||||
def greet_typeddict(who: PersonTD) -> str:
|
||||
"""`who` arrives as a plain dict; TypedDict drives the schema and editor hints."""
|
||||
return f"Hello {who['name']}, my {who['title']}"
|
||||
|
||||
@mcp.tool()
|
||||
def greet_dataclass(who: PersonDC) -> str:
|
||||
"""`who` arrives as a PersonDC instance (pydantic coerces the wire dict)."""
|
||||
return f"Hello {who.name}, my {who.title}"
|
||||
|
||||
@mcp.tool()
|
||||
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')}"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
# With lowlevel.Server there is no reflection layer: you author the JSON Schema
|
||||
# yourself and validate/unpack `params.arguments` in the handler.
|
||||
PERSON_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}, "title": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
TOOLS = [
|
||||
types.Tool(
|
||||
name=f"greet_{variant}",
|
||||
description=f"Greet ({variant} input shape)",
|
||||
input_schema={"type": "object", "properties": {"who": PERSON_SCHEMA}, "required": ["who"]},
|
||||
)
|
||||
for variant in ("pydantic", "typeddict", "dataclass")
|
||||
]
|
||||
TOOLS.append(
|
||||
types.Tool(
|
||||
name="greet_dict",
|
||||
description="Greet (free-form dict input)",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"who": {"type": "object", "additionalProperties": True}},
|
||||
"required": ["who"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=TOOLS)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.arguments is not None
|
||||
who = params.arguments["who"]
|
||||
text = f"Hello {who['name']}, my {who.get('title', 'friend')}"
|
||||
return types.CallToolResult(content=[types.TextContent(text=text)])
|
||||
|
||||
return Server("schema-validators-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Reference in New Issue
Block a user