# 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 " } } } } ``` For SSE transport, change URL to `/mcp/sse/`.