django-admin-fastmcp 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_admin_fastmcp/__init__.py +3 -0
- django_admin_fastmcp/admin.py +59 -0
- django_admin_fastmcp/apps.py +11 -0
- django_admin_fastmcp/auth.py +135 -0
- django_admin_fastmcp/checks.py +121 -0
- django_admin_fastmcp/conf.py +63 -0
- django_admin_fastmcp/errors.py +24 -0
- django_admin_fastmcp/exposure.py +98 -0
- django_admin_fastmcp/management/__init__.py +0 -0
- django_admin_fastmcp/management/commands/__init__.py +0 -0
- django_admin_fastmcp/management/commands/admin_mcp_serve.py +49 -0
- django_admin_fastmcp/migrations/0001_initial.py +117 -0
- django_admin_fastmcp/migrations/__init__.py +0 -0
- django_admin_fastmcp/models.py +205 -0
- django_admin_fastmcp/oauth.py +304 -0
- django_admin_fastmcp/request.py +58 -0
- django_admin_fastmcp/serialize.py +93 -0
- django_admin_fastmcp/server.py +88 -0
- django_admin_fastmcp/templates/django_admin_fastmcp/authorize.html +28 -0
- django_admin_fastmcp/tools/__init__.py +63 -0
- django_admin_fastmcp/tools/actions.py +69 -0
- django_admin_fastmcp/tools/introspect.py +120 -0
- django_admin_fastmcp/tools/lookup.py +42 -0
- django_admin_fastmcp/tools/read.py +144 -0
- django_admin_fastmcp/tools/write.py +181 -0
- django_admin_fastmcp/urls.py +29 -0
- django_admin_fastmcp/well_known_urls.py +20 -0
- django_admin_fastmcp-0.1.0.dist-info/METADATA +276 -0
- django_admin_fastmcp-0.1.0.dist-info/RECORD +31 -0
- django_admin_fastmcp-0.1.0.dist-info/WHEEL +4 -0
- django_admin_fastmcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Read-only audit changelists for McpClient and McpToken (SPEC.md section 8).
|
|
2
|
+
|
|
3
|
+
Who authorized which client, with which scopes, last used when. Revocation is
|
|
4
|
+
an admin action. Nothing is created by hand: tokens are issued only by the
|
|
5
|
+
OAuth flow. Staff see their own grants, superusers see all.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.contrib import admin
|
|
9
|
+
|
|
10
|
+
from django_admin_fastmcp.models import McpClient, McpToken
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReadOnlyAdmin(admin.ModelAdmin):
|
|
14
|
+
mcp_expose = False # belt and suspenders on top of the built-in denylist
|
|
15
|
+
|
|
16
|
+
def has_add_permission(self, request):
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
def has_change_permission(self, request, obj=None):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@admin.register(McpClient)
|
|
24
|
+
class McpClientAdmin(ReadOnlyAdmin):
|
|
25
|
+
list_display = ("name", "client_id", "created_at")
|
|
26
|
+
search_fields = ("name", "client_id")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@admin.register(McpToken)
|
|
30
|
+
class McpTokenAdmin(ReadOnlyAdmin):
|
|
31
|
+
list_display = (
|
|
32
|
+
"client",
|
|
33
|
+
"user",
|
|
34
|
+
"scope_list",
|
|
35
|
+
"access_expires_at",
|
|
36
|
+
"refresh_expires_at",
|
|
37
|
+
"revoked_at",
|
|
38
|
+
"last_used_at",
|
|
39
|
+
)
|
|
40
|
+
list_filter = ("revoked_at",)
|
|
41
|
+
actions = ("revoke_grants",)
|
|
42
|
+
|
|
43
|
+
@admin.display(description="scopes")
|
|
44
|
+
def scope_list(self, obj):
|
|
45
|
+
return ", ".join(obj.scopes)
|
|
46
|
+
|
|
47
|
+
def get_queryset(self, request):
|
|
48
|
+
queryset = super().get_queryset(request).select_related("user", "client")
|
|
49
|
+
if request.user.is_superuser:
|
|
50
|
+
return queryset
|
|
51
|
+
return queryset.filter(user=request.user)
|
|
52
|
+
|
|
53
|
+
@admin.action(description="Revoke selected grants", permissions=["view"])
|
|
54
|
+
def revoke_grants(self, request, queryset):
|
|
55
|
+
count = 0
|
|
56
|
+
for grant in queryset:
|
|
57
|
+
grant.revoke()
|
|
58
|
+
count += 1
|
|
59
|
+
self.message_user(request, f"Revoked {count} grant(s).")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoAdminFastmcpConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "django_admin_fastmcp"
|
|
7
|
+
verbose_name = "Admin MCP"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
# Import registers the system checks (SPEC.md section 3).
|
|
11
|
+
from django_admin_fastmcp import checks # noqa: F401
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""DjangoAdminTokenVerifier, RemoteAuthProvider wiring, current_user() (SPEC.md section 8).
|
|
2
|
+
|
|
3
|
+
The MCP server side of auth: a FastMCP `RemoteAuthProvider` composes the
|
|
4
|
+
verifier with RFC 9728 protected-resource metadata naming the Django site as
|
|
5
|
+
the authorization server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextvars
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
from asgiref.sync import sync_to_async
|
|
12
|
+
from django.contrib.auth import get_user_model
|
|
13
|
+
from django.utils import timezone
|
|
14
|
+
from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
|
|
15
|
+
from fastmcp.server.dependencies import get_access_token
|
|
16
|
+
|
|
17
|
+
from django_admin_fastmcp import conf
|
|
18
|
+
from django_admin_fastmcp.errors import denied
|
|
19
|
+
from django_admin_fastmcp.models import (
|
|
20
|
+
ACCESS_TOKEN_MARKER,
|
|
21
|
+
McpToken,
|
|
22
|
+
constant_time_compare,
|
|
23
|
+
hash_secret,
|
|
24
|
+
split_wire_token,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
LAST_USED_THROTTLE = timedelta(minutes=1)
|
|
28
|
+
|
|
29
|
+
# Test hook: the in-memory MCP transport carries no HTTP bearer token, so the
|
|
30
|
+
# test suite impersonates a user here. Production calls never set it.
|
|
31
|
+
_impersonated: contextvars.ContextVar = contextvars.ContextVar(
|
|
32
|
+
"django_admin_fastmcp_user", default=None
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class impersonate:
|
|
37
|
+
"""Context manager for tests: run tool calls as `user`."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, user):
|
|
40
|
+
self.user = user
|
|
41
|
+
|
|
42
|
+
def __enter__(self):
|
|
43
|
+
self._token = _impersonated.set(self.user)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, *exc_info):
|
|
47
|
+
_impersonated.reset(self._token)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DjangoAdminTokenVerifier(TokenVerifier):
|
|
51
|
+
"""Verify a wire token against McpToken. Every failure path denies."""
|
|
52
|
+
|
|
53
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
54
|
+
return await sync_to_async(self.verify_token_sync)(token)
|
|
55
|
+
|
|
56
|
+
def verify_token_sync(self, token: str) -> AccessToken | None:
|
|
57
|
+
parts = split_wire_token(token)
|
|
58
|
+
if parts is None or parts[0] != ACCESS_TOKEN_MARKER:
|
|
59
|
+
return None
|
|
60
|
+
_, prefix, secret = parts
|
|
61
|
+
grant = McpToken.objects.filter(access_prefix=prefix).select_related("user").first()
|
|
62
|
+
if grant is None:
|
|
63
|
+
return None
|
|
64
|
+
if not constant_time_compare(hash_secret(secret, grant.salt), grant.access_hash):
|
|
65
|
+
return None
|
|
66
|
+
now = timezone.now()
|
|
67
|
+
if grant.is_revoked or grant.access_expires_at < now:
|
|
68
|
+
return None
|
|
69
|
+
if not (grant.user.is_active and grant.user.is_staff):
|
|
70
|
+
return None
|
|
71
|
+
expected_resource = conf.get("MCP_URL").rstrip("/")
|
|
72
|
+
if grant.resource and grant.resource.rstrip("/") != expected_resource:
|
|
73
|
+
return None
|
|
74
|
+
if grant.last_used_at is None or now - grant.last_used_at > LAST_USED_THROTTLE:
|
|
75
|
+
grant.last_used_at = now
|
|
76
|
+
grant.save(update_fields=["last_used_at"])
|
|
77
|
+
return AccessToken(
|
|
78
|
+
token=token,
|
|
79
|
+
client_id=str(grant.user.pk),
|
|
80
|
+
scopes=list(grant.scopes),
|
|
81
|
+
expires_at=int(grant.access_expires_at.timestamp()),
|
|
82
|
+
resource=grant.resource or None,
|
|
83
|
+
claims={"user_pk": grant.user.pk, "mcp_client": grant.client.name},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_auth() -> RemoteAuthProvider:
|
|
88
|
+
"""Compose the verifier with RFC 9728 protected-resource metadata.
|
|
89
|
+
|
|
90
|
+
`base_url` is the origin only. FastMCP appends the mount path to it when
|
|
91
|
+
the app is built, so the advertised resource comes out equal to MCP_URL,
|
|
92
|
+
which is what the verifier compares each token's audience against.
|
|
93
|
+
"""
|
|
94
|
+
return RemoteAuthProvider(
|
|
95
|
+
token_verifier=DjangoAdminTokenVerifier(),
|
|
96
|
+
authorization_servers=[conf.get("SITE_URL")],
|
|
97
|
+
base_url=conf.mcp_origin(),
|
|
98
|
+
resource_name=conf.get("SERVER_NAME"),
|
|
99
|
+
scopes_supported=["admin"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _access_token():
|
|
104
|
+
try:
|
|
105
|
+
return get_access_token()
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def current_user():
|
|
111
|
+
"""The Django user behind the current tool call. Denies when absent.
|
|
112
|
+
|
|
113
|
+
Runs inside a sync tool, so the ORM is legal here.
|
|
114
|
+
"""
|
|
115
|
+
override = _impersonated.get()
|
|
116
|
+
if override is not None:
|
|
117
|
+
return override
|
|
118
|
+
access_token = _access_token()
|
|
119
|
+
if access_token is None:
|
|
120
|
+
raise denied("no authenticated user on this call")
|
|
121
|
+
claims = access_token.claims or {}
|
|
122
|
+
user = get_user_model().objects.filter(pk=claims.get("user_pk")).first()
|
|
123
|
+
if user is None or not (user.is_active and user.is_staff):
|
|
124
|
+
raise denied("not a staff user")
|
|
125
|
+
return user
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def client_name() -> str:
|
|
129
|
+
"""The OAuth client name, for LogEntry change messages."""
|
|
130
|
+
if _impersonated.get() is not None:
|
|
131
|
+
return "test"
|
|
132
|
+
access_token = _access_token()
|
|
133
|
+
if access_token is None:
|
|
134
|
+
return "unknown"
|
|
135
|
+
return str((access_token.claims or {}).get("mcp_client", "unknown"))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Startup validation of the ADMIN_FASTMCP settings (SPEC.md section 3).
|
|
2
|
+
|
|
3
|
+
A bad value is an error, not a warning. Exceptions: auth.Permission and
|
|
4
|
+
auth.Group exposed and writable is a warning (SPEC.md section 7), because it
|
|
5
|
+
is dangerous rather than broken.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from django.apps import apps
|
|
11
|
+
from django.conf import settings
|
|
12
|
+
from django.core.checks import Error, Warning, register
|
|
13
|
+
from django.utils.module_loading import import_string
|
|
14
|
+
|
|
15
|
+
from django_admin_fastmcp import conf
|
|
16
|
+
|
|
17
|
+
PERMISSION_MODELS = ("auth.permission", "auth.group")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolves(label: str) -> bool:
|
|
21
|
+
if label.endswith(".*"):
|
|
22
|
+
return apps.is_installed(label[:-2]) or bool(
|
|
23
|
+
[m for m in apps.get_models() if m._meta.app_label == label[:-2]]
|
|
24
|
+
)
|
|
25
|
+
try:
|
|
26
|
+
apps.get_model(label)
|
|
27
|
+
except (LookupError, ValueError):
|
|
28
|
+
return False
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register()
|
|
33
|
+
def check_settings(app_configs, **kwargs):
|
|
34
|
+
from django_admin_fastmcp.server import all_tools
|
|
35
|
+
|
|
36
|
+
errors = []
|
|
37
|
+
configured = getattr(settings, "ADMIN_FASTMCP", {})
|
|
38
|
+
|
|
39
|
+
for key in configured:
|
|
40
|
+
if key not in conf.DEFAULTS:
|
|
41
|
+
errors.append(
|
|
42
|
+
Error(
|
|
43
|
+
f"unknown ADMIN_FASTMCP key: {key!r}",
|
|
44
|
+
hint=f"known keys: {sorted(conf.DEFAULTS)}",
|
|
45
|
+
id="django_admin_fastmcp.E001",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
for setting in ("MODELS", "EXCLUDE_MODELS", "WRITABLE_MODELS"):
|
|
50
|
+
for label in conf.get(setting):
|
|
51
|
+
if not _resolves(label):
|
|
52
|
+
errors.append(
|
|
53
|
+
Error(
|
|
54
|
+
f"ADMIN_FASTMCP[{setting!r}] names {label!r}, "
|
|
55
|
+
"which is not an installed model",
|
|
56
|
+
id="django_admin_fastmcp.E002",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
known_tools = set(all_tools())
|
|
61
|
+
for name in conf.get("DISABLED_TOOLS"):
|
|
62
|
+
if name not in known_tools:
|
|
63
|
+
errors.append(
|
|
64
|
+
Error(
|
|
65
|
+
f"ADMIN_FASTMCP['DISABLED_TOOLS'] names {name!r}, "
|
|
66
|
+
f"which is not a tool. Tools: {sorted(known_tools)}",
|
|
67
|
+
id="django_admin_fastmcp.E003",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
import_string(conf.get("ADMIN_SITE"))
|
|
73
|
+
except ImportError:
|
|
74
|
+
errors.append(
|
|
75
|
+
Error(
|
|
76
|
+
f"ADMIN_FASTMCP['ADMIN_SITE'] does not import: {conf.get('ADMIN_SITE')!r}",
|
|
77
|
+
id="django_admin_fastmcp.E004",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# MCP_URL is the path the endpoint is served on and the audience every
|
|
82
|
+
# token is bound to. A URL with no path would advertise the whole origin
|
|
83
|
+
# as the protected resource, and the served path would collapse to "/".
|
|
84
|
+
mcp_parts = urlsplit(conf.get("MCP_URL"))
|
|
85
|
+
if (
|
|
86
|
+
mcp_parts.scheme not in ("http", "https")
|
|
87
|
+
or not mcp_parts.netloc
|
|
88
|
+
or not mcp_parts.path.strip("/")
|
|
89
|
+
):
|
|
90
|
+
errors.append(
|
|
91
|
+
Error(
|
|
92
|
+
f"ADMIN_FASTMCP['MCP_URL'] must be an absolute http(s) URL with a path, "
|
|
93
|
+
f"got {conf.get('MCP_URL')!r}",
|
|
94
|
+
hint="for example https://admin.example.com/admin/mcp",
|
|
95
|
+
id="django_admin_fastmcp.E005",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
site_parts = urlsplit(conf.get("SITE_URL"))
|
|
100
|
+
if site_parts.scheme not in ("http", "https") or not site_parts.netloc:
|
|
101
|
+
errors.append(
|
|
102
|
+
Error(
|
|
103
|
+
f"ADMIN_FASTMCP['SITE_URL'] must be an absolute http(s) URL, "
|
|
104
|
+
f"got {conf.get('SITE_URL')!r}",
|
|
105
|
+
hint="it is the OAuth issuer, the Django site that serves the admin login",
|
|
106
|
+
id="django_admin_fastmcp.E006",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
writable = [w.lower() for w in conf.get("WRITABLE_MODELS")]
|
|
111
|
+
for label in PERMISSION_MODELS:
|
|
112
|
+
if label in writable:
|
|
113
|
+
errors.append(
|
|
114
|
+
Warning(
|
|
115
|
+
f"{label} is writable over MCP. An agent that can grant "
|
|
116
|
+
"permissions can escape the permission model.",
|
|
117
|
+
hint="remove it from ADMIN_FASTMCP['WRITABLE_MODELS']",
|
|
118
|
+
id="django_admin_fastmcp.W001",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return errors
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""One accessor over the ADMIN_FASTMCP settings dict (SPEC.md section 3).
|
|
2
|
+
|
|
3
|
+
`get(key)` reads a knob from the consumer's `ADMIN_FASTMCP` dict, with the
|
|
4
|
+
defaults below. It raises `KeyError` on an unknown key, which catches typos in
|
|
5
|
+
this package at development time. Typos in consumer settings are caught by
|
|
6
|
+
`checks.py` at startup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
|
|
14
|
+
DEFAULTS: dict[str, Any] = {
|
|
15
|
+
"SERVER_NAME": "django-admin",
|
|
16
|
+
"ADMIN_SITE": "django.contrib.admin.site",
|
|
17
|
+
"MODELS": (),
|
|
18
|
+
"EXCLUDE_MODELS": (),
|
|
19
|
+
"WRITABLE_MODELS": (),
|
|
20
|
+
"DISABLED_TOOLS": (),
|
|
21
|
+
"REDACT_FIELDS": ("password", "token", "secret", "api_key", "private_key"),
|
|
22
|
+
"MAX_PAGE_SIZE": 200,
|
|
23
|
+
"MAX_PKS": 1000,
|
|
24
|
+
"ACCESS_TOKEN_TTL_MINUTES": 60,
|
|
25
|
+
"REFRESH_TOKEN_TTL_DAYS": 90,
|
|
26
|
+
# Base URL of the Django site, the OAuth issuer. Used in discovery
|
|
27
|
+
# metadata and advertised by the MCP server as its authorization server.
|
|
28
|
+
"SITE_URL": "http://127.0.0.1:8000",
|
|
29
|
+
# Public URL of the MCP endpoint, the OAuth resource. Tokens are
|
|
30
|
+
# audience-bound to it, and it is the single source of truth for the path
|
|
31
|
+
# the endpoint is served on. The admin owns "/admin/mcp", next to the
|
|
32
|
+
# OAuth endpoints at "/admin/mcp/authorize" and friends.
|
|
33
|
+
"MCP_URL": "http://127.0.0.1:8765/admin/mcp",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get(key: str) -> Any:
|
|
38
|
+
if key not in DEFAULTS:
|
|
39
|
+
raise KeyError(f"unknown ADMIN_FASTMCP key: {key}")
|
|
40
|
+
configured = getattr(settings, "ADMIN_FASTMCP", {})
|
|
41
|
+
return configured.get(key, DEFAULTS[key])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mcp_origin() -> str:
|
|
45
|
+
"""Scheme and host of MCP_URL, without the path.
|
|
46
|
+
|
|
47
|
+
FastMCP builds the resource URL it advertises as `base_url` plus the mount
|
|
48
|
+
path, so `base_url` must not already carry the path. A `base_url` that
|
|
49
|
+
includes it makes discovery advertise the path twice, and then the audience
|
|
50
|
+
the client asks for no longer matches the one the verifier expects.
|
|
51
|
+
"""
|
|
52
|
+
parts = urlsplit(get("MCP_URL"))
|
|
53
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mcp_path() -> str:
|
|
57
|
+
"""The path MCP_URL points at, which is the path the endpoint is served on.
|
|
58
|
+
|
|
59
|
+
Always leading-slashed and never trailing-slashed, because FastMCP and
|
|
60
|
+
Starlette both treat "/admin/mcp" and "/admin/mcp/" as different routes.
|
|
61
|
+
`checks.py` refuses an MCP_URL with no path at startup.
|
|
62
|
+
"""
|
|
63
|
+
return "/" + urlsplit(get("MCP_URL")).path.strip("/")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""ToolError and the denial vocabulary (SPEC.md sections 3 and 5).
|
|
2
|
+
|
|
3
|
+
Fail closed: every unresolved lookup, missing ModelAdmin, unknown action,
|
|
4
|
+
unknown field, and unknown tool denies the call. `ToolError` is FastMCP's own
|
|
5
|
+
exception, so the message reaches the MCP client verbatim while everything
|
|
6
|
+
else stays masked.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastmcp.exceptions import ToolError
|
|
10
|
+
|
|
11
|
+
__all__ = ["ToolError", "denied", "not_found"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def denied(reason: str) -> ToolError:
|
|
15
|
+
"""A permission denial. Raise the return value."""
|
|
16
|
+
return ToolError(f"denied: {reason}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def not_found(label: str, pk: str) -> ToolError:
|
|
20
|
+
"""An object that does not exist or is hidden by get_queryset.
|
|
21
|
+
|
|
22
|
+
One message for both cases, so a caller cannot probe for hidden rows.
|
|
23
|
+
"""
|
|
24
|
+
return ToolError(f"not found: {label} with pk {pk!r}")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Which models are visible, which fields are redacted (SPEC.md section 7).
|
|
2
|
+
|
|
3
|
+
Default: every model in `admin_site._registry`. Narrowed by EXCLUDE_MODELS
|
|
4
|
+
(with "app_label.*" support), the MODELS allowlist, and `mcp_expose = False`
|
|
5
|
+
on a ModelAdmin. A built-in denylist cannot be overridden: every
|
|
6
|
+
`django_admin_fastmcp` model, `sessions.Session`, and `authtoken.Token`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from functools import cache
|
|
10
|
+
|
|
11
|
+
from django.apps import apps
|
|
12
|
+
from django.contrib.admin import AdminSite, ModelAdmin
|
|
13
|
+
from django.db.models import Model
|
|
14
|
+
from django.utils.module_loading import import_string
|
|
15
|
+
|
|
16
|
+
from django_admin_fastmcp import conf
|
|
17
|
+
|
|
18
|
+
# Written through MCP, these would let an agent escalate outside the audit
|
|
19
|
+
# trail. Never exposed, whatever the settings say.
|
|
20
|
+
BUILTIN_DENY_APPS = frozenset({"django_admin_fastmcp"})
|
|
21
|
+
BUILTIN_DENY_MODELS = frozenset({"sessions.session", "authtoken.token"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cache
|
|
25
|
+
def admin_site() -> AdminSite:
|
|
26
|
+
return import_string(conf.get("ADMIN_SITE"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def label_of(model: type[Model]) -> str:
|
|
30
|
+
return f"{model._meta.app_label}.{model.__name__}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize(label: str) -> str:
|
|
34
|
+
return label.lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _matches(label: str, patterns) -> bool:
|
|
38
|
+
"""True when `label` matches an exact "app.Model" or an "app.*" pattern."""
|
|
39
|
+
norm = _normalize(label)
|
|
40
|
+
app_label = norm.split(".", 1)[0]
|
|
41
|
+
for pattern in patterns:
|
|
42
|
+
pattern = _normalize(pattern)
|
|
43
|
+
if pattern == norm:
|
|
44
|
+
return True
|
|
45
|
+
if pattern.endswith(".*") and pattern[:-2] == app_label:
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_exposed(label: str) -> bool:
|
|
51
|
+
"""Fail closed: an unresolvable label is not exposed."""
|
|
52
|
+
try:
|
|
53
|
+
model = apps.get_model(label)
|
|
54
|
+
except (LookupError, ValueError):
|
|
55
|
+
return False
|
|
56
|
+
norm = _normalize(label_of(model))
|
|
57
|
+
if norm.split(".", 1)[0] in BUILTIN_DENY_APPS or norm in BUILTIN_DENY_MODELS:
|
|
58
|
+
return False
|
|
59
|
+
if _matches(norm, conf.get("EXCLUDE_MODELS")):
|
|
60
|
+
return False
|
|
61
|
+
allowlist = conf.get("MODELS")
|
|
62
|
+
if allowlist and not _matches(norm, allowlist):
|
|
63
|
+
return False
|
|
64
|
+
model_admin = admin_site()._registry.get(model)
|
|
65
|
+
if model_admin is None:
|
|
66
|
+
return False
|
|
67
|
+
return getattr(model_admin, "mcp_expose", True) is not False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def exposed_models() -> list[tuple[type[Model], ModelAdmin]]:
|
|
71
|
+
return [
|
|
72
|
+
(model, model_admin)
|
|
73
|
+
for model, model_admin in admin_site()._registry.items()
|
|
74
|
+
if is_exposed(label_of(model))
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_writable(label: str) -> bool:
|
|
79
|
+
"""Both gates: the model is exposed and named in WRITABLE_MODELS."""
|
|
80
|
+
return is_exposed(label) and _matches(label, conf.get("WRITABLE_MODELS"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def visible_field_names(model_admin: ModelAdmin, all_names: list[str]) -> list[str]:
|
|
84
|
+
"""Apply the per-ModelAdmin `mcp_fields` allowlist and
|
|
85
|
+
`mcp_exclude_fields` denylist to a list of field names."""
|
|
86
|
+
allow = getattr(model_admin, "mcp_fields", None)
|
|
87
|
+
deny = getattr(model_admin, "mcp_exclude_fields", ())
|
|
88
|
+
names = all_names
|
|
89
|
+
if allow is not None:
|
|
90
|
+
names = [n for n in names if n in allow]
|
|
91
|
+
return [n for n in names if n not in deny]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_redacted(field_name: str) -> bool:
|
|
95
|
+
"""Substring match against REDACT_FIELDS. An explicit `mcp_fields` entry
|
|
96
|
+
does not defeat redaction (SPEC.md section 7)."""
|
|
97
|
+
name = field_name.lower()
|
|
98
|
+
return any(marker in name for marker in conf.get("REDACT_FIELDS"))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Run the FastMCP app under its own uvicorn with lifespan enabled (SPEC.md section 12).
|
|
2
|
+
|
|
3
|
+
Phase 1: a separate process next to the Django project. Nothing about the
|
|
4
|
+
host application's serving configuration changes. `stateless_http=True`, so
|
|
5
|
+
any instance behind a load balancer can serve any request.
|
|
6
|
+
|
|
7
|
+
The served path comes from ADMIN_FASTMCP["MCP_URL"], which defaults to
|
|
8
|
+
"/admin/mcp". One setting drives the path, the discovery metadata, and the
|
|
9
|
+
audience the verifier expects, so the three cannot drift apart.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from django.core.management.base import BaseCommand
|
|
15
|
+
|
|
16
|
+
from django_admin_fastmcp import conf
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Command(BaseCommand):
|
|
20
|
+
help = "Serve the admin MCP server over streamable HTTP."
|
|
21
|
+
|
|
22
|
+
def add_arguments(self, parser):
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--host",
|
|
25
|
+
default="127.0.0.1",
|
|
26
|
+
help="Address to bind. Defaults to loopback: put a reverse proxy in front.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--port",
|
|
30
|
+
type=int,
|
|
31
|
+
default=None,
|
|
32
|
+
help="Port to bind. Defaults to the port in MCP_URL, or 8765.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def handle(self, *args, **options):
|
|
36
|
+
import uvicorn
|
|
37
|
+
|
|
38
|
+
from django_admin_fastmcp.server import BodySizeLimit, build_server
|
|
39
|
+
|
|
40
|
+
path = conf.mcp_path()
|
|
41
|
+
host = options["host"]
|
|
42
|
+
port = options["port"] or urlsplit(conf.get("MCP_URL")).port or 8765
|
|
43
|
+
|
|
44
|
+
app = build_server().http_app(stateless_http=True, path=path)
|
|
45
|
+
self.stdout.write(
|
|
46
|
+
f"Serving {conf.get('SERVER_NAME')} on http://{host}:{port}{path}\n"
|
|
47
|
+
f"Clients must reach it as {conf.get('MCP_URL')}"
|
|
48
|
+
)
|
|
49
|
+
uvicorn.run(BodySizeLimit(app), host=host, port=port, lifespan="on")
|