django-mcp-kit 0.1.0__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.
@@ -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"
django_mcp_kit/apps.py ADDED
@@ -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()
django_mcp_kit/asgi.py ADDED
@@ -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()
@@ -0,0 +1,36 @@
1
+ """Static bearer-token authentication.
2
+
3
+ Resolves a token to a user via a project-supplied callable, configured as a dotted
4
+ path in ``DJANGO_MCP_KIT["STATIC_BEARER_RESOLVER"]`` (e.g.
5
+ ``"myapp.models:UserProfile.user_for_token"``). Keeps the per-user token model that
6
+ Claude Code / Desktop use today.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .. import conf
12
+ from ..utils import import_object
13
+ from .base import Authenticator, bearer_token
14
+
15
+
16
+ class StaticBearer(Authenticator):
17
+ def __init__(self):
18
+ self._resolver = None
19
+
20
+ def resolver(self):
21
+ if self._resolver is None:
22
+ path = conf.get_setting("STATIC_BEARER_RESOLVER")
23
+ self._resolver = import_object(path) if path else False
24
+ return self._resolver or None
25
+
26
+ def authenticate(self, request):
27
+ token = bearer_token(request)
28
+ if not token:
29
+ return None
30
+ resolve = self.resolver()
31
+ if resolve is None:
32
+ return None
33
+ user = resolve(token)
34
+ if user is None or not getattr(user, "is_active", False):
35
+ return None
36
+ return user
@@ -0,0 +1,53 @@
1
+ """OAuth 2.1 resource server (django-oauth-toolkit).
2
+
3
+ Validate a DOT access token, resolve the Django user, and check required scopes. Also provides the RFC 9728
4
+ protected-resource metadata and the ``401 + WWW-Authenticate`` challenge -- these are
5
+ *ours*, not the SDK's.
6
+
7
+ ``oauth2_provider`` is imported lazily, so it's only loaded when this backend is used.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .. import conf
13
+ from .base import Authenticator, bearer_token, resource_metadata_url
14
+
15
+
16
+ class OAuthResourceServer(Authenticator):
17
+ def authenticate(self, request):
18
+ token = bearer_token(request)
19
+ if not token:
20
+ return None
21
+
22
+ from oauth2_provider.models import AccessToken as OAuthToken
23
+
24
+ oauth_token = (
25
+ OAuthToken.objects.filter(token=token).select_related("user").first()
26
+ )
27
+ if oauth_token is None or not oauth_token.is_valid():
28
+ return None
29
+ if oauth_token.user_id is None or not oauth_token.user.is_active:
30
+ return None
31
+
32
+ required = set(conf.get_setting("REQUIRED_SCOPES") or [])
33
+ granted = set(oauth_token.scope.split() if oauth_token.scope else [])
34
+ if required and not required <= granted:
35
+ return None
36
+
37
+ return oauth_token.user
38
+
39
+ def challenge(self):
40
+ meta = resource_metadata_url()
41
+ return 401, {"WWW-Authenticate": f'Bearer resource_metadata="{meta}"'}
42
+
43
+
44
+ def protected_resource_metadata():
45
+ """RFC 9728 metadata document served at /.well-known/oauth-protected-resource."""
46
+ issuer = conf.get_setting("OAUTH_ISSUER_URL")
47
+ resource = conf.get_setting("RESOURCE_SERVER_URL")
48
+ return {
49
+ "resource": resource,
50
+ "authorization_servers": [issuer] if issuer else [],
51
+ "scopes_supported": list(conf.get_setting("REQUIRED_SCOPES") or []),
52
+ "bearer_methods_supported": ["header"],
53
+ }
django_mcp_kit/conf.py ADDED
@@ -0,0 +1,44 @@
1
+ """Single access point for the ``DJANGO_MCP_KIT`` settings dict.
2
+
3
+ Keeping every setting read in one place means the rest of the library never reaches
4
+ into ``django.conf.settings`` directly for its own config.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from django.conf import settings
10
+
11
+ DEFAULTS = {
12
+ "SERVER_NAME": "django-mcp-kit",
13
+ "SERVER_VERSION": "0.1.0",
14
+ "AUTH_BACKENDS": ["django_mcp_kit.auth.StaticBearer"],
15
+ "TRANSPORT": "django_mcp_kit.transports.sdk",
16
+ # OAuth resource-server params (see auth/oauth.py):
17
+ "OAUTH_ISSUER_URL": None,
18
+ "RESOURCE_SERVER_URL": None,
19
+ "REQUIRED_SCOPES": ["mcp"],
20
+ # Name shown on the OAuth consent page (the DOT Application.name).
21
+ "OAUTH_APP_NAME": "MCP connector",
22
+ # Transport security: opt-in, off by default.
23
+ "DNS_REBINDING_PROTECTION": False,
24
+ "STATELESS": True,
25
+ "REQUIRE_AUTH": True,
26
+ # Deployment:
27
+ "TOPOLOGY": "separate",
28
+ "PORT": 8810,
29
+ # StaticBearer token -> user resolver (dotted path to a callable taking a token
30
+ # string and returning a User or None). e.g. "myapp.models.UserProfile.user_for_token".
31
+ "STATIC_BEARER_RESOLVER": None,
32
+ # Per-user write rate limit used by RateLimitedMixin (count, window-seconds).
33
+ "WRITE_RATE_LIMIT": (30, 60),
34
+ }
35
+
36
+
37
+ def get_setting(name, default=None):
38
+ """Return ``DJANGO_MCP_KIT[name]``, falling back to the built-in default."""
39
+ cfg = getattr(settings, "DJANGO_MCP_KIT", {}) or {}
40
+ if name in cfg:
41
+ return cfg[name]
42
+ if name in DEFAULTS:
43
+ return DEFAULTS[name]
44
+ return default
@@ -0,0 +1,155 @@
1
+ """The transport-neutral MCP dispatcher.
2
+
3
+ ``MCPDispatcher`` owns ``initialize`` / ``tools.list`` / ``tools.call`` and the
4
+ resource methods. It imports **no transport and no MCP SDK** -- transports drive it
5
+ by calling these coroutines and the dispatcher hands back plain dicts. User
6
+ resolution, permission checks, sync/async execution, and result/error formatting all
7
+ live here in one reusable loop.
8
+
9
+ The authenticated ``user`` is resolved by the transport/auth layer and passed in;
10
+ the dispatcher never authenticates.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import inspect
16
+
17
+ from asgiref.sync import sync_to_async
18
+
19
+ from . import conf
20
+ from .errors import MCPError, PermissionDenied, from_service_error
21
+ from .registry import resource_registry, tool_registry
22
+ from .resources import match_uri
23
+ from .tools import Content, Image
24
+
25
+ PROTOCOL_VERSION = "2025-06-18"
26
+
27
+
28
+ def _instantiate(perm):
29
+ """Permission entries may be classes or instances; normalise to instances."""
30
+ return perm() if isinstance(perm, type) else perm
31
+
32
+
33
+ def _content_block(value):
34
+ """Map a tool's return value to an MCP content block (rich output)."""
35
+ if isinstance(value, Image):
36
+ return {"type": "image", "data": value.data, "mimeType": f"image/{value.format}"}
37
+ if isinstance(value, Content):
38
+ return {"type": "blob", "blob": value.data, "mimeType": value.mime_type}
39
+ if isinstance(value, str):
40
+ return {"type": "text", "text": value}
41
+ # Default: JSON-serialise into a text block (clients parse it back).
42
+ import json
43
+
44
+ return {"type": "text", "text": json.dumps(value, default=str)}
45
+
46
+
47
+ class MCPDispatcher:
48
+ def __init__(self, *, tools=None, resources=None):
49
+ self.tools = tools or tool_registry
50
+ self.resources = resources or resource_registry
51
+
52
+ # -- initialize ------------------------------------------------------------
53
+ def initialize(self):
54
+ return {
55
+ "protocolVersion": PROTOCOL_VERSION,
56
+ "serverInfo": {
57
+ "name": conf.get_setting("SERVER_NAME"),
58
+ "version": conf.get_setting("SERVER_VERSION"),
59
+ },
60
+ "capabilities": {"tools": {"listChanged": False}, "resources": {}},
61
+ }
62
+
63
+ # -- tools -----------------------------------------------------------------
64
+ def list_tools(self):
65
+ out = []
66
+ for tool in self.tools.all():
67
+ entry = {
68
+ "name": tool.name,
69
+ "description": tool.get_description(),
70
+ "inputSchema": tool.get_input_schema(),
71
+ }
72
+ output_schema = tool.get_output_schema()
73
+ if output_schema is not None:
74
+ entry["outputSchema"] = output_schema
75
+ out.append(entry)
76
+ return out
77
+
78
+ async def call_tool(self, name, arguments=None, *, user=None):
79
+ """Validate args -> check permissions -> run -> format result.
80
+
81
+ Returns ``(content_blocks, is_error)`` so a transport can build either a
82
+ normal or error tool result without re-deriving the shape.
83
+ """
84
+ tool = self.tools.get(name)
85
+ if tool is None or not tool.is_enabled():
86
+ return [self._error_text(f"Unknown tool: {name}")], True
87
+
88
+ try:
89
+ kwargs = self._validate(tool, arguments)
90
+ # Permission classes may hit the ORM (user.has_perm, Wagtail
91
+ # permissions_for_user), so run them off the event loop too.
92
+ await sync_to_async(self._check_permissions, thread_sensitive=True)(tool, user, kwargs)
93
+ await self._maybe_async(tool.pre_run, user, **kwargs)
94
+ result = await self._maybe_async(tool.run, user, **kwargs)
95
+ except MCPError as exc:
96
+ return [self._error_text(exc.message)], True
97
+ except Exception as exc: # service-layer / unexpected errors
98
+ return [self._error_text(from_service_error(exc).message)], True
99
+
100
+ return [_content_block(result)], False
101
+
102
+ # -- resources -------------------------------------------------------------
103
+ def list_resources(self):
104
+ out = []
105
+ for res in self.resources.all():
106
+ entry = {
107
+ "uri": res.uri,
108
+ "name": res.get_name(),
109
+ "description": res.get_description(),
110
+ "mimeType": res.mime_type,
111
+ }
112
+ out.append(entry)
113
+ return out
114
+
115
+ async def read_resource(self, uri, *, user=None):
116
+ res, params = self._resolve_resource(uri)
117
+ if res is None:
118
+ raise from_service_error(MCPError(f"Unknown resource: {uri}", status=404))
119
+ await sync_to_async(self._check_permissions, thread_sensitive=True)(res, user, params)
120
+ value = await self._maybe_async(res.read, user, **params)
121
+ block = _content_block(value)
122
+ text = block.get("text", "")
123
+ return [{"uri": uri, "mimeType": res.mime_type, "text": text}]
124
+
125
+ # -- internals -------------------------------------------------------------
126
+ def _validate(self, tool, arguments):
127
+ return tool.validate(arguments)
128
+
129
+ def _check_permissions(self, obj, user, kwargs):
130
+ for perm in getattr(obj, "permission_classes", []):
131
+ perm = _instantiate(perm)
132
+ if not perm.has_permission(user, obj, **kwargs):
133
+ raise PermissionDenied(getattr(perm, "message", "Permission denied."))
134
+
135
+ def _resolve_resource(self, uri):
136
+ # Exact match first, then template match.
137
+ exact = self.resources.get(uri)
138
+ if exact is not None:
139
+ return exact, {}
140
+ for res in self.resources.all():
141
+ if res.is_template:
142
+ params = match_uri(res.uri, uri)
143
+ if params is not None:
144
+ return res, params
145
+ return None, {}
146
+
147
+ async def _maybe_async(self, fn, *args, **kwargs):
148
+ """Run a tool hook/body: await if async, else off-loop via sync_to_async
149
+ (so Django's ORM is safe)."""
150
+ if inspect.iscoroutinefunction(fn):
151
+ return await fn(*args, **kwargs)
152
+ return await sync_to_async(fn, thread_sensitive=True)(*args, **kwargs)
153
+
154
+ def _error_text(self, message):
155
+ return {"type": "text", "text": message}