Skip to content

Commit 6be0232

Browse files
bokelleyclaude
andauthored
fix(server): fix streamable-http ASGI error, host binding, and AdCP 3.0.1 scenario gaps (#296)
* fix(server): fix streamable-http ASGI error, host binding, and AdCP 3.0.1 scenario gaps Closes #295 serve() and create_mcp_server() now default stateless_http=True, which sets FastMCP's stateless_http+json_response flags. The streaming-SSE default caused "ASGI callable returned without completing response" and made examples/seller_agent.py report overall_status: "unreachable" to the AdCP storyboard runner. Expose host kwarg (ADCP_HOST env → 0.0.0.0 default) so container deployments (Fly.io, k8s, Cloud Run) bind the external interface instead of loopback. Add five AdCP 3.0.1 seed_* scenarios (seed_product, seed_pricing_option, seed_creative, seed_plan, seed_media_buy) to SCENARIOS, TestControllerStore stubs, _handle_test_controller dispatch, and both inputSchema locations. Add account field to comply_test_controller schema so runner detection works. Emit a WARNING log from capabilities_response() when compliance_ testing is declared without idempotency, surfacing the v2-mode downgrade. https://claude.ai/code/session_01RJtJkQ8rooA7monEAB6ZVd * refactor(server): rename stateless_http to streaming_responses The FastMCP-internal flag name ``stateless_http`` reads like "disable streamable-http" to a docstring-skimmer, but actually controls one mode within the streamable-http transport (synchronous JSON response vs. SSE-streamed response). Rename the SDK parameter to ``streaming_responses`` and flip the polarity so ``True`` means "enable streaming" rather than "disable a feature". Default is now ``False`` (one JSON response per request) which matches what AdCP tools actually emit today — none of them produce progress events. The FastMCP SSE-internal mode also has an upstream bug that drops the ASGI response, blocking the storyboard runner; the new default sidesteps that. Set ``streaming_responses=True`` if/when AdCP adds genuinely streamed tools. Internal call to ``mcp.settings.stateless_http = True`` is kept (that's FastMCP's own setting name; we negate our parameter at the boundary). Acting on user feedback that ``stateless_http=True`` was a confusing name for the flag's user-visible behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4aa7a6d commit 6be0232

4 files changed

Lines changed: 177 additions & 0 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@
839839
"inputSchema": {
840840
"type": "object",
841841
"properties": {
842+
"account": {"type": "object"},
842843
"scenario": {
843844
"type": "string",
844845
"enum": [
@@ -849,6 +850,11 @@
849850
"force_session_status",
850851
"simulate_delivery",
851852
"simulate_budget_spend",
853+
"seed_product",
854+
"seed_pricing_option",
855+
"seed_creative",
856+
"seed_plan",
857+
"seed_media_buy",
852858
],
853859
},
854860
"params": {"type": "object"},

src/adcp/server/responses.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ async def get_products():
2222

2323
from __future__ import annotations
2424

25+
import logging
2526
from datetime import datetime, timezone
2627
from typing import Any
2728

2829
from adcp.server.helpers import valid_actions_for_status
2930

31+
_logger = logging.getLogger("adcp.server")
32+
3033

3134
def _serialize(items: list[Any]) -> list[Any]:
3235
"""Serialize a list of dicts or Pydantic models to plain dicts."""
@@ -98,6 +101,14 @@ def capabilities_response(
98101
idempotency=store.capability(),
99102
)
100103
"""
104+
if compliance_testing is not None and not idempotency:
105+
_logger.warning(
106+
"capabilities_response: adcp.idempotency not declared. "
107+
"The AdCP 3.0.1 storyboard runner may downgrade to v2 mode and "
108+
"cascade failures across idempotency-dependent tracks. "
109+
"Pass idempotency={'supported': False} to declare non-support, "
110+
"or idempotency=store.capability() to declare support."
111+
)
101112
adcp_info: dict[str, Any] = {"major_versions": major_versions or [3]}
102113
if supported_versions is None and adcp_version is not None:
103114
supported_versions = [adcp_version]

src/adcp/server/serve.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ def serve(
305305
*,
306306
name: str = "adcp-agent",
307307
port: int | None = None,
308+
host: str | None = None,
308309
transport: str = "streamable-http",
309310
instructions: str | None = None,
310311
test_controller: TestControllerStore | None = None,
@@ -315,6 +316,7 @@ def serve(
315316
message_parser: MessageParser | None = None,
316317
advertise_all: bool = False,
317318
max_request_size: int | None = None,
319+
streaming_responses: bool = False,
318320
) -> None:
319321
"""Start an MCP or A2A server from an ADCP handler or server builder.
320322
@@ -371,6 +373,23 @@ def serve(
371373
entirely (not recommended — the cap is the only guard
372374
against adversarial payloads exhausting Pydantic validation
373375
CPU/memory). See :mod:`adcp.server._size_limit`.
376+
host: Network interface to bind to (MCP transports only). Defaults
377+
to the ``ADCP_HOST`` environment variable, then ``"0.0.0.0"``
378+
(all interfaces). Use ``"127.0.0.1"`` for local-only
379+
development. Container deployments (Fly.io, k8s, Cloud Run)
380+
require ``"0.0.0.0"`` so the process listens on the
381+
container's external interface.
382+
streaming_responses: When ``False`` (default), the streamable-http
383+
transport returns one ``application/json`` response per
384+
request. AdCP tools today don't emit progress events, and
385+
FastMCP's SSE-internal streaming default has an upstream bug
386+
that drops the ASGI response without completing — making the
387+
storyboard runner report ``overall_status: "unreachable"``.
388+
Set to ``True`` only if your tools genuinely emit progress
389+
notifications and your clients consume the SSE stream
390+
(MCP transports only). Note: the legacy ``transport="sse"``
391+
is a separate (deprecated) MCP transport, unrelated to this
392+
flag.
374393
375394
Security:
376395
This function does NOT configure authentication. In production,
@@ -428,13 +447,15 @@ async def force_account_status(self, account_id, status):
428447
handler,
429448
name=name,
430449
port=port,
450+
host=host,
431451
transport=transport,
432452
instructions=instructions,
433453
test_controller=test_controller,
434454
context_factory=context_factory,
435455
middleware=middleware,
436456
advertise_all=advertise_all,
437457
max_request_size=max_request_size,
458+
streaming_responses=streaming_responses,
438459
)
439460
else:
440461
valid = ", ".join(sorted(("a2a", "streamable-http", "sse", "stdio")))
@@ -523,24 +544,28 @@ def _serve_mcp(
523544
*,
524545
name: str,
525546
port: int | None,
547+
host: str | None = None,
526548
transport: str,
527549
instructions: str | None,
528550
test_controller: TestControllerStore | None,
529551
context_factory: ContextFactory | None = None,
530552
middleware: Sequence[SkillMiddleware] | None = None,
531553
advertise_all: bool = False,
532554
max_request_size: int | None = None,
555+
streaming_responses: bool = False,
533556
) -> None:
534557
"""Start an MCP server."""
535558
mcp = create_mcp_server(
536559
handler,
537560
name=name,
538561
port=port,
562+
host=host,
539563
instructions=instructions,
540564
include_test_controller=test_controller is not None,
541565
context_factory=context_factory,
542566
middleware=middleware,
543567
advertise_all=advertise_all,
568+
streaming_responses=streaming_responses,
544569
)
545570

546571
if test_controller is not None:
@@ -644,11 +669,13 @@ def create_mcp_server(
644669
*,
645670
name: str = "adcp-agent",
646671
port: int | None = None,
672+
host: str | None = None,
647673
instructions: str | None = None,
648674
include_test_controller: bool = False,
649675
context_factory: ContextFactory | None = None,
650676
middleware: Sequence[SkillMiddleware] | None = None,
651677
advertise_all: bool = False,
678+
streaming_responses: bool = False,
652679
) -> Any:
653680
"""Create a FastMCP server from an ADCP handler without starting it.
654681
@@ -692,6 +719,17 @@ def create_mcp_server(
692719
:func:`~adcp.server.get_tools_for_handler` for semantics;
693720
use ``True`` for spec-compliance storyboards or when you
694721
deliberately want to expose a ``not_supported`` tool.
722+
host: Network interface to bind to. Defaults to the ``ADCP_HOST``
723+
environment variable, then ``"0.0.0.0"`` (all interfaces).
724+
Use ``"127.0.0.1"`` for local-only development.
725+
streaming_responses: When ``False`` (default), the streamable-http
726+
transport returns one ``application/json`` response per
727+
request — the right shape for AdCP tools today (none of which
728+
emit progress events). The FastMCP SSE-internal streaming
729+
default also has an upstream bug that drops the ASGI response
730+
without completing, blocking the storyboard runner. Set to
731+
``True`` only if your tools genuinely emit progress
732+
notifications and your clients consume the SSE stream.
695733
696734
Returns:
697735
A configured FastMCP server instance. Call ``mcp.run()`` to start,
@@ -744,7 +782,15 @@ def create_mcp_server(
744782
from mcp.server.fastmcp import FastMCP
745783

746784
resolved_port = port or int(os.environ.get("PORT", "3001"))
785+
resolved_host = host if host is not None else (os.environ.get("ADCP_HOST") or "0.0.0.0")
747786
mcp = FastMCP(name, instructions=instructions, port=resolved_port)
787+
mcp.settings.host = resolved_host
788+
if not streaming_responses:
789+
# FastMCP's SSE-internal default has an upstream bug; switching to
790+
# stateless JSON-response mode is also semantically correct for
791+
# AdCP tools, which return one complete envelope per request.
792+
mcp.settings.stateless_http = True
793+
mcp.settings.json_response = True
748794
_register_handler_tools(
749795
mcp,
750796
handler,

src/adcp/server/test_controller.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ async def force_account_status(self, account_id, status):
5252
"force_session_status",
5353
"simulate_delivery",
5454
"simulate_budget_spend",
55+
# seed_* scenarios pre-populate storyboard fixtures (AdCP 3.0.1)
56+
"seed_product",
57+
"seed_pricing_option",
58+
"seed_creative",
59+
"seed_plan",
60+
"seed_media_buy",
5561
]
5662

5763

@@ -191,6 +197,77 @@ async def simulate_budget_spend(
191197
"""
192198
raise NotImplementedError
193199

200+
async def seed_product(
201+
self,
202+
fixture: dict[str, Any] | None = None,
203+
product_id: str | None = None,
204+
*,
205+
context: ToolContext | None = None,
206+
) -> dict[str, Any]:
207+
"""Pre-populate a product fixture for storyboard tests (AdCP 3.0.1).
208+
209+
Returns:
210+
{"product_id": str}
211+
"""
212+
raise NotImplementedError
213+
214+
async def seed_pricing_option(
215+
self,
216+
fixture: dict[str, Any] | None = None,
217+
product_id: str | None = None,
218+
pricing_option_id: str | None = None,
219+
*,
220+
context: ToolContext | None = None,
221+
) -> dict[str, Any]:
222+
"""Pre-populate a pricing option fixture for storyboard tests (AdCP 3.0.1).
223+
224+
Returns:
225+
{"pricing_option_id": str}
226+
"""
227+
raise NotImplementedError
228+
229+
async def seed_creative(
230+
self,
231+
fixture: dict[str, Any] | None = None,
232+
creative_id: str | None = None,
233+
*,
234+
context: ToolContext | None = None,
235+
) -> dict[str, Any]:
236+
"""Pre-populate a creative fixture for storyboard tests (AdCP 3.0.1).
237+
238+
Returns:
239+
{"creative_id": str}
240+
"""
241+
raise NotImplementedError
242+
243+
async def seed_plan(
244+
self,
245+
fixture: dict[str, Any] | None = None,
246+
plan_id: str | None = None,
247+
*,
248+
context: ToolContext | None = None,
249+
) -> dict[str, Any]:
250+
"""Pre-populate a plan fixture for storyboard tests (AdCP 3.0.1).
251+
252+
Returns:
253+
{"plan_id": str}
254+
"""
255+
raise NotImplementedError
256+
257+
async def seed_media_buy(
258+
self,
259+
fixture: dict[str, Any] | None = None,
260+
media_buy_id: str | None = None,
261+
*,
262+
context: ToolContext | None = None,
263+
) -> dict[str, Any]:
264+
"""Pre-populate a media buy fixture for storyboard tests (AdCP 3.0.1).
265+
266+
Returns:
267+
{"media_buy_id": str}
268+
"""
269+
raise NotImplementedError
270+
194271

195272
def _list_scenarios(store: TestControllerStore) -> list[str]:
196273
"""Detect which scenarios a store actually implements.
@@ -353,6 +430,37 @@ async def _handle_test_controller(
353430
media_buy_id=scenario_params.get("media_buy_id"),
354431
**extra,
355432
)
433+
elif scenario == "seed_product":
434+
result = await method(
435+
fixture=scenario_params.get("fixture"),
436+
product_id=scenario_params.get("product_id"),
437+
**extra,
438+
)
439+
elif scenario == "seed_pricing_option":
440+
result = await method(
441+
fixture=scenario_params.get("fixture"),
442+
product_id=scenario_params.get("product_id"),
443+
pricing_option_id=scenario_params.get("pricing_option_id"),
444+
**extra,
445+
)
446+
elif scenario == "seed_creative":
447+
result = await method(
448+
fixture=scenario_params.get("fixture"),
449+
creative_id=scenario_params.get("creative_id"),
450+
**extra,
451+
)
452+
elif scenario == "seed_plan":
453+
result = await method(
454+
fixture=scenario_params.get("fixture"),
455+
plan_id=scenario_params.get("plan_id"),
456+
**extra,
457+
)
458+
elif scenario == "seed_media_buy":
459+
result = await method(
460+
fixture=scenario_params.get("fixture"),
461+
media_buy_id=scenario_params.get("media_buy_id"),
462+
**extra,
463+
)
356464
else:
357465
return _controller_error("UNKNOWN_SCENARIO", f"Unknown scenario: {scenario}")
358466
except TestControllerError as e:
@@ -442,6 +550,7 @@ async def comply_test_controller(**kwargs: Any) -> str:
442550
tool.parameters = {
443551
"type": "object",
444552
"properties": {
553+
"account": {"type": "object"},
445554
"scenario": {
446555
"type": "string",
447556
"enum": [
@@ -452,6 +561,11 @@ async def comply_test_controller(**kwargs: Any) -> str:
452561
"force_session_status",
453562
"simulate_delivery",
454563
"simulate_budget_spend",
564+
"seed_product",
565+
"seed_pricing_option",
566+
"seed_creative",
567+
"seed_plan",
568+
"seed_media_buy",
455569
],
456570
},
457571
"params": {"type": "object"},

0 commit comments

Comments
 (0)