From ccf4371dd4551978115dffae9271eb49deabcb69 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 10 May 2026 10:21:31 -0400 Subject: [PATCH 1/2] feat(webhooks)!: replace `domain` builder kwarg with typed `protocol` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #632. The schema's `mcp-webhook-payload.json` defines a typed `protocol` field (`AdcpProtocol` enum: `media-buy | signals | governance | creative | brand | sponsored-intelligence`); the builder was instead accepting an undocumented `domain` string and stuffing it into the wire body via `extra='allow'`. The JS reference implementation (`buildTaskWebhookPayload` in `from-platform.ts`) sets `payload.protocol` and never sets `domain` — so the Python SDK was the outlier. Replaces `create_mcp_webhook_payload(..., domain=...)` and `WebhookSender.send_mcp(..., domain=...)` with `protocol=...`. The kwarg accepts either an `AdcpProtocol` enum value or a kebab-case string; unknown values raise `ValidationError` at construction. `AdcpProtocol` is now publicly exported from `adcp.types`. Zero in-repo callers passed `domain=`; verified by grep before the swap. The kwarg was added in #632's same migration window so the breaking- change cycle is bundled. BREAKING CHANGE: `domain` kwarg removed from `create_mcp_webhook_payload` and `WebhookSender.send_mcp`. Migrate to `protocol` (kebab-case string or `AdcpProtocol` enum value). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/types/__init__.py | 2 ++ src/adcp/webhook_sender.py | 6 ++--- src/adcp/webhooks.py | 15 ++++++----- tests/fixtures/public_api_snapshot.json | 1 + tests/test_webhooks_to_wire_dict.py | 36 +++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 58c540763..d8327599d 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -57,6 +57,7 @@ AcquireRightsResponse, ActivateSignalRequest, ActivateSignalResponse, + AdcpProtocol, AdvertiserIndustry, AggregatedTotals, AiTool, @@ -723,6 +724,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: # Request/Response types "ActivateSignalRequest", "ActivateSignalResponse", + "AdcpProtocol", "CreativeAction", "AggregatedTotals", "BuildCreativeRequest", diff --git a/src/adcp/webhook_sender.py b/src/adcp/webhook_sender.py index 1cc61652f..35966f72c 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -50,7 +50,7 @@ build_async_ip_pinned_transport, ) from adcp.signing.standard_webhooks import decode_secret as _decode_sw_secret -from adcp.types import GeneratedTaskStatus, TaskType +from adcp.types import AdcpProtocol, GeneratedTaskStatus, TaskType from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData from adcp.webhook_auth import ( AdcpLegacyHmacStrategy, @@ -528,7 +528,7 @@ async def send_mcp( operation_id: str | None = None, message: str | None = None, context_id: str | None = None, - domain: str | None = None, + protocol: AdcpProtocol | str | None = None, idempotency_key: str | None = None, token: str | None = None, extra_headers: Mapping[str, str] | None = None, @@ -557,7 +557,7 @@ async def send_mcp( operation_id=operation_id, message=message, context_id=context_id, - domain=domain, + protocol=protocol, idempotency_key=idempotency_key, token=token, ) diff --git a/src/adcp/webhooks.py b/src/adcp/webhooks.py index 5bb53b940..53d5c9734 100644 --- a/src/adcp/webhooks.py +++ b/src/adcp/webhooks.py @@ -56,7 +56,7 @@ WebhookVerifyOptions, verify_webhook_signature, ) -from adcp.types import GeneratedTaskStatus, McpWebhookPayload, TaskType +from adcp.types import AdcpProtocol, GeneratedTaskStatus, McpWebhookPayload, TaskType from adcp.types.base import AdCPBaseModel from adcp.webhook_receiver import ( LegacyHmacFallback, @@ -93,7 +93,7 @@ def create_mcp_webhook_payload( operation_id: str | None = None, message: str | None = None, context_id: str | None = None, - domain: str | None = None, + protocol: AdcpProtocol | str | None = None, idempotency_key: str | None = None, token: str | None = None, ) -> McpWebhookPayload: @@ -125,7 +125,8 @@ def create_mcp_webhook_payload( notifications without parsing URL paths. message: Human-readable summary of task state. context_id: Session/conversation identifier. - domain: AdCP domain this task belongs to. + protocol: AdCP protocol this task belongs to (see :class:`AdcpProtocol`). + Helps classify the operation type at a high level. idempotency_key: Sender-generated key stable across retries of the same event. Defaults to a freshly-generated UUID v4 — callers retrying delivery of the same event MUST pass the key from @@ -186,11 +187,10 @@ def create_mcp_webhook_payload( else: result_value = result - # `domain` and `token` aren't in the schema but are accepted via - # `extra='allow'`; they round-trip through `model_dump`. + # `token` isn't a typed schema field but is accepted via `extra='allow'`; + # it round-trips through `model_dump`. Tracked upstream for promotion to + # a typed field on `mcp-webhook-payload.json`. extras: dict[str, Any] = {} - if domain is not None: - extras["domain"] = domain if token is not None: # Buyer-supplied token from push_notification_config.token, # echoed back per push-notification-config.json spec text: @@ -202,6 +202,7 @@ def create_mcp_webhook_payload( "idempotency_key": idempotency_key, "task_id": task_id, "task_type": task_type, + "protocol": protocol, "status": status_value, "timestamp": timestamp, "operation_id": operation_id, diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index f0e344f50..ec81da887 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -397,6 +397,7 @@ "ActivateSignalRequest", "ActivateSignalResponse", "ActivateSignalSuccessResponse", + "AdcpProtocol", "AdvertiserIndustry", "AgentConfig", "AgentDeployment", diff --git a/tests/test_webhooks_to_wire_dict.py b/tests/test_webhooks_to_wire_dict.py index 427b7fa69..c7e87e4c4 100644 --- a/tests/test_webhooks_to_wire_dict.py +++ b/tests/test_webhooks_to_wire_dict.py @@ -163,3 +163,39 @@ def test_create_mcp_webhook_payload_rejects_invalid_task_type() -> None: task_type="get_products", idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", ) + + +def test_create_mcp_webhook_payload_protocol_kwarg() -> None: + """``protocol`` is the typed schema field (``AdcpProtocol`` enum). + Accepts the enum or a kebab-case string; rejects unknown values.""" + from pydantic import ValidationError + + from adcp.types import AdcpProtocol + + payload_enum = create_mcp_webhook_payload( + task_id="task_1", + status="completed", + task_type="create_media_buy", + protocol=AdcpProtocol.media_buy, + idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", + ) + payload_str = create_mcp_webhook_payload( + task_id="task_2", + status="completed", + task_type="create_media_buy", + protocol="media-buy", + idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", + ) + + assert to_wire_dict(payload_enum)["protocol"] == "media-buy" + assert to_wire_dict(payload_str)["protocol"] == "media-buy" + + # snake_case is wrong — the spec uses kebab-case for AdcpProtocol values. + with pytest.raises(ValidationError, match="protocol"): + create_mcp_webhook_payload( + task_id="task_3", + status="completed", + task_type="create_media_buy", + protocol="media_buy", + idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", + ) From 3006b50c09792940b56f987f0999f2f2cde5a2e3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 10 May 2026 11:18:01 -0400 Subject: [PATCH 2/2] feat(webhooks): auto-derive `protocol` from `task_type` in builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caller now passes only `task_type` and the builder fills `protocol` from the canonical `TaskType` → `AdcpProtocol` mapping. Mirrors `protocolForTool` in `adcontextprotocol/adcp-client:src/lib/server/ decisioning/runtime/protocol-for-tool.ts` so cross-SDK webhook bodies classify operations identically. The mapping covers all 20 spec values; explicit `protocol=` passed by the caller still wins. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/webhooks.py | 45 ++++++++++++++++++++++++++++- tests/test_webhooks_to_wire_dict.py | 38 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/adcp/webhooks.py b/src/adcp/webhooks.py index 53d5c9734..bdf51bff6 100644 --- a/src/adcp/webhooks.py +++ b/src/adcp/webhooks.py @@ -68,6 +68,34 @@ WebhookReceiverConfig, ) +# `task_type` → `protocol` mapping. Mirrors the JS reference +# implementation's `TOOL_PROTOCOL_MAP` in +# `adcontextprotocol/adcp-client:src/lib/server/decisioning/runtime/protocol-for-tool.ts` +# so cross-SDK webhook bodies classify operations identically. Updated +# alongside `task-type.json` enum extensions. +_TASK_TYPE_TO_PROTOCOL: dict[TaskType, AdcpProtocol] = { + TaskType.create_media_buy: AdcpProtocol.media_buy, + TaskType.update_media_buy: AdcpProtocol.media_buy, + TaskType.sync_creatives: AdcpProtocol.creative, + TaskType.activate_signal: AdcpProtocol.signals, + TaskType.get_signals: AdcpProtocol.signals, + TaskType.create_property_list: AdcpProtocol.governance, + TaskType.update_property_list: AdcpProtocol.governance, + TaskType.get_property_list: AdcpProtocol.governance, + TaskType.list_property_lists: AdcpProtocol.governance, + TaskType.delete_property_list: AdcpProtocol.governance, + TaskType.sync_accounts: AdcpProtocol.media_buy, + TaskType.get_account_financials: AdcpProtocol.media_buy, + TaskType.get_creative_delivery: AdcpProtocol.creative, + TaskType.sync_event_sources: AdcpProtocol.media_buy, + TaskType.sync_audiences: AdcpProtocol.media_buy, + TaskType.sync_catalogs: AdcpProtocol.media_buy, + TaskType.log_event: AdcpProtocol.media_buy, + TaskType.get_brand_identity: AdcpProtocol.brand, + TaskType.get_rights: AdcpProtocol.brand, + TaskType.acquire_rights: AdcpProtocol.brand, +} + def generate_webhook_idempotency_key() -> str: """Generate a cryptographically random idempotency_key for a webhook event. @@ -126,7 +154,9 @@ def create_mcp_webhook_payload( message: Human-readable summary of task state. context_id: Session/conversation identifier. protocol: AdCP protocol this task belongs to (see :class:`AdcpProtocol`). - Helps classify the operation type at a high level. + Auto-derived from ``task_type`` when omitted, matching the JS + SDK's ``protocolForTool`` so cross-SDK bodies classify + operations identically. Pass an explicit value to override. idempotency_key: Sender-generated key stable across retries of the same event. Defaults to a freshly-generated UUID v4 — callers retrying delivery of the same event MUST pass the key from @@ -178,6 +208,19 @@ def create_mcp_webhook_payload( status_value = status.value if hasattr(status, "value") else str(status) + # Auto-derive `protocol` from `task_type` when caller doesn't override. + # Matches `protocolForTool` in the JS reference SDK so cross-SDK bodies + # classify operations identically. + if protocol is None: + try: + task_type_enum = task_type if isinstance(task_type, TaskType) else TaskType(task_type) + except ValueError: + # Unknown string — let `model_validate` raise the canonical + # task_type error below rather than swallow it here. + task_type_enum = None + if task_type_enum is not None: + protocol = _TASK_TYPE_TO_PROTOCOL.get(task_type_enum) + # Foreign BaseModel subclasses (anything outside AdcpAsyncResponseData) # don't match the discriminated-union variants by identity — dump to a # dict so the union picks by shape, matching the dict path. diff --git a/tests/test_webhooks_to_wire_dict.py b/tests/test_webhooks_to_wire_dict.py index c7e87e4c4..558004228 100644 --- a/tests/test_webhooks_to_wire_dict.py +++ b/tests/test_webhooks_to_wire_dict.py @@ -165,6 +165,44 @@ def test_create_mcp_webhook_payload_rejects_invalid_task_type() -> None: ) +def test_create_mcp_webhook_payload_auto_derives_protocol_from_task_type() -> None: + """When caller doesn't pass ``protocol``, the builder fills it from + the ``task_type`` → ``AdcpProtocol`` mapping that mirrors the JS + SDK's ``protocolForTool``. Cross-SDK webhook bodies classify + operations identically without callers having to remember the map.""" + cases = { + "create_media_buy": "media-buy", + "get_brand_identity": "brand", + "create_property_list": "governance", + "activate_signal": "signals", + "sync_creatives": "creative", + } + for task_type, expected_protocol in cases.items(): + payload = create_mcp_webhook_payload( + task_id="t", + status="completed", + task_type=task_type, + idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", + ) + assert to_wire_dict(payload)["protocol"] == expected_protocol, task_type + + +def test_create_mcp_webhook_payload_explicit_protocol_overrides_auto_derive() -> None: + """An explicit ``protocol=`` always wins — the auto-derive is a + convenience, not a constraint. Adopters with a tracked task that + spans protocols (rare but spec-allowed) keep full control.""" + from adcp.types import AdcpProtocol + + payload = create_mcp_webhook_payload( + task_id="t", + status="completed", + task_type="create_media_buy", # would auto-derive to "media-buy" + protocol=AdcpProtocol.governance, + idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", + ) + assert to_wire_dict(payload)["protocol"] == "governance" + + def test_create_mcp_webhook_payload_protocol_kwarg() -> None: """``protocol`` is the typed schema field (``AdcpProtocol`` enum). Accepts the enum or a kebab-case string; rejects unknown values."""