Lifespans let a FastestMCP server run startup and shutdown logic exactly once per runtime.
That is the right place for state that should exist for the lifetime of the server process tree:
- warm configuration
- shared caches
- prepared lookup tables
- long-lived clients or pooled resources
- startup bookkeeping that tools should be able to read later
Unlike per-request dependencies, lifespan hooks do not run on every call. They run once when the server starts and clean up when the runtime stops.
Basic Shape
Register a lifespan with FastestMCP.add_lifespan/2:
server =
FastestMCP.server("lifespan")
|> FastestMCP.add_lifespan(fn _server ->
{%{"started_at" => DateTime.utc_now()},
fn ->
IO.puts("Shutting down")
end}
end)An enter hook can return:
- a map
{:ok, map}{map, cleanup_fun}{:ok, map, cleanup_fun}nil{:ok, nil}
The returned map becomes part of ctx.lifespan_context.
Accessing Lifespan State
Tools, prompts, and resources can read merged lifespan state from the context:
server =
FastestMCP.server("lifespan")
|> FastestMCP.add_lifespan(fn _server ->
%{"config" => %{"region" => "eu-west-1"}}
end)
|> FastestMCP.add_tool("lifespan_info", fn _arguments, ctx ->
ctx.lifespan_context
end)That keeps startup state visible without requiring tools to know how it was created.
Composing Multiple Lifespans
Multiple lifespan hooks compose in declaration order:
server =
FastestMCP.server("lifespan")
|> FastestMCP.add_lifespan(fn _server ->
%{"db" => "connected", "shared" => "first"}
end)
|> FastestMCP.add_lifespan(fn _server ->
%{"cache" => "warm", "shared" => "second"}
end)The merged context becomes:
%{
"cache" => "warm",
"db" => "connected",
"shared" => "second"
}Later lifespans win on key conflicts. That makes override order explicit.
Cleanup Order
Cleanup runs in reverse order.
If you register:
- configuration
- cache
- client
shutdown will clean up:
- client
- cache
- configuration
That ordering matters when later startup steps depend on earlier ones.
Mounted server lifespans are entered recursively when the parent runtime starts.
Mounted handlers see their own server's ctx.lifespan_context; parent and
child lifespan maps are not merged together. Shutdown runs child cleanup before
parent cleanup so mounted resources are released before parent resources they
may depend on.
Failure Behavior
Startup failures are cleaned up immediately.
If one lifespan has already entered and a later one raises or returns an invalid result:
- server startup fails
- already-entered lifespan cleanups still run
That prevents partially-started runtimes from leaking resources.
Lifespan vs Dependencies
Use lifespan when the state should exist once for the whole server runtime:
- a warmed lookup table
- a reusable client created during startup
- configuration loaded once
Use Dependency Injection when the value should be resolved once per request or background task.
In practice:
- lifespan is runtime-scoped
- dependencies are request-scoped
- session state is conversation-scoped
Example: Shared Startup Context
server =
FastestMCP.server("lifespan")
|> FastestMCP.add_lifespan(fn server ->
{:ok, %{"server_name" => server.name, "cache" => "warm"},
fn state ->
IO.inspect({:stopping, state["server_name"]})
end}
end)
|> FastestMCP.add_tool("show_context", fn _arguments, ctx ->
%{
server: ctx.server_name,
lifespan: ctx.lifespan_context
}
end)Why This Shape
FastestMCP keeps lifespan small and predictable.
There is no hidden application container and no second lifecycle system. Lifespans are just composable startup hooks that produce a merged context plus reverse-order cleanup callbacks. That fits OTP well and keeps startup state easy to inspect from tools.