From f84a78fc4251da70d6e011fef16226383c35d0ef Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 9 Nov 2026 04:06:02 +0000 Subject: [PATCH 1/5] fix(server): read the A2A profile's input member, and refuse parameters when the profile is activated The default A2A parser read only an invocation's `parameters` member. A client following the AdCP A2A profile (@adcp/sdk 16.7.0 on any A2A 1.0 agent) sends `{skill, input}`, so handlers received `{}`: `context` was not echoed and versioned tools failed with VERSION_UNSUPPORTED. The parser now reads `parameters` when present, otherwise `input`, and never merges them, so a caller that does not activate the profile is dispatched exactly as before. A request that activates the profile (A2A-Extensions, or the Message's extensions, names https://adcontextprotocol.org/extensions/adcp/v3) must carry exactly one DataPart with an `input` object; `parameters`, alone or beside `input`, is refused with INVALID_REQUEST before handler entry, as the profile requires. The raw-body receipt path reads the same member. Backport of 3f97994e7 from next. Refs #1887 Co-Authored-By: Claude Opus 5.5 --- src/adcp/reporting/receipts/transport.py | 4 +- src/adcp/server/a2a_server.py | 110 ++++++++++++++++++++--- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/adcp/reporting/receipts/transport.py b/src/adcp/reporting/receipts/transport.py index 901ca58e5a..9d8942dd3a 100644 --- a/src/adcp/reporting/receipts/transport.py +++ b/src/adcp/reporting/receipts/transport.py @@ -158,7 +158,9 @@ def a2a_receipt_parameters(body: bytes | None, *, task: str = TASK) -> dict[str, if invocation is None: return None try: - params = invocation.get("parameters") + # The same member the A2A dispatcher reads: legacy ``parameters`` when + # present, else the AdCP A2A profile's ``input``; never a merge. + params = invocation.get("parameters" if "parameters" in invocation else "input") if type(params) is dict: return _parameters(params, task) except (ValueError, TypeError, RecursionError): diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 926d8243f5..4f03ce2451 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -72,7 +72,9 @@ A2A :class:`RequestContext`. The default parser handles a DataPart (``data`` oneof) carrying -``{"skill": ..., "parameters": ...}`` plus a TextPart JSON fallback. +``{"skill": ..., "input": ...}`` (the AdCP A2A profile) or the legacy +``{"skill": ..., "parameters": ...}``, plus a TextPart JSON fallback; see +:func:`_invocation_arguments` for which member is read. Override this hook to accept alternative wire shapes — JSON-RPC 2.0 message bodies, vendor-specific DataPart schemas, or text-only skill encodings. Return ``(None, {})`` to signal "no parseable skill"; the @@ -156,6 +158,9 @@ def _get_decisioning_adcp_error_types() -> tuple[type[BaseException], ...]: _A2A_REQUEST_CONTEXT: ContextVar[Any | None] = ContextVar("adcp_a2a_request_context", default=None) _A2A_PARSED_REQUEST_SCOPE_KEY = "adcp.a2a_parsed_request" +#: AdCP A2A profile (``a2a-profile-extension.mdx``). A request activates it by +#: naming this URI in the ``A2A-Extensions`` service parameter. +_ADCP_A2A_PROFILE_URI = "https://adcontextprotocol.org/extensions/adcp/v3" class _A2ARequestContextMiddleware: @@ -268,6 +273,58 @@ def _normalize_a2a_parameters(params: Any) -> dict[str, Any]: return normalized +def _invocation_arguments(data: dict[str, Any]) -> dict[str, Any]: + """The task arguments of one invocation object: ``parameters`` or ``input``. + + One member is read, never a merge of both. ``parameters`` wins when the + key is present, so a caller that does not activate the AdCP A2A profile + (A2A 0.x clients, earlier SDKs) is dispatched exactly as before; otherwise + the profile's ``input`` member is read. The executor refuses a + profile-activated request that carries ``parameters`` at all + (:func:`_profile_invocation_violation`), so every request it accepts under + the profile is dispatched from ``input``. The choice depends only on the + invocation object, so the auth and signing middleware, which parse without + the request headers, see the same arguments as dispatch. + """ + member = "parameters" if "parameters" in data else "input" + return _normalize_a2a_parameters(data.get(member, {})) + + +def _profile_invocation_violation(context: RequestContext) -> str | None: + """Why a profile-activated message breaks the invocation shape, else ``None``. + + Under the AdCP A2A profile a Message carries exactly one DataPart whose + object has an object ``input``. ``parameters`` "is not an alias for + ``input``", and the agent "MUST reject the invocation rather than choose + among ambiguous inputs" — so ``parameters``, alone or beside ``input``, + is refused. A request that does not activate the profile returns ``None`` + and keeps the legacy shapes. + + The profile is activated by the ``A2A-Extensions`` service parameter; the + Message's own ``extensions`` list naming the URI counts too. Member names + are read from the protobuf ``Value`` directly, so a number the JSON + printer refuses cannot fail this check. + """ + message = context.message + activated = _ADCP_A2A_PROFILE_URI in context.requested_extensions or ( + message is not None and _ADCP_A2A_PROFILE_URI in message.extensions + ) + if not activated: + return None + parts = message.parts if message is not None else [] + data = [part.data for part in parts if part.WhichOneof("content") == "data"] + if len(data) != 1: + return "data_part_count" + if data[0].WhichOneof("kind") != "struct_value": + return "data_not_object" + fields = data[0].struct_value.fields + if "parameters" in fields: + return "parameters_member" + if "input" not in fields or fields["input"].WhichOneof("kind") != "struct_value": + return "input_missing" + return None + + def _default_parse_request(context: RequestContext) -> tuple[str | None, dict[str, Any]]: """Extract one unambiguous ADCP skill invocation from an A2A message. @@ -286,7 +343,7 @@ def _default_parse_request(context: RequestContext) -> tuple[str | None, dict[st if data is not None: skill = data.get("skill") if skill: - parsed.append((str(skill), _normalize_a2a_parameters(data.get("parameters", {})))) + parsed.append((str(skill), _invocation_arguments(data))) continue text = _part_text(part) @@ -297,12 +354,7 @@ def _default_parse_request(context: RequestContext) -> tuple[str | None, dict[st except (json.JSONDecodeError, TypeError): continue if isinstance(data, dict) and data.get("skill"): - parsed.append( - ( - str(data["skill"]), - _normalize_a2a_parameters(data.get("parameters", {})), - ) - ) + parsed.append((str(data["skill"]), _invocation_arguments(data))) if len(parsed) != 1: return None, {} @@ -375,8 +427,14 @@ class ADCPAgentExecutor(AgentExecutor): parameters, dispatched to the matching handler method, and the result is published back as A2A Task events. - Expects the explicit skill invocation format used by A2AAdapter: + Expects an explicit skill invocation DataPart, in the AdCP A2A profile's + shape or the legacy shape A2AAdapter sends: + Part(data={"skill": "get_products", "input": {...}}) Part(data={"skill": "get_products", "parameters": {...}}) + + A request that activates the profile (``A2A-Extensions`` names + ``https://adcontextprotocol.org/extensions/adcp/v3``) must use ``input``; + one carrying ``parameters`` is refused before handler entry. """ def __init__( @@ -506,6 +564,29 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non """Execute an ADCP skill from an incoming A2A message.""" skill_name, params = self._parse_request(context) + violation = _profile_invocation_violation(context) if self._message_parser is None else None + if violation is not None: + # The AdCP A2A profile refuses an ambiguous or legacy-shaped + # activated invocation before handler entry. Nothing is echoed: + # echoing ``context`` from either member would choose between them. + from adcp.decisioning.types import AdcpError + + logger.info("a2a dispatch rejected", extra={"reason": violation}) + await self._send_adcp_error( + event_queue, + context, + AdcpError( + "INVALID_REQUEST", + message=( + "An invocation that activates the AdCP A2A profile must carry " + "exactly one DataPart with an `input` object and no `parameters`" + ), + recovery="correctable", + ), + skill_name=skill_name or "", + ) + return + if skill_name is None: await self._send_error(event_queue, context, "No skill specified in message") return @@ -715,7 +796,8 @@ def _default_parse_request(self, context: RequestContext) -> tuple[str | None, d """Built-in parser. Supports two formats: 1. Explicit skill invocation via a DataPart: - ``Part(data={"skill": "get_products", "parameters": {...}})`` + ``Part(data={"skill": "get_products", "input": {...}})``, or the + legacy ``"parameters"`` member (see :func:`_invocation_arguments`) 2. Natural language fallback via TextPart (best-effort parse) Exposed as a module-level method so custom parsers can compose @@ -729,7 +811,7 @@ def _parse_text_request(self, text: str) -> tuple[str | None, dict[str, Any]]: try: data = json.loads(text) if isinstance(data, dict) and "skill" in data: - return str(data["skill"]), _normalize_a2a_parameters(data.get("parameters", {})) + return str(data["skill"]), _invocation_arguments(data) except (json.JSONDecodeError, TypeError): pass return None, {} @@ -1342,8 +1424,12 @@ def create_a2a_server( audit hooks need. message_parser: Optional :data:`MessageParser` for alternative wire shapes. The default parser handles a DataPart carrying + ``{"skill": ..., "input": ...}`` or the legacy ``{"skill": ..., "parameters": ...}`` plus a TextPart JSON - fallback. Supply this to accept JSON-RPC 2.0 message bodies, + fallback, and the executor enforces the AdCP A2A profile's + invocation shape on requests that activate it. A custom parser + owns its wire shapes, so that check is skipped. Supply this to + accept JSON-RPC 2.0 message bodies, vendor-specific DataPart schemas, or other layouts. The callable returns ``(skill_name, params)`` or ``(None, {})`` for "no parseable skill"; see :data:`MessageParser` and From 3c520d255947e06027719c09c632c80c3d437878 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 9 Nov 2026 04:12:19 +0000 Subject: [PATCH 2/5] test(server): cover the A2A input member and the profile's parameters refusal over the real app Refs #1887 Co-Authored-By: Claude Opus 5.5 --- tests/test_a2a_profile_input_member.py | 313 +++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tests/test_a2a_profile_input_member.py diff --git a/tests/test_a2a_profile_input_member.py b/tests/test_a2a_profile_input_member.py new file mode 100644 index 0000000000..818baf8ced --- /dev/null +++ b/tests/test_a2a_profile_input_member.py @@ -0,0 +1,313 @@ +"""A2A invocation member rule: ``input`` vs ``parameters`` (adcp-client-python#1887). + +The AdCP A2A profile sends ``{"skill": ..., "input": {...}}``; the legacy +shape sends ``parameters``. The default parser reads ``parameters`` when +present, otherwise ``input``, and never merges them. A request that activates +the profile (``A2A-Extensions`` header or ``message.extensions``) must carry +exactly one DataPart with an object ``input`` and no ``parameters``. + +Everything is driven over HTTP through the real app, not mocked parsers. +""" + +from __future__ import annotations + +import contextlib +import json +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +from asgi_lifespan import LifespanManager + +from adcp.reporting.receipts.transport import a2a_receipt_parameters +from adcp.reporting.receipts.wire import TASK as RECEIPT_TASK +from adcp.server import ADCPHandler, RequestSignatureVerification +from adcp.signing import StaticSignerKeys + +PROFILE = "https://adcontextprotocol.org/extensions/adcp/v3" +ARGS: dict[str, Any] = {"brief": "ctv", "adcp_version": "3.2", "context": {"trace": "t-1"}} + +V1 = {"content-type": "application/json", "A2A-Version": "1.0"} +V1_ACTIVE = {**V1, "A2A-Extensions": PROFILE} +V03 = {"content-type": "application/json"} + + +class _InputMemberSeller(ADCPHandler[Any]): + advertised_tools: set[str] = {"get_adcp_capabilities", "get_products"} + + def __init__(self) -> None: + self.seen: list[dict[str, Any]] = [] + + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"adcp": {"major_versions": [3]}, "supported_protocols": ["media_buy"]} + + async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: + if not isinstance(params, dict): + params = params.model_dump(exclude_none=True) + self.seen.append(params) + return {"products": []} + + +def _app(handler: ADCPHandler[Any], **kwargs: Any) -> Any: + from adcp.server.serve import _build_mcp_and_a2a_app + + return _build_mcp_and_a2a_app( + handler, + name="test-agent", + port=0, + host="127.0.0.1", + instructions=None, + test_controller=None, + validation=None, + advertise_all=False, + stateless_http=True, + allowed_hosts=["localhost"], + auth=None, + **kwargs, + ) + + +@contextlib.asynccontextmanager +async def _client(app: Any) -> AsyncIterator[httpx.AsyncClient]: + async with LifespanManager(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost" + ) as client: + yield client + + +def _v1(data: dict[str, Any], **message: Any) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "m", + "role": "ROLE_USER", + "parts": [{"data": data}], + **message, + } + }, + } + + +def _v03(data: dict[str, Any]) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "m", + "role": "user", + "kind": "message", + "parts": [{"kind": "data", "data": data}], + } + }, + } + + +async def _post( + handler: _InputMemberSeller, + body: dict[str, Any], + headers: dict[str, str], + **app_kwargs: Any, +) -> dict[str, Any]: + async with _client(_app(handler, **app_kwargs)) as client: + response = await client.post("/", content=json.dumps(body).encode(), headers=headers) + assert response.status_code == 200, response.text + result = response.json()["result"] + return result.get("task", result) # type: ignore[no-any-return] + + +def _state(task: dict[str, Any]) -> str: + # 1.0 spells it TASK_STATE_COMPLETED, 0.3 spells it completed. + return str(task["status"]["state"]).upper() + + +def _data(task: dict[str, Any]) -> dict[str, Any]: + parts = task["artifacts"][0]["parts"] + return next(p["data"] for p in parts if "data" in p) # type: ignore[no-any-return] + + +def _assert_completed_with_args(handler: _InputMemberSeller, task: dict[str, Any]) -> None: + assert _state(task).endswith("COMPLETED"), task + assert len(handler.seen) == 1 + assert handler.seen[0]["brief"] == "ctv" + assert handler.seen[0]["context"] == {"trace": "t-1"} + data = _data(task) + assert "adcp_error" not in data + assert data["context"] == {"trace": "t-1"} + assert "VERSION_UNSUPPORTED" not in json.dumps(task) + + +def _assert_refused(handler: _InputMemberSeller, task: dict[str, Any]) -> None: + assert _state(task).endswith("FAILED"), task + data = _data(task) + assert data["adcp_error"]["code"] == "INVALID_REQUEST" + assert "context" not in data + assert handler.seen == [] + + +# --------------------------------------------------------------------------- +# Profile activated: input is read +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_activated_by_header_reads_input() -> None: + handler = _InputMemberSeller() + body = _v1({"skill": "get_products", "input": ARGS}) + _assert_completed_with_args(handler, await _post(handler, body, V1_ACTIVE)) + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_activated_by_message_extensions_reads_input() -> None: + handler = _InputMemberSeller() + body = _v1({"skill": "get_products", "input": ARGS}, extensions=[PROFILE]) + _assert_completed_with_args(handler, await _post(handler, body, V1)) + + +# --------------------------------------------------------------------------- +# Profile activated: anything but exactly one object `input` is refused +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +@pytest.mark.parametrize( + "data", + [ + pytest.param( + {"skill": "get_products", "input": ARGS, "parameters": {"brief": "x"}}, id="both" + ), + pytest.param({"skill": "get_products", "parameters": ARGS}, id="parameters-only"), + pytest.param({"skill": "get_products"}, id="input-missing"), + pytest.param({"skill": "get_products", "input": "x"}, id="input-not-object"), + pytest.param({"skill": "get_products", "input": [ARGS]}, id="input-list"), + ], +) +async def test_activated_refuses_non_profile_shapes(data: dict[str, Any]) -> None: + handler = _InputMemberSeller() + # Echoable context rides along so a leak would be visible. + if "parameters" in data: + data["parameters"] = {**data["parameters"], "context": {"trace": "t-1"}} + task = await _post(handler, _v1(data), V1_ACTIVE) + _assert_refused(handler, task) + + +# --------------------------------------------------------------------------- +# Profile not activated: legacy behaviour is unchanged +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_v03_message_send_parameters_still_works() -> None: + handler = _InputMemberSeller() + body = _v03({"skill": "get_products", "parameters": ARGS}) + _assert_completed_with_args(handler, await _post(handler, body, V03)) + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_v1_unactivated_parameters_still_works() -> None: + handler = _InputMemberSeller() + body = _v1({"skill": "get_products", "parameters": ARGS}) + _assert_completed_with_args(handler, await _post(handler, body, V1)) + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_unactivated_both_members_parameters_wins_without_merging() -> None: + handler = _InputMemberSeller() + body = _v1( + { + "skill": "get_products", + "parameters": ARGS, + "input": {"brief": "from-input", "input_only_key": "leaked"}, + } + ) + task = await _post(handler, body, V1) + _assert_completed_with_args(handler, task) + assert "input_only_key" not in handler.seen[0] + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +@pytest.mark.parametrize("version", ["0.3", "1.0"]) +async def test_unactivated_input_only_is_read(version: str) -> None: + handler = _InputMemberSeller() + if version == "0.3": + body, headers = _v03({"skill": "get_products", "input": ARGS}), V03 + else: + body, headers = _v1({"skill": "get_products", "input": ARGS}), V1 + _assert_completed_with_args(handler, await _post(handler, body, headers)) + + +# --------------------------------------------------------------------------- +# Custom parser owns its shapes; signed-request gate carries input +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_custom_message_parser_is_not_subject_to_the_profile_check() -> None: + handler = _InputMemberSeller() + + def parser(context: Any) -> tuple[str | None, dict[str, Any]]: + return "get_products", {"brief": "custom"} + + body = _v1({"skill": "get_products", "parameters": ARGS}) + task = await _post(handler, body, V1_ACTIVE, message_parser=parser) + assert _state(task).endswith("COMPLETED"), task + assert [seen["brief"] for seen in handler.seen] == ["custom"] + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_signature_gate_middleware_parse_carries_input() -> None: + """Unsigned get_products is allowed; the gate's cached parse must hand + the dispatcher the ``input`` member, not an empty argument set.""" + handler = _InputMemberSeller() + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({"https://buyer.example.com/": {"keys": []}}), + required_for=frozenset({"create_media_buy"}), + ) + body = _v1({"skill": "get_products", "input": ARGS}) + task = await _post(handler, body, V1_ACTIVE, request_signature_verification=config) + _assert_completed_with_args(handler, task) + + +# --------------------------------------------------------------------------- +# Raw-body receipt path +# --------------------------------------------------------------------------- + + +def _receipt_body(**members: Any) -> bytes: + return json.dumps(_v1({"skill": RECEIPT_TASK, **members})).encode() + + +def test_receipt_raw_body_reads_input() -> None: + parsed = a2a_receipt_parameters(_receipt_body(input={"marker": "from-input"})) + assert parsed is not None + assert parsed["marker"] == "from-input" + + +def test_receipt_raw_body_reads_parameters() -> None: + parsed = a2a_receipt_parameters(_receipt_body(parameters={"marker": "from-parameters"})) + assert parsed is not None + assert parsed["marker"] == "from-parameters" + + +def test_receipt_raw_body_both_members_parameters_wins() -> None: + parsed = a2a_receipt_parameters( + _receipt_body(parameters={"marker": "from-parameters"}, input={"marker": "from-input"}) + ) + assert parsed is not None + assert parsed["marker"] == "from-parameters" + assert "from-input" not in json.dumps(parsed) From ba8e6abd014a69cad569309c0903eec7ff96314a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 9 Nov 2026 04:49:17 +0000 Subject: [PATCH 3/5] fix(server): treat a hand-built A2A context as not activating the profile ADCPAgentExecutor.execute is public, and a context built by hand (an adopter's executor test) may lack RequestContext.requested_extensions or Message.extensions. The profile check now reads both defensively instead of raising AttributeError. Refs #1887 Co-Authored-By: Claude Opus 5.5 --- src/adcp/server/a2a_server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 4f03ce2451..48c63bc421 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -306,8 +306,11 @@ def _profile_invocation_violation(context: RequestContext) -> str | None: printer refuses cannot fail this check. """ message = context.message - activated = _ADCP_A2A_PROFILE_URI in context.requested_extensions or ( - message is not None and _ADCP_A2A_PROFILE_URI in message.extensions + # ``execute`` is public: a hand-built context (an adopter's executor test) + # may lack the a2a-sdk attributes, and then has not activated the profile. + requested = getattr(context, "requested_extensions", None) or () + activated = _ADCP_A2A_PROFILE_URI in requested or ( + message is not None and _ADCP_A2A_PROFILE_URI in getattr(message, "extensions", ()) ) if not activated: return None From 54dc9498ec9716654874c612deaac373c70effb3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 9 Nov 2026 05:33:41 +0000 Subject: [PATCH 4/5] fix(server): keep dispatching an activated parameters-only A2A invocation on 9.x, with a deprecation warning 9.x is the stable line, so a patch must not start refusing requests that work today. An invocation that activates the AdCP A2A profile but carries only `parameters`, a missing or non-object `input`, or a DataPart count other than one is still dispatched as before, and a deprecation warning names the profile rule. Only an activated invocation carrying both `parameters` and `input` is refused, since it is ambiguous and no compliant client sends it. adcp 10 refuses every non-conforming activated shape. Refs #1887 Co-Authored-By: Claude Opus 5.5 --- src/adcp/server/a2a_server.py | 42 +++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 48c63bc421..4a10ce6ae9 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -280,9 +280,9 @@ def _invocation_arguments(data: dict[str, Any]) -> dict[str, Any]: key is present, so a caller that does not activate the AdCP A2A profile (A2A 0.x clients, earlier SDKs) is dispatched exactly as before; otherwise the profile's ``input`` member is read. The executor refuses a - profile-activated request that carries ``parameters`` at all - (:func:`_profile_invocation_violation`), so every request it accepts under - the profile is dispatched from ``input``. The choice depends only on the + profile-activated request that carries both members, and logs a + deprecation warning for one that carries only ``parameters`` + (:func:`_profile_invocation_violation`). The choice depends only on the invocation object, so the auth and signing middleware, which parse without the request headers, see the same arguments as dispatch. """ @@ -296,9 +296,11 @@ def _profile_invocation_violation(context: RequestContext) -> str | None: Under the AdCP A2A profile a Message carries exactly one DataPart whose object has an object ``input``. ``parameters`` "is not an alias for ``input``", and the agent "MUST reject the invocation rather than choose - among ambiguous inputs" — so ``parameters``, alone or beside ``input``, - is refused. A request that does not activate the profile returns ``None`` - and keeps the legacy shapes. + among ambiguous inputs". On 9.x only ``both_members`` is refused, since + no compliant client sends it; every other reason is a shape 9.x has + always dispatched, so it is logged as deprecated and still dispatched. + 10.x refuses them all. A request that does not activate the profile + returns ``None`` and keeps the legacy shapes. The profile is activated by the ``A2A-Extensions`` service parameter; the Message's own ``extensions`` list naming the URI counts too. Member names @@ -322,7 +324,7 @@ def _profile_invocation_violation(context: RequestContext) -> str | None: return "data_not_object" fields = data[0].struct_value.fields if "parameters" in fields: - return "parameters_member" + return "both_members" if "input" in fields else "parameters_member" if "input" not in fields or fields["input"].WhichOneof("kind") != "struct_value": return "input_missing" return None @@ -436,8 +438,10 @@ class ADCPAgentExecutor(AgentExecutor): Part(data={"skill": "get_products", "parameters": {...}}) A request that activates the profile (``A2A-Extensions`` names - ``https://adcontextprotocol.org/extensions/adcp/v3``) must use ``input``; - one carrying ``parameters`` is refused before handler entry. + ``https://adcontextprotocol.org/extensions/adcp/v3``) should use ``input``. + One carrying both members is refused before handler entry; one carrying + only ``parameters`` is dispatched with a deprecation warning (adcp 10 + refuses it). """ def __init__( @@ -568,10 +572,10 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non skill_name, params = self._parse_request(context) violation = _profile_invocation_violation(context) if self._message_parser is None else None - if violation is not None: - # The AdCP A2A profile refuses an ambiguous or legacy-shaped - # activated invocation before handler entry. Nothing is echoed: - # echoing ``context`` from either member would choose between them. + if violation == "both_members": + # Activated with both members is ambiguous: refuse before handler + # entry. Nothing is echoed: echoing ``context`` from either member + # would choose between them. from adcp.decisioning.types import AdcpError logger.info("a2a dispatch rejected", extra={"reason": violation}) @@ -589,6 +593,16 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non skill_name=skill_name or "", ) return + if violation is not None: + # 9.x has always dispatched these shapes; keep doing so in a patch + # release. 10.x refuses them as the profile requires. + logger.warning( + "Deprecated A2A invocation (%s): a request that activates the AdCP A2A " + "profile must carry exactly one DataPart with an `input` object and no " + "`parameters` (`parameters` is not an alias for `input`). Dispatched for " + "compatibility; adcp 10 refuses it.", + violation, + ) if skill_name is None: await self._send_error(event_queue, context, "No skill specified in message") @@ -1429,7 +1443,7 @@ def create_a2a_server( wire shapes. The default parser handles a DataPart carrying ``{"skill": ..., "input": ...}`` or the legacy ``{"skill": ..., "parameters": ...}`` plus a TextPart JSON - fallback, and the executor enforces the AdCP A2A profile's + fallback, and the executor checks the AdCP A2A profile's invocation shape on requests that activate it. A custom parser owns its wire shapes, so that check is skipped. Supply this to accept JSON-RPC 2.0 message bodies, From 9face59be504ce325c9f0f71b1cae09489403e4e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 9 Nov 2026 05:36:59 +0000 Subject: [PATCH 5/5] test(server): cover the 9.x warn-and-dispatch shapes for an activated A2A invocation Refs #1887 Co-Authored-By: Claude Opus 5.5 --- tests/test_a2a_profile_input_member.py | 97 +++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/tests/test_a2a_profile_input_member.py b/tests/test_a2a_profile_input_member.py index 818baf8ced..2f9e953d0f 100644 --- a/tests/test_a2a_profile_input_member.py +++ b/tests/test_a2a_profile_input_member.py @@ -3,9 +3,14 @@ The AdCP A2A profile sends ``{"skill": ..., "input": {...}}``; the legacy shape sends ``parameters``. The default parser reads ``parameters`` when present, otherwise ``input``, and never merges them. A request that activates -the profile (``A2A-Extensions`` header or ``message.extensions``) must carry +the profile (``A2A-Extensions`` header or ``message.extensions``) should carry exactly one DataPart with an object ``input`` and no ``parameters``. +9.x (this line) is non-breaking: it refuses only the both-members case, and +dispatches the other non-conforming activated shapes (``parameters`` only, +``input`` missing or not an object, a DataPart count other than one) with a +deprecation warning. 10.x refuses all of them. + Everything is driven over HTTP through the real app, not mocked parsers. """ @@ -13,6 +18,7 @@ import contextlib import json +import logging from collections.abc import AsyncIterator from typing import Any @@ -31,6 +37,7 @@ V1 = {"content-type": "application/json", "A2A-Version": "1.0"} V1_ACTIVE = {**V1, "A2A-Extensions": PROFILE} V03 = {"content-type": "application/json"} +PROFILE_REFUSAL = "must carry exactly one DataPart with an `input` object" class _InputMemberSeller(ADCPHandler[Any]): @@ -177,27 +184,95 @@ async def test_activated_by_message_extensions_reads_input() -> None: # --------------------------------------------------------------------------- +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_activated_both_members_is_refused() -> None: + handler = _InputMemberSeller() + data = { + "skill": "get_products", + "input": ARGS, + # Echoable context rides along so a leak would be visible. + "parameters": {"brief": "x", "context": {"trace": "t-1"}}, + } + task = await _post(handler, _v1(data), V1_ACTIVE) + _assert_refused(handler, task) + + +# --------------------------------------------------------------------------- +# Profile activated: 9.x dispatches the other non-conforming shapes, with a warning +# --------------------------------------------------------------------------- + + +def _deprecation_records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + return [ + r + for r in caplog.records + if r.name == "adcp.server.a2a_server" + and r.levelno == logging.WARNING + and r.getMessage().startswith("Deprecated A2A invocation (") + ] + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_activated_parameters_only_is_dispatched_with_a_deprecation_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.WARNING, logger="adcp.server.a2a_server") + handler = _InputMemberSeller() + body = _v1({"skill": "get_products", "parameters": ARGS}) + _assert_completed_with_args(handler, await _post(handler, body, V1_ACTIVE)) + records = _deprecation_records(caplog) + assert len(records) == 1 + message = records[0].getMessage() + assert "parameters_member" in message + assert "`parameters` is not an alias for `input`" in message + + @pytest.mark.asyncio @pytest.mark.filterwarnings("ignore::DeprecationWarning") @pytest.mark.parametrize( "data", [ - pytest.param( - {"skill": "get_products", "input": ARGS, "parameters": {"brief": "x"}}, id="both" - ), - pytest.param({"skill": "get_products", "parameters": ARGS}, id="parameters-only"), pytest.param({"skill": "get_products"}, id="input-missing"), pytest.param({"skill": "get_products", "input": "x"}, id="input-not-object"), pytest.param({"skill": "get_products", "input": [ARGS]}, id="input-list"), ], ) -async def test_activated_refuses_non_profile_shapes(data: dict[str, Any]) -> None: +async def test_activated_missing_or_non_object_input_warns_and_is_not_refused( + data: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="adcp.server.a2a_server") handler = _InputMemberSeller() - # Echoable context rides along so a leak would be visible. - if "parameters" in data: - data["parameters"] = {**data["parameters"], "context": {"trace": "t-1"}} - task = await _post(handler, _v1(data), V1_ACTIVE) - _assert_refused(handler, task) + async with _client(_app(handler)) as client: + response = await client.post("/", content=json.dumps(_v1(data)).encode(), headers=V1_ACTIVE) + assert response.status_code == 200, response.text + # Whether the handler runs with `{}` is not stable (a version check can + # fail); what is stable is the warning and that this is not the refusal. + assert PROFILE_REFUSAL not in response.text + records = _deprecation_records(caplog) + assert len(records) == 1 + assert "input_missing" in records[0].getMessage() + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_activated_extra_data_part_warns_and_is_dispatched( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.WARNING, logger="adcp.server.a2a_server") + handler = _InputMemberSeller() + body = _v1( + {}, + parts=[ + {"data": {"skill": "get_products", "input": ARGS}}, + {"data": {"note": "x"}}, + ], + ) + _assert_completed_with_args(handler, await _post(handler, body, V1_ACTIVE)) + records = _deprecation_records(caplog) + assert len(records) == 1 + assert "data_part_count" in records[0].getMessage() # ---------------------------------------------------------------------------