-
-
Notifications
You must be signed in to change notification settings - Fork 5
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.