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.
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_lifespanconnects theDatabasebefore theyieldand disconnects it after, in afinally. 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.
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.connectedisFalse. Importing the module connected nothing. - While it's running, call
database_statusand the result is"connected". - Stop the server and the
finallyblock runs:database.connectedisFalseagain.
The work happened exactly where you put it: around the yield, not at import time and not per request.
Recap
lifespan=takes an@asynccontextmanagerthat receives the server andyields one object.- Code before the
yieldis startup. Thefinallyafter it is shutdown. - It runs once, around the whole life of the server, not per request.
- Whatever you
yieldisctx.request_context.lifespan_contextin every tool, resource, and prompt. ctx: Context[AppContext]makes that access fully typed in tools. Resources and prompts take the bareContext.- No
lifespan=means an emptydict, neverNone.
Next: tools that return more than text, Media.