The MCP wire contract this package speaks. Stable across patch
versions; any breaking change is a major version bump and listed in
CHANGELOG.md.
The contract has two layers:
- JSON-RPC 2.0 — the envelope shape every request and response wears.
- MCP methods on top of JSON-RPC —
initialize,tools/list,tools/call. Plus the optional read-onlyGET /manifest/.
| URL (relative to your mount point) | Method | Purpose |
|---|---|---|
/ |
POST | The MCP JSON-RPC entry point. All MCP methods go through here. |
/manifest/ |
GET | Read-only catalogue document for humans and dashboards. |
Both endpoints enforce Django's session + CSRF auth and require an authenticated staff user. Per the SECURITY rules, no endpoint is CSRF-exempt.
| Caller | Code | Body |
|---|---|---|
| Anonymous (no session) | 401 | JSON-RPC error with code SERVER_ERROR_UNAUTHENTICATED (-32001). |
| Authenticated, non-staff | 403 | JSON-RPC error with code SERVER_ERROR_FORBIDDEN (-32002). |
| Authenticated staff | 200 | Normal flow; per-tool permission check still happens in rest-api. |
| Authenticated, no CSRF on POST | 403 | Django CSRF middleware response (HTML). |
Every request is a single JSON object (batch requests are not supported):
{
"jsonrpc": "2.0",
"id": <number | string | null>,
"method": "<method name>",
"params": { ... }
}A successful response:
{
"jsonrpc": "2.0",
"id": <echoed request id>,
"result": { ... }
}A failure response:
{
"jsonrpc": "2.0",
"id": <echoed request id or null>,
"error": {
"code": <integer>,
"message": "<string>",
"data": <optional, free-form>
}
}Failure responses use HTTP 400 (or 401 / 403 for the auth gate); the
error.code carries the precise signal.
Negotiates the protocol version and reports server capabilities.
Request params: none (any params are ignored).
Result:
{
"protocolVersion": "2024-11-05",
"serverInfo": { "name": "django-admin", "version": "<pkg version>" },
"capabilities": { "tools": { "listChanged": false } }
}protocolVersion and serverInfo.name are configurable via
DJANGO_ADMIN_MCP_API settings. capabilities.tools.listChanged is
false because the tool set is static (registered at import time).
Returns the same catalogue GET /manifest/ returns, in the
MCP-spec-correct shape.
The catalogue is static and not permission-filtered. Every
authenticated staff caller receives the identical tool list (the full
registered set minus DISABLED_TOOLS); it is not narrowed by the
caller's per-model ModelAdmin.has_*_permission. This is intentional
(#77):
- The catalogue lists capabilities (verbs like
admin.list,admin.destroy), not model instances. A singleadmin.destroytool applies to whatever model the caller has delete permission on; there is no per-model tool variant to hide. rest-api's per-user registry (GET /registry/, surfaced asadmin.registry) answers the which models can I touch? question on the correct axis. - Filtering the tool list against the registry would not produce a
meaningful narrowing (a verb either applies to all of a user's models
or none), and doing it any other way would mean re-deriving
permissions inside this layer — forbidden by the prime directive
(rest-api owns authorization; this package never calls
has_perm).
Presence in the catalogue is therefore not authorization. A tool
appearing in tools/list only means the server exposes that verb —
the actual permission check runs per call inside rest-api, which returns
the appropriate 4xx (surfaced as isError: true content) if the caller
lacks permission on the target model. Agents must not treat catalogue
presence as a grant. See threat-model.md for the security framing.
Request params: none.
Result:
{
"tools": [
{
"name": "admin.<verb>",
"description": "<one-line summary>",
"inputSchema": { "type": "object", ... }
},
...
]
}Invoke a tool. The tool's arguments are validated against its
inputSchema before forwarding to rest-api.
Request params:
{
"name": "<tool name>",
"arguments": { ... }
}Result on success:
{
"content": [
{ "type": "json", "json": <rest-api response body> }
],
"isError": false,
"status": <upstream HTTP status, usually 200>
}Result on upstream 4xx (e.g. rest-api 404 because the
app_label/model_name does not exist): the call still succeeds —
the agent got an answer — but the result content flags the upstream
failure:
{
"content": [{ "type": "json", "json": <rest-api error body> }],
"isError": true,
"status": <upstream HTTP status, 4xx>
}This distinction matters: a JSON-RPC error envelope means we could
not run the call (bad envelope, unknown method, schema violation). An
isError: true result content means rest-api ran the call and
returned a 4xx — the wire layer did its job.
The wire is a pure pass-through with one narrow, documented exception: the
custom-template discriminator. When a ModelAdmin renders a custom
template (a declared change_form_template / add_form_template, or a
change_view override that renders a hand-rolled page for the request),
rest-api 1.7.0's shared form-spec resolver returns
renderer: "html-fragment" — server-rendered HTML the SPA can show but an
MCP client can't introspect or drive.
Because there is no field map to inspect, the wire renames that single upstream signal — it does not re-detect anything:
admin.form_specreturns{ "renderer": "custom-template", "reason": "ModelAdmin override: change_form_template", "legacy_url": …, "spa_url": …, "machine_driveable": false }.legacy_urlis the upstreamsubmit_url;spa_urlis derived by reusing rest-api'smap_redirect_to_spa(the/admin/→SPA_URL_PREFIXswap).admin.form_submitresolves the form-spec first and, if it is acustom-templateform, refuses to submit (no fabricated field values, no forwarded POST), returning{ "ok": false, "reason": "custom-template", "message": "… not programmatically driveable …" }withisError: trueandstatus: 422.
The pre-1.7.0 legacy-iframe discriminator is dropped — MCP clients never
iframed anything, so it was never meaningful here. See
tools-reference.md for the full shape.
The catalogue is exposed via tools/list and GET /manifest/. The
mapping from MCP tool to rest-api endpoint is one-to-one and listed in
../django_admin_mcp_api/tools/README.md.
Every tool's inputSchema is a JSON Schema document; agents can read
the catalogue and self-correct their arguments without consulting the
docs. Schema dialect is Draft 2020-12.
Constants live in
../django_admin_mcp_api/server/errors.py.
| Code | Name | When |
|---|---|---|
| -32700 | PARSE_ERROR |
Request body is not valid JSON. |
| -32600 | INVALID_REQUEST |
Envelope shape is wrong (missing method, non-object, batch). |
| -32601 | METHOD_NOT_FOUND |
Unknown JSON-RPC method, or unknown tool in tools/call. |
| -32602 | INVALID_PARAMS |
Schema validation failed, or required argument missing. |
| -32603 | INTERNAL_ERROR |
Unhandled exception (should never happen — file a bug). |
| -32001 | SERVER_ERROR_UNAUTHENTICATED |
Caller is anonymous. |
| -32002 | SERVER_ERROR_FORBIDDEN |
Caller is authenticated but not staff. |
| -32003 | SERVER_ERROR_NOT_FOUND |
Reserved for future use. |
| -32004 | SERVER_ERROR_CSRF |
Reserved (Django middleware returns 403 first today). |
| -32005 | SERVER_ERROR_VALIDATION |
Reserved for future use. |
| -32099 | SERVER_ERROR_UPSTREAM |
NotImplementedError from the dispatcher (e.g. rest-api not installed). |
The contract follows semver:
- Patch (
0.1.x): bug fixes, doc tweaks, no surface changes. - Minor (
0.x.0): new tools, new optional fields on existing responses, new optional input fields. Backwards-compatible. - Major (
x.0.0): renaming or removing tools, changing the envelope shape, changing error code meanings, adding required arguments to existing tools.
Any breaking change is announced in CHANGELOG.md, paired with a
migration paragraph, and lands in a single PR labelled breaking.