Skip to content

feat(server): on_startup / on_shutdown hooks on serve(transport='both')#713

Merged
bokelley merged 2 commits into
mainfrom
claude/issue-709-lifespan-hooks
May 13, 2026
Merged

feat(server): on_startup / on_shutdown hooks on serve(transport='both')#713
bokelley merged 2 commits into
mainfrom
claude/issue-709-lifespan-hooks

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Closes #709.

Summary

  • Adds on_startup and on_shutdown kwargs to serve() (and the mirror fields on ServeConfig), threaded through _serve_mcp_and_a2a()_build_mcp_and_a2a_app()_composed_lifespan.
  • Hooks are zero-arg async callables; startups fire after both inner MCP and A2A lifespans have initialized, shutdowns fire before either tears down. Failing startup hook aborts boot via lifespan.startup.failed; shutdown hooks all run best-effort, first failure re-raises, later failures log via logger.exception.
  • Ships for transport="both" only — single-transport paths raise ValueError at boot rather than silently drop hooks. Rationale documented in code and error message.
  • New LifespanHook = Callable[[], Awaitable[None]] alias in adcp.server.serve with its own docstring.
  • Example examples/scheduler_lifespan.py shows the salesagent pattern (start/stop a scheduler tied to lifecycle) — replaces the 61-LOC SchedulerLifespanMiddleware adopters had to write before.

Test plan

  • tests/test_serve_lifespan_hooks.py covers happy-path ordering (startups before yield, shutdowns after), failure propagation (startup raise aborts boot), shutdown best-effort (all hooks run even if one raises), boot-time validation on non-both transports, no-op default, and ServeConfig plumbing.
  • Existing tests/test_unified_mcp_a2a.py still passes — the unified app builds the same way when no hooks are supplied.
  • ruff check src/, mypy src/adcp/, pre-commit hooks all clean.

🤖 Generated with Claude Code

…t='both')

Closes #709.

Adopters running the unified MCP+A2A binary needed an SDK-owned
extension point for lifecycle-bound background work (schedulers,
queue consumers, cache warmers). Previously they had to wire a
custom ASGI middleware that intercepts the ``lifespan`` scope —
fragile because positioning relative to the SDK's own lifespan
composition was easy to get wrong.

This threads ``on_startup`` / ``on_shutdown`` lists of zero-arg async
callables through ``serve()`` -> ``_serve_mcp_and_a2a()`` ->
``_build_mcp_and_a2a_app()`` into ``_composed_lifespan``. Startup
hooks fire after both inner framework lifespans have entered (so
user code can rely on FastMCP / a2a-sdk being ready); shutdown hooks
fire before either tears down. A failing startup hook propagates as
``lifespan.startup.failed`` and aborts boot. Shutdown hooks all run
on a best-effort basis — the first failure re-raises, later
failures land in ``logger.exception``.

Single-transport paths (``streamable-http``, ``sse``, ``a2a``,
``stdio``) raise ``ValueError`` at boot when hooks are passed —
those code paths don't construct an SDK-owned parent Starlette and
adding the same composition would require mutating FastMCP /
a2a-sdk internals. Fail closed instead of silently dropping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Catch ``Exception`` rather than ``BaseException`` in the shutdown
  hook loop. ``CancelledError`` / ``KeyboardInterrupt`` / ``SystemExit``
  are the signals uvicorn uses to drive shutdown — collecting them
  into ``first_error`` and re-raising as ordinary exceptions breaks
  the shutdown path.

- Don't mask an upstream exception when a shutdown hook also raises.
  If the body raised (framework teardown, request handler escaping)
  the operator wants to see the upstream cause, not a secondary
  cleanup failure — log the shutdown error and let the original
  propagate.

- Suppressed-hook errors log via ``logger.error(..., exc_info=False)``
  instead of ``logger.exception``. Adopter closures capture DB DSNs,
  tokens, scheduler handles — tracebacks attached to log aggregators
  (Sentry, Datadog) would surface those verbatim. The re-raised
  ``first_error`` still carries the real traceback to Starlette.

- Re-export ``LifespanHook`` from ``adcp.server`` so adopters can
  type their hook lists without reaching into a private path.

- Document ``on_startup`` / ``on_shutdown`` in the ``serve()`` Args
  block, including the ``transport='both'``-only restriction.

- Error message on non-``both`` transport now points at
  ``examples/scheduler_lifespan.py`` and the follow-up tracking.

- ``test_shutdown_hooks_all_attempted_when_one_raises`` now captures
  the raised exception and asserts on its message; previously the
  ``try/except: pass`` swallowed exactly the re-raise the test
  claimed to verify.

- Example uses ``ClassVar`` annotation on ``advertised_tools`` and
  shows where production code stashes hook-captured resources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit aca625e into main May 13, 2026
16 checks passed
@bokelley bokelley deleted the claude/issue-709-lifespan-hooks branch May 13, 2026 09:35
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.

feat(server): expose lifespan / on_startup / on_shutdown hooks on serve()

1 participant