Skip to content

fix(server): preserve Starlette lifespan when public_url is callable#680

Merged
bokelley merged 1 commit into
mainfrom
claude/issue-676-callable-public-url-lifespan
May 12, 2026
Merged

fix(server): preserve Starlette lifespan when public_url is callable#680
bokelley merged 1 commit into
mainfrom
claude/issue-676-callable-public-url-lifespan

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Fixes #676. serve(transport="both", public_url=<callable>) crashed at lifespan startup with AttributeError: 'function' object has no attribute 'router' and the process exited 0 with no requests served.

Root cause: create_a2a_server returned a raw ASGI callable (the per-request card middleware) when public_url was a PublicUrlResolver, but the unified-transport composer in _serve_mcp_and_a2a reaches a2a_inner.router.lifespan_context directly. Only the static-string and None paths returned a Starlette app.

This implements option 1 from the issue: install the per-request card intercept as a proper Starlette middleware (app.add_middleware(_PerRequestCardMiddleware, ...)) so the returned object stays a Starlette instance with .router intact. The composer keeps working unchanged.

Changes

  • src/adcp/server/a2a_server.py — replaced _wrap_with_per_request_card closure with a class-based ASGI middleware _PerRequestCardMiddleware; create_a2a_server now installs it via app.add_middleware(...) instead of wrapping. The card-serving behaviour is unchanged.
  • tests/test_unified_mcp_a2a.py — added test_unified_app_starts_with_callable_public_url regression test. Verified it fails on the pre-fix source with the same AttributeError reported in the issue, and passes on the fix.

Test plan

  • pytest tests/test_a2a_public_url_resolver.py tests/test_unified_mcp_a2a.py — 24 passed
  • Smoke: pytest tests/test_auth_middleware.py tests/test_mcp_middleware_composition.py tests/test_discovery_endpoint.py — 69 passed
  • ruff check clean on modified files
  • mypy src/adcp/ clean
  • Black formatted (pre-commit hook re-ran)
  • Regression test verified to fail without the fix

create_a2a_server returned a raw ASGI callable when public_url was a
PublicUrlResolver, so the unified-transport composer's
a2a_inner.router.lifespan_context access raised AttributeError at
startup and the process exited 0 with no requests served.

Install the per-request card intercept as a Starlette middleware via
add_middleware so the returned object stays a Starlette app with
.router intact. The static and None public_url paths are unchanged.

Fixes #676

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley
Copy link
Copy Markdown
Contributor Author

Expert review summary

code-reviewer: approved — no blockers. Starlette middleware semantics correct, lifespan composition correct (router-level lifespan bypasses middleware stack which is the right behavior), regression test exercises the bug path. Nits (non-blocking): hot-path imports inside __call__ could be hoisted; test could assert exact card field name rather than the supportedInterfaces or supported_interfaces fallback.

python-expert: approved — no blockers. Confirmed A2ABearerAuthMiddleware ordering is preserved (auth outside Starlette, card middleware inside — well-known paths short-circuit auth via the existing _A2A_DISCOVERY_PATHS allowlist). add_middleware after init is safe before first request. Lifespan path through _dispatch -> parent -> _composed_lifespan correctly bypasses the auth wrapper too.

CI: 16/16 green across Python 3.10–3.13, conformance, storyboards.

Merging.

@bokelley bokelley merged commit 8632847 into main May 12, 2026
16 checks passed
@bokelley bokelley deleted the claude/issue-676-callable-public-url-lifespan branch May 12, 2026 00:52
@bokelley
Copy link
Copy Markdown
Contributor Author

Thanks for the review summary, @bokelley. No action needed on my end — happy to see the experts aligned and CI clean.

Triaged by Claude Code. Session: https://claude.ai/code/${CLAUDE_CODE_REMOTE_SESSION_ID}


Generated by Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve(transport="both") + callable public_url crashes at startup: 'function' object has no attribute 'router'

1 participant