Skip to content

Commit 4ef032b

Browse files
bokelleyclaude
andauthored
feat(server): pre-adapter validation against legacy schema (stage 4b2) (#671)
When the buyer claims a legacy version that routes through the adapter path, validate the input against the buyer's claimed schema *before* the adapter runs. Legacy field paths surface in errors — easier to act on than v3 paths after translation. Flow when wire_version in LEGACY_ADAPTER_VERSIONS: 1. Lookup adapter; INVALID_REQUEST if missing. 2. (new) Validate params against the legacy schema: - strict: raise VALIDATION_ERROR with claimed_version in details - warn: log the violation and let the adapter try - off / no config: skip (zero-overhead path preserved) 3. Run adapter.adapt_request(params). 4. Post-adapter validation against the SDK pin (existing). Stage 4b1 bundled v2.5 schemas; this wires them into the dispatch path. Full versioned-schema-validation port complete. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd6837f commit 4ef032b

2 files changed

Lines changed: 240 additions & 12 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,10 +2028,13 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
20282028
wire_version = None
20292029

20302030
# Legacy-version routing: if the buyer claims a version handled
2031-
# via the adapter path (e.g. ``"2.5"``), translate the request
2032-
# to current-version shape before validation. The output is then
2033-
# validated against the SDK pin's schema, so a buggy translator
2034-
# surfaces as ``INVALID_REQUEST`` with a field-level pointer.
2031+
# via the adapter path (e.g. ``"2.5"``), validate the params
2032+
# against the legacy schema first, *then* translate to the
2033+
# current shape. Pre-adapter validation surfaces structural
2034+
# errors with the legacy schema's field paths — far easier
2035+
# for the buyer to act on than a v3 field-path error after a
2036+
# confusing translation. Post-adapter validation (further
2037+
# down) catches translator bugs against the SDK pin.
20352038
legacy_adapter: Any = None
20362039
if wire_version in LEGACY_ADAPTER_VERSIONS:
20372040
legacy_adapter = get_legacy_adapter(wire_version, method_name)
@@ -2051,6 +2054,38 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
20512054
)
20522055
],
20532056
)
2057+
2058+
# Pre-adapter validation against the legacy schema.
2059+
# Only runs when validation is enabled at all
2060+
# (``request_mode != off`` AND a config is supplied) — keeps
2061+
# the zero-overhead path for adopters who haven't opted in.
2062+
# ``strict`` rejects; ``warn`` logs and proceeds so the
2063+
# adapter still gets to translate (matching the existing
2064+
# post-adapter contract).
2065+
if request_mode is not None and request_mode != "off":
2066+
pre_outcome = validate_request(method_name, params, version=wire_version)
2067+
if not pre_outcome.valid:
2068+
summary = format_issues(pre_outcome.issues)
2069+
if request_mode == "strict":
2070+
payload = build_adcp_validation_error_payload(
2071+
method_name, "request", pre_outcome.issues
2072+
)
2073+
# Annotate with the wire version so adopter
2074+
# telemetry knows which schema rejected.
2075+
payload_details = dict(payload.get("details") or {})
2076+
payload_details["claimed_version"] = wire_version
2077+
payload["details"] = payload_details
2078+
raise ADCPTaskError(
2079+
operation=method_name,
2080+
errors=[Error(**payload)],
2081+
)
2082+
logger.warning(
2083+
"Schema validation warning (pre-adapter %s) for %s: %s",
2084+
wire_version,
2085+
method_name,
2086+
summary,
2087+
)
2088+
20542089
try:
20552090
params = legacy_adapter.adapt_request(params)
20562091
except Exception as exc:
@@ -2067,14 +2102,10 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
20672102
)
20682103
],
20692104
) from exc
2070-
# ``adapt_request`` produced a current-version dict;
2071-
# validate it against the SDK pin's schema, not the buyer's
2072-
# claimed legacy version. This is the variable Stage 4b
2073-
# (real legacy schema bundle) extends: pre-adapter input
2074-
# gets validated against ``wire_version``, post-adapter
2075-
# output against ``None`` (SDK pin) as today. The
2076-
# ``post_adapter_validator_version`` name documents which
2077-
# of the two roles this value plays.
2105+
# Adapter output is validated against the SDK pin
2106+
# (catches translator bugs with v3 field paths). The
2107+
# ``post_adapter_validator_version`` name documents
2108+
# which side of the adapter this value plays.
20782109
post_adapter_validator_version: str | None = None
20792110
else:
20802111
post_adapter_validator_version = wire_version
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Stage 4b2 tests: pre-adapter validation against the legacy schema.
2+
3+
When the buyer's claimed version routes through the legacy adapter,
4+
``create_tool_caller`` validates the input against that legacy version's
5+
schema *before* the adapter runs. Structural errors surface with the
6+
legacy schema's field paths — far easier for the buyer than a v3 field
7+
path after a confusing translation.
8+
9+
These tests exercise the strict + warn modes, plus the no-validation
10+
fallthrough.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import logging
16+
from typing import Any
17+
18+
import pytest
19+
20+
from adcp.exceptions import ADCPTaskError
21+
from adcp.server.base import ADCPHandler, ToolContext
22+
from adcp.server.mcp_tools import create_tool_caller
23+
from adcp.validation.client_hooks import ValidationHookConfig
24+
25+
26+
class _SyncCreativesHandler(ADCPHandler[Any]):
27+
def __init__(self) -> None:
28+
self.received: list[dict[str, Any]] = []
29+
30+
async def sync_creatives(self, params: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
31+
self.received.append(params)
32+
return {"creatives": []}
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# Strict mode — v2.5 schema violations raise INVALID_REQUEST
37+
# ---------------------------------------------------------------------------
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_strict_rejects_v2_5_payload_with_v2_5_field_path() -> None:
42+
"""A v2.5 buyer's payload that fails v2.5 validation is rejected
43+
*before* the adapter runs. The error reports the v2.5 schema's
44+
field path — far easier for the buyer to act on than a v3 field
45+
path after a confusing translation.
46+
47+
v2.5 ``sync_creatives`` types ``creatives`` as ``array``; sending
48+
an int triggers a type error from the v2.5 validator.
49+
"""
50+
handler = _SyncCreativesHandler()
51+
caller = create_tool_caller(
52+
handler,
53+
"sync_creatives",
54+
validation=ValidationHookConfig(requests="strict"),
55+
)
56+
57+
with pytest.raises(ADCPTaskError) as exc_info:
58+
await caller({"adcp_version": "2.5", "creatives": 42})
59+
60+
err = exc_info.value.errors[0]
61+
# ``build_adcp_validation_error_payload`` returns
62+
# ``VALIDATION_ERROR`` (matches the post-adapter contract).
63+
assert err.code == "VALIDATION_ERROR"
64+
# The v2.5 schema reported the type error at /creatives.
65+
assert "/creatives" in err.message
66+
# Wire version preserved in details so adopter telemetry can
67+
# attribute the failure to a legacy claim.
68+
assert err.details is not None
69+
assert err.details.get("claimed_version") == "2.5"
70+
# Handler never ran.
71+
assert handler.received == []
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_strict_rejects_payload_valid_in_v2_5_but_missing_v3_required() -> None:
76+
"""A v2.5 buyer's payload that's valid in v2.5 but missing a
77+
v3-required field passes pre-adapter validation, gets translated,
78+
and is then rejected by post-adapter v3 validation. The error
79+
surfaces the v3 schema's field path so the buyer knows what to
80+
supply.
81+
"""
82+
handler = _SyncCreativesHandler()
83+
caller = create_tool_caller(
84+
handler,
85+
"sync_creatives",
86+
validation=ValidationHookConfig(requests="strict"),
87+
)
88+
89+
# v2.5 only requires ``creatives``; this is structurally fine.
90+
# v3 also requires ``idempotency_key`` + ``account``, so the
91+
# post-adapter v3 check fails.
92+
with pytest.raises(ADCPTaskError) as exc_info:
93+
await caller({"adcp_version": "2.5", "creatives": []})
94+
95+
err = exc_info.value.errors[0]
96+
assert err.code == "VALIDATION_ERROR"
97+
# v3 schema reported the missing-field error.
98+
assert "idempotency_key" in err.message or "account" in err.message
99+
assert handler.received == []
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# Warn mode — validation failures log but don't block
104+
# ---------------------------------------------------------------------------
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_warn_logs_v2_5_validation_failure_and_proceeds(
109+
caplog: pytest.LogCaptureFixture,
110+
) -> None:
111+
"""In ``warn`` mode, a v2.5 schema violation logs but lets the
112+
adapter try anyway. Matches the existing post-adapter warn semantics
113+
so adopters have a consistent escalation path strict ← warn ← off."""
114+
handler = _SyncCreativesHandler()
115+
caller = create_tool_caller(
116+
handler,
117+
"sync_creatives",
118+
validation=ValidationHookConfig(requests="warn"),
119+
)
120+
121+
with caplog.at_level(logging.WARNING):
122+
await caller({"adcp_version": "2.5", "creatives": 42})
123+
124+
# Warning logged about pre-adapter validation failure.
125+
assert any("pre-adapter 2.5" in rec.message.lower() for rec in caplog.records), [
126+
rec.message for rec in caplog.records
127+
]
128+
129+
130+
# ---------------------------------------------------------------------------
131+
# Off — no validation runs (default behaviour preserved)
132+
# ---------------------------------------------------------------------------
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_no_validation_config_skips_pre_adapter_check() -> None:
137+
"""The zero-overhead path: ``validation=None`` (default) bypasses
138+
both pre- and post-adapter validation. Stage 4b2 must not pull
139+
schema-loading into the hot path for adopters who haven't opted in.
140+
"""
141+
handler = _SyncCreativesHandler()
142+
caller = create_tool_caller(handler, "sync_creatives") # no validation=
143+
144+
# Garbage payload — would fail v2.5 validation if it ran. The
145+
# adapter still gets a chance because validation is off; the v2.5
146+
# sync_creatives adapter is permissive when ``creatives`` isn't a
147+
# list (returns args unchanged), so the handler sees the original.
148+
await caller({"adcp_version": "2.5", "creatives": 42})
149+
150+
assert len(handler.received) == 1
151+
assert handler.received[0]["creatives"] == 42
152+
153+
154+
# ---------------------------------------------------------------------------
155+
# Adapter-output validation against v3 still runs
156+
# ---------------------------------------------------------------------------
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_v2_5_payload_valid_against_v2_5_but_translator_produces_invalid_v3() -> None:
161+
"""Belt + braces: even if pre-adapter validation passes, the
162+
*post*-adapter v3 validation catches translator bugs (the contract
163+
Stage 4 already ships). This test pins that the v2.5 pre-check
164+
doesn't replace the v3 post-check."""
165+
166+
from adcp.compat.legacy import AdapterPair, _reset_registry_for_tests, register_adapter
167+
168+
# Register a rogue adapter that returns a v3-invalid dict so the
169+
# post-adapter validator should catch it.
170+
def rogue(payload: dict[str, Any]) -> dict[str, Any]:
171+
return {"creatives": "not-a-list-after-adapt"}
172+
173+
_reset_registry_for_tests()
174+
try:
175+
register_adapter(
176+
"2.5",
177+
AdapterPair(tool_name="sync_creatives", adapt_request=rogue),
178+
)
179+
180+
handler = _SyncCreativesHandler()
181+
# ``strict`` so a v3 validation failure raises; the v2.5
182+
# pre-check needs to pass (empty creatives is fine in v2.5).
183+
caller = create_tool_caller(
184+
handler,
185+
"sync_creatives",
186+
validation=ValidationHookConfig(requests="strict"),
187+
)
188+
189+
with pytest.raises(ADCPTaskError) as exc_info:
190+
await caller({"adcp_version": "2.5", "creatives": []})
191+
192+
# The v3 post-adapter validator caught the bad output.
193+
err = exc_info.value.errors[0]
194+
assert err.code in ("INVALID_REQUEST", "VALIDATION_ERROR")
195+
assert handler.received == []
196+
finally:
197+
_reset_registry_for_tests()

0 commit comments

Comments
 (0)