Skip to content

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

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 substringspost, 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.

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

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.

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:

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-proxy:TAG \
--set webhook.injection.mcp.pinStore=control-plane
  • webhook.injection.mcp.enabled ships false (helm/norviq/values.yaml), so on a stock install the declared tier is empty by construction.
  • webhook.injection.mcp.proxyImage is required when mcp.enabled is true and has no fallback. It must carry the relocatable proxy payload at webhook.injection.mcp.proxySourcePath (default /opt/norviq/mcp-proxy); build it from scripts/mcp-proxy-payload.Dockerfile in the repository and confirm it runs in the images your MCP servers actually use with scripts/mcp-proxy-payload-verify.sh. Do not point it at the engine image — that carries the norviq package, not the frozen payload, and the init container’s cp finds nothing.
  • webhook.injection.mcp.pinStore defaults to control-plane, and only that store reports. With memory or file, 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 via norviq.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.

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.

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.

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 same schema_available predicate 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.

  • 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/revoke for one tool, or DELETE /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 next tools/list re-pins whatever that server is serving at that moment.
  • No CLI command. norviq has no tools group; 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”.