django-admin-mcp-api 0.1.0a0__py3-none-any.whl
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.
- LICENSE +21 -0
- django_admin_mcp_api/README.md +30 -0
- django_admin_mcp_api/__init__.py +22 -0
- django_admin_mcp_api/apps.py +30 -0
- django_admin_mcp_api/conf.py +57 -0
- django_admin_mcp_api/server/README.md +33 -0
- django_admin_mcp_api/server/__init__.py +17 -0
- django_admin_mcp_api/server/dispatch.py +223 -0
- django_admin_mcp_api/server/errors.py +41 -0
- django_admin_mcp_api/server/jsonrpc.py +98 -0
- django_admin_mcp_api/server/manifest.py +51 -0
- django_admin_mcp_api/server/views.py +193 -0
- django_admin_mcp_api/tools/README.md +45 -0
- django_admin_mcp_api/tools/__init__.py +71 -0
- django_admin_mcp_api/tools/action.py +53 -0
- django_admin_mcp_api/tools/add_form.py +37 -0
- django_admin_mcp_api/tools/autocomplete.py +42 -0
- django_admin_mcp_api/tools/base.py +65 -0
- django_admin_mcp_api/tools/bulk_update.py +47 -0
- django_admin_mcp_api/tools/create.py +44 -0
- django_admin_mcp_api/tools/delete_preview.py +42 -0
- django_admin_mcp_api/tools/destroy.py +38 -0
- django_admin_mcp_api/tools/history.py +38 -0
- django_admin_mcp_api/tools/list_objects.py +48 -0
- django_admin_mcp_api/tools/panel.py +47 -0
- django_admin_mcp_api/tools/recent_actions.py +28 -0
- django_admin_mcp_api/tools/registry.py +28 -0
- django_admin_mcp_api/tools/retrieve.py +39 -0
- django_admin_mcp_api/tools/schema.py +28 -0
- django_admin_mcp_api/tools/set_password.py +50 -0
- django_admin_mcp_api/tools/update.py +44 -0
- django_admin_mcp_api/urls.py +35 -0
- django_admin_mcp_api-0.1.0a0.dist-info/LICENSE +21 -0
- django_admin_mcp_api-0.1.0a0.dist-info/METADATA +281 -0
- django_admin_mcp_api-0.1.0a0.dist-info/RECORD +36 -0
- django_admin_mcp_api-0.1.0a0.dist-info/WHEEL +4 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 django-admin-mcp contributors
|
|
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,30 @@
|
|
|
1
|
+
# `django_admin_mcp_api/`
|
|
2
|
+
|
|
3
|
+
The shipped package. A Django app + an MCP wire-protocol adapter over
|
|
4
|
+
[`django-admin-rest-api`](https://github.com/MartinCastroAlvarez/django-admin-rest-api).
|
|
5
|
+
|
|
6
|
+
## What lives here
|
|
7
|
+
|
|
8
|
+
| File / dir | Purpose |
|
|
9
|
+
| --------------------- | ------------------------------------------------------------- |
|
|
10
|
+
| `__init__.py` | Version + default app config pointer. |
|
|
11
|
+
| `apps.py` | `DjangoAdminMcpApiConfig` — registered via `INSTALLED_APPS`. |
|
|
12
|
+
| `conf.py` | The one place to read `DJANGO_ADMIN_MCP_API` settings from. |
|
|
13
|
+
| `urls.py` | Two URLs: `POST /` (MCP JSON-RPC) and `GET /manifest/`. |
|
|
14
|
+
| [`server/`](server/) | The wire layer (views, JSON-RPC, dispatcher, manifest). |
|
|
15
|
+
| [`tools/`](tools/) | One module per MCP tool (16 tools total). |
|
|
16
|
+
|
|
17
|
+
## What must NOT live here
|
|
18
|
+
|
|
19
|
+
- Database queries.
|
|
20
|
+
- Permission checks (the staff gate in `server/views.py` is the only
|
|
21
|
+
exception, and it is a baseline — the real check is in rest-api).
|
|
22
|
+
- Serialization.
|
|
23
|
+
- Any new admin behaviour.
|
|
24
|
+
|
|
25
|
+
## Pointers
|
|
26
|
+
|
|
27
|
+
- [`../README.md`](../README.md) — user-facing docs.
|
|
28
|
+
- [`../ARCHITECTURE.md`](../ARCHITECTURE.md) — request flow + dispatcher seam.
|
|
29
|
+
- [`../SECURITY.md`](../SECURITY.md) — security invariants.
|
|
30
|
+
- [`../CLAUDE.md`](../CLAUDE.md) — agent contract.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""django-admin-mcp-api: an MCP (Model Context Protocol) server for the Django admin.
|
|
2
|
+
|
|
3
|
+
The package exposes every operation of a consumer's ``ModelAdmin`` as an MCP
|
|
4
|
+
tool, reusing Django's session + CSRF auth and the consumer's existing
|
|
5
|
+
``AdminSite`` registry. See ``ARCHITECTURE.md`` for the full design and
|
|
6
|
+
``SECURITY.md`` for the non-negotiable security rules.
|
|
7
|
+
|
|
8
|
+
This is the public surface; everything else under ``server/`` and
|
|
9
|
+
``tools/`` is implementation. The Django app config is auto-discovered
|
|
10
|
+
via ``apps.py`` once ``"django_admin_mcp_api"`` is added to
|
|
11
|
+
``INSTALLED_APPS``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0a0"
|
|
17
|
+
|
|
18
|
+
# Default Django app config — consumers add ``"django_admin_mcp_api"`` to
|
|
19
|
+
# INSTALLED_APPS and Django picks this up automatically.
|
|
20
|
+
default_app_config = "django_admin_mcp_api.apps.DjangoAdminMcpApiConfig"
|
|
21
|
+
|
|
22
|
+
__all__ = ["__version__", "default_app_config"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Django app config for django-admin-mcp-api."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DjangoAdminMcpApiConfig(AppConfig):
|
|
9
|
+
"""App config for ``django_admin_mcp_api``.
|
|
10
|
+
|
|
11
|
+
Registered via ``INSTALLED_APPS``. We deliberately do not perform any
|
|
12
|
+
side-effects in ``ready()`` other than configuration validation —
|
|
13
|
+
every endpoint is opt-in through the consumer's URL conf.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name = "django_admin_mcp_api"
|
|
17
|
+
label = "django_admin_mcp_api"
|
|
18
|
+
verbose_name = "Django Admin MCP API"
|
|
19
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
20
|
+
|
|
21
|
+
def ready(self) -> None:
|
|
22
|
+
"""Validate settings on app startup.
|
|
23
|
+
|
|
24
|
+
Kept light to avoid slow Django boot in test suites. Any future
|
|
25
|
+
signal wiring goes here.
|
|
26
|
+
"""
|
|
27
|
+
# Importing the conf module triggers settings validation via
|
|
28
|
+
# ``django.core.checks``-friendly accessors. See
|
|
29
|
+
# ``django_admin_mcp_api.conf`` for the actual checks.
|
|
30
|
+
from django_admin_mcp_api import conf # noqa: F401
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Settings accessors for django-admin-mcp-api.
|
|
2
|
+
|
|
3
|
+
Every consumer-tunable knob is read through this module, never directly
|
|
4
|
+
from ``django.conf.settings``. That keeps defaults and validation in one
|
|
5
|
+
place and makes the surface easy to enumerate in docs.
|
|
6
|
+
|
|
7
|
+
All settings live under the ``DJANGO_ADMIN_MCP_API`` namespace:
|
|
8
|
+
|
|
9
|
+
.. code-block:: python
|
|
10
|
+
|
|
11
|
+
DJANGO_ADMIN_MCP_API = {
|
|
12
|
+
"PROTOCOL_VERSION": "2024-11-05", # MCP spec version we speak
|
|
13
|
+
"SERVER_NAME": "django-admin", # advertised via initialize
|
|
14
|
+
"ADMIN_SITE": "django.contrib.admin.site", # dotted path
|
|
15
|
+
"ALLOW_ANONYMOUS": False, # MUST stay False in production
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Anything not present in the consumer's settings falls back to the
|
|
19
|
+
defaults in :data:`DEFAULTS` below.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from django.conf import settings
|
|
27
|
+
|
|
28
|
+
# Public settings namespace.
|
|
29
|
+
NAMESPACE = "DJANGO_ADMIN_MCP_API"
|
|
30
|
+
|
|
31
|
+
# Defaults — the one place to read these from.
|
|
32
|
+
DEFAULTS: dict[str, Any] = {
|
|
33
|
+
"PROTOCOL_VERSION": "2024-11-05",
|
|
34
|
+
"SERVER_NAME": "django-admin",
|
|
35
|
+
"SERVER_VERSION": None, # falls back to the package __version__
|
|
36
|
+
"ADMIN_SITE": "django.contrib.admin.site",
|
|
37
|
+
"ALLOW_ANONYMOUS": False,
|
|
38
|
+
# The dotted path to a callable returning a Dispatcher. ``None``
|
|
39
|
+
# means use the built-in default (which raises NotImplementedError
|
|
40
|
+
# until django-admin-rest-api is wired — tracked in the integration
|
|
41
|
+
# issue).
|
|
42
|
+
"DISPATCHER_FACTORY": None,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get(key: str) -> Any:
|
|
47
|
+
"""Return the value for ``key``, falling back to :data:`DEFAULTS`.
|
|
48
|
+
|
|
49
|
+
Unknown keys raise :class:`KeyError` rather than silently returning
|
|
50
|
+
``None`` — that catches typos in consumer settings during startup.
|
|
51
|
+
"""
|
|
52
|
+
if key not in DEFAULTS:
|
|
53
|
+
raise KeyError(f"Unknown {NAMESPACE} setting: {key!r}")
|
|
54
|
+
bag = getattr(settings, NAMESPACE, {}) or {}
|
|
55
|
+
if key in bag:
|
|
56
|
+
return bag[key]
|
|
57
|
+
return DEFAULTS[key]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# `django_admin_mcp_api/server/`
|
|
2
|
+
|
|
3
|
+
The MCP protocol layer. Speaks MCP JSON-RPC and forwards to
|
|
4
|
+
**django-admin-rest-api**. Owns *zero* admin logic.
|
|
5
|
+
|
|
6
|
+
## What lives here
|
|
7
|
+
|
|
8
|
+
| Module | Purpose |
|
|
9
|
+
| ------------ | ---------------------------------------------------------------- |
|
|
10
|
+
| `views.py` | `McpEndpointView` (POST → JSON-RPC), `ManifestView` (GET → manifest). |
|
|
11
|
+
| `jsonrpc.py` | JSON-RPC 2.0 request/response envelopes + parse helpers. |
|
|
12
|
+
| `errors.py` | MCP / JSON-RPC error code constants. |
|
|
13
|
+
| `manifest.py`| Builds the tool catalogue from the `tools/` registry. |
|
|
14
|
+
| `dispatch.py`| The single forward point into django-admin-rest-api. |
|
|
15
|
+
|
|
16
|
+
## What must NOT live here
|
|
17
|
+
|
|
18
|
+
- Database queries.
|
|
19
|
+
- Permission checks (Django session + CSRF + `ModelAdmin.has_*_permission`
|
|
20
|
+
are the only auth signals, and they belong to django-admin-rest-api).
|
|
21
|
+
- Field serialization. The rest-api response is forwarded as-is in the
|
|
22
|
+
MCP `result.content`.
|
|
23
|
+
- Any feature that is not already present in django-admin-rest-api.
|
|
24
|
+
|
|
25
|
+
If you find yourself reaching for `objects.all()`, `user.has_perm`, or a
|
|
26
|
+
serializer, you are in the wrong package — open the change against
|
|
27
|
+
django-admin-rest-api instead.
|
|
28
|
+
|
|
29
|
+
## Pointers
|
|
30
|
+
|
|
31
|
+
- [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) — the wire shape.
|
|
32
|
+
- [`../../SECURITY.md`](../../SECURITY.md) — the non-negotiables.
|
|
33
|
+
- [`../tools/README.md`](../tools/README.md) — the tool catalogue.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""MCP server layer.
|
|
2
|
+
|
|
3
|
+
This package owns the wire protocol (MCP JSON-RPC over HTTP) and nothing
|
|
4
|
+
else. All admin business logic — permissions, querysets, forms,
|
|
5
|
+
serialization, validation — lives in **django-admin-rest-api**. Code here
|
|
6
|
+
must never:
|
|
7
|
+
|
|
8
|
+
- query the database directly
|
|
9
|
+
- call ``user.has_perm`` or any other permission check
|
|
10
|
+
- serialize or deserialize a model
|
|
11
|
+
- mutate any data
|
|
12
|
+
|
|
13
|
+
Every MCP tool call is reshaped into the equivalent django-admin-rest-api
|
|
14
|
+
HTTP request and forwarded through the dispatcher (see ``dispatch.py``).
|
|
15
|
+
The response is reshaped back into an MCP JSON-RPC payload. That is all
|
|
16
|
+
this package does.
|
|
17
|
+
"""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The single forward point into django-admin-rest-api.
|
|
2
|
+
|
|
3
|
+
``Dispatcher`` is the only seam between this package and the underlying
|
|
4
|
+
REST API. Everything else in ``django_admin_mcp_api`` — views, manifest,
|
|
5
|
+
tools — talks to the dispatcher and nothing else. That keeps the
|
|
6
|
+
"protocol-only, no admin logic" invariant easy to enforce: this file is
|
|
7
|
+
the *only* place where the integration shape lives.
|
|
8
|
+
|
|
9
|
+
## The shape
|
|
10
|
+
|
|
11
|
+
Each MCP tool call carries ``(tool_name, arguments)``. The dispatcher
|
|
12
|
+
translates that into a ``DispatchTarget`` (HTTP method + path + body)
|
|
13
|
+
against django-admin-rest-api, forwards it through the rest-api view
|
|
14
|
+
matching that path, and returns the ``HttpResponseBase`` for the view
|
|
15
|
+
layer to shape back into a JSON-RPC envelope.
|
|
16
|
+
|
|
17
|
+
The default implementation is :class:`RestApiDispatcher`, which uses
|
|
18
|
+
Django's URL resolver against ``django_admin_rest_api.api.urls`` to
|
|
19
|
+
find the target view and calls it with a synthetic request that carries
|
|
20
|
+
the original session, user, cookies, and CSRF state.
|
|
21
|
+
|
|
22
|
+
Consumers can override the dispatcher via the
|
|
23
|
+
``DJANGO_ADMIN_MCP_API["DISPATCHER_FACTORY"]`` setting (a dotted path
|
|
24
|
+
to a zero-arg callable that returns a :class:`Dispatcher`). Tests use
|
|
25
|
+
this seam to swap in a :class:`FakeDispatcher` that records the
|
|
26
|
+
forwards instead of executing them.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from importlib import import_module
|
|
34
|
+
from typing import Any
|
|
35
|
+
from typing import Protocol
|
|
36
|
+
from typing import runtime_checkable
|
|
37
|
+
from urllib.parse import urlencode
|
|
38
|
+
|
|
39
|
+
from django.http import HttpRequest
|
|
40
|
+
from django.http import HttpResponseBase
|
|
41
|
+
from django.test import RequestFactory
|
|
42
|
+
from django.urls import Resolver404
|
|
43
|
+
from django.urls import resolve
|
|
44
|
+
|
|
45
|
+
from django_admin_mcp_api import conf
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class DispatchTarget:
|
|
50
|
+
"""A translated MCP tool call, ready to forward to rest-api.
|
|
51
|
+
|
|
52
|
+
``path`` is rooted at the rest-api API root (e.g.
|
|
53
|
+
``/<app>/<model>/<pk>/`` — *not* including the ``api/v1/`` prefix or
|
|
54
|
+
the consumer's mount point; those are handled by the dispatcher).
|
|
55
|
+
``body`` is a JSON-serialisable object; the dispatcher encodes it.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
method: str # "GET", "POST", "PATCH", "DELETE"
|
|
59
|
+
path: str
|
|
60
|
+
body: dict[str, Any] | None = None
|
|
61
|
+
query: dict[str, str] | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@runtime_checkable
|
|
65
|
+
class Dispatcher(Protocol):
|
|
66
|
+
"""The seam to django-admin-rest-api.
|
|
67
|
+
|
|
68
|
+
Implementations forward ``target`` against the rest-api views,
|
|
69
|
+
propagating the consumer's authenticated ``HttpRequest`` (session +
|
|
70
|
+
CSRF) untouched. They must not mutate the original ``request``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def dispatch(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
request: HttpRequest,
|
|
77
|
+
target: DispatchTarget,
|
|
78
|
+
) -> HttpResponseBase: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RestApiDispatcher:
|
|
82
|
+
"""Forward a :class:`DispatchTarget` to django-admin-rest-api views.
|
|
83
|
+
|
|
84
|
+
Resolves the target's path against ``django_admin_rest_api.api.urls``
|
|
85
|
+
to find the view function, then constructs a synthetic
|
|
86
|
+
:class:`HttpRequest` that carries the original request's
|
|
87
|
+
``user``, ``session``, cookies, messages backend, and CSRF state.
|
|
88
|
+
The synthetic request is what rest-api's view sees — auth therefore
|
|
89
|
+
matches the MCP caller exactly, and rest-api's permission +
|
|
90
|
+
queryset checks run unchanged.
|
|
91
|
+
|
|
92
|
+
The dispatcher does not mutate ``request`` or query the database.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
URLCONF = "django_admin_rest_api.api.urls"
|
|
96
|
+
|
|
97
|
+
def dispatch(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
request: HttpRequest,
|
|
101
|
+
target: DispatchTarget,
|
|
102
|
+
) -> HttpResponseBase:
|
|
103
|
+
synthetic = self._build_synthetic_request(request, target)
|
|
104
|
+
try:
|
|
105
|
+
match = resolve(target.path, urlconf=self.URLCONF)
|
|
106
|
+
except Resolver404 as exc:
|
|
107
|
+
raise UnknownRestApiPath(target.path) from exc
|
|
108
|
+
return match.func(synthetic, *match.args, **match.kwargs)
|
|
109
|
+
|
|
110
|
+
def _build_synthetic_request(
|
|
111
|
+
self,
|
|
112
|
+
request: HttpRequest,
|
|
113
|
+
target: DispatchTarget,
|
|
114
|
+
) -> HttpRequest:
|
|
115
|
+
factory = RequestFactory()
|
|
116
|
+
method = target.method.lower()
|
|
117
|
+
builder = getattr(factory, method, None)
|
|
118
|
+
if builder is None:
|
|
119
|
+
raise UnsupportedDispatchMethod(target.method)
|
|
120
|
+
|
|
121
|
+
path = target.path
|
|
122
|
+
if target.query:
|
|
123
|
+
path = f"{path}?{urlencode(target.query, doseq=True)}"
|
|
124
|
+
|
|
125
|
+
kwargs: dict[str, Any] = {}
|
|
126
|
+
if target.body is not None and target.method.upper() in {"POST", "PATCH", "PUT", "DELETE"}:
|
|
127
|
+
kwargs["data"] = json.dumps(target.body)
|
|
128
|
+
kwargs["content_type"] = "application/json"
|
|
129
|
+
|
|
130
|
+
synthetic = builder(path, **kwargs)
|
|
131
|
+
|
|
132
|
+
# Carry over auth-bearing attributes so rest-api sees the same
|
|
133
|
+
# caller that the MCP endpoint saw. We do not mutate the
|
|
134
|
+
# original ``request`` — the synthetic gets its own attributes.
|
|
135
|
+
synthetic.user = request.user
|
|
136
|
+
if hasattr(request, "session"):
|
|
137
|
+
synthetic.session = request.session
|
|
138
|
+
synthetic.COOKIES = request.COOKIES
|
|
139
|
+
if hasattr(request, "_messages"):
|
|
140
|
+
synthetic._messages = request._messages
|
|
141
|
+
if hasattr(request, "_dont_enforce_csrf_checks"):
|
|
142
|
+
synthetic._dont_enforce_csrf_checks = request._dont_enforce_csrf_checks
|
|
143
|
+
return synthetic
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PendingDispatcher:
|
|
147
|
+
"""Fallback used when the consumer has not installed rest-api.
|
|
148
|
+
|
|
149
|
+
Every call raises :class:`NotImplementedError` with a pointer to the
|
|
150
|
+
rest-api install step. The :func:`get_dispatcher` factory only
|
|
151
|
+
falls back to this if importing rest-api fails, so a normal install
|
|
152
|
+
never sees it.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
REASON = (
|
|
156
|
+
"django-admin-rest-api is required at runtime. "
|
|
157
|
+
"Install it (``pip install django-admin-rest-api``) and add "
|
|
158
|
+
'"django_admin_rest_api" to INSTALLED_APPS.'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def dispatch(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
request: HttpRequest,
|
|
165
|
+
target: DispatchTarget,
|
|
166
|
+
) -> HttpResponseBase:
|
|
167
|
+
del request, target
|
|
168
|
+
raise NotImplementedError(self.REASON)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class UnknownRestApiPath(Exception):
|
|
172
|
+
"""Raised when a tool's path does not resolve against rest-api's URL conf."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, path: str) -> None:
|
|
175
|
+
super().__init__(f"No rest-api view matches {path!r}")
|
|
176
|
+
self.path = path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class UnsupportedDispatchMethod(Exception):
|
|
180
|
+
"""Raised when the target HTTP method is not supported by RequestFactory."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, method: str) -> None:
|
|
183
|
+
super().__init__(f"Unsupported HTTP method {method!r}")
|
|
184
|
+
self.method = method
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ImproperConfiguredDispatcher(Exception):
|
|
188
|
+
"""Raised when ``DISPATCHER_FACTORY`` resolves to something invalid."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_dispatcher() -> Dispatcher:
|
|
192
|
+
"""Return the active dispatcher.
|
|
193
|
+
|
|
194
|
+
Resolution order:
|
|
195
|
+
|
|
196
|
+
1. ``DJANGO_ADMIN_MCP_API["DISPATCHER_FACTORY"]`` — dotted path to
|
|
197
|
+
a zero-arg callable. Used by tests and by consumers who want to
|
|
198
|
+
swap in their own forwarder.
|
|
199
|
+
2. The built-in :class:`RestApiDispatcher`, provided
|
|
200
|
+
``django_admin_rest_api`` is importable.
|
|
201
|
+
3. :class:`PendingDispatcher` — only reached if rest-api is not
|
|
202
|
+
installed; every call raises :class:`NotImplementedError`.
|
|
203
|
+
"""
|
|
204
|
+
dotted = conf.get("DISPATCHER_FACTORY")
|
|
205
|
+
if dotted:
|
|
206
|
+
module_name, _, attr = dotted.rpartition(".")
|
|
207
|
+
if not module_name:
|
|
208
|
+
raise ImproperConfiguredDispatcher(
|
|
209
|
+
f"DISPATCHER_FACTORY must be a dotted path, got {dotted!r}"
|
|
210
|
+
)
|
|
211
|
+
factory = getattr(import_module(module_name), attr)
|
|
212
|
+
instance = factory()
|
|
213
|
+
if not isinstance(instance, Dispatcher):
|
|
214
|
+
raise ImproperConfiguredDispatcher(
|
|
215
|
+
f"DISPATCHER_FACTORY {dotted!r} returned {type(instance).__name__}, "
|
|
216
|
+
"which does not satisfy the Dispatcher protocol"
|
|
217
|
+
)
|
|
218
|
+
return instance
|
|
219
|
+
try:
|
|
220
|
+
import_module("django_admin_rest_api.api.urls")
|
|
221
|
+
except ImportError:
|
|
222
|
+
return PendingDispatcher()
|
|
223
|
+
return RestApiDispatcher()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 + MCP error code constants.
|
|
2
|
+
|
|
3
|
+
Reference: https://www.jsonrpc.org/specification#error_object and the
|
|
4
|
+
MCP spec error vocabulary. Keeping these in one place means views and
|
|
5
|
+
the dispatcher emit identical codes for identical conditions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# JSON-RPC 2.0 reserved codes (-32768 to -32000).
|
|
11
|
+
PARSE_ERROR = -32700
|
|
12
|
+
INVALID_REQUEST = -32600
|
|
13
|
+
METHOD_NOT_FOUND = -32601
|
|
14
|
+
INVALID_PARAMS = -32602
|
|
15
|
+
INTERNAL_ERROR = -32603
|
|
16
|
+
|
|
17
|
+
# Implementation-defined server errors (-32000 to -32099). We use a few
|
|
18
|
+
# of these to carry rest-api-layer signals out to the MCP client.
|
|
19
|
+
SERVER_ERROR_UNAUTHENTICATED = -32001
|
|
20
|
+
SERVER_ERROR_FORBIDDEN = -32002
|
|
21
|
+
SERVER_ERROR_NOT_FOUND = -32003
|
|
22
|
+
SERVER_ERROR_CSRF = -32004
|
|
23
|
+
SERVER_ERROR_VALIDATION = -32005
|
|
24
|
+
SERVER_ERROR_UPSTREAM = -32099
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Human-readable defaults paired with each code; senders can override the
|
|
28
|
+
# ``message`` field when they have a more specific signal to surface.
|
|
29
|
+
DEFAULT_MESSAGES: dict[int, str] = {
|
|
30
|
+
PARSE_ERROR: "Parse error",
|
|
31
|
+
INVALID_REQUEST: "Invalid Request",
|
|
32
|
+
METHOD_NOT_FOUND: "Method not found",
|
|
33
|
+
INVALID_PARAMS: "Invalid params",
|
|
34
|
+
INTERNAL_ERROR: "Internal error",
|
|
35
|
+
SERVER_ERROR_UNAUTHENTICATED: "Authentication required",
|
|
36
|
+
SERVER_ERROR_FORBIDDEN: "Permission denied",
|
|
37
|
+
SERVER_ERROR_NOT_FOUND: "Not found",
|
|
38
|
+
SERVER_ERROR_CSRF: "CSRF verification failed",
|
|
39
|
+
SERVER_ERROR_VALIDATION: "Validation error",
|
|
40
|
+
SERVER_ERROR_UPSTREAM: "Upstream rest-api error",
|
|
41
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 envelope helpers.
|
|
2
|
+
|
|
3
|
+
The MCP spec rides on JSON-RPC 2.0. These helpers parse incoming
|
|
4
|
+
envelopes and shape outgoing ones; they do not know anything about MCP
|
|
5
|
+
*methods* or *tools* — that's the view's job.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from django_admin_mcp_api.server import errors
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonRpcError(Exception):
|
|
18
|
+
"""Raised by parse helpers; carries a JSON-RPC error code + data.
|
|
19
|
+
|
|
20
|
+
Views catch this and shape the response envelope.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
code: int,
|
|
26
|
+
message: str | None = None,
|
|
27
|
+
data: Any | None = None,
|
|
28
|
+
*,
|
|
29
|
+
request_id: Any = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message or errors.DEFAULT_MESSAGES.get(code, "Error")
|
|
33
|
+
self.data = data
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
super().__init__(self.message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class JsonRpcRequest:
|
|
40
|
+
"""A parsed JSON-RPC 2.0 request envelope."""
|
|
41
|
+
|
|
42
|
+
method: str
|
|
43
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
id: Any = None # noqa: A003 — JSON-RPC field name; not a builtin shadow here.
|
|
45
|
+
jsonrpc: str = "2.0"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_request(payload: Any) -> JsonRpcRequest:
|
|
49
|
+
"""Parse and validate a single JSON-RPC 2.0 request envelope.
|
|
50
|
+
|
|
51
|
+
Raises :class:`JsonRpcError` on any structural problem.
|
|
52
|
+
|
|
53
|
+
Batch requests (a JSON array) are not supported — the MCP spec uses
|
|
54
|
+
one request per HTTP POST. If a batch arrives we reject with
|
|
55
|
+
``INVALID_REQUEST``.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(payload, dict):
|
|
58
|
+
raise JsonRpcError(errors.INVALID_REQUEST, "Envelope must be a JSON object")
|
|
59
|
+
if payload.get("jsonrpc") != "2.0":
|
|
60
|
+
raise JsonRpcError(errors.INVALID_REQUEST, 'jsonrpc field must be "2.0"')
|
|
61
|
+
method = payload.get("method")
|
|
62
|
+
if not isinstance(method, str) or not method:
|
|
63
|
+
raise JsonRpcError(
|
|
64
|
+
errors.INVALID_REQUEST,
|
|
65
|
+
"method must be a non-empty string",
|
|
66
|
+
request_id=payload.get("id"),
|
|
67
|
+
)
|
|
68
|
+
params = payload.get("params", {})
|
|
69
|
+
if params is None:
|
|
70
|
+
params = {}
|
|
71
|
+
if not isinstance(params, dict):
|
|
72
|
+
raise JsonRpcError(
|
|
73
|
+
errors.INVALID_PARAMS,
|
|
74
|
+
"params must be an object",
|
|
75
|
+
request_id=payload.get("id"),
|
|
76
|
+
)
|
|
77
|
+
return JsonRpcRequest(method=method, params=params, id=payload.get("id"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def success(request_id: Any, result: Any) -> dict[str, Any]:
|
|
81
|
+
"""Shape a successful JSON-RPC response envelope."""
|
|
82
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def failure(
|
|
86
|
+
request_id: Any,
|
|
87
|
+
code: int,
|
|
88
|
+
message: str | None = None,
|
|
89
|
+
data: Any | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Shape a JSON-RPC error response envelope."""
|
|
92
|
+
err: dict[str, Any] = {
|
|
93
|
+
"code": code,
|
|
94
|
+
"message": message or errors.DEFAULT_MESSAGES.get(code, "Error"),
|
|
95
|
+
}
|
|
96
|
+
if data is not None:
|
|
97
|
+
err["data"] = data
|
|
98
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": err}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Build the MCP server descriptor + tool catalogue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django_admin_mcp_api import __version__
|
|
8
|
+
from django_admin_mcp_api import conf
|
|
9
|
+
from django_admin_mcp_api import tools
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def server_info() -> dict[str, str]:
|
|
13
|
+
"""Return the MCP ``initialize`` server-info block."""
|
|
14
|
+
return {
|
|
15
|
+
"name": conf.get("SERVER_NAME"),
|
|
16
|
+
"version": conf.get("SERVER_VERSION") or __version__,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def initialize_result() -> dict[str, Any]:
|
|
21
|
+
"""Build the result payload for the MCP ``initialize`` method."""
|
|
22
|
+
return {
|
|
23
|
+
"protocolVersion": conf.get("PROTOCOL_VERSION"),
|
|
24
|
+
"serverInfo": server_info(),
|
|
25
|
+
# We expose tools but no resources/prompts/sampling — those are
|
|
26
|
+
# not in scope for this adapter (and would require new code in
|
|
27
|
+
# django-admin-rest-api, which is out of bounds for this package).
|
|
28
|
+
"capabilities": {
|
|
29
|
+
"tools": {"listChanged": False},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tools_catalogue() -> list[dict[str, Any]]:
|
|
35
|
+
"""Build the ``tools/list`` result payload."""
|
|
36
|
+
return [tool.to_manifest_entry() for tool in tools.all_tools()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def manifest() -> dict[str, Any]:
|
|
40
|
+
"""Build the read-only GET /manifest/ document.
|
|
41
|
+
|
|
42
|
+
This is *not* part of the MCP spec — it's a curl-friendly view of
|
|
43
|
+
the same content for humans and dashboards. The MCP-spec-correct
|
|
44
|
+
catalogue is what ``tools/list`` returns; this endpoint wraps it
|
|
45
|
+
with server metadata so a single GET gives the full picture.
|
|
46
|
+
"""
|
|
47
|
+
return {
|
|
48
|
+
"server": server_info(),
|
|
49
|
+
"protocolVersion": conf.get("PROTOCOL_VERSION"),
|
|
50
|
+
"tools": tools_catalogue(),
|
|
51
|
+
}
|