Skip to content

feat(testing): apps from build_asgi_app / serve() can start their lifespan only once, because the MCP session manager is created at build time #1311

Closed

Description

@bokelley

Summary

On 8.0.0, an app built by adcp.testing.build_asgi_app (or by serve()'s builders) can run its lifespan only once. The SDK creates a single ADCPStreamableHTTPSessionManager when it builds the streamable app (_create_adcp_mcp_session_manager, called at adcp/server/serve.py:2811-2828), and every lifespan start reuses it (lifespan=lambda app: manager.run(), serve.py:2846). mcp's StreamableHTTPSessionManager.run() is single-use by design: the second entry raises RuntimeError, and the docstring says "Create a new instance if you need to run again". So stopping and restarting the same app object fails on the second startup. Maintainers closed the mcp-side request to change this as not planned (modelcontextprotocol/python-sdk#1692). FastMCP 4 handles it by creating a fresh session manager inside each lifespan (fastmcp/server/http.py).

Reproduction (adcp 8.0.0, mcp 2.2.0)

import warnings

warnings.simplefilter("ignore")

from starlette.testclient import TestClient

from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform, SingletonAccounts
from adcp.testing import build_asgi_app
from adcp.decisioning.capabilities import Account as CapabilitiesAccount


class Seller(DecisioningPlatform):
    capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"], channels=["display"],
                                           pricing_models=["cpm"],
                                           account=CapabilitiesAccount(supported_billing=["operator"]))
    accounts = SingletonAccounts(account_id="acct_1")

    def get_products(self, req, ctx): return {"products": []}
    def create_media_buy(self, req, ctx): raise NotImplementedError
    def update_media_buy(self, media_buy_id, patch, ctx): raise NotImplementedError
    def sync_creatives(self, req, ctx): raise NotImplementedError
    def get_media_buy_delivery(self, req, ctx): raise NotImplementedError
    def get_media_buys(self, req, ctx): raise NotImplementedError


app = build_asgi_app(Seller(), transport="mcp")
for attempt in (1, 2):
    try:
        with TestClient(app):
            print(f"lifespan {attempt}: started")
    except Exception as exc:
        print(f"lifespan {attempt}:", type(exc).__name__, "|", exc)

Output:

lifespan 1: started
lifespan 2: RuntimeError | StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.

Why an adopter hits this

Test harnesses commonly enter an app's lifespan more than once, for example one TestClient(app) per test against a shared, expensive-to-build app. Embedded Sales Agent builds its app once per process and can't restart it. Its harness, tests/harness/_asgi_app.py (150 LOC), does three things: it keeps one app per process, runs that app's lifespan on a dedicated daemon-thread event loop, and marshals every request onto that loop with run_coroutine_threadsafe. The last part is needed because the manager's anyio task group is bound to the loop that called run(). Rebuilding the app per test would work, but would also rebuild everything the seller wires at build time.

Ask

Create the MCP session manager inside the app's lifespan rather than at build time, as FastMCP 4 does. Each lifespan start then gets a fresh instance, and the app can be started and stopped repeatedly. If build-time construction must stay, a documented build_asgi_app(..., reusable_lifespan=True) would also work.

Activity

  1. added
    enhancementNew feature or request
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  2. bokelley commented on Oct 2, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Bug
    Bucket(s): client (server build surface — adcp/server/serve.py, adcp.testing)
    Status: ready-to-implement

    What the experts said:

    • code-reviewer: Genuine bug — StreamableHTTPSessionManager.run() is single-use by upstream design; the fix is correct in principle but must use a mutable holder (_live) because ADCPStreamableHTTPASGIApp.__call__ closes over the build-time streamable_http reference; self._session_manager must also be assigned inside the lifespan (not at build time).
    • dx-expert: Fix should be unconditional, not a reusable_lifespan=True opt-in — the testing module's job is to remove infrastructure plumbing from adopter code; the docstring's "drop-in compatible with TestClient" claim is a false promise until fixed; a regression test covering two consecutive lifespan cycles is required alongside the fix.

    My take: Both experts converge. The fix is a well-scoped, non-breaking closure refactor in one function plus one new regression test.

    Implementation scope:

    • src/adcp/server/serve.py, streamable_http_app() (~line 2811–2847): Replace the build-time _create_adcp_mcp_session_manager(...) + lifespan=lambda app: manager.run() with an @asynccontextmanager lifespan that creates a fresh manager and StreamableHTTPASGIApp on each start. Use a one-element mutable list _live: list[tuple[Any, Any]] (holding (manager, streamable_http_instance)) shared between the lifespan and ADCPStreamableHTTPASGIApp.__call__; deref via _live[0] in __call__. Set self._session_manager = manager inside the lifespan and clear on exit.
    • tests/: Add a regression test that enters the app's lifespan twice on the same object (e.g. two with TestClient(app): blocks, mirroring the issue's reproduction) and asserts no RuntimeError.
    • Non-breaking: public API and exported types unchanged; _session_manager is underscore-prefixed internal; streamable_http_app() return type (Starlette) unchanged.
    • Required checks: pytest tests/test_testing_decisioning.py, mypy src/adcp/server/serve.py, ruff check .

    Triaged by Claude Code. Session: https://claude.ai/code/session_011sjGbaoJjJiSmfEuGPAFd6


    Generated by Claude Code

  3. added and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  4. bokelley commented on Oct 3, 2026

    @bokelley
    ContributorAuthor

    Fixed by #1333 (squash a6e7673): serve() binds a fresh MCP session manager and endpoints for each lifespan, so the same app can complete repeated startup/request/shutdown cycles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions