Skip to content

Lifespan

Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model.

You don't want to build it on every call, and you do want to close it cleanly. That's what the lifespan is for.

A typed lifespan

A lifespan is an @asynccontextmanager that receives the server and yields one object. Whatever you yield is available to every handler for as long as the server runs.

server.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from mcp.server import MCPServer
from mcp.server.mcpserver import Context


class Database:
    @classmethod
    async def connect(cls) -> "Database":
        return cls()

    async def disconnect(self) -> None: ...

    def query(self) -> int:
        return 3


@dataclass
class AppContext:
    db: Database


@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
    db = await Database.connect()
    try:
        yield AppContext(db=db)
    finally:
        await db.disconnect()


mcp = MCPServer("Bookshop", lifespan=app_lifespan)


@mcp.tool()
def count_books(genre: str, ctx: Context[AppContext]) -> str:
    """Count the books in a genre."""
    db = ctx.request_context.lifespan_context.db
    return f"{db.query()} books in {genre!r}."

Read it bottom-up:

  • app_lifespan connects the Database before the yield and disconnects it after, in a finally. That's startup and shutdown.
  • It yields an AppContext, a plain dataclass holding the things you set up. One field today, ten tomorrow.
  • MCPServer("Bookshop", lifespan=app_lifespan) is the whole wiring.
  • Inside the tool, the yielded object is ctx.request_context.lifespan_context.

The lifespan runs once. It is entered when the server starts (before the first request) and exited when the server stops. Every request in between shares the same AppContext.

Info

If you've written a FastAPI lifespan, you already know this. Same decorator, same yield, same finally.

What the model sees

Nothing new. ctx is a Context parameter, so the SDK injects it and it never reaches the input schema:

{
  "type": "object",
  "properties": {
    "genre": {"title": "Genre", "type": "string"}
  },
  "required": ["genre"],
  "title": "count_booksArguments"
}

genre is the only argument the model can pass. The lifespan is your server's business.

@mcp.resource() and @mcp.prompt() functions can take a ctx parameter too, written as a bare Context for a reason the next section gets to. Everything ctx carries is in The Context.

It really is typed

Look at the annotation again: ctx: Context[AppContext].

That one type parameter is why ctx.request_context.lifespan_context is an AppContext to your type checker. .db autocompletes; .dbb is an error before you ever run the server.

Write a bare Context instead and lifespan_context is typed as dict[str, Any]: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help.

Warning

Context[AppContext] is a tool-only spelling. Put it on an @mcp.resource() or @mcp.prompt() function and every call to that handler fails. The client gets an error back, and the server log shows why:

Context is not available outside of a request

In resources and prompts, write the bare ctx: Context. The object your lifespan yielded is still ctx.request_context.lifespan_context at runtime; you give up the type parameter, not the object.

Tip

There is always a lifespan. If you don't pass one, the SDK's default yields an empty dict, so ctx.request_context.lifespan_context is {}, never None. That default is also why a bare Context types it as dict[str, Any].

Watch it happen

"Startup runs before the first request" is the kind of sentence you should not have to take on faith.

Strip the server down to the lifecycle: give Database a connected flag, flip it in connect() and disconnect(), and add a tool that reports it.

server.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from mcp.server import MCPServer
from mcp.server.mcpserver import Context


class Database:
    def __init__(self) -> None:
        self.connected = False

    async def connect(self) -> None:
        self.connected = True

    async def disconnect(self) -> None:
        self.connected = False


@dataclass
class AppContext:
    db: Database


database = Database()


@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
    await database.connect()
    try:
        yield AppContext(db=database)
    finally:
        await database.disconnect()


mcp = MCPServer("Bookshop", lifespan=app_lifespan)


@mcp.tool()
def database_status(ctx: Context[AppContext]) -> str:
    """Report whether the database connection is up."""
    db = ctx.request_context.lifespan_context.db
    return "connected" if db.connected else "disconnected"

database lives at module level for one reason: so you can look at it from outside the server.

Check

Three moments, three values:

  • Before the server starts, database.connected is False. Importing the module connected nothing.
  • While it's running, call database_status and the result is "connected".
  • Stop the server and the finally block runs: database.connected is False again.

The work happened exactly where you put it: around the yield, not at import time and not per request.

Recap

  • lifespan= takes an @asynccontextmanager that receives the server and yields one object.
  • Code before the yield is startup. The finally after it is shutdown.
  • It runs once, around the whole life of the server, not per request.
  • Whatever you yield is ctx.request_context.lifespan_context in every tool, resource, and prompt.
  • ctx: Context[AppContext] makes that access fully typed in tools. Resources and prompts take the bare Context.
  • No lifespan= means an empty dict, never None.

Next: tools that return more than text, Media.