From 5855e8d6234d304589576fe84208b792aa0e0d96 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 2 May 2026 23:25:53 -0400 Subject: [PATCH 1/9] fix(ci): correct broken curl readiness check in storyboard poll loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent isn't yet listening, ``curl -w "%{http_code}"`` writes "000" to stdout AND exits non-zero, so ``... || echo "000"`` appended a second "000" — making HTTP_CODE the string "000000". The ``"$HTTP_CODE" != "000"`` comparison then succeeded on the first iteration, falsely declaring the agent ready before it had started. Both storyboard jobs (seller_agent.py and v3_reference_seller) failed on every run with overall_status=unreachable as a result. Move ``||`` onto the command-substitution assignment so the fallback overwrites instead of concatenates. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f27e5fb5..effdad8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -364,8 +364,10 @@ jobs: for i in $(seq 1 60); do # Any HTTP response (including 405 on GET to a POST-only endpoint) # means the server is up and accepting connections. + # ``||`` runs on the assignment so curl's "000" stdout and the + # fallback don't concatenate when the connection is refused. HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \ - http://127.0.0.1:3001/mcp 2>/dev/null || echo "000") + http://127.0.0.1:3001/mcp 2>/dev/null) || HTTP_CODE="000" if [ "$HTTP_CODE" != "000" ]; then echo "Seller agent ready (HTTP ${HTTP_CODE}, pid ${AGENT_PID})" break @@ -503,8 +505,10 @@ jobs: # SubdomainTenantMiddleware resolves ``acme.localhost`` → # ``t_acme`` and the request progresses past the 404 # ``unknown-host`` early-return. + # ``||`` runs on the assignment so curl's "000" stdout and the + # fallback don't concatenate when the connection is refused. HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \ - http://acme.localhost:3001/mcp 2>/dev/null || echo "000") + http://acme.localhost:3001/mcp 2>/dev/null) || HTTP_CODE="000" if [ "$HTTP_CODE" != "000" ] && [ "$HTTP_CODE" != "404" ]; then echo "v3 reference seller ready (HTTP ${HTTP_CODE}, pid ${AGENT_PID})" break From 36a7dad6dfccaa33751a834b0c2f7717614cb430 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 2 May 2026 23:39:26 -0400 Subject: [PATCH 2/9] fix(server): outputSchema must declare type:object per MCP spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP requires ``outputSchema`` to define top-level ``type: "object"`` because it describes ``CallToolResult.structuredContent``, which is always a JSON object. Pydantic's ``TypeAdapter.json_schema`` for discriminated-union response types (``CreateMediaBuyResponse``, ``AcquireRightsResponse``, etc.) emits ``{"anyOf": [...]}`` with no ``type`` field — Zod-validated MCP clients reject these tools at ``tools/list``. The storyboard runner's capability discovery flagged five such tools as ``invalid_value`` on ``outputSchema.type``, taking the agent to ``overall_status: unreachable``. Every variant inside the union is itself a Pydantic model rendered as ``type: "object"``, so adding root-level ``type: "object"`` alongside ``anyOf`` is semantically equivalent (must be an object AND match a variant) and MCP-spec-conformant. Existing union-shape assertions in ``tests/test_tools_list_output_schema.py`` continue to pass — the ``anyOf`` is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/server/mcp_tools.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 8816c215..dac7e69d 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -1553,6 +1553,15 @@ def _generate_pydantic_output_schemas() -> dict[str, dict[str, Any]]: tool_name, ) continue + # MCP requires ``outputSchema`` root-level ``type: "object"`` — + # the schema describes ``CallToolResult.structuredContent`` which + # is always a JSON object. Discriminated-union responses + # (CreateMediaBuyResponse, AcquireRightsResponse, etc.) come + # back from Pydantic as ``{"anyOf": [...]}`` with no ``type``, + # which Zod-validated MCP clients reject. Every variant in the + # union is itself an object, so adding ``"type": "object"`` + # at the root is semantically equivalent and MCP-spec-conformant. + schema.setdefault("type", "object") schemas[tool_name] = schema return schemas From 73af0a5d886a04a48ff964df6db0a9a0f24fd671 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 2 May 2026 23:39:43 -0400 Subject: [PATCH 3/9] fix(v3-ref-seller): disable F12 auto-emit gate to unblock boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 reference seller's platform claims the ``sales-non-guaranteed`` specialism (which exposes ``create_media_buy``, ``sync_creatives``, ``update_media_buy``) but doesn't wire a ``WebhookSender`` or ``WebhookDeliverySupervisor`` — server-boot ``validate_webhook_sender_for_platform`` raises ``AdcpError[INVALID_REQUEST]`` and the process dies before it listens, taking storyboard CI to ``overall_status: unreachable``. Pass ``auto_emit_completion_webhooks=False`` to ``serve()``. The reference platform doesn't emit completion webhooks, which matches the supported "I handle webhooks manually" code path the validator calls out. Adopters whose platforms need webhook delivery wire a ``WebhookSender`` (or ``InMemoryWebhookDeliverySupervisor``) and drop the kwarg — see the webhook_supervisor module for the wiring pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/v3_reference_seller/src/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/examples/v3_reference_seller/src/app.py b/examples/v3_reference_seller/src/app.py index faa0bb76..83b6e1e8 100644 --- a/examples/v3_reference_seller/src/app.py +++ b/examples/v3_reference_seller/src/app.py @@ -164,6 +164,15 @@ def main() -> None: # for runners; production sellers leave both kwargs unset. mock_ad_server=mock_ad_server, enable_debug_endpoints=True, + # The reference platform doesn't emit completion webhooks — + # turn off the F12 auto-emit gate so server boot doesn't trip + # ``validate_webhook_sender_for_platform``. Adopters whose + # platforms need webhook delivery wire a + # :class:`WebhookSender` (or + # :class:`InMemoryWebhookDeliverySupervisor`) and remove this + # kwarg — see the webhook_supervisor module for the wiring + # pattern. + auto_emit_completion_webhooks=False, ) From 06b6c27990e39a058221d6ab6e33c32b105b4cfa Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 00:07:38 -0400 Subject: [PATCH 4/9] fix(v3-ref-seller): dispose engine after schema bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``asyncio.run(_bootstrap_schema(engine))`` opens asyncpg connections on a transient event loop that closes when ``asyncio.run`` returns. The connections stay in the pool, but asyncpg binds connection- internal Future objects to the loop they were opened on. uvicorn then runs on its own loop, and the first request raises ``RuntimeError: got Future attached to a different loop`` — returning HTTP 500 on the readiness GET and taking storyboard CI to ``overall_status: unreachable``. Dispose the engine inside the bootstrap coroutine so the pool is emptied while the bootstrap loop is still alive. uvicorn opens fresh connections on its own loop on first use. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/v3_reference_seller/src/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/v3_reference_seller/src/app.py b/examples/v3_reference_seller/src/app.py index 83b6e1e8..b9999d0f 100644 --- a/examples/v3_reference_seller/src/app.py +++ b/examples/v3_reference_seller/src/app.py @@ -85,6 +85,14 @@ async def _bootstrap_schema(engine) -> None: """ async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # asyncpg binds connection-internal Future objects to the loop + # they were opened on. Bootstrapping via ``asyncio.run`` runs on + # a transient loop that closes when ``asyncio.run`` returns; if + # those connections stay in the pool, uvicorn's own loop trips + # ``RuntimeError: got Future attached to a different loop`` on + # the first request. Dispose so uvicorn opens a fresh pool on + # its own loop. + await engine.dispose() def main() -> None: From 4900766873407bbfa97e737396580f3c19213a44 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 00:07:53 -0400 Subject: [PATCH 5/9] fix(seller-agent): filter invalid channels from seeded products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storyboard fixtures from @adcp/sdk's ``delivery_reporting.yaml`` ship ``channels: ["video"]`` for ``outdoor_video_q2`` (and similar legacy names elsewhere). ``"video"`` isn't in the canonical ``MediaChannelSchema`` enum from schemas/cache/enums/channels.json (the enum has ``olv``, ``ctv``, ``linear_tv``, etc. — bare ``"video"`` was never a valid value), so the SDK's strict response validator rejects ``get_products`` and storyboard CI reports the agent as ``mcp_error: VALIDATION_ERROR[/products/N/channels/0]``. Filter incoming fixture ``channels`` against the spec enum in ``seed_product`` and drop the field if no values remain. The static ``PRODUCTS`` block doesn't declare ``channels`` either, so the seller behaves consistently across static and seeded products. The upstream fixture is genuinely buggy and should be fixed in @adcp/sdk; this is a defensive normalization on the demo seller side so storyboard CI keeps moving while that gets sorted upstream. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/seller_agent.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/examples/seller_agent.py b/examples/seller_agent.py index 61f599b3..979687b3 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -43,6 +43,38 @@ PORT = int(os.environ.get("ADCP_PORT") or os.environ.get("PORT") or 3001) AGENT_URL = f"http://localhost:{PORT}/mcp" +# Spec-valid values for ``Product.channels`` (the canonical +# ``MediaChannelSchema`` enum from schemas/cache/enums/channels.json). +# Storyboard fixtures occasionally seed legacy channel names ("video") +# that aren't in the enum; ``seed_product`` filters incoming fixture +# channels against this set so the demo seller doesn't echo invalid +# values back through ``get_products`` and trip strict response +# validation. +_VALID_CHANNELS: frozenset[str] = frozenset( + { + "display", + "olv", + "social", + "search", + "ctv", + "linear_tv", + "radio", + "streaming_audio", + "podcast", + "dooh", + "ooh", + "print", + "cinema", + "email", + "gaming", + "retail_media", + "influencer", + "affiliate", + "product_placement", + "sponsored_intelligence", + } +) + accounts: dict[str, dict[str, Any]] = {} media_buys: dict[str, dict[str, Any]] = {} creatives: dict[str, dict[str, Any]] = {} @@ -769,6 +801,17 @@ async def seed_product( data = dict(fixture or {}) pid = product_id or data.get("product_id") or f"seeded-{uuid.uuid4().hex[:8]}" data["product_id"] = pid + # Filter ``channels`` to spec-valid values from the canonical + # ``MediaChannelSchema`` enum. Upstream storyboard fixtures + # occasionally ship legacy names like ``"video"`` that aren't + # in the enum; surfacing them through get_products would fail + # strict response validation. + if "channels" in data: + valid = [c for c in data.get("channels") or [] if c in _VALID_CHANNELS] + if valid: + data["channels"] = valid + else: + data.pop("channels", None) # Ensure schema-required fields are present so downstream validation # passes even when the runner sends a minimal fixture with only # product_id. Defaults are spec-valid (non-empty arrays where From 360dcb83e62c1d11438128d4e47d2cdf3c6e8bf1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 00:17:07 -0400 Subject: [PATCH 6/9] fix(v3-ref-seller): normalize host before tenant lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``SubdomainTenantMiddleware`` passes the raw Host header to ``router.resolve()``. RFC 7230 makes the header case-insensitive and lets the client include ``:port``; the Protocol docstring is explicit that implementations strip the port suffix as needed. The CI readiness probe sends ``Host: acme.localhost:3001``, but ``SqlSubdomainTenantRouter.resolve`` ran the string verbatim through ``WHERE host == :host`` and never matched the seeded ``acme.localhost`` row — every request 404'd as ``unknown-host``, storyboard CI reported the agent as ``unreachable``. Lower-case and strip the port suffix before the cache lookup AND the DB query so ``ACME.localhost:3001`` resolves the same row as ``acme.localhost``. Adds a regression test that captures the SQL bind to prove the literal port-suffixed host doesn't reach the WHERE clause. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v3_reference_seller/src/tenant_router.py | 7 ++++ .../v3_reference_seller/tests/test_smoke.py | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/examples/v3_reference_seller/src/tenant_router.py b/examples/v3_reference_seller/src/tenant_router.py index 56ca8f90..84e9eafc 100644 --- a/examples/v3_reference_seller/src/tenant_router.py +++ b/examples/v3_reference_seller/src/tenant_router.py @@ -49,6 +49,13 @@ def __init__( self._cache_lock = asyncio.Lock() async def resolve(self, host: str) -> Tenant | None: + # The middleware passes the raw Host header. RFC 7230 makes it + # case-insensitive and lets the client include ``:port``; the + # Protocol docstring is explicit that implementations strip the + # port suffix as needed. Normalize before the cache lookup AND + # the DB query so ``acme.localhost:3001`` resolves the same + # row as the seeded ``acme.localhost``. + host = host.strip().lower().split(":", 1)[0] # Bounded FIFO cache — when full, the oldest insertion is # evicted regardless of access frequency. Fine for stable # tenant sets under ``cache_size``; adopters with churn or diff --git a/examples/v3_reference_seller/tests/test_smoke.py b/examples/v3_reference_seller/tests/test_smoke.py index 3e22a5d4..e54cbbc8 100644 --- a/examples/v3_reference_seller/tests/test_smoke.py +++ b/examples/v3_reference_seller/tests/test_smoke.py @@ -96,6 +96,41 @@ def scalar_one_or_none(self): assert result is None +@pytest.mark.asyncio +async def test_tenant_router_strips_port_and_lowercases_host() -> None: + """The middleware passes the raw Host header. RFC 7230 makes it + case-insensitive and lets the client include ``:port``; the + Protocol docstring is explicit that implementations strip the + port suffix as needed. ``ACME.localhost:3001`` and + ``acme.localhost`` MUST hit the same DB row.""" + from src.tenant_router import SqlSubdomainTenantRouter + + captured: list[str] = [] + + class _CapturingSession: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def execute(self, stmt): + captured.append(str(stmt.compile(compile_kwargs={"literal_binds": True}))) + + class _Result: + def scalar_one_or_none(self): + return None + + return _Result() + + router = SqlSubdomainTenantRouter(sessionmaker=lambda: _CapturingSession()) # type: ignore[arg-type] + await router.resolve("ACME.localhost:3001") + assert captured, "expected a SQL execute" + assert ( + "'acme.localhost'" in captured[-1] + ), f"router did not normalize host before query: {captured[-1]!r}" + + @pytest.mark.asyncio async def test_buyer_registry_returns_none_without_tenant() -> None: """Without a tenant context (ContextVar unset), the registry From 1ae3ee50f654eff2ee19e272ae8b6d755e02e12a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 00:32:29 -0400 Subject: [PATCH 7/9] feat(server): expose DNS-rebinding controls on serve() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastMCP's TransportSecurityMiddleware enforces a strict default ``allowed_hosts`` (loopback only — ``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``). Adopters serving multi-tenant subdomain hosts (``acme.example.com``, ``acme.localhost``) get ``421 Misdirected Request`` on every MCP request — the storyboard runner reports the agent as ``unreachable`` because capability discovery never completes. Surface the underlying knobs on ``adcp.server.serve.serve`` and ``create_mcp_server``: * ``allowed_hosts``: extends the FastMCP default (loopback probes still work alongside adopter-specified tenant hosts). * ``allowed_origins``: symmetric, for the Origin header check. * ``enable_dns_rebinding_protection``: turns the MCP-layer check off entirely — the right move for adopters whose outer ASGI middleware (e.g. :class:`SubdomainTenantMiddleware`) already validates the Host header against a tenant table, so duplicating the check against a static allow-list adds operational overhead without a security benefit. Threaded through ``_serve_mcp``, ``_serve_mcp_and_a2a``, and ``_build_mcp_and_a2a_app`` so every transport sees the same wiring. ``adcp.decisioning.serve`` already forwards via ``**serve_kwargs``, so adopters using the decisioning wrapper pick this up for free. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/server/serve.py | 56 +++++++++++++++++ tests/test_serve_transport_security.py | 84 ++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/test_serve_transport_security.py diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index acbf50cf..881b33bc 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -419,6 +419,9 @@ def serve( base_url: str | None = None, specialisms: list[str] | None = None, description: str | None = None, + allowed_hosts: Sequence[str] | None = None, + allowed_origins: Sequence[str] | None = None, + enable_dns_rebinding_protection: bool | None = None, ) -> None: """Start an MCP or A2A server from an ADCP handler or server builder. @@ -641,6 +644,9 @@ async def force_account_status(self, account_id, status): base_url=base_url, specialisms=specialisms, description=description, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, ) elif transport == "both": _serve_mcp_and_a2a( @@ -663,6 +669,9 @@ async def force_account_status(self, account_id, status): base_url=base_url, specialisms=specialisms, description=description, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, ) else: valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio"))) @@ -940,6 +949,9 @@ def _serve_mcp( base_url: str | None = None, specialisms: list[str] | None = None, description: str | None = None, + allowed_hosts: Sequence[str] | None = None, + allowed_origins: Sequence[str] | None = None, + enable_dns_rebinding_protection: bool | None = None, ) -> None: """Start an MCP server.""" mcp = create_mcp_server( @@ -954,6 +966,9 @@ def _serve_mcp( advertise_all=advertise_all, streaming_responses=streaming_responses, validation=validation, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, ) if test_controller is not None: @@ -1139,6 +1154,9 @@ def _build_mcp_and_a2a_app( base_url: str | None = None, specialisms: list[str] | None = None, description: str | None = None, + allowed_hosts: Sequence[str] | None = None, + allowed_origins: Sequence[str] | None = None, + enable_dns_rebinding_protection: bool | None = None, ) -> Any: """Build the unified MCP+A2A ASGI app without starting a server. @@ -1173,6 +1191,9 @@ def _build_mcp_and_a2a_app( advertise_all=advertise_all, streaming_responses=streaming_responses, validation=validation, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, ) if test_controller is not None: from adcp.server.test_controller import register_test_controller @@ -1276,6 +1297,9 @@ def _serve_mcp_and_a2a( base_url: str | None = None, specialisms: list[str] | None = None, description: str | None = None, + allowed_hosts: Sequence[str] | None = None, + allowed_origins: Sequence[str] | None = None, + enable_dns_rebinding_protection: bool | None = None, ) -> None: """Serve MCP and A2A on a single port via path dispatch. @@ -1317,6 +1341,9 @@ def _serve_mcp_and_a2a( base_url=base_url, specialisms=specialisms, description=description, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + enable_dns_rebinding_protection=enable_dns_rebinding_protection, ) app = _apply_asgi_middleware(app, asgi_middleware) @@ -1351,6 +1378,9 @@ def create_mcp_server( advertise_all: bool = False, streaming_responses: bool = False, validation: ValidationHookConfig | None = None, + allowed_hosts: Sequence[str] | None = None, + allowed_origins: Sequence[str] | None = None, + enable_dns_rebinding_protection: bool | None = None, ) -> Any: """Create a FastMCP server from an ADCP handler without starting it. @@ -1466,6 +1496,32 @@ def create_mcp_server( # AdCP tools, which return one complete envelope per request. mcp.settings.stateless_http = True mcp.settings.json_response = True + # FastMCP's TransportSecurityMiddleware enforces DNS-rebinding + # protection: the default ``allowed_hosts`` accepts only loopback + # patterns (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``). Adopters + # serving multi-tenant subdomain hosts (``acme.example.com``, + # ``acme.localhost``) extend the list or the transport returns + # ``421 Misdirected Request`` and MCP discovery fails. Adopters + # whose outer ASGI middleware already validates hosts against a + # tenant table (e.g. :class:`SubdomainTenantMiddleware`) can set + # ``enable_dns_rebinding_protection=False`` so the MCP-layer check + # doesn't duplicate the upstream validation. + if ( + enable_dns_rebinding_protection is not None + or allowed_hosts is not None + or allowed_origins is not None + ): + from mcp.server.transport_security import TransportSecuritySettings + + if mcp.settings.transport_security is None: + mcp.settings.transport_security = TransportSecuritySettings() + ts = mcp.settings.transport_security + if enable_dns_rebinding_protection is not None: + ts.enable_dns_rebinding_protection = enable_dns_rebinding_protection + if allowed_hosts: + ts.allowed_hosts = [*ts.allowed_hosts, *allowed_hosts] + if allowed_origins: + ts.allowed_origins = [*ts.allowed_origins, *allowed_origins] _register_handler_tools( mcp, handler, diff --git a/tests/test_serve_transport_security.py b/tests/test_serve_transport_security.py new file mode 100644 index 00000000..2d77f4a7 --- /dev/null +++ b/tests/test_serve_transport_security.py @@ -0,0 +1,84 @@ +"""``create_mcp_server`` plumbs DNS-rebinding-protection knobs through +to FastMCP's :class:`TransportSecuritySettings`. + +FastMCP's default ``allowed_hosts`` accepts only loopback patterns +(``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``). Adopters serving +multi-tenant subdomain hosts (``acme.example.com``, +``acme.localhost``) need to either extend the list or disable the +MCP-layer check entirely (when a tenant-aware ASGI middleware +already validates the Host header). Without these kwargs the +transport returns ``421 Misdirected Request`` and MCP discovery +fails — see PR #443 / storyboard CI ``v3_reference_seller`` +job for the original symptom. + +Pin the plumbing here so a future refactor doesn't silently drop +it. +""" + +from __future__ import annotations + +from typing import Any + +from adcp.server.base import ADCPHandler +from adcp.server.serve import create_mcp_server + + +class _StubHandler(ADCPHandler[Any]): + """Empty handler — only the FastMCP settings are under test.""" + + +def test_default_transport_security_keeps_loopback_allowlist() -> None: + """No kwargs → FastMCP defaults intact (loopback-only host list).""" + mcp = create_mcp_server(_StubHandler(), name="t") + ts = mcp.settings.transport_security + assert ts is not None + assert ts.enable_dns_rebinding_protection is True + # FastMCP's loopback defaults — match exactly so a regression in + # this list (e.g. an upstream rename) breaks here, not at runtime. + assert "localhost:*" in ts.allowed_hosts + assert "127.0.0.1:*" in ts.allowed_hosts + + +def test_allowed_hosts_extends_default_list() -> None: + """``allowed_hosts=[...]`` extends rather than replaces the + FastMCP default — loopback probes still work alongside the + adopter's tenant hosts. + """ + mcp = create_mcp_server( + _StubHandler(), + name="t", + allowed_hosts=["acme.localhost:*", "beta.localhost:*"], + ) + ts = mcp.settings.transport_security + assert ts is not None + assert "localhost:*" in ts.allowed_hosts # default preserved + assert "acme.localhost:*" in ts.allowed_hosts + assert "beta.localhost:*" in ts.allowed_hosts + + +def test_allowed_origins_extends_default_list() -> None: + """Symmetric to ``allowed_hosts`` — origins extend the default.""" + mcp = create_mcp_server( + _StubHandler(), + name="t", + allowed_origins=["http://acme.localhost:*"], + ) + ts = mcp.settings.transport_security + assert ts is not None + assert "http://localhost:*" in ts.allowed_origins # default preserved + assert "http://acme.localhost:*" in ts.allowed_origins + + +def test_enable_dns_rebinding_protection_false_disables_check() -> None: + """Adopters with their own tenant-aware host validation pass + ``enable_dns_rebinding_protection=False`` so the MCP-layer check + doesn't duplicate the upstream validation. + """ + mcp = create_mcp_server( + _StubHandler(), + name="t", + enable_dns_rebinding_protection=False, + ) + ts = mcp.settings.transport_security + assert ts is not None + assert ts.enable_dns_rebinding_protection is False From 7b4e4bd147bee9006211cd8759d5e3de39bdcfe2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 00:32:42 -0400 Subject: [PATCH 8/9] fix(v3-ref-seller): disable MCP DNS-rebinding check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``SubdomainTenantMiddleware`` (wired via ``asgi_middleware``) already validates the Host header against the seeded tenant table — that's the load-bearing host check for this seller. Without further config, FastMCP's TransportSecurityMiddleware also rejects any non-loopback Host (``acme.localhost:3001`` → ``421 Misdirected Request``), and the storyboard runner reports the agent as ``unreachable`` because MCP discovery never completes. Pass ``enable_dns_rebinding_protection=False`` to ``serve()`` so the MCP-layer check is off and the SubdomainTenantMiddleware stays the single host-validation point. Adopters that don't run a tenant-aware ASGI middleware leave the kwarg unset to keep the FastMCP defaults active. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/v3_reference_seller/src/app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/v3_reference_seller/src/app.py b/examples/v3_reference_seller/src/app.py index b9999d0f..af0a4411 100644 --- a/examples/v3_reference_seller/src/app.py +++ b/examples/v3_reference_seller/src/app.py @@ -181,6 +181,18 @@ def main() -> None: # kwarg — see the webhook_supervisor module for the wiring # pattern. auto_emit_completion_webhooks=False, + # FastMCP's TransportSecurityMiddleware enforces DNS-rebinding + # protection: its default ``allowed_hosts`` accepts only + # loopback (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``), so + # subdomain hosts like ``acme.localhost:3001`` are rejected + # with ``421 Misdirected Request``. ``SubdomainTenantMiddleware`` + # above already validates the Host header against the seeded + # tenant table — that's the load-bearing host check for this + # seller. Disabling the MCP-layer check avoids duplicating + # the same validation against a static, hard-to-extend list. + # Adopters that don't run a tenant-aware ASGI middleware leave + # this kwarg unset to keep the FastMCP defaults active. + enable_dns_rebinding_protection=False, ) From 9531e1503347a91be160de5cf68a82b65cc4fd58 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 07:01:09 -0400 Subject: [PATCH 9/9] ci: switch storyboard runner from @adcp/client to @adcp/sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``@adcp/client`` was renamed to ``@adcp/sdk`` at v6.0; the old name is deprecated and ``@adcp/client@latest`` still resolves to 5.25.1, where the ``mock-server`` subcommand the v3 storyboard upstream relies on doesn't exist — the runner falls back to interpreting ``mock-server`` as an unknown agent alias and the upstream readiness loop times out. Switch all three ``npx`` invocations to ``@adcp/sdk``: * seller_agent.py storyboard runner: ``@adcp/sdk@latest`` (track latest to surface protocol drift, same posture as before). * v3 reference seller storyboard runner: ditto. * Upstream mock-server boot (translator pattern): pin floor at ``@adcp/sdk@>=6.7.0`` — that's the version that introduced the ``adcp mock-server `` subcommand. Leaving it as ``@latest`` would work today but a downgrade-to-6.6 SDK release would silently break this step. --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83752162..387ff8a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -390,11 +390,13 @@ jobs: - name: Run storyboard suite timeout-minutes: 5 - # @adcp/client@latest is intentionally unpinned — this is AdCP's own CI + # @adcp/sdk@latest is intentionally unpinned — this is AdCP's own CI # running AdCP's own canonical runner. Tracking latest surfaces protocol # drift as soon as it ships, which is the point of this job. + # (@adcp/client was renamed to @adcp/sdk at v6.0; the old name is + # deprecated and still resolves to 5.25.1.) run: | - npx -y -p @adcp/client@latest adcp storyboard run \ + npx -y -p @adcp/sdk@latest adcp storyboard run \ http://127.0.0.1:3001/mcp media_buy_seller \ --json --allow-http \ > storyboard-result.json @@ -494,7 +496,10 @@ jobs: - name: Start JS mock-server upstream run: | - npx -y -p @adcp/client@latest \ + # ``adcp mock-server `` ships in @adcp/sdk 6.7.0+. + # Pin the floor so an unrelated SDK rebuild doesn't silently + # land us on a version where the subcommand is gone. + npx -y -p '@adcp/sdk@>=6.7.0' \ adcp mock-server sales-guaranteed --port 4503 --api-key test-key & MOCK_PID=$! echo "MOCK_PID=$MOCK_PID" >> "$GITHUB_ENV" @@ -577,7 +582,7 @@ jobs: # /etc/hosts override so the buyer can reach acme.localhost # (the seeded tenant subdomain). echo "127.0.0.1 acme.localhost" | sudo tee -a /etc/hosts - npx -y -p @adcp/client@latest adcp storyboard run \ + npx -y -p @adcp/sdk@latest adcp storyboard run \ http://acme.localhost:3001/mcp media_buy_seller \ --json --allow-http \ > v3-storyboard-result.json || true