Skip to content

feat: modern-di integration for FastMCP - #1

Merged
lesnik512 merged 2 commits into
mainfrom
initial-release
Oct 10, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
initial-release

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Implements modern-python/modern-di#462, following the design settled in the issue comment.

Summary

  • setup_di(server, container, *, manage_lifespan=True):
    • attaches the root container, registers fastmcp_context_provider, and adds a lifespan provider and the DI middleware;
    • the lifespan opens the container at startup and closes it with close_async() at shutdown;
    • with manage_lifespan=False, another app sharing the container owns that.
  • The middleware's on_request hook opens one request container per MCP request (tool call, resource read, prompt render, list call; not notifications) and holds it in a ContextVar.
  • FromDI(dependency) is a parameter default (a FastMCP Dependency):
    • FastMCP strips it from the schema and resolves it before moving a sync function to a worker thread;
    • it raises RuntimeError naming setup_di when no request container is active, which includes background tasks.
  • fetch_di_container(server) returns the root container.
  • Repo scaffolding is copied from modern-di-faststream: workflows, justfile, agent docs, triage labels.

Design decisions

  • FromDI is a parameter default, not Annotated metadata. FastMCP keeps an Annotated parameter in the schema, and its resolver never reads that metadata. FastMCP gives no hook when @mcp.tool runs, so misuse is rejected in the lifespan instead:
    • the lifespan lists the server's tools, resources, templates and prompts and raises TypeError before the first request;
    • a mounted server's components are proxies, and components added after startup are not listed, so neither is checked; the README says so;
    • recorded in docs/adr/0001-from-di-is-a-default-value.md.
  • The missing-container error is both a RuntimeError and a FastMCPError. FastMCP replaces any other error raised while resolving a dependency with "Failed to resolve dependency", which would hide the setup_di hint. It passes a FastMCPError through unchanged, so the hint reaches the client.
  • The startup check skips annotations it can't resolve. A TYPE_CHECKING-only import is one example: FastMCP accepts such tools, and the check must not break them.
  • Calling setup_di twice on one server raises. Otherwise the server would get a second middleware and a second lifespan.
  • The repo's ruff config adds FromDI to bugbear's extend-immutable-calls. The README shows users how to do the same, in place of # noqa: B008.

Test plan

  • just lint-ci, just test-ci, just test-branch (20 tests, 100% line and branch coverage), just adr-check
  • Floors: fastmcp 4.0.0 + modern-di 4.0.0 on Python 3.11, all tests pass
  • Code review (standards and spec axes). Extra probes passed:
    • 3.14t with the GIL off;
    • restart;
    • an error inside a tool, and cancellation, with finalizers still running;
    • 10 concurrent calls (async and sync) getting distinct request containers;
    • CurrentContext() and FastMCP Depends() mixed with FromDI;
    • mask_error_details=True.
  • Background tasks (task=True) are not exercised, since that needs fastmcp[tasks] and Docket. The error message covers them.

@lesnik512
lesnik512 merged commit b1e0209 into main Oct 10, 2026
12 checks passed
@lesnik512
lesnik512 deleted the initial-release branch October 10, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant