Skip to main content
Glama
Django Ninja MCP Server

Django Ninja MCP Server

by mbaragiola

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 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 2.x and Django Ninja 1.7+. Supports Python 3.10+ and Django 4.2+.

Install

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

Related MCP server: drf-mcp

Expose tools

# 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()
# settings.py
NINJA_MCP_SERVER = "myproject.mcp.mcp"

An operation must allow MCP calls in its auth:

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

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:

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

Codex:

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:

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:

# 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:

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:

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"],
}
# 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:

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

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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    F
    maintenance
    Integrates MCP tool hosting into Django applications, enabling easy definition and serving of MCP tools, resources, and prompts via ASGI with support for URL path parameters and logging.
    743 PyPI
    72
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Exposes Django REST Framework APIs as MCP tools for AI agents via the Model Context Protocol, with automatic discovery and security.
    12 PyPI
    1
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Introspects a Django REST API's OpenAPI schema and exposes each endpoint as an MCP tool, enabling Claude or any MCP client to interact with the API via natural language.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    A Django-native MCP application and tool registration framework inspired by Celery, providing async tool definitions, autodiscovery, and stdio/Streamable HTTP serving built on the official MCP Python SDK.
    2
    MIT