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:

  1. configuration
  2. cache
  3. client

shutdown will clean up:

  1. client
  2. cache
  3. 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.