Skip to content

Commit 2affd00

Browse files
bokelleyclaude
andauthored
fix(ci): unblock storyboard runner — poll, MCP outputSchema, ref seller boot (#443)
* fix(ci): correct broken curl readiness check in storyboard poll loop 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) <noreply@anthropic.com> * fix(server): outputSchema must declare type:object per MCP spec 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) <noreply@anthropic.com> * fix(v3-ref-seller): disable F12 auto-emit gate to unblock boot 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) <noreply@anthropic.com> * fix(v3-ref-seller): dispose engine after schema bootstrap ``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) <noreply@anthropic.com> * fix(seller-agent): filter invalid channels from seeded products 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) <noreply@anthropic.com> * fix(v3-ref-seller): normalize host before tenant lookup ``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) <noreply@anthropic.com> * feat(server): expose DNS-rebinding controls on serve() 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) <noreply@anthropic.com> * fix(v3-ref-seller): disable MCP DNS-rebinding check ``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) <noreply@anthropic.com> * ci: switch storyboard runner from @adcp/client to @adcp/sdk ``@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 <specialism>`` subcommand. Leaving it as ``@latest`` would work today but a downgrade-to-6.6 SDK release would silently break this step. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0af6098 commit 2affd00

8 files changed

Lines changed: 272 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,10 @@ jobs:
396396
for i in $(seq 1 60); do
397397
# Any HTTP response (including 405 on GET to a POST-only endpoint)
398398
# means the server is up and accepting connections.
399+
# ``||`` runs on the assignment so curl's "000" stdout and the
400+
# fallback don't concatenate when the connection is refused.
399401
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
400-
http://127.0.0.1:3001/mcp 2>/dev/null || echo "000")
402+
http://127.0.0.1:3001/mcp 2>/dev/null) || HTTP_CODE="000"
401403
if [ "$HTTP_CODE" != "000" ]; then
402404
echo "Seller agent ready (HTTP ${HTTP_CODE}, pid ${AGENT_PID})"
403405
break
@@ -554,8 +556,10 @@ jobs:
554556
# renames or removes a specific network. The endpoint is
555557
# always present on the harness-side mock.
556558
for i in $(seq 1 120); do
559+
# ``||`` runs on the assignment so curl's "000" stdout and the
560+
# fallback don't concatenate when the connection is refused.
557561
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
558-
http://127.0.0.1:4503/_debug/traffic 2>/dev/null || echo "000")
562+
http://127.0.0.1:4503/_debug/traffic 2>/dev/null) || HTTP_CODE="000"
559563
if [ "$HTTP_CODE" = "200" ]; then
560564
echo "Upstream mock ready (HTTP 200, pid $MOCK_PID, $i polls)"
561565
break
@@ -589,8 +593,10 @@ jobs:
589593
SELLER_PID=$!
590594
echo "SELLER_PID=$SELLER_PID" >> "$GITHUB_ENV"
591595
for i in $(seq 1 60); do
596+
# ``||`` runs on the assignment so curl's "000" stdout and the
597+
# fallback don't concatenate when the connection is refused.
592598
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
593-
http://127.0.0.1:3001/mcp 2>/dev/null || echo "000")
599+
http://127.0.0.1:3001/mcp 2>/dev/null) || HTTP_CODE="000"
594600
if [ "$HTTP_CODE" != "000" ]; then
595601
echo "Seller ready (HTTP ${HTTP_CODE}, pid ${SELLER_PID})"
596602
break

examples/seller_agent.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,38 @@
4343
PORT = int(os.environ.get("ADCP_PORT") or os.environ.get("PORT") or 3001)
4444
AGENT_URL = f"http://localhost:{PORT}/mcp"
4545

