Your MCP Server Is a Second API: How to Add One to Django Without Skipping Row-Level Checks (Part 3)
Thin MCP tools that go through the same scoped, audited, row-level door as your agents, plus five Django mounting traps and a 14-test suite.

Short version
- An MCP server is a second API to your data. If its tools run their own queries, they skip every rule your first API enforces. On November 3, a CVE was published for exactly that shape in a CMS called Instatic.
- Don't write that second API. Make every MCP tool a thin wrapper around the one function your agents already go through (
call_tool()from Part 2). Same scopes, same row-level queryset, same approval queue, same audit log.- Use the official Python SDK's v2 API (
MCPServer, not the oldFastMCP). A bearer token is the Part 2 grant, verified by aTokenVerifier. The SDK answers 401 for everything else.- Mounting next to Django has five traps, three of which I hit myself: the lifespan, route order, the discovery document, the host allowlist and the sync ORM. Fixes are below.
- Test over a real socket. The SDK's in-memory client skips authorization. 14 new pytest tests (32 total) run against a live uvicorn.
On November 3, 2026, a CVE record was published for Instatic, a content management system with AI chat and MCP tools. Before version 0.0.21, a user with only ai.chat and content.edit.own could call the MCP tools content_get_document and content_list_documents "without the object-level authorization checks enforced by the normal HTTP API" (CVE-2026-121458). That read other users' unpublished drafts and listed rows from the posts system table. The advisory says the HTTP endpoint "correctly prevents the same access and returns 404", so the boundary existed. It just wasn't on the MCP surface (GHSA-x5cc-8486-8qqx).
I'm not saying your Django app has this bug. I'm saying the shape is easy to build by accident. You add an MCP server, write a tool that does Document.objects.filter(...), and the tool works in the demo. Nobody asks which rules the view code applied before that queryset, because the tool isn't a view.
This is Part 3 of Shipping AI Agents in Django. Part 1 put a human approval gate in front of destructive actions. Part 2 gave each agent run a scoped, 15-minute grant, checked against the human's own Django permissions on every call. Now I'll expose that same door over MCP, so any MCP client can use the tools without getting a new set of rules.
Why roles matter more than tools
If you build team software, you already have this problem. Stride, our project tool, has walkthroughs split into "For everyone" and "For admins & managers", plus one called "Let AI do the busywork". People in one workspace hold different powers. As soon as an assistant acts for one of them, it has to hold exactly those powers and no more. None of the code below comes from Stride. The demo uses invoices, a clerk who can annotate them and a manager who can void them. The question is the same.
Pick your stack before you copy a tutorial
Two things changed recently, and many tutorials predate both.
First, the protocol. The 2026-07-28 revision of MCP is stateless: no initialization handshake, no session ID. Each request is a plain HTTP POST (Adam Johnson, who built django-mcpz on top of it, and the SDK docs).
Second, the official Python SDK. pip install mcp now installs 2.x, and the high-level class FastMCP is MCPServer. The old mcp.server.fastmcp import path is gone rather than deprecated (what's new in v2). The 1.x line moved to maintenance. If you paste a 2025 snippet into a 2026 install, the first line fails.
You have options. I haven't run the third-party ones, so this is only what their own pages say:
django-mcpztargets the stateless protocol, fits a plain synchronous WSGI deployment, requires anauthcallable on every server, and ships optional bearer-token and OAuth apps. It implements tools only, and its author has no plans for async support or streaming events (announcement).django-mcp-serverexposes models and Django REST Framework APIs as tools and depends on DRF (PyPI).mcp-djangogives an assistant project exploration and a Python shell. Its README says do not use it in production (GitHub).
I used the official SDK because I wanted to see its authorization surface directly. The price is an ASGI deployment next to Django, which django-mcpz avoids. Everything below ran on Python 3.13, Django 5.2.19 (the November 3 security release, per the Django weblog) and mcp 2.3.0.
One more reason to write your own server on purpose: on November 3, Glow Labs reported that in 98% of the sessions where a coding agent installed or suggested a deprecated MCP server, the agent appeared to have filled in the package name from the model's training data (Help Net Security). The server you don't choose can get chosen for you.
The design: one door
MCP client (agent runtime)
│ Authorization: Bearer <grant token>
▼
/agent/mcp ── TokenVerifier ── resolve_grant() 401 if expired, revoked, agent off
│
▼
thin MCP tool ──► call_tool(token, name, args) ← the Part 2 door
scope in grant? user still has the permission?
destructive → PendingAction (a human approves)
Invoice.objects.visible_to(user) … audit row
The MCP layer owns four things only: authenticating the bearer, describing tools to the model, translating errors, and getting Django's synchronous ORM off the event loop. It owns no business rule. If you only take one thing from this post, take that.
Step 1: a grant is the bearer token
The SDK has no opinion on what a valid token is. You implement TokenVerifier: one async method, token in, AccessToken | None out (authorization docs). My verifier asks Part 2's resolve_grant(), which already refuses expired or revoked grants, disabled agents and inactive users.
Imports first, since the v2 paths are easy to get wrong:
from typing import Annotated
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import close_old_connections, connection
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import AnyHttpUrl, Field
from .grants import resolve_grant
from .tools import ToolDenied, call_tool
class GrantTokenVerifier:
"""Bearer token in, AccessToken out. The token is a Part 2 grant."""
async def verify_token(self, token: str) -> AccessToken | None:
grant = await sync_to_async(resolve_grant)(token) # expired / revoked / agent off -> None
if grant is None:
return None
return AccessToken(
token=token,
client_id=grant.agent.slug,
scopes=list(grant.scopes),
expires_at=int(grant.expires_at.timestamp()),
resource=settings.MCP_RESOURCE_URL, # these grants only exist for this server
subject=str(grant.on_behalf_of_id),
claims={"grant_id": str(grant.pk), "correlation_id": str(grant.correlation_id)},
)
mcp = MCPServer(
"lycore-billing",
instructions="Billing tools. You act for one signed-in user and can only do what they can do.",
token_verifier=GrantTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl(settings.MCP_ISSUER_URL),
resource_server_url=AnyHttpUrl(settings.MCP_RESOURCE_URL),
validate_token_resource=True,
),
)
Three details:
resource=plusvalidate_token_resource=Truemakes the SDK refuse any token not issued for this server's URL. The MCP spec says servers must only accept tokens issued for them as the audience and must not accept or pass through any other tokens (authorization, security best practices). Grants are opaque tokens I mint myself, so the check is trivially true here. It matters once a real authorization server is involved.scopescopies the grant's scopes. The SDK doesn't enforce per-tool scopes for you;call_tool()does.- This is not OAuth.
issuer_urlis required byAuthSettings, and I pointed it at the site itself, but there is no authorization server behind it. That works for agent runtimes you control: your orchestrator mints a grant and hands it to its MCP client. It does not work for hosted assistants that can only connect through an OAuth consent flow. Adam Johnson makes the same distinction, anddjango-mcpzships an OAuth app for it. I haven't tested that app.
Step 2: tools are thin, errors are deliberate
def _in_django(fn, *args):
"""Run sync Django code off the event loop, then tidy up the DB connection."""
def run():
try:
return fn(*args)
finally:
if not connection.in_atomic_block: # never close mid-transaction (tests)
close_old_connections()
return sync_to_async(run)()
async def _call(tool_name: str, **args):
access = get_access_token()
if access is None: # stdio or in-memory: there is no caller identity
raise ToolError("Authentication required.")
try:
return await _in_django(call_tool, access.token, tool_name, args)
except ToolDenied:
# The reason is in AuditEvent. The model gets no map of our permissions.
raise ToolError("Not permitted for this user or grant.") from None
except ObjectDoesNotExist:
# Same answer for "missing" and "not yours": no enumeration.
raise ToolError("No matching record.") from None
Number = Annotated[str, Field(pattern=r"^INV-[0-9]{1,8}$", description="Invoice number, e.g. INV-1042")]
@mcp.tool(
title="List open invoices",
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),
)
async def list_open_invoices(
customer: Annotated[str, Field(max_length=100, description="Optional customer name filter")] = "",
) -> list[dict[str, str]]:
"""List open invoices the signed-in user can see (max 50)."""
return await _call("list_open_invoices", customer=customer)
Why each choice:
- Same error for "missing" and "not yours".
Invoice.objects.visible_to(user).get(...)raisesDoesNotExistfor both. The caller getsNo matching record.either way, so an injected agent can't use error text to enumerate other people's invoice numbers. In the test run both messages are identical. - Permission denials say nothing specific. The reason (
scope invoices:void not in grant,user lacks billing.void_invoice) goes toAuditEvent, not to the model. The cost: an honest agent can't tell why it was refused. I think that's the right default for a tool surface an attacker can steer, but it makes agents worse at self-correcting. - Input constraints are the schema.
pattern=andmax_length=become JSON Schema, and the SDK rejects bad arguments before the function runs (tools docs). A../etc/passwdinvoice number never reachescall_tool(). - Annotations are hints.
destructive_hint=Truetells a client UI to be careful. The SDK docs say never to rely on a client honouring them. The real control is thatvoid_invoicecan only queue aPendingAction. ToolErroris what the model reads. In my runs the SDK prependsError executing tool <name>:to the message. Any other exception becomes a bare failure for the model and a traceback in your log (error handling).
The remaining two tools are add_invoice_note and void_invoice, built the same way. Each wrapper is a decorator, a docstring and one return await _call(...).
Step 3: mount it next to Django
import os
from contextlib import asynccontextmanager
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.conf import settings
from django.core.asgi import get_asgi_application
from mcp.server.transport_security import TransportSecuritySettings
from starlette.applications import Starlette
from starlette.routing import Mount
django_app = get_asgi_application() # also runs django.setup()
from agents.mcp_server import mcp # noqa: E402 (needs apps loaded)
mcp_app = mcp.streamable_http_app(
transport_security=TransportSecuritySettings(
allowed_hosts=settings.MCP_ALLOWED_HOSTS,
allowed_origins=[], # no browser clients
),
)
@asynccontextmanager
async def lifespan(app):
# A mounted app's own lifespan never runs, so the host enters it.
async with mcp.session_manager.run():
yield
# The 401 points clients at /.well-known/... on the site root, but a mounted app
# only serves it under its prefix. Lift the discovery route out to the root.
discovery = [r for r in mcp_app.routes if r.path.startswith("/.well-known/")]
app = Starlette(
routes=[
*discovery,
Mount("/agent", app=mcp_app), # endpoint: /agent/mcp. First: Mount("/") matches everything
Mount("/", app=django_app), # the rest of the site, admin, approvals
],
lifespan=lifespan,
)
Four traps. I hit two of them, reproduced a third, and took the lifespan one from the docs:
- The lifespan. A mounted sub-app's lifespan never runs, so the host app must enter
mcp.session_manager.run(). Skip it and the first request fails withRuntimeError: Task group is not initialized(ASGI docs). - Route order.
Mount("/")matches every path, so the MCP mount must come before it. I mounted MCP at/agentrather than at/mcp. My first attempt at/mcpwithstreamable_http_path="/"sent requests to the bare path/mcpto Django. I didn't chase why; the documented prefix-plus-default-path layout worked. - The discovery document. The 401 response tells clients to fetch
/.well-known/oauth-protected-resource/agent/mcpfrom the site root. A mounted app serves that route only under its prefix, so my first live run returned a 500 from Django there. Lifting the route to the top level (thediscoverylist) fixed it. A test now follows the 401's own pointer and requires a 200. - The host allowlist. Behind a real hostname the app rejects every request with a 421 until you pass
transport_security=(deploy docs). I sent a request with a made-upHost:header and got421 Misdirected Request. Put your real hostnames inMCP_ALLOWED_HOSTS.
And one Django-specific trap: the ORM raises SynchronousOnlyOperation if you call it with an event loop running in that thread. That is why _in_django() hands every ORM call to sync_to_async. Django normally closes stale connections on its request signals. These requests never pass through Django's handler, as far as I can tell, so the wrapper calls close_old_connections() itself, but never inside an open transaction. I haven't load-tested this. sync_to_async defaults to one shared thread, so a slow query will queue other tool calls behind it.
Step 4: run it
Seed what Part 2's tests use: a clerk who can view and change invoices, a manager who can also void, a billing-assistant agent, two invoices (INV-1 belongs to the clerk, INV-2 to the manager). Then:
uvicorn config.asgi:app --port 8000
python manage.py mint_agent_grant billing-assistant --user clerk \
--scope invoices:read --scope invoices:annotate
# stderr: grant 523ec216-… for clerk, expires 22:27:35 UTC
# stdout: the bearer token, once
The management command wraps issue_grant(), so the Part 2 rules still apply. Ask for a scope the clerk lacks and it refuses. Here is a Python MCP client with that token in the Authorization header, run against the live server. The client builds its own httpx2 client for the header, as the transport docs show:
['list_open_invoices', 'add_invoice_note', 'void_invoice']
False {'result': [{'number': 'INV-1', 'customer': 'Acme', 'amount': '120.00'}]}
True Error executing tool add_invoice_note: No matching record.
False {'number': 'INV-1', 'notes': 'Customer asked for a PO number'}
True Error executing tool void_invoice: Not permitted for this user or grant.
Line 2: the clerk sees INV-1 only. Line 3: INV-2 is the manager's, so it looks like a missing invoice. Line 5: the grant has no invoices:void scope. The audit table afterwards:
list_open_invoices allowed
add_invoice_note error DoesNotExist
add_invoice_note allowed
void_invoice denied scope invoices:void not in grant
Row 2 is a fix to my own Part 2 code. In call_tool(), the audit row was written after the tool returned, so a tool that raised left no trace. Now:
try:
result = tool.func(user=user, **args)
except Exception as exc:
# A tool that blows up (bad row number, DB error) still leaves a trace.
_audit(grant, tool_name, "error", type(exc).__name__, args)
raise
Tests: the in-memory client can't test auth
The SDK's in-memory Client(mcp) is great for tool logic, but it connects straight to the server object. The docs say authorization is skipped there, and over stdio too (authorization docs). My tool wrappers refuse to run without a caller identity, so every test goes through a real socket. A session-scoped fixture starts the real ASGI app on a free port with uvicorn in a thread. Tests use django_db(transaction=True), because the server thread needs committed rows.
Fourteen new tests sit on top of Part 2's eighteen: no token gives a 401 with a discovery pointer that resolves; an unknown, revoked or kill-switched grant stops working; row-level rules and "someone else's invoice looks missing"; scope denials are audited without leaking scope names; a permission removed mid-run is enforced; void_invoice only queues; bad input never reaches call_tool(); tool errors leave an audit row. Two I'd copy first (db is sync_to_async; call() opens a client with the bearer header):
async def test_row_level_rules_apply_over_mcp(mcp_url, agent, clerk, invoices):
_, token = await db(issue_grant)(agent=agent, user=clerk, scopes=["invoices:read"])
result = await call(mcp_url, token, "list_open_invoices")
numbers = [row["number"] for row in result.structured_content["result"]]
assert numbers == ["INV-1"] # INV-2 belongs to the manager
async def test_every_registered_tool_has_exactly_one_mcp_twin(mcp_url, agent, clerk):
_, token = await db(issue_grant)(agent=agent, user=clerk, scopes=["invoices:read"])
http, client = mcp_client(mcp_url, token)
async with http:
async with client:
listed = {tool.name for tool in (await client.list_tools()).tools}
assert listed == set(REGISTRY)
The second is the drift guard. If someone adds a tool to the registry and forgets its MCP wrapper, or the reverse, CI fails. It's the cheapest defence I know against the "second API" problem. It checks names, not behaviour, so the row-level test above still has to exist.
What this doesn't do
- No OAuth. Covered above. For hosted assistants you need a real authorization server and the spec's flow: discovery, resource indicators and PKCE (spec).
- Per-run grants versus IDE configs. A 15-minute token suits an orchestrator that mints one per run. It does not suit a developer pasting a credential into an editor config. Adam Johnson's answer there is per-client revocable bearer tokens that each act as a user. I'd keep the same scope and permission checks and lengthen the TTL, but that is a judgment call.
- The tool list is the same for everyone. I confirmed a read-only grant still sees all three tools in
tools/list. Calls are denied, but the capability names are visible. - Two lookups per call. The verifier resolves the grant, then
call_tool()resolves it again. I kept the second one socall_tool()stays the single enforcement point. - Allowed is still allowed. A prompt-injected agent can use everything the user can use under that grant. Scopes shrink what's reachable and the approval queue covers destructive actions. Neither stops junk notes on every invoice the clerk can edit.
- Audit arguments may hold personal data. I log them. Decide what to redact before you do the same.
Checklist for your PR template
### MCP tool checklist
- [ ] Tool is a thin wrapper over the existing registry function; no ORM in the wrapper
- [ ] Registry function declares scope, Django permission and kind (read / write / destructive)
- [ ] Reads go through the same row-level queryset as the HTTP views
- [ ] Destructive tools queue for approval; annotations are hints only
- [ ] Inputs constrained with Field(pattern=..., max_length=...)
- [ ] Missing and forbidden rows return the same message
- [ ] Denials return a generic message; the reason goes to the audit log
- [ ] Failures (exceptions) write an audit row too
- [ ] Drift test: registry tools == MCP tools
- [ ] Tested over HTTP with a real bearer token, not only in memory
- [ ] MCP_ALLOWED_HOSTS lists the real hostnames; discovery URL returns 200
Open question for readers
Right now tools/list shows every grant the full tool list, and call_tool() is the only place that says no. I like that there's one enforcement point. The alternative is filtering the list by the grant's scopes: a smaller context for the model, and an injected agent never learns that void_invoice exists. But then the list has to stay in sync with the call-time check, which is the same drift problem again, and the answer now differs per token. (My reading of the v2 notes is that list results carry cache hints, so a per-token list would need to be scoped to the caller. I haven't tested that.)
Which do you ship, and why? A related one: what TTL do you give grants for human-driven clients like IDEs? Reply with what you do and what went wrong. I read every comment and will fold the good answers into the next part.
Shipping AI Agents in Django
- Part 1: Human-in-the-loop approval gates, audit trails and evals
- Part 2: Least-privilege agent identity
- Part 3: an MCP server for your Django app (this post)
- Planned next: permission-aware retrieval, so RAG respects the same row-level rules
If you're wiring agents into a Django or Python product and want a second pair of eyes on the permission model, that's the kind of work we do at Lycore.
About the author
- Custom software & AI development company. We build production-ready AI integrations, mobile apps, and web platforms for businesses that want real results. lycore.com
Comments
Join the discussionNo comments yet. Be the first to comment.



