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.
- django_mcp_kit/__init__.py +47 -0
- django_mcp_kit/apps.py +13 -0
- django_mcp_kit/asgi.py +41 -0
- django_mcp_kit/auth/__init__.py +22 -0
- django_mcp_kit/auth/base.py +73 -0
- django_mcp_kit/auth/bearer.py +36 -0
- django_mcp_kit/auth/oauth.py +53 -0
- django_mcp_kit/conf.py +44 -0
- django_mcp_kit/dispatcher.py +155 -0
- django_mcp_kit/drf.py +209 -0
- django_mcp_kit/errors.py +96 -0
- django_mcp_kit/management/__init__.py +0 -0
- django_mcp_kit/management/commands/__init__.py +0 -0
- django_mcp_kit/management/commands/create_mcp_oauth_client.py +48 -0
- django_mcp_kit/management/commands/runserver_mcp.py +49 -0
- django_mcp_kit/models.py +90 -0
- django_mcp_kit/oauth_client.py +62 -0
- django_mcp_kit/permissions.py +45 -0
- django_mcp_kit/registry.py +74 -0
- django_mcp_kit/resources.py +96 -0
- django_mcp_kit/schema.py +68 -0
- django_mcp_kit/services.py +53 -0
- django_mcp_kit/tools.py +193 -0
- django_mcp_kit/transports/__init__.py +0 -0
- django_mcp_kit/transports/http.py +15 -0
- django_mcp_kit/transports/sdk.py +192 -0
- django_mcp_kit/urls.py +33 -0
- django_mcp_kit/utils.py +31 -0
- django_mcp_kit/wagtail_connector/__init__.py +0 -0
- django_mcp_kit/wagtail_connector/apps.py +8 -0
- django_mcp_kit/wagtail_connector/migrations/0001_initial.py +28 -0
- django_mcp_kit/wagtail_connector/migrations/__init__.py +0 -0
- django_mcp_kit/wagtail_connector/models.py +47 -0
- django_mcp_kit-0.1.0.dist-info/METADATA +252 -0
- django_mcp_kit-0.1.0.dist-info/RECORD +36 -0
- django_mcp_kit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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}
|