46+
# Spec-valid values for ``Product.channels`` (the canonical
47+
# ``MediaChannelSchema`` enum from schemas/cache/enums/channels.json).
48+
# Storyboard fixtures occasionally seed legacy channel names ("video")
49+
# that aren't in the enum; ``seed_product`` filters incoming fixture
50+
# channels against this set so the demo seller doesn't echo invalid
51+
# values back through ``get_products`` and trip strict response
52+
# validation.
53+
_VALID_CHANNELS: frozenset[str] = frozenset(
54+
{
55+
"display",
56+
"olv",
57+
"social",
58+
"search",
59+
"ctv",
60+
"linear_tv",
61+
"radio",
62+
"streaming_audio",
63+
"podcast",
64+
"dooh",
65+
"ooh",
66+
"print",
67+
"cinema",
68+
"email",
69+
"gaming",
70+
"retail_media",
71+
"influencer",
72+
"affiliate",
73+
"product_placement",
74+
"sponsored_intelligence",
75+
}
76+
)
77+
4678
accounts: dict[str, dict[str, Any]] = {}
4779
media_buys: dict[str, dict[str, Any]] = {}
4880
creatives: dict[str, dict[str, Any]] = {}
@@ -769,6 +801,17 @@ async def seed_product(
769801
data = dict(fixture or {})
770802
pid = product_id or data.get("product_id") or f"seeded-{uuid.uuid4().hex[:8]}"
771803
data["product_id"] = pid
804+
# Filter ``channels`` to spec-valid values from the canonical
805+
# ``MediaChannelSchema`` enum. Upstream storyboard fixtures
806+
# occasionally ship legacy names like ``"video"`` that aren't
807+
# in the enum; surfacing them through get_products would fail
808+
# strict response validation.
809+
if "channels" in data:
810+
valid = [c for c in data.get("channels") or [] if c in _VALID_CHANNELS]
811+
if valid:
812+
data["channels"] = valid
813+
else:
814+
data.pop("channels", None)
772815
# Ensure schema-required fields are present so downstream validation
773816
# passes even when the runner sends a minimal fixture with only
774817
# product_id. Defaults are spec-valid (non-empty arrays where

examples/v3_reference_seller/src/app.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ async def _bootstrap_schema(engine) -> None:
9090
"""
9191
async with engine.begin() as conn:
9292
await conn.run_sync(Base.metadata.create_all)
93+
# asyncpg binds connection-internal Future objects to the loop
94+
# they were opened on. Bootstrapping via ``asyncio.run`` runs on
95+
# a transient loop that closes when ``asyncio.run`` returns; if
96+
# those connections stay in the pool, uvicorn's own loop trips
97+
# ``RuntimeError: got Future attached to a different loop`` on
98+
# the first request. Dispose so uvicorn opens a fresh pool on
99+
# its own loop.
100+
await engine.dispose()
93101

94102

95103
def main() -> None:
@@ -170,6 +178,27 @@ def main() -> None:
170178
validation=ValidationHookConfig(requests="strict", responses="strict"),
171179
mock_ad_server=mock_ad_server,
172180
enable_debug_endpoints=True,
181+
# The reference platform doesn't emit completion webhooks —
182+
# turn off the F12 auto-emit gate so server boot doesn't trip
183+
# ``validate_webhook_sender_for_platform``. Adopters whose
184+
# platforms need webhook delivery wire a
185+
# :class:`WebhookSender` (or
186+
# :class:`InMemoryWebhookDeliverySupervisor`) and remove this
187+
# kwarg — see the webhook_supervisor module for the wiring
188+
# pattern.
189+
auto_emit_completion_webhooks=False,
190+
# FastMCP's TransportSecurityMiddleware enforces DNS-rebinding
191+
# protection: its default ``allowed_hosts`` accepts only
192+
# loopback (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``), so
193+
# subdomain hosts like ``acme.localhost:3001`` are rejected
194+
# with ``421 Misdirected Request``. ``SubdomainTenantMiddleware``
195+
# above already validates the Host header against the seeded
196+
# tenant table — that's the load-bearing host check for this
197+
# seller. Disabling the MCP-layer check avoids duplicating
198+
# the same validation against a static, hard-to-extend list.
199+
# Adopters that don't run a tenant-aware ASGI middleware leave
200+
# this kwarg unset to keep the FastMCP defaults active.
201+
enable_dns_rebinding_protection=False,
173202
)
174203

175204

examples/v3_reference_seller/src/tenant_router.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ def __init__(
4949
self._cache_lock = asyncio.Lock()
5050

5151
async def resolve(self, host: str) -> Tenant | None:
52+
# The middleware passes the raw Host header. RFC 7230 makes it
53+
# case-insensitive and lets the client include ``:port``; the
54+
# Protocol docstring is explicit that implementations strip the
55+
# port suffix as needed. Normalize before the cache lookup AND
56+
# the DB query so ``acme.localhost:3001`` resolves the same
57+
# row as the seeded ``acme.localhost``.
58+
host = host.strip().lower().split(":", 1)[0]
5259
# Bounded FIFO cache — when full, the oldest insertion is
5360
# evicted regardless of access frequency. Fine for stable
5461
# tenant sets under ``cache_size``; adopters with churn or

examples/v3_reference_seller/tests/test_smoke.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,41 @@ def scalar_one_or_none(self):
102102
assert result is None
103103

104104

105+
@pytest.mark.asyncio
106+
async def test_tenant_router_strips_port_and_lowercases_host() -> None:
107+
"""The middleware passes the raw Host header. RFC 7230 makes it
108+
case-insensitive and lets the client include ``:port``; the
109+
Protocol docstring is explicit that implementations strip the
110+
port suffix as needed. ``ACME.localhost:3001`` and
111+
``acme.localhost`` MUST hit the same DB row."""
112+
from src.tenant_router import SqlSubdomainTenantRouter
113+
114+
captured: list[str] = []
115+
116+
class _CapturingSession:
117+
async def __aenter__(self):
118+
return self
119+
120+
async def __aexit__(self, *args):
121+
return None
122+
123+
async def execute(self, stmt):
124+
captured.append(str(stmt.compile(compile_kwargs={"literal_binds": True})))
125+
126+
class _Result:
127+
def scalar_one_or_none(self):
128+
return None
129+
130+
return _Result()
131+
132+
router = SqlSubdomainTenantRouter(sessionmaker=lambda: _CapturingSession()) # type: ignore[arg-type]
133+
await router.resolve("ACME.localhost:3001")
134+
assert captured, "expected a SQL execute"
135+
assert (
136+
"'acme.localhost'" in captured[-1]
137+
), f"router did not normalize host before query: {captured[-1]!r}"
138+
139+
105140
@pytest.mark.asyncio
106141
async def test_buyer_registry_returns_none_without_tenant() -> None:
107142
"""Without a tenant context (ContextVar unset), the registry

src/adcp/server/mcp_tools.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,15 @@ def _generate_pydantic_output_schemas() -> dict[str, dict[str, Any]]:
15531553
tool_name,
15541554
)
15551555
continue
1556+
# MCP requires ``outputSchema`` root-level ``type: "object"`` —
1557+
# the schema describes ``CallToolResult.structuredContent`` which
1558+
# is always a JSON object. Discriminated-union responses
1559+
# (CreateMediaBuyResponse, AcquireRightsResponse, etc.) come
1560+
# back from Pydantic as ``{"anyOf": [...]}`` with no ``type``,
1561+
# which Zod-validated MCP clients reject. Every variant in the
1562+
# union is itself an object, so adding ``"type": "object"``
1563+
# at the root is semantically equivalent and MCP-spec-conformant.
1564+
schema.setdefault("type", "object")
15561565
schemas[tool_name] = schema
15571566

