Skip to content

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

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:

Terminal window
python -m norviq.mcp --server-id filesystem -- npx -y @modelcontextprotocol/server-filesystem /work

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.

Terminal window
python -m norviq.mcp --http --listen 127.0.0.1:9000 --upstream https://mcp.example.com/mcp

There 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.

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:

Terminal window
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-servers that matches no container — a typo that “worked” is the failure mode;
  • a named container with no explicit command — its real argv is the image ENTRYPOINT, 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.

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.

Terminal window
# 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):

Terminal window
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 discovered and nothing is withheld. A proxy that starts while the API is unreachable does not enforce a blocked decision until it can read one. Logged at NRVQ-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.

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.

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 shadowingsend_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.

Terminal window
# 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]}'
Terminal window
# 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.

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.

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.

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.

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:

Terminal window
# 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.

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.

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 none and 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 sees NRVQ_API_TOKEN and, 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. authorization is not a hop header, so a bearer token the agent holds reaches whatever --upstream names. 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/read and sampling/createMessage are 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_id is 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.
  • Writing policies — the Rego contract every input.mcp rule is written against
  • Compliance — what the MCP controls would have blocked, before you promote them
  • Asset & attack graphs — MCP servers appear as mcp_server nodes with serves edges
  • Sidecar injection — the same admission webhook, and the labels that route a pod to it