Skip to content

Add modern-di-fastmcp integration #462

Description

@lesnik512

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

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions