Skip to content

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

urls.py
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 is csrf_exempt, so POST and DELETE are not rejected for a missing token.
  • LoginRequiredMiddleware (Django 5.1+) — the endpoint sets login_required = False; authentication is the MCP auth backend's job, and an unauthenticated call gets a JSON 401 with a WWW-Authenticate challenge rather than a 302.

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 via arun_service. The full request handling stays on the event loop.
  • Sync services (def create_invoice(*, data)) are dispatched through sync_to_async. Django's connection pooling handles the thread hop correctly; ORM calls inside the service work without SynchronousOnlyOperation errors.

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 as MCPServer(sse_broker=RedisSSEBroker(client)). See Multi-worker SSE with Redis.
  • Roll your own — implement subscribe / unsubscribe / publish against NATS, Kafka, etc., and pass it as MCPServer(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.