Context, progress and lifespan

Model Context Protocol (MCP) · Practical · 12 min

Real servers do slow work and hold connections; these hooks make that efficient and visible to the user.

Two features cover most needs of real-world servers.

The Context. Add a parameter typed ctx: Context to any tool and the SDK fills it in (it is hidden from the model's schema). It gives you the request ID, the ability to read your own resources (await ctx.read_resource(...)), and progress reporting: await ctx.report_progress(done, total=..., message=...) sends updates the host can show as a progress bar while a long tool runs.

Those calls use await, so the tool must be async def. In short: an async def function can pause at each await while waiting on slow input/output (network, disk), letting the server handle other requests meanwhile. In SDK v2 a plain def tool also works; the SDK runs it on a separate worker thread so it doesn't block the server.

The lifespan. Opening a database connection inside every tool call is slow. A lifespan is a function that runs once at startup, creates shared objects, and cleans them up at shutdown. You write it as an async context manager (a with-style block): code before yield runs at startup, code after runs at shutdown. Pass it as MCPServer("x", lifespan=app_lifespan); tools read the yielded object through ctx.request_context.lifespan_context.

In SDK v2 the lifespan runs once per server process, and its objects are shared by every request. That makes it the right place for connection pools and caches, and the wrong place for anything that belongs to one user.

Example

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server import MCPServer
from mcp.server.mcpserver import Context


@dataclass
class AppState:
    db: dict            # stand-in for a real connection pool


@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppState]:
    db = {"rows": 0}    # open once at startup
    try:
        yield AppState(db=db)
    finally:
        db.clear()      # close at shutdown


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


@mcp.tool()
async def import_rows(urls: list[str], ctx: Context[AppState]) -> str:
    """Import rows from a list of URLs."""
    db = ctx.request_context.lifespan_context.db
    for i, url in enumerate(urls, start=1):
        db["rows"] += 1
        await ctx.report_progress(i, total=len(urls), message=f"Imported {url}")
    return f"Imported {len(urls)} rows"

Remember

  • A ctx: Context parameter is injected and hidden from the model
  • ctx.report_progress(...) shows progress for long tools
  • Lifespan = startup/shutdown hook for shared resources
  • In SDK v2 the lifespan runs once per process, shared by all requests

Watch out for

  • Storing per-user data in the lifespan object; it is shared by every client of the server.

Check yourself

  1. Where should a database connection pool be created?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: py.sdk.modelcontextprotocol.io/handlers/context/