Async deployment¶
MCPServer exposes two URL trees that mount the same registries, auth backend,
and session store. Pick one based on infrastructure:
| Mount | Use when |
|---|---|
server.urls (default) |
WSGI, mixed sync/async work, simplicity |
server.async_urls |
ASGI, high-concurrency workloads, async-native services |
Both speak the same MCP wire format — clients cannot tell the deployments
apart. The difference is internal: async_urls dispatches I/O-bound handlers
through arun_service / arun_selector so a single Python process can serve
many concurrent MCP calls without a thread pool.
Switch to ASGI¶
from django.urls import path
from invoices.mcp import server
urlpatterns = [
# Sync: path("mcp/", server.urls),
path("mcp/", server.async_urls),
]
Run with an ASGI server:
uvicorn myproject.asgi:application
# or
daphne -b 0.0.0.0 -p 8000 myproject.asgi:application
# or
hypercorn myproject.asgi:application --bind 0.0.0.0:8000
A standard myproject/asgi.py from django-admin startproject works without
modification — Django's get_asgi_application() happily serves async views.
Middleware¶
Both mounts sit outside Django's browser-session machinery, because MCP is a bearer-token transport with no CSRF token and no login page to redirect to:
CsrfViewMiddleware— the endpoint iscsrf_exempt, soPOSTandDELETEare not rejected for a missing token.LoginRequiredMiddleware(Django 5.1+) — the endpoint setslogin_required = False; authentication is the MCP auth backend's job, and an unauthenticated call gets a JSON401with aWWW-Authenticatechallenge rather than a302.
Everything else in MIDDLEWARE applies normally.
Sync collaborators are bridged automatically¶
AsyncStreamableHttpViewSet accepts the same auth backend and session store as
the sync view. Sync methods on those collaborators are wrapped in
asgiref.sync.sync_to_async at the call site, so the existing
AllowAnyBackend, DjangoOAuthToolkitBackend, InMemorySessionStore, and
DjangoCacheSessionStore work unchanged.
If you write a genuinely async backend or store — e.g. one that hits a remote
IDP via httpx.AsyncClient — declare its methods async def and they are
awaited directly without the thread hop:
class HttpxAuthBackend:
async def authenticate(self, request):
async with httpx.AsyncClient() as client:
response = await client.get(
"https://idp.example/userinfo",
headers={"Authorization": request.META.get("HTTP_AUTHORIZATION", "")},
)
if response.status_code != 200:
return None
return TokenInfo(user=response.json()["sub"], scopes=())
def protected_resource_metadata(self):
return ProtectedResourceMetadata(resource="https://example.com/mcp/")
def www_authenticate_challenge(self, *, scopes=None, error=None):
return 'Bearer realm="mcp"'
server = MCPServer(name="my-app", auth_backend=HttpxAuthBackend())
The acall helper detects coroutine-functions at runtime via
inspect.iscoroutinefunction and routes accordingly — no marker interface
required.
The bridging runs one way only
Sync collaborators work under async_urls. An async collaborator does
not work under the sync server.urls: that view has no event loop to
await on, and an un-awaited coroutine is truthy, so an async
authenticate mounted there would authenticate every caller. The sync
transport refuses such a request with ImproperlyConfigured instead of
serving it. Mount an async backend under async_urls only — see
Write an async-native auth backend.
Sync vs async services¶
Both work under async_urls:
- Async services (
async def create_invoice(*, data)) run native viaarun_service. The full request handling stays on the event loop. - Sync services (
def create_invoice(*, data)) are dispatched throughsync_to_async. Django's connection pooling handles the thread hop correctly; ORM calls inside the service work withoutSynchronousOnlyOperationerrors.
The same applies to selectors. Mix freely — the dispatch path picks the right strategy per call.
Server-initiated push (SSE on GET)¶
When a session opens GET /mcp/, the async view returns a
text/event-stream response. The server pushes JSON-RPC payloads on that
stream as events; the client interprets each data: line as one MCP
message. Idle periods produce SSE keep-alive comments (: keepalive) every
~15 seconds so reverse proxies don't close the connection.
# from app code (a service, a Django signal, a background task — anything
# running in the same process as the MCP server):
await server.notify(
session_id,
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {"progressToken": "task-7", "value": 0.42},
},
)
notify returns True if a subscriber was attached, False otherwise. A
miss is normal — sessions without an open SSE stream just don't see the
event. Most callers fire-and-forget.
Wire details¶
The endpoint enforces the same headers as POST: Mcp-Protocol-Version
required, Mcp-Session-Id required and validated against the session
store. Origin allowlist applies. With no broker configured (e.g. a
MCPServer(sse_broker=None)), GET returns 405 — spec-compliant when the
server has nothing to push. So does SESSIONS_ENABLED = False, since a
session id is what addresses a client's channel.
Either 405 is decided before authentication, so it is what an
unauthenticated caller sees too. This is deliberate: no credential opens a
stream that isn't on offer, and answering 401 there would hand the client a
WWW-Authenticate challenge — the signal it uses to start an OAuth flow, and
a browser window in desktop clients — for a token that changes nothing. Where
a broker is wired and sessions are on, GET authenticates first as before.
Scaling across workers¶
The default InMemorySSEBroker is single-process. A multi-worker deployment
can:
- Keep SSE on a single process by running one ASGI worker (or pinning SSE-enabled requests to one worker via session affinity). The simplest path and works for most apps.
- Use the first-party
RedisSSEBroker(behind the[redis]extra) — pass it asMCPServer(sse_broker=RedisSSEBroker(client)). See Multi-worker SSE with Redis. - Roll your own — implement
subscribe/unsubscribe/publishagainst NATS, Kafka, etc., and pass it asMCPServer(sse_broker=...).
The single-subscriber rule applies per-session: re-subscribing replaces
the previous queue. Message replay across reconnects is opt-in via an
SSEReplayBuffer (InMemorySSEReplayBuffer or RedisSSEReplayBuffer)
passed as MCPServer(sse_replay_buffer=...); with no buffer configured,
Last-Event-ID is silently ignored. See SSE replay buffer.
When sync is the right answer¶
If you don't have async-native services and aren't running ASGI today,
server.urls is the simpler path. Switching to async without genuine async
work below the dispatch layer adds complexity (thread pool, connection
management, more failure modes) without buying anything observable.