15581567
return schemas

src/adcp/server/serve.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,9 @@ def serve(
444444
base_url: str | None = None,
445445
specialisms: list[str] | None = None,
446446
description: str | None = None,
447+
allowed_hosts: Sequence[str] | None = None,
448+
allowed_origins: Sequence[str] | None = None,
449+
enable_dns_rebinding_protection: bool | None = None,
447450
) -> None:
448451
"""Start an MCP or A2A server from an ADCP handler or server builder.
449452
@@ -691,6 +694,9 @@ async def force_account_status(self, account_id, status):
691694
base_url=base_url,
692695
specialisms=specialisms,
693696
description=description,
697+
allowed_hosts=allowed_hosts,
698+
allowed_origins=allowed_origins,
699+
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
694700
)
695701
elif transport == "both":
696702
_serve_mcp_and_a2a(
@@ -713,6 +719,9 @@ async def force_account_status(self, account_id, status):
713719
base_url=base_url,
714720
specialisms=specialisms,
715721
description=description,
722+
allowed_hosts=allowed_hosts,
723+
allowed_origins=allowed_origins,
724+
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
716725
)
717726
else:
718727
valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio")))
@@ -996,6 +1005,9 @@ def _serve_mcp(
9961005
base_url: str | None = None,
9971006
specialisms: list[str] | None = None,
9981007
description: str | None = None,
1008+
allowed_hosts: Sequence[str] | None = None,
1009+
allowed_origins: Sequence[str] | None = None,
1010+
enable_dns_rebinding_protection: bool | None = None,
9991011
) -> None:
10001012
"""Start an MCP server."""
10011013
mcp = create_mcp_server(
@@ -1010,6 +1022,9 @@ def _serve_mcp(
10101022
advertise_all=advertise_all,
10111023
streaming_responses=streaming_responses,
10121024
validation=validation,
1025+
allowed_hosts=allowed_hosts,
1026+
allowed_origins=allowed_origins,
1027+
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
10131028
)
10141029

10151030
if test_controller is not None:
@@ -1199,6 +1214,9 @@ def _build_mcp_and_a2a_app(
11991214
base_url: str | None = None,
12001215
specialisms: list[str] | None = None,
12011216
description: str | None = None,
1217+
allowed_hosts: Sequence[str] | None = None,
1218+
allowed_origins: Sequence[str] | None = None,
1219+
enable_dns_rebinding_protection: bool | None = None,
12021220
) -> Any:
12031221
"""Build the unified MCP+A2A ASGI app without starting a server.
12041222
@@ -1233,6 +1251,9 @@ def _build_mcp_and_a2a_app(
12331251
advertise_all=advertise_all,
12341252
streaming_responses=streaming_responses,
12351253
validation=validation,
1254+
allowed_hosts=allowed_hosts,
1255+
allowed_origins=allowed_origins,
1256+
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
12361257
)
12371258
if test_controller is not None:
12381259
from adcp.server.test_controller import register_test_controller
@@ -1336,6 +1357,9 @@ def _serve_mcp_and_a2a(
13361357
base_url: str | None = None,
13371358
specialisms: list[str] | None = None,
13381359
description: str | None = None,
1360+
allowed_hosts: Sequence[str] | None = None,
1361+
allowed_origins: Sequence[str] | None = None,
1362+
enable_dns_rebinding_protection: bool | None = None,
13391363
) -> None:
13401364
"""Serve MCP and A2A on a single port via path dispatch.
13411365
@@ -1377,6 +1401,9 @@ def _serve_mcp_and_a2a(
13771401
base_url=base_url,
13781402
specialisms=specialisms,
13791403
description=description,
1404+
allowed_hosts=allowed_hosts,
1405+
allowed_origins=allowed_origins,
1406+
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
13801407
)
13811408
app = _apply_asgi_middleware(app, asgi_middleware)
13821409

@@ -1411,6 +1438,9 @@ def create_mcp_server(
14111438
advertise_all: bool = False,
14121439
streaming_responses: bool = False,
14131440
validation: ValidationHookConfig | None = DEFAULT_VALIDATION,
1441+
allowed_hosts: Sequence[str] | None = None,
1442+
allowed_origins: Sequence[str] | None = None,
1443+
enable_dns_rebinding_protection: bool | None = None,
14141444
) -> Any:
14151445
"""Create a FastMCP server from an ADCP handler without starting it.
14161446
@@ -1526,6 +1556,32 @@ def create_mcp_server(
15261556
# AdCP tools, which return one complete envelope per request.
15271557
mcp.settings.stateless_http = True
15281558
mcp.settings.json_response = True
1559+
# FastMCP's TransportSecurityMiddleware enforces DNS-rebinding
1560+
# protection: the default ``allowed_hosts`` accepts only loopback
1561+
# patterns (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``). Adopters
1562+
# serving multi-tenant subdomain hosts (``acme.example.com``,
1563+
# ``acme.localhost``) extend the list or the transport returns
1564+
# ``421 Misdirected Request`` and MCP discovery fails. Adopters
1565+
# whose outer ASGI middleware already validates hosts against a
1566+
# tenant table (e.g. :class:`SubdomainTenantMiddleware`) can set
1567+
# ``enable_dns_rebinding_protection=False`` so the MCP-layer check
1568+
# doesn't duplicate the upstream validation.
1569+
if (
1570+
enable_dns_rebinding_protection is not None
1571+
or allowed_hosts is not None
1572+
or allowed_origins is not None
1573+
):
1574+
from mcp.server.transport_security import TransportSecuritySettings
1575+
1576+
if mcp.settings.transport_security is None:
1577+
mcp.settings.transport_security = TransportSecuritySettings()
1578+
ts = mcp.settings.transport_security
1579+
if enable_dns_rebinding_protection is not None:
1580+
ts.enable_dns_rebinding_protection = enable_dns_rebinding_protection
1581+
if allowed_hosts:
1582+
ts.allowed_hosts = [*ts.allowed_hosts, *allowed_hosts]
1583+
if allowed_origins:
1584+
ts.allowed_origins = [*ts.allowed_origins, *allowed_origins]
15291585
_register_handler_tools(
15301586
mcp,
15311587
handler,

0 commit comments

Comments
 (0)