feat(server): typed handler params via Pydantic annotation (closes #214)#238
Merged
feat(server): typed handler params via Pydantic annotation (closes #214)#238
Conversation
Handlers may declare `params: GetProductsRequest` instead of `dict[str, Any]`. The dispatcher reads the annotation and deserialises the raw dict into the model before calling the handler — authors get IDE autocomplete, Pydantic validation at the boundary, and typed attribute access instead of params.get(...) everywhere. Flagged as the biggest structural boilerplate complaint in rounds 4–7 of DX validation. - _resolve_params_pydantic_model inspects the handler override's `params` annotation. Returns the Pydantic class for direct annotations AND unions with dict (the shape specialized SDK bases declare). Returns None for dict / missing annotation / forward-ref failures — dispatcher falls back to the dict path. - create_tool_caller runs the inspection once at setup. On each call, if a model was resolved and params is a dict, model_validate first. Pydantic ValidationError at the boundary surfaces as a structured ADCPTaskError with code INVALID_REQUEST (spec-typed `correctable` recovery classification) including the field path and Pydantic error details. translate_error maps this to the right MCP/A2A error shape. - Raw dict is preserved for inject_context() so ADCP context echo still works under typed dispatch (the wire `context` field isn't part of typed request models). Back-compat is automatic. Handlers with `params: dict[str, Any]` route unchanged. Sibling handlers with mixed typed/dict signatures coexist on the same instance. - 13 tests: signature resolution (direct, union, missing, non-pydantic, dict), typed handler dispatch, legacy dict dispatch, validation- error surface, mixed coexistence, context echo, double-validation no-op, custom Pydantic model. - docs/handler-authoring.md gets a "Typed handler params" section. - examples/typed_handler_demo.py — minimal runnable demonstration. Response-side typed validation is out of scope; response builders still return dicts. Future work per the issue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security reviewer (Medium — both reviewers converged on this): - Pydantic's exc.errors() echoes the raw offending value under `input` (and ctx/url) verbatim. In multi-hop agent chains the error flows through broker intermediaries — a mistyped bearer token or secret- shaped idempotency_key would land in their logs. Now stripped via exc.errors(include_input=False, include_context=False, include_url=False). - Populate Error.field with the field path so clients programmatically locate the bad value in their own request (the spec's dedicated field, previously unset). Code reviewer: - Silent fallback when typing.get_type_hints raises now logs at debug. A typo like `params: GeProductsRequest` silently degraded to dict-dispatch; the log line gives authors a breadcrumb without changing behavior. - Fixed "returns a new validated instance" mis-statement in docs + test comment. model_validate on an already-typed instance returns the same object and skips validators — worth knowing because a custom @field_validator won't fire twice. - Renamed _StrictGetProducts to _StrictGetProductsRequest for consistency with SDK Request convention. - Inlined the cast_empty_products helper — it was defined below its usage via late binding, which read as a mistake. Docs example: - Added "Demo only" comment pointing to mcp_with_auth_middleware.py so copy-paste starters don't ship 0.0.0.0 with no auth. Follow-up: issue #239 for transport-layer request-size cap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lidator doc Both reviewers converged on a regression in the round-1 fix: Error.field was correctly populated but MCP transport dropped it. Only A2A surfaced the field structurally (via data.errors[i].field passthrough). MCP clients only saw the embedded hint in the message's human-readable text — no programmatic access. translate_error._to_mcp now accepts `field` and embeds it in the code prefix: `INVALID_REQUEST[packages[0].budget]: ...`. Clients can parse the bracketed form with a simple regex to recover both the AdCP code and the field path — same shape the JS client uses. translate_error also lifts `field` off ADCPTaskError.errors[0] so the chain from dispatcher -> translate -> MCP ToolError preserves it end-to-end. Plus tests: - Regression guard for MCP field surfacing (explicit bracketed form). - PII-leak regression guard asserting a secret-shaped input is never echoed back in the error details (closes the must-fix from round 1's security review via a test, not just a code fix). Docs: - Custom-validator caveat: include_input=False only suppresses Pydantic's default echo. Authors layering @field_validator MUST NOT f-string the offending value into their ValueError message, or they re-introduce the leak via msg. Describe the constraint, not the value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a2a-sdk 1.0.0 was released 2026-04-20 as a breaking rewrite: types moved to a2a.types.a2a_pb2, DefaultRequestHandler renamed, ServerError removed from a2a.utils.errors, Part/Message no longer take `root=`. Our >=0.3.0 pin picked it up in CI after this PR was filed, producing 28 mypy errors across webhooks, client, protocols/a2a, server/a2a_server, server/translate. Cap at <1.0 until we do the compat migration (separate PR). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #214 — the biggest structural boilerplate complaint across rounds 4–7 of DX validation. Handlers declare `params: GetProductsRequest` instead of `dict[str, Any]`; the dispatcher deserialises at the boundary.
```python
class MySeller(ADCPHandler):
async def get_products(
self,
params: GetProductsRequest,
context: ToolContext | None = None,
) -> GetProductsResponse:
# Typed attributes — no params.get("buying_mode") anywhere.
if params.buying_mode.value == "refine":
...
```
What's in
What's out
Test plan
🤖 Generated with Claude Code