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.
Files changed (36) hide show
  1. django_mcp_kit-0.1.0/PKG-INFO +252 -0
  2. django_mcp_kit-0.1.0/README.md +230 -0
  3. django_mcp_kit-0.1.0/django_mcp_kit/__init__.py +47 -0
  4. django_mcp_kit-0.1.0/django_mcp_kit/apps.py +13 -0
  5. django_mcp_kit-0.1.0/django_mcp_kit/asgi.py +41 -0
  6. django_mcp_kit-0.1.0/django_mcp_kit/auth/__init__.py +22 -0
  7. django_mcp_kit-0.1.0/django_mcp_kit/auth/base.py +73 -0
  8. django_mcp_kit-0.1.0/django_mcp_kit/auth/bearer.py +36 -0
  9. django_mcp_kit-0.1.0/django_mcp_kit/auth/oauth.py +53 -0
  10. django_mcp_kit-0.1.0/django_mcp_kit/conf.py +44 -0
  11. django_mcp_kit-0.1.0/django_mcp_kit/dispatcher.py +155 -0
  12. django_mcp_kit-0.1.0/django_mcp_kit/drf.py +209 -0
  13. django_mcp_kit-0.1.0/django_mcp_kit/errors.py +96 -0
  14. django_mcp_kit-0.1.0/django_mcp_kit/management/__init__.py +0 -0
  15. django_mcp_kit-0.1.0/django_mcp_kit/management/commands/__init__.py +0 -0
  16. django_mcp_kit-0.1.0/django_mcp_kit/management/commands/create_mcp_oauth_client.py +48 -0
  17. django_mcp_kit-0.1.0/django_mcp_kit/management/commands/runserver_mcp.py +49 -0
  18. django_mcp_kit-0.1.0/django_mcp_kit/models.py +90 -0
  19. django_mcp_kit-0.1.0/django_mcp_kit/oauth_client.py +62 -0
  20. django_mcp_kit-0.1.0/django_mcp_kit/permissions.py +45 -0
  21. django_mcp_kit-0.1.0/django_mcp_kit/registry.py +74 -0
  22. django_mcp_kit-0.1.0/django_mcp_kit/resources.py +96 -0
  23. django_mcp_kit-0.1.0/django_mcp_kit/schema.py +68 -0
  24. django_mcp_kit-0.1.0/django_mcp_kit/services.py +53 -0
  25. django_mcp_kit-0.1.0/django_mcp_kit/tools.py +193 -0
  26. django_mcp_kit-0.1.0/django_mcp_kit/transports/__init__.py +0 -0
  27. django_mcp_kit-0.1.0/django_mcp_kit/transports/http.py +15 -0
  28. django_mcp_kit-0.1.0/django_mcp_kit/transports/sdk.py +192 -0
  29. django_mcp_kit-0.1.0/django_mcp_kit/urls.py +33 -0
  30. django_mcp_kit-0.1.0/django_mcp_kit/utils.py +31 -0
  31. django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/__init__.py +0 -0
  32. django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/apps.py +8 -0
  33. django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/migrations/0001_initial.py +28 -0
  34. django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/migrations/__init__.py +0 -0
  35. django_mcp_kit-0.1.0/django_mcp_kit/wagtail_connector/models.py +47 -0
  36. 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()