Repository navigation
Add modern-di-fastmcp integration #462
Description
Ship modern-di-fastmcp as a separate-repo integration, following
docs/integrations/writing-integrations.md.
Feasibility
Prototyped against fastmcp==3.4.6 and fastmcp==4.0.3, running unchanged on both.
| Unit of work | Scope | Hook |
|---|---|---|
| Server process | APP |
FastMCP.add_provider + Provider.lifespan() |
| One MCP request (tool call / resource read / prompt render) | REQUEST |
add_middleware + Middleware.on_message |
| MCP session | not offered | no session-close hook exists |
Injection is fastmcp.dependencies.Depends(...) as a parameter default.
Injected params never reach the LLM: FastMCP strips them from the tool signature before schema
generation, via without_injected_parameters in fastmcp/server/dependencies.py, applied to
tools, resources and resource templates. Verified with two injected params present, the generated
schema listed only the real one, and a client attempt to supply an injected param was rejected.
Also verified in the prototype: APP open/close, one shared REQUEST container per MCP call,
per-request finalizers firing, and container.override() intact.
Estimated ~90 LOC in main.py, mid-range for the family's 76-213.
Public surface
| Name | Shape |
|---|---|
setup_di(app: FastMCP, container: Container) -> Container |
attaches the root container, registers the connection ContextProvider, adds the lifespan provider and the DI middleware |
fetch_di_container(app: FastMCP) -> Container |
reads the root back out |
FromDI(dependency: AbstractProvider[T] | type[T]) -> T |
returns fastmcp.dependencies.Depends(...), used as a default, not Annotated |
mcp = FastMCP("my-server")
container = setup_di(mcp, Container(groups=[Dependencies]))
container.validate()
@mcp.tool
def list_users(
limit: int,
service: UserService = FromDI(Dependencies.user_service), # noqa: B008
) -> list[str]:
return service.list(limit)The client sees only limit.
Decisions needed before implementation
-
FromDIform: use the parameter-default spelling. Still wants an ADR in the integration's
own repo; one was drafted and dropped unmerged, so the reasoning lives here for now.FromDIis used as
service: UserService = FromDI(...)with a scoped# noqa: B008exemption; the
Annotated[T, FromDI(...)]form the family mandates is not offered, because FastMCP detects the
marker only as a default.uncalled_for.introspection.get_dependency_parametersiterates
signature.parametersand keeps those whereisinstance(parameter.default, Dependency);
annotation metadata is never consulted, and there is no annotation-reading counterpart.Verified on 3.4.6 and 4.0.3, one tool per form on the same server: the default spelling generates
schema['name']and resolves, while theAnnotatedspelling generates['name', 'svc']and
then fails the call withToolError, missing required argument. It does not merely fail to
inject, it advertises a parameter to the model and rejects the call the model makes. -
Which root-lifecycle hook: settled, use
add_provider.ServerExtension(4.x only) looked
semantically right, but itsidentifieris a required reverse-DNS string, validated at
registration and advertised underServerCapabilities.extensions. Using one for a lifecycle
callback would announce a protocol capability the integration does not implement, whereas
Provider's thinness is invisible to clients.add_provideralso works on both 3.x and 4.x,
avoiding afastmcp>=4floor.lite-bootstrapreached the same conclusion independently and
recorded it in ADR0001-fastmcp-teardown-via-provider-lifespan.md; cross-reference it rather
than re-deriving. Note that ADR's revisit trigger names "a documented public way to compose a
lifespan post-construction", which FastMCP 4'sadd_extensionarguably satisfies, so it reads as
fired until someone amends it. -
What the
ContextProviderbinds to. The prototype bindsMiddlewareContext.
fastmcp.Contextwould be more useful, butMiddlewareContext.fastmcp_contextis
Context | None, so it cannot be bound unconditionally.
Contract items needing a framework-specific answer
Two checklist items the generic spec cannot answer for
this host:
-
Root reopen on restart. The checklist requires the root container to reopen on startup so a
restart does not depend on the implicit-reuse warning (ContainerClosedWarning) and gets
finalizers wired to shutdown. Unaddressed: what happens when the sameFastMCPserver is started
twice against one root container, given the lifecycle runs throughProvider.lifespan()rather
than a composed lifespan. -
close_asyncvsclose_sync. FastMCP's lifespan is async, soclose_asyncis the
expected match, but the spec asks for this to be stated rather than assumed.
Known constraints
- No
SESSIONscope. No session-close hook exists, so it cannot be offered with deterministic
teardown. Scope table isAPP+REQUEST. pyprojectdependency spelling must account forfastmcpbeing a metapackage over
fastmcp-slimin 4.x.- Churn. Three majors in ~17 months, repo moved
jlowintoPrefectHQ, DI engine swapped twice,
sse_app()/streamable_http_app()removed in v4, and the engine underneath (uncalled-for) is at
0.4.0. This would be the family's least stable host. Mitigated by depending only on
fastmcp.dependencies.Depends, which FastMCP re-exports and documents.
Not verified
- Whether
Middleware.on_messagefires on every dispatch path in every transport. The prototype
exercised the in-memory client only, thoughon_messageis documented as covering all MCP traffic. - Behaviour under FastMCP background tasks (
fastmcp[tasks]/ Docket), where a tool may run outside
a request and aREQUEST-scoped container would not exist.
Context
- FastMCP ships its own DI (
Depends,Shared,Current*), so this pays off mainly for someone who
already has a modern-di graph, or who wants one container shared across a FastAPI app and an MCP
server mounted beside it. - Prior art:
dishka-fastmcp,fastmcp-dishka,wireup,fastmcp-injector. - The PyPI name
modern-di-fastmcpis free.
Activity
lesnik512 commentedon Oct 10, 2026
Design settled after checking this issue against FastMCP 4.1.0 (
research/0462-fastmcp-integration.md) and against prior art in dishka-fastmcp and wireup (research/0462-0465-prior-art.md). It changes the issue in these places:on_request, noton_message. Since FastMCP 4.0,on_messagealso fires for notifications.fastmcp>=4,<5. CI runs on the latest 4.x, plus a scheduled run against the newest minor.svc: T = FromDI(T)as a parameter default, backed by FastMCP'sDepends. TheAnnotatedform leaks into the tool schema and the call then fails, soFromDIinsideAnnotatedraises when the tool is defined. An ADR in the new repo records this exception to the family convention.setup_di(mcp, container, manage_lifespan=False)for a root that FastAPI or another app opens and closes.FromDIraises a clear error there. Supporting them is a follow-up.Unchanged from the issue:
add_provider) and REQUEST (one container per MCP request, held in aContextVar). There is still no SESSION.fastmcp.Contextis registered asfastmcp_context_provider.FromDIraisesRuntimeErrornamingsetup_diwhen no request container is active.docs: add the FastMCP integration page (#462) (#637)
lesnik512 commentedon Oct 10, 2026
Done. modern-di-fastmcp 4.0.0 is on PyPI, from modern-python/modern-di-fastmcp#1, and the integration page landed in #637: https://modern-di.modern-python.org/integrations/fastmcp/.