From 36fd9fb02642da225f556440cab99def6cc2b58e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:37:39 +0000 Subject: [PATCH 1/2] feat(testing): extend build_asgi_app with full serve-layer kwargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_asgi_app previously returned only the bare mcp.streamable_http_app() without auth, ASGI middleware, path normalization, or request size capping. This caused silent gaps when tests needed transport-layer fidelity matching production (auth middleware, CORS, custom context_factory, etc.). Now applies the same wrapping chain as _run_mcp_http: auth (innermost) → path_normalize → discovery → size_limit → asgi_middleware New params on build_asgi_app and build_test_client: auth, asgi_middleware, context_factory, middleware, streaming_responses, enable_dns_rebinding_protection, allowed_origins, max_request_size, validation (default DEFAULT_VALIDATION matching production), discovery_base_url Also adds AGENTS.md import quick-reference entry for build_asgi_app, build_test_client, make_request_context so coding agents discover these helpers without falling back to network-layer mocks. Closes #618 https://claude.ai/code/session_012Ujdm5DZ7CyECWqSq9KrHN --- AGENTS.md | 11 +- src/adcp/testing/decisioning.py | 151 ++++++++++++++++++-- tests/test_testing_decisioning.py | 230 ++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7797ef372..1b0d89ca0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,15 @@ from adcp.server.responses import capabilities_response, products_response from adcp.server import adcp_error, valid_actions_for_status, resolve_account from adcp.server import inject_context, cancel_media_buy_response -# Testing +# Pre-configured test agents (simple smoke testing) from adcp.testing import test_agent, creative_agent + +# In-process transport testing (DecisioningPlatform) +from adcp.testing import build_asgi_app, build_test_client, make_request_context + +# build_asgi_app: returns a Starlette ASGI app for httpx.ASGITransport / TestClient +# build_test_client: async context manager wrapping build_asgi_app + LifespanManager +# Example: +# async with build_test_client(my_platform, auth=BearerTokenAuth(...)) as client: +# resp = await client.post("/mcp/", json={"jsonrpc": "2.0", ...}) ``` diff --git a/src/adcp/testing/decisioning.py b/src/adcp/testing/decisioning.py index a17ce102c..d306a6109 100644 --- a/src/adcp/testing/decisioning.py +++ b/src/adcp/testing/decisioning.py @@ -32,6 +32,8 @@ from adcp.decisioning.context import RequestContext from adcp.decisioning.types import Account +from adcp.validation.client_hooks import SERVER_DEFAULT_VALIDATION as _DEFAULT_VALIDATION +from adcp.validation.client_hooks import ValidationHookConfig if TYPE_CHECKING: from collections.abc import AsyncIterator, Mapping, Sequence @@ -46,6 +48,8 @@ ResourceResolver, StateReader, ) + from adcp.server.auth import BearerTokenAuth + from adcp.server.serve import ASGIMiddlewareEntry, ContextFactory, SkillMiddleware def make_request_context( @@ -136,20 +140,39 @@ def build_asgi_app( advertise_all: bool = False, auto_emit_completion_webhooks: bool = False, allowed_hosts: Sequence[str] | None = None, + allowed_origins: Sequence[str] | None = None, + auth: BearerTokenAuth | None = None, + asgi_middleware: Sequence[ASGIMiddlewareEntry] | None = None, + context_factory: ContextFactory | None = None, + middleware: Sequence[SkillMiddleware] | None = None, + streaming_responses: bool = False, + enable_dns_rebinding_protection: bool | None = None, + max_request_size: int | None = None, + validation: ValidationHookConfig | None = _DEFAULT_VALIDATION, + discovery_base_url: str | None = None, **factory_kwargs: Any, ) -> Any: """Build a Starlette ASGI app for in-process integration tests. - Composes :func:`adcp.decisioning.create_adcp_server_from_platform` - with :func:`adcp.server.create_mcp_server` and returns the - streamable-HTTP ASGI app — the same surface - :func:`adcp.decisioning.serve` would mount, minus the network bind. - - Defaults differ from the production :func:`serve` wrapper in one - place: ``auto_emit_completion_webhooks`` is ``False`` so tests don't - need to wire a :class:`adcp.webhook_sender.WebhookSender` just to - instantiate a sales platform. Override to ``True`` if your test - explicitly exercises the F12 auto-emit path. + Returns the same middleware stack that :func:`adcp.decisioning.serve` + mounts, minus the network bind. Pass any kwargs you would pass to + :func:`adcp.decisioning.serve` (except the uvicorn-binding ones: + ``port``, ``host``) and the result is drop-in compatible with + ``httpx.ASGITransport``, ``starlette.testclient.TestClient``, or any + ASGI test harness. + + The wrapping order mirrors production + (``_run_mcp_http`` in ``adcp.server.serve``): + + 1. ``auth`` innermost — body-peeks the JSON-RPC payload before the + path normalizer reshapes it (discovery bypass). + 2. Path normalizer — strips trailing slashes so ``/mcp/`` → ``/mcp`` + without a 307 round-trip. + 3. Discovery wrapper (only when ``discovery_base_url`` is provided) — + serves ``/.well-known/adcp-agents.json``. + 4. Size cap (``max_request_size``; ``None`` → 10 MB default). + 5. ``asgi_middleware`` outermost — CORS, tenant resolution, custom + auth, etc. :param platform: The :class:`DecisioningPlatform` instance under test. @@ -168,16 +191,55 @@ def build_asgi_app( embedded in your ``base_url`` when using a non-loopback test address (e.g. ``["test"]`` for ``base_url="http://test"``). :func:`build_test_client` sets this automatically. + :param allowed_origins: CORS origin allowlist forwarded to + :func:`create_mcp_server`. ``None`` → FastMCP default (no CORS). + :param auth: Optional :class:`~adcp.server.auth.BearerTokenAuth` + config applied to the MCP ASGI app. Drives + :class:`~adcp.server.auth.BearerTokenAuthMiddleware`. ``None`` + → no bearer-token validation (unauthenticated). + :param asgi_middleware: Optional ASGI middleware entries applied + outermost — same semantics as :func:`serve`'s ``asgi_middleware`` + param. Use for CORS, request-id propagation, custom auth. + :param context_factory: Optional factory that builds a + :class:`~adcp.server.ToolContext` per tool call. Forwarded to + :func:`create_mcp_server`. ``None`` → bare ``ToolContext()``. + :param middleware: Optional sequence of + :data:`~adcp.server.serve.SkillMiddleware` callables wrapping + every tool dispatch. Forwarded to :func:`create_mcp_server`. + :param streaming_responses: Forwarded to :func:`create_mcp_server`. + Default ``False``. + :param enable_dns_rebinding_protection: Forwarded to + :func:`create_mcp_server`. ``None`` → FastMCP default. + :param max_request_size: Request body size cap in bytes. ``None`` → + the framework default (10 MB). ``0`` → disabled. + :param validation: Schema validation config forwarded to + :func:`create_mcp_server`. Defaults to + :data:`~adcp.server.serve.DEFAULT_VALIDATION` (strict on both + requests and responses) — matches production. Pass + ``validation=None`` to disable. + :param discovery_base_url: When provided, mounts the + ``/.well-known/adcp-agents.json`` discovery endpoint using this + as the advertised base URL (e.g. ``"http://test"``). ``None`` → + discovery endpoint not mounted. :param factory_kwargs: Forwarded to - :func:`create_adcp_server_from_platform` (executor, registry, - webhook_sender, etc.). + :func:`create_adcp_server_from_platform`. Accepted keys: + ``executor``, ``registry``, ``webhook_sender``, + ``webhook_supervisor``, ``buyer_agent_registry``, + ``config_store``, ``property_list_fetcher``, ``state_reader``, + ``resource_resolver``. :returns: A Starlette ASGI application. Usable with ``starlette.testclient.TestClient``, ``httpx.AsyncClient(app=app, ...)``, or any ASGI test harness. """ from adcp.decisioning.serve import create_adcp_server_from_platform - from adcp.server.serve import create_mcp_server + from adcp.server.serve import ( + _apply_asgi_middleware, + _wrap_mcp_with_auth, + _wrap_with_path_normalize, + _wrap_with_size_limit, + create_mcp_server, + ) handler, _executor, _registry = create_adcp_server_from_platform( platform, @@ -191,8 +253,31 @@ def build_asgi_app( name=server_name, advertise_all=advertise_all, allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + context_factory=context_factory, + middleware=middleware, + streaming_responses=streaming_responses, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, + validation=validation, ) - return mcp.streamable_http_app() + # Mirror the wrapping chain from _run_mcp_http (adcp.server.serve). + # auth must be innermost so its JSON-RPC body-peek runs before the + # path normalizer reshapes scope["path"]. + app = mcp.streamable_http_app() + app = _wrap_mcp_with_auth(app, auth) + app = _wrap_with_path_normalize(app) + if discovery_base_url is not None: + from adcp.server.serve import _wrap_with_discovery + + app = _wrap_with_discovery( + app, + name=server_name, + transports=["mcp"], + base_url=discovery_base_url, + ) + app = _wrap_with_size_limit(app, max_request_size) + app = _apply_asgi_middleware(app, asgi_middleware) + return app @asynccontextmanager @@ -205,6 +290,15 @@ async def build_test_client( auto_emit_completion_webhooks: bool = False, follow_redirects: bool = True, headers: Mapping[str, str] | None = None, + auth: BearerTokenAuth | None = None, + asgi_middleware: Sequence[ASGIMiddlewareEntry] | None = None, + context_factory: ContextFactory | None = None, + middleware: Sequence[SkillMiddleware] | None = None, + streaming_responses: bool = False, + enable_dns_rebinding_protection: bool | None = None, + max_request_size: int | None = None, + validation: ValidationHookConfig | None = _DEFAULT_VALIDATION, + discovery_base_url: str | None = None, **factory_kwargs: Any, ) -> AsyncIterator[httpx.AsyncClient]: """Async context manager yielding an ``httpx.AsyncClient`` wired against @@ -222,6 +316,11 @@ async def build_test_client( itself is an ``AbstractAsyncContextManager[httpx.AsyncClient]``; the yielded object is a plain ``httpx.AsyncClient``. + ``allowed_hosts`` is derived automatically from ``base_url`` — the + hostname is extracted and added to FastMCP's transport-security allowlist. + Pass ``allowed_hosts`` to :func:`build_asgi_app` directly when you need + custom control. + Requires ``asgi-lifespan`` (included in ``adcp[dev]``). Raises :class:`ImportError` with an actionable message if it is not installed. @@ -239,6 +338,21 @@ async def build_test_client( :param headers: Default headers attached to every request. Useful for auth tests: ``headers={"x-adcp-auth": "tok_..."}``. ``None`` → no default headers. + :param auth: Forwarded to :func:`build_asgi_app`. ``None`` → no + bearer-token validation. + :param asgi_middleware: Forwarded to :func:`build_asgi_app`. + :param context_factory: Forwarded to :func:`build_asgi_app`. + :param middleware: Forwarded to :func:`build_asgi_app`. + :param streaming_responses: Forwarded to :func:`build_asgi_app`. + :param enable_dns_rebinding_protection: Forwarded to + :func:`build_asgi_app`. + :param max_request_size: Forwarded to :func:`build_asgi_app`. + :param validation: Forwarded to :func:`build_asgi_app`. Defaults to + :data:`~adcp.server.serve.DEFAULT_VALIDATION` (strict). + :param discovery_base_url: Forwarded to :func:`build_asgi_app`. + When ``None`` (default), the discovery endpoint is not mounted. + Pass ``base_url`` here if your tests exercise + ``/.well-known/adcp-agents.json``. :param factory_kwargs: Forwarded to :func:`create_adcp_server_from_platform` via :func:`build_asgi_app` (executor, registry, webhook_sender, etc.). @@ -264,6 +378,15 @@ async def build_test_client( advertise_all=advertise_all, auto_emit_completion_webhooks=auto_emit_completion_webhooks, allowed_hosts=[hostname], + auth=auth, + asgi_middleware=asgi_middleware, + context_factory=context_factory, + middleware=middleware, + streaming_responses=streaming_responses, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, + max_request_size=max_request_size, + validation=validation, + discovery_base_url=discovery_base_url, **factory_kwargs, ) async with LifespanManager(app): diff --git a/tests/test_testing_decisioning.py b/tests/test_testing_decisioning.py index 1b76cab64..d431ece5d 100644 --- a/tests/test_testing_decisioning.py +++ b/tests/test_testing_decisioning.py @@ -193,6 +193,236 @@ def test_build_asgi_app_forwards_allowed_hosts() -> None: assert callable(app) +# ---- build_asgi_app: new serve-layer kwargs ---- + + +def test_build_asgi_app_forwards_context_factory() -> None: + """``context_factory=`` reaches ``create_mcp_server`` — construction + succeeds with a custom factory.""" + from adcp.server.base import ToolContext + from adcp.server.serve import RequestMetadata + + platform = _SalesPlatformWithMethods() + + def my_factory(meta: RequestMetadata) -> ToolContext: + return ToolContext(caller_identity="test-caller") + + app = build_asgi_app(platform, context_factory=my_factory) + assert callable(app) + + +def test_build_asgi_app_forwards_asgi_middleware() -> None: + """``asgi_middleware=`` is applied outermost — construction succeeds.""" + from typing import Any + + platform = _SalesPlatformWithMethods() + + class _PassthroughMiddleware: + def __init__(self, app: Any) -> None: + self.app = app + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + await self.app(scope, receive, send) + + app = build_asgi_app(platform, asgi_middleware=[(_PassthroughMiddleware, {})]) + assert callable(app) + + +def test_build_asgi_app_forwards_streaming_responses() -> None: + """``streaming_responses=True`` reaches ``create_mcp_server`` without error.""" + platform = _SalesPlatformWithMethods() + app = build_asgi_app(platform, streaming_responses=True) + assert callable(app) + + +def test_build_asgi_app_forwards_max_request_size() -> None: + """``max_request_size=`` is accepted — construction succeeds.""" + platform = _SalesPlatformWithMethods() + app = build_asgi_app(platform, max_request_size=1024 * 1024) + assert callable(app) + + +def test_build_asgi_app_forwards_auth_smoke() -> None: + """``auth=BearerTokenAuth(...)`` is applied — construction succeeds.""" + from adcp.server.auth import BearerTokenAuth + + platform = _SalesPlatformWithMethods() + auth = BearerTokenAuth(validate_token=lambda token: token == "tok_test") + app = build_asgi_app(platform, auth=auth) + assert callable(app) + + +def test_build_asgi_app_auth_rejects_unauthenticated() -> None: + """An app built with ``auth=`` returns 401 for non-discovery requests + missing a bearer token. + + ``initialize`` is exempt per spec; ``tools/call`` with a non-discovery + tool is not — the auth middleware should reject it before the request + reaches the MCP session manager (no lifespan needed for this path). + """ + import asyncio + + from adcp.server.auth import BearerTokenAuth + + platform = _SalesPlatformWithMethods() + auth = BearerTokenAuth(validate_token=lambda token: token == "tok_test") + app = build_asgi_app(platform, auth=auth, allowed_hosts=["test"]) + + async def _run() -> int: + import httpx + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + # ``tools/call`` with a non-discovery tool is not in the auth + # bypass list — expect 401 before the request reaches the MCP + # session manager. + resp = await client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_products", "arguments": {}}, + }, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + ) + return resp.status_code + + status = asyncio.run(_run()) + assert status == 401 + + +def test_build_asgi_app_discovery_endpoint_mounted_when_base_url_provided() -> None: + """When ``discovery_base_url=`` is given, the well-known discovery endpoint + is mounted and returns 200.""" + import asyncio + + platform = _SalesPlatformWithMethods() + app = build_asgi_app( + platform, + allowed_hosts=["test"], + discovery_base_url="http://test", + ) + + async def _run() -> int: + import httpx + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + resp = await client.get("/.well-known/adcp-agents.json") + return resp.status_code + + assert asyncio.run(_run()) == 200 + + +def test_build_asgi_app_discovery_endpoint_absent_by_default() -> None: + """Without ``discovery_base_url=``, the well-known path returns 404.""" + import asyncio + + platform = _SalesPlatformWithMethods() + app = build_asgi_app(platform, allowed_hosts=["test"]) + + async def _run() -> int: + import httpx + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + resp = await client.get("/.well-known/adcp-agents.json") + return resp.status_code + + assert asyncio.run(_run()) == 404 + + +async def test_build_asgi_app_path_normalize_applied() -> None: + """Trailing-slash requests route correctly without 307 redirect. + + The path normalizer strips ``/mcp/`` → ``/mcp`` before dispatch so + ``follow_redirects=False`` clients see 200, not 307. + + Uses ``asyncio.to_thread`` to build the app (``validate_capabilities_response_shape`` + inside ``create_adcp_server_from_platform`` calls ``asyncio.run()`` which + cannot be called from a running loop). + """ + import asyncio as _asyncio + + from asgi_lifespan import LifespanManager + + platform = _SalesPlatformWithMethods() + app = await _asyncio.to_thread(build_asgi_app, platform, allowed_hosts=["test"]) + + async with LifespanManager(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + follow_redirects=False, + ) as client: + resp = await client.post( + "/mcp/", + json={ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}, + }, + }, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + ) + # Path normalizer strips trailing slash — 200, not 307. + assert resp.status_code == 200 + + +# ---- build_test_client: new serve-layer kwargs ---- + + +async def test_build_test_client_forwards_auth() -> None: + """``auth=`` is forwarded through ``build_test_client`` — unauthenticated + non-discovery requests get 401. + + ``tools/call`` with a non-discovery tool is not in the auth bypass list. + """ + from adcp.server.auth import BearerTokenAuth + + platform = _SalesPlatformWithMethods() + auth = BearerTokenAuth(validate_token=lambda token: token == "tok_test") + async with build_test_client(platform, auth=auth) as client: + resp = await client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_products", "arguments": {}}, + }, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + ) + assert resp.status_code == 401 + + +async def test_build_test_client_forwards_validation_none() -> None: + """``validation=None`` disables schema validation — construction succeeds.""" + platform = _SalesPlatformWithMethods() + async with build_test_client(platform, validation=None) as client: + assert client is not None + + # ---- build_test_client ---- From 3b335d3fb2dd5fa2f01e5f7abf0525df9346c574 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:46:34 +0000 Subject: [PATCH 2/2] fix(testing): correct SERVER_DEFAULT_VALIDATION import typo; add allowed_origins to build_test_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SERVERDEFAULT_VALIDATION → SERVER_DEFAULT_VALIDATION (ImportError blocker found in pre-PR review) - Add allowed_origins param to build_test_client and wire it through to build_asgi_app, matching issue spec https://claude.ai/code/session_012Ujdm5DZ7CyECWqSq9KrHN --- src/adcp/testing/decisioning.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/adcp/testing/decisioning.py b/src/adcp/testing/decisioning.py index d306a6109..aa066f302 100644 --- a/src/adcp/testing/decisioning.py +++ b/src/adcp/testing/decisioning.py @@ -32,7 +32,7 @@ from adcp.decisioning.context import RequestContext from adcp.decisioning.types import Account -from adcp.validation.client_hooks import SERVER_DEFAULT_VALIDATION as _DEFAULT_VALIDATION +from adcp.validation.client_hooks import SERVER_DEFAULT_VALIDATION as DEFAULT_VALIDATION from adcp.validation.client_hooks import ValidationHookConfig if TYPE_CHECKING: @@ -148,7 +148,7 @@ def build_asgi_app( streaming_responses: bool = False, enable_dns_rebinding_protection: bool | None = None, max_request_size: int | None = None, - validation: ValidationHookConfig | None = _DEFAULT_VALIDATION, + validation: ValidationHookConfig | None = DEFAULT_VALIDATION, discovery_base_url: str | None = None, **factory_kwargs: Any, ) -> Any: @@ -291,13 +291,14 @@ async def build_test_client( follow_redirects: bool = True, headers: Mapping[str, str] | None = None, auth: BearerTokenAuth | None = None, + allowed_origins: Sequence[str] | None = None, asgi_middleware: Sequence[ASGIMiddlewareEntry] | None = None, context_factory: ContextFactory | None = None, middleware: Sequence[SkillMiddleware] | None = None, streaming_responses: bool = False, enable_dns_rebinding_protection: bool | None = None, max_request_size: int | None = None, - validation: ValidationHookConfig | None = _DEFAULT_VALIDATION, + validation: ValidationHookConfig | None = DEFAULT_VALIDATION, discovery_base_url: str | None = None, **factory_kwargs: Any, ) -> AsyncIterator[httpx.AsyncClient]: @@ -340,6 +341,8 @@ async def build_test_client( no default headers. :param auth: Forwarded to :func:`build_asgi_app`. ``None`` → no bearer-token validation. + :param allowed_origins: CORS origin allowlist forwarded to + :func:`build_asgi_app`. ``None`` → FastMCP default (no CORS). :param asgi_middleware: Forwarded to :func:`build_asgi_app`. :param context_factory: Forwarded to :func:`build_asgi_app`. :param middleware: Forwarded to :func:`build_asgi_app`. @@ -378,6 +381,7 @@ async def build_test_client( advertise_all=advertise_all, auto_emit_completion_webhooks=auto_emit_completion_webhooks, allowed_hosts=[hostname], + allowed_origins=allowed_origins, auth=auth, asgi_middleware=asgi_middleware, context_factory=context_factory,