Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions examples/v3_reference_seller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions examples/v3_reference_seller/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions examples/v3_reference_seller/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 13 additions & 1 deletion src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
Loading
Loading