Metadata-Version: 2.5
Name: django-ninja-mcp-server
Version: 0.1.0
Summary: Expose Django Ninja operations and Python functions as MCP tools for Claude Code, Codex and other MCP clients.
Project-URL: Source, https://github.com/mbaragiola/django-ninja-mcp-server
Project-URL: Issues, https://github.com/mbaragiola/django-ninja-mcp-server/issues
Author: Mariano Baragiola
License-Expression: BSD-3-Clause
License-File: LICENSE
Keywords: claude,codex,django,django-ninja,mcp,model context protocol
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Requires-Dist: django-ninja<2,>=1.7
Requires-Dist: django>=4.2
Requires-Dist: mcp<3,>=2.3
Provides-Extra: oauth
Requires-Dist: django-oauth-toolkit<4,>=3.4; extra == 'oauth'
Description-Content-Type: text/markdown

# django-ninja-mcp-server

Let MCP clients such as Claude Code and Codex call your Django project.

You pick which Django Ninja operations and Python functions to expose. Each one
becomes an MCP tool that runs as a real Django user, through the same auth,
validation and permission code as your HTTP API.

- **Local (stdio):** `manage.py mcp --user alice`. For a developer's own
  machine, with no tokens.
- **Remote (Streamable HTTP):** OAuth 2.1, with
  [django-oauth-toolkit](https://github.com/jazzband/django-oauth-toolkit)
  as the authorization server. Tokens must be issued for your MCP URL
  (RFC 8707), and each tool can require scopes.

Built on the official [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
2.x and [Django Ninja](https://django-ninja.dev) 1.7+.
Supports Python 3.10+ and Django 4.2+.

## Install

```bash
pip install django-ninja-mcp-server            # stdio only
pip install "django-ninja-mcp-server[oauth]"   # adds django-oauth-toolkit for HTTP
```

Add `"ninja_mcp"` to `INSTALLED_APPS` (for the `mcp` management command).

## Expose tools

```python
# myproject/mcp.py
from ninja_mcp import NinjaMCP, get_identity

from orders.api import api
from orders.models import Order

mcp = NinjaMCP("Shop", instructions="Tools for the shop's order data.")

# Django Ninja operations, by view name or operation ID.
mcp.add_operations(api, ["list_orders", "get_order"], scopes=["orders:read"])
mcp.add_operations(api, ["refund_order"], scopes=["orders:write"])


# Plain functions. Sync functions run in a worker thread, so the ORM works.
@mcp.tool(scopes=["orders:read"])
def count_open_orders() -> int:
    """Count the caller's open orders."""
    return Order.objects.filter(owner=get_identity().user, status="open").count()
```

```python
# settings.py
NINJA_MCP_SERVER = "myproject.mcp.mcp"
```

An operation must allow MCP calls in its `auth`:

```python
from ninja.security import django_auth
from ninja_mcp import MCPAuth


@api.get("/orders", response=list[OrderOut], auth=[django_auth, MCPAuth()])
def list_orders(request, status: str | None = None):
    return Order.objects.filter(owner=request.user)
```

`MCPAuth` matches only requests this package builds for a tool call; a real HTTP
request never passes it. During a tool call `request.user` is the caller and
`request.auth` is their `MCPIdentity` (`user`, `client_id`, `scopes`).

### What becomes a tool

- The tool name is the name you passed: a view function name, or an operation
  ID when view names clash. To choose other names, pass a mapping:
  `mcp.add_operations(api, {"orders": "list_orders"})`.
- Path and query parameters become top-level arguments. A JSON body becomes the
  `body` argument. Schemas come from Django Ninja's OpenAPI output.
- GET operations are marked read-only, DELETE destructive, PUT idempotent.
- A 4xx response becomes a tool error that carries the response body, so the
  model can read validation errors and correct itself. A 5xx response, an
  exception, a redirect or a binary body becomes a tool error with no internal
  detail; 5xx bodies go to the `ninja_mcp.operations` logger instead.
- Path parameters must match their URL converter (`int`, `slug`, `uuid`, and
  `str`, which refuses `/`), as Django's resolver would require.
- Unknown arguments are refused, and every tool schema says
  `additionalProperties: false`.

Some operations can't be exposed: those with header, cookie, form or file
parameters, streaming responses, several HTTP methods, or `auth` without
`MCPAuth`. `add_operations` raises `ImproperlyConfigured` for them at startup,
not on the first call.

Tool calls go to the operation directly, not through Django's URL resolver or
middleware. Do anything your views depend on (such as setting a tenant) in the
view or in an auth class. The request's scheme and host are those of
`resource_url`, or `http://localhost` over stdio, so `build_absolute_uri()`
works when that host is in `ALLOWED_HOSTS`.

### Scopes

A tool's `scopes` must all be in the caller's token. A caller without them does
not see the tool in `tools/list`, and calling it fails as if it didn't exist. A
tool with no scopes needs only a valid caller.

## Local use: Claude Code and Codex over stdio

```bash
python manage.py mcp --user alice                      # all tools
python manage.py mcp --user alice --scope orders:read  # only read tools
```

The command speaks MCP on stdin/stdout, and every call runs as `alice`. Anyone
who can run `manage.py` can already act as any user, so stdio takes no token.

Claude Code:

```bash
claude mcp add shop -- uv run python manage.py mcp --user alice
```

Codex:

```bash
codex mcp add shop -- uv run python manage.py mcp --user alice
```

Both clients start the command from their working directory. Run them from
your project root, or use absolute paths.

## Remote use: Streamable HTTP with OAuth

Give the server its public URL and the OAuth issuer:

```python
mcp = NinjaMCP(
    "Shop",
    resource_url="https://shop.example.com/mcp",
    issuer_url="https://shop.example.com",
)
```

Serve it from your ASGI entry point, next to Django:

```python
# myproject/asgi.py
from django.core.asgi import get_asgi_application

django_app = get_asgi_application()

from myproject.mcp import mcp  # noqa: E402
from ninja_mcp.asgi import MCPApplication  # noqa: E402

application = MCPApplication(mcp, django_app)
```

`MCPApplication` serves:

- `POST /mcp`: the MCP endpoint (stateless Streamable HTTP, JSON responses).
- `GET /.well-known/oauth-protected-resource/mcp`: RFC 9728 metadata naming
  your issuer and every scope your tools use.
- Everything else goes to Django.

The ASGI server must support the lifespan protocol, as uvicorn and hypercorn
do; without it the MCP endpoint answers 500. A WSGI site can run the same
`asgi.py` under uvicorn as a second process and
send `/mcp` and `/.well-known/oauth-protected-resource/` to it. Requests whose
`Host` is not the host of `resource_url` are refused (DNS rebinding
protection). If your proxy changes `Host`, pass `allowed_hosts=[...]`.

### Token checks

`DjangoOAuthTokenVerifier` accepts a django-oauth-toolkit access token only if
all of these hold:

- it is unexpired;
- its user is active;
- its `resource` includes `resource_url`. A token with no resource is refused;
  django-oauth-toolkit would accept it anywhere.

To check your own grants on every request (for example, a per-user allowlist
that admins can revoke), pass `authorize`:

```python
from ninja_mcp.oauth import DjangoOAuthTokenVerifier


def authorize(token):
    grant = Grant.objects.filter(
        user=token.user, application=token.application, active=True
    ).first()
    return grant.scopes if grant else None  # None refuses the token


mcp = NinjaMCP(
    "Shop",
    resource_url="https://shop.example.com/mcp",
    issuer_url="https://shop.example.com",
    token_verifier=DjangoOAuthTokenVerifier(
        "https://shop.example.com/mcp", authorize=authorize
    ),
)
```

### Authorization server settings

django-oauth-toolkit 3.4 has what MCP clients need. These settings let
Claude Code and Codex log in with no manual client setup:

```python
OAUTH2_PROVIDER = {
    "SCOPES": {"orders:read": "Read orders", "orders:write": "Change orders"},
    "DEFAULT_SCOPES": ["orders:read"],  # granted when a client asks for none
    "OIDC_ISS_ENDPOINT": "https://shop.example.com",  # the issuer: your origin
    "PKCE_REQUIRED": True,
    "COMPLIANT_BCP_RFC9700_PKCE_METHOD": True,  # S256 only
    "COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS": True,  # RFC 9207 `iss`
    "COMPLIANT_BCP_RFC9700_IMPLICIT_GRANT": True,
    "COMPLIANT_BCP_RFC9700_PASSWORD_GRANT": True,
    "ROTATE_REFRESH_TOKEN": True,
    "REFRESH_TOKEN_REUSE_PROTECTION": True,
    "ACCESS_TOKEN_EXPIRE_SECONDS": 3600,
    # CLI clients receive the code on http://localhost:<any port>/callback.
    "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"],
    "ALLOW_LOCALHOST_LOOPBACK": True,
    "OAUTH2_RESPONSE_TYPES_SUPPORTED": ["code"],
    "OAUTH2_GRANT_TYPES_SUPPORTED": ["authorization_code", "refresh_token"],
    "OAUTH2_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [
        "none",  # public clients; Codex needs it listed to use CIMD
        "client_secret_post",
        "client_secret_basic",
    ],
    # Client ID Metadata Documents: the client ID is a URL the client's
    # vendor publishes. Allow only the vendors you trust.
    "CIMD_ENABLED": True,
    "CIMD_REGISTRATION_PERMISSION_CLASSES": (
        "oauth2_provider.cimd.HostAllowlistCIMDPermission",
    ),
    "CIMD_ALLOWED_HOSTS": ["claude.ai", "chatgpt.com"],
}
```

```python
# urls.py
from oauth2_provider.urls import metadata_urlpatterns

urlpatterns = [
    # RFC 8414 metadata at the root, where every client looks.
    path("", include((metadata_urlpatterns, "oauth2_metadata"))),
    path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
    ...,
]
```

Then pass the same origin as the issuer: `NinjaMCP(..., issuer_url="https://shop.example.com")`.

Why each setting matters:

- **Issuer at the origin.** Claude Code documents only the root
  `/.well-known/oauth-authorization-server` path, and clients compare the
  `issuer` with the URL exactly.
- **RFC 9207 `iss`.** Without it, Codex adds a `/callback/<id>` path to its
  redirect URI, which a pre-registered client would not match.
- **Loopback redirects.** django-oauth-toolkit accepts any port on a port-less
  loopback redirect URI (RFC 8252); `ALLOW_LOCALHOST_LOOPBACK` extends that to
  `localhost`, which Claude Code uses.
- **CIMD.** Claude Code and Codex identify themselves by a metadata URL
  (`https://claude.ai/oauth/claude-code-client-metadata`,
  `https://chatgpt.com/oauth/codex/client.json`). Your server fetches it, so
  it needs outbound HTTPS to those hosts.

`tests/settings.py` uses exactly these settings, and `tests/test_oauth_flow.py`
runs the authorization code flow both clients use against them: S256 PKCE, a
loopback redirect on an arbitrary port, and `resource`. The tests do not fetch
the vendors' CIMD documents (that needs the network).

Connect the clients:

```bash
claude mcp add --transport http shop https://shop.example.com/mcp
codex mcp add shop --url https://shop.example.com/mcp && codex mcp login shop
```

Without CIMD, register a public client in the admin with the redirect URIs
`http://localhost/callback http://127.0.0.1/callback`, then pass its ID:
`claude mcp add ... --client-id <id>` or
`codex mcp add ... --oauth-client-id <id>`. django-oauth-toolkit also supports
dynamic client registration (`DCR_ENABLED`), but the MCP specification now
deprecates it in favour of CIMD, and open registration lets anyone create
clients.

Clients choose which scopes to request. Codex asks for the ones in the 401
challenge, which lists every scope your tools use. Claude Code does not ask for
any unless you pin them with `"oauth": {"scopes": "orders:read"}` in its MCP
config. A client that asks for none gets `DEFAULT_SCOPES`.

## Development

```bash
uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check .
```

## License

BSD 3-Clause, the same license as Django. See [LICENSE](LICENSE).
