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.
Files changed (62) hide show
  1. adcp/__init__.py +14 -1
  2. adcp/adagents.py +53 -9
  3. adcp/client.py +361 -57
  4. adcp/protocols/mcp.py +1 -3
  5. adcp/types/__init__.py +9 -33
  6. adcp/types/_generated.py +82 -13
  7. adcp/types/aliases.py +23 -0
  8. adcp/types/base.py +193 -0
  9. adcp/types/generated_poc/adagents.py +9 -13
  10. adcp/types/generated_poc/core/activation_key.py +12 -2
  11. adcp/types/generated_poc/core/assets/daast_asset.py +12 -2
  12. adcp/types/generated_poc/core/assets/image_asset.py +9 -5
  13. adcp/types/generated_poc/core/assets/vast_asset.py +12 -2
  14. adcp/types/generated_poc/core/assets/video_asset.py +9 -5
  15. adcp/types/generated_poc/core/async_response_data.py +72 -0
  16. adcp/types/generated_poc/core/brand_manifest_ref.py +35 -0
  17. adcp/types/generated_poc/core/creative_asset.py +4 -6
  18. adcp/types/generated_poc/core/creative_manifest.py +4 -6
  19. adcp/types/generated_poc/core/deployment.py +16 -8
  20. adcp/types/generated_poc/core/destination.py +12 -2
  21. adcp/types/generated_poc/core/format.py +3 -3
  22. adcp/types/generated_poc/core/{webhook_payload.py → mcp_webhook_payload.py} +8 -33
  23. adcp/types/generated_poc/core/pricing_option.py +51 -0
  24. adcp/types/generated_poc/core/product.py +4 -29
  25. adcp/types/generated_poc/core/promoted_offerings.py +5 -21
  26. adcp/types/generated_poc/core/property.py +4 -6
  27. adcp/types/generated_poc/core/publisher_property_selector.py +15 -2
  28. adcp/types/generated_poc/core/push_notification_config.py +1 -4
  29. adcp/types/generated_poc/core/start_timing.py +18 -0
  30. adcp/types/generated_poc/core/sub_asset.py +12 -2
  31. adcp/types/generated_poc/creative/preview_creative_response.py +3 -14
  32. adcp/types/generated_poc/creative/preview_render.py +3 -11
  33. adcp/types/generated_poc/media_buy/create_media_buy_async_response_input_required.py +37 -0
  34. adcp/types/generated_poc/media_buy/create_media_buy_async_response_submitted.py +19 -0
  35. adcp/types/generated_poc/media_buy/create_media_buy_async_response_working.py +31 -0
  36. adcp/types/generated_poc/media_buy/create_media_buy_request.py +7 -26
  37. adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +4 -2
  38. adcp/types/generated_poc/media_buy/get_products_async_response_input_required.py +38 -0
  39. adcp/types/generated_poc/media_buy/get_products_async_response_submitted.py +24 -0
  40. adcp/types/generated_poc/media_buy/get_products_async_response_working.py +35 -0
  41. adcp/types/generated_poc/media_buy/get_products_request.py +5 -20
  42. adcp/types/generated_poc/media_buy/list_creatives_response.py +5 -7
  43. adcp/types/generated_poc/media_buy/sync_creatives_async_response_input_required.py +31 -0
  44. adcp/types/generated_poc/media_buy/sync_creatives_async_response_submitted.py +19 -0
  45. adcp/types/generated_poc/media_buy/sync_creatives_async_response_working.py +37 -0
  46. adcp/types/generated_poc/media_buy/update_media_buy_async_response_input_required.py +30 -0
  47. adcp/types/generated_poc/media_buy/update_media_buy_async_response_submitted.py +19 -0
  48. adcp/types/generated_poc/media_buy/update_media_buy_async_response_working.py +31 -0
  49. adcp/types/generated_poc/media_buy/update_media_buy_request.py +4 -14
  50. adcp/types/generated_poc/signals/activate_signal_request.py +2 -2
  51. adcp/types/generated_poc/signals/activate_signal_response.py +2 -2
  52. adcp/types/generated_poc/signals/get_signals_request.py +2 -2
  53. adcp/types/generated_poc/signals/get_signals_response.py +2 -3
  54. adcp/utils/preview_cache.py +6 -4
  55. adcp/webhooks.py +508 -0
  56. {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/METADATA +2 -2
  57. {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/RECORD +61 -45
  58. adcp/types/generated_poc/core/dimensions.py +0 -18
  59. {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/WHEEL +0 -0
  60. {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/entry_points.txt +0 -0
  61. {adcp-2.12.2.dist-info → adcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
  62. {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.12.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 or no properties)
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 - return their properties
514
- properties = agent.get("properties", [])
515
- if not isinstance(properties, list):
516
- return []
517
-
518
- return [p for p in properties if isinstance(p, dict)]
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("id")
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(self, payload: dict[str, Any], signature: str) -> bool:
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
- payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
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"), payload_bytes, hashlib.sha256
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(self, webhook: WebhookPayload) -> TaskResult[Any]:
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 payload into typed TaskResult based on task_type.
868
+ Parse webhook data into typed TaskResult based on task_type.
838
869
 
839
870
  Args:
840
- webhook: Validated webhook payload
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
- if webhook.status == GeneratedTaskStatus.completed and webhook.result is not None:
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(webhook.result, response_type)
868
- return TaskResult[Any](
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": webhook.task_id,
874
- "operation_id": webhook.operation_id,
875
- "timestamp": webhook.timestamp,
876
- "message": webhook.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 webhook status to core TaskStatus enum
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(webhook.status, TaskStatus.FAILED)
894
-
895
- return TaskResult[Any](
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=webhook.result,
898
- success=webhook.status == GeneratedTaskStatus.completed,
899
- error=webhook.error if isinstance(webhook.error, str) else None,
946
+ data=result,
947
+ success=status == GeneratedTaskStatus.completed,
948
+ error=error_message,
900
949
  metadata={
901
- "task_id": webhook.task_id,
902
- "operation_id": webhook.operation_id,
903
- "timestamp": webhook.timestamp,
904
- "message": webhook.message,
905
- "context_id": webhook.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 handle_webhook(
958
+ async def _handle_mcp_webhook(
911
959
  self,
912
960
  payload: dict[str, Any],
913
- signature: str | None = None,
914
- ) -> TaskResult[Any]:
961
+ task_type: str,
962
+ operation_id: str,
963
+ signature: str | None,
964
+ timestamp: str | None = None,
965
+ ) -> TaskResult[AdcpAsyncResponseData]:
915
966
  """
916
- Handle incoming webhook and return typed result.
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
- signature: Optional HMAC-SHA256 signature for verification
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 WebhookPayload schema
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
- # Verify signature before processing
941
- if signature and not self._verify_webhook_signature(payload, signature):
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 = WebhookPayload.model_validate(payload)
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=webhook.operation_id or "unknown",
1146
+ operation_id=operation_id,
955
1147
  agent_id=self.agent_config.id,
956
- task_type=webhook.task_type.value,
1148
+ task_type=task_type,
957
1149
  timestamp=datetime.now(timezone.utc).isoformat(),
958
- metadata={"payload": payload},
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(webhook)
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: