You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adopters who need to run background work tied to the server lifecycle — schedulers, connection pools, cache warmers, message-queue consumers, telemetry exporters — have no public hook into serve()'s lifespan composition.
serve.py:1703 builds a private _composed_lifespan that chains the MCP inner app's lifespan with the A2A inner app's lifespan, mounts it on the parent Starlette, and offers no extension point for the caller.
What adopters do today
Salesagent wires a 61-LOC SchedulerLifespanMiddleware (core/middleware/scheduler_lifespan.py) that intercepts ASGI lifespan.startup / lifespan.shutdown scope events, fires user-supplied coroutines, and forwards the events through. Every adopter who needs lifecycle-tied background work writes the same shape of middleware.
It re-implements what Starlette's lifespan= already provides.
It's a middleware, so it's positionally fragile — adopters get the ordering wrong (e.g. wrapping it outside SigningVerifyMiddleware would put it in the wrong scope).
It can't await background tasks correctly without explicit error propagation, so adopters write subtly different (and subtly broken) variants.
Proposed
Pick one of:
(a) Pass-through to Starlette's lifespan signature
serve chains this inside its existing _composed_lifespan. Lets adopters share state via yield-bound resources.
(a) and (b) are not mutually exclusive — Starlette supports both. The minimal-viable surface is (a).
Why this isn't just "file an ASGI middleware example"
The whole point of serve() is that it owns the lifespan composition (PR #680 made this explicit when callable public_url started breaking it). Forcing adopters to break that abstraction with middleware re-introduces exactly the ordering bugs PR #680 was meant to eliminate.
Acceptance
serve(on_startup=..., on_shutdown=...) accepted as kwargs and threaded into _composed_lifespan
(Optional follow-on) serve(lifespan=...) accepts an async context manager
An example in examples/ demonstrating a scheduler tied to lifespan, replacing the middleware pattern
Salesagent can delete core/middleware/scheduler_lifespan.py and pass on_startup=[_start_schedulers], on_shutdown=[_stop_schedulers] instead
Real-world demand
Found while auditing the 5.3.0 bump in salesagent for SDK workarounds we could now rip out. This was the one workaround that couldn't be removed because the SDK exposes no extension point.
Problem
Adopters who need to run background work tied to the server lifecycle — schedulers, connection pools, cache warmers, message-queue consumers, telemetry exporters — have no public hook into
serve()'s lifespan composition.serve.py:1703builds a private_composed_lifespanthat chains the MCP inner app's lifespan with the A2A inner app's lifespan, mounts it on the parent Starlette, and offers no extension point for the caller.What adopters do today
Salesagent wires a 61-LOC
SchedulerLifespanMiddleware(core/middleware/scheduler_lifespan.py) that intercepts ASGIlifespan.startup/lifespan.shutdownscope events, fires user-supplied coroutines, and forwards the events through. Every adopter who needs lifecycle-tied background work writes the same shape of middleware.Sketch:
This works but:
lifespan=already provides.SigningVerifyMiddlewarewould put it in the wrong scope).awaitbackground tasks correctly without explicit error propagation, so adopters write subtly different (and subtly broken) variants.Proposed
Pick one of:
(a) Pass-through to Starlette's lifespan signature
serveappends these to the lifespan it already composes. Simplest adopter surface, mirrors what Starlette/FastAPI users already know.(b) Single composable lifespan kwarg
servechains this inside its existing_composed_lifespan. Lets adopters share state viayield-bound resources.(a) and (b) are not mutually exclusive — Starlette supports both. The minimal-viable surface is (a).
Why this isn't just "file an ASGI middleware example"
The whole point of
serve()is that it owns the lifespan composition (PR #680 made this explicit when callablepublic_urlstarted breaking it). Forcing adopters to break that abstraction with middleware re-introduces exactly the ordering bugs PR #680 was meant to eliminate.Acceptance
serve(on_startup=..., on_shutdown=...)accepted as kwargs and threaded into_composed_lifespanserve(lifespan=...)accepts an async context managerpublic_url(now working in 5.3.0 per fix(server): preserve Starlette lifespan when public_url is callable #680) continues to workexamples/demonstrating a scheduler tied to lifespan, replacing the middleware patterncore/middleware/scheduler_lifespan.pyand passon_startup=[_start_schedulers], on_shutdown=[_stop_schedulers]insteadReal-world demand
Found while auditing the 5.3.0 bump in salesagent for SDK workarounds we could now rip out. This was the one workaround that couldn't be removed because the SDK exposes no extension point.