Skip to main content
Glama
paritoshv

django-admin-mcp

by paritoshv
README.md
# django-admin-mcp

Give an AI agent **read-only access to your Django admin data over [MCP](https://modelcontextprotocol.io)** —
scoped to exactly what the signed-in staff user could already see in the admin. No new data surface,
just a new protocol over the one you already trust.

```
agent ──stdio──▶ shim ──HTTPS(x-api-key)──▶ /admin-mcp/ views ──▶ ModelAdmin (perms + queryset) ──▶ read replica
```

Every read is driven through the model's own `ModelAdmin`, so the agent sees precisely what that
user sees in `/admin/` — same permission checks, same row scoping, same search — with secrets
redacted and cost gates on every query.

## Why it's different

- **Permission-faithful by construction.** Access is gated by `ModelAdmin.has_view_permission`
  (model *and* object level), scoped by `ModelAdmin.get_queryset`, and searched via
  `ModelAdmin.get_search_results` — the exact calls the admin UI makes. A user with view rights on
  three models sees three models. There is no parallel permission system to keep in sync.
- **Zero-copy auth.** No API keys to mint, paste, or rotate. The MCP shim runs a loopback
  browser sign-in (OAuth-style PKCE): it opens `/auth/`, catches a one-time code on a
  `127.0.0.1` listener, and exchanges it for a **scoped, sliding-expiry token** stored per-host at
  `0600`. Expired token → the agent silently re-auths. The token authenticates to *this* endpoint
  only, so a leak can't reach any other API.
- **Safe to point at production.** Reads route to a configurable replica alias, run under a
  per-query statement timeout, never issue an unbounded `COUNT(*)`, refuse deep pagination, and are
  per-user rate-limited. Secret-looking fields (`password`, `token`, `api_key`, …, incl. keys
  nested in JSON columns) come back `***`. Whole models can be hidden by policy.
- **Drop-in.** One `include()` in your URLconf, a few optional settings, zero required
  dependencies beyond Django. Works with any MCP client (Claude Code, Codex, …) — the shim is
  stdlib-only.

## Install

```bash
pip install django-admin-mcp
```

```python
# settings.py
INSTALLED_APPS = [..., "admin_mcp"]
ADMIN_MCP_BASE_PATH = "/admin-mcp"          # must match where you mount the urls

# urls.py
from django.urls import include, path
urlpatterns = [
    path("admin/", admin.site.urls),
    path("admin-mcp/", include("admin_mcp.urls")),
]
```

That's the minimum. Then a staff user opens `https://<your-host>/admin-mcp/auth/` and copies the
one command it shows for their agent.

## Try it in 60 seconds

```bash
git clone https://github.com/paritoshv/django-admin-mcp && cd django-admin-mcp/example
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
# open http://127.0.0.1:8000/admin-mcp/auth/  → follow the one-liner it prints
```

The demo app ships an `Author`/`Book` model (with a redacted `api_key` field) and a `SecretRecord`
model that's hidden from MCP via `ADMIN_MCP_HIDDEN_MODELS` — so you can see scoping, redaction, and
model-hiding immediately.

## MCP tools

| Tool | Maps to | Notes |
|---|---|---|
| `admin_list_models` | which models you may view | discovery entry point |
| `admin_get_model_schema` | fields, relations, `search_fields`, `list_filter`, sortable fields | read this to know what's queryable |
| `admin_search` | the changelist | `query` needs `search_fields`; `filters` must be within `list_filter`; bounded count |
| `admin_get` | the change view | one row by pk, object-level permission enforced |
| `authenticate` | the loopback sign-in | usually automatic; call to sign in ahead of time |

## Endpoints

Mounted under `ADMIN_MCP_BASE_PATH`. `model` is `app_label.ModelName`.

| Path | Params | Auth | Returns |
|---|---|---|---|
| `models/` | – | staff | models the caller may view |
| `schema/` | `model` | staff | fields, relations, search/filter/sort surface |
| `search/` | `model`, `query?`, `filters?`, `order_by?`, `page?`, `page_size?` | staff | changelist rows + bounded count |
| `object/` | `model`, `pk` | staff | one object |
| `auth/` | `redirect_uri?`, `state?`, `code_challenge?` | browser session | setup page, or the loopback PKCE redirect |
| `auth/token/` | `code`, `code_verifier` (POST) | one-time PKCE code | exchanges a loopback code for a fresh token |
| `shim/` | – | staff | the stdio shim source (so setup is one `curl`) |

## Configuration

Everything is optional; defaults are safe.

| Setting | Default | Purpose |
|---|---|---|
| `ADMIN_MCP_ENABLED` | `True` | master on/off |
| `ADMIN_MCP_ENABLED_HOOK` | – | dotted path to `callable() -> bool` — a **runtime killswitch** (feature flag / infra toggle) with no deploy |
| `ADMIN_MCP_READONLY_DB` | `"default"` | DB alias reads route to; point at a replica in prod |
| `ADMIN_MCP_HIDDEN_MODELS` | `[]` | `"app.Model"` labels never served (and hidden from discovery) |
| `ADMIN_MCP_HIDDEN_MODELS_HOOK` | – | dotted path to `callable(model) -> bool` for dynamic hiding (e.g. an encrypted/PII data-classification router) |
| `ADMIN_MCP_CACHE_ALIAS` | `"default"` | Django cache backing tokens/codes/throttle — use a **shared Redis/Memcached** in production |
| `ADMIN_MCP_SECRET_MARKERS` | password, secret, token, api_key, … | substrings that mark a field for redaction |
| `ADMIN_MCP_SERVER_NAME` | `"admin-mcp"` | MCP server name on the setup page; scope per environment |
| `ADMIN_MCP_BASE_PATH` | `"/admin-mcp"` | where the app is mounted (advertised to the shim) |
| `ADMIN_MCP_PAGE_SIZE` / `_MAX_PAGE_SIZE` / `_COUNT_CAP` / `_STATEMENT_TIMEOUT_MS` | 25 / 100 / 1000 / 5000 | pagination + cost caps |
| `ADMIN_MCP_THROTTLE_AT` / `_THROTTLE_TIMEFRAME` | 120 / 60 | per-user rate limit |
| `ADMIN_MCP_TOKEN_TTL_SECONDS` / `_TOKEN_MAX_LIFETIME_SECONDS` / `_CODE_TTL_SECONDS` | 86400 / 604800 / 60 | token idle window, absolute lifetime, one-time-code TTL |

### Production notes

- Set `ADMIN_MCP_CACHE_ALIAS` to a **shared** cache (Redis/Memcached). The default LocMem cache is
  per-process, so tokens minted on one worker won't resolve on another.
- Point `ADMIN_MCP_READONLY_DB` at a read replica so agent reads never touch the primary.
- Wire `ADMIN_MCP_ENABLED_HOOK` to a feature flag if you want to kill the endpoint without a deploy.

## Security model

- **Auth:** a live admin **session**, or a scoped **token** in the `x-api-key` header. The token is
  a random opaque string; only its SHA-256 is stored. Idle 24h expiry that **slides on use**, plus
  a 7-day absolute cap. It resolves to an *active staff* user on every call, so deactivating the
  account or revoking staff invalidates it immediately, TTL notwithstanding.
- **Loopback PKCE refresh:** `/auth/` only ever redirects the one-time code to a validated
  `http://127.0.0.1|localhost` URI — never an external host — and the code (60s, single-use) is
  worthless without the PKCE verifier held by the shim that started the flow.
- **Redaction** runs *after* honouring each admin's `exclude`/`fields` whitelist — defence in
  depth, so even a mis-configured admin can't leak a secret column into an LLM context.
- **Never evaluates `list_display` callables** (they can trigger N+1s or side effects); serialises
  concrete fields only.
- **Audit trail:** one structured record per call (who/what/outcome/timing) on the `admin_mcp.audit`
  logger — never the response values, so the log isn't a second data-egress path.

## Tests

```bash
python runtests.py
```

## License

MIT