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
-
FromDI form: 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. FromDI is used as
service: UserService = FromDI(...) with a scoped # noqa: B008 exemption; 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_parameters iterates
signature.parameters and keeps those where isinstance(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 the Annotated spelling generates ['name', 'svc'] and
then fails the call with ToolError, 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 its identifier is a required reverse-DNS string, validated at
registration and advertised under ServerCapabilities.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_provider also works on both 3.x and 4.x,
avoiding a fastmcp>=4 floor. lite-bootstrap reached the same conclusion independently and
recorded it in ADR 0001-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's add_extension arguably satisfies, so it reads as
fired until someone amends it.
-
What the ContextProvider binds to. The prototype binds MiddlewareContext.
fastmcp.Context would be more useful, but MiddlewareContext.fastmcp_context is
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 same FastMCP server is started
twice against one root container, given the lifecycle runs through Provider.lifespan() rather
than a composed lifespan.
-
close_async vs close_sync. FastMCP's lifespan is async, so close_async is the
expected match, but the spec asks for this to be stated rather than assumed.
Known constraints
- No
SESSION scope. No session-close hook exists, so it cannot be offered with deterministic
teardown. Scope table is APP + REQUEST.
pyproject dependency spelling must account for fastmcp being a metapackage over
fastmcp-slim in 4.x.
- Churn. Three majors in ~17 months, repo moved
jlowin to PrefectHQ, 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_message fires on every dispatch path in every transport. The prototype
exercised the in-memory client only, though on_message is documented as covering all MCP traffic.
- Behaviour under FastMCP background tasks (
fastmcp[tasks] / Docket), where a tool may run outside
a request and a REQUEST-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-fastmcp is free.
Ship
modern-di-fastmcpas a separate-repo integration, followingdocs/integrations/writing-integrations.md.Feasibility
Prototyped against
fastmcp==3.4.6andfastmcp==4.0.3, running unchanged on both.APPFastMCP.add_provider+Provider.lifespan()REQUESTadd_middleware+Middleware.on_messageInjection 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_parametersinfastmcp/server/dependencies.py, applied totools, 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:
APPopen/close, one sharedREQUESTcontainer 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
setup_di(app: FastMCP, container: Container) -> ContainerContextProvider, adds the lifespan provider and the DI middlewarefetch_di_container(app: FastMCP) -> ContainerFromDI(dependency: AbstractProvider[T] | type[T]) -> Tfastmcp.dependencies.Depends(...), used as a default, notAnnotatedThe client sees only
limit.Decisions needed before implementation
FromDIform: use the parameter-default spelling. Still wants an ADR in the integration'sown repo; one was drafted and dropped unmerged, so the reasoning lives here for now.
FromDIis used asservice: UserService = FromDI(...)with a scoped# noqa: B008exemption; theAnnotated[T, FromDI(...)]form the family mandates is not offered, because FastMCP detects themarker only as a default.
uncalled_for.introspection.get_dependency_parametersiteratessignature.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']andthen fails the call with
ToolError, missing required argument. It does not merely fail toinject, 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) lookedsemantically right, but its
identifieris a required reverse-DNS string, validated atregistration and advertised under
ServerCapabilities.extensions. Using one for a lifecyclecallback 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 a
fastmcp>=4floor.lite-bootstrapreached the same conclusion independently andrecorded it in ADR
0001-fastmcp-teardown-via-provider-lifespan.md; cross-reference it ratherthan re-deriving. Note that ADR's revisit trigger names "a documented public way to compose a
lifespan post-construction", which FastMCP 4's
add_extensionarguably satisfies, so it reads asfired until someone amends it.
What the
ContextProviderbinds to. The prototype bindsMiddlewareContext.fastmcp.Contextwould be more useful, butMiddlewareContext.fastmcp_contextisContext | 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 getsfinalizers wired to shutdown. Unaddressed: what happens when the same
FastMCPserver is startedtwice against one root container, given the lifecycle runs through
Provider.lifespan()ratherthan a composed lifespan.
close_asyncvsclose_sync. FastMCP's lifespan is async, soclose_asyncis theexpected match, but the spec asks for this to be stated rather than assumed.
Known constraints
SESSIONscope. No session-close hook exists, so it cannot be offered with deterministicteardown. Scope table is
APP+REQUEST.pyprojectdependency spelling must account forfastmcpbeing a metapackage overfastmcp-slimin 4.x.jlowintoPrefectHQ, DI engine swapped twice,sse_app()/streamable_http_app()removed in v4, and the engine underneath (uncalled-for) is at0.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
Middleware.on_messagefires on every dispatch path in every transport. The prototypeexercised the in-memory client only, though
on_messageis documented as covering all MCP traffic.fastmcp[tasks]/ Docket), where a tool may run outsidea request and a
REQUEST-scoped container would not exist.Context
Depends,Shared,Current*), so this pays off mainly for someone whoalready has a modern-di graph, or who wants one container shared across a FastAPI app and an MCP
server mounted beside it.
dishka-fastmcp,fastmcp-dishka,wireup,fastmcp-injector.modern-di-fastmcpis free.