adcp 2.12.2__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/base.py +193 -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.12.2.dist-info → adcp-2.14.0.dist-info}/METADATA +2 -2
- {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/RECORD +61 -45
- adcp/types/generated_poc/core/dimensions.py +0 -18
- {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/WHEEL +0 -0
- {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
- {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/top_level.txt +0 -0
adcp/__init__.py
CHANGED
|
@@ -101,6 +101,7 @@ from adcp.types import (
|
|
|
101
101
|
ListCreativeFormatsResponse,
|
|
102
102
|
ListCreativesRequest,
|
|
103
103
|
ListCreativesResponse,
|
|
104
|
+
McpWebhookPayload,
|
|
104
105
|
MediaBuy,
|
|
105
106
|
MediaBuyStatus,
|
|
106
107
|
Package,
|
|
@@ -178,8 +179,14 @@ from adcp.validation import (
|
|
|
178
179
|
validate_product,
|
|
179
180
|
validate_publisher_properties_item,
|
|
180
181
|
)
|
|
182
|
+
from adcp.webhooks import (
|
|
183
|
+
create_a2a_webhook_payload,
|
|
184
|
+
create_mcp_webhook_payload,
|
|
185
|
+
extract_webhook_result_data,
|
|
186
|
+
get_adcp_signed_headers_for_webhook,
|
|
187
|
+
)
|
|
181
188
|
|
|
182
|
-
__version__ = "2.
|
|
189
|
+
__version__ = "2.14.0"
|
|
183
190
|
|
|
184
191
|
|
|
185
192
|
def get_adcp_version() -> str:
|
|
@@ -215,6 +222,12 @@ __all__ = [
|
|
|
215
222
|
"TaskResult",
|
|
216
223
|
"TaskStatus",
|
|
217
224
|
"WebhookMetadata",
|
|
225
|
+
# Webhook utilities
|
|
226
|
+
"create_mcp_webhook_payload",
|
|
227
|
+
"create_a2a_webhook_payload",
|
|
228
|
+
"get_adcp_signed_headers_for_webhook",
|
|
229
|
+
"extract_webhook_result_data",
|
|
230
|
+
"McpWebhookPayload",
|
|
218
231
|
# Common request/response types (re-exported for convenience)
|
|
219
232
|
"CreateMediaBuyRequest",
|
|
220
233
|
"CreateMediaBuyResponse",
|
adcp/adagents.py
CHANGED
|
@@ -478,12 +478,19 @@ def get_all_tags(adagents_data: dict[str, Any]) -> set[str]:
|
|
|
478
478
|
def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> list[dict[str, Any]]:
|
|
479
479
|
"""Get all properties authorized for a specific agent.
|
|
480
480
|
|
|
481
|
+
Handles all authorization types per the AdCP specification:
|
|
482
|
+
- inline_properties: Properties defined directly in the agent's properties array
|
|
483
|
+
- property_ids: Filter top-level properties by property_id
|
|
484
|
+
- property_tags: Filter top-level properties by tags
|
|
485
|
+
- publisher_properties: References properties from other publisher domains
|
|
486
|
+
(returns the selector objects, not resolved properties)
|
|
487
|
+
|
|
481
488
|
Args:
|
|
482
489
|
adagents_data: Parsed adagents.json data
|
|
483
490
|
agent_url: URL of the agent to filter by
|
|
484
491
|
|
|
485
492
|
Returns:
|
|
486
|
-
List of properties for the specified agent (empty if agent not found
|
|
493
|
+
List of properties for the specified agent (empty if agent not found)
|
|
487
494
|
|
|
488
495
|
Raises:
|
|
489
496
|
AdagentsValidationError: If adagents_data is malformed
|
|
@@ -495,6 +502,11 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li
|
|
|
495
502
|
if not isinstance(authorized_agents, list):
|
|
496
503
|
raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
|
|
497
504
|
|
|
505
|
+
# Get top-level properties for reference-based authorization types
|
|
506
|
+
top_level_properties = adagents_data.get("properties", [])
|
|
507
|
+
if not isinstance(top_level_properties, list):
|
|
508
|
+
top_level_properties = []
|
|
509
|
+
|
|
498
510
|
# Normalize the agent URL for comparison
|
|
499
511
|
normalized_agent_url = normalize_url(agent_url)
|
|
500
512
|
|
|
@@ -510,12 +522,44 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li
|
|
|
510
522
|
if normalize_url(agent_url_from_json) != normalized_agent_url:
|
|
511
523
|
continue
|
|
512
524
|
|
|
513
|
-
# Found the agent -
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
525
|
+
# Found the agent - determine authorization type
|
|
526
|
+
authorization_type = agent.get("authorization_type", "")
|
|
527
|
+
|
|
528
|
+
# Handle inline_properties (properties array directly on agent)
|
|
529
|
+
if authorization_type == "inline_properties" or "properties" in agent:
|
|
530
|
+
properties = agent.get("properties", [])
|
|
531
|
+
if not isinstance(properties, list):
|
|
532
|
+
return []
|
|
533
|
+
return [p for p in properties if isinstance(p, dict)]
|
|
534
|
+
|
|
535
|
+
# Handle property_ids (filter top-level properties by property_id)
|
|
536
|
+
if authorization_type == "property_ids":
|
|
537
|
+
authorized_ids = set(agent.get("property_ids", []))
|
|
538
|
+
return [
|
|
539
|
+
p
|
|
540
|
+
for p in top_level_properties
|
|
541
|
+
if isinstance(p, dict) and p.get("property_id") in authorized_ids
|
|
542
|
+
]
|
|
543
|
+
|
|
544
|
+
# Handle property_tags (filter top-level properties by tags)
|
|
545
|
+
if authorization_type == "property_tags":
|
|
546
|
+
authorized_tags = set(agent.get("property_tags", []))
|
|
547
|
+
return [
|
|
548
|
+
p
|
|
549
|
+
for p in top_level_properties
|
|
550
|
+
if isinstance(p, dict) and set(p.get("tags", [])) & authorized_tags
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
# Handle publisher_properties (cross-domain references)
|
|
554
|
+
# Returns the selector objects; caller must resolve against other domains
|
|
555
|
+
if authorization_type == "publisher_properties":
|
|
556
|
+
publisher_props = agent.get("publisher_properties", [])
|
|
557
|
+
if not isinstance(publisher_props, list):
|
|
558
|
+
return []
|
|
559
|
+
return [p for p in publisher_props if isinstance(p, dict)]
|
|
560
|
+
|
|
561
|
+
# No recognized authorization type - return empty
|
|
562
|
+
return []
|
|
519
563
|
|
|
520
564
|
return []
|
|
521
565
|
|
|
@@ -544,8 +588,8 @@ class AuthorizationContext:
|
|
|
544
588
|
if not isinstance(prop, dict):
|
|
545
589
|
continue
|
|
546
590
|
|
|
547
|
-
# Extract property ID
|
|
548
|
-
prop_id = prop.get("
|
|
591
|
+
# Extract property ID (per AdCP v2 schema, the field is "property_id")
|
|
592
|
+
prop_id = prop.get("property_id")
|
|
549
593
|
if prop_id and isinstance(prop_id, str):
|
|
550
594
|
self.property_ids.append(prop_id)
|
|
551
595
|
|
adcp/client.py
CHANGED
|
@@ -11,6 +11,7 @@ from collections.abc import Callable
|
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from a2a.types import Task, TaskStatusUpdateEvent
|
|
14
15
|
from pydantic import BaseModel
|
|
15
16
|
|
|
16
17
|
from adcp.exceptions import ADCPWebhookSignatureError
|
|
@@ -45,7 +46,6 @@ from adcp.types import (
|
|
|
45
46
|
SyncCreativesResponse,
|
|
46
47
|
UpdateMediaBuyRequest,
|
|
47
48
|
UpdateMediaBuyResponse,
|
|
48
|
-
WebhookPayload,
|
|
49
49
|
)
|
|
50
50
|
from adcp.types.core import (
|
|
51
51
|
Activity,
|
|
@@ -55,6 +55,7 @@ from adcp.types.core import (
|
|
|
55
55
|
TaskResult,
|
|
56
56
|
TaskStatus,
|
|
57
57
|
)
|
|
58
|
+
from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData
|
|
58
59
|
from adcp.utils.operation_id import create_operation_id
|
|
59
60
|
|
|
60
61
|
logger = logging.getLogger(__name__)
|
|
@@ -811,13 +812,23 @@ class ADCPClient:
|
|
|
811
812
|
"""Async context manager exit."""
|
|
812
813
|
await self.close()
|
|
813
814
|
|
|
814
|
-
def _verify_webhook_signature(
|
|
815
|
+
def _verify_webhook_signature(
|
|
816
|
+
self, payload: dict[str, Any], signature: str, timestamp: str
|
|
817
|
+
) -> bool:
|
|
815
818
|
"""
|
|
816
819
|
Verify HMAC-SHA256 signature of webhook payload.
|
|
817
820
|
|
|
821
|
+
The verification algorithm matches get_adcp_signed_headers_for_webhook:
|
|
822
|
+
1. Constructs message as "{timestamp}.{json_payload}"
|
|
823
|
+
2. JSON-serializes payload with compact separators
|
|
824
|
+
3. UTF-8 encodes the message
|
|
825
|
+
4. HMAC-SHA256 signs with the shared secret
|
|
826
|
+
5. Compares against the provided signature (with "sha256=" prefix stripped)
|
|
827
|
+
|
|
818
828
|
Args:
|
|
819
829
|
payload: Webhook payload dict
|
|
820
|
-
signature: Signature to verify
|
|
830
|
+
signature: Signature to verify (with or without "sha256=" prefix)
|
|
831
|
+
timestamp: ISO 8601 timestamp from X-AdCP-Timestamp header
|
|
821
832
|
|
|
822
833
|
Returns:
|
|
823
834
|
True if signature is valid, False otherwise
|
|
@@ -825,22 +836,53 @@ class ADCPClient:
|
|
|
825
836
|
if not self.webhook_secret:
|
|
826
837
|
return True
|
|
827
838
|
|
|
828
|
-
|
|
839
|
+
# Strip "sha256=" prefix if present
|
|
840
|
+
if signature.startswith("sha256="):
|
|
841
|
+
signature = signature[7:]
|
|
842
|
+
|
|
843
|
+
# Serialize payload to JSON with consistent formatting (matches signing)
|
|
844
|
+
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode("utf-8")
|
|
845
|
+
|
|
846
|
+
# Construct signed message: timestamp.payload (matches get_adcp_signed_headers_for_webhook)
|
|
847
|
+
signed_message = f"{timestamp}.{payload_bytes.decode('utf-8')}"
|
|
848
|
+
|
|
849
|
+
# Generate expected signature
|
|
829
850
|
expected_signature = hmac.new(
|
|
830
|
-
self.webhook_secret.encode("utf-8"),
|
|
851
|
+
self.webhook_secret.encode("utf-8"), signed_message.encode("utf-8"), hashlib.sha256
|
|
831
852
|
).hexdigest()
|
|
832
853
|
|
|
833
854
|
return hmac.compare_digest(signature, expected_signature)
|
|
834
855
|
|
|
835
|
-
def _parse_webhook_result(
|
|
856
|
+
def _parse_webhook_result(
|
|
857
|
+
self,
|
|
858
|
+
task_id: str,
|
|
859
|
+
task_type: str,
|
|
860
|
+
operation_id: str,
|
|
861
|
+
status: GeneratedTaskStatus,
|
|
862
|
+
result: Any,
|
|
863
|
+
timestamp: datetime | str,
|
|
864
|
+
message: str | None,
|
|
865
|
+
context_id: str | None,
|
|
866
|
+
) -> TaskResult[AdcpAsyncResponseData]:
|
|
836
867
|
"""
|
|
837
|
-
Parse webhook
|
|
868
|
+
Parse webhook data into typed TaskResult based on task_type.
|
|
838
869
|
|
|
839
870
|
Args:
|
|
840
|
-
|
|
871
|
+
task_id: Unique identifier for this task
|
|
872
|
+
task_type: Task type from application routing (e.g., "get_products")
|
|
873
|
+
operation_id: Operation identifier from application routing
|
|
874
|
+
status: Current task status
|
|
875
|
+
result: Task-specific payload (AdCP response data)
|
|
876
|
+
timestamp: ISO 8601 timestamp when webhook was generated
|
|
877
|
+
message: Human-readable summary of task state
|
|
878
|
+
context_id: Session/conversation identifier
|
|
841
879
|
|
|
842
880
|
Returns:
|
|
843
881
|
TaskResult with task-specific typed response data
|
|
882
|
+
|
|
883
|
+
Note:
|
|
884
|
+
This method works with both MCP and A2A protocols by accepting
|
|
885
|
+
protocol-agnostic parameters rather than protocol-specific objects.
|
|
844
886
|
"""
|
|
845
887
|
from adcp.utils.response_parser import parse_json_or_text
|
|
846
888
|
|
|
@@ -859,21 +901,20 @@ class ADCPClient:
|
|
|
859
901
|
}
|
|
860
902
|
|
|
861
903
|
# Handle completed tasks with result parsing
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
response_type = response_type_map.get(webhook.task_type.value)
|
|
904
|
+
if status == GeneratedTaskStatus.completed and result is not None:
|
|
905
|
+
response_type = response_type_map.get(task_type)
|
|
865
906
|
if response_type:
|
|
866
907
|
try:
|
|
867
|
-
parsed_result: Any = parse_json_or_text(
|
|
868
|
-
return TaskResult[
|
|
908
|
+
parsed_result: Any = parse_json_or_text(result, response_type)
|
|
909
|
+
return TaskResult[AdcpAsyncResponseData](
|
|
869
910
|
status=TaskStatus.COMPLETED,
|
|
870
911
|
data=parsed_result,
|
|
871
912
|
success=True,
|
|
872
913
|
metadata={
|
|
873
|
-
"task_id":
|
|
874
|
-
"operation_id":
|
|
875
|
-
"timestamp":
|
|
876
|
-
"message":
|
|
914
|
+
"task_id": task_id,
|
|
915
|
+
"operation_id": operation_id,
|
|
916
|
+
"timestamp": timestamp,
|
|
917
|
+
"message": message,
|
|
877
918
|
},
|
|
878
919
|
)
|
|
879
920
|
except ValueError as e:
|
|
@@ -881,8 +922,7 @@ class ADCPClient:
|
|
|
881
922
|
# Fall through to untyped result
|
|
882
923
|
|
|
883
924
|
# Handle failed, input-required, or unparseable results
|
|
884
|
-
# Convert
|
|
885
|
-
# Map generated enum values to core enum values
|
|
925
|
+
# Convert status to core TaskStatus enum
|
|
886
926
|
status_map = {
|
|
887
927
|
GeneratedTaskStatus.completed: TaskStatus.COMPLETED,
|
|
888
928
|
GeneratedTaskStatus.submitted: TaskStatus.SUBMITTED,
|
|
@@ -890,77 +930,341 @@ class ADCPClient:
|
|
|
890
930
|
GeneratedTaskStatus.failed: TaskStatus.FAILED,
|
|
891
931
|
GeneratedTaskStatus.input_required: TaskStatus.NEEDS_INPUT,
|
|
892
932
|
}
|
|
893
|
-
task_status = status_map.get(
|
|
894
|
-
|
|
895
|
-
|
|
933
|
+
task_status = status_map.get(status, TaskStatus.FAILED)
|
|
934
|
+
|
|
935
|
+
# Extract error message from result.errors if present
|
|
936
|
+
error_message: str | None = None
|
|
937
|
+
if result is not None and hasattr(result, "errors"):
|
|
938
|
+
errors = getattr(result, "errors", None)
|
|
939
|
+
if errors and len(errors) > 0:
|
|
940
|
+
first_error = errors[0]
|
|
941
|
+
if hasattr(first_error, "message"):
|
|
942
|
+
error_message = first_error.message
|
|
943
|
+
|
|
944
|
+
return TaskResult[AdcpAsyncResponseData](
|
|
896
945
|
status=task_status,
|
|
897
|
-
data=
|
|
898
|
-
success=
|
|
899
|
-
error=
|
|
946
|
+
data=result,
|
|
947
|
+
success=status == GeneratedTaskStatus.completed,
|
|
948
|
+
error=error_message,
|
|
900
949
|
metadata={
|
|
901
|
-
"task_id":
|
|
902
|
-
"operation_id":
|
|
903
|
-
"timestamp":
|
|
904
|
-
"message":
|
|
905
|
-
"context_id":
|
|
906
|
-
"progress": webhook.progress,
|
|
950
|
+
"task_id": task_id,
|
|
951
|
+
"operation_id": operation_id,
|
|
952
|
+
"timestamp": timestamp,
|
|
953
|
+
"message": message,
|
|
954
|
+
"context_id": context_id,
|
|
907
955
|
},
|
|
908
956
|
)
|
|
909
957
|
|
|
910
|
-
async def
|
|
958
|
+
async def _handle_mcp_webhook(
|
|
911
959
|
self,
|
|
912
960
|
payload: dict[str, Any],
|
|
913
|
-
|
|
914
|
-
|
|
961
|
+
task_type: str,
|
|
962
|
+
operation_id: str,
|
|
963
|
+
signature: str | None,
|
|
964
|
+
timestamp: str | None = None,
|
|
965
|
+
) -> TaskResult[AdcpAsyncResponseData]:
|
|
915
966
|
"""
|
|
916
|
-
Handle
|
|
917
|
-
|
|
918
|
-
This method:
|
|
919
|
-
1. Verifies webhook signature (if provided)
|
|
920
|
-
2. Validates payload against WebhookPayload schema
|
|
921
|
-
3. Parses task-specific result data into typed response
|
|
922
|
-
4. Emits activity for monitoring
|
|
967
|
+
Handle MCP webhook delivered via HTTP POST.
|
|
923
968
|
|
|
924
969
|
Args:
|
|
925
970
|
payload: Webhook payload dict
|
|
926
|
-
|
|
971
|
+
task_type: Task type from application routing
|
|
972
|
+
operation_id: Operation identifier from application routing
|
|
973
|
+
signature: Optional HMAC-SHA256 signature for verification (X-AdCP-Signature header)
|
|
974
|
+
timestamp: Optional timestamp for signature verification (X-AdCP-Timestamp header)
|
|
927
975
|
|
|
928
976
|
Returns:
|
|
929
977
|
TaskResult with parsed task-specific response data
|
|
930
978
|
|
|
931
979
|
Raises:
|
|
932
980
|
ADCPWebhookSignatureError: If signature verification fails
|
|
933
|
-
ValidationError: If payload doesn't match
|
|
934
|
-
|
|
935
|
-
Example:
|
|
936
|
-
>>> result = await client.handle_webhook(payload, signature)
|
|
937
|
-
>>> if result.success and isinstance(result.data, GetProductsResponse):
|
|
938
|
-
>>> print(f"Found {len(result.data.products)} products")
|
|
981
|
+
ValidationError: If payload doesn't match McpWebhookPayload schema
|
|
939
982
|
"""
|
|
940
|
-
|
|
941
|
-
|
|
983
|
+
from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload
|
|
984
|
+
|
|
985
|
+
# Verify signature before processing (requires both signature and timestamp)
|
|
986
|
+
if (
|
|
987
|
+
signature
|
|
988
|
+
and timestamp
|
|
989
|
+
and not self._verify_webhook_signature(payload, signature, timestamp)
|
|
990
|
+
):
|
|
942
991
|
logger.warning(
|
|
943
992
|
f"Webhook signature verification failed for agent {self.agent_config.id}"
|
|
944
993
|
)
|
|
945
994
|
raise ADCPWebhookSignatureError("Invalid webhook signature")
|
|
946
995
|
|
|
947
|
-
# Validate and parse webhook payload
|
|
948
|
-
webhook =
|
|
996
|
+
# Validate and parse MCP webhook payload
|
|
997
|
+
webhook = McpWebhookPayload.model_validate(payload)
|
|
998
|
+
|
|
999
|
+
# Emit activity for monitoring
|
|
1000
|
+
self._emit_activity(
|
|
1001
|
+
Activity(
|
|
1002
|
+
type=ActivityType.WEBHOOK_RECEIVED,
|
|
1003
|
+
operation_id=operation_id,
|
|
1004
|
+
agent_id=self.agent_config.id,
|
|
1005
|
+
task_type=task_type,
|
|
1006
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
1007
|
+
metadata={"payload": payload, "protocol": "mcp"},
|
|
1008
|
+
)
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
# Extract fields and parse result
|
|
1012
|
+
return self._parse_webhook_result(
|
|
1013
|
+
task_id=webhook.task_id,
|
|
1014
|
+
task_type=task_type,
|
|
1015
|
+
operation_id=operation_id,
|
|
1016
|
+
status=webhook.status,
|
|
1017
|
+
result=webhook.result,
|
|
1018
|
+
timestamp=webhook.timestamp,
|
|
1019
|
+
message=webhook.message,
|
|
1020
|
+
context_id=webhook.context_id,
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
async def _handle_a2a_webhook(
|
|
1024
|
+
self, payload: Task | TaskStatusUpdateEvent, task_type: str, operation_id: str
|
|
1025
|
+
) -> TaskResult[AdcpAsyncResponseData]:
|
|
1026
|
+
"""
|
|
1027
|
+
Handle A2A webhook delivered through Task or TaskStatusUpdateEvent.
|
|
1028
|
+
|
|
1029
|
+
Per A2A specification:
|
|
1030
|
+
- Terminated statuses (completed, failed): Payload is Task with artifacts[].parts[]
|
|
1031
|
+
- Intermediate statuses (working, input-required, submitted):
|
|
1032
|
+
Payload is TaskStatusUpdateEvent with status.message.parts[]
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
payload: A2A Task or TaskStatusUpdateEvent object
|
|
1036
|
+
task_type: Task type from application routing
|
|
1037
|
+
operation_id: Operation identifier from application routing
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
TaskResult with parsed task-specific response data
|
|
1041
|
+
|
|
1042
|
+
Note:
|
|
1043
|
+
Signature verification is NOT applicable for A2A webhooks
|
|
1044
|
+
as they arrive through authenticated A2A connections, not HTTP.
|
|
1045
|
+
"""
|
|
1046
|
+
from a2a.types import DataPart, TextPart
|
|
1047
|
+
|
|
1048
|
+
adcp_data: Any = None
|
|
1049
|
+
text_message: str | None = None
|
|
1050
|
+
task_id: str
|
|
1051
|
+
context_id: str | None
|
|
1052
|
+
status_state: str
|
|
1053
|
+
timestamp: datetime | str
|
|
1054
|
+
|
|
1055
|
+
# Type detection and extraction based on payload type
|
|
1056
|
+
if isinstance(payload, TaskStatusUpdateEvent):
|
|
1057
|
+
# Intermediate status: Extract from status.message.parts[]
|
|
1058
|
+
task_id = payload.task_id
|
|
1059
|
+
context_id = payload.context_id
|
|
1060
|
+
status_state = payload.status.state if payload.status else "failed"
|
|
1061
|
+
timestamp = (
|
|
1062
|
+
payload.status.timestamp
|
|
1063
|
+
if payload.status and payload.status.timestamp
|
|
1064
|
+
else datetime.now(timezone.utc)
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
# Extract from status.message.parts[]
|
|
1068
|
+
if payload.status and payload.status.message and payload.status.message.parts:
|
|
1069
|
+
# Extract DataPart for structured AdCP payload
|
|
1070
|
+
data_parts = [
|
|
1071
|
+
p.root for p in payload.status.message.parts if isinstance(p.root, DataPart)
|
|
1072
|
+
]
|
|
1073
|
+
if data_parts:
|
|
1074
|
+
# Use last DataPart as authoritative
|
|
1075
|
+
last_data_part = data_parts[-1]
|
|
1076
|
+
adcp_data = last_data_part.data
|
|
1077
|
+
|
|
1078
|
+
# Unwrap {"response": {...}} wrapper if present (ADK pattern)
|
|
1079
|
+
if isinstance(adcp_data, dict) and "response" in adcp_data:
|
|
1080
|
+
if len(adcp_data) == 1:
|
|
1081
|
+
adcp_data = adcp_data["response"]
|
|
1082
|
+
else:
|
|
1083
|
+
adcp_data = adcp_data["response"]
|
|
1084
|
+
|
|
1085
|
+
# Extract TextPart for human-readable message
|
|
1086
|
+
for part in payload.status.message.parts:
|
|
1087
|
+
if isinstance(part.root, TextPart):
|
|
1088
|
+
text_message = part.root.text
|
|
1089
|
+
break
|
|
1090
|
+
|
|
1091
|
+
else:
|
|
1092
|
+
# Terminated status (Task): Extract from artifacts[].parts[]
|
|
1093
|
+
task_id = payload.id
|
|
1094
|
+
context_id = payload.context_id
|
|
1095
|
+
status_state = payload.status.state if payload.status else "failed"
|
|
1096
|
+
timestamp = (
|
|
1097
|
+
payload.status.timestamp
|
|
1098
|
+
if payload.status and payload.status.timestamp
|
|
1099
|
+
else datetime.now(timezone.utc)
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
# Extract from task.artifacts[].parts[]
|
|
1103
|
+
# Following A2A spec: use last artifact, last DataPart is authoritative
|
|
1104
|
+
if payload.artifacts:
|
|
1105
|
+
# Use last artifact (most recent in streaming scenarios)
|
|
1106
|
+
target_artifact = payload.artifacts[-1]
|
|
1107
|
+
|
|
1108
|
+
if target_artifact.parts:
|
|
1109
|
+
# Extract DataPart for structured AdCP payload
|
|
1110
|
+
data_parts = [
|
|
1111
|
+
p.root for p in target_artifact.parts if isinstance(p.root, DataPart)
|
|
1112
|
+
]
|
|
1113
|
+
if data_parts:
|
|
1114
|
+
# Use last DataPart as authoritative
|
|
1115
|
+
last_data_part = data_parts[-1]
|
|
1116
|
+
adcp_data = last_data_part.data
|
|
1117
|
+
|
|
1118
|
+
# Unwrap {"response": {...}} wrapper if present (ADK pattern)
|
|
1119
|
+
if isinstance(adcp_data, dict) and "response" in adcp_data:
|
|
1120
|
+
if len(adcp_data) == 1:
|
|
1121
|
+
adcp_data = adcp_data["response"]
|
|
1122
|
+
else:
|
|
1123
|
+
adcp_data = adcp_data["response"]
|
|
1124
|
+
|
|
1125
|
+
# Extract TextPart for human-readable message
|
|
1126
|
+
for part in target_artifact.parts:
|
|
1127
|
+
if isinstance(part.root, TextPart):
|
|
1128
|
+
text_message = part.root.text
|
|
1129
|
+
break
|
|
1130
|
+
|
|
1131
|
+
# Map A2A status.state to GeneratedTaskStatus enum
|
|
1132
|
+
status_map = {
|
|
1133
|
+
"completed": GeneratedTaskStatus.completed,
|
|
1134
|
+
"submitted": GeneratedTaskStatus.submitted,
|
|
1135
|
+
"working": GeneratedTaskStatus.working,
|
|
1136
|
+
"failed": GeneratedTaskStatus.failed,
|
|
1137
|
+
"input-required": GeneratedTaskStatus.input_required,
|
|
1138
|
+
"input_required": GeneratedTaskStatus.input_required, # Handle both formats
|
|
1139
|
+
}
|
|
1140
|
+
mapped_status = status_map.get(status_state, GeneratedTaskStatus.failed)
|
|
949
1141
|
|
|
950
1142
|
# Emit activity for monitoring
|
|
951
1143
|
self._emit_activity(
|
|
952
1144
|
Activity(
|
|
953
1145
|
type=ActivityType.WEBHOOK_RECEIVED,
|
|
954
|
-
operation_id=
|
|
1146
|
+
operation_id=operation_id,
|
|
955
1147
|
agent_id=self.agent_config.id,
|
|
956
|
-
task_type=
|
|
1148
|
+
task_type=task_type,
|
|
957
1149
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
958
|
-
metadata={
|
|
1150
|
+
metadata={
|
|
1151
|
+
"task_id": task_id,
|
|
1152
|
+
"protocol": "a2a",
|
|
1153
|
+
"payload_type": (
|
|
1154
|
+
"TaskStatusUpdateEvent"
|
|
1155
|
+
if isinstance(payload, TaskStatusUpdateEvent)
|
|
1156
|
+
else "Task"
|
|
1157
|
+
),
|
|
1158
|
+
},
|
|
959
1159
|
)
|
|
960
1160
|
)
|
|
961
1161
|
|
|
962
|
-
# Parse and return typed result
|
|
963
|
-
return self._parse_webhook_result(
|
|
1162
|
+
# Parse and return typed result by passing extracted fields directly
|
|
1163
|
+
return self._parse_webhook_result(
|
|
1164
|
+
task_id=task_id,
|
|
1165
|
+
task_type=task_type,
|
|
1166
|
+
operation_id=operation_id,
|
|
1167
|
+
status=mapped_status,
|
|
1168
|
+
result=adcp_data,
|
|
1169
|
+
timestamp=timestamp,
|
|
1170
|
+
message=text_message,
|
|
1171
|
+
context_id=context_id,
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
async def handle_webhook(
|
|
1175
|
+
self,
|
|
1176
|
+
payload: dict[str, Any] | Task | TaskStatusUpdateEvent,
|
|
1177
|
+
task_type: str,
|
|
1178
|
+
operation_id: str,
|
|
1179
|
+
signature: str | None = None,
|
|
1180
|
+
timestamp: str | None = None,
|
|
1181
|
+
) -> TaskResult[AdcpAsyncResponseData]:
|
|
1182
|
+
"""
|
|
1183
|
+
Handle incoming webhook and return typed result.
|
|
1184
|
+
|
|
1185
|
+
This method provides a unified interface for handling webhooks from both
|
|
1186
|
+
MCP and A2A protocols:
|
|
1187
|
+
|
|
1188
|
+
- MCP Webhooks: HTTP POST with dict payload, optional HMAC signature
|
|
1189
|
+
- A2A Webhooks: Task or TaskStatusUpdateEvent objects based on status
|
|
1190
|
+
|
|
1191
|
+
The method automatically detects the protocol type and routes to the
|
|
1192
|
+
appropriate handler. Both protocols return a consistent TaskResult
|
|
1193
|
+
structure with typed AdCP response data.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
payload: Webhook payload - one of:
|
|
1197
|
+
- dict[str, Any]: MCP webhook payload from HTTP POST
|
|
1198
|
+
- Task: A2A webhook for terminated statuses (completed, failed)
|
|
1199
|
+
- TaskStatusUpdateEvent: A2A webhook for intermediate statuses
|
|
1200
|
+
(working, input-required, submitted)
|
|
1201
|
+
task_type: Task type from application routing (e.g., "get_products").
|
|
1202
|
+
Applications should extract this from URL routing pattern:
|
|
1203
|
+
/webhook/{task_type}/{agent_id}/{operation_id}
|
|
1204
|
+
operation_id: Operation identifier from application routing.
|
|
1205
|
+
Used to correlate webhook notifications with original task submission.
|
|
1206
|
+
signature: Optional HMAC-SHA256 signature for MCP webhook verification
|
|
1207
|
+
(X-AdCP-Signature header). Ignored for A2A webhooks.
|
|
1208
|
+
timestamp: Optional timestamp for MCP webhook signature verification
|
|
1209
|
+
(X-AdCP-Timestamp header). Required when signature is provided.
|
|
1210
|
+
|
|
1211
|
+
Returns:
|
|
1212
|
+
TaskResult with parsed task-specific response data. The structure
|
|
1213
|
+
is identical regardless of protocol.
|
|
1214
|
+
|
|
1215
|
+
Raises:
|
|
1216
|
+
ADCPWebhookSignatureError: If MCP signature verification fails
|
|
1217
|
+
ValidationError: If MCP payload doesn't match WebhookPayload schema
|
|
1218
|
+
|
|
1219
|
+
Note:
|
|
1220
|
+
task_type and operation_id were deprecated from the webhook payload
|
|
1221
|
+
per AdCP specification. Applications must extract these from URL
|
|
1222
|
+
routing and pass them explicitly.
|
|
1223
|
+
|
|
1224
|
+
Examples:
|
|
1225
|
+
MCP webhook (HTTP endpoint):
|
|
1226
|
+
>>> @app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
|
|
1227
|
+
>>> async def webhook_handler(task_type: str, operation_id: str, request: Request):
|
|
1228
|
+
>>> payload = await request.json()
|
|
1229
|
+
>>> signature = request.headers.get("X-AdCP-Signature")
|
|
1230
|
+
>>> timestamp = request.headers.get("X-AdCP-Timestamp")
|
|
1231
|
+
>>> result = await client.handle_webhook(
|
|
1232
|
+
>>> payload, task_type, operation_id, signature, timestamp
|
|
1233
|
+
>>> )
|
|
1234
|
+
>>> if result.success:
|
|
1235
|
+
>>> print(f"Task completed: {result.data}")
|
|
1236
|
+
|
|
1237
|
+
A2A webhook with Task (terminated status):
|
|
1238
|
+
>>> async def on_task_completed(task: Task):
|
|
1239
|
+
>>> # Extract task_type and operation_id from your app's task tracking
|
|
1240
|
+
>>> task_type = your_task_registry.get_type(task.id)
|
|
1241
|
+
>>> operation_id = your_task_registry.get_operation_id(task.id)
|
|
1242
|
+
>>> result = await client.handle_webhook(
|
|
1243
|
+
>>> task, task_type, operation_id
|
|
1244
|
+
>>> )
|
|
1245
|
+
>>> if result.success:
|
|
1246
|
+
>>> print(f"Task completed: {result.data}")
|
|
1247
|
+
|
|
1248
|
+
A2A webhook with TaskStatusUpdateEvent (intermediate status):
|
|
1249
|
+
>>> async def on_task_update(event: TaskStatusUpdateEvent):
|
|
1250
|
+
>>> # Extract task_type and operation_id from your app's task tracking
|
|
1251
|
+
>>> task_type = your_task_registry.get_type(event.task_id)
|
|
1252
|
+
>>> operation_id = your_task_registry.get_operation_id(event.task_id)
|
|
1253
|
+
>>> result = await client.handle_webhook(
|
|
1254
|
+
>>> event, task_type, operation_id
|
|
1255
|
+
>>> )
|
|
1256
|
+
>>> if result.status == GeneratedTaskStatus.working:
|
|
1257
|
+
>>> print(f"Task still working: {result.metadata.get('message')}")
|
|
1258
|
+
"""
|
|
1259
|
+
# Detect protocol type and route to appropriate handler
|
|
1260
|
+
if isinstance(payload, (Task, TaskStatusUpdateEvent)):
|
|
1261
|
+
# A2A webhook (Task or TaskStatusUpdateEvent)
|
|
1262
|
+
return await self._handle_a2a_webhook(payload, task_type, operation_id)
|
|
1263
|
+
else:
|
|
1264
|
+
# MCP webhook (dict payload)
|
|
1265
|
+
return await self._handle_mcp_webhook(
|
|
1266
|
+
payload, task_type, operation_id, signature, timestamp
|
|
1267
|
+
)
|
|
964
1268
|
|
|
965
1269
|
|
|
966
1270
|
class ADCPMultiAgentClient:
|
adcp/protocols/mcp.py
CHANGED
|
@@ -136,9 +136,7 @@ class MCPAdapter(ProtocolAdapter):
|
|
|
136
136
|
and ("cancel scope" in exc_str or "async context" in exc_str)
|
|
137
137
|
) or (
|
|
138
138
|
# HTTP errors during cleanup (if httpx is available)
|
|
139
|
-
HTTPX_AVAILABLE
|
|
140
|
-
and HTTPStatusError is not None
|
|
141
|
-
and isinstance(exc, HTTPStatusError)
|
|
139
|
+
HTTPX_AVAILABLE and HTTPStatusError is not None and isinstance(exc, HTTPStatusError)
|
|
142
140
|
)
|
|
143
141
|
|
|
144
142
|
if is_known_cleanup_error:
|