django-admin-fastmcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_admin_fastmcp-0.1.0/.gitignore +41 -0
- django_admin_fastmcp-0.1.0/CHANGELOG.md +30 -0
- django_admin_fastmcp-0.1.0/LICENSE +21 -0
- django_admin_fastmcp-0.1.0/PKG-INFO +276 -0
- django_admin_fastmcp-0.1.0/README.md +240 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/__init__.py +3 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/admin.py +59 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/apps.py +11 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/auth.py +135 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/checks.py +121 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/conf.py +63 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/errors.py +24 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/exposure.py +98 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/management/__init__.py +0 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/management/commands/__init__.py +0 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/management/commands/admin_mcp_serve.py +49 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/migrations/0001_initial.py +117 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/migrations/__init__.py +0 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/models.py +205 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/oauth.py +304 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/request.py +58 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/serialize.py +93 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/server.py +88 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/templates/django_admin_fastmcp/authorize.html +28 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/__init__.py +63 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/actions.py +69 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/introspect.py +120 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/lookup.py +42 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/read.py +144 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/write.py +181 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/urls.py +29 -0
- django_admin_fastmcp-0.1.0/django_admin_fastmcp/well_known_urls.py +20 -0
- django_admin_fastmcp-0.1.0/pyproject.toml +167 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Env config
|
|
2
|
+
.envrc
|
|
3
|
+
.envrc.make.tmp
|
|
4
|
+
.env
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
.python-version
|
|
10
|
+
|
|
11
|
+
# Django
|
|
12
|
+
*.log
|
|
13
|
+
*.sqlite3
|
|
14
|
+
*.sqlite3-journal
|
|
15
|
+
|
|
16
|
+
# Testing and coverage
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
.coverage.*
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# Virtual environments
|
|
23
|
+
.venv/
|
|
24
|
+
|
|
25
|
+
# Type checkers and linters
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
|
|
29
|
+
# IDEs
|
|
30
|
+
.vscode/
|
|
31
|
+
.idea/
|
|
32
|
+
*.sublime-*
|
|
33
|
+
.dir-locals.el
|
|
34
|
+
|
|
35
|
+
# macOS
|
|
36
|
+
.DS_Store
|
|
37
|
+
|
|
38
|
+
# Distribution
|
|
39
|
+
build/
|
|
40
|
+
dist/
|
|
41
|
+
*.egg-info/
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- One permission system. The `write_via_mcp` permission and the
|
|
13
|
+
`admin:write` scope are gone. Writes are gated by `WRITABLE_MODELS`
|
|
14
|
+
(deployment-level, bans whole models such as an event log) and by the
|
|
15
|
+
user's own admin permissions, nothing else. Every grant carries the
|
|
16
|
+
single `admin` scope.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- The OAuth 2.1 authorization server: dynamic client registration, PKCE
|
|
21
|
+
consent flow riding the admin session, token exchange with refresh
|
|
22
|
+
rotation, and revocation.
|
|
23
|
+
- The token verifier and `RemoteAuthProvider` wiring for the MCP server.
|
|
24
|
+
- The eleven tools: `list_models`, `describe_model`, `search_objects`,
|
|
25
|
+
`get_object`, `object_history`, `recent_actions`, `create_object`,
|
|
26
|
+
`update_object`, `delete_object`, `run_action`, and `autocomplete`.
|
|
27
|
+
- Exposure filtering, field redaction, admin-metadata serialization, the
|
|
28
|
+
synthetic request with a message sink, system checks, and the
|
|
29
|
+
`admin_mcp_serve` command.
|
|
30
|
+
- The permission-matrix test suite from `SPEC.md` section 11.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eddy Ernesto del Valle Pino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-admin-fastmcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expose the Django admin as an MCP server, gated by each user's own admin permissions
|
|
5
|
+
Project-URL: Homepage, https://github.com/edelvalle/django-admin-fastmcp
|
|
6
|
+
Project-URL: Repository, https://github.com/edelvalle/django-admin-fastmcp.git
|
|
7
|
+
Project-URL: Issues, https://github.com/edelvalle/django-admin-fastmcp/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/edelvalle/django-admin-fastmcp/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Eddy Ernesto del Valle Pino <eddy@edelvalle.me>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: admin,agents,ai,django,fastmcp,mcp,model-context-protocol
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Framework :: Django :: 6.0
|
|
20
|
+
Classifier: Intended Audience :: Developers
|
|
21
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Programming Language :: Python
|
|
24
|
+
Classifier: Programming Language :: Python :: 3
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
29
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
30
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
31
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
32
|
+
Requires-Python: >=3.11
|
|
33
|
+
Requires-Dist: django>=5.0
|
|
34
|
+
Requires-Dist: fastmcp<4,>=3.4
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# django-admin-fastmcp
|
|
38
|
+
|
|
39
|
+
A reusable Django app that exposes the Django admin as an MCP server, built on
|
|
40
|
+
[FastMCP](https://gofastmcp.com).
|
|
41
|
+
|
|
42
|
+
Every tool call runs as the staff user who owns the bearer token. Every tool call asks the
|
|
43
|
+
`ModelAdmin` for permission first. A superuser can do everything a superuser can do in the
|
|
44
|
+
admin. A staff user can do exactly what that staff user can do in the admin, and nothing
|
|
45
|
+
more.
|
|
46
|
+
|
|
47
|
+
`SPEC.md` is the full specification.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
Three rules define the package:
|
|
52
|
+
|
|
53
|
+
1. **No parallel permission system.** Authorization delegates to the `ModelAdmin` methods:
|
|
54
|
+
`has_view_permission`, `has_add_permission`, `has_change_permission`,
|
|
55
|
+
`has_delete_permission`, `get_queryset`, `get_readonly_fields`, and `get_actions`.
|
|
56
|
+
A `get_queryset` override that hides rows hides them from MCP too.
|
|
57
|
+
2. **No parallel data surface.** Writes go through the admin's own `ModelForm` and
|
|
58
|
+
`save_model`, then record a `LogEntry`. The admin history page stays truthful.
|
|
59
|
+
3. **Fail closed.** Every unresolved lookup, missing `ModelAdmin`, unknown action, unknown
|
|
60
|
+
field, and unknown tool denies the call.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv add django-admin-fastmcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add the app to your settings:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
INSTALLED_APPS = [
|
|
72
|
+
...,
|
|
73
|
+
"django.contrib.admin",
|
|
74
|
+
"django_admin_fastmcp",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
ADMIN_FASTMCP = {
|
|
78
|
+
"SERVER_NAME": "acme-admin",
|
|
79
|
+
"EXCLUDE_MODELS": ("auth.Permission", "auth.Group"),
|
|
80
|
+
"WRITABLE_MODELS": (), # empty means no writes at all
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Mount the OAuth endpoints on the same site as the admin:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# urls.py
|
|
88
|
+
urlpatterns = [
|
|
89
|
+
# RFC 8414 fixes this one at the site root.
|
|
90
|
+
path("", include("django_admin_fastmcp.well_known_urls")),
|
|
91
|
+
# This prefix is yours to choose. Match it to the path in MCP_URL.
|
|
92
|
+
path("admin/mcp/", include("django_admin_fastmcp.urls")),
|
|
93
|
+
path("admin/", admin.site.urls),
|
|
94
|
+
]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The package hardcodes no prefix. Every URL the metadata document advertises comes
|
|
98
|
+
from `reverse()`, so a project that mounts the endpoints at `/backoffice/oauth/`
|
|
99
|
+
gets that in discovery and clients follow it. Two rules: the well-known document
|
|
100
|
+
belongs at the root, because a client derives its URL from the issuer, and the
|
|
101
|
+
endpoints should sit on the same site as the admin, because the consent page
|
|
102
|
+
rides the admin session cookie.
|
|
103
|
+
|
|
104
|
+
Apply the migrations:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python manage.py migrate django_admin_fastmcp
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Nothing else. No per-model registration, no mixin, no decorators. The server exposes
|
|
111
|
+
whatever the admin already exposes.
|
|
112
|
+
|
|
113
|
+
## Connect a client
|
|
114
|
+
|
|
115
|
+
Run the server (see [Deployment](#deployment)), then register it:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Claude Code
|
|
119
|
+
claude mcp add --transport http acme-admin https://<host>/admin/mcp
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Use the path with no trailing slash. `/admin/mcp/` answers a 307 redirect to
|
|
123
|
+
`/admin/mcp`, and not every client follows a redirect on POST.
|
|
124
|
+
|
|
125
|
+
No token, no header. The first call starts the standard MCP OAuth flow:
|
|
126
|
+
|
|
127
|
+
1. The client opens your browser at the authorize page on the Django site.
|
|
128
|
+
2. Your admin session cookie identifies you. If you are logged out, the normal admin
|
|
129
|
+
login appears first.
|
|
130
|
+
3. A consent page shows the client name and what approval means. You approve.
|
|
131
|
+
4. The client receives its tokens and connects. It refreshes them by itself.
|
|
132
|
+
|
|
133
|
+
Any MCP client that speaks streamable HTTP with OAuth works the same way, for example
|
|
134
|
+
Cursor or a FastMCP `Client`.
|
|
135
|
+
|
|
136
|
+
Rules around access:
|
|
137
|
+
|
|
138
|
+
- Any staff user can authorize a client, for themselves only.
|
|
139
|
+
- A grant acts with your own admin permissions, never more. There is no separate
|
|
140
|
+
permission system: whoever may change a model in the admin may change it over MCP,
|
|
141
|
+
when the server lists that model in `WRITABLE_MODELS`.
|
|
142
|
+
- Refresh tokens expire after `REFRESH_TOKEN_TTL_DAYS` (default 90), so re-consent
|
|
143
|
+
happens that often. Revocation is an admin action on the grant changelist.
|
|
144
|
+
|
|
145
|
+
## Tools
|
|
146
|
+
|
|
147
|
+
Eleven generic tools, mounted under the namespace `admin`, so wire names are
|
|
148
|
+
`admin_list_models` and so on. Each takes `model` as `"app_label.ModelName"`. The tool
|
|
149
|
+
list is static. What varies per user is what each tool lets that user see and do.
|
|
150
|
+
|
|
151
|
+
### Read
|
|
152
|
+
|
|
153
|
+
| Tool | Arguments | Returns |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `list_models` | none | Every exposed model this caller may view, with permission flags. |
|
|
156
|
+
| `describe_model` | `model` | Fields, list display, filters, search fields, readonly fields, and available actions. |
|
|
157
|
+
| `search_objects` | `model`, `q`, `filters`, `order_by`, `page`, `page_size` | Rows plus `total`. `q` uses the admin's own search. An unknown filter is an error. |
|
|
158
|
+
| `get_object` | `model`, `pk` | One serialized instance. |
|
|
159
|
+
| `object_history` | `model`, `pk` | Admin log entries for that object, newest first. |
|
|
160
|
+
| `recent_actions` | `limit` | Admin log entries, scoped to the caller unless the caller is a superuser. |
|
|
161
|
+
|
|
162
|
+
### Write
|
|
163
|
+
|
|
164
|
+
Write tools need the model listed in `WRITABLE_MODELS`; your own admin permissions
|
|
165
|
+
decide the rest, per model and per object. A model outside the list refuses every write
|
|
166
|
+
and every action, whoever calls. Leave sensitive models out, an event log for example,
|
|
167
|
+
and no MCP client can ever write to them.
|
|
168
|
+
|
|
169
|
+
| Tool | Arguments | Behavior |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `create_object` | `model`, `data` | Validates through the admin form, then saves and logs. |
|
|
172
|
+
| `update_object` | `model`, `pk`, `data` | Partial update. Readonly fields are ignored. |
|
|
173
|
+
| `delete_object` | `model`, `pk`, `confirm` | Without `confirm`, returns the exact deletion cascade and changes nothing. |
|
|
174
|
+
| `run_action` | `model`, `action`, `pks`, `confirm` | Runs an admin action. Without `confirm`, returns a preview. |
|
|
175
|
+
| `autocomplete` | `model`, `field`, `q` | Resolves a foreign-key value to a primary key by searching the related model. |
|
|
176
|
+
|
|
177
|
+
Every returned row carries `pk` as a string and an `admin_url`, so an agent can hand a
|
|
178
|
+
person a link into the real admin.
|
|
179
|
+
|
|
180
|
+
## Settings
|
|
181
|
+
|
|
182
|
+
All keys live in the `ADMIN_FASTMCP` dict. An unknown key is an error at startup.
|
|
183
|
+
|
|
184
|
+
| Key | Default | Meaning |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| `SERVER_NAME` | `"django-admin"` | Name the MCP server advertises. |
|
|
187
|
+
| `ADMIN_SITE` | `"django.contrib.admin.site"` | Dotted path to the `AdminSite`. |
|
|
188
|
+
| `MODELS` | `()` | Allowlist of `"app_label.ModelName"`. When non-empty, nothing else is exposed. |
|
|
189
|
+
| `EXCLUDE_MODELS` | `()` | Denylist. Supports `"app_label.*"`. |
|
|
190
|
+
| `WRITABLE_MODELS` | `()` | Models that accept writes. Empty means no writes, whoever calls. |
|
|
191
|
+
| `DISABLED_TOOLS` | `()` | Tool names removed from the catalogue entirely. |
|
|
192
|
+
| `REDACT_FIELDS` | `("password", "token", "secret", "api_key", "private_key")` | Substring match on field names. Values read `"[redacted]"`. |
|
|
193
|
+
| `MAX_PAGE_SIZE` | `200` | Cap on `search_objects` page size. |
|
|
194
|
+
| `MAX_PKS` | `1000` | Cap on `pks` per `run_action`. |
|
|
195
|
+
| `ACCESS_TOKEN_TTL_MINUTES` | `60` | Access token lifetime. Clients renew with the refresh token. |
|
|
196
|
+
| `REFRESH_TOKEN_TTL_DAYS` | `90` | Refresh token lifetime. Re-consent happens this often. |
|
|
197
|
+
| `SITE_URL` | `"http://127.0.0.1:8000"` | Public URL of the Django site. It is the OAuth issuer, and the MCP server names it as its authorization server. |
|
|
198
|
+
| `MCP_URL` | `"http://127.0.0.1:8765/admin/mcp"` | Public URL of the MCP endpoint. |
|
|
199
|
+
|
|
200
|
+
Set `SITE_URL` and `MCP_URL` for any real deployment. `MCP_URL` is the single
|
|
201
|
+
source of three things that must agree: the path the endpoint is served on, the
|
|
202
|
+
`resource` that discovery advertises, and the audience every token is bound to.
|
|
203
|
+
Its path defaults to `/admin/mcp`. A startup check refuses a `MCP_URL` with no
|
|
204
|
+
path, because then the whole origin would be advertised as the protected
|
|
205
|
+
resource.
|
|
206
|
+
|
|
207
|
+
## Per-ModelAdmin knobs
|
|
208
|
+
|
|
209
|
+
Set these on a `ModelAdmin` class, no mixin needed:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
class InvoiceAdmin(admin.ModelAdmin):
|
|
213
|
+
mcp_expose = False # hide this model from MCP entirely
|
|
214
|
+
mcp_fields = ("number", "total") # allowlist of serialized fields
|
|
215
|
+
mcp_exclude_fields = ("internal_note",) # denylist of serialized fields
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`REDACT_FIELDS` wins over `mcp_fields`. Listing a password field explicitly does not
|
|
219
|
+
reveal it.
|
|
220
|
+
|
|
221
|
+
## Safety
|
|
222
|
+
|
|
223
|
+
An admin MCP server for a superuser is a remote shell over the production database,
|
|
224
|
+
driven by a language model. The rails:
|
|
225
|
+
|
|
226
|
+
- `WRITABLE_MODELS` defaults to empty, so no model accepts writes until the deployment
|
|
227
|
+
names it. Everything else is your ordinary Django permissions, asked through the
|
|
228
|
+
`ModelAdmin` on every call.
|
|
229
|
+
- Access tokens are short-lived. Only salted hashes are stored, so a leaked database row
|
|
230
|
+
cannot be replayed.
|
|
231
|
+
- `delete_object` and `run_action` preview by default and change nothing until
|
|
232
|
+
`confirm=True`.
|
|
233
|
+
- Every mutation records a `LogEntry` attributed to the grant's user, with the client
|
|
234
|
+
name in the change message, for example `"Changed status. Via MCP (client: Claude
|
|
235
|
+
Code)."`. A write that cannot record a `LogEntry` rolls back.
|
|
236
|
+
- The package's own models, `sessions.Session`, and `authtoken.Token` are never exposed,
|
|
237
|
+
whatever the settings say.
|
|
238
|
+
- Keep `auth.Permission` and `auth.Group` out of `WRITABLE_MODELS`. An agent that can
|
|
239
|
+
grant permissions can escape the permission model.
|
|
240
|
+
|
|
241
|
+
## Deployment
|
|
242
|
+
|
|
243
|
+
**Separate process.** Run the MCP server next to your Django project:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
python manage.py admin_mcp_serve
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
It serves the path from `MCP_URL`, which is `/admin/mcp` by default, on the port from
|
|
250
|
+
`MCP_URL`, or 8765 when that URL names no port. Both are overridable with `--host` and
|
|
251
|
+
`--port`. Nothing about your existing serving configuration changes. Route
|
|
252
|
+
`/admin/mcp` through your ingress to that port, and make sure the `Authorization`
|
|
253
|
+
header passes through.
|
|
254
|
+
|
|
255
|
+
**Mounted (M3).** Mount the server at `/admin/mcp` inside your project's `asgi.py`. One
|
|
256
|
+
constraint: dispatch on the exact path. The OAuth endpoints live directly below the same
|
|
257
|
+
prefix (`/admin/mcp/authorize` and friends) and Django must keep serving those, so a
|
|
258
|
+
dispatcher that sends everything under `/admin/mcp` to FastMCP would swallow them. The
|
|
259
|
+
recipe ships with milestone M3.
|
|
260
|
+
|
|
261
|
+
The server is stateless, so any instance behind a load balancer can serve any request.
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
make install # bootstrap uv, pin Python, install dependencies
|
|
267
|
+
make test # run the permission matrix
|
|
268
|
+
make check # format, lint, typecheck, and test
|
|
269
|
+
make migrate # migrate the test project
|
|
270
|
+
make serve # run the MCP server against the test project on :8765/admin/mcp
|
|
271
|
+
make help # everything else
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
MIT
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# django-admin-fastmcp
|
|
2
|
+
|
|
3
|
+
A reusable Django app that exposes the Django admin as an MCP server, built on
|
|
4
|
+
[FastMCP](https://gofastmcp.com).
|
|
5
|
+
|
|
6
|
+
Every tool call runs as the staff user who owns the bearer token. Every tool call asks the
|
|
7
|
+
`ModelAdmin` for permission first. A superuser can do everything a superuser can do in the
|
|
8
|
+
admin. A staff user can do exactly what that staff user can do in the admin, and nothing
|
|
9
|
+
more.
|
|
10
|
+
|
|
11
|
+
`SPEC.md` is the full specification.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
Three rules define the package:
|
|
16
|
+
|
|
17
|
+
1. **No parallel permission system.** Authorization delegates to the `ModelAdmin` methods:
|
|
18
|
+
`has_view_permission`, `has_add_permission`, `has_change_permission`,
|
|
19
|
+
`has_delete_permission`, `get_queryset`, `get_readonly_fields`, and `get_actions`.
|
|
20
|
+
A `get_queryset` override that hides rows hides them from MCP too.
|
|
21
|
+
2. **No parallel data surface.** Writes go through the admin's own `ModelForm` and
|
|
22
|
+
`save_model`, then record a `LogEntry`. The admin history page stays truthful.
|
|
23
|
+
3. **Fail closed.** Every unresolved lookup, missing `ModelAdmin`, unknown action, unknown
|
|
24
|
+
field, and unknown tool denies the call.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv add django-admin-fastmcp
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Add the app to your settings:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
INSTALLED_APPS = [
|
|
36
|
+
...,
|
|
37
|
+
"django.contrib.admin",
|
|
38
|
+
"django_admin_fastmcp",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
ADMIN_FASTMCP = {
|
|
42
|
+
"SERVER_NAME": "acme-admin",
|
|
43
|
+
"EXCLUDE_MODELS": ("auth.Permission", "auth.Group"),
|
|
44
|
+
"WRITABLE_MODELS": (), # empty means no writes at all
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Mount the OAuth endpoints on the same site as the admin:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# urls.py
|
|
52
|
+
urlpatterns = [
|
|
53
|
+
# RFC 8414 fixes this one at the site root.
|
|
54
|
+
path("", include("django_admin_fastmcp.well_known_urls")),
|
|
55
|
+
# This prefix is yours to choose. Match it to the path in MCP_URL.
|
|
56
|
+
path("admin/mcp/", include("django_admin_fastmcp.urls")),
|
|
57
|
+
path("admin/", admin.site.urls),
|
|
58
|
+
]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The package hardcodes no prefix. Every URL the metadata document advertises comes
|
|
62
|
+
from `reverse()`, so a project that mounts the endpoints at `/backoffice/oauth/`
|
|
63
|
+
gets that in discovery and clients follow it. Two rules: the well-known document
|
|
64
|
+
belongs at the root, because a client derives its URL from the issuer, and the
|
|
65
|
+
endpoints should sit on the same site as the admin, because the consent page
|
|
66
|
+
rides the admin session cookie.
|
|
67
|
+
|
|
68
|
+
Apply the migrations:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python manage.py migrate django_admin_fastmcp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Nothing else. No per-model registration, no mixin, no decorators. The server exposes
|
|
75
|
+
whatever the admin already exposes.
|
|
76
|
+
|
|
77
|
+
## Connect a client
|
|
78
|
+
|
|
79
|
+
Run the server (see [Deployment](#deployment)), then register it:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Claude Code
|
|
83
|
+
claude mcp add --transport http acme-admin https://<host>/admin/mcp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use the path with no trailing slash. `/admin/mcp/` answers a 307 redirect to
|
|
87
|
+
`/admin/mcp`, and not every client follows a redirect on POST.
|
|
88
|
+
|
|
89
|
+
No token, no header. The first call starts the standard MCP OAuth flow:
|
|
90
|
+
|
|
91
|
+
1. The client opens your browser at the authorize page on the Django site.
|
|
92
|
+
2. Your admin session cookie identifies you. If you are logged out, the normal admin
|
|
93
|
+
login appears first.
|
|
94
|
+
3. A consent page shows the client name and what approval means. You approve.
|
|
95
|
+
4. The client receives its tokens and connects. It refreshes them by itself.
|
|
96
|
+
|
|
97
|
+
Any MCP client that speaks streamable HTTP with OAuth works the same way, for example
|
|
98
|
+
Cursor or a FastMCP `Client`.
|
|
99
|
+
|
|
100
|
+
Rules around access:
|
|
101
|
+
|
|
102
|
+
- Any staff user can authorize a client, for themselves only.
|
|
103
|
+
- A grant acts with your own admin permissions, never more. There is no separate
|
|
104
|
+
permission system: whoever may change a model in the admin may change it over MCP,
|
|
105
|
+
when the server lists that model in `WRITABLE_MODELS`.
|
|
106
|
+
- Refresh tokens expire after `REFRESH_TOKEN_TTL_DAYS` (default 90), so re-consent
|
|
107
|
+
happens that often. Revocation is an admin action on the grant changelist.
|
|
108
|
+
|
|
109
|
+
## Tools
|
|
110
|
+
|
|
111
|
+
Eleven generic tools, mounted under the namespace `admin`, so wire names are
|
|
112
|
+
`admin_list_models` and so on. Each takes `model` as `"app_label.ModelName"`. The tool
|
|
113
|
+
list is static. What varies per user is what each tool lets that user see and do.
|
|
114
|
+
|
|
115
|
+
### Read
|
|
116
|
+
|
|
117
|
+
| Tool | Arguments | Returns |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `list_models` | none | Every exposed model this caller may view, with permission flags. |
|
|
120
|
+
| `describe_model` | `model` | Fields, list display, filters, search fields, readonly fields, and available actions. |
|
|
121
|
+
| `search_objects` | `model`, `q`, `filters`, `order_by`, `page`, `page_size` | Rows plus `total`. `q` uses the admin's own search. An unknown filter is an error. |
|
|
122
|
+
| `get_object` | `model`, `pk` | One serialized instance. |
|
|
123
|
+
| `object_history` | `model`, `pk` | Admin log entries for that object, newest first. |
|
|
124
|
+
| `recent_actions` | `limit` | Admin log entries, scoped to the caller unless the caller is a superuser. |
|
|
125
|
+
|
|
126
|
+
### Write
|
|
127
|
+
|
|
128
|
+
Write tools need the model listed in `WRITABLE_MODELS`; your own admin permissions
|
|
129
|
+
decide the rest, per model and per object. A model outside the list refuses every write
|
|
130
|
+
and every action, whoever calls. Leave sensitive models out, an event log for example,
|
|
131
|
+
and no MCP client can ever write to them.
|
|
132
|
+
|
|
133
|
+
| Tool | Arguments | Behavior |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `create_object` | `model`, `data` | Validates through the admin form, then saves and logs. |
|
|
136
|
+
| `update_object` | `model`, `pk`, `data` | Partial update. Readonly fields are ignored. |
|
|
137
|
+
| `delete_object` | `model`, `pk`, `confirm` | Without `confirm`, returns the exact deletion cascade and changes nothing. |
|
|
138
|
+
| `run_action` | `model`, `action`, `pks`, `confirm` | Runs an admin action. Without `confirm`, returns a preview. |
|
|
139
|
+
| `autocomplete` | `model`, `field`, `q` | Resolves a foreign-key value to a primary key by searching the related model. |
|
|
140
|
+
|
|
141
|
+
Every returned row carries `pk` as a string and an `admin_url`, so an agent can hand a
|
|
142
|
+
person a link into the real admin.
|
|
143
|
+
|
|
144
|
+
## Settings
|
|
145
|
+
|
|
146
|
+
All keys live in the `ADMIN_FASTMCP` dict. An unknown key is an error at startup.
|
|
147
|
+
|
|
148
|
+
| Key | Default | Meaning |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `SERVER_NAME` | `"django-admin"` | Name the MCP server advertises. |
|
|
151
|
+
| `ADMIN_SITE` | `"django.contrib.admin.site"` | Dotted path to the `AdminSite`. |
|
|
152
|
+
| `MODELS` | `()` | Allowlist of `"app_label.ModelName"`. When non-empty, nothing else is exposed. |
|
|
153
|
+
| `EXCLUDE_MODELS` | `()` | Denylist. Supports `"app_label.*"`. |
|
|
154
|
+
| `WRITABLE_MODELS` | `()` | Models that accept writes. Empty means no writes, whoever calls. |
|
|
155
|
+
| `DISABLED_TOOLS` | `()` | Tool names removed from the catalogue entirely. |
|
|
156
|
+
| `REDACT_FIELDS` | `("password", "token", "secret", "api_key", "private_key")` | Substring match on field names. Values read `"[redacted]"`. |
|
|
157
|
+
| `MAX_PAGE_SIZE` | `200` | Cap on `search_objects` page size. |
|
|
158
|
+
| `MAX_PKS` | `1000` | Cap on `pks` per `run_action`. |
|
|
159
|
+
| `ACCESS_TOKEN_TTL_MINUTES` | `60` | Access token lifetime. Clients renew with the refresh token. |
|
|
160
|
+
| `REFRESH_TOKEN_TTL_DAYS` | `90` | Refresh token lifetime. Re-consent happens this often. |
|
|
161
|
+
| `SITE_URL` | `"http://127.0.0.1:8000"` | Public URL of the Django site. It is the OAuth issuer, and the MCP server names it as its authorization server. |
|
|
162
|
+
| `MCP_URL` | `"http://127.0.0.1:8765/admin/mcp"` | Public URL of the MCP endpoint. |
|
|
163
|
+
|
|
164
|
+
Set `SITE_URL` and `MCP_URL` for any real deployment. `MCP_URL` is the single
|
|
165
|
+
source of three things that must agree: the path the endpoint is served on, the
|
|
166
|
+
`resource` that discovery advertises, and the audience every token is bound to.
|
|
167
|
+
Its path defaults to `/admin/mcp`. A startup check refuses a `MCP_URL` with no
|
|
168
|
+
path, because then the whole origin would be advertised as the protected
|
|
169
|
+
resource.
|
|
170
|
+
|
|
171
|
+
## Per-ModelAdmin knobs
|
|
172
|
+
|
|
173
|
+
Set these on a `ModelAdmin` class, no mixin needed:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
class InvoiceAdmin(admin.ModelAdmin):
|
|
177
|
+
mcp_expose = False # hide this model from MCP entirely
|
|
178
|
+
mcp_fields = ("number", "total") # allowlist of serialized fields
|
|
179
|
+
mcp_exclude_fields = ("internal_note",) # denylist of serialized fields
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`REDACT_FIELDS` wins over `mcp_fields`. Listing a password field explicitly does not
|
|
183
|
+
reveal it.
|
|
184
|
+
|
|
185
|
+
## Safety
|
|
186
|
+
|
|
187
|
+
An admin MCP server for a superuser is a remote shell over the production database,
|
|
188
|
+
driven by a language model. The rails:
|
|
189
|
+
|
|
190
|
+
- `WRITABLE_MODELS` defaults to empty, so no model accepts writes until the deployment
|
|
191
|
+
names it. Everything else is your ordinary Django permissions, asked through the
|
|
192
|
+
`ModelAdmin` on every call.
|
|
193
|
+
- Access tokens are short-lived. Only salted hashes are stored, so a leaked database row
|
|
194
|
+
cannot be replayed.
|
|
195
|
+
- `delete_object` and `run_action` preview by default and change nothing until
|
|
196
|
+
`confirm=True`.
|
|
197
|
+
- Every mutation records a `LogEntry` attributed to the grant's user, with the client
|
|
198
|
+
name in the change message, for example `"Changed status. Via MCP (client: Claude
|
|
199
|
+
Code)."`. A write that cannot record a `LogEntry` rolls back.
|
|
200
|
+
- The package's own models, `sessions.Session`, and `authtoken.Token` are never exposed,
|
|
201
|
+
whatever the settings say.
|
|
202
|
+
- Keep `auth.Permission` and `auth.Group` out of `WRITABLE_MODELS`. An agent that can
|
|
203
|
+
grant permissions can escape the permission model.
|
|
204
|
+
|
|
205
|
+
## Deployment
|
|
206
|
+
|
|
207
|
+
**Separate process.** Run the MCP server next to your Django project:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
python manage.py admin_mcp_serve
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
It serves the path from `MCP_URL`, which is `/admin/mcp` by default, on the port from
|
|
214
|
+
`MCP_URL`, or 8765 when that URL names no port. Both are overridable with `--host` and
|
|
215
|
+
`--port`. Nothing about your existing serving configuration changes. Route
|
|
216
|
+
`/admin/mcp` through your ingress to that port, and make sure the `Authorization`
|
|
217
|
+
header passes through.
|
|
218
|
+
|
|
219
|
+
**Mounted (M3).** Mount the server at `/admin/mcp` inside your project's `asgi.py`. One
|
|
220
|
+
constraint: dispatch on the exact path. The OAuth endpoints live directly below the same
|
|
221
|
+
prefix (`/admin/mcp/authorize` and friends) and Django must keep serving those, so a
|
|
222
|
+
dispatcher that sends everything under `/admin/mcp` to FastMCP would swallow them. The
|
|
223
|
+
recipe ships with milestone M3.
|
|
224
|
+
|
|
225
|
+
The server is stateless, so any instance behind a load balancer can serve any request.
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
make install # bootstrap uv, pin Python, install dependencies
|
|
231
|
+
make test # run the permission matrix
|
|
232
|
+
make check # format, lint, typecheck, and test
|
|
233
|
+
make migrate # migrate the test project
|
|
234
|
+
make serve # run the MCP server against the test project on :8765/admin/mcp
|
|
235
|
+
make help # everything else
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Read-only audit changelists for McpClient and McpToken (SPEC.md section 8).
|
|
2
|
+
|
|
3
|
+
Who authorized which client, with which scopes, last used when. Revocation is
|
|
4
|
+
an admin action. Nothing is created by hand: tokens are issued only by the
|
|
5
|
+
OAuth flow. Staff see their own grants, superusers see all.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.contrib import admin
|
|
9
|
+
|
|
10
|
+
from django_admin_fastmcp.models import McpClient, McpToken
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReadOnlyAdmin(admin.ModelAdmin):
|
|
14
|
+
mcp_expose = False # belt and suspenders on top of the built-in denylist
|
|
15
|
+
|
|
16
|
+
def has_add_permission(self, request):
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
def has_change_permission(self, request, obj=None):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@admin.register(McpClient)
|
|
24
|
+
class McpClientAdmin(ReadOnlyAdmin):
|
|
25
|
+
list_display = ("name", "client_id", "created_at")
|
|
26
|
+
search_fields = ("name", "client_id")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@admin.register(McpToken)
|
|
30
|
+
class McpTokenAdmin(ReadOnlyAdmin):
|
|
31
|
+
list_display = (
|
|
32
|
+
"client",
|
|
33
|
+
"user",
|
|
34
|
+
"scope_list",
|
|
35
|
+
"access_expires_at",
|
|
36
|
+
"refresh_expires_at",
|
|
37
|
+
"revoked_at",
|
|
38
|
+
"last_used_at",
|
|
39
|
+
)
|
|
40
|
+
list_filter = ("revoked_at",)
|
|
41
|
+
actions = ("revoke_grants",)
|
|
42
|
+
|
|
43
|
+
@admin.display(description="scopes")
|
|
44
|
+
def scope_list(self, obj):
|
|
45
|
+
return ", ".join(obj.scopes)
|
|
46
|
+
|
|
47
|
+
def get_queryset(self, request):
|
|
48
|
+
queryset = super().get_queryset(request).select_related("user", "client")
|
|
49
|
+
if request.user.is_superuser:
|
|
50
|
+
return queryset
|
|
51
|
+
return queryset.filter(user=request.user)
|
|
52
|
+
|
|
53
|
+
@admin.action(description="Revoke selected grants", permissions=["view"])
|
|
54
|
+
def revoke_grants(self, request, queryset):
|
|
55
|
+
count = 0
|
|
56
|
+
for grant in queryset:
|
|
57
|
+
grant.revoke()
|
|
58
|
+
count += 1
|
|
59
|
+
self.message_user(request, f"Revoked {count} grant(s).")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoAdminFastmcpConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "django_admin_fastmcp"
|
|
7
|
+
verbose_name = "Admin MCP"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
# Import registers the system checks (SPEC.md section 3).
|
|
11
|
+
from django_admin_fastmcp import checks # noqa: F401
|