Skip to content

Uniform serialization seam for create_a2a/mcp_webhook_payload return shapes #601

@bokelley

Description

@bokelley

Problem

adcp.webhooks exports two helpers that adopters use when forwarding lifecycle events to a buyer-registered push_notification_config.url:

Helper Returns Serialization API
create_a2a_webhook_payload a2a_pb2.Task / a2a_pb2.TaskStatusUpdateEvent (protobuf, since a2a-sdk 1.0+) MessageToDict(...)
create_mcp_webhook_payload dict[str, Any] (today; ideally McpWebhookPayload Pydantic) already-dict, but adopters wrap as McpWebhookPayload.model_construct(**dict) to get a typed shape

A sender that accepts "any AdCP webhook payload" can't uniformly serialize the result. Adopters end up writing per-shape dispatch:

if isinstance(payload, (Task, TaskStatusUpdateEvent)):
    payload_dict = MessageToDict(payload, preserving_proto_field_name=False)
elif isinstance(payload, McpWebhookPayload):
    payload_dict = payload.model_dump(mode="json", exclude_none=True)
elif isinstance(payload, dict):
    payload_dict = payload

Brittle: a future a2a-sdk version that adds a Pydantic façade would silently change which branch runs, and every adopter has to repeat this dispatch in their HTTP send path.

Repro (downstream context)

Before this dispatch was added in salesagent PR #209, the salesagent protocol_webhook_service.send_notification called payload.model_dump(...) unconditionally. With a2a.types.Task resolving to a protobuf message in a2a-sdk 1.0+, this raised:

AttributeError: 'Task' object has no attribute 'model_dump'

…mid-delivery. Webhook silently failed; buyer never received completion notification. salesagent's investigation chain in bokelley/salesagent#64 traced the failure.

Proposal

Add a uniform serialization seam to adcp.webhooks:

from typing import Any
from a2a.types import Task, TaskStatusUpdateEvent
from adcp.types import McpWebhookPayload

def to_wire_dict(
    payload: Task | TaskStatusUpdateEvent | McpWebhookPayload | dict[str, Any],
) -> dict[str, Any]:
    """Serialize any webhook payload to a JSON-ready dict.

    - protobuf (Task / TaskStatusUpdateEvent) → MessageToDict with
      preserving_proto_field_name=False so JSON keys match the A2A wire
      spec (camelCase: ``id``, ``taskId``, ``contextId``).
    - Pydantic (McpWebhookPayload, future Pydantic façades) → model_dump.
    - dict → return as-is (legacy adopter passthrough).
    """
    from google.protobuf.json_format import MessageToDict

    if isinstance(payload, (Task, TaskStatusUpdateEvent)):
        return MessageToDict(payload, preserving_proto_field_name=False)
    if isinstance(payload, McpWebhookPayload):
        return payload.model_dump(mode="json", exclude_none=True)
    if isinstance(payload, dict):
        return payload
    raise TypeError(
        f"Unsupported webhook payload type {type(payload).__name__}: expected "
        "Task / TaskStatusUpdateEvent (protobuf), McpWebhookPayload (pydantic), or dict"
    )

Adopters then do payload_dict = to_wire_dict(payload) regardless of which builder produced it. Backward-compatible: existing per-shape dispatch keeps working; to_wire_dict is opt-in.

Bonus: align return types

create_mcp_webhook_payload returning McpWebhookPayload (Pydantic) instead of dict[str, Any] would let adopters drop the .model_construct(**dict) ceremony. salesagent's context_manager.py carries a TODO about this:

# TODO: Fix in adcp python client - create_mcp_webhook_payload should return
# McpWebhookPayload instead of dict[str, Any] for proper type safety
mcp_payload_dict = create_mcp_webhook_payload(step.step_id, status_enum, step.response_data)
payload = McpWebhookPayload.model_construct(**mcp_payload_dict)

With consistent return types and to_wire_dict as the single serialization seam, adopters never have to know the underlying shape.

Acceptance criteria

  • adcp.webhooks.to_wire_dict exported from adcp.webhooks and adcp package root.
  • Roundtrip test: to_wire_dict(create_a2a_webhook_payload(...)) produces a dict with id / contextId keys (camelCase per A2A wire).
  • Roundtrip test: to_wire_dict(create_mcp_webhook_payload(...)) produces a dict with task_id / task_type (MCP schema).
  • Optional: create_mcp_webhook_payload returns McpWebhookPayload instead of dict (with a deprecation shim for one minor release).

References

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions