Skip to content

feat(server): typed handler params via Pydantic annotation (closes #214)#238

Merged
bokelley merged 4 commits intomainfrom
bokelley/typed-handler-params
Apr 20, 2026
Merged

feat(server): typed handler params via Pydantic annotation (closes #214)#238
bokelley merged 4 commits intomainfrom
bokelley/typed-handler-params

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

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

  • Signature inspection (`_resolve_params_pydantic_model`). Reads the handler override's `params` annotation. Returns the Pydantic class for direct annotations and unions with dict (the specialized-base shape). Returns `None` on dict / missing / forward-ref failure — dispatcher falls back to dict path.
  • Dispatcher (`create_tool_caller`). Inspection runs once at setup. On each call, `model_validate` the raw dict before calling the handler. `ValidationError` → structured `ADCPTaskError(INVALID_REQUEST)` with field path and Pydantic error details.
  • Context echo preserved. `inject_context` still reads from the raw dict, so ADCP context echo works under typed dispatch (the wire `context` field isn't part of typed request models).
  • Back-compat is automatic. Legacy `params: dict[str, Any]` handlers route unchanged. Sibling methods with mixed typed/dict signatures coexist.

What's out

  • Response-side typed validation — current response builders return dicts; breaking that would require coordinated migration.
  • Full seller-example rewrite — left to a follow-up once skills are updated to teach the typed pattern as primary. `examples/typed_handler_demo.py` is the minimal reference for now.

Test plan

  • 13 unit/integration tests in `tests/test_typed_handler_params.py`
  • Signature resolution: direct Pydantic, union with dict, missing, non-Pydantic class, plain dict
  • Dispatch: typed handler receives Pydantic instance, legacy dict handler unchanged
  • Error surface: Pydantic ValidationError → ADCPTaskError(INVALID_REQUEST) with details
  • Mixed typed/legacy on same handler
  • Context echo works under typed dispatch
  • Existing `Model.model_validate(params)` pattern inside specialized bases still works (no double-validation crash)
  • Custom Pydantic model (not SDK-generated)
  • Second tool (list_creative_formats) — plumbing is tool-agnostic
  • Full suite: 1863/1863 pass
  • mypy clean (672 source files)

🤖 Generated with Claude Code

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>
bokelley and others added 3 commits April 20, 2026 11:22
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>
@bokelley bokelley merged commit dcc2c87 into main Apr 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sdk(server): typed handler signatures — params: GetProductsRequest instead of params.get(...)

1 participant