Skip to content
Merged
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
66 changes: 66 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,72 @@ def _resolve_identity(ctx: ToolContext | None) -> ResolvedIdentity:
)
```

## Typed handler params

Handler methods may declare their `params` as a Pydantic model instead
of `dict[str, Any]`. The dispatcher reads the annotation and
deserialises the incoming request before calling your method — you
get IDE autocomplete, Pydantic validation at the handler boundary, and
typed attribute access in exchange for a one-line signature change.

```python
from adcp.server import ADCPHandler, ToolContext
from adcp.types import GetProductsRequest, GetProductsResponse, Product


class MySeller(ADCPHandler):
async def get_products(
self,
params: GetProductsRequest,
context: ToolContext | None = None,
) -> GetProductsResponse:
# params.buying_mode, params.promoted_offering, params.brief —
# typed, validated, autocompleted. No params.get(...) anywhere.
if params.buying_mode.value == "refine":
...
return GetProductsResponse(products=[...])
```

**Validation errors surface as `INVALID_REQUEST`.** A Pydantic
`ValidationError` at the boundary is converted to a structured AdCP
error with the field path and validation detail — callers see the
spec-typed recovery classification (`correctable`), not a stack trace.
The raw offending value is stripped from the error (SDK sends
`include_input=False` to Pydantic) so mistyped secrets don't echo
back to multi-hop intermediaries.

> **Custom validator caveat.** If you layer `@field_validator` or
> `@model_validator` on a custom params model, **don't f-string the
> offending value into the `ValueError` message**
> (`raise ValueError(f"bad token {v}")`). The message text flows into
> the client-visible error — `include_input=False` only suppresses
> Pydantic's default echo, not your own. Stick to describing the
> constraint (`raise ValueError("token must match pk_… pattern")`).

**Back-compat is automatic.** Handlers that keep `params: dict[str, Any]`
work unchanged. The dispatcher falls back to the dict path when no
Pydantic model is in the annotation — migrate incrementally, one
method at a time. Sibling methods with mixed typed/dict signatures
coexist on the same handler.

**Unions with dict are supported.** `params: GetProductsRequest | dict[str, Any]`
(the shape the specialized SDK bases use internally) works — the
dispatcher picks the first Pydantic branch and deserialises. Existing
handlers that do defensive `GetProductsRequest.model_validate(params)`
inside the method still work: Pydantic's `model_validate` on an
already-typed instance is a no-op (returns the same object; field
validators are skipped — so a custom `@field_validator` layered on a
params model won't fire twice, and won't fire again on the defensive
re-call inside the handler).

**Custom models too.** You aren't restricted to the SDK's generated
request classes. Any `BaseModel` subclass declared on `params`
triggers typed dispatch — useful when you want to layer stricter
field constraints or business invariants on top of the spec shape.
Define the model at module top-level so forward-reference resolution
works (`from __future__ import annotations` stringifies all
annotations).

## Authentication

The SDK does not enforce authentication. There are two supported
Expand Down
90 changes: 90 additions & 0 deletions examples/typed_handler_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Typed handler params — minimal demonstration (#214).

Shows a handler that declares its ``params`` as a Pydantic model
rather than ``dict[str, Any]``. The dispatcher validates and
deserialises the request at the boundary, so the handler body works
with typed attributes instead of ``params.get(...)``.

Mixing typed and dict signatures on the same handler is supported —
useful for migrating a large seller one method at a time.

Run::

python examples/typed_handler_demo.py

Then call ``get_products`` from any MCP client. A request missing
``buying_mode`` returns a structured ``INVALID_REQUEST`` AdCP error.
"""

from __future__ import annotations

from typing import Any

from adcp.server import ADCPHandler, ToolContext, serve
from adcp.types import (
GetAdcpCapabilitiesResponse,
GetProductsRequest,
GetProductsResponse,
Product,
PublisherPropertiesAll,
)


class TypedSeller(ADCPHandler):
"""Minimal handler demonstrating typed dispatch.

Only two methods are overridden:

- ``get_adcp_capabilities`` — required by the AdCP spec.
- ``get_products`` — typed ``params: GetProductsRequest``. The
dispatcher deserialises before calling, so ``params.buying_mode``
is a typed enum attribute, not a dict lookup.
"""

_agent_type = "typed-demo-seller"

async def get_adcp_capabilities(
self, params: dict[str, Any], context: ToolContext | None = None
) -> dict[str, Any]:
return GetAdcpCapabilitiesResponse(
adcp={"major_versions": [3]},
supported_protocols=["media_buy"],
).model_dump(mode="json", exclude_none=True)

async def get_products(
self,
params: GetProductsRequest,
context: ToolContext | None = None,
) -> dict[str, Any]:
# Typed attribute access — no params.get("buying_mode") anywhere.
# Pydantic already validated the shape; the handler focuses on
# business logic.
requested_mode = params.buying_mode.value

products: list[Product] = [
Product(
product_id="demo-product",
name=f"Demo — {requested_mode} mode",
description="A demonstration product for the typed-dispatch example.",
publisher_properties=[
PublisherPropertiesAll(
publisher_domain="example.com",
selection_type="all",
)
],
format_ids=[],
delivery_type="non_guaranteed",
pricing_options=[],
)
]

return GetProductsResponse(products=products).model_dump(mode="json", exclude_none=True)


if __name__ == "__main__":
# Demo only — ``serve()`` defaults to binding 0.0.0.0 with no auth.
# For production, wrap with an auth middleware (see
# ``examples/mcp_with_auth_middleware.py``) and restrict the host
# via reverse-proxy config or the ``port=`` / bind-host hooks.
serve(TypedSeller(), name="typed-demo-seller", transport="streamable-http")
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ dependencies = [
"httpcore>=1.0,<2.0",
"pydantic>=2.0.0",
"typing-extensions>=4.5.0",
"a2a-sdk>=0.3.0",
# Cap at <1.0 — a2a-sdk 1.0.0 (released 2026-04-20) is a breaking
# rewrite that moves types to a2a.types.a2a_pb2, renames
# DefaultRequestHandler, removes ServerError from a2a.utils.errors,
# and changes Part/Message construction away from ``root=`` kwargs.
# Migration is non-trivial (28+ mypy errors across webhooks, client,
# protocols/a2a, server/a2a_server, server/translate). Tracked as a
# separate compat PR.
"a2a-sdk>=0.3.0,<1.0",
"mcp>=1.23.2",
"email-validator>=2.0.0",
"cryptography>=41.0.0",
Expand Down
122 changes: 116 additions & 6 deletions src/adcp/server/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,58 @@ def get_tools_for_handler(
]


def _resolve_params_pydantic_model(method: Any) -> type[Any] | None:
"""Resolve the Pydantic model the handler expects for ``params``.

Inspects the method's ``params`` annotation. Returns the Pydantic
class when the annotation is:

- A direct ``BaseModel`` subclass (``params: GetProductsRequest``).
- A Union / Optional whose first member is a ``BaseModel`` subclass
(``params: GetProductsRequest | dict[str, Any]``). This shape is
what the specialized SDK handler bases declare — typed-dispatch
treats the first Pydantic branch as the authoritative shape, so
existing ``params: Request | dict`` annotations keep working.

Returns ``None`` for ``dict``, missing annotation, or forward
references that fail to resolve — the dispatcher then falls back
to the legacy dict path.

Cached per method object via the returned value being computed once
at ``create_tool_caller`` setup time.
"""
import typing
from types import UnionType

from pydantic import BaseModel

try:
hints = typing.get_type_hints(method)
except Exception as exc: # forward-ref failure, missing import, etc.
# Log at debug so an author whose typed annotation silently
# failed to resolve (typo in the class name, import not at
# module top-level, PEP 563 name bound in a local scope) can
# find out why their handler is dispatching via the dict path.
logger.debug(
"typed params annotation failed to resolve for %r: %s; "
"falling back to dict dispatch",
method,
exc,
)
return None
annotation = hints.get("params")
if annotation is None:
return None
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return annotation
origin = typing.get_origin(annotation)
if origin is typing.Union or origin is UnionType:
for arg in typing.get_args(annotation):
if isinstance(arg, type) and issubclass(arg, BaseModel):
return arg
return None


def create_tool_caller(
handler: ADCPHandler[Any],
method_name: str,
Expand All @@ -1398,6 +1450,17 @@ def create_tool_caller(
``context`` field, it is echoed back in the response (ADCP requirement).
Handlers no longer need to call ``inject_context()`` manually.

**Typed params (closes #214).** When the handler method declares its
``params`` parameter as a Pydantic model (e.g.
``params: GetProductsRequest``), the dispatcher deserialises the raw
dict into the model before calling the handler — giving authors
IDE autocomplete, Pydantic validation at the boundary, and typed
attribute access instead of ``params.get(...)``. Handlers still
declaring ``params: dict[str, Any]`` keep working unchanged. A
Pydantic ``ValidationError`` surfaces as a structured
``INVALID_REQUEST`` AdCP error so callers see a spec-typed recovery
classification rather than a raw stack trace.

Args:
handler: The ADCP handler instance
method_name: Name of the method to call
Expand All @@ -1411,19 +1474,68 @@ def create_tool_caller(
per-principal scoping, audit logging) gets the real principal. When
no context is supplied, a bare :class:`ToolContext` is used.
"""
from pydantic import ValidationError

from adcp.exceptions import ADCPTaskError
from adcp.server.helpers import inject_context
from adcp.types import Error

method = getattr(handler, method_name)
params_model = _resolve_params_pydantic_model(method)

async def call_tool(params: dict[str, Any], context: ToolContext | None = None) -> Any:
ctx = context if context is not None else ToolContext()
result = await method(params, ctx)
raw_params = params # Preserve the original dict for context echo.
call_params: Any = params
if params_model is not None and isinstance(params, dict):
try:
call_params = params_model.model_validate(params)
except ValidationError as exc:
# Surface as a structured AdCP error so MCP clients see
# INVALID_REQUEST with a field-level pointer instead of
# a raw Pydantic traceback. translate_error maps this
# to ToolError (MCP) / ServerError (A2A) per transport.
#
# Strip ``input``/``ctx``/``url`` from the Pydantic error
# details — they echo the raw offending value verbatim
# (``input`` in particular). In multi-hop agent chains the
# response flows through intermediaries, so echoing the
# user-supplied value is a PII/secret-leak vector: a
# mistyped API key or secret-shaped idempotency_key could
# land in the broker's logs. The field path in
# ``Error.field`` is all clients need to programmatically
# locate the bad value in their own request.
errors_list = exc.errors(
include_input=False, include_context=False, include_url=False
)
first: dict[str, Any] = dict(errors_list[0]) if errors_list else {}
field_path = ".".join(str(loc) for loc in first.get("loc", ()))
message = first.get("msg", "validation failed")
suggestion = (
f"Invalid value for field {field_path!r}: {message}"
if field_path
else f"Request validation failed: {message}"
)
raise ADCPTaskError(
operation=method_name,
errors=[
Error(
code="INVALID_REQUEST",
field=field_path or None,
message=suggestion,
details={"validation_errors": errors_list},
)
],
) from exc
result = await method(call_params, ctx)
# Convert Pydantic models to JSON-safe dicts for MCP serialization
if hasattr(result, "model_dump"):
result = result.model_dump(mode="json", exclude_none=True)
# ADCP requires echoing context from request to response
# ADCP requires echoing context from request to response — read
# from the raw dict the transport sent, not from the validated
# model (which won't carry the wire ``context`` field).
if isinstance(result, dict):
inject_context(params, result)
inject_context(raw_params, result)
return result

return call_tool
Expand Down Expand Up @@ -1482,9 +1594,7 @@ def get_tool_names(self) -> list[str]:
return list(self._tools.keys())


def create_mcp_tools(
handler: ADCPHandler[Any], *, advertise_all: bool = False
) -> MCPToolSet:
def create_mcp_tools(handler: ADCPHandler[Any], *, advertise_all: bool = False) -> MCPToolSet:
"""Create MCP tools from an ADCP handler.

This is the main entry point for MCP server integration.
Expand Down
Loading
Loading