Skip to content

Commit 385dc80

Browse files
bokelleyclaude
andauthored
feat(client): looks_like_v3_capabilities — drop the v2-downgrade footgun on capabilities-validation failure (#475)
Port of JS commit 27bd79d (#1201). When `get_adcp_capabilities` fails strict schema validation but the response is structurally v3-shaped, surface the v3 validation error loudly instead of silently re-classifying the agent as v2 — which downstream tooling turns into a cascade of confusing "AdCP schema data for version v2.5 not found" errors that have nothing to do with the original wire-shape bug. `looks_like_v3_capabilities(data)` checks for any one v3 signal: the `adcp` envelope, `supported_protocols` array, or any v3 protocol-level block (`account`, `media_buy`, `signals`, `creative`, `brand`, `governance`, `sponsored_intelligence`, `compliance_testing`). Arrays are explicitly rejected via the `_is_plain_object` helper so `adcp: []` / `media_buy: []` don't false-positive. `ADCPClient.refresh_capabilities` now distinguishes parse failure (the `_parse_response` "Failed to parse response:" prefix) from transport failure: parse failures re-fetch the raw dict from the adapter and run the heuristic; transport failures fall straight through to the original "Failed to fetch capabilities" path so the existing failure-raises test keeps passing. Closes #461. Refs #452. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8dd5dab commit 385dc80

4 files changed

Lines changed: 401 additions & 4 deletions

File tree

src/adcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from adcp.capabilities import ( # noqa: F401
2626
FeatureResolver,
2727
build_synthetic_capabilities,
28+
looks_like_v3_capabilities,
2829
validate_capabilities,
2930
)
3031
from adcp.client import ADCPClient, ADCPMultiAgentClient, Checkpoint

src/adcp/capabilities.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,70 @@
8787
del _task, _feature
8888

8989

90+
def _is_plain_object(value: Any) -> bool:
91+
"""Return True iff ``value`` is a non-array dict.
92+
93+
Mirrors the JS ``isPlainObject`` helper used by ``looks_like_v3_capabilities``.
94+
Excludes lists so that ``adcp: []`` or ``media_buy: []`` don't get mistaken
95+
for v3 envelope blocks just because ``isinstance(_, dict)`` would have
96+
happened to return False anyway — kept for symmetry with the JS check
97+
so future contributors don't reintroduce a ``isinstance(_, (dict, list))``
98+
false-positive.
99+
"""
100+
return isinstance(value, dict)
101+
102+
103+
def looks_like_v3_capabilities(data: Any) -> bool:
104+
"""Heuristic: does this ``get_adcp_capabilities`` response look v3-shaped?
105+
106+
Used by ``ADCPClient.refresh_capabilities`` when the response fails strict
107+
schema validation but is structurally non-empty. The question the heuristic
108+
answers is "is this a v3 agent with a wire-shape bug, or a v2 agent that
109+
happens to advertise the tool?". Falling back to v2 in the former case
110+
masks the original bug behind cascading v2.5-schema-not-found errors;
111+
treating it as v3 surfaces the wire-shape bug at its source.
112+
113+
Affirmative v3 signals (any one is enough):
114+
115+
- ``adcp`` block (only v3 servers carry the
116+
``{ major_versions, idempotency, ... }`` envelope)
117+
- ``supported_protocols`` array (v3-only top-level field)
118+
- any v3 protocol-level capability block (``account``, ``media_buy``,
119+
``signals``, ``creative``, ``brand``, ``governance``,
120+
``sponsored_intelligence``, ``compliance_testing``)
121+
122+
v2 servers don't expose ``get_adcp_capabilities`` at all (the tool itself
123+
is a v3-only addition), so reaching this function with a non-empty payload
124+
already strongly implies v3 — but the structural check belt-and-suspenders
125+
against genuinely empty / null responses.
126+
127+
Args:
128+
data: Raw response payload (typically a dict, but accepts any value
129+
so callers don't have to narrow before calling).
130+
131+
Returns:
132+
True if any v3 signal is present; False for empty, null, non-dict,
133+
or shape-mismatched inputs.
134+
"""
135+
if not _is_plain_object(data):
136+
return False
137+
if _is_plain_object(data.get("adcp")):
138+
return True
139+
if isinstance(data.get("supported_protocols"), list):
140+
return True
141+
v3_blocks = (
142+
"account",
143+
"media_buy",
144+
"signals",
145+
"creative",
146+
"brand",
147+
"governance",
148+
"sponsored_intelligence",
149+
"compliance_testing",
150+
)
151+
return any(_is_plain_object(data.get(block)) for block in v3_blocks)
152+
153+
90154
def build_synthetic_capabilities(
91155
supported_protocols: list[str],
92156
*,
@@ -171,7 +235,7 @@ def supports(self, feature: str) -> bool:
171235

172236
# Targeting check: "targeting.geo_countries"
173237
if feature.startswith("targeting."):
174-
attr_name = feature[len("targeting."):]
238+
attr_name = feature[len("targeting.") :]
175239
if caps.media_buy is None or caps.media_buy.execution is None:
176240
return False
177241
targeting = caps.media_buy.execution.targeting
@@ -307,8 +371,7 @@ def validate_capabilities(
307371
for method_name in handler_methods:
308372
if not hasattr(handler, method_name):
309373
warnings.append(
310-
f"Feature '{feature}' is declared but handler has no "
311-
f"'{method_name}' method"
374+
f"Feature '{feature}' is declared but handler has no " f"'{method_name}' method"
312375
)
313376
continue
314377

src/adcp/client.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from mcp import ClientSession
2323

2424
from adcp._version import resolve_adcp_version
25-
from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver
25+
from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver, looks_like_v3_capabilities
2626
from adcp.exceptions import ADCPError, ADCPWebhookSignatureError
2727
from adcp.protocols.a2a import A2AAdapter
2828
from adcp.protocols.base import ProtocolAdapter
@@ -1042,15 +1042,71 @@ async def fetch_capabilities(self) -> GetAdcpCapabilitiesResponse:
10421042
async def refresh_capabilities(self) -> GetAdcpCapabilitiesResponse:
10431043
"""Fetch capabilities from the seller, bypassing cache.
10441044
1045+
On strict-schema validation failure the raw response is inspected with
1046+
``looks_like_v3_capabilities``: if the agent is structurally v3-shaped,
1047+
a wire-shape bug is surfaced loudly with the original validation error
1048+
rather than silently downgrading to v2 (the v2 fallback would then ask
1049+
for v2.5 schemas, which aren't shipped — one missing field would
1050+
cascade into "AdCP schema data for version v2.5 not found"). Genuinely
1051+
non-v3 responses still fall through to the transport-error path.
1052+
10451053
Returns:
10461054
The seller's capabilities response.
1055+
1056+
Raises:
1057+
ADCPError: On transport failure, or when the response is
1058+
v3-shaped but fails schema validation. The error message
1059+
explicitly references v3 in the latter case so the underlying
1060+
wire-shape bug doesn't get blamed on a v2.5-schema cascade.
10471061
"""
10481062
result = await self.get_adcp_capabilities(GetAdcpCapabilitiesRequest())
10491063
if result.success and result.data is not None:
10501064
self._capabilities = result.data
10511065
self._feature_resolver = FeatureResolver(result.data)
10521066
self._capabilities_fetched_at = time.monotonic()
10531067
return self._capabilities
1068+
1069+
# The typed call discards the raw payload on parse failure (only the
1070+
# error string survives). Distinguish parse-failure (worth shape-
1071+
# checking) from transport-failure (no data ever arrived) by the
1072+
# error prefix produced by ProtocolAdapter._parse_response. Only on
1073+
# parse-failure do we re-fetch the raw dict from the adapter to
1074+
# inspect its shape; transport failures fall straight through to
1075+
# the original error path.
1076+
raw_data: Any = None
1077+
is_parse_failure = result.error is not None and result.error.startswith(
1078+
"Failed to parse response:"
1079+
)
1080+
if is_parse_failure:
1081+
raw_result = await self.adapter.get_adcp_capabilities(
1082+
GetAdcpCapabilitiesRequest().model_dump(mode="json", exclude_none=True)
1083+
)
1084+
raw_data = raw_result.data
1085+
if isinstance(raw_data, list) and len(raw_data) == 1 and isinstance(raw_data[0], dict):
1086+
# MCP content array — unwrap a single-item content envelope
1087+
# so the heuristic sees the same shape the parser would.
1088+
raw_data = raw_data[0]
1089+
1090+
if looks_like_v3_capabilities(raw_data):
1091+
logger.warning(
1092+
"[AdCP] Agent %r returned a get_adcp_capabilities response that "
1093+
"failed validation, but the response is structurally v3-shaped. "
1094+
"The agent has a wire-shape bug — that's the thing to fix. "
1095+
"(has_error=%s, has_data=%s)",
1096+
self.agent_config.id,
1097+
bool(result.error),
1098+
raw_data is not None,
1099+
)
1100+
raise ADCPError(
1101+
f"v3 capabilities response from agent {self.agent_config.id!r} "
1102+
f"failed schema validation: {result.error or result.message}. "
1103+
f"The response is structurally v3-shaped (carries `adcp`, "
1104+
f"`supported_protocols`, or a v3 protocol block) — fix the "
1105+
f"agent's wire shape rather than downgrading to v2.",
1106+
agent_id=self.agent_config.id,
1107+
agent_uri=self.agent_config.agent_uri,
1108+
)
1109+
10541110
raise ADCPError(
10551111
f"Failed to fetch capabilities: {result.error or result.message}",
10561112
agent_id=self.agent_config.id,

0 commit comments

Comments
 (0)