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
2 changes: 2 additions & 0 deletions src/adcp/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
AcquireRightsResponse,
ActivateSignalRequest,
ActivateSignalResponse,
AdcpProtocol,
AdvertiserIndustry,
AggregatedTotals,
AiTool,
Expand Down Expand Up @@ -723,6 +724,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
# Request/Response types
"ActivateSignalRequest",
"ActivateSignalResponse",
"AdcpProtocol",
"CreativeAction",
"AggregatedTotals",
"BuildCreativeRequest",
Expand Down
6 changes: 3 additions & 3 deletions src/adcp/webhook_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
58 changes: 51 additions & 7 deletions src/adcp/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -93,7 +121,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:
Expand Down Expand Up @@ -125,7 +153,10 @@ 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`).
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
Expand Down Expand Up @@ -177,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.
Expand All @@ -186,11 +230,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:
Expand All @@ -202,6 +245,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,
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/public_api_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@
"ActivateSignalRequest",
"ActivateSignalResponse",
"ActivateSignalSuccessResponse",
"AdcpProtocol",
"AdvertiserIndustry",
"AgentConfig",
"AgentDeployment",
Expand Down
74 changes: 74 additions & 0 deletions tests/test_webhooks_to_wire_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,77 @@ 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_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."""
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",
)
Loading