Skip to content

Lifecycle and scopes

dishka-fastmcp separates FastMCP registration from operation execution. The decorator cleans the public signature during registration. At execution time, @inject resolves the active FastMCP application and owns the request scope, unless DishkaMiddleware opened one for the whole MCP request.

Scope boundaries

Scope Boundary Owner
Scope.APP Server lifetime Root container, closed by dishka_lifespan or by its owner
Scope.SESSION One MCP connection, with DishkaMiddleware(session_scope=True); see Session scope DishkaMiddleware, finalized by the MCP SDK
Scope.REQUEST One tool call, resource read, or prompt render; with DishkaMiddleware, one MCP request @inject, or DishkaMiddleware

Pass dishka_lifespan to the FastMCP server you run. By default it closes the container on shutdown, which fits a container the server owns; for a shared container see Sharing the container:

container = make_async_container(AppProvider())
mcp = FastMCP('app', lifespan=dishka_lifespan(container))

If the FastMCP server already has a lifespan, combine it with dishka_lifespan:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans


@asynccontextmanager
async def application_lifespan(
    app: FastMCP,
) -> AsyncIterator[dict[str, object]]:
    resource = await create_resource()
    try:
        yield {'resource': resource}
    finally:
        await resource.close()


mcp = FastMCP(
    'app',
    lifespan=combine_lifespans(
        application_lifespan,
        dishka_lifespan(container),
    ),
)
setup_dishka(container, mcp)

combine_lifespans enters lifespans in argument order and exits them in reverse order. Placing dishka_lifespan last closes the Dishka container before the other lifespan releases its resources, which suits providers that depend on those resources. When the other lifespan uses the container instead, list dishka_lifespan first; see Sharing the container.

dishka_lifespan returns a FastMCP Lifespan, so it also composes with @lifespan functions through |. The order rules are the same: the left side enters first and exits last. | accepts only Lifespan instances, so a lifespan written with @asynccontextmanager, like application_lifespan above, goes through combine_lifespans or FastMCP's ContextManagerLifespan wrapper.

from fastmcp.server.lifespan import lifespan


@lifespan
async def database(server: FastMCP) -> AsyncIterator[dict[str, object]]:
    yield {'database': await connect()}


mcp = FastMCP('app', lifespan=database | dishka_lifespan(container))

The combined lifespan above belongs to a FastMCP instance. When composing FastMCP with FastAPI or Starlette, keep dishka_lifespan on the FastMCP server and combine the lifespan exposed by mcp.http_app() at the ASGI layer. This is also the correct pattern for hosting multiple independent FastMCP servers:

from fastapi import FastAPI


first = FastMCP('first', lifespan=dishka_lifespan(first_container))
second = FastMCP('second', lifespan=dishka_lifespan(second_container))
setup_dishka(first_container, first)
setup_dishka(second_container, second)

first_app = first.http_app()
second_app = second.http_app()
app = FastAPI(
    lifespan=combine_lifespans(
        first_app.lifespan,
        second_app.lifespan,
    ),
)
app.mount('/first', first_app)
app.mount('/second', second_app)

Do not combine dishka_lifespan(first_container) and dishka_lifespan(second_container) directly at the ASGI layer: combine_lifespans passes the same ASGI application to every lifespan. Each mcp.http_app().lifespan adapter preserves the corresponding FastMCP instance.

Sharing the container

dishka_lifespan closes the container when the FastMCP server stops. If the same container also serves a FastAPI application, a FastStream broker, a background worker or a test session, those components must be done with it by then. Dishka raises no error on get() after close(): it creates the Scope.APP dependencies again. Code that runs after the close silently opens new resources, and they leak unless the container is closed once more.

When the other component's lifespan is combined with the FastMCP one, list the FastMCP lifespan first. combine_lifespans exits lifespans in reverse order, so the container closes after the other component has shut down:

mcp_app = mcp.http_app(path='/')
app = FastAPI(lifespan=combine_lifespans(mcp_app.lifespan, app_lifespan))
app.mount('/mcp', mcp_app)

FastMCP's FastAPI guide lists app_lifespan first. With that order the container is already closed while app_lifespan shuts down.

