Settings¶
Every key lives in a single SPEC_MCP dict in your Django settings.
All of them are optional — the defaults below apply when a key is absent.
# settings.py
SPEC_MCP = {
"ALLOWED_ORIGINS": ["https://app.example.com"],
"REQUIRE_TOOL_PERMISSIONS": True,
}
Reading an unknown key raises KeyError, so a typo surfaces immediately rather
than silently taking a default.
Assigning the dict replaces it; it does not merge
Absent keys fall back to the defaults above, so a partial
SPEC_MCP is fine. But assigning the dict — including
settings.SPEC_MCP = {...} in a test, or @override_settings —
replaces whatever was there, so a project-level opt-out silently disappears
inside that scope. Repeat the keys you rely on in those literals.
The same holds one level down: a dict-valued setting such as
SERVER_INFO is taken whole, not deep-merged. Supplying
{"version": "1.0"} drops any name you had configured alongside it (the
package name is used as the fallback), so give the dict every key you want.
These settings are per-project defaults. Every scalar below can be overridden per server, which is what you want as soon as you mount more than one:
Collaborators are not configured here at all — the auth backend, session
store, SSE broker and server identity are passed to
MCPServer(...) in urls.py, because a settings dict can't hold a live
object.
Protocol¶
The companion cacheScope is derived, not configured: a listing filtered by
FILTER_LISTINGS_BY_PERMISSIONS is private, and so is a tools/list that asked
a tool's operation-scope affordance, since that answer is this caller's; any other
listing is public, and a resource body is always private. public licenses a shared proxy to serve
one response across authorization contexts, which is not a preference — getting
it wrong is a cross-tenant disclosure with a cache in front of it.
What public on a catalog does and doesn't say
The spec's criterion for public is that "the response does not contain
user-specific data" — which an unfiltered catalog satisfies exactly: every
caller gets byte-identical output, so the derivation is the spec's own test
rather than a judgement call. It is also why asking an affordance makes a
tools/list private even with filtering off: the answer is asked against
the caller's user and request, so two callers may be told different things.
It is worth being clear what follows, though. public is not a statement
that the catalog is non-confidential. It permits a shared intermediary to
serve a stored copy across authorization contexts, and a tool catalog does
describe the server's capability surface. If that surface is something you
would rather not have cached in front of an authenticated endpoint, turn on
FILTER_LISTINGS_BY_PERMISSIONS — the result becomes per-caller and the
derivation follows it to private.
| Key | Default | What it does |
|---|---|---|
PROTOCOL_VERSIONS |
["2026-07-28", "2025-11-25", "2025-06-18"] |
Every revision this server speaks, most-preferred first, across both eras — 2026-07-28 is modern (per-request metadata, no session), the rest are legacy (initialize handshake). server/discover reports the whole list; each era validates against its own half. initialize never offers a modern version whatever heads the list: the handshake does not exist there, so answering with it would hand the client a protocol the transport would refuse on its next request. |
REQUIRE_PROTOCOL_VERSION_HEADER |
True |
Reject a post-initialize request that omits MCP-Protocol-Version (HTTP 400). Set False for clients that never send it. A header that is present but unsupported is rejected either way — downgrading silently there would mask a real version mismatch. |
SERVER_INFO |
{"name": "django-spec-mcp"} |
Default serverInfo for initialize. Recognised keys: name, version, title, description, websiteUrl, icons (a list of {src, mimeType, sizes, theme} dicts). Prefer per-server identity: MCPServer(name=…, version=…, title=…, website_url=…, icons=…). description is settings-only — the constructor's description= is the initialize instructions string, which is written for the model rather than for a connection list. |
CATALOG_CACHE_TTL_MS |
60000 |
How long a client may cache a catalog result — server/discover plus the four list methods — emitted as ttlMs. 0 means "immediately stale". The registered bindings are fixed once the process boots, so for them the honest ceiling is "until the next deploy", which nothing here can know; a minute costs a client one stale minute after a release rather than a stale catalog for the life of its connection. A tool an operation-scope affordance leaves out of tools/list is the exception: it can come and go under a running client, and one honouring ttlMs may not re-list for this long unless it is sent notifications/tools/list_changed — see Concepts. |
RESOURCE_CACHE_TTL_MS |
0 |
The same for resources/read. 0 by default because a resource body is whatever a selector just produced. A genuinely static resource — an interactive view, a rendered document — opts in per binding with cache_ttl_ms=. |
TASK_TTL_MS |
86400000 (24 h) |
How long a created task stays readable, reported to the client as ttlMs. Both a promise and a bound: after it elapses the record may be dropped, and a client still politely polling gets "unknown task" — which looks exactly like work that vanished, hence the generous default. None disables expiry, which is only sound for a store that evicts on its own (the cache-backed one falls back to a week so an un-polled task cannot pin memory). |
TASK_POLL_INTERVAL_MS |
5000 |
Suggested tasks/get cadence, sent as pollIntervalMs. Advisory — clients SHOULD honour it, and a server MAY rate-limit one that polls faster. Worth tuning to how long the work actually takes: too low and every task costs a stream of no-op polls, too high and a finished task sits while its client waits. None omits the hint. |
INPUT_REQUEST_TTL_SECONDS |
600 (10 min) |
How long a requestState handed to a client stays redeemable. Bounds the replay window on the one value in this protocol that leaves the server, passes through the client, and comes back trusted — the spec requires the expiry alongside the principal and originating-request checks, which are not configurable because there is no defensible value other than "enforced". Long enough to read a confirmation dialog and decide; short enough that a token captured from a log is dead before anyone replays it. |
MAX_INPUT_ROUNDS |
5 |
How many times one call may ask the user for something before failing instead of asking again. The spec explicitly allows asking repeatedly, and a service that wants a confirmation and a reason legitimately needs two rounds. What this bounds is the service whose condition the answer never clears — otherwise client and server volley the same question at a user indefinitely. Past the cap the call returns the service's own message as an isError result. |
Transport & security¶
| Key | Default | What it does |
|---|---|---|
ALLOWED_ORIGINS |
[] |
Origin allowlist, enforced on every request (mandatory per the MCP spec). ["*"] allows any origin — local development only. |
MAX_PROGRESS_NOTIFICATIONS |
1000 |
Ceiling on notifications/progress frames emitted for one request. The spec asks both parties to rate-limit progress, and the failure mode is the familiar one: a service reporting per row over a large table turns one call into a flood. Past the cap further reports are dropped — the dispatch is untouched and the final response still arrives. |
MAX_REQUEST_BYTES |
1048576 (1 MiB) |
Maximum accepted request body size. |
SESSIONS_ENABLED |
True |
Whether the server mints and requires an Mcp-Session-Id. False runs the sessionless legacy mode, which is conformant rather than a fallback — but the session id is what addresses a client's SSE channel, so with it gone the GET stream has no address and answers 405. Request/response tool calling is untouched. |
SESSION_TTL_SECONDS |
86400 (24 h) |
How long a session may sit idle before it expires. The window restarts on every successful read, so a session in continuous use never lapses. |
SESSION_MAX_AGE_SECONDS |
604800 (7 days) |
Ceiling on a session's total lifetime regardless of activity. Not optional in spirit, though None disables it: a session's principal binding is checked once, at initialize, so without an absolute cap a sliding idle window keeps a revoked principal alive for as long as it keeps talking. A cache-backed store may also evict earlier under memory pressure, which is indistinguishable from expiry on the client side — if sessions vanish early, check the eviction policy before this setting. |
SUBSCRIPTION_MAX_SECONDS |
3600 (1 h) |
How long one subscriptions/listen stream may stay open before the server closes it gracefully and the client re-subscribes. An authorization control as much as a resource one: a subscription's permissions are checked once, when it opens, so without a cap a principal whose access was revoked keeps receiving change signals for as long as it holds the connection. None disables the cap — and both bounds with it. |
MAX_CONCURRENT_SUBSCRIPTIONS |
100 |
Ceiling on concurrent subscription streams per worker. Each parks an ASGI task for its lifetime, so without a bound an authenticated caller can exhaust the worker pool by opening streams in a loop. Past the cap a subscription is refused with 503 / -32603 rather than queued. None disables. |
SSE_STREAM_MAX_SECONDS |
3600 (1 h) |
How long one GET session stream may stay open before the server closes it gracefully with a : stream closed comment frame. The session stream's counterpart to SUBSCRIPTION_MAX_SECONDS, and an authorization control on the same terms: the caller is authenticated once, when the stream opens, so without a cap a principal whose access was revoked keeps receiving that session's pushes for as long as it holds the connection — and it holds it indefinitely, since the keep-alive is what stops any proxy reaping it. An SSE client reconnects by itself; pair with sse_replay_buffer= for a gapless reconnect. None disables. |
MAX_CONCURRENT_SSE_STREAMS |
100 |
Ceiling on concurrent GET session streams per worker. Each parks an ASGI task for its lifetime and minting sessions is uncapped, so without a bound an authenticated caller opens one stream per session it minted and exhausts the worker pool. Past the cap the GET is refused with 503 / -32603 rather than queued. None disables. |
Tools & output¶
| Key | Default | What it does |
|---|---|---|
INCLUDE_STRUCTURED_CONTENT |
True |
Emit structuredContent on tool results. |
INCLUDE_OUTPUT_SCHEMA |
True |
Advertise outputSchema on tool definitions. |
INCLUDE_VALIDATION_VALUE |
False |
Include the offending arguments dict under value in validation errors: beside detail in a tools/call validation_error result's error object, and in the -32602 data that prompts/get and a kernel resources/read answer (for a read, the URI's variables as sent). Off by default — that dict can carry PII or secrets, which would then flow back to the client and into its logs. |
PAGE_SIZE |
100 |
Maximum items returned by one listing call (tools/list, resources/list, resources/templates/list, prompts/list). Clients page with the opaque cursor echoed in the response. |
One combination is a spec violation
INCLUDE_STRUCTURED_CONTENT and INCLUDE_OUTPUT_SCHEMA are independent,
but advertising outputSchema while suppressing structuredContent is
forbidden by the spec and raises ImproperlyConfigured at request time. If
you turn INCLUDE_STRUCTURED_CONTENT off, turn INCLUDE_OUTPUT_SCHEMA off
too (or set include_output_schema=False per binding). The other direction
— structuredContent without outputSchema — is allowed.
Outbound bounds¶
The mirror of MAX_REQUEST_BYTES on the way out. All three accept None to
disable, and all three can be overridden per tool at registration
(max_result_bytes=, dispatch_timeout=, max_page_size=) — pass None
there to lift the bound for one deliberately-large or deliberately-slow tool.
See What the package bounds.
| Key | Default | What it does |
|---|---|---|
MAX_RESULT_BYTES |
5242880 (5 MiB) |
Ceiling on one tool result or resource read, measured on the encoded wire payload. A successful tool result carries the payload twice — as structuredContent and as the spec's backwards-compatibility text mirror — so a ceiling counting one copy would be wrong by 2× against the thing that matters, the client's context window. Over the ceiling the call returns an isError result naming the remedy; it is never truncated, because a clipped list reads as complete to the model reasoning from it. |
MAX_PAGE_SIZE |
100 |
Ceiling on how many rows one selector-tool call returns, paged or not. For a paginate=True tool it bounds the model-supplied limit, and clamps page to the last page that exists so an arbitrarily large page number cannot become an arbitrarily large SQL OFFSET. For a paginate=False tool it is applied as a LIMIT before rendering and the call is refused over the ceiling rather than truncated, for the reason REQUIRE_LIST_PAGINATION gives below. None disables the ceiling on both. Advertised as maximum on the generated inputSchema and clamped at dispatch — the schema tells a well-behaved model what to ask for, the clamp is what stops you trusting it. Clamping is safe here because totalPages / hasNext keep a clamped page self-describing. The default matches the page a selector tool serves when the model omits limit entirely, so an unconfigured deployment never advertises a page larger than the one it defaults to. |
DISPATCH_TIMEOUT |
60.0 |
Wall-clock ceiling, in seconds, on one dispatch. ASGI only — a sync WSGI view has no in-process way to bound its own dispatch. It does not reclaim the worker: a thread parked in a database driver's socket read is not interruptible by asyncio cancellation, so the query runs on. What it buys is a terminal protocol event instead of an open request that never resolves. Pair it with a database statement timeout. |
REQUIRE_LIST_PAGINATION |
False |
Registering a LIST selector tool with paginate=False raises ImproperlyConfigured instead of emitting UnboundedListWarning. Such a tool serialises whatever its selector resolves to — the whole table, for a plain Model.objects.all() — and unlike a paginated tool there is no honest way to clamp it, because the result carries no metadata saying rows were dropped. MAX_PAGE_SIZE is the backstop — applied as a LIMIT before the rows are rendered, so an oversized table is refused rather than fetched and measured, which is what MAX_RESULT_BYTES alone could not do. paginate=True is the fix. |
Permissions¶
| Key | Default | What it does |
|---|---|---|
REQUIRE_TOOL_PERMISSIONS |
True |
Registering a tool or resource whose kernel spec declares no permissions (permissions=None), or a prompt with neither permissions=[…] nor scopes=, raises ImproperlyConfigured. Set it to False to downgrade that to an UnguardedToolWarning while migrating a large surface. The warning exists because a guard outside the spec — a decorator on another view, a middleware scoped to another route, a REST_FRAMEWORK default — has no effect over MCP: what reaches /mcp/ is the auth backend, the spec's own permissions and the gates passed at registration, so an operation that looks guarded on another route ships here as an unguarded binding. Interactive views (register_ui_resource) are exempt: a view carries no tenant data by construction. A kernel spec counts as guarded by its own permissions alone, a scope gate apart; with the setting off, one declaring permissions=None and registered through register_tool or register_resource is called under a kernel Grant built at each call for that spec and caller, so it runs with no permission check rather than failing. A binding built by hand and passed to a registry is never granted, so the kernel refuses an unguarded one at dispatch. See Authentication and Kernel specs. |
REQUIRE_TOOL_DESCRIPTIONS |
False |
Registering a tool with no description raises ImproperlyConfigured instead of emitting UndescribedToolWarning. The description is the only thing a model reads to decide whether and how to call a tool, so an empty one ships a tool that cannot be used correctly — and tools/list renders it indistinguishably from a documented one. There is deliberately no docstring fallback: a docstring is written for the next developer, not for a model choosing between tools. See Documenting tools. |
FILTER_LISTINGS_BY_PERMISSIONS |
False |
Drop bindings whose permissions deny the caller from tools/list, resources/list, resources/templates/list and prompts/list, and, for a kernel binding, those its spec's checks refuse when asked with no arguments (Filtering listings). Per-binding always_listed=True opts one back in as a discovery aid. A tool whose operation-scope affordance is unmet is left out of tools/list whatever this says. |
Observability¶
| Key | Default | What it does |
|---|---|---|
RECORD_SERVICE_EXCEPTIONS |
False |
Record a ServiceError raised from a tool callable on the active OpenTelemetry span before mapping it to a JSON-RPC error. Off by default because services often raise ServiceError for routine business-rule denials, which would flood error pipelines. ServiceValidationError is never recorded — it is client input failure, not a server fault. See Observability. |