- Implemented custom form widgets for date, time, and datetime fields with DaisyUI styling. - Created utility functions for formatting dates, times, and numbers according to user preferences. - Developed views for profile settings, API key management, and notifications, including health check endpoints. - Added URL configurations for Themis tests and main application routes. - Established test cases for custom widgets to ensure proper functionality and integration. - Defined project metadata and dependencies in pyproject.toml for package management.
1046 lines
32 KiB
Markdown
1046 lines
32 KiB
Markdown
# Django MCP Pattern v1.0.0
|
|
|
|
Standardizes embedding a FastMCP server inside a Django ASGI process with token-based authentication, modular tool registration, and dual transport (Streamable HTTP + SSE). Used by Angelia 2 (Wagtail CMS); applicable to any Django project.
|
|
|
|
## 🐾 Red Panda Approval™
|
|
|
|
This pattern follows Red Panda Approval standards.
|
|
|
|
---
|
|
|
|
## Why a Pattern, Not a Shared App
|
|
|
|
Every Django project that exposes MCP tools has different domain models, different permission requirements, and different admin UX needs. A single reusable Django app cannot accommodate this variability:
|
|
|
|
- A **CMS platform** needs page-tree tools, media upload tools, and editorial permissions scoped to Wagtail collections
|
|
- An **e-commerce project** needs product-catalog tools, order-status tools, and Stripe-scoped API tokens
|
|
- An **internal dashboard** needs reporting tools, data-export tools, and LDAP-group-scoped permissions
|
|
- A **DevOps platform** needs deployment tools, log-query tools, and service-account tokens
|
|
|
|
The tools, models, and admin surfaces differ — but the **wiring** is always the same:
|
|
|
|
Instead, this pattern defines:
|
|
|
|
- **Required components** — token model, auth middleware, context helpers, server instance, ASGI mount
|
|
- **Recommended behaviors** — audit trail, metrics, masked display, dev-mode bypass
|
|
- **Extension guidelines** — tool registration, resource registration, admin UI, CLI management
|
|
- **Standard constants** — token length, state keys, settings names, metric names
|
|
|
|
---
|
|
|
|
## Required Components
|
|
|
|
The non-negotiable minimum every implementation must provide.
|
|
|
|
### MCPToken Model
|
|
|
|
```python
|
|
import secrets
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
class MCPToken(models.Model):
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='mcp_tokens',
|
|
)
|
|
token = models.CharField(max_length=64, unique=True, db_index=True)
|
|
name = models.CharField(max_length=100)
|
|
is_active = models.BooleanField(default=True)
|
|
expires_at = models.DateTimeField(null=True, blank=True)
|
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
allowed_tools = models.JSONField(default=list, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
def save(self, **kwargs):
|
|
if not self.token:
|
|
self.token = secrets.token_urlsafe(48) # 64-char URL-safe string
|
|
super().save(**kwargs)
|
|
|
|
@property
|
|
def is_valid(self):
|
|
if not self.is_active:
|
|
return False
|
|
if self.expires_at and self.expires_at < timezone.now():
|
|
return False
|
|
return True
|
|
|
|
def can_use_tool(self, tool_name: str) -> bool:
|
|
if not self.allowed_tools:
|
|
return True # Empty list = unrestricted
|
|
return tool_name in self.allowed_tools
|
|
```
|
|
|
|
**Required fields:**
|
|
|
|
| Field | Type | Purpose |
|
|
|-------|------|---------|
|
|
| `user` | FK → User | Auth scope — all operations run as this user |
|
|
| `token` | CharField(64) | Auto-generated bearer token, unique + indexed |
|
|
| `name` | CharField(100) | Friendly label ("Claude Desktop", "CI Script") |
|
|
| `is_active` | BooleanField | Revocation flag |
|
|
| `expires_at` | DateTimeField | Optional expiry (null = never) |
|
|
| `last_used_at` | DateTimeField | Audit trail — updated on each request |
|
|
| `allowed_tools` | JSONField | Tool whitelist (empty list = all tools) |
|
|
| `created_at` | DateTimeField | Auto-set on creation |
|
|
| `updated_at` | DateTimeField | Auto-set on save |
|
|
|
|
**Required methods:**
|
|
|
|
| Method | Returns | Purpose |
|
|
|--------|---------|---------|
|
|
| `is_valid` | bool | Checks active + not expired |
|
|
| `can_use_tool(name)` | bool | Whitelist check (empty = permit all) |
|
|
|
|
### Auth Resolution
|
|
|
|
```python
|
|
from django.contrib.auth import get_user_model
|
|
from django.utils import timezone
|
|
|
|
User = get_user_model()
|
|
|
|
class MCPAuthError(Exception):
|
|
pass
|
|
|
|
def resolve_mcp_user(token_string: str) -> tuple:
|
|
try:
|
|
token = (
|
|
MCPToken.objects
|
|
.select_related('user')
|
|
.get(token=token_string)
|
|
)
|
|
except MCPToken.DoesNotExist:
|
|
raise MCPAuthError("Invalid MCP token.")
|
|
|
|
if not token.is_active:
|
|
raise MCPAuthError("Token has been deactivated.")
|
|
if token.expires_at and token.expires_at < timezone.now():
|
|
raise MCPAuthError("Token has expired.")
|
|
if not token.user.is_active:
|
|
raise MCPAuthError("User account is disabled.")
|
|
|
|
token.record_usage()
|
|
return token.user, token
|
|
|
|
def check_tool_permission(token: MCPToken, tool_name: str) -> bool:
|
|
return token.can_use_tool(tool_name)
|
|
```
|
|
|
|
**Validation chain (order matters):**
|
|
|
|
1. Token exists in database (`select_related('user')` for single query)
|
|
2. Token is active (`is_active=True`)
|
|
3. Token is not expired (`expires_at` is null or in the future)
|
|
4. Bound user is active (`user.is_active=True`)
|
|
5. Record usage for audit trail
|
|
|
|
### Auth Middleware
|
|
|
|
```python
|
|
from asgiref.sync import sync_to_async
|
|
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
from fastmcp.server.dependencies import get_http_request
|
|
|
|
STATE_KEY_USER = "mcp_user"
|
|
STATE_KEY_TOKEN = "mcp_token"
|
|
|
|
class MCPAuthMiddleware(Middleware):
|
|
async def __call__(self, context: MiddlewareContext, call_next):
|
|
require_auth = getattr(settings, 'MCP_REQUIRE_AUTH', True)
|
|
|
|
token_string = self._extract_token(context)
|
|
|
|
user = None
|
|
token = None
|
|
|
|
if token_string:
|
|
try:
|
|
user, token = await sync_to_async(
|
|
resolve_mcp_user, thread_sensitive=True
|
|
)(token_string)
|
|
except MCPAuthError as e:
|
|
if require_auth:
|
|
raise PermissionError(str(e))
|
|
elif require_auth and context.method == "tools/call":
|
|
raise PermissionError(
|
|
"Authentication required. Provide a Bearer token."
|
|
)
|
|
|
|
# Tool-level permission check
|
|
if token and context.method == "tools/call":
|
|
tool_name = self._extract_tool_name(context)
|
|
if tool_name and not check_tool_permission(token, tool_name):
|
|
raise PermissionError(
|
|
f"Token does not have permission to call '{tool_name}'."
|
|
)
|
|
|
|
# Store on request-scoped state
|
|
fastmcp_ctx = context.fastmcp_context
|
|
if fastmcp_ctx and user:
|
|
await fastmcp_ctx.set_state(
|
|
STATE_KEY_USER, user, serializable=False
|
|
)
|
|
await fastmcp_ctx.set_state(
|
|
STATE_KEY_TOKEN, token, serializable=False
|
|
)
|
|
|
|
return await call_next(context)
|
|
|
|
def _extract_token(self, context: MiddlewareContext) -> str | None:
|
|
try:
|
|
request = get_http_request()
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
return auth_header[7:]
|
|
except RuntimeError:
|
|
pass # No HTTP request (e.g., stdio transport)
|
|
return None
|
|
|
|
def _extract_tool_name(self, context: MiddlewareContext) -> str | None:
|
|
msg = context.message
|
|
if hasattr(msg, 'params') and hasattr(msg.params, 'name'):
|
|
return msg.params.name
|
|
return None
|
|
```
|
|
|
|
**Middleware responsibilities:**
|
|
|
|
1. Extract Bearer token from HTTP `Authorization` header
|
|
2. Resolve token to Django User via `sync_to_async` (ORM is synchronous)
|
|
3. Check tool-level permissions on `tools/call` requests
|
|
4. Store user and token on FastMCP's request-scoped state (`serializable=False`)
|
|
5. Handle dev-mode bypass when `MCP_REQUIRE_AUTH=False`
|
|
6. Gracefully skip auth when no HTTP request exists (stdio transport)
|
|
|
|
### Context Helpers
|
|
|
|
```python
|
|
from fastmcp.server.context import Context
|
|
|
|
async def get_mcp_user(ctx: Context):
|
|
return await ctx.get_state(STATE_KEY_USER)
|
|
|
|
async def get_mcp_token(ctx: Context):
|
|
return await ctx.get_state(STATE_KEY_TOKEN)
|
|
```
|
|
|
|
Tools use these to access the authenticated user:
|
|
|
|
```python
|
|
@mcp.tool
|
|
async def create_item(title: str, ctx: Context = None) -> dict:
|
|
user = await get_mcp_user(ctx)
|
|
# ORM operations run as this user
|
|
```
|
|
|
|
### FastMCP Server Instance
|
|
|
|
```python
|
|
from fastmcp import FastMCP
|
|
|
|
mcp = FastMCP(
|
|
"my-project",
|
|
instructions="System prompt describing your domain for LLMs.",
|
|
middleware=[MCPAuthMiddleware()],
|
|
)
|
|
|
|
# Register tools by domain
|
|
register_product_tools(mcp)
|
|
register_order_tools(mcp)
|
|
```
|
|
|
|
**Requirements:**
|
|
|
|
- Single global `FastMCP` instance created at module import time
|
|
- Auth middleware injected at server level (not per-tool)
|
|
- `instructions` string guides LLMs on domain concepts (page types, field meanings, workflows)
|
|
- Tool registration via modular functions, one per domain
|
|
|
|
### ASGI Mount
|
|
|
|
```python
|
|
import os
|
|
import django
|
|
|
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
|
django.setup()
|
|
|
|
from contextlib import asynccontextmanager
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Mount, Route
|
|
from starlette.responses import JSONResponse
|
|
|
|
from mcp_server.server import mcp
|
|
|
|
mcp_http_app = mcp.http_app(path="/", transport="streamable-http")
|
|
mcp_sse_app = mcp.http_app(path="/", transport="sse")
|
|
|
|
async def health(request):
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
async with mcp_http_app.lifespan(app):
|
|
async with mcp_sse_app.lifespan(app):
|
|
yield
|
|
|
|
app = Starlette(
|
|
routes=[
|
|
Route("/mcp/health", health),
|
|
Mount("/mcp/sse", app=mcp_sse_app), # More specific path first
|
|
Mount("/mcp", app=mcp_http_app),
|
|
],
|
|
lifespan=lifespan,
|
|
)
|
|
```
|
|
|
|
**Requirements:**
|
|
|
|
- `django.setup()` before any Django imports (ORM, models)
|
|
- Health check endpoint at `/mcp/health`
|
|
- `/mcp/sse` route listed before `/mcp` (Starlette matches first hit)
|
|
- Lifespan combines both transport apps
|
|
|
|
---
|
|
|
|
## Standard Constants
|
|
|
|
Use these values for consistency across implementations.
|
|
|
|
### Token Generation
|
|
|
|
```python
|
|
# 64-character URL-safe token via stdlib
|
|
token = secrets.token_urlsafe(48)
|
|
```
|
|
|
|
### State Keys
|
|
|
|
```python
|
|
STATE_KEY_USER = "mcp_user"
|
|
STATE_KEY_TOKEN = "mcp_token"
|
|
```
|
|
|
|
### Settings
|
|
|
|
```python
|
|
# settings.py
|
|
MCP_REQUIRE_AUTH = env.bool('MCP_REQUIRE_AUTH', default=True)
|
|
```
|
|
|
|
### Metric Names
|
|
|
|
```python
|
|
mcp_tool_invocations_total # Counter — labels: tool, status
|
|
mcp_tool_duration_seconds # Histogram — labels: tool
|
|
mcp_auth_failures_total # Counter — labels: reason
|
|
mcp_active_sessions # Gauge
|
|
```
|
|
|
|
### Tool Description Limit
|
|
|
|
The MCP specification requires tool descriptions ≤ **1024 characters**. Use a stricter internal limit of **750 characters** to leave headroom for protocol overhead. Validate with:
|
|
|
|
```python
|
|
import inspect
|
|
assert len(inspect.cleandoc(tool_fn.__doc__)) <= 750
|
|
```
|
|
|
|
---
|
|
|
|
## Recommended Behaviors
|
|
|
|
Most implementations should include these, but they are not strictly required.
|
|
|
|
### Audit Trail
|
|
|
|
```python
|
|
def record_usage(self):
|
|
self.last_used_at = timezone.now()
|
|
self.save(update_fields=['last_used_at'])
|
|
```
|
|
|
|
Called by `resolve_mcp_user()` on every authenticated request. Provides admin visibility into token activity.
|
|
|
|
### Masked Token Display
|
|
|
|
```python
|
|
def get_masked_token(self):
|
|
if len(self.token) > 8:
|
|
return f"{'*' * (len(self.token) - 8)}{self.token[-8:]}"
|
|
return "********"
|
|
```
|
|
|
|
Used in admin list views and logs. Never expose full tokens after initial creation.
|
|
|
|
### Prometheus Metrics
|
|
|
|
```python
|
|
from prometheus_client import Counter, Histogram, Gauge
|
|
|
|
mcp_tool_invocations_total = Counter(
|
|
'mcp_tool_invocations_total',
|
|
'Total MCP tool invocations',
|
|
['tool', 'status'],
|
|
)
|
|
|
|
mcp_tool_duration_seconds = Histogram(
|
|
'mcp_tool_duration_seconds',
|
|
'MCP tool execution duration in seconds',
|
|
['tool'],
|
|
buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
|
)
|
|
|
|
mcp_auth_failures_total = Counter(
|
|
'mcp_auth_failures_total',
|
|
'Total MCP authentication failures',
|
|
['reason'],
|
|
)
|
|
|
|
def record_tool_call(tool_name: str, status: str, duration: float):
|
|
mcp_tool_invocations_total.labels(tool=tool_name, status=status).inc()
|
|
mcp_tool_duration_seconds.labels(tool=tool_name).observe(duration)
|
|
```
|
|
|
|
### sync_to_async for ORM
|
|
|
|
All Django ORM calls inside async tool functions **must** be wrapped with `sync_to_async`:
|
|
|
|
```python
|
|
from asgiref.sync import sync_to_async
|
|
|
|
@mcp.tool
|
|
async def create_item(title: str, ctx: Context = None) -> dict:
|
|
user = await get_mcp_user(ctx)
|
|
|
|
def _do_create():
|
|
item = MyModel(title=title, created_by=user)
|
|
item.save()
|
|
return {"id": item.id, "title": item.title}
|
|
|
|
return await sync_to_async(_do_create, thread_sensitive=True)()
|
|
```
|
|
|
|
The `thread_sensitive=True` parameter ensures ORM operations run in Django's main thread, avoiding database connection issues.
|
|
|
|
### Dev Mode Auth Bypass
|
|
|
|
When `MCP_REQUIRE_AUTH=False`, the middleware skips token validation. Tools run without a user context. This is only for local development — never disable in production.
|
|
|
|
---
|
|
|
|
## Tool Registration Pattern
|
|
|
|
Tools are organized by domain in separate modules. Each module exports a `register_*_tools(mcp)` factory function that defines tools as closures, capturing the `mcp` instance.
|
|
|
|
### File Structure
|
|
|
|
```
|
|
mcp_server/
|
|
tools/
|
|
__init__.py
|
|
pages.py # register_page_tools(mcp)
|
|
media.py # register_media_tools(mcp)
|
|
blog.py # register_blog_tools(mcp)
|
|
orders.py # register_order_tools(mcp)
|
|
```
|
|
|
|
### Factory Function
|
|
|
|
```python
|
|
# mcp_server/tools/products.py
|
|
|
|
import time
|
|
from asgiref.sync import sync_to_async
|
|
from fastmcp.server.context import Context
|
|
from ..context import get_mcp_user
|
|
from ..metrics import record_tool_call
|
|
|
|
def register_product_tools(mcp):
|
|
|
|
@mcp.tool
|
|
def list_products(category: str | None = None, limit: int = 20) -> dict:
|
|
"""List products with optional category filter. Returns id, name,
|
|
price, and category for each product."""
|
|
start = time.time()
|
|
try:
|
|
qs = Product.objects.all()
|
|
if category:
|
|
qs = qs.filter(category__slug=category)
|
|
result = {
|
|
"products": [
|
|
{"id": p.id, "name": p.name, "price": str(p.price)}
|
|
for p in qs[:limit]
|
|
]
|
|
}
|
|
record_tool_call("list_products", "success", time.time() - start)
|
|
return result
|
|
except Exception:
|
|
record_tool_call("list_products", "error", time.time() - start)
|
|
raise
|
|
|
|
@mcp.tool
|
|
async def create_product(
|
|
name: str, price: str, description: str,
|
|
ctx: Context = None,
|
|
) -> dict:
|
|
"""Create a new product. Price as decimal string (e.g. '29.99')."""
|
|
start = time.time()
|
|
try:
|
|
user = await get_mcp_user(ctx) if ctx else None
|
|
|
|
def _do_create():
|
|
product = Product(
|
|
name=name,
|
|
price=Decimal(price),
|
|
description=description,
|
|
created_by=user,
|
|
)
|
|
product.save()
|
|
return {"id": product.id, "name": product.name}
|
|
|
|
result = await sync_to_async(_do_create, thread_sensitive=True)()
|
|
record_tool_call("create_product", "success", time.time() - start)
|
|
return result
|
|
except Exception:
|
|
record_tool_call("create_product", "error", time.time() - start)
|
|
raise
|
|
```
|
|
|
|
### Sync vs Async Decision
|
|
|
|
| Tool Type | Define As | ORM Access | Use When |
|
|
|-----------|-----------|------------|----------|
|
|
| Read-only queries | `def tool(...)` | Direct | Simple lookups, listing, search |
|
|
| Mutations | `async def tool(..., ctx)` | `sync_to_async` | Create, update, delete — needs user context |
|
|
|
|
Read-only tools can be synchronous because FastMCP handles the async bridge. Mutation tools must be async to access the request-scoped user context via `await get_mcp_user(ctx)`.
|
|
|
|
### Server Registration
|
|
|
|
```python
|
|
# mcp_server/server.py
|
|
|
|
mcp = FastMCP("my-project", instructions="...", middleware=[...])
|
|
|
|
register_product_tools(mcp)
|
|
register_order_tools(mcp)
|
|
register_inventory_tools(mcp)
|
|
```
|
|
|
|
All `register_*` calls happen at module import time. The tools are available immediately when the ASGI app starts.
|
|
|
|
---
|
|
|
|
## ASGI Dual Transport Mount
|
|
|
|
Two MCP transports share a single FastMCP instance, served by one Uvicorn process.
|
|
|
|
### Streamable HTTP (Standard)
|
|
|
|
POST-based JSON-RPC at `/mcp/`. Stateless — could support multiple workers (but single worker is simpler when SSE is also served).
|
|
|
|
### SSE (Legacy)
|
|
|
|
Server-Sent Events at `/mcp/sse/`. Stateful — session state lives in the worker's memory. **Requires single Uvicorn worker.** Supported for backward compatibility with older MCP clients.
|
|
|
|
### Health Check
|
|
|
|
`GET /mcp/health` returns `{"status": "ok"}`. Used by load balancers, Docker health checks, and monitoring.
|
|
|
|
### Deployment
|
|
|
|
```bash
|
|
# Separate from the Django WSGI server
|
|
uvicorn myproject.asgi:app --host 0.0.0.0 --port 8001 --workers 1
|
|
```
|
|
|
|
The MCP server runs on a separate port from Django's WSGI server (Gunicorn). Nginx routes `/mcp/` to Uvicorn:
|
|
|
|
```nginx
|
|
# Streamable HTTP + SSE
|
|
location /mcp/ {
|
|
proxy_pass http://mcp:8001;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_buffering off;
|
|
proxy_cache off;
|
|
proxy_read_timeout 300s;
|
|
}
|
|
```
|
|
|
|
> ⚠️ `proxy_buffering off` and `proxy_cache off` are required for SSE. Without them, Nginx buffers the event stream and clients see no output.
|
|
|
|
---
|
|
|
|
## Admin UI & CLI Token Management
|
|
|
|
Two approaches for creating and managing tokens.
|
|
|
|
### Django Admin
|
|
|
|
Register `MCPToken` with Django's admin. Show the full token **once** after creation — it cannot be retrieved later.
|
|
|
|
```python
|
|
# admin.py
|
|
from django.contrib import admin
|
|
from .models import MCPToken
|
|
|
|
@admin.register(MCPToken)
|
|
class MCPTokenAdmin(admin.ModelAdmin):
|
|
list_display = ['name', 'user', 'is_active', 'masked_token_display',
|
|
'expires_at', 'last_used_at', 'created_at']
|
|
list_filter = ['is_active']
|
|
search_fields = ['name', 'user__email']
|
|
readonly_fields = ['token', 'last_used_at', 'created_at', 'updated_at']
|
|
```
|
|
|
|
For frameworks with richer admin UIs (e.g., Wagtail snippets), register as a snippet with grouped tool selection widgets and token-shown-once creation views.
|
|
|
|
### Management Command
|
|
|
|
For scripted or CI token creation:
|
|
|
|
```python
|
|
# management/commands/create_mcp_token.py
|
|
from django.core.management.base import BaseCommand, CommandError
|
|
|
|
class Command(BaseCommand):
|
|
help = "Create an MCP token for a user and print the full token."
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument("--user", required=True, help="User email.")
|
|
parser.add_argument("--name", required=True, help="Token name.")
|
|
parser.add_argument("--tools", default="",
|
|
help="Comma-separated allowed tools (empty = all).")
|
|
parser.add_argument("--expires-days", type=int, default=None,
|
|
help="Days until expiry (omit for no expiry).")
|
|
|
|
def handle(self, *args, **options):
|
|
User = get_user_model()
|
|
try:
|
|
user = User.objects.get(email=options["user"])
|
|
except User.DoesNotExist:
|
|
raise CommandError(f'User "{options["user"]}" not found.')
|
|
|
|
if not user.is_active:
|
|
raise CommandError(f'User "{options["user"]}" is inactive.')
|
|
|
|
allowed_tools = []
|
|
if options["tools"]:
|
|
allowed_tools = [t.strip() for t in options["tools"].split(",")
|
|
if t.strip()]
|
|
|
|
expires_at = None
|
|
if options["expires_days"] is not None:
|
|
if options["expires_days"] < 1:
|
|
raise CommandError("--expires-days must be at least 1.")
|
|
expires_at = timezone.now() + timedelta(days=options["expires_days"])
|
|
|
|
token = MCPToken.objects.create(
|
|
user=user,
|
|
name=options["name"],
|
|
allowed_tools=allowed_tools,
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
self.stdout.write(self.style.SUCCESS("✔ MCP token created"))
|
|
self.stdout.write(f" Name: {token.name}")
|
|
self.stdout.write(f" User: {user.email}")
|
|
self.stdout.write(self.style.WARNING(" Token (shown once):"))
|
|
self.stdout.write(f" {token.token}")
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```bash
|
|
# All tools, no expiry
|
|
python manage.py create_mcp_token --user admin@example.com --name "Claude Desktop"
|
|
|
|
# Restricted tools
|
|
python manage.py create_mcp_token --user admin@example.com --name "Read Only" \
|
|
--tools list_products,get_order_status
|
|
|
|
# 30-day expiry
|
|
python manage.py create_mcp_token --user admin@example.com --name "Temp" \
|
|
--expires-days 30
|
|
```
|
|
|
|
---
|
|
|
|
## MCP Resources
|
|
|
|
Read-only reference data registered with `@mcp.resource()`. Resources give LLMs context about your domain without requiring tool calls.
|
|
|
|
```python
|
|
# mcp_server/tools/reference.py
|
|
from pathlib import Path
|
|
|
|
def register_resources(mcp):
|
|
|
|
@mcp.resource("myapp://api-schema")
|
|
def api_schema() -> str:
|
|
"""OpenAPI schema for the public API."""
|
|
return Path("static/openapi.yaml").read_text()
|
|
|
|
@mcp.resource("myapp://style-guide")
|
|
def style_guide() -> str:
|
|
"""Content style guide for consistent authoring."""
|
|
return Path("static/style-guide.md").read_text()
|
|
```
|
|
|
|
**Good candidates for resources:**
|
|
|
|
- Design tokens / CSS custom properties
|
|
- Template structure descriptions
|
|
- API schemas or field references
|
|
- Content style guides
|
|
- Image/media specifications
|
|
|
|
---
|
|
|
|
## Domain Extension Examples
|
|
|
|
### Wagtail CMS (Angelia)
|
|
|
|
Angelia adds Wagtail-specific patterns on top of the core:
|
|
|
|
- **Page tree tools** — `get_page_tree()`, `get_page_content()` navigate Wagtail's hierarchical page model
|
|
- **Type-specific CRUD** — `create_flex_page()`, `create_blog_post()`, `create_event()` with type-aware field handling
|
|
- **Media tools** — `search_images()` returns pre-generated rendition URLs; `upload_image()` uses async httpx
|
|
- **Wagtail snippet admin** — MCPToken registered as a Wagtail snippet with `GroupedToolWidget` for tool selection
|
|
- **Design resources** — CSS custom properties, base template structure, rendition spec reference
|
|
- **Permissions** — Token inherits user's Wagtail page permissions, collection permissions, group memberships
|
|
- **Audit trail** — All page revisions record `user=token.user`
|
|
|
|
### Generic Django (Inventory API)
|
|
|
|
A hypothetical inventory system using standard Django:
|
|
|
|
```python
|
|
# mcp_server/tools/inventory.py
|
|
|
|
def register_inventory_tools(mcp):
|
|
|
|
@mcp.tool
|
|
def search_products(query: str, in_stock: bool = True, limit: int = 20) -> dict:
|
|
"""Search products by name or SKU. Filter by stock availability."""
|
|
qs = Product.objects.filter(name__icontains=query)
|
|
if in_stock:
|
|
qs = qs.filter(stock_quantity__gt=0)
|
|
return {"products": [
|
|
{"id": p.id, "name": p.name, "sku": p.sku, "stock": p.stock_quantity}
|
|
for p in qs[:limit]
|
|
]}
|
|
|
|
@mcp.tool
|
|
async def adjust_stock(
|
|
product_id: int, quantity_change: int, reason: str,
|
|
ctx: Context = None,
|
|
) -> dict:
|
|
"""Adjust stock for a product. Positive = restock, negative = deduct."""
|
|
user = await get_mcp_user(ctx)
|
|
|
|
def _do_adjust():
|
|
product = Product.objects.get(id=product_id)
|
|
product.stock_quantity += quantity_change
|
|
product.save(update_fields=['stock_quantity'])
|
|
StockAdjustment.objects.create(
|
|
product=product, quantity=quantity_change,
|
|
reason=reason, user=user,
|
|
)
|
|
return {"id": product.id, "new_stock": product.stock_quantity}
|
|
|
|
return await sync_to_async(_do_adjust, thread_sensitive=True)()
|
|
```
|
|
|
|
- **Django admin** — Standard `MCPTokenAdmin` with `list_display` and `list_filter`
|
|
- **Permissions** — Django model permissions (`can_change_product`, `can_view_order`)
|
|
- **Resources** — Product category taxonomy, warehouse location reference
|
|
|
|
---
|
|
|
|
## Anti-Patterns
|
|
|
|
- ❌ Don't run multiple Uvicorn workers with SSE transport — sessions live in the worker's memory and POSTs hit random workers, causing `404 Not Found` on `/mcp/messages/`
|
|
- ❌ Don't store tokens hashed — the middleware needs plaintext lookup via `MCPToken.objects.get(token=token_string)`. Use `unique=True` + `db_index=True` instead
|
|
- ❌ Don't skip `sync_to_async` for ORM calls in async tools — Django raises `SynchronousOnlyOperation` when ORM methods are called from an async context
|
|
- ❌ Don't put auth logic inside individual tools — use middleware so auth is enforced uniformly before any tool executes
|
|
- ❌ Don't exceed 750-character tool descriptions — the MCP spec allows 1024, but leaving headroom avoids protocol overhead issues
|
|
- ❌ Don't inline metrics recording in every tool — extract a shared `record_tool_call(name, status, duration)` helper to keep tools focused on business logic
|
|
- ❌ Don't serialize user/token to state — use `serializable=False` to prevent Django model instances from leaking into logs or JSON responses
|
|
- ❌ Don't show full tokens after initial creation — display the masked version (`get_masked_token()`) in admin list views and logs
|
|
|
|
---
|
|
|
|
## Settings
|
|
|
|
```python
|
|
# settings.py
|
|
|
|
# Require Bearer token authentication for MCP requests.
|
|
# Set to False only for local development.
|
|
MCP_REQUIRE_AUTH = env.bool('MCP_REQUIRE_AUTH', default=True)
|
|
```
|
|
|
|
Additional deployment settings (not MCP-specific, but required for the pattern):
|
|
|
|
| Setting | Example | Purpose |
|
|
|---------|---------|---------|
|
|
| `ASGI_APPLICATION` | `'myproject.asgi.app'` | Uvicorn entrypoint |
|
|
| Uvicorn port | `8001` | Separate from WSGI server |
|
|
| Uvicorn workers | `1` | Required for SSE transport |
|
|
| Nginx proxy target | `http://mcp:8001` | Route `/mcp/` to Uvicorn |
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
Standard test cases every implementation should cover.
|
|
|
|
### Token Model Tests
|
|
|
|
```python
|
|
class MCPTokenModelTest(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(
|
|
email="test@example.com", password="pass123"
|
|
)
|
|
|
|
def test_token_auto_generated(self):
|
|
"""Token is auto-generated on creation."""
|
|
token = MCPToken.objects.create(user=self.user, name="Test")
|
|
self.assertIsNotNone(token.token)
|
|
self.assertTrue(len(token.token) > 20)
|
|
|
|
def test_active_token_is_valid(self):
|
|
"""Active non-expired token is valid."""
|
|
token = MCPToken.objects.create(user=self.user, name="Valid")
|
|
self.assertTrue(token.is_valid)
|
|
|
|
def test_inactive_token_not_valid(self):
|
|
"""Deactivated token is not valid."""
|
|
token = MCPToken.objects.create(
|
|
user=self.user, name="Off", is_active=False
|
|
)
|
|
self.assertFalse(token.is_valid)
|
|
|
|
def test_expired_token_not_valid(self):
|
|
"""Expired token is not valid."""
|
|
token = MCPToken.objects.create(
|
|
user=self.user, name="Old",
|
|
expires_at=timezone.now() - timedelta(hours=1),
|
|
)
|
|
self.assertFalse(token.is_valid)
|
|
|
|
def test_tool_restriction(self):
|
|
"""Restricted token only permits listed tools."""
|
|
token = MCPToken.objects.create(
|
|
user=self.user, name="Limited",
|
|
allowed_tools=["list_products"],
|
|
)
|
|
self.assertTrue(token.can_use_tool("list_products"))
|
|
self.assertFalse(token.can_use_tool("delete_product"))
|
|
|
|
def test_unrestricted_permits_all(self):
|
|
"""Empty allowed_tools permits any tool."""
|
|
token = MCPToken.objects.create(user=self.user, name="Open")
|
|
self.assertTrue(token.can_use_tool("anything"))
|
|
|
|
def test_record_usage(self):
|
|
"""record_usage updates last_used_at."""
|
|
token = MCPToken.objects.create(user=self.user, name="Usage")
|
|
self.assertIsNone(token.last_used_at)
|
|
token.record_usage()
|
|
token.refresh_from_db()
|
|
self.assertIsNotNone(token.last_used_at)
|
|
|
|
def test_masked_token(self):
|
|
"""Masked token hides most characters."""
|
|
token = MCPToken.objects.create(user=self.user, name="Mask")
|
|
masked = token.get_masked_token()
|
|
self.assertTrue(masked.endswith(token.token[-8:]))
|
|
self.assertIn("*", masked)
|
|
```
|
|
|
|
### Auth Resolution Tests
|
|
|
|
```python
|
|
class MCPAuthTest(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(
|
|
email="auth@example.com", password="pass123"
|
|
)
|
|
self.token = MCPToken.objects.create(
|
|
user=self.user, name="Auth Test"
|
|
)
|
|
|
|
def test_resolve_valid_token(self):
|
|
"""Valid token resolves to user and token."""
|
|
user, token = resolve_mcp_user(self.token.token)
|
|
self.assertEqual(user.email, "auth@example.com")
|
|
|
|
def test_invalid_token_raises(self):
|
|
with self.assertRaises(MCPAuthError):
|
|
resolve_mcp_user("invalid-token-string")
|
|
|
|
def test_inactive_token_raises(self):
|
|
self.token.is_active = False
|
|
self.token.save()
|
|
with self.assertRaises(MCPAuthError):
|
|
resolve_mcp_user(self.token.token)
|
|
|
|
def test_expired_token_raises(self):
|
|
self.token.expires_at = timezone.now() - timedelta(hours=1)
|
|
self.token.save()
|
|
with self.assertRaises(MCPAuthError):
|
|
resolve_mcp_user(self.token.token)
|
|
|
|
def test_disabled_user_raises(self):
|
|
self.user.is_active = False
|
|
self.user.save()
|
|
with self.assertRaises(MCPAuthError):
|
|
resolve_mcp_user(self.token.token)
|
|
```
|
|
|
|
### Server Registration Tests
|
|
|
|
```python
|
|
class MCPServerRegistrationTest(TestCase):
|
|
def test_expected_tools_registered(self):
|
|
"""All expected tools are registered on the server."""
|
|
from .server import mcp
|
|
tools = asyncio.run(mcp.list_tools())
|
|
tool_names = {t.name for t in tools}
|
|
for expected in ["list_products", "create_product", "adjust_stock"]:
|
|
self.assertIn(expected, tool_names)
|
|
|
|
def test_resources_registered(self):
|
|
"""All expected resources are registered."""
|
|
from .server import mcp
|
|
resources = asyncio.run(mcp.list_resources())
|
|
uris = {str(r.uri) for r in resources}
|
|
self.assertIn("myapp://api-schema", uris)
|
|
```
|
|
|
|
### Management Command Tests
|
|
|
|
```python
|
|
class CreateMCPTokenCommandTest(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(
|
|
email="cmd@example.com", password="pass123"
|
|
)
|
|
|
|
def test_create_basic_token(self):
|
|
out = StringIO()
|
|
call_command(
|
|
"create_mcp_token",
|
|
user="cmd@example.com",
|
|
name="CLI Test",
|
|
stdout=out,
|
|
)
|
|
self.assertEqual(MCPToken.objects.count(), 1)
|
|
self.assertIn("CLI Test", out.getvalue())
|
|
|
|
def test_invalid_user_raises(self):
|
|
with self.assertRaises(CommandError):
|
|
call_command(
|
|
"create_mcp_token",
|
|
user="nobody@example.com",
|
|
name="Fail",
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Deployment
|
|
|
|
### Dual-Worker Architecture
|
|
|
|
| Process | Server | Port | Protocol | Purpose |
|
|
|---------|--------|------|----------|---------|
|
|
| Web | Gunicorn | 8080 | WSGI | Django views, admin, static |
|
|
| MCP | Uvicorn | 8001 | ASGI | MCP tools (Streamable HTTP + SSE) |
|
|
|
|
Both processes share the same Django codebase and database. Nginx routes traffic:
|
|
|
|
- `/mcp/*` → Uvicorn (port 8001)
|
|
- Everything else → Gunicorn (port 8080)
|
|
|
|
### Docker Compose
|
|
|
|
```yaml
|
|
services:
|
|
web:
|
|
build: .
|
|
command: gunicorn --bind :8080 --workers 3 myproject.wsgi
|
|
ports:
|
|
- "8080:8080"
|
|
|
|
mcp:
|
|
build: .
|
|
command: uvicorn myproject.asgi:app --host 0.0.0.0 --port 8001 --workers 1
|
|
ports:
|
|
- "8001:8001"
|
|
|
|
nginx:
|
|
image: nginx:alpine
|
|
volumes:
|
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
ports:
|
|
- "443:443"
|
|
depends_on:
|
|
- web
|
|
- mcp
|
|
```
|
|
|
|
### Entrypoint Pattern
|
|
|
|
Wait for dependencies before starting the server:
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
set -e
|
|
|
|
# Wait for database
|
|
until PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -U $DB_USER -d $DB_NAME -c '\q'; do
|
|
echo "Waiting for database..."
|
|
sleep 2
|
|
done
|
|
|
|
# Run migrations
|
|
python manage.py migrate --noinput
|
|
|
|
# Collect static files
|
|
python manage.py collectstatic --noinput
|
|
|
|
# Start server (command passed via Docker CMD)
|
|
exec "$@"
|
|
```
|
|
|
|
### Client Configuration
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"my-project": {
|
|
"url": "https://my-site.com/mcp/",
|
|
"headers": {
|
|
"Authorization": "Bearer <token>"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
For SSE transport, change URL to `/mcp/sse/`.
|