When the container belongs to something else, pass finalize_container=False. The lifespan still registers the container and hands it to mounted routers, and the owner closes it. The name follows finalize_container in dishka's own integrations; here it defaults to True:

mcp = FastMCP('app', lifespan=dishka_lifespan(container, finalize_container=False))
setup_dishka(container, mcp)

A registration that setup_dishka made before the server started survives the shutdown, so direct call_tool() calls keep working between sessions. It still points at the container after the owner closes it, so stop calling the server at that point.

Ordering cannot help when the owner's lifecycle runs outside this combine_lifespans call: a FastStream application, a background worker, or a test session. Each async with Client(server) block starts and stops the server, so with the default finalize_container=True two blocks in a row close the container when the first one exits, and the second one silently gets new Scope.APP dependencies. Use finalize_container=False there.

Mounted servers

A server built from routers needs one container, on the server you serve. Give that server dishka_lifespan(container) and setup_dishka(container, server); routers mounted into it at any depth use its container. This includes routers mounted later and mounts with namespace or tool_names:

container = make_async_container(AppProvider())
server = FastMCP('app', lifespan=dishka_lifespan(container))
setup_dishka(container, server)

users = FastMCP('users')


@users.tool
@inject
async def get_user(user_id: int, repo: FromDishka[UserRepository]) -> User:
    return await repo.get(user_id)


server.mount(users, namespace='users')

dishka_lifespan puts the container into the server's lifespan state. FastMCP hands that state to every MCP request the server serves, mounted components included, so a component whose own server has no container takes it from there. The rules that follow from it:

  • A mounted server with its own setup_dishka keeps its own container. Its own dishka_lifespan does the same only when the server is mounted before the parent starts: FastMCP runs a mounted server's lifespan together with the parent's, so a server mounted while the parent serves needs setup_dishka.
  • Servers mounted under such a server without a container of their own use the serving root's container, never the nearest set-up server's. Nothing reports it when the root's container provides the same types, so call setup_dishka on a server that must use the middle server's container.
  • A router mounted into several servers uses the container of the server that serves the request, so one router module can back several applications.
  • A server without dishka_lifespan hands no container to its routers, even when the same router is mounted into another server that has one. Its DishkaMiddleware is the exception: routers without a container of their own share the request scope it opens with their async handlers. The container must also reach the serving server's lifespan state: combine_lifespans keeps it next to lifespans that yield a mapping or None, and a lifespan of your own that enters dishka_lifespan must pass its state through.
  • The container travels with MCP requests (a Client, HTTP, stdio). A direct await server.call_tool(...) outside a request reaches only the server's own components; test mounted routers through Client(server).
  • A server behind create_proxy(...) runs its own MCP session and needs its own setup_dishka.

Context and FastMCP from FastMCPProvider still describe the mounted server that owns the executing component, unless DishkaMiddleware opened the scope; see Request scope for the whole MCP request.

Request scope for the whole MCP request

@inject opens Scope.REQUEST around one handler call, so code outside a handler has no scope: a dynamic component provider that builds tools/list from the user and feature flags, a tool assembled from FunctionTool(fn=...) whose dependency type is only known at runtime, or middleware. Add DishkaMiddleware to open one scope for the whole MCP request, and reach it through get_request_container():

from collections.abc import Sequence

from fastmcp.server.providers import Provider as ComponentProvider
from fastmcp.tools import Tool

from dishka_fastmcp import DishkaFastMCPError, DishkaMiddleware, get_request_container


class FeatureTools(ComponentProvider):
    async def _list_tools(self) -> Sequence[Tool]:
        try:
            container = get_request_container()
        except DishkaFastMCPError:
            return []  # FastMCP also lists components outside any request
        flags = await container.get(FeatureFlags)
        return [tool for tool in ALL_TOOLS if flags.enabled(tool.name)]


container = make_async_container(AppProvider(), FastMCPProvider())
mcp = FastMCP(
    'app',
    lifespan=dishka_lifespan(container),
    middleware=[DishkaMiddleware(), AuditMiddleware()],
)
setup_dishka(container, mcp)
mcp.add_provider(FeatureTools())

