Implementing MCP (Model Context Protocol) in Django: giving AI agents secure, type-safe access to your ORM.
dj-mcp exposes your Django data to AI agents (Claude, custom MCP clients, etc.) over the
Model Context Protocol with tenant isolation, capability-based API keys, per-tool
auditing, and rate limiting.
- Architecture
- Quickstart
- Demo data + first API key
- MCP endpoints
- Available tools
- Calling the server
- Connecting an AI agent
- Management commands
- Admin UI
- Project layout
- Configuration reference
- Production notes
┌────────────────────────────┐
AI agent │ Anthropic SDK / MCP client │
└─────────────┬──────────────┘
│ JSON-RPC over HTTP or SSE
▼
┌─────────────────────────────────────┐
│ Django (ASGI/WSGI) │
│ mcp_server/views.py │
│ - Bearer auth (sync_to_async) │
│ - rate limit (Redis/LocMem) │
├─────────────────────────────────────┤
│ mcp_server/protocol.py │
│ JSON-RPC: initialize / list / call│
├─────────────────────────────────────┤
│ mcp_server/tools.py (ToolRegistry) │
│ BaseTool + capability filter │
├─────────────────────────────────────┤
│ student_tools.py / write_tools.py │
│ lookup_student, get_student_usage,│
│ list_at_risk_students, add_note │
├─────────────────────────────────────┤
│ students/models + analytics/models │
│ Django ORM (PostgreSQL) │
└─────────────────────────────────────┘
Key properties:
- Tenant isolation is enforced by the framework, not the agent. Every tool query
is automatically scoped with
tenant_id=tenant_id. The agent cannot override it. - Capability-scoped keys. Each
MCPApiKeycarries a list of capabilities (read:students,read:analytics,update:students,create:notes,delete:data,admin). Tools are only visible to a key that has the matching capability. - Per-call audit. Every
tools/callwrites anMCPApiKeyUsagerow with status, duration, arguments, request id, and error message (if any). - Rate limited. Per-minute and per-day limits enforced by the key's
requests_per_minute/requests_per_dayfields. - Raw key never stored. Only the SHA-256 hash is persisted; the raw key is shown once at creation time.
- Python 3.13+
- Docker (for PostgreSQL and, optionally, Redis)
- Or a local PostgreSQL + Redis if you prefer not to use Docker
git clone <your-repo-url> dj-mcp
cd dj-mcp
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install -e ..env is already provided with sensible defaults for local development:
DJANGO_SECRET_KEY=django-insecure-...
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
POSTGRES_DB=dj_mcp
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost # use "db" when running web inside docker-compose
POSTGRES_PORT=5432
DJANGO_PORT=8000
CORS_ALLOW_ALL_ORIGINS=True
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Optional Redis for rate limiting (defaults to LocMemCache if unset)
# DJANGO_CACHE_BACKEND=django.core.cache.backends.redis.RedisCache
# REDIS_URL=redis://127.0.0.1:6379/1docker compose up -d dbpython manage.py migrate
python manage.py runserver 0.0.0.0:8000docker compose up --buildThis starts db (Postgres 17) and web (Django), applies migrations, and serves
on http://localhost:8000.
A single command seeds 10 students, 30 days of usage metrics per student, sample notes, and a demo MCP API key with all non-admin capabilities:
python manage.py seed_data --tenant-id 1 --resetSample output:
Cleared existing data for tenant 1.
Created MCP API Key (save it - shown once):
mcp_gWPgNzYtnmNavE5m6eo6a4tq2ejZxywt1-Na_JdU0Jw
[OK] Seed complete: 10 students, 300 usage metrics, sample notes.
Save the raw key — it is never shown again. You'll pass it as
Authorization: Bearer <your-key> on every MCP request.
If you lose the key, re-run
seed_data --reset(it will clear and re-seed) or create a new key withpython manage.py create_mcp_key(see below).
All endpoints are mounted under /mcp/.
| Method | URL | Purpose |
|---|---|---|
| GET | /mcp/health/ |
Unauthenticated health check. |
| POST | /mcp/message/ |
HTTP transport — one JSON-RPC request per request. |
| GET | /mcp/sse/ |
SSE transport — opens a stream, returns session id. |
| POST | /mcp/sse/?session=<id> |
SSE transport — send JSON-RPC over a session. |
Every MCP request must include:
Authorization: Bearer mcp_<your-token>
Missing/invalid tokens return 401. Rate-limit violations return 429 with
Retry-After: 60.
| Method | Params | Description |
|---|---|---|
initialize |
none | Returns protocol version + server info. |
tools/list |
none | Lists tools the API key can call. |
tools/call |
name, arguments |
Invokes a tool; returns content blocks. |
resources/list |
none | Lists accessible resources. |
ping |
none | Health probe. |
Each tool is a subclass of BaseTool in mcp_server/tools.py. They are
auto-registered via the @ToolRegistry.register decorator.
Look up a student by name or email.
{
"query": "ada",
"match_type": "partial", // or "exact"
"limit": 5 // 1..20
}Returns: id, name, email, plan, mrr, health_score, status
(active / churned), enrollment_date.
Aggregate API call / DAU / feature-adoption metrics for a student over N days.
{ "student_id": 1, "days": 30 }Returns: api_calls, avg_dau, active_features.
List students whose health_score is below a threshold, sorted by severity.
{
"health_score_below": 60,
"limit": 20,
"include_churned": false
}Returns: id, name, health_score, mrr, last_active.
Append a typed note to a student's record. The note is automatically flagged
created_by_agent=True and tenant-scoped.
{
"student_id": 1,
"note": "Renewal conversation went well; customer open to upgrade.",
"note_type": "opportunity" // observation | action_taken | risk_flag | opportunity | general
}Returns: note_id, student, note_type, created: true.
| Tool | Required capability | Read-only? |
|---|---|---|
lookup_student |
read:students |
yes |
get_student_usage |
read:analytics |
yes |
list_at_risk_students |
read:analytics |
yes |
add_student_note |
create:notes |
no |
The admin capability implicitly grants all of the above plus update:students
and delete:data.
KEY="mcp_gWPgNzYtnmNavE5m6eo6a4tq2ejZxywt1-Na_JdU0Jw"
# initialize
curl -s http://127.0.0.1:8000/mcp/message/ \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# list tools
curl -s http://127.0.0.1:8000/mcp/message/ \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# call a tool
curl -s http://127.0.0.1:8000/mcp/message/ \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"lookup_student",
"arguments":{"query":"ada","match_type":"partial"}}}'Sample response (tools/call):
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{"type": "text", "text": "{\"students\": [...]}"}],
"isError": false
}
}# 1. Open stream in one terminal — note the session id in the X-Session-ID header
curl -N -H "Authorization: Bearer $KEY" http://127.0.0.1:8000/mcp/sse/
# 2. Send messages using the session id returned in step 1
curl -s "http://127.0.0.1:8000/mcp/sse/?session=<SESSION_ID>" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'agent_example.py shows the canonical pattern using the Anthropic Python SDK
with the MCP client beta:
import os, anthropic
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
response = client.beta.messages.create(
model="claude-opus-4-20250514",
max_tokens=2048,
mcp_servers=[
{
"type": "url",
"url": "http://127.0.0.1:8000/mcp/sse/",
"name": "django-crm",
"api_key": os.environ["MCP_API_KEY"], # the mcp_... key from seed_data
}
],
messages=[
{"role": "user",
"content": "List students with health scores below 50 and suggest an outreach plan."}
],
betas=["mcp-client-2025-04-04"],
)
print(response.content)Run it with:
export ANTHROPIC_API_KEY=sk-ant-...
export MCP_API_KEY=mcp_gWPgNzYtnmNavE5m6eo6a4tq2ejZxywt1-Na_JdU0Jw
python agent_example.pyThe agent will:
initializeagainst your Django server,tools/listto see what's available,- call
list_at_risk_students(and possiblylookup_student/get_student_usage) to ground its answer, - produce a final response that your app can render.
python manage.py seed_data [--tenant-id N] [--reset]
Creates demo data and a demo API key for the given tenant. --reset clears
existing data for that tenant first.
python manage.py create_mcp_key \
--tenant-id 1 \
--name "Production Agent" \
--capabilities read:students read:analytics create:notes \
--tools lookup_student get_student_usage # optional explicit allowlist
--rate-limit 120 \
--description "Agent used in production support bot"
Prints the raw key once. Valid capability values are listed in
mcp_server/models.py::MCPCapability.
migrate, makemigrations, createsuperuser, runserver, shell,
dumpdata, loaddata all work as normal.
The Django admin is enabled at /admin/. Create a superuser:
python manage.py createsuperuserThen log in at http://localhost:8000/admin/. Available admin pages:
- Students (
students.Student,students.StudentNote) - Analytics (
analytics.UsageMetric) - MCP / Api keys (
mcp_server.MCPApiKey) - MCP / Api key usage (
mcp_server.MCPApiKeyUsage) — fully read-only audit log of every tool call.
From the API key admin you can:
- toggle
is_active - edit capabilities (checkbox widget)
- adjust
requests_per_minute/requests_per_day - set
expires_at - inspect
last_used_at(read-only)
dj-mcp/
├── agent_example.py # Anthropic SDK demo
├── docker-compose.yml # Postgres + Django
├── Dockerfile
├── pyproject.toml
├── manage.py
├── project/
│ ├── settings.py # INSTALLED_APPS, CACHES, REST_FRAMEWORK
│ ├── urls.py # /admin/ + /mcp/ includes
│ ├── asgi.py / wsgi.py
├── mcp_server/ # the MCP server itself
│ ├── models.py # MCPApiKey, MCPApiKeyUsage, MCPCapability
│ ├── tools.py # BaseTool, ToolRegistry, ToolResult
│ ├── protocol.py # JSON-RPC handler
│ ├── views.py # HTTP + SSE views (auth, rate limit)
│ ├── urls.py # /mcp/health/ /mcp/message/ /mcp/sse/
│ ├── admin.py
│ ├── student_tools.py # read tools
│ ├── write_tools.py # write tools
│ └── management/commands/
│ ├── create_mcp_key.py
│ └── seed_data.py
├── students/ # multi-tenant Student + StudentNote models
│ ├── models.py
│ ├── admin.py
│ └── migrations/0001_initial.py
├── analytics/ # UsageMetric model
│ ├── models.py
│ ├── admin.py
│ └── migrations/0001_initial.py
└── .env
All settings are env-driven (read in project/settings.py).
| Variable | Default | Notes |
|---|---|---|
DJANGO_SECRET_KEY |
insecure fallback | Set a real key in production. |
DJANGO_DEBUG |
True |
Set False in production. |
DJANGO_ALLOWED_HOSTS |
localhost,127.0.0.1 |
Comma-separated. |
POSTGRES_DB |
dj_mcp |
|
POSTGRES_USER |
postgres |
|
POSTGRES_PASSWORD |
postgres |
|
POSTGRES_HOST |
localhost (or db in compose) |
|
POSTGRES_PORT |
5432 |
|
DJANGO_PORT |
8000 |
Used by agent_example.py. |
CORS_ALLOW_ALL_ORIGINS |
True |
Lock down in production. |
CORS_ALLOWED_ORIGINS |
http://localhost:3000,127.0.0.1:3000 |
|
DJANGO_CACHE_BACKEND |
LocMemCache |
Set to django.core.cache.backends.redis.RedisCache for production. |
REDIS_URL |
mcp-cache-default |
Redis connection string (location). |
ANTHROPIC_API_KEY |
— | Only needed by agent_example.py. |
MCP_API_KEY |
— | Only needed by agent_example.py. |
- Use ASGI. Run behind an ASGI server (e.g.
daphne,uvicornwith--workers, orgunicorn -k uvicorn.workers.UvicornWorker) so async views and the SSE streaming endpoint perform well:pip install daphne daphne -b 0.0.0.0 -p 8000 project.asgi:application
- Use Redis for caching so rate limiting is atomic and shared across workers:
Add a
DJANGO_CACHE_BACKEND=django.core.cache.backends.redis.RedisCache REDIS_URL=redis://redis:6379/1
redisservice todocker-compose.ymlaccordingly. - Rotate keys by setting
is_active=Falseon old keys and minting new ones viacreate_mcp_key. Old raw keys are unrecoverable — agents must be updated. - Audit. Periodically inspect
MCPApiKeyUsage(admin or DB) to find unusual call patterns, denied calls, or slow tools. - Per-tool allowlists. Use
--tools ...oncreate_mcp_keyto grant a key the principle of least privilege even within broader capability tags. - Secrets. Never commit a real
.envwith production secrets. Use a secrets manager (Azure Key Vault, AWS Secrets Manager, Doppler, etc.).
See LICENSE.