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).
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
bodyargument. 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.operationslogger instead. - Path parameters must match their URL converter (
int,slug,uuid, andstr, 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
resourceincludesresource_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-serverpath, and clients compare theissuerwith 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_LOOPBACKextends that tolocalhost, 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.
Metadata
Release files for django-ninja-mcp-server 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_ninja_mcp_server-0.1.0.tar.gz | 26.4 kB | With provenance | Details for django_ninja_mcp_server-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_ninja_mcp_server-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for django_ninja_mcp_server-0.1.0-py3-none-any.whl |
Total release size: 48.2 kB
Release files / django_ninja_mcp_server-0.1.0.tar.gz
| Download URL | django_ninja_mcp_server-0.1.0.tar.gz |
|---|---|
| Size | 26.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cd041a59010afc1828c9f562aab8e6c8a7a2e4d7fcb43c03687fe3c5891a9a88
|
|
BLAKE2b-256 checksum How to use checksums |
be4297f8bf9888a9046fc5e6cd93fe2ce89898085272259c9f2e3d21bd19d76e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 5, 2026.
Transparency logRelease files / django_ninja_mcp_server-0.1.0-py3-none-any.whl
| Download URL | django_ninja_mcp_server-0.1.0-py3-none-any.whl |
|---|---|
| Size | 21.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a337d24d240f8f57ab30d186574d273fe0ad6c07e8cf4b4d783c9108273df625
|
|
BLAKE2b-256 checksum How to use checksums |
611b1892acf52378e0405fbc7b693457c5940a31a87921e4b45c740d48d80e2e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 5, 2026.
Transparency logSecurity for django-ninja-mcp-server 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for django_ninja_mcp_server-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 5, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.