Without the middleware nothing changes: @inject keeps its per-handler scope. With it:

  • Put DishkaMiddleware first. Middleware listed before it runs outside the scope.
  • Every request gets a scope: tool calls, list requests, resource reads, prompt renders, the handshake (initialize or server/discover) and ping. The scope is lazy, so a request that resolves nothing creates no dependencies. Notifications get no scope.
  • Async @inject handlers share the scope, so each REQUEST dependency has one instance per request, also when providers of one request resolve it concurrently. The scope is finalized once, when the rest of the request is done and before the response goes out, so a slow finalizer delays the response. An exception from a handler closes it and propagates unchanged.
  • To keep one instance per request, the scope has a lock, like dishka's APP scope: lookups in one request run one at a time. A factory may resolve through the container it gets as a parameter, but only in its own task. Called from a task of its own, for example under asyncio.gather, that lookup waits for the lock its caller holds and never returns. Declare such dependencies as factory parameters instead.
  • The middleware finalizes the scope after the handler has produced its result. A finalizer that raises therefore fails the whole request with a protocol error, which a 2026-07-28 client always sees as Internal server error; the server log has the cause. Without the middleware the same error is raised inside the handler call and reaches the client as a tool error.
  • get_request_container() raises DishkaFastMCPError where the request has no scope. FastMCP lists component providers outside any request, for example on startup to collect background-task components, so a provider returns no components there, as above. Completion and extension-method handlers run without the scope too: FastMCP gives them a fresh request context. A task a handler spawns loses the scope once the request is served.
  • A tool built as FunctionTool(fn=call, parameters=...) resolves its runtime dependency with await get_request_container().get(dependency_type). The container belongs to the event loop: a sync call, which FastMCP runs in a worker thread, resolves with anyio.from_thread.run(container.get, dependency_type).
  • The root container is found as for @inject, and it must be an AsyncContainer. The scope lives on the event loop, where a sync Container would block, so with one every request fails with a protocol error, and the server logs the DishkaFastMCPError.
  • Sync handlers keep a scope of their own in their worker thread: sharing the event-loop scope would finalize thread-affine resources away from the thread that created them. Under an AsyncContainer they fail as without the middleware, because a sync handler needs a sync Container; a mounted router set up with its own sync Container keeps working.
  • The outermost DishkaMiddleware of a request opens the scope; a mounted server's own DishkaMiddleware does not open a second one. Mounted routers without a container of their own share the scope. A router set up with a different container keeps per-handler scopes from it, and get_request_container() raises there, so its code never mixes instances of two containers.
  • The scope's Context and FastMCP describe the request as the middleware sees it: FastMCP is the server whose DishkaMiddleware opened the scope, also for the handlers of mounted routers that share it.
  • A direct await server.call_tool(...) outside an MCP request gets no scope, and @inject opens one per handler. task=True handlers get none either: get_request_container() raises in the worker, and @inject keeps rejecting them.

Values built from the request need no extra hook. A REQUEST provider that takes Context from FastMCPProvider reads the request, list requests included:

class UserProvider(Provider):
    @provide(scope=Scope.REQUEST)
    def user(self, ctx: Context) -> User:
        meta = ctx.request_context.meta if ctx.request_context else None
        return User((meta or {}).get('user', 'anonymous'))

Session scope for the MCP connection

Scope.SESSION holds objects that belong to one client connection: a browser or shell the client drives over several calls, an upstream client, a per-client cache. Pass session_scope=True to DishkaMiddleware, and the REQUEST scope of every request is opened from the SESSION scope of its connection:

class BrowserProvider(Provider):
    @provide(scope=Scope.SESSION)
    async def browser(self, pool: BrowserPool) -> AsyncIterator[Browser]:
        browser = await pool.launch()
        yield browser
        await browser.close()


mcp = FastMCP(
    'app',
    lifespan=dishka_lifespan(container),
    middleware=[DishkaMiddleware(session_scope=True)],
)

The scope follows the MCP connection, and how long a connection lives depends on the protocol era the client speaks and on the transport:

