django-mcp-kit 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_mcp_kit-0.1.0/PKG-INFO +252 -0
- django_mcp_kit-0.1.0/README.md +230 -0
- django_mcp_kit-0.1.0/django_mcp_kit/__init__.py +47 -0
- django_mcp_kit-0.1.0/django_mcp_kit/apps.py +13 -0
- django_mcp_kit-0.1.0/django_mcp_kit/asgi.py +41 -0
- django_mcp_kit-0.1.0/django_mcp_kit/auth/__init__.py +22 -0
- django_mcp_kit-0.1.0/django_mcp_kit/auth/base.py +73 -0
- django_mcp_kit-0.1.0/django_mcp_kit/auth/bearer.py +36 -0
- django_mcp_kit-0.1.0/django_mcp_kit/auth/oauth.py +53 -0
- django_mcp_kit-0.1.0/django_mcp_kit/conf.py +44 -0
- django_mcp_kit-0.1.0/django_mcp_kit/dispatcher.py +155 -0
- django_mcp_kit-0.1.0/django_mcp_kit/drf.py +209 -0
- django_mcp_kit-0.1.0/django_mcp_kit/errors.py +96 -0
- django_mcp_kit-0.1.0/django_mcp_kit/management/__init__.py +0 -0
- django_mcp_kit-0.1.0/django_mcp_kit/management/commands/__init__.py +0 -0
- django_mcp_kit-0.1.0/django_mcp_kit/management/commands/create_mcp_oauth_client.py +48 -0
- django_mcp_kit-0.1.0/django_mcp_kit/management/commands/runserver_mcp.py +49 -0
- django_mcp_kit-0.1.0/django_mcp_kit/models.py +90 -0
- django_mcp_kit-0.1.0/django_mcp_kit/oauth_client.py +62 -0
- django_mcp_kit-0.1.0/django_mcp_kit/permissions.py +45 -0
- django_mcp_kit-0.1.0/django_mcp_kit/registry.py +74 -0
- django_mcp_kit-0.1.0/django_mcp_kit/resources.py +96 -0
- django_mcp_kit-0.1.0/django_mcp_kit/schema.py +68 -0
- django_mcp_kit-0.1.0/django_mcp_kit/services.py +53 -0
- django_mcp_kit-0.1.0/django_mcp_kit/tools.py +193 -0
- django_mcp_kit-0.1.0/django_mcp_kit/transports/__init__.py +0 -0
- django_mcp_kit-0.1.0/django_mcp_kit/transports/http.py +15 -0
- django_mcp_kit-0.1.0/django_mcp_kit/transports/sdk.py +192 -0
- django_mcp_kit-0.1.0/django_mcp_kit/urls.py +33 -0
- django_mcp_kit-0.1.0/django_mcp_kit/utils.py +31 -0
- django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/__init__.py +0 -0
- django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/apps.py +8 -0
- django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/migrations/0001_initial.py +28 -0
- django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/migrations/__init__.py +0 -0
- django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/models.py +47 -0
- django_mcp_kit-0.1.0/pyproject.toml +49 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-mcp-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turns Django/Wagtail code into MCP tools (and resources) for Claude clients
|
|
5
|
+
Author: CaptainDuck
|
|
6
|
+
Author-email: johanna@techco.fi
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Provides-Extra: wagtail
|
|
11
|
+
Requires-Dist: django (>=4.2)
|
|
12
|
+
Requires-Dist: django-oauth-toolkit (>=2.0)
|
|
13
|
+
Requires-Dist: djangorestframework (>=3.14)
|
|
14
|
+
Requires-Dist: mcp (>=1.2)
|
|
15
|
+
Requires-Dist: pydantic (>=2)
|
|
16
|
+
Requires-Dist: singleserver (>=0.4)
|
|
17
|
+
Requires-Dist: uvicorn (>=0.27)
|
|
18
|
+
Requires-Dist: wagtail (>=6,<8) ; extra == "wagtail"
|
|
19
|
+
Project-URL: Repository, https://github.com/CaptainDuck/django-mcp-kit
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# django-mcp-kit
|
|
23
|
+
|
|
24
|
+
Turns Django/Wagtail code into MCP tools (and resources) for Claude clients — without
|
|
25
|
+
coupling your business logic to any MCP framework.
|
|
26
|
+
|
|
27
|
+
- **Transport-neutral core.** A registry + dispatcher own `initialize` / `tools.list` /
|
|
28
|
+
`tools.call`; the official MCP SDK is used **only at the wire** (`transports/sdk.py`)
|
|
29
|
+
and is swappable. No FastMCP.
|
|
30
|
+
- **Native Django authz.** DRF-style `permission_classes` checked before `run()`; object
|
|
31
|
+
permissions (Wagtail `permissions_for_user`) stay in your service layer.
|
|
32
|
+
- **Pluggable auth.** Static bearer tokens *and* OAuth 2.1 resource server
|
|
33
|
+
(django-oauth-toolkit) behind one `Authenticator` interface, with RFC 9728 discovery
|
|
34
|
+
metadata and the `401 + WWW-Authenticate` handshake.
|
|
35
|
+
- **On-ramps.** Register a DRF `ViewSet`, expose a model (read-only by default), or
|
|
36
|
+
declare MCP resources.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install django-mcp-kit # core: MCP SDK wire transport, OAuth, DRF on-ramp, singleserver
|
|
42
|
+
pip install "django-mcp-kit[wagtail]" # + the optional Wagtail admin settings page
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> Batteries included. The MCP SDK + `uvicorn` (the only wire transport today),
|
|
46
|
+
> django-oauth-toolkit (OAuth resource server), DRF (the ViewSet/model on-ramp), and
|
|
47
|
+
> `singleserver` are all core dependencies — their imports stay lazy/contained, so the
|
|
48
|
+
> architectural boundary holds in code even though the packages ship by default. The one
|
|
49
|
+
> optional extra is **`[wagtail]`**, for the Wagtail admin settings page (Django-only
|
|
50
|
+
> projects don't need Wagtail).
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# settings.py
|
|
56
|
+
INSTALLED_APPS += ["django_mcp_kit"]
|
|
57
|
+
DJANGO_MCP_KIT = {
|
|
58
|
+
"SERVER_NAME": "my-content",
|
|
59
|
+
"AUTH_BACKENDS": [
|
|
60
|
+
"django_mcp_kit.auth.OAuthResourceServer",
|
|
61
|
+
"django_mcp_kit.auth.StaticBearer",
|
|
62
|
+
],
|
|
63
|
+
"STATIC_BEARER_RESOLVER": "myapp.models:UserProfile.user_for_token",
|
|
64
|
+
"OAUTH_ISSUER_URL": "https://example.com",
|
|
65
|
+
"RESOURCE_SERVER_URL": "https://example.com/mcp",
|
|
66
|
+
"REQUIRED_SCOPES": ["mcp"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# urls.py — discovery + health for the Django site
|
|
70
|
+
path("", include("django_mcp_kit.urls")), # /healthz, /.well-known/oauth-protected-resource
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# myapp/mcp_tools.py — autodiscovered on app load
|
|
75
|
+
from django_mcp_kit import Tool, Schema, tool
|
|
76
|
+
from . import services
|
|
77
|
+
|
|
78
|
+
class PatchBlock(Tool):
|
|
79
|
+
name = "patch_block"
|
|
80
|
+
description = "Replace one homepage block by id; saves a DRAFT."
|
|
81
|
+
|
|
82
|
+
class Input(Schema):
|
|
83
|
+
block_id: str
|
|
84
|
+
value: dict
|
|
85
|
+
|
|
86
|
+
def run(self, user, block_id, value): # sync — runs off the event loop
|
|
87
|
+
return services.save_homepage_draft(user, block_id=block_id, value=value)
|
|
88
|
+
|
|
89
|
+
@tool(name="get_draft", description="Latest homepage draft.")
|
|
90
|
+
def get_draft(user) -> dict:
|
|
91
|
+
return services.homepage_draft()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Register a DRF ViewSet (one tool per action) or a model (read-only by default)
|
|
96
|
+
from django_mcp_kit.drf import register_drf_viewset
|
|
97
|
+
from django_mcp_kit.models import ModelToolset
|
|
98
|
+
|
|
99
|
+
register_drf_viewset(OrderViewSet, prefix="order") # order_list, order_create, …
|
|
100
|
+
|
|
101
|
+
class ProductToolset(ModelToolset):
|
|
102
|
+
model = Product
|
|
103
|
+
actions = ["list", "retrieve"] # add "create"/"update"/"delete" to opt in
|
|
104
|
+
as_resource = True
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Run it
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python manage.py runserver_mcp --port 8810 # ASGI/uvicorn MCP process
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Deployment
|
|
114
|
+
|
|
115
|
+
The MCP endpoint is **always ASGI** (Streamable HTTP uses SSE), but how it runs is a
|
|
116
|
+
choice. All topologies run the same code — `runserver_mcp` serving the app from
|
|
117
|
+
`django_mcp_kit.asgi:get_application` — only the process management differs. Pick by how
|
|
118
|
+
your *site* is served and how much isolation you want:
|
|
119
|
+
|
|
120
|
+
| Topology | Site stays WSGI | Process | Best for |
|
|
121
|
+
|---|:--:|---|---|
|
|
122
|
+
| **A — Co-located** | no (site → ASGI) | one ASGI process | single-service / already-ASGI sites |
|
|
123
|
+
| **B — singleserver aux** | yes | gunicorn boots the MCP process, workers share it | dev == prod, no systemd |
|
|
124
|
+
| **C — separate systemd unit** | yes | independent ASGI daemon | production SSE (recommended) |
|
|
125
|
+
|
|
126
|
+
Health checks point at **`/healthz`** (a plain 200) — never `/mcp`, which is the
|
|
127
|
+
Streamable-HTTP endpoint and returns 4xx to a bare GET.
|
|
128
|
+
|
|
129
|
+
### A — Co-located (one ASGI process)
|
|
130
|
+
|
|
131
|
+
Mount the MCP app beside your Django ASGI app; requests to `/mcp` (and `/healthz`,
|
|
132
|
+
`/.well-known/...`) go to MCP, everything else to Django:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# asgi.py
|
|
136
|
+
from django.core.asgi import get_asgi_application
|
|
137
|
+
from django_mcp_kit.asgi import mount
|
|
138
|
+
|
|
139
|
+
application = mount(get_asgi_application()) # serves /mcp on the same process
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### B — singleserver-managed aux ([`deploy/gunicorn.conf.py`](./deploy/gunicorn.conf.py))
|
|
143
|
+
|
|
144
|
+
Your site stays WSGI/gunicorn. The first gunicorn worker boots the MCP process; all
|
|
145
|
+
workers share it via an atomic socket lock (no systemd). Wire it in `post_fork`:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
# gunicorn.conf.py
|
|
149
|
+
def post_fork(server, worker):
|
|
150
|
+
from django_mcp_kit.services import connect
|
|
151
|
+
connect()
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Point your front-end proxy `/mcp` at `DJANGO_MCP_KIT["PORT"]` (default `8810`). The
|
|
155
|
+
shipped `SingleServer` uses `health_check_url="/healthz"` and a bounded graceful shutdown.
|
|
156
|
+
|
|
157
|
+
### C — Separate systemd unit ([`deploy/django-mcp.service`](./deploy/django-mcp.service))
|
|
158
|
+
|
|
159
|
+
Run the MCP server as its own daemon — decoupled lifecycle, real graceful restart. The
|
|
160
|
+
sample unit runs `runserver_mcp` with a bounded `--timeout-graceful-shutdown` (so
|
|
161
|
+
long-lived SSE streams don't stall stop/restart) and `Restart=always`. Edit the paths/user,
|
|
162
|
+
then:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
sudo cp deploy/django-mcp.service /etc/systemd/system/
|
|
166
|
+
sudo systemctl daemon-reload && sudo systemctl enable --now django-mcp
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### nginx ([`deploy/nginx-mcp.conf`](./deploy/nginx-mcp.conf))
|
|
170
|
+
|
|
171
|
+
For B and C, proxy `/mcp` to the MCP process. SSE requires **buffering off** and long read
|
|
172
|
+
timeouts — the sample sets `proxy_buffering off` and `proxy_read_timeout 3600s`, and also
|
|
173
|
+
proxies the `/.well-known/oauth-protected-resource` metadata. Add it inside your `server {}`
|
|
174
|
+
block and reload nginx.
|
|
175
|
+
|
|
176
|
+
## Authorization Server (OAuth) setup
|
|
177
|
+
|
|
178
|
+
This library is the **Resource Server** — it validates bearer tokens and serves the
|
|
179
|
+
RFC 9728 discovery metadata. It does **not** provide the **Authorization Server** (the
|
|
180
|
+
login, `/o/authorize`, `/o/token`, and the **consent page**). That role is
|
|
181
|
+
django-oauth-toolkit (DOT), which ships as a dependency but must be wired up by the
|
|
182
|
+
project:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
# settings.py
|
|
186
|
+
INSTALLED_APPS += ["oauth2_provider"]
|
|
187
|
+
OAUTH2_PROVIDER = {
|
|
188
|
+
"SCOPES": {"mcp": "Access MCP tools"},
|
|
189
|
+
"PKCE_REQUIRED": True, # public clients (browser/native, e.g. claude.ai)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# urls.py — mount the Authorization Server endpoints
|
|
193
|
+
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Provisioning the OAuth client
|
|
197
|
+
|
|
198
|
+
Register an OAuth client (a DOT `Application`) per connector with the bundled command —
|
|
199
|
+
idempotent, public + PKCE by default:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
python manage.py create_mcp_oauth_client https://claude.ai/api/mcp/auth_callback \
|
|
203
|
+
--name "My connector" # name shown on the consent page; --skip-consent to auto-approve
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The client name defaults to `DJANGO_MCP_KIT["OAUTH_APP_NAME"]` (`"MCP connector"`) when
|
|
207
|
+
`--name` is omitted. The command prints the **Client ID** to paste into the connector.
|
|
208
|
+
`django_mcp_kit.oauth_client.ensure_oauth_application(...)` is the same helper if you'd
|
|
209
|
+
rather provision from code.
|
|
210
|
+
|
|
211
|
+
### The consent page
|
|
212
|
+
|
|
213
|
+
The consent screen is rendered by **DOT's `AuthorizationView`** at `/o/authorize/` using
|
|
214
|
+
its default template **`oauth2_provider/authorize.html`** (a plain approve/deny form).
|
|
215
|
+
Whether it appears is controlled per-client by **`Application.skip_authorization`**:
|
|
216
|
+
|
|
217
|
+
- `skip_authorization=False` (DOT default) — the user is shown the consent page on first
|
|
218
|
+
authorization.
|
|
219
|
+
- `skip_authorization=True` — consent is auto-approved (no page). Reasonable for a
|
|
220
|
+
trusted first-party connector.
|
|
221
|
+
|
|
222
|
+
The name shown on that consent page is the `Application.name` — set it per client with
|
|
223
|
+
`create_mcp_oauth_client --name`, or change the default via `DJANGO_MCP_KIT["OAUTH_APP_NAME"]`.
|
|
224
|
+
To customise the consent UI, override `oauth2_provider/authorize.html` in your own
|
|
225
|
+
templates directory. This library has no opinion on and no default for the consent page —
|
|
226
|
+
it only consumes the access token DOT issues.
|
|
227
|
+
|
|
228
|
+
### Configuring it from the Wagtail admin (optional)
|
|
229
|
+
|
|
230
|
+
For Wagtail projects, add the optional app to get a **"MCP connector"** page under the
|
|
231
|
+
admin **Settings** menu that provisions/updates the client on save:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
pip install "django-mcp-kit[wagtail]" # Wagtail 6.x or 7.x
|
|
235
|
+
```
|
|
236
|
+
```python
|
|
237
|
+
INSTALLED_APPS += ["django_mcp_kit.wagtail_connector"]
|
|
238
|
+
# then: python manage.py migrate
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Fields: enable, the consent-page name, redirect URIs, and skip-consent. **Access is
|
|
242
|
+
gated by the `change_mcpconnectorsettings` permission — i.e. superusers only by default**;
|
|
243
|
+
delegate to specific staff by granting that permission via a Group. Wagtail is *not* a
|
|
244
|
+
core dependency — it's the optional `[wagtail]` extra, so Django-only projects skip it.
|
|
245
|
+
|
|
246
|
+
## Develop
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
poetry install
|
|
250
|
+
poetry run pytest
|
|
251
|
+
```
|
|
252
|
+
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# django-mcp-kit
|
|
2
|
+
|
|
3
|
+
Turns Django/Wagtail code into MCP tools (and resources) for Claude clients — without
|
|
4
|
+
coupling your business logic to any MCP framework.
|
|
5
|
+
|
|
6
|
+
- **Transport-neutral core.** A registry + dispatcher own `initialize` / `tools.list` /
|
|
7
|
+
`tools.call`; the official MCP SDK is used **only at the wire** (`transports/sdk.py`)
|
|
8
|
+
and is swappable. No FastMCP.
|
|
9
|
+
- **Native Django authz.** DRF-style `permission_classes` checked before `run()`; object
|
|
10
|
+
permissions (Wagtail `permissions_for_user`) stay in your service layer.
|
|
11
|
+
- **Pluggable auth.** Static bearer tokens *and* OAuth 2.1 resource server
|
|
12
|
+
(django-oauth-toolkit) behind one `Authenticator` interface, with RFC 9728 discovery
|
|
13
|
+
metadata and the `401 + WWW-Authenticate` handshake.
|
|
14
|
+
- **On-ramps.** Register a DRF `ViewSet`, expose a model (read-only by default), or
|
|
15
|
+
declare MCP resources.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install django-mcp-kit # core: MCP SDK wire transport, OAuth, DRF on-ramp, singleserver
|
|
21
|
+
pip install "django-mcp-kit[wagtail]" # + the optional Wagtail admin settings page
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> Batteries included. The MCP SDK + `uvicorn` (the only wire transport today),
|
|
25
|
+
> django-oauth-toolkit (OAuth resource server), DRF (the ViewSet/model on-ramp), and
|
|
26
|
+
> `singleserver` are all core dependencies — their imports stay lazy/contained, so the
|
|
27
|
+
> architectural boundary holds in code even though the packages ship by default. The one
|
|
28
|
+
> optional extra is **`[wagtail]`**, for the Wagtail admin settings page (Django-only
|
|
29
|
+
> projects don't need Wagtail).
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
# settings.py
|
|
35
|
+
INSTALLED_APPS += ["django_mcp_kit"]
|
|
36
|
+
DJANGO_MCP_KIT = {
|
|
37
|
+
"SERVER_NAME": "my-content",
|
|
38
|
+
"AUTH_BACKENDS": [
|
|
39
|
+
"django_mcp_kit.auth.OAuthResourceServer",
|
|
40
|
+
"django_mcp_kit.auth.StaticBearer",
|
|
41
|
+
],
|
|
42
|
+
"STATIC_BEARER_RESOLVER": "myapp.models:UserProfile.user_for_token",
|
|
43
|
+
"OAUTH_ISSUER_URL": "https://example.com",
|
|
44
|
+
"RESOURCE_SERVER_URL": "https://example.com/mcp",
|
|
45
|
+
"REQUIRED_SCOPES": ["mcp"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# urls.py — discovery + health for the Django site
|
|
49
|
+
path("", include("django_mcp_kit.urls")), # /healthz, /.well-known/oauth-protected-resource
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# myapp/mcp_tools.py — autodiscovered on app load
|
|
54
|
+
from django_mcp_kit import Tool, Schema, tool
|
|
55
|
+
from . import services
|
|
56
|
+
|
|
57
|
+
class PatchBlock(Tool):
|
|
58
|
+
name = "patch_block"
|
|
59
|
+
description = "Replace one homepage block by id; saves a DRAFT."
|
|
60
|
+
|
|
61
|
+
class Input(Schema):
|
|
62
|
+
block_id: str
|
|
63
|
+
value: dict
|
|
64
|
+
|
|
65
|
+
def run(self, user, block_id, value): # sync — runs off the event loop
|
|
66
|
+
return services.save_homepage_draft(user, block_id=block_id, value=value)
|
|
67
|
+
|
|
68
|
+
@tool(name="get_draft", description="Latest homepage draft.")
|
|
69
|
+
def get_draft(user) -> dict:
|
|
70
|
+
return services.homepage_draft()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# Register a DRF ViewSet (one tool per action) or a model (read-only by default)
|
|
75
|
+
from django_mcp_kit.drf import register_drf_viewset
|
|
76
|
+
from django_mcp_kit.models import ModelToolset
|
|
77
|
+
|
|
78
|
+
register_drf_viewset(OrderViewSet, prefix="order") # order_list, order_create, …
|
|
79
|
+
|
|
80
|
+
class ProductToolset(ModelToolset):
|
|
81
|
+
model = Product
|
|
82
|
+
actions = ["list", "retrieve"] # add "create"/"update"/"delete" to opt in
|
|
83
|
+
as_resource = True
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Run it
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python manage.py runserver_mcp --port 8810 # ASGI/uvicorn MCP process
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Deployment
|
|
93
|
+
|
|
94
|
+
The MCP endpoint is **always ASGI** (Streamable HTTP uses SSE), but how it runs is a
|
|
95
|
+
choice. All topologies run the same code — `runserver_mcp` serving the app from
|
|
96
|
+
`django_mcp_kit.asgi:get_application` — only the process management differs. Pick by how
|
|
97
|
+
your *site* is served and how much isolation you want:
|
|
98
|
+
|
|
99
|
+
| Topology | Site stays WSGI | Process | Best for |
|
|
100
|
+
|---|:--:|---|---|
|
|
101
|
+
| **A — Co-located** | no (site → ASGI) | one ASGI process | single-service / already-ASGI sites |
|
|
102
|
+
| **B — singleserver aux** | yes | gunicorn boots the MCP process, workers share it | dev == prod, no systemd |
|
|
103
|
+
| **C — separate systemd unit** | yes | independent ASGI daemon | production SSE (recommended) |
|
|
104
|
+
|
|
105
|
+
Health checks point at **`/healthz`** (a plain 200) — never `/mcp`, which is the
|
|
106
|
+
Streamable-HTTP endpoint and returns 4xx to a bare GET.
|
|
107
|
+
|
|
108
|
+
### A — Co-located (one ASGI process)
|
|
109
|
+
|
|
110
|
+
Mount the MCP app beside your Django ASGI app; requests to `/mcp` (and `/healthz`,
|
|
111
|
+
`/.well-known/...`) go to MCP, everything else to Django:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# asgi.py
|
|
115
|
+
from django.core.asgi import get_asgi_application
|
|
116
|
+
from django_mcp_kit.asgi import mount
|
|
117
|
+
|
|
118
|
+
application = mount(get_asgi_application()) # serves /mcp on the same process
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### B — singleserver-managed aux ([`deploy/gunicorn.conf.py`](./deploy/gunicorn.conf.py))
|
|
122
|
+
|
|
123
|
+
Your site stays WSGI/gunicorn. The first gunicorn worker boots the MCP process; all
|
|
124
|
+
workers share it via an atomic socket lock (no systemd). Wire it in `post_fork`:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# gunicorn.conf.py
|
|
128
|
+
def post_fork(server, worker):
|
|
129
|
+
from django_mcp_kit.services import connect
|
|
130
|
+
connect()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Point your front-end proxy `/mcp` at `DJANGO_MCP_KIT["PORT"]` (default `8810`). The
|
|
134
|
+
shipped `SingleServer` uses `health_check_url="/healthz"` and a bounded graceful shutdown.
|
|
135
|
+
|
|
136
|
+
### C — Separate systemd unit ([`deploy/django-mcp.service`](./deploy/django-mcp.service))
|
|
137
|
+
|
|
138
|
+
Run the MCP server as its own daemon — decoupled lifecycle, real graceful restart. The
|
|
139
|
+
sample unit runs `runserver_mcp` with a bounded `--timeout-graceful-shutdown` (so
|
|
140
|
+
long-lived SSE streams don't stall stop/restart) and `Restart=always`. Edit the paths/user,
|
|
141
|
+
then:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
sudo cp deploy/django-mcp.service /etc/systemd/system/
|
|
145
|
+
sudo systemctl daemon-reload && sudo systemctl enable --now django-mcp
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### nginx ([`deploy/nginx-mcp.conf`](./deploy/nginx-mcp.conf))
|
|
149
|
+
|
|
150
|
+
For B and C, proxy `/mcp` to the MCP process. SSE requires **buffering off** and long read
|
|
151
|
+
timeouts — the sample sets `proxy_buffering off` and `proxy_read_timeout 3600s`, and also
|
|
152
|
+
proxies the `/.well-known/oauth-protected-resource` metadata. Add it inside your `server {}`
|
|
153
|
+
block and reload nginx.
|
|
154
|
+
|
|
155
|
+
## Authorization Server (OAuth) setup
|
|
156
|
+
|
|
157
|
+
This library is the **Resource Server** — it validates bearer tokens and serves the
|
|
158
|
+
RFC 9728 discovery metadata. It does **not** provide the **Authorization Server** (the
|
|
159
|
+
login, `/o/authorize`, `/o/token`, and the **consent page**). That role is
|
|
160
|
+
django-oauth-toolkit (DOT), which ships as a dependency but must be wired up by the
|
|
161
|
+
project:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# settings.py
|
|
165
|
+
INSTALLED_APPS += ["oauth2_provider"]
|
|
166
|
+
OAUTH2_PROVIDER = {
|
|
167
|
+
"SCOPES": {"mcp": "Access MCP tools"},
|
|
168
|
+
"PKCE_REQUIRED": True, # public clients (browser/native, e.g. claude.ai)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# urls.py — mount the Authorization Server endpoints
|
|
172
|
+
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Provisioning the OAuth client
|
|
176
|
+
|
|
177
|
+
Register an OAuth client (a DOT `Application`) per connector with the bundled command —
|
|
178
|
+
idempotent, public + PKCE by default:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
python manage.py create_mcp_oauth_client https://claude.ai/api/mcp/auth_callback \
|
|
182
|
+
--name "My connector" # name shown on the consent page; --skip-consent to auto-approve
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The client name defaults to `DJANGO_MCP_KIT["OAUTH_APP_NAME"]` (`"MCP connector"`) when
|
|
186
|
+
`--name` is omitted. The command prints the **Client ID** to paste into the connector.
|
|
187
|
+
`django_mcp_kit.oauth_client.ensure_oauth_application(...)` is the same helper if you'd
|
|
188
|
+
rather provision from code.
|
|
189
|
+
|
|
190
|
+
### The consent page
|
|
191
|
+
|
|
192
|
+
The consent screen is rendered by **DOT's `AuthorizationView`** at `/o/authorize/` using
|
|
193
|
+
its default template **`oauth2_provider/authorize.html`** (a plain approve/deny form).
|
|
194
|
+
Whether it appears is controlled per-client by **`Application.skip_authorization`**:
|
|
195
|
+
|
|
196
|
+
- `skip_authorization=False` (DOT default) — the user is shown the consent page on first
|
|
197
|
+
authorization.
|
|
198
|
+
- `skip_authorization=True` — consent is auto-approved (no page). Reasonable for a
|
|
199
|
+
trusted first-party connector.
|
|
200
|
+
|
|
201
|
+
The name shown on that consent page is the `Application.name` — set it per client with
|
|
202
|
+
`create_mcp_oauth_client --name`, or change the default via `DJANGO_MCP_KIT["OAUTH_APP_NAME"]`.
|
|
203
|
+
To customise the consent UI, override `oauth2_provider/authorize.html` in your own
|
|
204
|
+
templates directory. This library has no opinion on and no default for the consent page —
|
|
205
|
+
it only consumes the access token DOT issues.
|
|
206
|
+
|
|
207
|
+
### Configuring it from the Wagtail admin (optional)
|
|
208
|
+
|
|
209
|
+
For Wagtail projects, add the optional app to get a **"MCP connector"** page under the
|
|
210
|
+
admin **Settings** menu that provisions/updates the client on save:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
pip install "django-mcp-kit[wagtail]" # Wagtail 6.x or 7.x
|
|
214
|
+
```
|
|
215
|
+
```python
|
|
216
|
+
INSTALLED_APPS += ["django_mcp_kit.wagtail_connector"]
|
|
217
|
+
# then: python manage.py migrate
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Fields: enable, the consent-page name, redirect URIs, and skip-consent. **Access is
|
|
221
|
+
gated by the `change_mcpconnectorsettings` permission — i.e. superusers only by default**;
|
|
222
|
+
delegate to specific staff by granting that permission via a Group. Wagtail is *not* a
|
|
223
|
+
core dependency — it's the optional `[wagtail]` extra, so Django-only projects skip it.
|
|
224
|
+
|
|
225
|
+
## Develop
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
poetry install
|
|
229
|
+
poetry run pytest
|
|
230
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""django-mcp-kit -- turn Django/Wagtail code into MCP tools for Claude clients.
|
|
2
|
+
|
|
3
|
+
Public API: import tools, schema, permissions, and the registry from here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .errors import (
|
|
7
|
+
BadRequest,
|
|
8
|
+
Invalid,
|
|
9
|
+
MCPError,
|
|
10
|
+
NotAuthenticated,
|
|
11
|
+
NotFound,
|
|
12
|
+
PermissionDenied,
|
|
13
|
+
RateLimited,
|
|
14
|
+
)
|
|
15
|
+
from .permissions import AllowAny, BasePermission, HasDjangoPerm, IsAuthenticated
|
|
16
|
+
from .registry import resource_registry, tool_registry
|
|
17
|
+
from .registry import tool_registry as registry
|
|
18
|
+
from .resources import Resource, resource
|
|
19
|
+
from .schema import Schema
|
|
20
|
+
from .tools import Content, Image, RateLimitedMixin, Tool, tool
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Tool",
|
|
24
|
+
"tool",
|
|
25
|
+
"Schema",
|
|
26
|
+
"Image",
|
|
27
|
+
"Content",
|
|
28
|
+
"RateLimitedMixin",
|
|
29
|
+
"Resource",
|
|
30
|
+
"resource",
|
|
31
|
+
"registry",
|
|
32
|
+
"tool_registry",
|
|
33
|
+
"resource_registry",
|
|
34
|
+
"BasePermission",
|
|
35
|
+
"AllowAny",
|
|
36
|
+
"IsAuthenticated",
|
|
37
|
+
"HasDjangoPerm",
|
|
38
|
+
"MCPError",
|
|
39
|
+
"BadRequest",
|
|
40
|
+
"NotFound",
|
|
41
|
+
"PermissionDenied",
|
|
42
|
+
"RateLimited",
|
|
43
|
+
"Invalid",
|
|
44
|
+
"NotAuthenticated",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
default_app_config = "django_mcp_kit.apps.DjangoMCPKitConfig"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoMCPKitConfig(AppConfig):
|
|
5
|
+
name = "django_mcp_kit"
|
|
6
|
+
verbose_name = "Django MCP Kit"
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
# Import every app's mcp_tools.py so tools/resources self-register.
|
|
11
|
+
from .registry import autodiscover
|
|
12
|
+
|
|
13
|
+
autodiscover()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""ASGI entrypoints.
|
|
2
|
+
|
|
3
|
+
``get_application()`` builds the standalone MCP ASGI app from the configured transport
|
|
4
|
+
(``DJANGO_MCP_KIT["TRANSPORT"]``). ``mount()`` composes the MCP app beside an existing
|
|
5
|
+
Django ASGI app for the co-located topology (A).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import import_module
|
|
11
|
+
|
|
12
|
+
from . import conf
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_application(dispatcher=None):
|
|
16
|
+
"""Build the MCP ASGI app from the configured transport module."""
|
|
17
|
+
module = import_module(conf.get_setting("TRANSPORT"))
|
|
18
|
+
return module.build_application(dispatcher)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def mount(django_asgi_app, prefix="/mcp"):
|
|
22
|
+
"""Route ``prefix`` (and the MCP app's discovery/health) to the MCP app, and
|
|
23
|
+
everything else to ``django_asgi_app`` (topology A -- one ASGI process).
|
|
24
|
+
|
|
25
|
+
The combined app forwards the ASGI ``lifespan`` to the MCP app so the
|
|
26
|
+
Streamable-HTTP session manager starts; HTTP requests are dispatched by path.
|
|
27
|
+
"""
|
|
28
|
+
mcp_app = get_application()
|
|
29
|
+
mcp_paths = (prefix, "/healthz", "/.well-known/oauth-protected-resource")
|
|
30
|
+
|
|
31
|
+
async def application(scope, receive, send):
|
|
32
|
+
if scope["type"] == "lifespan":
|
|
33
|
+
await mcp_app(scope, receive, send)
|
|
34
|
+
return
|
|
35
|
+
path = scope.get("path", "")
|
|
36
|
+
if any(path == p or path.startswith(p + "/") or path.startswith(prefix) for p in mcp_paths):
|
|
37
|
+
await mcp_app(scope, receive, send)
|
|
38
|
+
else:
|
|
39
|
+
await django_asgi_app(scope, receive, send)
|
|
40
|
+
|
|
41
|
+
return application
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .base import (
|
|
2
|
+
Authenticator,
|
|
3
|
+
authenticate_request,
|
|
4
|
+
bearer_token,
|
|
5
|
+
get_backends,
|
|
6
|
+
resource_metadata_path,
|
|
7
|
+
resource_metadata_url,
|
|
8
|
+
)
|
|
9
|
+
from .bearer import StaticBearer
|
|
10
|
+
from .oauth import OAuthResourceServer, protected_resource_metadata
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Authenticator",
|
|
14
|
+
"StaticBearer",
|
|
15
|
+
"OAuthResourceServer",
|
|
16
|
+
"authenticate_request",
|
|
17
|
+
"bearer_token",
|
|
18
|
+
"get_backends",
|
|
19
|
+
"protected_resource_metadata",
|
|
20
|
+
"resource_metadata_path",
|
|
21
|
+
"resource_metadata_url",
|
|
22
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Pluggable authentication for the resource server.
|
|
2
|
+
|
|
3
|
+
An :class:`Authenticator` validates the incoming request and returns a Django
|
|
4
|
+
``User`` (or ``None``). Backends listed in ``DJANGO_MCP_KIT["AUTH_BACKENDS"]`` are
|
|
5
|
+
tried in order, so a deployment can support OAuth *and* static bearer at once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from .. import conf
|
|
13
|
+
from ..utils import import_object
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Authenticator:
|
|
17
|
+
def authenticate(self, request): # pragma: no cover - interface
|
|
18
|
+
"""Return a Django ``User`` or ``None``."""
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def challenge(self):
|
|
22
|
+
"""Optional ``(status, headers)`` to send when no backend authenticates."""
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bearer_token(request):
|
|
27
|
+
"""Extract a ``Bearer`` token from a Django request or a header-mapping shim."""
|
|
28
|
+
header = ""
|
|
29
|
+
headers = getattr(request, "headers", None)
|
|
30
|
+
if headers is not None:
|
|
31
|
+
header = headers.get("Authorization") or headers.get("authorization") or ""
|
|
32
|
+
else:
|
|
33
|
+
header = getattr(request, "META", {}).get("HTTP_AUTHORIZATION", "")
|
|
34
|
+
prefix = "Bearer "
|
|
35
|
+
if header.startswith(prefix):
|
|
36
|
+
return header[len(prefix):].strip()
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_backends():
|
|
41
|
+
return [import_object(path)() for path in conf.get_setting("AUTH_BACKENDS")]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def authenticate_request(request, backends=None):
|
|
45
|
+
"""Try each backend in order.
|
|
46
|
+
|
|
47
|
+
Returns ``(user, None)`` on success, or ``(None, (status, headers))`` with the
|
|
48
|
+
first backend's challenge when nothing authenticates.
|
|
49
|
+
"""
|
|
50
|
+
backends = backends if backends is not None else get_backends()
|
|
51
|
+
for backend in backends:
|
|
52
|
+
user = backend.authenticate(request)
|
|
53
|
+
if user is not None:
|
|
54
|
+
return user, None
|
|
55
|
+
for backend in backends:
|
|
56
|
+
challenge = backend.challenge()
|
|
57
|
+
if challenge:
|
|
58
|
+
return None, challenge
|
|
59
|
+
return None, (401, {"WWW-Authenticate": "Bearer"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resource_metadata_path():
|
|
63
|
+
"""RFC 9728 well-known path."""
|
|
64
|
+
return "/.well-known/oauth-protected-resource"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resource_metadata_url():
|
|
68
|
+
"""Absolute URL of the protected-resource metadata document."""
|
|
69
|
+
resource = conf.get_setting("RESOURCE_SERVER_URL") or ""
|
|
70
|
+
parsed = urlparse(resource)
|
|
71
|
+
if parsed.scheme and parsed.netloc:
|
|
72
|
+
return f"{parsed.scheme}://{parsed.netloc}{resource_metadata_path()}"
|
|
73
|
+
return resource_metadata_path()
|