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.
@@ -0,0 +1,3 @@
1
+ """Expose the Django admin as an MCP server, gated by each user's own admin permissions."""
2
+
3
+ __version__ = "0.1.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")