MCP servers
An MCP server tells your agent what tools exist, in prose the model reads as authoritative, and it can change that prose after you approved it. That is the plane the SDK and sidecar paths do not have: they see calls, never definitions.
Norviq’s MCP proxy sits in the agent’s pod and mediates the protocol in both directions. It is a
protocol adapter, not a second engine — an mcp/tools/call maps onto the same ToolCallEvent
the SDK produces, reaches the same POST /api/v1/evaluate, and is adjudicated by the same policy.
A rule you wrote for a LangChain agent applies to an MCP tool of the same name with no changes.
What is new is split across two gates with deliberately different costs:
| Gate A — discovery | Gate B — invocation | |
|---|---|---|
| Runs on | tools/list and its siblings, prompt templates, notifications/* |
tools/call, resources/read, sampling/createMessage |
| Frequency | a handful of times per session | every call |
| Cost | scan + hash + pin comparison | one /evaluate round trip |
| Decided by | the proxy, from configuration and the registry — policy is not consulted | policy, after five proxy-side checks |
| Nature | heuristic, evadable by construction | deterministic backstop |
Gate A never runs on the Gate B path. A tools/call costs one dictionary lookup against a catalog
built at discovery, which is why it is affordable to put definition state in front of every policy
decision.
flowchart TB
subgraph gatea["Gate A -- once per session"]
list["server returns<br/>tools/list"] --> reg{"server registry<br/>decision"}
reg -->|blocked| empty["every definition withheld<br/>empty tools list + audit row"]
reg -->|discovered / registered| scan["scan each definition<br/>+ hash it"]
scan --> pin{"digest vs<br/>approved pin"}
pin --> act["pass / sanitize / strip<br/>cached on the catalog entry"]
act --> model["what the model is shown"]
end
subgraph gateb["Gate B -- every call"]
call["tools/call"] --> carry{"Gate-A carry-over<br/>drift / quarantined / stripped?"}
carry -->|yes| refuse["refused in-proxy<br/>reported as a block"]
carry -->|no| schema{"conforms to the tool's<br/>own inputSchema?"}
schema -->|no| refuse
schema -->|yes| eval["POST /api/v1/evaluate<br/>with input.mcp"]
eval --> decision["allow / block /<br/>escalate / audit"]
end
act -.->|catalog entry| carry
Getting the proxy into the path
Section titled “Getting the proxy into the path”A stdio MCP server is a child process of its client. There is no socket to sit in front of, so the proxy becomes the child:
python -m norviq.mcp --server-id filesystem -- npx -y @modelcontextprotocol/server-filesystem /workStreamable HTTP
Section titled “Streamable HTTP”Fronts a remote MCP endpoint, still as a sidecar in the agent’s pod rather than a shared gateway — a shared gateway would have to attest callers over the network, which is a different design.
python -m norviq.mcp --http --listen 127.0.0.1:9000 --upstream https://mcp.example.com/mcpThere is no norviq-mcp console script and no norviq mcp CLI subcommand; python -m norviq.mcp is
the entry point. Flags: --server-id (keys the definition pins — set it explicitly; it defaults to
the literal upstream in HTTP mode), --session-id, --listen (default 127.0.0.1:9000),
--upstream, and --tool-name-prefix.
Identity comes from the SVID, never from an MCP message. MCP carries no principal at all; the proxy
runs in the agent’s pod, so its SPIFFE identity is that workload’s, and /api/v1/evaluate re-binds
every enforcement-selecting field to the caller’s own credential anyway.
In Kubernetes
Section titled “In Kubernetes”Injection is annotation-driven and off by default (webhook.injection.mcp.enabled: false). The
proxy payload image is required with no fallback — the engine image carries the norviq package,
not the relocatable payload, so there is nothing sane to default to:
helm upgrade norviq oci://ghcr.io/norviq-dev/charts/norviq --version 0.2.5 -n norviq --reset-then-reuse-values --set webhook.injection.enabled=true --set webhook.injection.mcp.enabled=true --set webhook.injection.mcp.proxyImage=<registry>/norviq-mcp-payload:<tag>A pod then opts in per container. Its image needs nothing installed — an init container copies the
payload in from proxyImage, and the injector rewrites the named container’s command to exec the
proxy:
metadata: annotations: norviq.io/mcp-servers: "filesystem,github" # containers whose command IS an MCP server norviq.io/mcp-server-id.github: "github-prod" # optional stable pin id (default: container name)Two shapes are denied at admission rather than skipped, because “never leave a named server ungoverned” is the whole point of naming it:
- a name in
norviq.io/mcp-serversthat matches no container — a typo that “worked” is the failure mode; - a named container with no explicit
command— its real argv is the imageENTRYPOINT, which admission cannot see, so the proxy cannot wrap it.
Injection covers the stdio shape only. A container that looks like an HTTP-transport server (it
declares a containerPort) is admitted with a warning, NRVQ-WHK-4051, rather than refused: the
rewrite happens, the pod comes up, and the proxy is not in the MCP data path. Deploy the --http
driver yourself for that case.
The server registry
Section titled “The server registry”GET /api/v1/mcp/servers is the inventory MCP has none of its own. It populates itself the first
time an agent runs a tools/list through a proxy — there is nothing to configure — and an operator
records a decision on each row.
Status is three-valued on purpose:
| Status | Meaning | Effect at discovery |
|---|---|---|
discovered |
seen, never reviewed. Where a server lands on first sight, and never a state it is promoted into automatically | reachable — tools are listed |
registered |
an operator said this server belongs here | reachable |
blocked |
an operator said it does not | every definition withheld: the tools list is rewritten to empty, annotated server_blocked, and one audit row is written naming the method tools/list and the count withheld |
writable is a separate axis on a registered server, because “may I reach it” and “may I write
through it” are different questions and a read-only knowledge base is an ordinary shape.
Blocking is enforced at discovery, not at call time. For the poisoning vectors the description is the payload: by the time a call arrives the prose has already been rendered into the model’s context and has already had its chance to steer it.
# what is my estate talking to, worst first?curl -s "$NRVQ_API_URL/api/v1/mcp/servers?namespace=chatbot-prod" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" \ | jq '.[] | {server_id, status, writable, tools, drifted, quarantined, flagged, worst_severity, health, observed_health}'{ "server_id": "slack", "status": "discovered", "writable": false, "tools": 3, "drifted": 1, "quarantined": 0, "flagged": 1, "worst_severity": "critical", "health": "drift", "observed_health": "drift"}Two roll-ups, because they answer different questions. observed_health is what the estate says —
drift, quarantined, flagged, unreviewed, ok — and knows nothing about decisions. health
folds the decision in, so blocked outranks every observation; the list sorts on it, worst first,
ties broken on server_id. A server the operator already refused must not sink below one that merely
drifted, and a blocked server whose definitions are in fact clean still shows that in
observed_health — which is the fact an unblock decision turns on.
Record a decision (admin):
curl -s -X POST "$NRVQ_API_URL/api/v1/mcp/servers/decision" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" -H "Content-Type: application/json" \ -d '{"namespace":"chatbot-prod","server_id":"postgres","status":"registered","writable":false, "note":"read replica only; writes go through the ticketing service"}'{ "namespace": "chatbot-prod", "server_id": "postgres", "status": "registered", "writable": false, "previous_status": "discovered", "note": "read replica only; writes go through the ticketing service", "registered_servers": 3}registered_servers is the count the generated registry module was rebuilt with. -1 means the
module could not be rebuilt: the decision is saved and durable, but the two registry-backed controls
below are stale until the next decision in that namespace. It is logged at NRVQ-MCP-5075.
A blocked server is never writable — the flag is cleared on the way in, so unblocking later cannot
resurrect a latent write grant. The same endpoint returns a server to discovered.
Running proxies read the registry on a timer, at startup and every mcp_pin_refresh_s seconds
(default 30), on the discovery path only. A block therefore takes up to one interval plus one
tools/list to reach a live session. Two honest fail-open properties:
- before the first successful load the registry is empty, so every server reads
discoveredand nothing is withheld. A proxy that starts while the API is unreachable does not enforce ablockeddecision until it can read one. Logged atNRVQ-MCP-5077; Gate B is unaffected, so a policy that refuses an unregistered server still refuses it. - a load that fails after a success keeps the last good copy rather than clearing it, so a blocked server does not become reachable because the API restarted.
The proxy never writes a decision. Deciding is a human act performed in the console, and a compromised sidecar that could register itself would make the control a formality.
Gate A: definitions
Section titled “Gate A: definitions”Pinning and drift
Section titled “Pinning and drift”Approval is bound to content, not to a name:
pin_id = sha256(server_id + NUL + tool_name)[:32]digest = sha256(canonical_json(name, title, description, inputSchema, outputSchema, annotations))The digest deliberately excludes transport metadata and any _meta, so a server bumping an unrelated
field does not manufacture a false drift. A detector that cries wolf gets switched off, which is worse
than not having one.
pin_status |
When | What the proxy does |
|---|---|---|
first_seen |
newly pinned this session under tofu |
allowed, recorded |
pinned |
the served digest matches the approved one | allowed |
drift |
the served digest differs from the approved one | stripped from tools/list, calls refused |
quarantined |
awaiting approval (strict mode, or approval revoked) |
stripped, calls refused |
unknown |
reported to policy for a tool with no catalog entry | see the note below |
On drift the pin is not updated: drift_count increments, last_digest records what is being
served now, and approval stays with the definition that was approved. Silently re-pinning would mean
an attacker only has to absorb one blocked call. The approved canonical text is retained so the
console can diff approved-versus-served — the old definition cannot be re-fetched from a server that
has already replaced it.
pin_status and scan_severity report unknown, not none, for a tool the proxy has no catalog
entry for. none is what a definition that was scanned and came back clean carries, so reporting it
for a tool nobody looked at would spell “I never looked” exactly like “I looked and it was fine”.
unknown is outside the severity vocabulary, so it satisfies no allow list and no high/critical block.
Pin identity is the name. Leaving send_report untouched and adding send_report_v2 carrying the
payload is not drift — it is a new tool with a fresh first_seen pin.
Scanner severity and what it does
Section titled “Scanner severity and what it does”Gate A matches a fixed rule table against a confusable skeleton of each string (casefolded,
combining marks and zero-width characters stripped), so homoglyph and mark-stacking evasion collapses
before the rules run; invisible characters are checked separately on the raw text, because the
skeleton can never witness them. Findings are graded low through critical, and severity maps onto
one of three actions:
| Action | What the model receives | Callable? |
|---|---|---|
pass |
the definition unchanged | yes |
sanitize |
the tool, with description replaced by a stub and annotations dropped |
yes |
strip |
nothing — the entry is removed from tools/list |
no |
Thresholds ship at mcp_scan_strip_severity: high and mcp_scan_sanitize_severity: medium, so a
high or critical definition is withheld and a medium one is listed with its prose withheld.
Three cases outrank severity entirely: drift and quarantined are always strip, because the fact
of the change is the finding regardless of how innocent the new text scans; and a definition the
scanner’s character budget could not fully read is stripped rather than sanitised — a walk that
stopped early must not produce the same report as a walk that found nothing.
Sanitising leaves inputSchema in place, whose description and default values reach the model
exactly as prose does. A sanitised homoglyph twin is still listed and still selectable. That is why
the withholding threshold sits where it does.
Cross-catalog name shadowing — send_email and send_emaiI folding to the same skeleton — is
graded critical. It is only detectable with the whole catalog in hand. Note that server_id is
not folded: pоstgres-prod with a Cyrillic о is a distinct pin row rather than a detected
collision, resolved when an operator registers it, not at call time.
Approve, revoke, forget
Section titled “Approve, revoke, forget”# what changed, and against what?curl -s "$NRVQ_API_URL/api/v1/mcp/pins?namespace=chatbot-prod&server_id=slack&status=drift" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" \ | jq '.[] | {tool_name, status, approved_digest, last_digest, drift_count, scan_severity, findings: [.findings[].rule]}'# adopt the definition you actually reviewed (admin)curl -s -X POST "$NRVQ_API_URL/api/v1/mcp/pins/approve" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" -H "Content-Type: application/json" \ -d '{"namespace":"chatbot-prod","server_id":"slack","tool_name":"post_message", "digest":"<the served digest from last_digest>"}'Approve names the digest explicitly and returns 409 if it matches neither the approved nor the
currently-served definition — a server that changes again between you reading the diff and the
approval landing cannot get the new text blessed by a click meant for the old one.
POST /api/v1/mcp/pins/revoke (admin) withdraws approval; the tool is withheld until re-approved.
DELETE /api/v1/mcp/servers/{namespace}/{server_id} (admin) forgets every pin for a server, so
the next tools/list re-pins whatever it serves at that moment. That is a deliberate re-TOFU and is
destructive in the security-relevant direction — it is logged at NRVQ-MCP-5045. A blocked decision
deliberately survives forget; otherwise forgetting would be a way to launder a refusal into a clean
first sight. A registered decision is dropped, because re-registering after a decommission should be
a deliberate act.
All of this is on the MCP Servers page in the console (Security Operations → MCP Servers), which leads with the server inventory, then the per-tool definitions with scanner findings, an approved-versus-served diff, and the Register / Block / Approve / Revoke / Forget actions.
Where the approval lives
Section titled “Where the approval lives”The proxy reports the catalog it observed to POST /api/v1/mcp/pins/observe with its service
credential, and the server computes the durable verdict — the approved digest never leaves the
control plane, so a compromised proxy cannot mark its own drift as approved. The report is sent as a
background task after the tools/list response has already been forwarded, so it is durability and
visibility, never the decision itself. The namespace is bound to the caller’s credential rather than
taken from the body, exactly as /evaluate does.
Neither path adopts a changed digest: observe increments drift_count and leaves approved_digest
untouched, and adoption is only POST /mcp/pins/approve, which is admin-gated.
The verdict the proxy acts on is computed in-process against approved digests pulled from
GET /api/v1/mcp/pins at startup and re-pulled on the refresh timer (mcp_pin_refresh_s, default 30s;
0 disables). Without that refresh a running proxy would hold its startup copy for its whole lifetime,
so a revoke would update the console while the tool stayed listed and callable until the pod restarted.
Gate B: what reaches policy
Section titled “Gate B: what reaches policy”A tools/call passes five proxy-side checks before /evaluate is reached, in this order:
| # | Check | Refuses when | Turned off by |
|---|---|---|---|
| 1 | JSON-RPC batch | the message is an array (batching was removed from MCP in 2025-06-18) | nothing |
| 2 | Transport-header smuggling | any argument at any depth is named x-mcp-header |
mcp_allow_tool_headers=true |
| 3 | Malformed arguments |
params.arguments is present and is not an object |
nothing |
| 4 | Gate-A carry-over | pin status is drift or quarantined, or the discovery action was strip |
nothing — change the thresholds, or approve the pin |
| 5 | Schema conformance | a missing required argument, a wrong-typed value, or — when the server declared additionalProperties: false — an argument it never declared |
mcp_enforce_schema=false |
| 6 | Policy | the decision from /evaluate is not an allow |
this is where your rules live |
Check 5 runs before evaluation deliberately. An argument the tool never declared is one no policy
mentions either, so evaluating first would produce an allow meaning “no rule objected to a field
nobody knew about”. It is a deliberate subset of JSON Schema, not a validator, and it only runs when
the tool has a catalog entry that carries a published inputSchema.
Refusals at checks 2–5 happen before policy runs, but they are not invisible: each is reported to
the control plane as a block with its own rule_id, so it lands in the audit log, the attack graph and
compliance counts. Reports are rate-limited to one per (tool, rule) per 60 seconds, carrying the
suppressed count — a refusal is exactly when an attacker is active, so an unconditional report would
be an amplifier against the control plane.
A tools/call refusal is shaped as an MCP tool error (result.isError) rather than a JSON-RPC error,
because several hosts treat a server-originated protocol error on a tool call as a session fault and
tear the connection down — a targeted denial would become an outage. The block is absolute either way:
the upstream server never sees the call.
The facts input.mcp publishes
Section titled “The facts input.mcp publishes”For MCP callers the evaluator publishes input.mcp and lifts input.direction out of it. For every
other caller input.mcp is {} and input.direction is "call".
| Fact | Values | Meaning |
|---|---|---|
mcp.server |
the --server-id string |
which integration served this tool |
mcp.transport |
stdio | http |
which driver mediated it |
mcp.surface |
tools/call, resources/read, sampling/createMessage, answer |
which RPC produced this decision |
mcp.direction |
call | answer |
also lifted to input.direction |
mcp.pin_status |
pinned, first_seen, drift, quarantined, unknown |
approval state of the definition |
mcp.scan_severity |
none, low, medium, high, critical, unknown |
worst Gate-A finding on the definition |
mcp.definition_seen |
bool | whether this tool was in the catalog the proxy scanned |
mcp.catalog_stale |
bool | the server announced a change not yet re-read |
mcp.schema_enforced |
bool | a schema was declared and the checker could apply all of what it understands |
mcp.schema_closed |
bool | the server declared additionalProperties: false |
mcp.schema_notes |
list of strings | what conformance could not enforce, in plain words |
mcp.tool_digest |
16 hex chars | a 16-character (64-bit) prefix of the definition digest; absent when there is no catalog entry |
catalog_stale is false when there is no catalog entry at all — the signal is inverted for the most
suspicious case. Use definition_seen for “was this ever scanned”; use catalog_stale only for “the
entry I have may be out of date”.
What a policy can and cannot see on the call path
Section titled “What a policy can and cannot see on the call path”Because check 4 fires before check 6, several input.mcp values are not observable by a policy on
the ordinary tools/call path: pin_status of drift or quarantined, and scan_severity of
high or critical at the default strip threshold. Those calls are already refused by the proxy.
The MCP controls that key on them are therefore defence in depth for events that reach /evaluate by
another route — the answer plane, which is evaluated before the call gate, red-team traffic, and any
caller reaching /evaluate directly. The proxy is the mechanism that stops a drifted tool on the call
path.
To make a severity rule live on the call path, either lower what your policy blocks on to include
medium (reachable, because a medium definition is sanitised and stays callable), or raise
mcp_scan_strip_severity to critical so a high definition reaches policy instead of being
withheld. Pick one deliberately; doing both means nothing is withheld and nothing is blocked.
The MCP baseline controls
Section titled “The MCP baseline controls”Five MCP controls ship in the strict baseline preset. Four are guarded on input.mcp.server, which
is only set when a call arrived through the MCP proxy; the fifth keys on input.direction == "answer",
which nothing but the proxy’s answer plane produces. On an estate with no MCP they are all inert —
which is why they can ship enforcing without a measurable false-positive surface.
| Control | What it catches | default_effect |
What deny does |
|---|---|---|---|
mcp_definition_drift |
the rug pull: a server is serving a definition that is not the one an operator approved | deny |
escalate |
mcp_definition_never_scanned |
a call for a definition Gate A never read — a stateless client, or a proxy restarted since discovery | deny |
escalate |
mcp_tool_not_approved |
a quarantined definition, under strict pin mode |
deny |
block |
mcp_definition_flagged |
the definition itself carries instruction-injection shaped text — tool poisoning, where the description is the payload | deny |
block |
mcp_answer_carries_secret |
a credential being sent back as the answer to a server-composed input_required question |
deny |
block |
The two that escalate do so deliberately. Adopting a changed definition is a legitimate operator action
— a server that ships a bug fix changes its description — and a cold start is ordinary. The safe
default there is a human looking at the diff, not a silently broken agent. GET /baseline/controls
reports this per control as enforced_as, so the console does not advertise a hard denial for a
control that holds a call for review.
Two more MCP controls live outside the preset, compiled into the reserved __mcp__ scope from the
server registry you maintain:
| Control | Fires on | Default decision |
|---|---|---|
mcp_unregistered_server |
an MCP call naming a server that is not registered in this namespace |
audit |
mcp_unapproved_write_server |
a non-read verb through a registered server not marked writable |
block |
They default differently on purpose. Registration is housekeeping that lags reality, and blocking on it would break an estate every time somebody stands up an integration before telling the console. An unapproved write is different: you already said which servers may be written through, so a write anywhere else is a statement about an integration you considered and declined.
Both are inert on an empty registry — not “everything is unregistered”. A fresh install would
otherwise flag every MCP call and the first thing an operator would do is switch the control off.
mcp_unapproved_write_server also excludes the verb unknown: the classifier saying “I cannot tell”
is not evidence of a write.
Posture: what a stock install actually does
Section titled “Posture: what a stock install actually does”The chart renders its baseline in baselineClusterPolicy.enforcementMode: audit. On a stock install
these controls evaluate and record, and the call proceeds. Use the compliance
view to see what they would have blocked before you promote anything.
Saving control effects writes a separate policy on the reserved __controls__ scope at priority 2,
above the chart’s baseline at priority 1, and that policy always runs in block mode — the compiled
module carries each control’s own effect. So a control you set to deny blocks (or escalates) even
though the chart’s baseline ships observing:
# promote the two MCP controls that matter most in this namespace (admin)curl -s -X PUT "$NRVQ_API_URL/api/v1/baseline/controls" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" -H "Content-Type: application/json" \ -d '{"namespace":"chatbot-prod","preset":"strict", "effects":{"mcp_definition_flagged":"deny","mcp_definition_drift":"deny"}}'Only deviations from a control’s own shipped default are stored, so a namespace running entirely at
the defaults keeps zero rows and a future release that changes a default reaches it. The controls API
is strict-preset only in practice — moderate and permissive carry no controls region.
norviq redteam run --agent <class> --namespace <ns> exercises five MCP attacks, MCP-01 through
MCP-05. MCP-01 is the one mcp_unapproved_write_server blocks; MCP-02 and MCP-03 forge
input.mcp fields and exist to measure the PEP-reported residual described above — they are not
expected to be closed by a rule. MCP-04 and MCP-05 carry no MCP context at all: they check that an
agent class’s own allowlist policy did not strip a baseline floor.
Configuration
Section titled “Configuration”Every setting below is read from the proxy process’s own environment, not from the control plane.
PUT /api/v1/settings does not reach them and the console cannot display them. All are inert unless
the proxy is running.
| Variable | Default | Effect |
|---|---|---|
NRVQ_MCP_PIN_MODE |
tofu |
tofu pins on first sight and enforces change; strict quarantines until approved. An unrecognised value is coerced to strict, so a typo cannot silently disable the gate |
NRVQ_MCP_PIN_STORE |
memory |
memory | file | control-plane. The chart sets control-plane |
NRVQ_MCP_PIN_PATH |
"" |
file location when the store is file |
NRVQ_MCP_PIN_REFRESH_S |
30 |
how often a control-plane-backed proxy re-reads pins and server decisions; 0 disables |
NRVQ_MCP_SCAN_STRIP_SEVERITY |
high |
at or above this, a definition is removed from tools/list |
NRVQ_MCP_SCAN_SANITIZE_SEVERITY |
medium |
at or above this, the description is replaced by a stub |
NRVQ_MCP_SCAN_RESPONSES |
true |
scan server-returned content and the non-tools/list discovery surfaces |
NRVQ_MCP_ENFORCE_SCHEMA |
true |
refuse a call that contradicts the tool’s own inputSchema |
NRVQ_MCP_OUTPUT_DLP_ENABLED |
true |
mask PAN/SSN in tool results and structuredContent |
NRVQ_MCP_GOVERN_RESOURCES |
true |
evaluate resources/read against policy |
NRVQ_MCP_GOVERN_SAMPLING |
true |
evaluate server-initiated sampling/createMessage |
NRVQ_MCP_ALLOW_TOOL_HEADERS |
false |
permit tool parameters to set outbound HTTP headers (x-mcp-header) |
NRVQ_MCP_MAX_PENDING_REQUESTS |
4096 |
per-direction cap on in-flight request-id bookkeeping |
Output DLP defaults on here while the SDK’s equivalent defaults off. The SDK returns a tool result to application code, which may need the raw value; an MCP tool result is pasted straight into the model’s context, from where it reaches the transcript, the provider, and any downstream tool the model then calls.
Chart keys that set these on injected proxies:
| Helm key | Default | Note |
|---|---|---|
webhook.injection.mcp.enabled |
false |
|
webhook.injection.mcp.proxyImage |
"" |
required when enabled, no fallback |
webhook.injection.mcp.proxySourcePath |
/opt/norviq/mcp-proxy |
where the payload lives in proxyImage |
webhook.injection.mcp.pinStore |
control-plane |
memory/file exist for air-gapped single-process use |
webhook.injection.mcp.pinMode |
tofu |
strict is safer and needs an approval workflow to be practical |
Pin durability, stated plainly:
| Store | Survives restart | Shared across replicas |
|---|---|---|
memory |
no — a restart is a free re-TOFU | no |
file |
yes | only if the path is shared storage; a corrupt file raises at construction rather than degrading to “no pins” |
control-plane |
yes | yes — pins live with policy: tenant-scoped, RBAC’d, audited, console-visible |
memory is refused on the HTTP transport and upgraded to control-plane with NRVQ-MCP-5065: under a
stateless protocol any request may land on any instance, so a per-process store means replica A
approves what replica B has never seen. file with no NRVQ_MCP_PIN_PATH degrades to in-process and
says so at NRVQ-MCP-5067. An unrecognised store kind raises at startup rather than defaulting —
silently running an in-process store leaves drift detection off while every surface still reports tools
as pinned.
If the control plane is unreachable at startup, the pin store degrades to per-process TOFU: pins load
empty, every tool reads first_seen, cross-pod drift detection is unavailable, and it is logged at
NRVQ-MCP-5046. Gate B is unaffected.
What this does not do
Section titled “What this does not do”Gate A is a heuristic and evadable by construction; Gate B is the deterministic backstop. Individually:
- The scanner catches shapes, not meanings. A paraphrased instruction that avoids every pattern in
the rule table scans
noneand is pinned. Schema conformance is the deterministic half of the answer: a paraphrased instruction still has to produce a call carrying an argument the developer never declared. - Fencing is a request, not a control. Flagged server-returned content is wrapped in
<untrusted-content>with an instruction to treat it as data, and delivered. That is right for data the agent asked for — silently returning nothing is indistinguishable from a broken tool — but a model that ignores the fence has defeated the ingest path. Content past the 1 MiB per-response guard budget is fenced but neither scanned nor masked, and the fence text says so. - Output DLP masks PAN and SSN only. An API key or a private key in a tool result is not redacted by this setting.
- The upstream stdio server inherits the proxy’s environment. The child process is spawned without
a scrubbed
env, so a wrapped MCP server seesNRVQ_API_TOKENand, under internal TLS, the client key material. Treat the MCP server image as inside the trust boundary of the pod’s Norviq credential, and prefer servers you build or vendor deliberately. - The HTTP driver relays request headers upstream.
authorizationis not a hop header, so a bearer token the agent holds reaches whatever--upstreamnames. It leaves before any JSON-RPC decision exists, so no gate applies to it. Norviq brokers no OAuth on the agent-to-server leg. resources/readandsampling/createMessageare evaluated, but nothing shipped rules on them. The mechanism is real and a block is honoured; no bundled policy or baseline control matches either. That is a policy you write, not a control that is missing.server_idis operator-chosen, not attested. It is set by the pod annotation or--server-id, so anyone who can create a pod in the namespace can assert one. It is a statement about your own deployment.
Where this fits
Section titled “Where this fits”- Writing policies — the Rego contract every
input.mcprule is written against - Compliance — what the MCP controls would have blocked, before you promote them
- Asset & attack graphs — MCP servers appear as
mcp_servernodes withservesedges - Sidecar injection — the same admission webhook, and the labels that route a pod to it