diff --git a/src/adcp/compat/legacy/__init__.py b/src/adcp/compat/legacy/__init__.py index 72c0773d..725393e4 100644 --- a/src/adcp/compat/legacy/__init__.py +++ b/src/adcp/compat/legacy/__init__.py @@ -32,6 +32,12 @@ #: Versions handled via the legacy-adapter path. Distinct from #: ``COMPATIBLE_ADCP_VERSIONS`` in :mod:`adcp._version`, which lists the #: versions the SDK natively validates against. +#: +#: **Iteration order is probe-precedence order.** When the dispatcher +#: runs shape-based detection (Stage 6, ``is_legacy_shape``), it walks +#: this tuple and breaks on the first match. List versions +#: highest-to-lowest priority — newer legacy versions before older — +#: so an ambiguous payload routes to the most-recent matching adapter. LEGACY_ADAPTER_VERSIONS: Final[tuple[str, ...]] = ("2.5",) # Per-version adapter module list. Data, not control flow, so adding a diff --git a/src/adcp/compat/legacy/types.py b/src/adcp/compat/legacy/types.py index 7a4dc7d4..9e4e6d30 100644 --- a/src/adcp/compat/legacy/types.py +++ b/src/adcp/compat/legacy/types.py @@ -54,3 +54,15 @@ class AdapterPair: tool_name: str adapt_request: Callable[[dict[str, Any]], dict[str, Any]] normalize_response: Callable[[dict[str, Any]], dict[str, Any]] | None = None + # Optional shape probe used by the dispatcher when the buyer didn't + # send an ``adcp_version`` / ``adcp_major_version`` envelope (real v2.5 + # buyers can't — the field didn't exist in the v2.5 schema). The + # probe should return ``True`` only on strong, unambiguous v2.5 + # markers — fields that exist in v2.5 but NOT in v3 (e.g. + # ``brand_manifest``, ``creative_ids`` in packages, bare-string + # ``format_id``). False positives downgrade a real v3 buyer to v2.5 + # validation, which is the worst outcome; bias conservatively. Tools + # with pass-through requests (``list_creative_formats``, + # ``preview_creative``) leave this ``None`` because their request + # shape is identical across versions. + is_legacy_shape: Callable[[dict[str, Any]], bool] | None = None diff --git a/src/adcp/compat/legacy/v2_5/create_media_buy.py b/src/adcp/compat/legacy/v2_5/create_media_buy.py index f745bd71..239e91b0 100644 --- a/src/adcp/compat/legacy/v2_5/create_media_buy.py +++ b/src/adcp/compat/legacy/v2_5/create_media_buy.py @@ -40,9 +40,27 @@ def adapt_request(payload: dict[str, Any]) -> dict[str, Any]: return out +def is_legacy_shape(payload: dict[str, Any]) -> bool: + """v2.5 ``create_media_buy`` markers: + + * top-level ``brand_manifest`` URL (v3 uses ``brand: {domain}``) + * any package with ``creative_ids: list[str]`` (v3 uses + ``creative_assignments: [{creative_id, ...}]``) + """ + if "brand_manifest" in payload: + return True + packages = payload.get("packages") + if isinstance(packages, list): + for pkg in packages: + if isinstance(pkg, dict) and isinstance(pkg.get("creative_ids"), list): + return True + return False + + ADAPTER = AdapterPair( tool_name="create_media_buy", adapt_request=adapt_request, normalize_response=normalize_media_buy_response, + is_legacy_shape=is_legacy_shape, ) register_adapter("2.5", ADAPTER) diff --git a/src/adcp/compat/legacy/v2_5/get_products.py b/src/adcp/compat/legacy/v2_5/get_products.py index 019dbc6f..2ea88ce2 100644 --- a/src/adcp/compat/legacy/v2_5/get_products.py +++ b/src/adcp/compat/legacy/v2_5/get_products.py @@ -229,9 +229,18 @@ def normalize_response(response: dict[str, Any]) -> dict[str, Any]: } +def is_legacy_shape(payload: dict[str, Any]) -> bool: + """v2.5 ``get_products`` carries either ``brand_manifest`` (URL + string field that v3 doesn't have) or ``promoted_offerings`` + (nested object replaced by ``catalog`` in v3). Either is a + strong signal.""" + return "brand_manifest" in payload or "promoted_offerings" in payload + + ADAPTER = AdapterPair( tool_name="get_products", adapt_request=adapt_request, normalize_response=normalize_response, + is_legacy_shape=is_legacy_shape, ) register_adapter("2.5", ADAPTER) diff --git a/src/adcp/compat/legacy/v2_5/sync_creatives.py b/src/adcp/compat/legacy/v2_5/sync_creatives.py index fa6c6651..99cf21fe 100644 --- a/src/adcp/compat/legacy/v2_5/sync_creatives.py +++ b/src/adcp/compat/legacy/v2_5/sync_creatives.py @@ -133,5 +133,23 @@ def adapt_request(payload: dict[str, Any]) -> dict[str, Any]: return out -ADAPTER = AdapterPair(tool_name="sync_creatives", adapt_request=adapt_request) +def is_legacy_shape(payload: dict[str, Any]) -> bool: + """v2.5 ``sync_creatives`` has at least one creative whose + ``format_id`` is a bare string (v3 always emits the structured + ``{agent_url, id}`` form). Strong signal — v3 wouldn't emit this + even with ``adcp_version`` omitted.""" + creatives = payload.get("creatives") + if not isinstance(creatives, list): + return False + for creative in creatives: + if isinstance(creative, dict) and isinstance(creative.get("format_id"), str): + return True + return False + + +ADAPTER = AdapterPair( + tool_name="sync_creatives", + adapt_request=adapt_request, + is_legacy_shape=is_legacy_shape, +) register_adapter("2.5", ADAPTER) diff --git a/src/adcp/compat/legacy/v2_5/update_media_buy.py b/src/adcp/compat/legacy/v2_5/update_media_buy.py index 6d24c04f..36aedd32 100644 --- a/src/adcp/compat/legacy/v2_5/update_media_buy.py +++ b/src/adcp/compat/legacy/v2_5/update_media_buy.py @@ -36,9 +36,22 @@ def adapt_request(payload: dict[str, Any]) -> dict[str, Any]: return out +def is_legacy_shape(payload: dict[str, Any]) -> bool: + """v2.5 ``update_media_buy``: any package with ``creative_ids: + list[str]`` is the marker (no ``brand_manifest`` on updates).""" + packages = payload.get("packages") + if not isinstance(packages, list): + return False + for pkg in packages: + if isinstance(pkg, dict) and isinstance(pkg.get("creative_ids"), list): + return True + return False + + ADAPTER = AdapterPair( tool_name="update_media_buy", adapt_request=adapt_request, normalize_response=normalize_media_buy_response, + is_legacy_shape=is_legacy_shape, ) register_adapter("2.5", ADAPTER) diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index da81fa00..fadbdd42 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -2027,14 +2027,47 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) ) wire_version = None - # Legacy-version routing: if the buyer claims a version handled - # via the adapter path (e.g. ``"2.5"``), validate the params - # against the legacy schema first, *then* translate to the - # current shape. Pre-adapter validation surfaces structural - # errors with the legacy schema's field paths — far easier - # for the buyer to act on than a v3 field-path error after a - # confusing translation. Post-adapter validation (further - # down) catches translator bugs against the SDK pin. + # Shape-based legacy detection (issue: real v2.5 buyers can't + # send ``adcp_version`` — the field didn't exist in the v2.5 + # schema). When the envelope is empty and a legacy adapter + # registers an ``is_legacy_shape`` probe, run it. A match + # promotes ``wire_version`` to the probe's version so the + # adapter path below fires normally. Bias is conservative: + # probes return ``True`` only on fields v3 doesn't emit + # (``brand_manifest``, ``creative_ids``, bare-string + # ``format_id``). False positives downgrade a real v3 buyer + # to legacy validation, which is the worst outcome. + if wire_version is None: + for candidate in LEGACY_ADAPTER_VERSIONS: + candidate_adapter = get_legacy_adapter(candidate, method_name) + if candidate_adapter is None: + continue + probe = candidate_adapter.is_legacy_shape + if probe is None: + continue + try: + matched = probe(params) if isinstance(params, dict) else False + except Exception: # noqa: BLE001 — defensive: probes are pure-ish + matched = False + if matched: + logger.info( + "Detected %s wire shape for %s (no envelope version " + "supplied); routing through legacy adapter.", + candidate, + method_name, + ) + wire_version = candidate + break + + # Legacy-version routing: if the buyer claims (or shape-detected) + # a version handled via the adapter path (e.g. ``"2.5"``), + # validate the params against the legacy schema first, *then* + # translate to the current shape. Pre-adapter validation + # surfaces structural errors with the legacy schema's field + # paths — far easier for the buyer to act on than a v3 + # field-path error after a confusing translation. Post-adapter + # validation (further down) catches translator bugs against + # the SDK pin. legacy_adapter: Any = None if wire_version in LEGACY_ADAPTER_VERSIONS: legacy_adapter = get_legacy_adapter(wire_version, method_name) diff --git a/tests/test_dispatcher_shape_detection.py b/tests/test_dispatcher_shape_detection.py new file mode 100644 index 00000000..5ae6ccdc --- /dev/null +++ b/tests/test_dispatcher_shape_detection.py @@ -0,0 +1,269 @@ +"""Stage 6 tests: shape-based legacy detection. + +Real v2.5 buyers can't send ``adcp_version`` — the field didn't exist +in the v2.5 schema. When the envelope is empty, the dispatcher falls +through to per-tool shape probes registered on the ``AdapterPair``. +A match promotes the request to the legacy adapter path; a miss +proceeds with SDK-pin validation. + +These tests cover the four tools that have unambiguous v2.5 markers +(``sync_creatives``, ``get_products``, ``create_media_buy``, +``update_media_buy``) plus the bias-conservative case where a real v3 +buyer's payload doesn't trigger downgrade. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from adcp.server.base import ADCPHandler, ToolContext +from adcp.server.mcp_tools import create_tool_caller + +_CANONICAL_URL = "https://creative.adcontextprotocol.org" + + +class _SyncCreativesHandler(ADCPHandler[Any]): + def __init__(self) -> None: + self.received: list[dict[str, Any]] = [] + + async def sync_creatives(self, params: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: + self.received.append(params) + return {"creatives": []} + + +class _GetProductsHandler(ADCPHandler[Any]): + def __init__(self) -> None: + self.received: list[dict[str, Any]] = [] + + async def get_products(self, params: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: + self.received.append(params) + return {"products": []} + + +class _CreateMediaBuyHandler(ADCPHandler[Any]): + def __init__(self) -> None: + self.received: list[dict[str, Any]] = [] + + async def create_media_buy(self, params: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: + self.received.append(params) + return {"media_buy_id": "mb-1"} + + +class _UpdateMediaBuyHandler(ADCPHandler[Any]): + def __init__(self) -> None: + self.received: list[dict[str, Any]] = [] + + async def update_media_buy(self, params: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: + self.received.append(params) + return {"updated": True} + + +# --------------------------------------------------------------------------- +# sync_creatives — bare-string format_id is the v2.5 marker +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sync_creatives_bare_string_format_id_triggers_v2_5_adapter() -> None: + """No ``adcp_version`` on the wire, but a creative has + ``format_id`` as a bare string. Shape probe matches, adapter runs, + handler sees the v3-shaped structured ``format_id``.""" + handler = _SyncCreativesHandler() + caller = create_tool_caller(handler, "sync_creatives") + + await caller( + { + "creatives": [ + { + "creative_id": "c1", + "name": "Banner", + "format_id": "display_300x250", # v2.5 bare string + "assets": {}, + } + ], + } + ) + + assert len(handler.received) == 1 + fid = handler.received[0]["creatives"][0]["format_id"] + assert fid == {"agent_url": _CANONICAL_URL, "id": "display_300x250"} + + +@pytest.mark.asyncio +async def test_sync_creatives_structured_format_id_does_not_trigger_v2_5() -> None: + """v3 buyer (no envelope, structured format_id) → no shape match, + handler sees the payload unchanged. Bias-conservative check.""" + handler = _SyncCreativesHandler() + caller = create_tool_caller(handler, "sync_creatives") + + structured = {"agent_url": _CANONICAL_URL, "id": "display_300x250"} + await caller( + { + "creatives": [ + { + "creative_id": "c1", + "name": "Banner", + "format_id": structured, + "assets": {}, + } + ], + } + ) + + assert len(handler.received) == 1 + assert handler.received[0]["creatives"][0]["format_id"] == structured + + +# --------------------------------------------------------------------------- +# get_products — brand_manifest OR promoted_offerings is the marker +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_products_brand_manifest_triggers_v2_5_adapter() -> None: + handler = _GetProductsHandler() + caller = create_tool_caller(handler, "get_products") + + await caller({"brand_manifest": "https://acme.example.com"}) + + assert handler.received[0]["brand"] == {"domain": "acme.example.com"} + assert "brand_manifest" not in handler.received[0] + + +@pytest.mark.asyncio +async def test_get_products_promoted_offerings_triggers_v2_5_adapter() -> None: + handler = _GetProductsHandler() + caller = create_tool_caller(handler, "get_products") + + await caller({"promoted_offerings": {"offerings": [{"name": "x"}]}}) + + assert handler.received[0]["catalog"] == { + "type": "offering", + "items": [{"name": "x"}], + } + assert "promoted_offerings" not in handler.received[0] + + +@pytest.mark.asyncio +async def test_get_products_v3_payload_no_shape_match() -> None: + """v3 buyer with ``brand: {domain}`` — no v2.5 marker. Adapter must + not fire.""" + handler = _GetProductsHandler() + caller = create_tool_caller(handler, "get_products") + + await caller({"brand": {"domain": "acme.example.com"}, "brief": "Q4"}) + + # Handler sees the v3 payload unchanged. + assert handler.received[0]["brand"] == {"domain": "acme.example.com"} + + +# --------------------------------------------------------------------------- +# create_media_buy / update_media_buy — creative_ids in package is the marker +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_media_buy_creative_ids_in_package_triggers_v2_5() -> None: + handler = _CreateMediaBuyHandler() + caller = create_tool_caller(handler, "create_media_buy") + + await caller({"packages": [{"pkg_id": "p1", "creative_ids": ["c1", "c2"]}]}) + + pkg = handler.received[0]["packages"][0] + assert pkg["creative_assignments"] == [ + {"creative_id": "c1"}, + {"creative_id": "c2"}, + ] + assert "creative_ids" not in pkg + + +@pytest.mark.asyncio +async def test_update_media_buy_creative_ids_in_package_triggers_v2_5() -> None: + handler = _UpdateMediaBuyHandler() + caller = create_tool_caller(handler, "update_media_buy") + + await caller({"packages": [{"pkg_id": "p1", "creative_ids": ["c1"]}]}) + + pkg = handler.received[0]["packages"][0] + assert pkg["creative_assignments"] == [{"creative_id": "c1"}] + assert "creative_ids" not in pkg + + +@pytest.mark.asyncio +async def test_create_media_buy_v3_assignments_no_shape_match() -> None: + """v3 buyer with ``creative_assignments`` — no v2.5 marker.""" + handler = _CreateMediaBuyHandler() + caller = create_tool_caller(handler, "create_media_buy") + + v3_assignments = [{"creative_id": "c1", "weight": 50}] + await caller({"packages": [{"pkg_id": "p1", "creative_assignments": v3_assignments}]}) + + pkg = handler.received[0]["packages"][0] + assert pkg["creative_assignments"] == v3_assignments + assert "creative_ids" not in pkg + + +# --------------------------------------------------------------------------- +# Explicit version wins over shape detection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_explicit_adcp_version_skips_shape_probe() -> None: + """If the buyer DID send ``adcp_version="3.0"`` but the payload + coincidentally has a v2.5-looking field, the explicit version wins. + Shape detection is the fallback, not an override. + + (Constructed scenario: this shouldn't happen with a well-behaved v3 + client, but the precedence needs to be tested.) + """ + handler = _GetProductsHandler() + caller = create_tool_caller(handler, "get_products") + + await caller( + { + "adcp_version": "3.0", + "brand_manifest": "https://acme.example.com", # v2.5 marker + "brief": "Q4", + } + ) + + # Adapter was NOT run — handler sees the raw payload with + # brand_manifest still present. + assert handler.received[0].get("brand_manifest") == "https://acme.example.com" + assert "brand" not in handler.received[0] + + +# --------------------------------------------------------------------------- +# Tools without a probe (list_creative_formats, preview_creative) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_probe_means_no_shape_detection() -> None: + """``list_creative_formats`` and ``preview_creative`` have + pass-through requests (no v2.5 wire marker possible), so they + declare no probe. A request without envelope reaches the handler + without adapter routing. + """ + + class _ListFormatsHandler(ADCPHandler[Any]): + def __init__(self) -> None: + self.received: list[dict[str, Any]] = [] + + async def list_creative_formats( + self, params: dict[str, Any], ctx: ToolContext + ) -> dict[str, Any]: + self.received.append(params) + return {"formats": []} + + handler = _ListFormatsHandler() + caller = create_tool_caller(handler, "list_creative_formats") + + await caller({"filter": {"x": 1}}) + + # No adapter ran (would have wrapped in something v3-shaped). + # Handler sees the payload as the buyer sent it. + assert handler.received[0] == {"filter": {"x": 1}}