Skip to content

Commit d2ffac7

Browse files
bokelleyclaude
andauthored
feat(server): route validation by wire adcp_version (stage 3) (#664)
* feat(validation): per-version validator loader (stage 2) ``get_validator``, ``validate_request``, and ``validate_response`` now accept an optional ``version=`` kwarg. ``None`` defaults to the SDK's compile-time pin (existing behaviour, byte-identical for current call sites). A wire-version string (``"3.0.7"``, ``"2.5"``, ``"3.1.0-beta.1"``) routes to the per-bundle-key cache laid down by Stage 1 — each version's file index, compiled validators, and core registry live in their own ``_LoaderState`` so cross-version traffic doesn't share compilation state. The dispatcher call sites are unchanged today; Stage 3 will detect the buyer's ``adcp_major_version`` off the wire and thread it through. Also widens ``resolve_bundle_key`` to accept bare ``MAJOR.MINOR`` (already a bundle key — matches the wire envelope's release-precision ``adcp_version`` field, so the dispatcher can pass it straight through). Tests: * ``test_schema_loader_per_version`` lays down a synthetic ``schemas/cache/2.5/`` fixture, asserts validators for the synthetic legacy tool and the real SDK-pin tools coexist independently, and pins ``version=None`` behaviour to the SDK pin. * ``test_validation_version`` extended with the new pass-through and rejection cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(validation): isolate per-version loader fixture from repo working tree Review feedback on PR #659 (Stage 2). Two nits: * Fixture used to write into the repo's real ``schemas/cache/2.5/``, which means CI runs against an installed wheel would silently miss the synthetic bundle (packaged ``_schemas/`` wins) and the assertions would be vacuous. Move to ``tmp_path`` + ``monkeypatch.setattr`` on the loader's resolver instead. * Teardown used a sequence of ``rmdir`` calls that fail if anything else lands in the directories mid-run. ``shutil.rmtree(..., ignore_errors= True)`` is robust. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): route validation by wire ``adcp_version`` (stage 3) Stage 3 of the versioned-schema-validation port. ``create_tool_caller`` now detects the buyer's claimed version off the request envelope and threads it through to the per-version validator added in Stage 2. Detection order (mirrors the TS SDK's ``applyVersionEnvelope`` and the spec text on every ``*-request`` schema): 1. ``adcp_version`` (3.1+ release-precision string) — normalized to release precision (``"3.0.7"`` → ``"3.0"``); must be in ``COMPATIBLE_ADCP_VERSIONS`` or ``VERSION_UNSUPPORTED`` is raised. 2. ``adcp_major_version`` (legacy integer) — maps to the highest supported minor for that major. Unknown major raises ``VERSION_UNSUPPORTED`` with structured details. 3. Neither field set — falls through to the SDK pin (existing behaviour). New module ``adcp.validation.envelope`` owns the detection logic; ``UnsupportedVersionError`` carries the wire value and supported set so the dispatcher can echo both into the error envelope's ``details``. Existing ``test_spec_compat_hooks`` payloads used ``adcp_major_version=4`` as a wire-shape placeholder — updated to ``3`` now that the dispatcher actually validates the field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(envelope): reject string adcp_major_version, preserve int type in echo Review feedback on PR #660. * String ``adcp_major_version`` (e.g. ``"3"``) used to fall through to ``None`` and silently get SDK-pin validation. A buyer that JSON-stringified the field deserves a loud ``VERSION_UNSUPPORTED``, not a quiet behaviour swap. Same for ``< 1`` values (below the spec's ``minimum: 1`` bound). * ``claimed_version`` in error details used to be ``str(exc.wire_value)``, which converted the int field to ``"4"``. Pass through verbatim so buyer telemetry sees the same type they sent. * Rename ``test_no_version_field_validator_uses_sdk_pin`` to actually test that claim — pass a ``ValidationHookConfig`` so the validator fires, and assert ``version=None`` is threaded. Split the original test's "no validation config means no validator runs" assertion into its own regression test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): default-permissive VERSION_UNSUPPORTED gate (ADCP_STRICT_VERSION_ENVELOPE) Downstream-impact audit on PR #660 flagged that switching from "wire schema accepts adcp_major_version 1-99" to "dispatcher rejects unknown versions" in one release would break test fixtures using ``4`` as a sentinel and any buyer claiming an unsupported version that happened to pass schema validation. Decouple legacy adapter routing (additive, ships now) from version-envelope strictness (subtractive, ships under a gate). * Default (``ADCP_STRICT_VERSION_ENVELOPE`` unset or ``"0"``): an unsupported wire version is logged at WARNING with a migration hint pointing at the env var. The dispatcher falls through to SDK-pin validation — same as 5.1.0 behaviour for that payload. * Strict (``ADCP_STRICT_VERSION_ENVELOPE=1``): raise the ``VERSION_UNSUPPORTED`` envelope the spec prescribes, before any handler dispatch. This becomes the default in 5.3. Existing strict-mode tests gain a ``strict_version_envelope`` fixture so the spec-prescribed behaviour stays covered. New ``test_unsupported_version_permissive_falls_through`` exercises the default-permissive path and asserts the migration hint shows up in logs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6311a9a commit d2ffac7

5 files changed

Lines changed: 517 additions & 5 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import copy
2222
import difflib
2323
import logging
24+
import os
2425
from collections.abc import Callable, Iterable
2526
from typing import Any
2627

@@ -1945,6 +1946,7 @@ def create_tool_caller(
19451946
from adcp.exceptions import ADCPTaskError
19461947
from adcp.server.helpers import inject_context
19471948
from adcp.types import Error
1949+
from adcp.validation.envelope import UnsupportedVersionError, detect_wire_version
19481950
from adcp.validation.schema_errors import build_adcp_validation_error_payload
19491951
from adcp.validation.schema_validator import (
19501952
format_issues,
@@ -1980,8 +1982,52 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
19801982
],
19811983
) from exc
19821984

1985+
# Wire-version detection: read ``adcp_version`` / ``adcp_major_version``
1986+
# off the post-hook params (legacy buyers may rely on a hook to
1987+
# populate the envelope, so this runs after pre_validation_hook).
1988+
# ``None`` means the buyer didn't claim a version — fall through
1989+
# to the SDK pin via ``version=None`` on the validator.
1990+
#
1991+
# Strictness gate: setting ``ADCP_STRICT_VERSION_ENVELOPE=1``
1992+
# raises ``VERSION_UNSUPPORTED`` for unsupported claims (the
1993+
# spec-prescribed behaviour). The default (off) logs a warning
1994+
# and falls through to SDK-pin validation — adopters with test
1995+
# fixtures using placeholder version values (``adcp_major_version=4``
1996+
# was a common sentinel before this gate existed) keep working
1997+
# while they migrate. Strict will become the default in 5.3.
1998+
try:
1999+
wire_version = detect_wire_version(params)
2000+
except UnsupportedVersionError as exc:
2001+
if os.environ.get("ADCP_STRICT_VERSION_ENVELOPE", "0") == "1":
2002+
raise ADCPTaskError(
2003+
operation=method_name,
2004+
errors=[
2005+
Error(
2006+
code="VERSION_UNSUPPORTED",
2007+
message=str(exc),
2008+
# Preserve the wire field's original type so
2009+
# buyer telemetry sees the same shape they
2010+
# sent (int for ``adcp_major_version``, str
2011+
# for ``adcp_version``).
2012+
details={
2013+
"claimed_version": exc.wire_value,
2014+
"supported_versions": list(exc.supported),
2015+
},
2016+
)
2017+
],
2018+
) from exc
2019+
logger.warning(
2020+
"Wire-version envelope rejected by detect_wire_version (%s); "
2021+
"falling through to SDK-pin validation. "
2022+
"Set ADCP_STRICT_VERSION_ENVELOPE=1 to raise "
2023+
"VERSION_UNSUPPORTED instead. Strict will become the default "
2024+
"in 5.3.",
2025+
exc,
2026+
)
2027+
wire_version = None
2028+
19832029
if request_mode is not None and request_mode != "off":
1984-
outcome = validate_request(method_name, params)
2030+
outcome = validate_request(method_name, params, version=wire_version)
19852031
if not outcome.valid:
19862032
summary = format_issues(outcome.issues)
19872033
if request_mode == "strict":
@@ -2076,7 +2122,7 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
20762122
# per-tool response schema would false-positive on it and
20772123
# convert a real protocol error into a fake VALIDATION_ERROR.
20782124
if "adcp_error" not in result:
2079-
outcome = validate_response(method_name, result)
2125+
outcome = validate_response(method_name, result, version=wire_version)
20802126
if not outcome.valid:
20812127
summary = format_issues(outcome.issues)
20822128
logger.warning(
@@ -2109,7 +2155,9 @@ def __init__(
21092155
*,
21102156
advertise_all: bool = False,
21112157
validation: ValidationHookConfig | None = None,
2112-
pre_validation_hooks: dict[str, Callable[[str, dict[str, Any]], dict[str, Any]]] | None = None,
2158+
pre_validation_hooks: (
2159+
dict[str, Callable[[str, dict[str, Any]], dict[str, Any]]] | None
2160+
) = None,
21132161
):
21142162
"""Create tool set from handler.
21152163

src/adcp/validation/envelope.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Wire-version detection for inbound AdCP requests.
2+
3+
Per the AdCP version-envelope contract (``core/version-envelope.json``),
4+
every request carries either:
5+
6+
* ``adcp_version`` — release-precision string (``"3.0"``, ``"3.1"``,
7+
``"3.1-beta"``). Added in 3.1+; takes precedence when present.
8+
* ``adcp_major_version`` — integer (``2``, ``3``). The pre-3.1 wire shape
9+
and the lowest common denominator for buyers that don't yet emit the
10+
release-precision field.
11+
12+
:func:`detect_wire_version` collapses both shapes to a release-precision
13+
string the loader can pass to :func:`adcp.validation.schema_loader.get_validator`
14+
as ``version=``. A buyer claiming an unsupported version raises a
15+
:class:`UnsupportedVersionError`, which the dispatcher converts to an
16+
``AdcpError`` with code ``VERSION_UNSUPPORTED`` per the spec.
17+
18+
Mirrors the JS SDK's ``applyVersionEnvelope`` in
19+
``src/lib/protocols/index.ts``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any
25+
26+
from adcp._version import COMPATIBLE_ADCP_VERSIONS, normalize_to_release_precision
27+
28+
29+
class UnsupportedVersionError(ValueError):
30+
"""The wire version the buyer claims isn't supported by this server.
31+
32+
Carries the original wire value plus the supported list so the
33+
dispatcher can echo both into ``VERSION_UNSUPPORTED`` error details.
34+
"""
35+
36+
def __init__(self, wire_value: str | int, supported: tuple[str, ...]) -> None:
37+
self.wire_value = wire_value
38+
self.supported = supported
39+
super().__init__(
40+
f"AdCP version {wire_value!r} is not supported by this server "
41+
f"(supported release-precision versions: {list(supported)})."
42+
)
43+
44+
45+
def detect_wire_version(
46+
payload: Any,
47+
*,
48+
supported: tuple[str, ...] = COMPATIBLE_ADCP_VERSIONS,
49+
) -> str | None:
50+
"""Return the release-precision version a request claims, or ``None``.
51+
52+
Resolution order:
53+
54+
1. ``payload['adcp_version']`` — string, normalized to release
55+
precision (``"3.0.7"`` → ``"3.0"``). Must be in ``supported`` or
56+
raises :class:`UnsupportedVersionError`.
57+
2. ``payload['adcp_major_version']`` — int. Maps to the highest minor
58+
in ``supported`` for that major. No supported minor for the major
59+
raises :class:`UnsupportedVersionError`.
60+
3. Neither field set — returns ``None`` so the caller falls back to
61+
the SDK's compile-time pin.
62+
63+
Non-dict payloads return ``None`` (validation skipped — the schema
64+
layer rejects non-dict requests via its own type check).
65+
"""
66+
if not isinstance(payload, dict):
67+
return None
68+
69+
explicit = payload.get("adcp_version")
70+
if isinstance(explicit, str) and explicit:
71+
try:
72+
normalized = normalize_to_release_precision(explicit)
73+
except ValueError as exc:
74+
raise UnsupportedVersionError(explicit, supported) from exc
75+
if normalized not in supported:
76+
raise UnsupportedVersionError(explicit, supported)
77+
return normalized
78+
# Empty-string ``adcp_version`` falls through to ``adcp_major_version``
79+
# intentionally — pre-3.1 buyers may set both fields, and an empty
80+
# string from a half-migrated client shouldn't override the int field.
81+
82+
major_value = payload.get("adcp_major_version")
83+
# Wire field is strictly an int per spec (``minimum:1, maximum:99``).
84+
# Two type-coercion cases that would otherwise bypass the supported-set
85+
# check silently — reject loudly instead:
86+
# * ``bool`` is an ``int`` subclass; ``True``/``False`` would map to
87+
# major=1/0.
88+
# * String ints (``"3"``) from a buyer that JSON-stringified the field —
89+
# ``isinstance(x, int)`` returns False, so without an explicit check
90+
# the buyer would silently get SDK-pin validation instead of an error.
91+
if isinstance(major_value, str):
92+
raise UnsupportedVersionError(major_value, supported)
93+
if isinstance(major_value, int) and not isinstance(major_value, bool):
94+
if major_value < 1:
95+
raise UnsupportedVersionError(major_value, supported)
96+
candidates = [v for v in supported if v.startswith(f"{major_value}.")]
97+
if not candidates:
98+
raise UnsupportedVersionError(major_value, supported)
99+
# Highest supported minor for this major.
100+
return max(candidates, key=lambda v: int(v.split(".")[1].split("-")[0]))
101+
102+
return None

0 commit comments

Comments
 (0)