Skip to content

Usage with FastMCP

How to use

1. Install modern-di-fastmcp

uv add modern-di-fastmcp
pip install modern-di-fastmcp
poetry add modern-di-fastmcp

2. Apply to your server

import dataclasses

import fastmcp
import modern_di_fastmcp
from modern_di import Container, Group, Scope, providers


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
    greeting: str = "Hello"


@dataclasses.dataclass(kw_only=True, slots=True)
class GreetingService:
    settings: Settings   # APP-scoped, injected by type

    def greet(self, name: str) -> str:
        return f"{self.settings.greeting}, {name}!"


class AppGroup(Group):
    settings = providers.Factory(Settings, scope=Scope.APP, cache=True)
    service = providers.Factory(GreetingService, scope=Scope.REQUEST)


mcp = fastmcp.FastMCP("greeter")
container = Container(groups=[AppGroup])
modern_di_fastmcp.setup_di(mcp, container)
container.validate()  # after setup_di: its connection provider is now registered


@mcp.tool
def greet(name: str, service: GreetingService = modern_di_fastmcp.FromDI(GreetingService)) -> str:  # noqa: B008
    return service.greet(name)

The same FromDI default works on @mcp.resource, resource templates and @mcp.prompt. FastMCP leaves injected parameters out of the schema it sends to the client, so a client never sees or supplies them.

FromDI is a default value

Unlike the other integrations, FromDI is the parameter's default, x: T = FromDI(T), and not Annotated metadata. FastMCP keeps an Annotated parameter in the schema and asks the client for its value. When the parameter's type has no schema, as with a plain class, FastMCP refuses the tool when it is defined. When it has one, as with a dataclass, the server raises TypeError at startup naming the parameter. That check covers the server's own tools, resources and prompts; components of a mounted server, and components added after startup, are not checked.

Ruff's B008 flags a call in a default. Add FromDI to bugbear's immutable calls instead of writing # noqa: B008 on every parameter:

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["modern_di_fastmcp.FromDI"]

Scopes

A middleware opens a Scope.REQUEST child container for every MCP request: a tool call, a resource read, a prompt render or a list call. Notifications open nothing. The child is closed with close_async(), so REQUEST-scoped finalizers may be async or sync. There is no Scope.SESSION child: an MCP session has no close hook, so nothing could close it.

setup_di on the server that clients connect to covers its own components and those of every server mounted on it. setup_di on a mounted server covers only that server's components, and a FromDI parameter on the parent's own tools then fails with a ToolError that names setup_di.

Root container lifecycle

setup_di opens the root container when the server's lifespan starts and closes it with close_async() when the lifespan ends, which runs APP-scoped finalizers. With manage_lifespan=False it leaves both to another app; see Sharing a container with FastAPI.

Framework context objects

The current fastmcp.Context is available through fastmcp_context_provider, at Scope.REQUEST. Inject it by type into a factory:

@dataclasses.dataclass(kw_only=True, slots=True)
class RequestInfo:
    context: fastmcp.Context

    @property
    def request_id(self) -> str:
        return self.context.request_id


class AppGroup(Group):
    request_info = providers.Factory(RequestInfo, scope=Scope.REQUEST)

It is the middleware's Context for the request, a sibling of the one FastMCP passes to a tool through CurrentContext(); both share the request's state.

Sharing a container with FastAPI

When a FastMCP server is mounted inside a FastAPI app, one container can serve both. Exactly one of them must own the container's lifespan, or the first to stop closes it for the other. Let FastAPI own it and pass manage_lifespan=False to the FastMCP side:

import fastapi
import modern_di_fastapi

modern_di_fastmcp.setup_di(mcp, container, manage_lifespan=False)

mcp_app = mcp.http_app(path="/")
app = fastapi.FastAPI(lifespan=mcp_app.lifespan)  # FastMCP's HTTP app needs its lifespan to run
modern_di_fastapi.setup_di(app, container)
app.mount("/mcp", mcp_app)

Background tasks

A tool registered with task=True needs fastmcp[tasks] and a TasksExtension added with mcp.add_extension(...). Such a tool runs in FastMCP's task worker, outside middleware, so there is no request container for it and FromDI cannot resolve. The client gets a ToolError with Failed to resolve dependencies for parameter(s): ..., and the server logs a RuntimeError that names background tasks.

Testing

Use FastMCP's in-memory client. Entering it runs the server's lifespan, so the container opens and closes with it:

async with fastmcp.Client(mcp) as client:
    result = await client.call_tool("greet", {"name": "world"})
assert result.data == "Hello, world!"

A FromDI inside Annotated that the server rejects at startup reaches the test as RuntimeError: Client failed to connect: ..., followed by the server's message naming the parameter.

See also

API

Symbol Description
setup_di(server, container, *, manage_lifespan=True) Attaches the APP container to the server, registers fastmcp_context_provider, and adds the middleware that creates a REQUEST child container per MCP request. The server's lifespan opens the container and closes it at shutdown; manage_lifespan=False leaves that to another app. Raises TypeError at startup for a FromDI inside Annotated, and RuntimeError when called twice for one server. Returns the container.
FromDI(dependency) Parameter default that resolves a provider instance or a plain type from the request container. Raises RuntimeError naming setup_di when no request container is active.
fetch_di_container(server) Returns the APP container attached to the server; raises RuntimeError when setup_di was not called.
fastmcp_context_provider ContextProvider for the current fastmcp.Context.