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.
Files changed (36) hide show
  1. LICENSE +21 -0
  2. django_admin_mcp_api/README.md +30 -0
  3. django_admin_mcp_api/__init__.py +22 -0
  4. django_admin_mcp_api/apps.py +30 -0
  5. django_admin_mcp_api/conf.py +57 -0
  6. django_admin_mcp_api/server/README.md +33 -0
  7. django_admin_mcp_api/server/__init__.py +17 -0
  8. django_admin_mcp_api/server/dispatch.py +223 -0
  9. django_admin_mcp_api/server/errors.py +41 -0
  10. django_admin_mcp_api/server/jsonrpc.py +98 -0
  11. django_admin_mcp_api/server/manifest.py +51 -0
  12. django_admin_mcp_api/server/views.py +193 -0
  13. django_admin_mcp_api/tools/README.md +45 -0
  14. django_admin_mcp_api/tools/__init__.py +71 -0
  15. django_admin_mcp_api/tools/action.py +53 -0
  16. django_admin_mcp_api/tools/add_form.py +37 -0
  17. django_admin_mcp_api/tools/autocomplete.py +42 -0
  18. django_admin_mcp_api/tools/base.py +65 -0
  19. django_admin_mcp_api/tools/bulk_update.py +47 -0
  20. django_admin_mcp_api/tools/create.py +44 -0
  21. django_admin_mcp_api/tools/delete_preview.py +42 -0
  22. django_admin_mcp_api/tools/destroy.py +38 -0
  23. django_admin_mcp_api/tools/history.py +38 -0
  24. django_admin_mcp_api/tools/list_objects.py +48 -0
  25. django_admin_mcp_api/tools/panel.py +47 -0
  26. django_admin_mcp_api/tools/recent_actions.py +28 -0
  27. django_admin_mcp_api/tools/registry.py +28 -0
  28. django_admin_mcp_api/tools/retrieve.py +39 -0
  29. django_admin_mcp_api/tools/schema.py +28 -0
  30. django_admin_mcp_api/tools/set_password.py +50 -0
  31. django_admin_mcp_api/tools/update.py +44 -0
  32. django_admin_mcp_api/urls.py +35 -0
  33. django_admin_mcp_api-0.1.0a0.dist-info/LICENSE +21 -0
  34. django_admin_mcp_api-0.1.0a0.dist-info/METADATA +281 -0
  35. django_admin_mcp_api-0.1.0a0.dist-info/RECORD +36 -0
  36. 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
+ }