adcp 2.13.0__py3-none-any.whl → 2.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adcp/__init__.py +14 -1
- adcp/adagents.py +53 -9
- adcp/client.py +361 -57
- adcp/protocols/mcp.py +1 -3
- adcp/types/__init__.py +9 -33
- adcp/types/_generated.py +82 -13
- adcp/types/aliases.py +23 -0
- adcp/types/generated_poc/adagents.py +9 -13
- adcp/types/generated_poc/core/activation_key.py +12 -2
- adcp/types/generated_poc/core/assets/daast_asset.py +12 -2
- adcp/types/generated_poc/core/assets/image_asset.py +9 -5
- adcp/types/generated_poc/core/assets/vast_asset.py +12 -2
- adcp/types/generated_poc/core/assets/video_asset.py +9 -5
- adcp/types/generated_poc/core/async_response_data.py +72 -0
- adcp/types/generated_poc/core/brand_manifest_ref.py +35 -0
- adcp/types/generated_poc/core/creative_asset.py +4 -6
- adcp/types/generated_poc/core/creative_manifest.py +4 -6
- adcp/types/generated_poc/core/deployment.py +16 -8
- adcp/types/generated_poc/core/destination.py +12 -2
- adcp/types/generated_poc/core/format.py +3 -3
- adcp/types/generated_poc/core/{webhook_payload.py → mcp_webhook_payload.py} +8 -33
- adcp/types/generated_poc/core/pricing_option.py +51 -0
- adcp/types/generated_poc/core/product.py +4 -29
- adcp/types/generated_poc/core/promoted_offerings.py +5 -21
- adcp/types/generated_poc/core/property.py +4 -6
- adcp/types/generated_poc/core/publisher_property_selector.py +15 -2
- adcp/types/generated_poc/core/push_notification_config.py +1 -4
- adcp/types/generated_poc/core/start_timing.py +18 -0
- adcp/types/generated_poc/core/sub_asset.py +12 -2
- adcp/types/generated_poc/creative/preview_creative_response.py +3 -14
- adcp/types/generated_poc/creative/preview_render.py +3 -11
- adcp/types/generated_poc/media_buy/create_media_buy_async_response_input_required.py +37 -0
- adcp/types/generated_poc/media_buy/create_media_buy_async_response_submitted.py +19 -0
- adcp/types/generated_poc/media_buy/create_media_buy_async_response_working.py +31 -0
- adcp/types/generated_poc/media_buy/create_media_buy_request.py +7 -26
- adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +4 -2
- adcp/types/generated_poc/media_buy/get_products_async_response_input_required.py +38 -0
- adcp/types/generated_poc/media_buy/get_products_async_response_submitted.py +24 -0
- adcp/types/generated_poc/media_buy/get_products_async_response_working.py +35 -0
- adcp/types/generated_poc/media_buy/get_products_request.py +5 -20
- adcp/types/generated_poc/media_buy/list_creatives_response.py +5 -7
- adcp/types/generated_poc/media_buy/sync_creatives_async_response_input_required.py +31 -0
- adcp/types/generated_poc/media_buy/sync_creatives_async_response_submitted.py +19 -0
- adcp/types/generated_poc/media_buy/sync_creatives_async_response_working.py +37 -0
- adcp/types/generated_poc/media_buy/update_media_buy_async_response_input_required.py +30 -0
- adcp/types/generated_poc/media_buy/update_media_buy_async_response_submitted.py +19 -0
- adcp/types/generated_poc/media_buy/update_media_buy_async_response_working.py +31 -0
- adcp/types/generated_poc/media_buy/update_media_buy_request.py +4 -14
- adcp/types/generated_poc/signals/activate_signal_request.py +2 -2
- adcp/types/generated_poc/signals/activate_signal_response.py +2 -2
- adcp/types/generated_poc/signals/get_signals_request.py +2 -2
- adcp/types/generated_poc/signals/get_signals_response.py +2 -3
- adcp/utils/preview_cache.py +6 -4
- adcp/webhooks.py +508 -0
- {adcp-2.13.0.dist-info → adcp-2.14.0.dist-info}/METADATA +2 -2
- {adcp-2.13.0.dist-info → adcp-2.14.0.dist-info}/RECORD +60 -44
- adcp/types/generated_poc/core/dimensions.py +0 -18
- {adcp-2.13.0.dist-info → adcp-2.14.0.dist-info}/WHEEL +0 -0
- {adcp-2.13.0.dist-info → adcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {adcp-2.13.0.dist-info → adcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
- {adcp-2.13.0.dist-info → adcp-2.14.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: media_buy/update_media_buy_request.json
|
|
3
|
-
# timestamp: 2025-
|
|
3
|
+
# timestamp: 2025-12-11T15:09:37+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ from ..core import context as context_1
|
|
|
13
13
|
from ..core import creative_asset, creative_assignment
|
|
14
14
|
from ..core import ext as ext_1
|
|
15
15
|
from ..core import push_notification_config as push_notification_config_1
|
|
16
|
-
from ..core import targeting
|
|
16
|
+
from ..core import start_timing, targeting
|
|
17
17
|
from ..enums import pacing as pacing_1
|
|
18
18
|
|
|
19
19
|
|
|
@@ -137,12 +137,7 @@ class UpdateMediaBuyRequest1(AdCPBaseModel):
|
|
|
137
137
|
description='Optional webhook configuration for async update notifications. Publisher will send webhook when update completes if operation takes longer than immediate response time.'
|
|
138
138
|
),
|
|
139
139
|
] = None
|
|
140
|
-
start_time:
|
|
141
|
-
str | AwareDatetime | None,
|
|
142
|
-
Field(
|
|
143
|
-
description="Campaign start timing: 'asap' or ISO 8601 date-time", title='Start Timing'
|
|
144
|
-
),
|
|
145
|
-
] = None
|
|
140
|
+
start_time: start_timing.StartTiming | None = None
|
|
146
141
|
|
|
147
142
|
|
|
148
143
|
Packages2 = Packages
|
|
@@ -177,12 +172,7 @@ class UpdateMediaBuyRequest2(AdCPBaseModel):
|
|
|
177
172
|
description='Optional webhook configuration for async update notifications. Publisher will send webhook when update completes if operation takes longer than immediate response time.'
|
|
178
173
|
),
|
|
179
174
|
] = None
|
|
180
|
-
start_time:
|
|
181
|
-
str | AwareDatetime | None,
|
|
182
|
-
Field(
|
|
183
|
-
description="Campaign start timing: 'asap' or ISO 8601 date-time", title='Start Timing'
|
|
184
|
-
),
|
|
185
|
-
] = None
|
|
175
|
+
start_time: start_timing.StartTiming | None = None
|
|
186
176
|
|
|
187
177
|
|
|
188
178
|
class UpdateMediaBuyRequest(RootModel[UpdateMediaBuyRequest1 | UpdateMediaBuyRequest2]):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: signals/activate_signal_request.json
|
|
3
|
-
# timestamp: 2025-
|
|
3
|
+
# timestamp: 2025-12-11T15:09:37+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@ class ActivateSignalRequest(AdCPBaseModel):
|
|
|
20
20
|
)
|
|
21
21
|
context: context_1.ContextObject | None = None
|
|
22
22
|
deployments: Annotated[
|
|
23
|
-
list[destination.
|
|
23
|
+
list[destination.Destination],
|
|
24
24
|
Field(
|
|
25
25
|
description='Target deployment(s) for activation. If the authenticated caller matches one of these deployment targets, activation keys will be included in the response.',
|
|
26
26
|
min_length=1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: signals/activate_signal_response.json
|
|
3
|
-
# timestamp: 2025-
|
|
3
|
+
# timestamp: 2025-12-11T15:09:37+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@ class ActivateSignalResponse1(AdCPBaseModel):
|
|
|
20
20
|
)
|
|
21
21
|
context: context_1.ContextObject | None = None
|
|
22
22
|
deployments: Annotated[
|
|
23
|
-
list[deployment.
|
|
23
|
+
list[deployment.Deployment],
|
|
24
24
|
Field(description='Array of deployment results for each deployment target'),
|
|
25
25
|
]
|
|
26
26
|
ext: ext_1.ExtensionObject | None = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: signals/get_signals_request.json
|
|
3
|
-
# timestamp: 2025-
|
|
3
|
+
# timestamp: 2025-12-11T15:09:37+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -27,7 +27,7 @@ class DeliverTo(AdCPBaseModel):
|
|
|
27
27
|
list[Country], Field(description='Countries where signals will be used (ISO codes)')
|
|
28
28
|
]
|
|
29
29
|
deployments: Annotated[
|
|
30
|
-
list[destination.
|
|
30
|
+
list[destination.Destination],
|
|
31
31
|
Field(
|
|
32
32
|
description='List of deployment targets (DSPs, sales agents, etc.). If the authenticated caller matches one of these deployment targets, activation keys will be included in the response.',
|
|
33
33
|
min_length=1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by datamodel-codegen:
|
|
2
2
|
# filename: signals/get_signals_response.json
|
|
3
|
-
# timestamp: 2025-
|
|
3
|
+
# timestamp: 2025-12-11T15:09:37+00:00
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
@@ -32,8 +32,7 @@ class Signal(AdCPBaseModel):
|
|
|
32
32
|
]
|
|
33
33
|
data_provider: Annotated[str, Field(description='Name of the data provider')]
|
|
34
34
|
deployments: Annotated[
|
|
35
|
-
list[deployment.
|
|
36
|
-
Field(description='Array of deployment targets'),
|
|
35
|
+
list[deployment.Deployment], Field(description='Array of deployment targets')
|
|
37
36
|
]
|
|
38
37
|
description: Annotated[str, Field(description='Detailed signal description')]
|
|
39
38
|
name: Annotated[str, Field(description='Human-readable signal name')]
|
adcp/utils/preview_cache.py
CHANGED
|
@@ -87,13 +87,15 @@ class PreviewURLGenerator:
|
|
|
87
87
|
first_render = preview.renders[0] if preview.renders else None
|
|
88
88
|
|
|
89
89
|
if first_render:
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
# PreviewRender is a RootModel, access attributes via .root
|
|
91
|
+
render = first_render.root
|
|
92
|
+
has_url = hasattr(render, "preview_url")
|
|
93
|
+
preview_url = str(render.preview_url) if has_url else None
|
|
92
94
|
preview_data = {
|
|
93
95
|
"preview_id": preview.preview_id,
|
|
94
96
|
"preview_url": preview_url,
|
|
95
|
-
"preview_html": getattr(
|
|
96
|
-
"render_id":
|
|
97
|
+
"preview_html": getattr(render, "preview_html", None),
|
|
98
|
+
"render_id": render.render_id,
|
|
97
99
|
"input": preview.input.model_dump(),
|
|
98
100
|
"expires_at": str(result.data.expires_at),
|
|
99
101
|
}
|
adcp/webhooks.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Webhook creation and signing utilities for AdCP agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from a2a.types import (
|
|
12
|
+
Artifact,
|
|
13
|
+
DataPart,
|
|
14
|
+
Message,
|
|
15
|
+
Part,
|
|
16
|
+
Role,
|
|
17
|
+
Task,
|
|
18
|
+
TaskState,
|
|
19
|
+
TaskStatus,
|
|
20
|
+
TaskStatusUpdateEvent,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from adcp.types import GeneratedTaskStatus
|
|
24
|
+
from adcp.types.base import AdCPBaseModel
|
|
25
|
+
from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_mcp_webhook_payload(
|
|
29
|
+
task_id: str,
|
|
30
|
+
status: GeneratedTaskStatus,
|
|
31
|
+
result: AdcpAsyncResponseData | dict[str, Any] | None = None,
|
|
32
|
+
timestamp: datetime | None = None,
|
|
33
|
+
task_type: str | None = None,
|
|
34
|
+
operation_id: str | None = None,
|
|
35
|
+
message: str | None = None,
|
|
36
|
+
context_id: str | None = None,
|
|
37
|
+
domain: str | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""
|
|
40
|
+
Create MCP webhook payload dictionary.
|
|
41
|
+
|
|
42
|
+
This function helps agent implementations construct properly formatted
|
|
43
|
+
webhook payloads for sending to clients.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
task_id: Unique identifier for the task
|
|
47
|
+
status: Current task status
|
|
48
|
+
task_type: Optionally type of AdCP operation (e.g., "get_products", "create_media_buy")
|
|
49
|
+
timestamp: When the webhook was generated (defaults to current UTC time)
|
|
50
|
+
result: Task-specific payload (AdCP response data)
|
|
51
|
+
operation_id: Publisher-defined operation identifier (deprecated from payload,
|
|
52
|
+
should be in URL routing, but included for backward compatibility)
|
|
53
|
+
message: Human-readable summary of task state
|
|
54
|
+
context_id: Session/conversation identifier
|
|
55
|
+
domain: AdCP domain this task belongs to
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dictionary matching McpWebhookPayload schema, ready to be sent as JSON
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
Create a completed webhook with results:
|
|
62
|
+
>>> from adcp.webhooks import create_mcp_webhook_payload
|
|
63
|
+
>>> from adcp.types import GeneratedTaskStatus
|
|
64
|
+
>>>
|
|
65
|
+
>>> payload = create_mcp_webhook_payload(
|
|
66
|
+
... task_id="task_123",
|
|
67
|
+
... task_type="get_products",
|
|
68
|
+
... status=GeneratedTaskStatus.completed,
|
|
69
|
+
... result={"products": [...]},
|
|
70
|
+
... message="Found 5 products"
|
|
71
|
+
... )
|
|
72
|
+
|
|
73
|
+
Create a failed webhook with error:
|
|
74
|
+
>>> payload = create_mcp_webhook_payload(
|
|
75
|
+
... task_id="task_456",
|
|
76
|
+
... task_type="create_media_buy",
|
|
77
|
+
... status=GeneratedTaskStatus.failed,
|
|
78
|
+
... result={"errors": [{"code": "INVALID_INPUT", "message": "..."}]},
|
|
79
|
+
... message="Validation failed"
|
|
80
|
+
... )
|
|
81
|
+
|
|
82
|
+
Create a working status update:
|
|
83
|
+
>>> payload = create_mcp_webhook_payload(
|
|
84
|
+
... task_id="task_789",
|
|
85
|
+
... task_type="sync_creatives",
|
|
86
|
+
... status=GeneratedTaskStatus.working,
|
|
87
|
+
... message="Processing 3 of 10 creatives"
|
|
88
|
+
... )
|
|
89
|
+
"""
|
|
90
|
+
if timestamp is None:
|
|
91
|
+
timestamp = datetime.now(timezone.utc)
|
|
92
|
+
|
|
93
|
+
# Convert status enum to string value
|
|
94
|
+
status_value = status.value if hasattr(status, "value") else str(status)
|
|
95
|
+
|
|
96
|
+
# Build payload matching McpWebhookPayload schema
|
|
97
|
+
payload: dict[str, Any] = {
|
|
98
|
+
"task_id": task_id,
|
|
99
|
+
"task_type": task_type,
|
|
100
|
+
"status": status_value,
|
|
101
|
+
"timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Add optional fields only if provided
|
|
105
|
+
if result is not None:
|
|
106
|
+
# Convert Pydantic model to dict if needed for JSON serialization
|
|
107
|
+
if hasattr(result, "model_dump"):
|
|
108
|
+
payload["result"] = result.model_dump(mode="json")
|
|
109
|
+
else:
|
|
110
|
+
payload["result"] = result
|
|
111
|
+
|
|
112
|
+
if operation_id is not None:
|
|
113
|
+
payload["operation_id"] = operation_id
|
|
114
|
+
|
|
115
|
+
if message is not None:
|
|
116
|
+
payload["message"] = message
|
|
117
|
+
|
|
118
|
+
if context_id is not None:
|
|
119
|
+
payload["context_id"] = context_id
|
|
120
|
+
|
|
121
|
+
if domain is not None:
|
|
122
|
+
payload["domain"] = domain
|
|
123
|
+
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_adcp_signed_headers_for_webhook(
|
|
128
|
+
headers: dict[str, Any], secret: str, timestamp: str, payload: dict[str, Any] | AdCPBaseModel
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""
|
|
131
|
+
Generate AdCP-compliant signed headers for webhook delivery.
|
|
132
|
+
|
|
133
|
+
This function creates a cryptographic signature that proves the webhook
|
|
134
|
+
came from an authorized agent and protects against replay attacks by
|
|
135
|
+
including a timestamp in the signed message.
|
|
136
|
+
|
|
137
|
+
The function adds two headers to the provided headers dict:
|
|
138
|
+
- X-AdCP-Signature: HMAC-SHA256 signature in format "sha256=<hex_digest>"
|
|
139
|
+
- X-AdCP-Timestamp: ISO 8601 timestamp used in signature generation
|
|
140
|
+
|
|
141
|
+
The signing algorithm:
|
|
142
|
+
1. Constructs message as "{timestamp}.{json_payload}"
|
|
143
|
+
2. JSON-serializes payload with compact separators (no sorted keys for performance)
|
|
144
|
+
3. UTF-8 encodes the message
|
|
145
|
+
4. HMAC-SHA256 signs with the shared secret
|
|
146
|
+
5. Hex-encodes and prefixes with "sha256="
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
headers: Existing headers dictionary to add signature headers to
|
|
150
|
+
secret: Shared secret key for HMAC signing
|
|
151
|
+
timestamp: ISO 8601 timestamp string (e.g., "2025-01-15T10:00:00Z")
|
|
152
|
+
payload: Webhook payload (dict or Pydantic model - will be JSON-serialized)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The modified headers dictionary with signature headers added
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
Sign and send an MCP webhook:
|
|
159
|
+
>>> from adcp.webhooks import create_mcp_webhook_payload get_adcp_signed_headers_for_webhook
|
|
160
|
+
>>> from datetime import datetime, timezone
|
|
161
|
+
>>>
|
|
162
|
+
>>> payload = create_mcp_webhook_payload(
|
|
163
|
+
... task_id="task_123",
|
|
164
|
+
... task_type="get_products",
|
|
165
|
+
... status="completed",
|
|
166
|
+
... result={"products": [...]}
|
|
167
|
+
... )
|
|
168
|
+
>>> headers = {"Content-Type": "application/json"}
|
|
169
|
+
>>> timestamp = datetime.now(timezone.utc).isoformat()
|
|
170
|
+
>>> signed_headers = get_adcp_signed_headers_for_webhook(
|
|
171
|
+
... headers, secret="my-webhook-secret", timestamp=timestamp, payload=payload
|
|
172
|
+
... )
|
|
173
|
+
>>>
|
|
174
|
+
>>> # Send webhook with signed headers
|
|
175
|
+
>>> import httpx
|
|
176
|
+
>>> response = await httpx.post(
|
|
177
|
+
... webhook_url,
|
|
178
|
+
... json=payload,
|
|
179
|
+
... headers=signed_headers
|
|
180
|
+
... )
|
|
181
|
+
|
|
182
|
+
Headers will contain:
|
|
183
|
+
>>> print(signed_headers)
|
|
184
|
+
{
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
"X-AdCP-Signature": "sha256=a1b2c3...",
|
|
187
|
+
"X-AdCP-Timestamp": "2025-01-15T10:00:00Z"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
Sign with Pydantic model directly:
|
|
191
|
+
>>> from adcp import GetMediaBuyDeliveryResponse
|
|
192
|
+
>>> from datetime import datetime, timezone
|
|
193
|
+
>>>
|
|
194
|
+
>>> response: GetMediaBuyDeliveryResponse = ... # From API call
|
|
195
|
+
>>> headers = {"Content-Type": "application/json"}
|
|
196
|
+
>>> timestamp = datetime.now(timezone.utc).isoformat()
|
|
197
|
+
>>> signed_headers = get_adcp_signed_headers_for_webhook(
|
|
198
|
+
... headers, secret="my-webhook-secret", timestamp=timestamp, payload=response
|
|
199
|
+
... )
|
|
200
|
+
>>> # Pydantic model is automatically converted to dict for signing
|
|
201
|
+
"""
|
|
202
|
+
# Convert Pydantic model to dict if needed
|
|
203
|
+
# All AdCP types inherit from AdCPBaseModel (Pydantic BaseModel)
|
|
204
|
+
if hasattr(payload, "model_dump"):
|
|
205
|
+
payload_dict = payload.model_dump(mode="json")
|
|
206
|
+
else:
|
|
207
|
+
payload_dict = payload
|
|
208
|
+
|
|
209
|
+
# Serialize payload to JSON with consistent formatting
|
|
210
|
+
# Note: sort_keys=False for performance (key order doesn't affect signature)
|
|
211
|
+
payload_bytes = json.dumps(payload_dict, separators=(",", ":"), sort_keys=False).encode("utf-8")
|
|
212
|
+
|
|
213
|
+
# Construct signed message: timestamp.payload
|
|
214
|
+
# Including timestamp prevents replay attacks
|
|
215
|
+
signed_message = f"{timestamp}.{payload_bytes.decode('utf-8')}"
|
|
216
|
+
|
|
217
|
+
# Generate HMAC-SHA256 signature over timestamp + payload
|
|
218
|
+
signature_hex = hmac.new(
|
|
219
|
+
secret.encode("utf-8"), signed_message.encode("utf-8"), hashlib.sha256
|
|
220
|
+
).hexdigest()
|
|
221
|
+
|
|
222
|
+
# Add AdCP-compliant signature headers
|
|
223
|
+
headers["X-AdCP-Signature"] = f"sha256={signature_hex}"
|
|
224
|
+
headers["X-AdCP-Timestamp"] = timestamp
|
|
225
|
+
|
|
226
|
+
return headers
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> AdcpAsyncResponseData | None:
|
|
230
|
+
"""
|
|
231
|
+
Extract result data from webhook payload (MCP or A2A format).
|
|
232
|
+
|
|
233
|
+
This utility function handles webhook payloads from both MCP and A2A protocols,
|
|
234
|
+
extracting the result data regardless of the webhook format. Useful for quick
|
|
235
|
+
inspection, logging, or custom webhook routing logic without requiring full
|
|
236
|
+
client initialization.
|
|
237
|
+
|
|
238
|
+
Protocol Detection:
|
|
239
|
+
- A2A Task: Has "artifacts" field (terminated statuses: completed, failed)
|
|
240
|
+
- A2A TaskStatusUpdateEvent: Has nested "status.message" structure (intermediate statuses)
|
|
241
|
+
- MCP: Has "result" field directly
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
webhook_payload: Raw webhook dictionary from HTTP request (JSON-deserialized)
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
AdcpAsyncResponseData union type containing the extracted AdCP response, or None
|
|
248
|
+
if no result present. For A2A webhooks, unwraps data from artifacts/message parts
|
|
249
|
+
structure. For MCP webhooks, returns the result field directly.
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
Extract from MCP webhook:
|
|
253
|
+
>>> mcp_payload = {
|
|
254
|
+
... "task_id": "task_123",
|
|
255
|
+
... "task_type": "create_media_buy",
|
|
256
|
+
... "status": "completed",
|
|
257
|
+
... "timestamp": "2025-01-15T10:00:00Z",
|
|
258
|
+
... "result": {"media_buy_id": "mb_123", "buyer_ref": "ref_123", "packages": []}
|
|
259
|
+
... }
|
|
260
|
+
>>> result = extract_webhook_result_data(mcp_payload)
|
|
261
|
+
>>> print(result["media_buy_id"])
|
|
262
|
+
mb_123
|
|
263
|
+
|
|
264
|
+
Extract from A2A Task webhook:
|
|
265
|
+
>>> a2a_task_payload = {
|
|
266
|
+
... "id": "task_456",
|
|
267
|
+
... "context_id": "ctx_456",
|
|
268
|
+
... "status": {"state": "completed", "timestamp": "2025-01-15T10:00:00Z"},
|
|
269
|
+
... "artifacts": [
|
|
270
|
+
... {
|
|
271
|
+
... "artifact_id": "artifact_456",
|
|
272
|
+
... "parts": [
|
|
273
|
+
... {
|
|
274
|
+
... "data": {
|
|
275
|
+
... "media_buy_id": "mb_456",
|
|
276
|
+
... "buyer_ref": "ref_456",
|
|
277
|
+
... "packages": []
|
|
278
|
+
... }
|
|
279
|
+
... }
|
|
280
|
+
... ]
|
|
281
|
+
... }
|
|
282
|
+
... ]
|
|
283
|
+
... }
|
|
284
|
+
>>> result = extract_webhook_result_data(a2a_task_payload)
|
|
285
|
+
>>> print(result["media_buy_id"])
|
|
286
|
+
mb_456
|
|
287
|
+
|
|
288
|
+
Extract from A2A TaskStatusUpdateEvent webhook:
|
|
289
|
+
>>> a2a_event_payload = {
|
|
290
|
+
... "task_id": "task_789",
|
|
291
|
+
... "context_id": "ctx_789",
|
|
292
|
+
... "status": {
|
|
293
|
+
... "state": "working",
|
|
294
|
+
... "timestamp": "2025-01-15T10:00:00Z",
|
|
295
|
+
... "message": {
|
|
296
|
+
... "message_id": "msg_789",
|
|
297
|
+
... "role": "agent",
|
|
298
|
+
... "parts": [
|
|
299
|
+
... {"data": {"current_step": "processing", "percentage": 50}}
|
|
300
|
+
... ]
|
|
301
|
+
... }
|
|
302
|
+
... },
|
|
303
|
+
... "final": False
|
|
304
|
+
... }
|
|
305
|
+
>>> result = extract_webhook_result_data(a2a_event_payload)
|
|
306
|
+
>>> print(result["percentage"])
|
|
307
|
+
50
|
|
308
|
+
|
|
309
|
+
Handle webhook with no result:
|
|
310
|
+
>>> empty_payload = {"task_id": "task_000", "status": "working", "timestamp": "..."}
|
|
311
|
+
>>> result = extract_webhook_result_data(empty_payload)
|
|
312
|
+
>>> print(result)
|
|
313
|
+
None
|
|
314
|
+
"""
|
|
315
|
+
# Detect A2A Task format (has "artifacts" field)
|
|
316
|
+
if "artifacts" in webhook_payload:
|
|
317
|
+
# Extract from task.artifacts[].parts[]
|
|
318
|
+
artifacts = webhook_payload.get("artifacts", [])
|
|
319
|
+
if not artifacts:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
# Use last artifact (most recent)
|
|
323
|
+
target_artifact = artifacts[-1]
|
|
324
|
+
parts = target_artifact.get("parts", [])
|
|
325
|
+
if not parts:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
# Find DataPart (skip TextPart)
|
|
329
|
+
for part in parts:
|
|
330
|
+
# Check if this part has "data" field (DataPart)
|
|
331
|
+
if "data" in part:
|
|
332
|
+
data = part["data"]
|
|
333
|
+
# Unwrap {"response": {...}} wrapper if present (A2A convention)
|
|
334
|
+
if isinstance(data, dict) and "response" in data and len(data) == 1:
|
|
335
|
+
return cast(AdcpAsyncResponseData, data["response"])
|
|
336
|
+
return cast(AdcpAsyncResponseData, data)
|
|
337
|
+
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
# Detect A2A TaskStatusUpdateEvent format (has nested "status.message")
|
|
341
|
+
status = webhook_payload.get("status")
|
|
342
|
+
if isinstance(status, dict):
|
|
343
|
+
message = status.get("message")
|
|
344
|
+
if isinstance(message, dict):
|
|
345
|
+
# Extract from status.message.parts[]
|
|
346
|
+
parts = message.get("parts", [])
|
|
347
|
+
if not parts:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
# Find DataPart
|
|
351
|
+
for part in parts:
|
|
352
|
+
if "data" in part:
|
|
353
|
+
data = part["data"]
|
|
354
|
+
# Unwrap {"response": {...}} wrapper if present
|
|
355
|
+
if isinstance(data, dict) and "response" in data and len(data) == 1:
|
|
356
|
+
return cast(AdcpAsyncResponseData, data["response"])
|
|
357
|
+
return cast(AdcpAsyncResponseData, data)
|
|
358
|
+
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
# MCP format: result field directly
|
|
362
|
+
return cast(AdcpAsyncResponseData | None, webhook_payload.get("result"))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def create_a2a_webhook_payload(
|
|
366
|
+
task_id: str,
|
|
367
|
+
status: GeneratedTaskStatus,
|
|
368
|
+
context_id: str,
|
|
369
|
+
result: AdcpAsyncResponseData | dict[str, Any],
|
|
370
|
+
timestamp: datetime | None = None,
|
|
371
|
+
) -> Task | TaskStatusUpdateEvent:
|
|
372
|
+
"""
|
|
373
|
+
Create A2A webhook payload (Task or TaskStatusUpdateEvent).
|
|
374
|
+
|
|
375
|
+
Per A2A specification:
|
|
376
|
+
- Terminated statuses (completed, failed): Returns Task with artifacts[].parts[]
|
|
377
|
+
- Intermediate statuses (working, input-required, submitted): Returns TaskStatusUpdateEvent
|
|
378
|
+
with status.message.parts[]
|
|
379
|
+
|
|
380
|
+
This function helps agent implementations construct properly formatted A2A webhook
|
|
381
|
+
payloads for sending to clients.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
task_id: Unique identifier for the task
|
|
385
|
+
status: Current task status
|
|
386
|
+
context_id: Session/conversation identifier (required by A2A protocol)
|
|
387
|
+
timestamp: When the webhook was generated (defaults to current UTC time)
|
|
388
|
+
result: Task-specific payload (AdCP response data)
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Task object for terminated statuses, TaskStatusUpdateEvent for intermediate statuses
|
|
392
|
+
|
|
393
|
+
Examples:
|
|
394
|
+
Create a completed Task webhook:
|
|
395
|
+
>>> from adcp.webhooks import create_a2a_webhook_payload
|
|
396
|
+
>>> from adcp.types import GeneratedTaskStatus
|
|
397
|
+
>>>
|
|
398
|
+
>>> task = create_a2a_webhook_payload(
|
|
399
|
+
... task_id="task_123",
|
|
400
|
+
... status=GeneratedTaskStatus.completed,
|
|
401
|
+
... result={"products": [...]},
|
|
402
|
+
... message="Found 5 products"
|
|
403
|
+
... )
|
|
404
|
+
>>> # task is a Task object with artifacts containing the result
|
|
405
|
+
|
|
406
|
+
Create a working status update:
|
|
407
|
+
>>> event = create_a2a_webhook_payload(
|
|
408
|
+
... task_id="task_456",
|
|
409
|
+
... status=GeneratedTaskStatus.working,
|
|
410
|
+
... message="Processing 3 of 10 items"
|
|
411
|
+
... )
|
|
412
|
+
>>> # event is a TaskStatusUpdateEvent with status.message
|
|
413
|
+
|
|
414
|
+
Send A2A webhook via HTTP POST:
|
|
415
|
+
>>> import httpx
|
|
416
|
+
>>> from a2a.types import Task
|
|
417
|
+
>>>
|
|
418
|
+
>>> payload = create_a2a_webhook_payload(...)
|
|
419
|
+
>>> # Serialize to dict for JSON
|
|
420
|
+
>>> if isinstance(payload, Task):
|
|
421
|
+
... payload_dict = payload.model_dump(mode='json')
|
|
422
|
+
... else:
|
|
423
|
+
... payload_dict = payload.model_dump(mode='json')
|
|
424
|
+
>>>
|
|
425
|
+
>>> response = await httpx.post(webhook_url, json=payload_dict)
|
|
426
|
+
"""
|
|
427
|
+
if timestamp is None:
|
|
428
|
+
timestamp = datetime.now(timezone.utc)
|
|
429
|
+
|
|
430
|
+
# Convert datetime to ISO string for A2A protocol
|
|
431
|
+
timestamp_str = timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp
|
|
432
|
+
|
|
433
|
+
# Map GeneratedTaskStatus to A2A status state string
|
|
434
|
+
status_value = status.value if hasattr(status, "value") else str(status)
|
|
435
|
+
|
|
436
|
+
# Map AdCP status to A2A status state
|
|
437
|
+
# Note: A2A uses "input-required" (hyphenated) while AdCP uses "input_required" (underscore)
|
|
438
|
+
status_mapping = {
|
|
439
|
+
"completed": "completed",
|
|
440
|
+
"failed": "failed",
|
|
441
|
+
"working": "working",
|
|
442
|
+
"submitted": "submitted",
|
|
443
|
+
"input_required": "input-required",
|
|
444
|
+
}
|
|
445
|
+
a2a_status_state = status_mapping.get(status_value, status_value)
|
|
446
|
+
|
|
447
|
+
# Build parts for the message/artifact
|
|
448
|
+
parts: list[Part] = []
|
|
449
|
+
|
|
450
|
+
# Add DataPart
|
|
451
|
+
# Convert AdcpAsyncResponseData to dict if it's a Pydantic model
|
|
452
|
+
if hasattr(result, "model_dump"):
|
|
453
|
+
result_dict: dict[str, Any] = result.model_dump(mode="json")
|
|
454
|
+
else:
|
|
455
|
+
result_dict = result
|
|
456
|
+
|
|
457
|
+
data_part = DataPart(data=result_dict)
|
|
458
|
+
parts.append(Part(root=data_part))
|
|
459
|
+
|
|
460
|
+
# Determine if this is a terminated status (Task) or intermediate (TaskStatusUpdateEvent)
|
|
461
|
+
is_terminated = status in [GeneratedTaskStatus.completed, GeneratedTaskStatus.failed]
|
|
462
|
+
|
|
463
|
+
# Convert string to TaskState enum
|
|
464
|
+
task_state_enum = TaskState(a2a_status_state)
|
|
465
|
+
|
|
466
|
+
if is_terminated:
|
|
467
|
+
# Create Task object with artifacts for terminated statuses
|
|
468
|
+
task_status = TaskStatus(state=task_state_enum, timestamp=timestamp_str)
|
|
469
|
+
|
|
470
|
+
# Build artifact with parts
|
|
471
|
+
# Note: Artifact requires artifact_id, use task_id as prefix
|
|
472
|
+
if parts:
|
|
473
|
+
artifact = Artifact(
|
|
474
|
+
artifact_id=f"{task_id}_result",
|
|
475
|
+
parts=parts,
|
|
476
|
+
)
|
|
477
|
+
artifacts = [artifact]
|
|
478
|
+
else:
|
|
479
|
+
artifacts = []
|
|
480
|
+
|
|
481
|
+
return Task(
|
|
482
|
+
id=task_id,
|
|
483
|
+
status=task_status,
|
|
484
|
+
artifacts=artifacts,
|
|
485
|
+
context_id=context_id,
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
# Create TaskStatusUpdateEvent with status.message for intermediate statuses
|
|
489
|
+
# Build message with parts
|
|
490
|
+
if parts:
|
|
491
|
+
message_obj = Message(
|
|
492
|
+
message_id=f"{task_id}_msg",
|
|
493
|
+
role=Role.agent, # Agent is responding
|
|
494
|
+
parts=parts,
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
message_obj = None
|
|
498
|
+
|
|
499
|
+
task_status = TaskStatus(
|
|
500
|
+
state=task_state_enum, timestamp=timestamp_str, message=message_obj
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
return TaskStatusUpdateEvent(
|
|
504
|
+
task_id=task_id,
|
|
505
|
+
status=task_status,
|
|
506
|
+
context_id=context_id,
|
|
507
|
+
final=False, # Intermediate statuses are not final
|
|
508
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adcp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.14.0
|
|
4
4
|
Summary: Official Python client for the Ad Context Protocol (AdCP)
|
|
5
5
|
Author-email: AdCP Community <maintainers@adcontextprotocol.org>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -25,7 +25,7 @@ Requires-Dist: httpx>=0.24.0
|
|
|
25
25
|
Requires-Dist: pydantic>=2.0.0
|
|
26
26
|
Requires-Dist: typing-extensions>=4.5.0
|
|
27
27
|
Requires-Dist: a2a-sdk>=0.3.0
|
|
28
|
-
Requires-Dist: mcp>=
|
|
28
|
+
Requires-Dist: mcp>=1.23.2
|
|
29
29
|
Requires-Dist: email-validator>=2.0.0
|
|
30
30
|
Provides-Extra: dev
|
|
31
31
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|