Performance baseline¶
Where the time goes on a tools/call. The numbers below are a single
data point — what they're really for is locating overhead by layer, so
you know where to look first if you ever need to optimise.
Layers¶
A tools/call traverses three layers:
HTTP request
│
│ ① Transport — origin / protocol-version / session validation,
│ JSON-RPC parsing, response serialization
▼
Dispatch
│
│ ② Handler — name lookup, permissions, rate limits, input validation,
│ kwarg-pool resolution, output serializer, ToolResult shaping
▼
Service callable
③ — your code
The benchmark in scripts/benchmark.py runs the same trivial service
(def _service(*, data) -> {"result": data["n"] * 2}) through each
layer and reports the per-call median.
A run on this machine¶
Python 3.14, MacBook (Apple silicon), in-memory SQLite, fresh
InMemorySessionStore, no auth backend overhead (AllowAnyBackend):
| Path | Median (µs) | Overhead vs direct |
|---|---|---|
| Direct callable | ~0.1 | 1× |
| Handler dispatch only | ~50 | ~500× |
| Full HTTP round-trip | ~170 | ~1,800× |
Run it locally:
What to take away¶
- Per-call overhead is in the tens of microseconds at the handler layer and ~170 µs end-to-end for an in-process Django test client. For typical MCP workloads — LLM agents calling tools at human speed — this is several orders of magnitude below what matters. Spending effort optimising the dispatch path is almost never worth it.
- The dominant cost on real servers is the service callable — a database round-trip, a remote API call, or LLM-shaped output rendering. Optimise those.
- The transport accounts for roughly a third of the total in this
micro-benchmark (~120 µs out of ~170 µs). Most of that is Django
request/response construction and JSON-RPC envelope parsing — the
same cost any Django view pays. Async dispatch (
server.async_urls) shifts I/O off the request thread but doesn't materially change per-call CPU.
Where to look if a real workload is slow¶
A few specific things that can show up at scale:
- Auth backend — if every call hits an external introspection endpoint, that's the bottleneck. Cache the introspection result in Django's cache (per-token TTL) and reuse across requests.
- Output serializer —
ModelSerializer(many=True)on a list of thousands is N+1-prone. Add.select_related()/.prefetch_related()in the selector; useoutput_format="toon"to reduce token count if the bottleneck is downstream LLM cost. atomic=Trueon a service that doesn't write — services default to wrapping intransaction.atomic(). If a service is read-shaped, setatomic=Falseon the spec, or — better — register it as a selector tool so the read pipeline runs without the transaction overhead.- SSE broker —
InMemorySSEBrokerpushes are sub-µs; Redis pub/sub adds ~1 ms pernotify. Acceptable for nearly all use cases; if you do hit a hot path, batch notifications.
What the package bounds¶
Inbound work has been bounded since the beginning — MAX_REQUEST_BYTES rejects
an oversized body with 413 before parsing. Outbound work is bounded by three
settings, all of which take None to disable and all of which can be overridden
per tool at registration:
| Bound | Setting | Per-tool | Behaviour over the bound |
|---|---|---|---|
| Result size | MAX_RESULT_BYTES (5 MiB) |
max_result_bytes= |
isError result naming the remedy |
| Page size | MAX_PAGE_SIZE (100) |
max_page_size= |
limit clamped down; hasNext says there's more |
| Row count | MAX_PAGE_SIZE (100) |
max_page_size= |
unpaginated: isError result; paginated: page clamped to the last that exists |
| Duration | DISPATCH_TIMEOUT (60 s) |
dispatch_timeout= |
isError result; ASGI only |
Three things worth knowing before you tune them:
Every payload goes out twice. A successful tool result carries the payload
as structuredContent and as the content[0] text mirror the spec asks for,
so the context cost at the client is roughly 2× the payload. If you are fighting
a context window rather than a byte ceiling, INCLUDE_STRUCTURED_CONTENT=False
(server-wide or per binding) halves it at once — clients that don't parse the
structured field lose nothing.
A deadline does not reclaim the worker. DISPATCH_TIMEOUT cancels the
asyncio task, but a thread parked in psycopg's socket read — which is where
every ORM-backed spec spends its time — is not interruptible by asyncio, so the
thread stays hot until the query ends. The deadline buys the client a terminal
answer instead of an open request; it does not free the connection. Set a
database-level statement timeout for that half:
DATABASES = {
"default": {
# …
"OPTIONS": {"options": "-c statement_timeout=30000"}, # PostgreSQL, ms
}
}
Truncation is never the answer. Over a ceiling, a call fails with an error
the model can act on ("narrow the filter, lower limit") rather than returning
a shortened payload. A clipped list looks complete to a model, which then
reasons from it — a wrong answer delivered confidently is worse than a failed
call.
What is not bounded¶
- Query cost. Nothing here stops a selector from issuing an expensive join;
the bounds measure the result, not the work.
select_related/prefetch_relatedand a database statement timeout are the tools for that. - The work a selector does before the ceiling applies.
MAX_PAGE_SIZEbounds an unpaginated LIST tool's rows with aLIMITbefore rendering, and the call is refused over that ceiling rather than truncated — apaginate=Falseresult has nowhere to record that rows were dropped, so a clipped list would read as complete to the model. What no bound reaches is an expensive selector that computes before it returns. Registering one emitsUnboundedListWarning;REQUIRE_LIST_PAGINATION=Truemakes it an error. - Concurrency of dispatch. Bounding one call says nothing about how many
run at once. Rate limits (
rate_limits=per binding) are the lever there, and they are charged once per client call whether it runs inline or as a task. Long-lived streams are bounded separately, per worker:MAX_CONCURRENT_SUBSCRIPTIONSforsubscriptions/listenandMAX_CONCURRENT_SSE_STREAMSfor theGETsession stream, each paired with a lifetime cap (SUBSCRIPTION_MAX_SECONDS,SSE_STREAM_MAX_SECONDS). - Notification backlog for a client that stops reading. The SSE brokers hold
a bounded per-session queue (
max_queued_events=, 1024 by default) and drop the oldest payload past it, reporting the drop aspublishreturningFalse. Delivery was always best-effort — a client that missed a notification re-reads — so dropping is the honest bound; blocking would park the publisher on a reader that may never return.
Adding profile points¶
The package emits OpenTelemetry spans for mcp.tools.call,
mcp.resources.read, and mcp.prompts.get when the [otel] extra is
installed. See docs/observability.md — that's
the right tool for measuring real-world latency.