Connection SESSION lifetime
2025 handshake (initialize) over stdio, stateful Streamable HTTP, SSE, or an in-memory Client(server, mode='legacy') The whole connection
2026-07-28 (server/discover), which FastMCP 4's Client picks by default on stdio, Streamable HTTP and in memory One request
Stateless Streamable HTTP (stateless_http=True), either era One request

The 2026-07-28 protocol is stateless by design: the server builds a fresh connection for every request. A SESSION dependency then lives for one request, the way it does for an HTTP request in dishka's Starlette integration. Use the scope for resources worth reusing while a connection lasts. State that has to survive between 2026-07-28 calls belongs in FastMCP's session state, keyed by a session_id argument or by the authenticated user.

  • The scope opens on the connection's first request and its concurrent requests share it. Like the request scope it has a lock, with the same rule for factories that resolve through their container from a task of their own.
  • FastMCPProvider provides Context and FastMCP at REQUEST scope, so a SESSION factory cannot take them; dishka rejects such a graph when the container is built. The factory runs inside the request that first resolves it.
  • The scope closes when the connection does: the client disconnects (stdio EOF, the end of an SSE stream or of an in-memory client block), a stateful HTTP client sends DELETE, the HTTP session stays idle past its timeout, or the server shuts down. On shutdown, sessions close before dishka_lifespan closes the APP scope.
  • Set an idle timeout when SESSION runs over stateful HTTP. A client that vanishes without DELETE keeps its session, and the SESSION dependencies in it, until the timeout. FastMCP 4.0 sets no timeout, and FastMCP 4.1 takes the SDK default: 1800 seconds from mcp 2.2, none before. Without a timeout those dependencies stay until the server restarts. Pass mcp.http_app(session_idle_timeout=600), or set FASTMCP_HTTP_SESSION_IDLE_TIMEOUT=600 for mcp.run(transport='http'). Every request pushes the deadline forward, and from mcp 2.2 so does an open GET stream.
  • A 2026-07-28 connection closes its SESSION scope before the response goes out, so a slow finalizer delays the response. A stateless HTTP request from a 2025 client closes it after the response.
  • The MCP SDK runs the SESSION finalizers. It logs their errors as connection exit_stack cleanup raised on the mcp.server.runner logger instead of raising them, and abandons finalizers still running after five seconds. The client never sees them.
  • FastMCP's stdio client transports, PythonStdioTransport among them, keep the server process, and with it the connection, alive after the async with Client(...) block by default. Pass keep_alive=False to close the session with the client.
  • Only DishkaMiddleware opens a SESSION scope. Without it, and for direct server.call_tool() calls, @inject opens REQUEST straight from the root container: dishka enters the skipped SESSION scope with each REQUEST scope and closes it with that request. A mounted router set up with a container of its own gets SESSION scopes of its own from its own DishkaMiddleware(session_scope=True), when no outer middleware opened the request's scope; otherwise its handlers open REQUEST from its container.
  • The MCP SDK exposes the per-client connection only as the private ServerSession._connection, which FastMCP itself reads for PingMiddleware and StatefulProxyClient. dishka-fastmcp reads it in one place, only with session_scope=True, and its tests fail on an upgrade that removes it.

Sync request finalization

FastMCP offloads regular sync handlers to worker threads. @inject opens and closes the Dishka request container inside the wrapped handler, so thread-affine resources stay in one thread:

import sqlite3
from collections.abc import Iterator

from dishka import Provider, Scope, provide


class DatabaseProvider(Provider):
    @provide(scope=Scope.REQUEST)
    def connection(self) -> Iterator[sqlite3.Connection]:
        connection = sqlite3.connect('app.db')
        try:
            yield connection
        finally:
            connection.close()

Sync Scope.APP dependencies must still be thread-safe because calls may run on different workers and APP cleanup happens during server shutdown.

Limitations

Scope.SESSION spans more than one request only on 2025 handshake connections over stdio, stateful Streamable HTTP, SSE and the in-memory Client(server, mode='legacy'); see Session scope.

Handlers registered with task=True are unsupported. FastMCP 4 runs them in a fastmcp-tasks worker, outside the request that queued them, and @inject raises DishkaFastMCPError there. Resolve dependencies during the request and pass plain values to background work.