Insiders header, routing and audit¶
The insiders surface lets an HTTP client opt in to experimental behaviour and declare which route, model, agent and session a request belongs to. This page documents the header contract, what routing derives from it, the audit trail it produces, and the boundaries of the feature as shipped.
Nothing here is active without the header: a request that does not send it behaves exactly as it did before routing existed, with one deliberate exception noted in Backward compatibility.
Trust model¶
Routing headers are client-supplied and therefore forgeable. They are not an authentication mechanism and must not be treated as an identity.
Treat the opt-in as a capability you grant to a caller that is already inside a
trust boundary. Run the server behind a gateway that authenticates callers (or
keep MCP_HOST on localhost) before relying on anything the header says. The
one place headers do participate in a security decision is
route-bound tokens, and there a mismatch fails closed.
Route values are attacker-controlled strings that end up in filesystem paths and log lines. Every route is therefore passed through a whitelist before use; see Route sanitisation.
The X-MCP-Insiders header¶
The header name defaults to X-MCP-Insiders and can be renamed with
MCP_INSIDERS_HEADER. Lookup is case-insensitive, because HTTP header names
are.
Grammar¶
The value is a comma-separated list. Every problem is reported and ignored – a typo can never break a request.
Value |
Effect |
|---|---|
(header absent) |
disabled |
(empty or whitespace) |
disabled |
|
disabled |
|
enabled, every registered feature at its default |
|
enabled, |
|
enabled, |
|
enabled, each registered key overridden individually |
Additional rules:
Key names are case-insensitive and treat
-and_as the same character, soModel_Routingandmodel-routingaddress one feature.Keys that are not registered are collected into
unknown_keysand ignored. A client can never activate a feature the server does not know about.An unparsable value (for example
model-routing=maybe) is collected intomalformedand that key is dropped.A header longer than 4096 characters disables the whole header and records
header-too-longinmalformed.
Registered features¶
Feature |
Default |
Effect when active |
|---|---|---|
|
on |
the request’s route, model, agent, client and session are resolved from the routing headers, and one audit record is appended per tool call |
|
on |
insiders-tagged tools are listed and callable, and |
“Default on” describes a feature’s state once the surface is enabled. Sending
X-MCP-Insiders: true turns both on; sending no header at all turns nothing on.
Precedence, weakest first:
the registered default,
the per-request override in the header,
MCP_INSIDERS_DISABLED_FEATURES– an operator kill switch that wins over anything a client sends.
A disabled header yields an empty feature set regardless of the other two.
Declaring a route¶
When model-routing is active, routing reads a set of candidate headers. Each
group is an ordered list and the first header carrying a non-empty value
wins.
Field |
Default candidates |
Environment override |
|---|---|---|
model |
|
|
agent |
|
|
client |
|
|
session |
the first client candidate whose name contains |
– |
All three list variables are JSON, for example
MCP_AGENT_HEADERS='["X-MCP-Agent-ID","X-MCP-Agent"]'. A comma-separated
value is a settings error, not a list.
Route priority¶
The route is the first of these that is available:
the first non-empty agent candidate,
the first non-empty client candidate,
the MCP client’s advertised name (
client_info.name),stdiowhen the request did not arrive over HTTP, otherwiseunknown.
Route sanitisation¶
Whatever the route came from, it must survive a whitelist before it can be used as a directory name or written to a log line:
allowed characters:
A-Za-z0-9._-length: 1 to 64 characters
rejected: path separators (
/,\), traversal (.,..), whitespace, non-ASCII text, the empty string, and anything longer than 64 characters
A rejected value becomes unknown. Note that only the route is sanitised
this way; the extracted model, agent, client and session fields keep
the raw value so an operator can see what a client actually sent.
Per-route logging and the audit trail¶
Log records¶
Every tool call emits one line to a logger named
novelai_image_mcp.audit.<route>, carrying the tool name, route, model, agent,
client, session, duration, outcome and the active features. Because the logger
name contains the route, an operator can route or filter per team with standard
logging configuration.
Audit records¶
An audit record is appended to an append-only JSONL file only while
model-routing is active. The destination is MCP_AUDIT_LOG_PATH when set,
otherwise audit.jsonl inside NOVELAI_OUTPUT_DIR.
The file is opened with O_APPEND and each record is written with a single
os.write, so concurrent writers append whole lines instead of interleaving
halves of one. A crash mid-write can lose a line; it cannot corrupt a line that
was already written.
Field |
Content |
|---|---|
|
UTC timestamp, ISO-8601 |
|
the sanitised route |
|
the extracted value, or |
|
tool name |
|
wall-clock duration, rounded to three decimals |
|
|
|
sorted names of the active features |
|
sorted |
|
sorted unrecognised keys from the header |
|
sorted unusable entries from the header |
|
the sanitised routing header snapshot, capped at 32 entries of at most 256 characters each |
|
a summary of the call arguments (see below) |
Redaction¶
The trail is designed so a payload cannot leak into it:
Prompt-shaped arguments (
prompt,negative_prompt, …) become{"length": n, "sha256": "<first 16 hex characters>"}. The prompt text is never written.Image-shaped arguments (
image,mask,images,init_image,references,base64) become the literal[redacted image].Any string longer than 256 characters is digested rather than written, so a base64 blob cannot slip through under an unexpected argument name.
Containers are reduced to their size; unknown objects to their type name.
Credential-bearing headers –
authorization,cookie,set-cookie,proxy-authorization, and anything ending in-token,-secret,-password,-passwd,-keyor-credential(s)– are removed from the header snapshot before it is recorded, and are never logged.
Failure behaviour¶
Auditing must never break the thing it observes. If the audit file cannot be
created or written, the failure is logged at ERROR and the tool call still
returns its normal result. There is no code path in which an audit problem
turns into a failed tool call.
Route-scoped output¶
When routing is active and MCP_ROUTE_SCOPED_OUTPUT is true (the default),
generated images are written under a per-route subdirectory:
<NOVELAI_OUTPUT_DIR>/<sanitised route>/<name>-<timestamp>-<hex>.png
The novelai://outputs/{name} resource resolves names in the same directory, so
a client reads back exactly what it wrote. The directory name comes from the
same whitelist described above, so a hostile route cannot escape the output
directory.
With routing off, or with MCP_ROUTE_SCOPED_OUTPUT=false, every path is the one
the server produced before routing existed.
Route-bound tokens¶
MCP_AUTH_TOKEN keeps its original meaning: one static bearer token for the
HTTP transport, validated by FastMCP before any MCP request is served.
MCP_ROUTE_TOKENS adds route-bound tokens on top. It is a JSON object mapping
a route to its token:
MCP_AUTH_TOKEN=static-token
MCP_ROUTE_TOKENS='{"team-a":"token-for-team-a","team-b":"token-for-team-b"}'
A route token authenticates as its route, and is rejected when the same request
declares a different route through the routing headers. A mismatch makes the
verifier reject the request, which the bearer middleware turns into a 401 with
a WWW-Authenticate challenge – not a ToolError. Both routes are logged at
WARNING so the rejection stays auditable.
Details worth knowing:
A request with no usable declared route – no routing header at all, or a value that sanitises to
unknown– is accepted and attributed to the token’s own route. Only two different, both-present routes fail closed.The static token never takes part in route checking, so its semantics are unchanged whether or not route tokens are configured.
An entry is skipped, with a warning, when it cannot be honoured: an empty token, a token equal to
MCP_AUTH_TOKEN(the static token wins), a route name that does not pass the whitelist (that token is not registered at all, so it can never authenticate), or a token bound to two routes (the first route in sorted order wins).With neither setting configured, no auth middleware is installed and the endpoint behaves exactly as before.
Backward compatibility¶
A request that does not send the insiders header sees today’s server, with one intentional exception:
Tools, resources, prompts and schemas are unchanged.
No tool is added or removed. No shipped tool carries the
insiderstag, so the listing filter never hides anything from a client that has not opted in.Generated images keep the flat
NOVELAI_OUTPUT_DIRlayout.No audit file is written.
MCP_AUTH_TOKENbehaves exactly as before.
Warning
director_tool’s emotion and emotion_level parameters are now gated.
Passing either one without the experimental-tools opt-in fails with a
ToolError. This is a deliberate behaviour change and is recorded in the
CHANGELOG under Changed. A non-default emotion_level alone counts as
passing the parameter, so the default call shape is unaffected.
Extension points¶
Add a sibling feature. Add one entry to
INSIDERS_FEATURESinapps/server/src/novelai_image_mcp/features.pywith its name,default_enabledand a one-line summary. The header parser, the middleware and every tool pick it up with no further change.Gate a new tool. Register it with
tags={"insiders"}. The listing filter and the by-name rejection both follow from the tag. Do not add that tag to an existing tool: it would remove the tool from every client that does not send the header.Gate a new parameter on an existing tool. Read
features: frozenset[str] = Depends(get_effective_features)and raise aToolErrorfrom the body whenEXPERIMENTAL_TOOLSis absent, the waydirector_tooldoes.
v1 boundaries¶
Tool schemas are static. FastMCP publishes a tool’s parameter schema when the server starts, so a schema cannot vary per request. Experimental parameters therefore stay visible in the schema, and the opt-in is enforced in the tool body instead. A client that introspects the schema sees the parameter but cannot use it without the header.
Routing is per request, not per connection. The header is read from the HTTP request that carries the tool call.
The header is not authentication. See Trust model.