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.
Files changed (33) hide show
  1. django_admin_fastmcp-0.1.0/.gitignore +41 -0
  2. django_admin_fastmcp-0.1.0/CHANGELOG.md +30 -0
  3. django_admin_fastmcp-0.1.0/LICENSE +21 -0
  4. django_admin_fastmcp-0.1.0/PKG-INFO +276 -0
  5. django_admin_fastmcp-0.1.0/README.md +240 -0
  6. django_admin_fastmcp-0.1.0/django_admin_fastmcp/__init__.py +3 -0
  7. django_admin_fastmcp-0.1.0/django_admin_fastmcp/admin.py +59 -0
  8. django_admin_fastmcp-0.1.0/django_admin_fastmcp/apps.py +11 -0
  9. django_admin_fastmcp-0.1.0/django_admin_fastmcp/auth.py +135 -0
  10. django_admin_fastmcp-0.1.0/django_admin_fastmcp/checks.py +121 -0
  11. django_admin_fastmcp-0.1.0/django_admin_fastmcp/conf.py +63 -0
  12. django_admin_fastmcp-0.1.0/django_admin_fastmcp/errors.py +24 -0
  13. django_admin_fastmcp-0.1.0/django_admin_fastmcp/exposure.py +98 -0
  14. django_admin_fastmcp-0.1.0/django_admin_fastmcp/management/__init__.py +0 -0
  15. django_admin_fastmcp-0.1.0/django_admin_fastmcp/management/commands/__init__.py +0 -0
  16. django_admin_fastmcp-0.1.0/django_admin_fastmcp/management/commands/admin_mcp_serve.py +49 -0
  17. django_admin_fastmcp-0.1.0/django_admin_fastmcp/migrations/0001_initial.py +117 -0
  18. django_admin_fastmcp-0.1.0/django_admin_fastmcp/migrations/__init__.py +0 -0
  19. django_admin_fastmcp-0.1.0/django_admin_fastmcp/models.py +205 -0
  20. django_admin_fastmcp-0.1.0/django_admin_fastmcp/oauth.py +304 -0
  21. django_admin_fastmcp-0.1.0/django_admin_fastmcp/request.py +58 -0
  22. django_admin_fastmcp-0.1.0/django_admin_fastmcp/serialize.py +93 -0
  23. django_admin_fastmcp-0.1.0/django_admin_fastmcp/server.py +88 -0
  24. django_admin_fastmcp-0.1.0/django_admin_fastmcp/templates/django_admin_fastmcp/authorize.html +28 -0
  25. django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/__init__.py +63 -0
  26. django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/actions.py +69 -0
  27. django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/introspect.py +120 -0
  28. django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/lookup.py +42 -0
  29. django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/read.py +144 -0
  30. django_admin_fastmcp-0.1.0/django_admin_fastmcp/tools/write.py +181 -0
  31. django_admin_fastmcp-0.1.0/django_admin_fastmcp/urls.py +29 -0
  32. django_admin_fastmcp-0.1.0/django_admin_fastmcp/well_known_urls.py +20 -0
  33. 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,3 @@
1
+ """Expose the Django admin as an MCP server, gated by each user's own admin permissions."""
2
+
3
+ __version__ = "0.1.0"
@@ -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