Tool registry
Before you can narrow a tool you have to know what it takes. The tool registry answers one question per tool — how well does Norviq know this name? — because the answer decides what security you can express about it. A tool with a published definition can have its arguments constrained. A tool Norviq has only ever seen in traffic can be allowed or denied by name and nothing else.
The registry is a read-only projection served by GET /api/v1/tools and rendered on the Tools
console page. It owns no table and writes nothing: every row comes from a store that already exists,
and the endpoint’s whole job is to say where each name came from
(norviq/api/routers/tools.py).
Two tiers, never merged
Section titled “Two tiers, never merged”flowchart LR
subgraph declared["Declared — strong tier"]
P["MCP proxy sees<br/>tools/list"] --> O["POST /mcp/pins/observe"]
O --> DB[("mcp_tool_pins<br/>approved_canonical")]
end
subgraph observed["Observed — weak tier"]
E["/evaluate decisions"] --> A[("audit_log<br/>tool_name")]
end
DB --> R["GET /api/v1/tools"]
A --> R
R --> UI["Tools page ·<br/>policy builder autocomplete"]
| Tier | source |
What it proves | What it buys you |
|---|---|---|---|
| Declared | mcp_declared |
An MCP server published a definition through the proxy, and the control plane stored it. May carry a JSON Schema. | Per-argument scoping, where a schema survived. Plus the pin, scan and drift signals below. |
| Observed | observed |
The name appeared in real traffic inside the selected window. | Nothing about the tool’s shape. Name matching and whole-call facts only. |
The two tiers are returned side by side and are never unioned. A caller that flattens them back
into one set has recreated the defect the endpoint was built to retire: the old policy builder
inferred a “known tools” set by unioning observed names with capability substrings — post,
http, delete — and then treated the union as an existence oracle, so it offered names that could
not exist and suppressed its own unknown-tool warning for exactly those names.
Declaration is not approval. /tools emits a mcp_declared row for every row in mcp_tool_pins,
and in strict pin mode a definition written on first sight has approved = false. Read pin_status
per row; do not read the tier as a human sign-off.
Reading the registry
Section titled “Reading the registry”curl -s "$NRVQ_API_URL/api/v1/tools?namespace=payments&range=30d" \ -H "Authorization: Bearer $NRVQ_API_TOKEN" | jq .[ { "name": "send_email", "name_skeleton": "send_email", "source": "mcp_declared", "namespace": "payments", "server_id": "smtp", "pin_status": "pinned", "scan_severity": "none", "description": "Send a message to one recipient.", "description_withheld": false, "input_schema": { "type": "object", "properties": { "to": { "type": "string" }, "count": { "type": "integer" } } }, "schema_available": true, "last_seen_at": "2026-08-18T09:14:22+00:00" }, { "name": "run_query", "name_skeleton": "run_query", "source": "observed", "namespace": "payments", "server_id": null, "pin_status": null, "scan_severity": null, "description": null, "description_withheld": false, "input_schema": null, "schema_available": false, "last_seen_at": null }]Query parameters:
| Parameter | Values | Default | Effect |
|---|---|---|---|
namespace |
a namespace name, or omitted | omitted | Omitted (or all from the console) means “every namespace this principal may read”. A namespace-scoped principal is pinned to its own namespace regardless. |
range |
24h, 7d, 30d, 90d |
30d |
Observed tier only. Any other value is rejected by FastAPI validation. |
Any authenticated principal may call it — viewer included. There is no admin gate, because reading
what exists is not a privileged act; tenant scoping does the containment.
Row fields
Section titled “Row fields”| Field | Meaning |
|---|---|
name |
The tool name as it was published or called, verbatim. |
name_skeleton |
The Unicode confusable skeleton of name, computed server-side (norviq/engine/confusables.py). This is the same normalisation the evaluator exposes as input.tool_name_normalized. Never recompute it in a browser — a locally derived value will disagree. |
source |
mcp_declared or observed. |
namespace |
The namespace this fact belongs to. Part of the row’s identity — see below. |
server_id |
The MCP server that published it; null on observed rows. |
pin_status |
pinned, drift, or quarantined; null on observed rows. |
scan_severity |
The definition scanner’s grade — none, low, medium, high, critical; null on observed rows. |
description |
The approved definition’s description, or null when withheld or absent. |
description_withheld |
True when the scanner condemned the description (see below). |
input_schema |
The inputSchema from the approved definition, or null. |
schema_available |
Whether input_schema is present and usable. |
last_seen_at |
When the pin was last refreshed by a tools/list. null on observed rows. |
The registry deliberately reads approved_canonical, never last_canonical. last_canonical is
whatever the server is serving right now, which on a drifted or hostile server is attacker-controlled
and unreviewed; seeding a policy-authoring picker from it would let a server that changed its own
definition steer which arguments an operator believes exist.
How a tool becomes declared
Section titled “How a tool becomes declared”A declared row exists because the MCP action firewall saw a tools/list and reported it. That
requires the whole path to be in place:
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-proxy:TAG \ --set webhook.injection.mcp.pinStore=control-planewebhook.injection.mcp.enabledshipsfalse(helm/norviq/values.yaml), so on a stock install the declared tier is empty by construction.webhook.injection.mcp.proxyImageis required whenmcp.enabledis true and has no fallback. It must carry the relocatable proxy payload atwebhook.injection.mcp.proxySourcePath(default/opt/norviq/mcp-proxy); build it fromscripts/mcp-proxy-payload.Dockerfilein the repository and confirm it runs in the images your MCP servers actually use withscripts/mcp-proxy-payload-verify.sh. Do not point it at the engine image — that carries thenorviqpackage, not the frozen payload, and the init container’scpfinds nothing.webhook.injection.mcp.pinStoredefaults tocontrol-plane, and only that store reports. Withmemoryorfile, Gate A still enforces locally but nothing reaches the control plane — the registry, the MCP Servers page and cross-pod drift detection all stay empty.- The pod must name its MCP server containers:
norviq.io/mcp-servers: "filesystem,github", with an optional stable pin id per container vianorviq.io/mcp-server-id.<container>. - The server must actually serve a
tools/list.
Both transports report: stdio and streamable-HTTP each call the shared report gate after mediating a
server message. The report is fingerprinted on (tool_name, digest) pairs, so the control plane hears
about first sight and every change and not about a server repeating itself — a persistently
poisoned server is rewritten on every list, and a rewrite-triggered report would hand the report rate
to the server.
A failed report is a visibility loss, not an enforcement loss: the local Gate A decision was already made from state loaded at startup, and the call path is unaffected.
How a tool becomes observed
Section titled “How a tool becomes observed”The observed tier is SELECT DISTINCT tool_name, namespace FROM audit_log over the selected window —
so it covers every enforcement path that produces a decision, whether the call came through the SDK,
the injected sidecar or the MCP proxy, and whether the decision was allow or block.
Synthetic traffic is excluded. Rows whose framework is redteam, and rows from the seeded
probe/test/eval agent classes (e2e-, probe-, policy-tester, smoke-, canary-, wave<N>e2e…,
and the rest of norviq/api/synthetic.py), never register as evidence that a tool is real. That is
the same predicate the Asset Graph, the Attack Graph and Compliance reconcile against — a red-team run
does not populate your inventory.
A name that is declared in a namespace and has been called there stays in the strong tier only; it is not emitted twice.
Namespaces are part of a tool’s identity
Section titled “Namespaces are part of a tool’s identity”Suppression of the observed row is keyed on (namespace, name), never on the bare name. On the
console’s default “All namespaces” scope an admin’s result spans every tenant, and a name-only key
would suppress the observed row for payments/run_query because some other namespace happened to
pin a run_query — presenting an unpinned, unscanned tool of unknown shape as declared-and-approved.
Declared in one namespace and merely observed in another are two facts, and they keep their two rows. The console adds a Namespace column only when the view actually spans more than one.
Schemas, and what a policy can actually address
Section titled “Schemas, and what a policy can actually address”schema_available: true means the approved definition parsed and carried an inputSchema. From there
you can write input.derived.param_paths conditions against its arguments. What is addressable is
narrower than what the schema declares, because the evaluator walks the payload, not the schema,
and emits a param_paths key only for string leaves:
| Declared type | Addressable? | Why |
|---|---|---|
string |
Yes | The one type that produces a param_paths key. Any enum is offered as value suggestions. |
| no declared type | Yes, with a caveat | Matches only if the value arrives as text. |
integer, number, boolean |
No | Never appear in param_paths. A condition on one compares against "" — inside an allowlist grant that is a permanent block; in rules mode it is a rule that never fires. |
array |
No | Indexed concretely at runtime (ids[0], ids[1]). A schema says the argument is a list, never how long. Scope by a path you saw in a dry-run, or use a whole-call fact. |
object |
Not itself | Its string leaves are. An object that names properties and accepts unnamed ones (additionalProperties) is flagged: the unnamed keys appear at runtime and scoping the listed ones leaves them unconstrained. |
$ref, oneOf, anyOf, allOf |
No | Picking one arm of a union would name an argument the tool may not take. |
| name with characters a policy field cannot contain | No | The condition could not be saved by either compiler. |
The walk is bounded to 12 levels and 256 paths, mirroring the evaluator’s own _MAX_PATH_DEPTH and
_MAX_PATHS — a path deeper or later than the evaluator will walk cannot appear at runtime whatever
the schema says. The console shows unusable paths rather than hiding them, and prints the count as
<addressable> of <total>.
Gate A’s own runtime schema conformance check (NRVQ_MCP_ENFORCE_SCHEMA, default on) is a separate
mechanism: it refuses a tools/call whose arguments contradict the server’s currently served
schema. It does not read this registry, and tools with no published schema are unaffected either way.
Signals that need a human
Section titled “Signals that need a human”description_withheld. The definition scanner grades every published definition. When the grade
meets the sanitize threshold (NRVQ_MCP_SCAN_SANITIZE_SEVERITY, default medium) or the strip
threshold (NRVQ_MCP_SCAN_STRIP_SEVERITY, default high), the firewall replaces or removes the
description before the model ever sees it — and /tools withholds it too. This matters because
approved_canonical keeps the pre-sanitize text: echoing it would render, in the operator’s
console, the exact injection payload the firewall withheld from the model. The flag is re-derived
server-side from the API pod’s own configured thresholds, because the firewall’s action is not
persisted.
description_withheld is a fact about the description text only. At the shipped defaults it is
true from medium up, while the tool itself is only stripped from high up. Argument descriptions
inside input_schema have the same provenance and the same risk — the console suppresses those too
when the flag is set.
pin_status. drift means the server is serving a definition that differs from the approved one;
the proxy strips the tool from every tools/list and refuses calls to it. quarantined means no
operator has approved the definition. In either state the tool is withheld from the model, and on a
drifted pin the schema you are reading is the pinned baseline — not what the server is serving now.
Resolve both on the MCP Servers page.
scan_severity at high or critical. At the default strip threshold a grade this high strips
the tool whatever the pin says.
name_skeleton differing from name. A homoglyph. send_еmail with a Cyrillic е reads as
send_email to a human and is a different string to a matcher. The registry ships the skeleton so you
can see the two disagree.
One name, several servers. The declared tier returns one row per pin — identity is
(namespace, server_id, tool_name) — so a name published by two distinct servers appears twice. The
engine only ever sees the bare name, so a policy naming that tool governs every server publishing
it. The console counts distinct server_id values and raises the collision explicitly.
The Tools page
Section titled “The Tools page”Console → Security Operations → Tools. Five tiles across the top — Tools known, Declared, Scopeable, Observed only, Flagged — then two panels that are deliberately two panels and not one sorted table with a Source column:
- Declared — tool, scopeability, server, pin, scan grade, addressable-argument count, last seen.
The count pill reads
N · <n> schema-backed, computed off the sameschema_availablepredicate as the Scopeable tile. - Observed only — tool, scopeability, and what this costs you: a plain statement of the control you do not have, e.g. “Destination hosts cannot be restricted” for an HTTP-shaped name, “Cannot restrict recipients” for a mail-shaped one.
The window selector offers exactly the four ranges the endpoint accepts. Clicking a row opens a detail dialog with the argument tree and a Scope this tool in a policy hand-off, which lands you in the visual builder in allowlist mode with the tool already listed and its scope row reading “Any arguments · unrestricted”. The agent class is left empty on purpose — a tool is not owned by a class, and inventing one would produce a policy targeting an agent that may not exist.
That hand-off is not suppressed for a withheld tool; it carries a caveat instead. Gating it would make the registry restrict rather than inform.
The registry also backs the policy builder’s tool-name autocomplete and its unknown-tool warning.
There the warning is suppressed whenever the registry is null or empty — an empty registry means
“we know nothing”, not “nothing exists”, and warning on every name an operator types in that state is
how a warning gets trained out of existence before the one time it matters.
Limits
Section titled “Limits”- Read-only. There is no write, delete or bulk-import path. To remove declared rows, act on the
pins behind them:
POST /api/v1/mcp/pins/revokefor one tool, orDELETE /api/v1/mcp/servers/{namespace}/{server_id}to forget every pin for a server. Both are admin-only, and forgetting a server is destructive in the security-relevant direction — the nexttools/listre-pins whatever that server is serving at that moment. - No CLI command.
norviqhas notoolsgroup; use the API or the console. - Presence is not enforcement. A tool appearing here means Norviq knows the name. Whether a call to it is allowed is decided by policy on the call path, independently of anything in this registry.
- Absence is never an all-clear. An empty declared tier is the default posture of a stock install, and a failed read renders as an error in the console precisely so it cannot be mistaken for “there are none”.