# dishka-fastmcp

> dishka IoC container integration for FastMCP. Declare dependencies as
> `FromDishka[Service]` in MCP tools, resources and prompts; dishka resolves them
> per request with real scopes, finalization and modular providers, sharing one
> container with the rest of the application.

Install: `uv add dishka-fastmcp` (or `pip install dishka-fastmcp`). Requires
Python 3.11+, `dishka>=1.10.1`, `fastmcp>=4.0.0,<5`.

## Rules that matter

- **Put `@inject` BELOW the FastMCP decorator** (`@mcp.tool` / `@mcp.resource` /
  `@mcp.prompt`). `@inject` rewrites the signature to strip `FromDishka`
  parameters; FastMCP then builds the JSON schema from the cleaned signature, so
  injected services never appear as tool arguments. Wrong order leaks the service
  into the schema.
- **Call `setup_dishka(container, mcp)` once** before running the server.
- **Routers need no setup of their own.** Routers mounted into a server whose
  lifespan includes `dishka_lifespan(container)` use that container during the
  MCP requests the server serves (any depth, with or without `namespace`). A
  router mounted into several servers uses the serving server's container. A
  router with its own `setup_dishka` keeps its container, and so does one with
  its own `dishka_lifespan` if it is mounted before the server starts. Routers
  below it still take the serving server's, silently, so give them
  `setup_dishka` when they need the middle one. A direct `server.call_tool()`
  outside an MCP request does not reach mounted routers; test them through
  `Client(server)`.
- **Async handlers need `make_async_container`; sync handlers need
  `make_container`** (regular sync handlers run in a FastMCP worker thread).
  Mixing the two raises `DishkaFastMCPError`.
- **Scopes: `Scope.APP`** (server lifetime) and **`Scope.REQUEST`** (one tool
  call / resource read / prompt render, finalized when it ends). `Scope.SESSION`
  is intentionally unsupported: FastMCP has no session-teardown hook, so a
  session-lifetime scope cannot be finalized deterministically.
- **Close the root container on shutdown** to finalize `Scope.APP` providers:
  pass `dishka_lifespan(container)` to `FastMCP(lifespan=...)`. Compose this
  helper with an existing lifespan so shutdown also removes the container's
  application registration. If the container is shared with FastAPI, FastStream,
  a worker or a test session, list `mcp.http_app().lifespan` first in
  `combine_lifespans`, or pass
  `dishka_lifespan(container, finalize_container=False)` and close it where it
  is owned. Dishka re-creates APP dependencies on `get()` after
  `close()` without an error, so with a wrong order shutdown code silently opens
  new resources, which leak unless the container is closed again.
- **Sync `Scope.APP` dependencies must be thread-safe.** FastMCP may use different
  worker threads, and APP cleanup runs from the server lifespan. Put thread-affine
  resources in `Scope.REQUEST`, which is created and finalized in one worker.
- **`task=True` handlers are not supported.** They run in a `fastmcp-tasks`
  worker, outside the request that queued them, so injection there raises
  `DishkaFastMCPError`. Resolve dependencies inside the request and pass plain
  values to the task.
- **Return completed values from ordinary handlers.** Returning an awaitable,
  generator, or async generator is rejected because it would run after the
  handler returns, when the REQUEST scope may already be finalized. Tool
  handlers defined directly as generator functions remain supported for the
  whole iteration.
- **For a scope over the whole MCP request, add `DishkaMiddleware()` first** in
  `FastMCP(middleware=[...])`. Dynamic component providers, `FunctionTool`
  callables and middleware then resolve REQUEST dependencies with
  `await get_request_container().get(T)`, and async `@inject` handlers share
  the same instances, finalized once before the response goes out. FastMCP also
  lists providers outside requests (on startup), where `get_request_container()`
  raises; return no components there. It raises in completion and
  extension-method handlers as well. Sync handlers keep their own worker-thread
  scope, and a router set up with a different container keeps its own scopes
  and gets no request container.

## API

- `setup_dishka(container, app)` — associate a root container with a `FastMCP` app.
- `inject` — decorator binding `FromDishka[...]` parameters; auto-detects sync vs async.
- `FromDishka[T]` — mark a parameter for injection (re-exported from dishka).
- `FastMCPProvider()` — add to the container to inject the current request's
  `fastmcp.Context` or `fastmcp.FastMCP` via dishka's `from_context`. Operation
  arguments remain regular tool, resource, or prompt parameters.
- `dishka_lifespan(container, *, finalize_container=True)` — build a FastMCP
  `Lifespan` that registers the container, hands it to mounted routers and
  closes it (async or sync) on shutdown; `finalize_container=False` leaves
  closing to the container's owner. Compose it with `combine_lifespans`, or with
  `|` when the other lifespan is a FastMCP `@lifespan` function.
- `DishkaMiddleware()` — FastMCP middleware that opens one REQUEST scope per MCP
  request (list requests included) and shares it with async `@inject` handlers.
  Put it first; it needs an `AsyncContainer`.
- `get_request_container()` — the REQUEST `AsyncContainer` of the current MCP
  request, shared with async `@inject` handlers; raises `DishkaFastMCPError`
  outside the middleware's scope.
- `DishkaFastMCPError` — raised on integration misuse: a missing or wrong-kind
  container, a different container already registered for the app, a returned
  awaitable or generator, injection inside a `task=True` worker,
  `get_request_container()` outside a request scope, or `DishkaMiddleware` with
  a sync container.

## Usage

```python
from dishka import Provider, Scope, make_async_container, provide
from fastmcp import FastMCP

from dishka_fastmcp import FromDishka, dishka_lifespan, inject, setup_dishka


class Catalog:
    _prices = {'book': 12, 'pen': 2}

    def price(self, item: str) -> int:
        return self._prices.get(item, 0)


class AppProvider(Provider):
    catalog = provide(Catalog, scope=Scope.REQUEST)


container = make_async_container(AppProvider())
mcp = FastMCP('shop', lifespan=dishka_lifespan(container))
setup_dishka(container, mcp)


@mcp.tool
@inject
async def get_price(item: str, catalog: FromDishka[Catalog]) -> int:
    return catalog.price(item)  # client sees only `item` in the schema
```

## Links

- [Documentation](https://bagowix.github.io/dishka-fastmcp/)
- [Context7](https://context7.com/bagowix/dishka-fastmcp)
- [README](https://github.com/bagowix/dishka-fastmcp/blob/main/README.md)
- [Source](https://github.com/bagowix/dishka-fastmcp)
- [PyPI](https://pypi.org/project/dishka-fastmcp/)
- [dishka documentation](https://dishka.readthedocs.io/)
- [FastMCP documentation](https://gofastmcp.com/)
