diff --git a/examples/v3_reference_seller/README.md b/examples/v3_reference_seller/README.md index 0f307bbbf..c8dbcc93c 100644 --- a/examples/v3_reference_seller/README.md +++ b/examples/v3_reference_seller/README.md @@ -106,6 +106,47 @@ populated by the framework's dispatch gate before the method runs. This file is the bulk of what an adopter customizes. Everything else is boilerplate the seller wires once. +## Validation mode + +The seller boots with `ValidationHookConfig(requests="strict", responses="strict")` by default. In strict mode the SDK schema-validates every incoming request **before** the handler runs and every outgoing response **after** the handler returns. A malformed request is rejected immediately with a spec-shaped `VALIDATION_ERROR` response; no business logic is touched. + +**When to drop to warn mode** — buyers and sellers in mixed-version rollouts sometimes send payloads that are valid under a slightly different spec revision. In `warn` mode the SDK logs a warning and processes the request/response normally, so you can observe drift without hard-failing traffic. + +Set `ADCP_ENV=production` (or `prod`) in your deployment environment to activate warn mode: + +```bash +ADCP_ENV=production DATABASE_URL=... python -m src.app +``` + +This reuses the same convention the SDK's client-side validator uses, so both sides flip together when `ADCP_ENV` changes. + +**Trade-offs at a glance** + +| | `strict` (default) | `warn` (`ADCP_ENV=production`) | +|---|---|---| +| Malformed requests | Rejected — `VALIDATION_ERROR` | Processed + warning logged | +| Non-conformant responses | Rejected — `VALIDATION_ERROR` | Returned to buyer + warning logged | +| Best for | Dev / CI / compliance testing | Production rollouts with mixed buyer or spec versions | + +**What a validation rejection looks like** — a strict-mode rejection returns a spec-shaped error body before the platform method runs: + +```json +{ + "adcp_error": { + "errors": [ + { + "code": "VALIDATION_ERROR", + "message": "Request validation failed: 'brief' is a required property", + "field": "/brief", + "details": { "side": "request" } + } + ] + } +} +``` + +Look for `errors[0].code == "VALIDATION_ERROR"` and `errors[0].details.side` (`"request"` or `"response"`) to distinguish validation failures from application errors in your buyer logs. + ## Auth modes The seller supports both v3 signed-request and pre-trust beta diff --git a/examples/v3_reference_seller/src/app.py b/examples/v3_reference_seller/src/app.py index 05ee51fe1..97c062d94 100644 --- a/examples/v3_reference_seller/src/app.py +++ b/examples/v3_reference_seller/src/app.py @@ -43,6 +43,8 @@ ToolContext, current_tenant, ) +from adcp.validation import ValidationHookConfig +from adcp.validation.client_hooks import ValidationMode from .audit import make_sink as make_audit_sink from .buyer_registry import make_registry as make_buyer_registry @@ -86,6 +88,26 @@ async def _bootstrap_schema(engine) -> None: await conn.run_sync(Base.metadata.create_all) +def _build_validation_config() -> ValidationHookConfig: + """Return a :class:`ValidationHookConfig` for boot-time wiring. + + Defaults to ``strict`` on both sides so malformed requests and + responses are rejected at the boundary — bugs like the recent + ``pricing_options`` shape conformance issue surface immediately + rather than at storyboard run time. + + Drops to ``warn`` when ``ADCP_ENV`` is ``prod`` or ``production`` + (the same convention the client-side response validator uses — set + ``ADCP_ENV=production`` and both sides flip together). This lets sellers running a mixed-buyer + rollout tolerate minor out-of-spec traffic without hard-failing + requests. Set ``ADCP_ENV=production`` in your deployment environment + when you need the softer mode. + """ + adcp_env = os.environ.get("ADCP_ENV", "").strip().lower() + mode: ValidationMode = "warn" if adcp_env in {"prod", "production"} else "strict" + return ValidationHookConfig(requests=mode, responses=mode) + + def main() -> None: """Entrypoint — boot the seller.""" logging.basicConfig( @@ -123,6 +145,7 @@ def main() -> None: transport="both", buyer_agent_registry=buyer_registry, context_factory=_build_context_factory(), + validation=_build_validation_config(), # SubdomainTenantMiddleware reads the request Host header, # resolves it via the SQL router, and sets the # ``current_tenant()`` contextvar before the handler runs. diff --git a/examples/v3_reference_seller/tests/test_smoke.py b/examples/v3_reference_seller/tests/test_smoke.py index 2cd59fa43..11adf9d4a 100644 --- a/examples/v3_reference_seller/tests/test_smoke.py +++ b/examples/v3_reference_seller/tests/test_smoke.py @@ -111,3 +111,96 @@ async def test_buyer_registry_returns_none_without_tenant() -> None: cred = ApiKeyCredential(kind="api_key", key_id="any") assert await registry.resolve_by_agent_url("https://x/") is None assert await registry.resolve_by_credential(cred) is None + + +# --------------------------------------------------------------------------- +# Validation config smoke tests (no DB, no HTTP) +# --------------------------------------------------------------------------- + + +def test_build_validation_config_strict_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + """_build_validation_config() defaults to strict when ADCP_ENV is unset.""" + monkeypatch.delenv("ADCP_ENV", raising=False) + + from src.app import _build_validation_config + + cfg = _build_validation_config() + assert cfg.requests == "strict" + assert cfg.responses == "strict" + + +def test_build_validation_config_warn_in_production(monkeypatch: pytest.MonkeyPatch) -> None: + """_build_validation_config() returns warn mode when ADCP_ENV=production.""" + monkeypatch.setenv("ADCP_ENV", "production") + + from src.app import _build_validation_config + + cfg = _build_validation_config() + assert cfg.requests == "warn" + assert cfg.responses == "warn" + + +def test_build_validation_config_warn_for_prod_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """_build_validation_config() also accepts ADCP_ENV=prod (short form).""" + monkeypatch.setenv("ADCP_ENV", "prod") + + from src.app import _build_validation_config + + cfg = _build_validation_config() + assert cfg.requests == "warn" + assert cfg.responses == "warn" + + +@pytest.mark.asyncio +async def test_strict_validation_rejects_malformed_request() -> None: + """Strict mode rejects an empty get_products call before the handler runs.""" + from adcp.exceptions import ADCPTaskError + from adcp.server.base import ADCPHandler, ToolContext + from adcp.server.mcp_tools import create_tool_caller + from adcp.validation import ValidationHookConfig + + class _StubSeller(ADCPHandler): # type: ignore[type-arg] + called = False + + async def get_products(self, params: dict, context: ToolContext | None = None) -> dict: # type: ignore[override] + _StubSeller.called = True + return {"products": []} + + handler = _StubSeller() + caller = create_tool_caller( + handler, "get_products", validation=ValidationHookConfig(requests="strict") + ) + with pytest.raises(ADCPTaskError) as exc_info: + await caller({}) + assert not handler.called, "handler must not be called when request is invalid" + assert exc_info.value.errors[0].code == "VALIDATION_ERROR" + assert exc_info.value.errors[0].details["side"] == "request" + + +@pytest.mark.asyncio +async def test_warn_validation_processes_malformed_request( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn mode logs a warning but still dispatches the handler.""" + import logging + + from adcp.server.base import ADCPHandler, ToolContext + from adcp.server.mcp_tools import create_tool_caller + from adcp.validation import ValidationHookConfig + + class _StubSeller(ADCPHandler): # type: ignore[type-arg] + called = False + + async def get_products(self, params: dict, context: ToolContext | None = None) -> dict: # type: ignore[override] + _StubSeller.called = True + return {"products": []} + + handler = _StubSeller() + caller = create_tool_caller( + handler, "get_products", validation=ValidationHookConfig(requests="warn") + ) + with caplog.at_level(logging.WARNING, logger="adcp.server.mcp_tools"): + result = await caller({}) + assert handler.called, "handler must be called in warn mode" + assert isinstance(result.get("products"), list) + assert any("validation warning" in r.message.lower() for r in caplog.records) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index af508554a..4fe12156e 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -47,6 +47,7 @@ from a2a.server.tasks.task_store import TaskStore from adcp.server.serve import ContextFactory, SkillMiddleware + from adcp.validation.client_hooks import ValidationHookConfig from collections.abc import Callable # noqa: E402 @@ -124,6 +125,7 @@ def __init__( middleware: Sequence[SkillMiddleware] | None = None, message_parser: MessageParser | None = None, advertise_all: bool = False, + validation: ValidationHookConfig | None = None, ) -> None: self._handler = handler self._context_factory = context_factory @@ -148,7 +150,7 @@ def __init__( name = tool_def["name"] if name == "comply_test_controller" and test_controller is None: continue - self._tool_callers[name] = create_tool_caller(handler, name) + self._tool_callers[name] = create_tool_caller(handler, name, validation=validation) if test_controller is not None: self._register_test_controller(test_controller) @@ -598,6 +600,7 @@ def create_a2a_server( middleware: Sequence[SkillMiddleware] | None = None, message_parser: MessageParser | None = None, advertise_all: bool = False, + validation: ValidationHookConfig | None = None, ) -> Any: """Create an A2A Starlette application from an ADCP handler. @@ -675,6 +678,14 @@ def create_a2a_server( ``skills`` list and in the executor's tool-caller registry. Turn on for spec-compliance storyboards or when the agent deliberately wants clients to see a ``not_supported`` tool. + validation: Optional :class:`~adcp.validation.ValidationHookConfig` + applied to every A2A skill dispatch. ``None`` (default) + disables schema validation. Pass + ``ValidationHookConfig(requests="strict", responses="strict")`` + to reject malformed payloads, or ``"warn"`` to log and + continue. Mirrors the MCP-side ``validation=`` param on + :func:`~adcp.server.create_mcp_server` — the same config + object works on both transports. Returns: A Starlette app ready to be run with uvicorn. @@ -688,6 +699,7 @@ def create_a2a_server( middleware=middleware, message_parser=message_parser, advertise_all=advertise_all, + validation=validation, ) agent_card = _build_agent_card( diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index 093bc7b37..4b4711f48 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -44,6 +44,7 @@ async def get_adcp_capabilities(self, params, context=None): from adcp.server.a2a_server import MessageParser from adcp.server.test_controller import TestControllerStore + from adcp.validation.client_hooks import ValidationHookConfig @dataclass(frozen=True) @@ -412,6 +413,7 @@ def serve( advertise_all: bool = False, max_request_size: int | None = None, streaming_responses: bool = False, + validation: ValidationHookConfig | None = None, ) -> None: """Start an MCP or A2A server from an ADCP handler or server builder. @@ -509,6 +511,16 @@ def serve( (MCP transports only). Note: the legacy ``transport="sse"`` is a separate (deprecated) MCP transport, unrelated to this flag. + validation: Optional :class:`~adcp.validation.ValidationHookConfig` + applied to every tool dispatch on both the MCP and A2A + transports. ``None`` (default) disables schema validation + entirely — no change from pre-4.4 behavior. Pass + ``ValidationHookConfig(requests="strict", responses="strict")`` + to reject malformed payloads at the boundary, or + ``ValidationHookConfig(requests="warn", responses="warn")`` + to log warnings without rejecting. The same config reaches + both transports when ``transport="both"`` — there is no + per-transport override. Security: This function does NOT configure authentication. In production, @@ -561,6 +573,7 @@ async def force_account_status(self, account_id, status): message_parser=message_parser, advertise_all=advertise_all, max_request_size=max_request_size, + validation=validation, ) elif transport in ("streamable-http", "sse", "stdio"): _serve_mcp( @@ -577,6 +590,7 @@ async def force_account_status(self, account_id, status): advertise_all=advertise_all, max_request_size=max_request_size, streaming_responses=streaming_responses, + validation=validation, ) elif transport == "both": _serve_mcp_and_a2a( @@ -595,6 +609,7 @@ async def force_account_status(self, account_id, status): advertise_all=advertise_all, max_request_size=max_request_size, streaming_responses=streaming_responses, + validation=validation, ) else: valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio"))) @@ -782,6 +797,7 @@ def _serve_mcp( advertise_all: bool = False, max_request_size: int | None = None, streaming_responses: bool = False, + validation: ValidationHookConfig | None = None, ) -> None: """Start an MCP server.""" mcp = create_mcp_server( @@ -795,6 +811,7 @@ def _serve_mcp( middleware=middleware, advertise_all=advertise_all, streaming_responses=streaming_responses, + validation=validation, ) if test_controller is not None: @@ -885,6 +902,7 @@ def _serve_a2a( message_parser: MessageParser | None = None, advertise_all: bool = False, max_request_size: int | None = None, + validation: ValidationHookConfig | None = None, ) -> None: """Start an A2A server using uvicorn.""" import uvicorn @@ -904,6 +922,7 @@ def _serve_a2a( middleware=middleware, message_parser=message_parser, advertise_all=advertise_all, + validation=validation, ) app = _wrap_with_size_limit(app, max_request_size) app = _apply_asgi_middleware(app, asgi_middleware) @@ -941,6 +960,7 @@ def _build_mcp_and_a2a_app( advertise_all: bool = False, max_request_size: int | None = None, streaming_responses: bool = False, + validation: ValidationHookConfig | None = None, ) -> Any: """Build the unified MCP+A2A ASGI app without starting a server. @@ -974,6 +994,7 @@ def _build_mcp_and_a2a_app( middleware=middleware, advertise_all=advertise_all, streaming_responses=streaming_responses, + validation=validation, ) if test_controller is not None: from adcp.server.test_controller import register_test_controller @@ -1000,6 +1021,7 @@ def _build_mcp_and_a2a_app( middleware=middleware, message_parser=message_parser, advertise_all=advertise_all, + validation=validation, ) # Lifespan composition: FastMCP's session manager initializes a @@ -1061,6 +1083,7 @@ def _serve_mcp_and_a2a( advertise_all: bool = False, max_request_size: int | None = None, streaming_responses: bool = False, + validation: ValidationHookConfig | None = None, ) -> None: """Serve MCP and A2A on a single port via path dispatch. @@ -1098,6 +1121,7 @@ def _serve_mcp_and_a2a( advertise_all=advertise_all, max_request_size=max_request_size, streaming_responses=streaming_responses, + validation=validation, ) app = _apply_asgi_middleware(app, asgi_middleware) @@ -1131,6 +1155,7 @@ def create_mcp_server( middleware: Sequence[SkillMiddleware] | None = None, advertise_all: bool = False, streaming_responses: bool = False, + validation: ValidationHookConfig | None = None, ) -> Any: """Create a FastMCP server from an ADCP handler without starting it. @@ -1185,6 +1210,15 @@ def create_mcp_server( without completing, blocking the storyboard runner. Set to ``True`` only if your tools genuinely emit progress notifications and your clients consume the SSE stream. + validation: Optional :class:`~adcp.validation.ValidationHookConfig` + applied to every tool call. When ``None`` (default), schema + validation is off — identical to pre-4.4 behavior. Pass + ``ValidationHookConfig(requests="strict", responses="strict")`` + to reject out-of-spec requests and responses at the boundary, + or ``ValidationHookConfig(requests="warn", responses="warn")`` + to log warnings and continue. See + :data:`~adcp.validation.ValidationMode` for the full set of + modes (``"strict"``, ``"warn"``, ``"off"``). Returns: A configured FastMCP server instance. Call ``mcp.run()`` to start, @@ -1253,6 +1287,7 @@ def create_mcp_server( context_factory=context_factory, middleware=middleware, advertise_all=advertise_all, + validation=validation, ) return mcp @@ -1265,6 +1300,7 @@ def _register_handler_tools( context_factory: ContextFactory | None = None, middleware: Sequence[SkillMiddleware] | None = None, advertise_all: bool = False, + validation: ValidationHookConfig | None = None, ) -> None: """Register all ADCP tools from a handler onto a FastMCP server.""" # Freeze middleware ordering at registration time. Tuple both guards @@ -1283,7 +1319,7 @@ def _register_handler_tools( continue description = tool_def.get("description", "") input_schema = tool_def.get("inputSchema", {"type": "object", "properties": {}}) - caller = create_tool_caller(handler, tool_name) + caller = create_tool_caller(handler, tool_name, validation=validation) _register_tool( mcp, tool_name,