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
27 changes: 20 additions & 7 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,14 +629,18 @@ top-level `adcp.server`):

```python
from adcp.server.idempotency import PgBackend
idempotency = IdempotencyStore(backend=PgBackend(pool=pg_pool), ttl_seconds=86_400)
idempotency = IdempotencyStore(
backend=PgBackend(pool=pg_pool, lock_pool=idempotency_lock_pool),
ttl_seconds=86_400,
)
```

The Pg-backed store survives restarts and is shared across workers.
`PgBackend` commits the cached response atomically with your handler's
business write when both run inside the same transaction — no window
where the side effect lands
but the cache entry doesn't.
The Pg-backed store survives restarts and is shared across workers. Size the
dedicated `lock_pool` for the maximum number of concurrently executing unique
idempotent operations; cache-hit replays do not acquire it. The SDK commits
the cache entry while holding the per-key advisory lock, but it does not share
a transaction with unrelated handler business writes. Protect non-idempotent
business effects with a matching database uniqueness constraint.

**`caller_identity` + `tenant_id` must be populated.** The store keys
its cache on `(tenant_id, caller_identity, idempotency_key)`. If
Expand Down Expand Up @@ -1079,14 +1083,23 @@ serve(
"/var/lib/myagent/push_configs.db",
allowed_destination_hosts=None, # public-HTTPS mode
),
push_sender=MyPushNotificationSender(...),
)
```

The store controls subscription registration and discovery; the sender
delivers task updates. Configure both for built-in delivery. Supplying a
store without a sender remains supported for custom delivery pipelines, but
the SDK emits a startup warning because subscriptions would otherwise be
accepted without any notification being sent. Implement the a2a-sdk
`PushNotificationSender` interface when its base sender does not match your
HTTP-client lifecycle or tenant-isolation model.

Choose the destination policy explicitly:

| Mode | Wiring | Behavior |
|---|---|---|
| Disabled | Omit `push_config_store` | Agent card does not advertise push support; registration is unsupported. |
| Disabled | Omit `push_config_store` and `push_sender` | Agent card does not advertise push support; registration is unsupported. |
| Public HTTPS | Pass a store with `allowed_destination_hosts=None` | Accept any HTTPS hostname that resolves only to public, non-reserved addresses. |
| Allowlist | Pass a non-empty `frozenset` | Apply the public HTTPS/SSRF checks, then require an exact canonical hostname match. |

Expand Down
3 changes: 0 additions & 3 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
"release-type": "python",
"package-name": "adcp",
"changelog-path": "CHANGELOG.md",
"versioning": "prerelease",
"prerelease-type": "rc",
"prerelease": true,
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"include-component-in-tag": false
Expand Down
45 changes: 40 additions & 5 deletions scripts/generate_ergonomic_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

Coercion patterns detected:
1. Enum fields -> coerce_to_enum
2. list[Enum] fields -> coerce_to_enum_list
2. list[Enum] fields -> coerce_to_enum_list (or order-preserving unique coercion)
3. ContextObject fields -> coerce_to_model(ContextObject)
4. ExtensionObject fields -> coerce_to_model(ExtensionObject)
5. list[BaseModel] fields -> coerce_subclass_list (for subclass variance)
Expand All @@ -30,6 +30,7 @@
# These are the main request types users construct
REQUEST_TYPES_TO_ANALYZE = [
"ListCreativeFormatsRequest",
"ListCreativeFormatsRequestCreativeAgent",
"ListCreativesRequest",
"PackageRequest",
"CreateMediaBuyRequest",
Expand Down Expand Up @@ -156,10 +157,17 @@ def analyze_model(model_class) -> list[dict]:
annotation, lambda t: isinstance(t, type) and issubclass(t, Enum)
)
if is_enum_list:
coercion_type = (
"unique_enum_list"
if model_class.__name__
in {"ListCreativeFormatsRequest", "ListCreativeFormatsRequestCreativeAgent"}
and field_name in {"disclosure_positions", "disclosure_persistence"}
else "enum_list"
)
coercions.append(
{
"field": field_name,
"type": "enum_list",
"type": coercion_type,
"target_class": enum_type,
}
)
Expand Down Expand Up @@ -230,6 +238,9 @@ def generate_code() -> str:
# Import all the types we need to analyze
from pydantic import BaseModel as _PydBaseModel

from adcp.types.generated_poc.creative.list_creative_formats_request import (
ListCreativeFormatsRequestCreativeAgent,
)
from adcp.types.generated_poc.creative.list_creatives_request import (
ListCreativesRequest,
Sort,
Expand Down Expand Up @@ -281,6 +292,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
# Map names to classes
request_classes = {
"ListCreativeFormatsRequest": ListCreativeFormatsRequest,
"ListCreativeFormatsRequestCreativeAgent": ListCreativeFormatsRequestCreativeAgent,
"ListCreativesRequest": ListCreativesRequest,
"PackageRequest": PackageRequest,
"CreateMediaBuyRequest": CreateMediaBuyRequest,
Expand Down Expand Up @@ -384,12 +396,13 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
"from collections.abc import Sequence",
"from typing import Annotated, Any",
"",
"from pydantic import BeforeValidator",
"from pydantic import BeforeValidator, WrapValidator",
"",
"from adcp.types.coercion import (",
" coerce_subclass_list,",
" coerce_to_enum,",
" coerce_to_enum_list,",
" coerce_to_unique_enum_list,",
" coerce_to_model,",
")",
"",
Expand All @@ -414,6 +427,9 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
lines.append("from adcp.types.generated_poc.media_buy.list_creative_formats_request import (")
lines.append(" ListCreativeFormatsRequest,")
lines.append(")")
lines.append("from adcp.types.generated_poc.creative.list_creative_formats_request import (")
lines.append(" ListCreativeFormatsRequestCreativeAgent,")
lines.append(")")
lines.append("from adcp.types.generated_poc.creative.list_creatives_request import (")
lines.append(" Field1 as ListCreativesField,")
lines.append(" ListCreativesRequest,")
Expand Down Expand Up @@ -499,6 +515,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
type_order = [
# Request types
"ListCreativeFormatsRequest",
"ListCreativeFormatsRequestCreativeAgent",
"ListCreativesRequest",
"Sort",
"GetProductsRequest",
Expand Down Expand Up @@ -526,7 +543,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
if c["type"] == "enum":
target = get_symbol_name(c["target_class"])
field_comments.append(f'{c["field"]}: {target} | str | None')
elif c["type"] == "enum_list":
elif c["type"] in {"enum_list", "unique_enum_list"}:
target = get_symbol_name(c["target_class"])
field_comments.append(f'{c["field"]}: list[{target} | str] | None')
elif c["type"] == "context":
Expand Down Expand Up @@ -563,6 +580,13 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
f"BeforeValidator(coerce_to_enum({target}))],"
)
lines.append(" )")
elif c["type"] == "unique_enum_list":
target = get_symbol_name(c["target_class"])
lines.append(" _patch_unique_enum_list(")
lines.append(f" {type_name},")
lines.append(f' "{field}",')
lines.append(f" {target},")
lines.append(" )")
elif c["type"] == "enum_list":
target = get_symbol_name(c["target_class"])
lines.append(" _patch_field_annotation(")
Expand Down Expand Up @@ -620,7 +644,18 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:

# PackageUpdate is now a single class (flattened from validation-only oneOf)

# Add helper function
# Add helper functions
lines.append("")
lines.append("def _patch_unique_enum_list(")
lines.append(" model: Any,")
lines.append(" field_name: str,")
lines.append(" enum_class: type,")
lines.append(") -> None:")
lines.append(' """Add an order-preserving unique-items enum validator."""')
lines.append(" model.model_fields[field_name].metadata.append(")
lines.append(" WrapValidator(coerce_to_unique_enum_list(enum_class))")
lines.append(" )")
lines.append("")
lines.append("")
lines.append("def _patch_field_annotation(")
lines.append(" model: type,")
Expand Down
13 changes: 12 additions & 1 deletion src/adcp/canonical_formats/references.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
DEFAULT_REFERENCE_TIMEOUT_SECONDS = 5.0
DEFAULT_REFERENCE_BODY_LIMIT_BYTES = 1024 * 1024
DEFAULT_MAX_SCHEMA_REFS = 256
DEFAULT_MAX_SCHEMA_IDS = 256
DEFAULT_MAX_REF_DEPTH = 8
DEFAULT_MAX_SCHEMA_KEYWORDS = 10_000
DEFAULT_MAX_SCHEMA_DEPTH = 128
Expand Down Expand Up @@ -157,6 +158,7 @@ def __init__(
timeout: float = DEFAULT_REFERENCE_TIMEOUT_SECONDS,
max_body_bytes: int = DEFAULT_REFERENCE_BODY_LIMIT_BYTES,
max_schema_refs: int = DEFAULT_MAX_SCHEMA_REFS,
max_schema_ids: int = DEFAULT_MAX_SCHEMA_IDS,
max_ref_depth: int = DEFAULT_MAX_REF_DEPTH,
max_schema_keywords: int = DEFAULT_MAX_SCHEMA_KEYWORDS,
max_schema_depth: int = DEFAULT_MAX_SCHEMA_DEPTH,
Expand All @@ -165,6 +167,7 @@ def __init__(
self._timeout = timeout
self._max_body_bytes = max_body_bytes
self._max_schema_refs = max_schema_refs
self._max_schema_ids = max_schema_ids
self._max_ref_depth = max_ref_depth
self._max_schema_keywords = max_schema_keywords
self._max_schema_depth = max_schema_depth
Expand Down Expand Up @@ -318,6 +321,7 @@ def _validate_schema(
document,
base_uri=reference.uri,
max_refs=self._max_schema_refs,
max_ids=self._max_schema_ids,
max_ref_depth=self._max_ref_depth,
max_keywords=self._max_schema_keywords,
max_depth=self._max_schema_depth,
Expand Down Expand Up @@ -534,11 +538,12 @@ def _validate_schema_refs(
*,
base_uri: str,
max_refs: int,
max_ids: int,
max_ref_depth: int,
max_keywords: int,
max_depth: int,
) -> str | None:
state = _SchemaRefState(max_refs=max_refs, max_keywords=max_keywords)
state = _SchemaRefState(max_refs=max_refs, max_ids=max_ids, max_keywords=max_keywords)
return _walk_schema_refs(
document,
base_uri=base_uri,
Expand All @@ -552,8 +557,10 @@ def _validate_schema_refs(
@dataclass
class _SchemaRefState:
max_refs: int
max_ids: int
max_keywords: int
refs: int = 0
ids: int = 0
keywords: int = 0


Expand All @@ -577,6 +584,9 @@ def _walk_schema_refs(
if raw_id is not None:
if not isinstance(raw_id, str):
return "$id must be a string"
state.ids += 1
if state.ids > state.max_ids:
return "format_schema exceeds $id count bound"
id_error, resolved_id = _validate_schema_id_value(raw_id, base_uri=base_uri)
if id_error is not None:
return id_error
Expand Down Expand Up @@ -709,6 +719,7 @@ def _trusted_aao_catalog_origin(parts: SplitResult) -> bool:
"CanonicalReferenceResolver",
"CanonicalReferenceResult",
"CanonicalReferenceStatus",
"DEFAULT_MAX_SCHEMA_IDS",
"DEFAULT_REFERENCE_BODY_LIMIT_BYTES",
"DEFAULT_REFERENCE_TIMEOUT_SECONDS",
"parse_canonical_reference",
Expand Down
39 changes: 37 additions & 2 deletions src/adcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ def __init__(
self.adapter.idempotency_capability_check = self._ensure_idempotency_capability
if signing is not None:
self.adapter.signing_request_hook = self._sign_outgoing_request
self.adapter.signing_capability_check = self._prepare_signing_capabilities
# Apply schema validation modes (default: requests=warn, responses=strict
# in dev/test, warn in production — see ``ValidationHookConfig`` docs).
self.adapter.configure_validation(validation)
Expand Down Expand Up @@ -1008,6 +1009,25 @@ async def _ensure_idempotency_capability(self) -> None:
self._idempotency_capability_verified = False
raise

async def _prepare_signing_capabilities(self) -> None:
"""Populate signing policy before a transport writer sends a request."""
await self.fetch_capabilities()

@staticmethod
def _mcp_operation_from_request(request: httpx.Request) -> str | None:
"""Extract one MCP ``tools/call`` name from its JSON-RPC body."""
try:
payload = json.loads(request.content)
except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
return None
if not isinstance(payload, dict) or payload.get("method") != "tools/call":
return None
params = payload.get("params")
if not isinstance(params, dict):
return None
name = params.get("name")
return name if isinstance(name, str) and name else None

async def _sign_outgoing_request(self, request: httpx.Request) -> None:
"""httpx request event hook that attaches RFC 9421 signature headers.

Expand All @@ -1022,7 +1042,8 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None:
"""
if self.signing is None:
return
operation = _signing_current_operation.get()
mcp_operation = self._mcp_operation_from_request(request)
operation = mcp_operation or _signing_current_operation.get()
# Unset ContextVar → out-of-band call (agent-card fetch, session
# initialize, etc). Skip without fetching capabilities.
#
Expand All @@ -1036,7 +1057,21 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None:
if operation is None or operation == "get_adcp_capabilities":
return

caps = await self.fetch_capabilities()
if mcp_operation is not None:
# MCP's httpx hook runs in a writer task whose ContextVar snapshot
# was captured when the session connected. The adapter prefetches
# capabilities in the caller task before enqueueing tools/call;
# fetching here would deadlock the same writer stream.
caps = self._capabilities
if caps is None:
raise RuntimeError(
"MCP request signing policy was not prefetched before tools/call"
)
else:
# A2A's event hook runs in the caller task. Retain this fallback
# for direct/custom transports, while the bundled adapter also
# prefetches so normal hooks stay network-free.
caps = self._capabilities or await self.fetch_capabilities()
req_signing = getattr(caps, "request_signing", None)

# Detect and surface a malformed seller config: supported=False is
Expand Down
Loading
Loading