Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from adcp.capabilities import ( # noqa: F401
FeatureResolver,
build_synthetic_capabilities,
looks_like_v3_capabilities,
validate_capabilities,
)
from adcp.client import ADCPClient, ADCPMultiAgentClient, Checkpoint
Expand Down
69 changes: 66 additions & 3 deletions src/adcp/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,70 @@
del _task, _feature


def _is_plain_object(value: Any) -> bool:
"""Return True iff ``value`` is a non-array dict.

Mirrors the JS ``isPlainObject`` helper used by ``looks_like_v3_capabilities``.
Excludes lists so that ``adcp: []`` or ``media_buy: []`` don't get mistaken
for v3 envelope blocks just because ``isinstance(_, dict)`` would have
happened to return False anyway — kept for symmetry with the JS check
so future contributors don't reintroduce a ``isinstance(_, (dict, list))``
false-positive.
"""
return isinstance(value, dict)


def looks_like_v3_capabilities(data: Any) -> bool:
"""Heuristic: does this ``get_adcp_capabilities`` response look v3-shaped?

Used by ``ADCPClient.refresh_capabilities`` when the response fails strict
schema validation but is structurally non-empty. The question the heuristic
answers is "is this a v3 agent with a wire-shape bug, or a v2 agent that
happens to advertise the tool?". Falling back to v2 in the former case
masks the original bug behind cascading v2.5-schema-not-found errors;
treating it as v3 surfaces the wire-shape bug at its source.

Affirmative v3 signals (any one is enough):

- ``adcp`` block (only v3 servers carry the
``{ major_versions, idempotency, ... }`` envelope)
- ``supported_protocols`` array (v3-only top-level field)
- any v3 protocol-level capability block (``account``, ``media_buy``,
``signals``, ``creative``, ``brand``, ``governance``,
``sponsored_intelligence``, ``compliance_testing``)

v2 servers don't expose ``get_adcp_capabilities`` at all (the tool itself
is a v3-only addition), so reaching this function with a non-empty payload
already strongly implies v3 — but the structural check belt-and-suspenders
against genuinely empty / null responses.

Args:
data: Raw response payload (typically a dict, but accepts any value
so callers don't have to narrow before calling).

Returns:
True if any v3 signal is present; False for empty, null, non-dict,
or shape-mismatched inputs.
"""
if not _is_plain_object(data):
return False
if _is_plain_object(data.get("adcp")):
return True
if isinstance(data.get("supported_protocols"), list):
return True
v3_blocks = (
"account",
"media_buy",
"signals",
"creative",
"brand",
"governance",
"sponsored_intelligence",
"compliance_testing",
)
return any(_is_plain_object(data.get(block)) for block in v3_blocks)


def build_synthetic_capabilities(
supported_protocols: list[str],
*,
Expand Down Expand Up @@ -171,7 +235,7 @@ def supports(self, feature: str) -> bool:

# Targeting check: "targeting.geo_countries"
if feature.startswith("targeting."):
attr_name = feature[len("targeting."):]
attr_name = feature[len("targeting.") :]
if caps.media_buy is None or caps.media_buy.execution is None:
return False
targeting = caps.media_buy.execution.targeting
Expand Down Expand Up @@ -307,8 +371,7 @@ def validate_capabilities(
for method_name in handler_methods:
if not hasattr(handler, method_name):
warnings.append(
f"Feature '{feature}' is declared but handler has no "
f"'{method_name}' method"
f"Feature '{feature}' is declared but handler has no " f"'{method_name}' method"
)
continue

Expand Down
58 changes: 57 additions & 1 deletion src/adcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from mcp import ClientSession

from adcp._version import resolve_adcp_version
from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver
from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver, looks_like_v3_capabilities
from adcp.exceptions import ADCPError, ADCPWebhookSignatureError
from adcp.protocols.a2a import A2AAdapter
from adcp.protocols.base import ProtocolAdapter
Expand Down Expand Up @@ -1042,15 +1042,71 @@ async def fetch_capabilities(self) -> GetAdcpCapabilitiesResponse:
async def refresh_capabilities(self) -> GetAdcpCapabilitiesResponse:
"""Fetch capabilities from the seller, bypassing cache.

On strict-schema validation failure the raw response is inspected with
``looks_like_v3_capabilities``: if the agent is structurally v3-shaped,
a wire-shape bug is surfaced loudly with the original validation error
rather than silently downgrading to v2 (the v2 fallback would then ask
for v2.5 schemas, which aren't shipped — one missing field would
cascade into "AdCP schema data for version v2.5 not found"). Genuinely
non-v3 responses still fall through to the transport-error path.

Returns:
The seller's capabilities response.

Raises:
ADCPError: On transport failure, or when the response is
v3-shaped but fails schema validation. The error message
explicitly references v3 in the latter case so the underlying
wire-shape bug doesn't get blamed on a v2.5-schema cascade.
"""
result = await self.get_adcp_capabilities(GetAdcpCapabilitiesRequest())
if result.success and result.data is not None:
self._capabilities = result.data
self._feature_resolver = FeatureResolver(result.data)
self._capabilities_fetched_at = time.monotonic()
return self._capabilities

# The typed call discards the raw payload on parse failure (only the
# error string survives). Distinguish parse-failure (worth shape-
# checking) from transport-failure (no data ever arrived) by the
# error prefix produced by ProtocolAdapter._parse_response. Only on
# parse-failure do we re-fetch the raw dict from the adapter to
# inspect its shape; transport failures fall straight through to
# the original error path.
raw_data: Any = None
is_parse_failure = result.error is not None and result.error.startswith(
"Failed to parse response:"
)
if is_parse_failure:
raw_result = await self.adapter.get_adcp_capabilities(
GetAdcpCapabilitiesRequest().model_dump(mode="json", exclude_none=True)
)
raw_data = raw_result.data
if isinstance(raw_data, list) and len(raw_data) == 1 and isinstance(raw_data[0], dict):
# MCP content array — unwrap a single-item content envelope
# so the heuristic sees the same shape the parser would.
raw_data = raw_data[0]

if looks_like_v3_capabilities(raw_data):
logger.warning(
"[AdCP] Agent %r returned a get_adcp_capabilities response that "
"failed validation, but the response is structurally v3-shaped. "
"The agent has a wire-shape bug — that's the thing to fix. "
"(has_error=%s, has_data=%s)",
self.agent_config.id,
bool(result.error),
raw_data is not None,
)
raise ADCPError(
f"v3 capabilities response from agent {self.agent_config.id!r} "
f"failed schema validation: {result.error or result.message}. "
f"The response is structurally v3-shaped (carries `adcp`, "
f"`supported_protocols`, or a v3 protocol block) — fix the "
f"agent's wire shape rather than downgrading to v2.",
agent_id=self.agent_config.id,
agent_uri=self.agent_config.agent_uri,
)

raise ADCPError(
f"Failed to fetch capabilities: {result.error or result.message}",
agent_id=self.agent_config.id,
Expand Down
Loading
Loading