Context and lifespan
Two problems, two mechanisms
Handlers so far have been pure functions. Real ones need two things they cannot get from their arguments: a way to reach the outside world during the call, and a way to share expensive resources across calls.
Context solves the first. lifespan solves the second.
Context is a typed parameter now
Add a parameter annotated Context and the SDK injects one:
from mcp.server.mcpserver import Context
@mcp.tool()
def whoami(ctx: Context) -> str:
"""Echo the current request id."""
return f"request {ctx.request_id}"request 6The parameter name is irrelevant; the type annotation drives injection. And critically, the context parameter does not appear in the tool's input schema — the model never sees it, never supplies it, and does not know it exists.
What it gives you:
ctx.request_id— identifies this request, for correlating logs.await ctx.read_resource(uri)— read one of your own resources from inside a handler, rather than duplicating the code that builds it.await ctx.report_progress(progress, total, message)— tell the client how far along a slow operation is.await ctx.elicit(...)andawait ctx.elicit_url(...)— ask the user something. Section 4 is about these.ctx.headers— transport headers,Noneon stdio. Read defensively:(ctx.headers or {}).get("authorization").ctx.request_context.lifespan_context— the shared state below.- *`ctx.session.send_`** — notify the client that your tool or resource list changed.
If you have read an older tutorial, it called mcp.get_context() inside the handler body. That function is gone in SDK v2; injection by annotation replaced it, because a context fetched from a global cannot be typed and cannot be scoped to one request. Add the parameter and delete the call.
Opening the shop in the morning
You would not unlock the building, turn on the lights and start the coffee machine for each customer who walks in. You do it once, before opening, and everyone who comes in that day uses what is already running.
A handler that opens a database connection per call is doing the per-customer version. lifespan is opening the shop:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
@dataclass
class AtriumContext:
bookings: Bookings
@asynccontextmanager
async def atrium_lifespan(server: MCPServer) -> AsyncIterator[AtriumContext]:
"""Open the booking store once, when the server starts."""
store = Bookings()
await store.connect()
try:
yield AtriumContext(bookings=store)
finally:
await store.disconnect()
mcp = MCPServer("atrium", lifespan=atrium_lifespan)Everything before yield runs at startup. The yielded object is available to every handler for the server's lifetime. Everything after yield runs at shutdown, and the try/finally is what makes that true even when something fails.
For an engineer the recognisable name is dependency scope — this is a singleton with a defined lifetime and guaranteed teardown, which is what a connection pool wants.
Reaching it from a handler
Type the context with your lifespan type and the attribute is properly typed:
@mcp.tool()
def find_slots(
room_id: str,
day: str,
minutes: Literal[30, 60, 120] = 60,
ctx: Context[AtriumContext] = None,
) -> list[Slot]:
"""Free slots in a room on a given day. Call this before booking anything."""
if room_id not in ROOMS:
raise ValueError(f"no such room: {room_id!r}. Try one of {', '.join(ROOMS)}.")
taken = ctx.request_context.lifespan_context.bookings.taken_hours(room_id, day)
return [
Slot(room_id=room_id, start=f"{day}T{h:02d}:00", minutes=minutes)
for h in range(OPENING, CLOSING)
if h not in taken
]Context[AtriumContext] means lifespan_context is an AtriumContext, so your editor knows .bookings exists.
Watch the state work. The floor opens 08:00 to 18:00, so an empty day has ten slots:
-- find_slots (empty day) --
slots: 10 first: {'room_id': 'studio-b', 'start': '2026-09-17T08:00', 'minutes': 60}
-- book_room --
-> {"booking_id": "bk_1", "room_id": "studio-b", "start": "2026-09-17T09:00", "minutes": 60}
-- find_slots again (9:00 now gone) --
slots: 9 first: {'room_id': 'studio-b', 'start': '2026-09-17T08:00', 'minutes': 60}Ten, then nine. Two handlers sharing one connection, opened once.
What belongs in there
Anything expensive to create and safe to share: database connections and pools, HTTP clients, loaded configuration, caches, an ML model.
What does not: anything scoped to one request. The lifespan object is shared across every concurrent handler, so per-request state stored there will be read by somebody else's call. Request-scoped things go in the handler, or on the context.
A note on threads, because it bites: synchronous handlers run on worker threads, so a shared object touched from a def handler must be thread-safe. The sqlite3 connection above passes check_same_thread=False for exactly this reason.
What to take into the next lesson
Context arrives by type annotation, is invisible to the model, and is how a handler reads resources, reports progress and asks the user questions; lifespan opens expensive things once and guarantees teardown, reached through ctx.request_context.lifespan_context. Next: proving any of it works.