How Navi connects to external MCP servers, how their tools are exposed, and how per-user credentials (BYOK) work.
Each server lives in its own file mcp_servers.d/<name>.json:
{
"transport": "streamable_http",
"url": "https://creds.example.com/mcp",
"headers": { "Authorization": "Bearer ${NAVI_MCP_GNEXUS_CREDS_TOKEN}" },
"groups": {
"creds": ["search_secrets", "get_secret", "reveal_secret"]
},
"instructions": "MANDATORY: ...",
"user_key": { "header": "Authorization", "prefix": "Bearer " }
}
| Field | Applies to | Meaning | ||
|---|---|---|---|---|
transport |
all | stdio \ |
sse \ |
streamable_http |
command, args, env, cwd |
stdio |
Subprocess launch | ||
url, headers |
sse, streamable_http |
Endpoint and HTTP headers | ||
groups |
all | Named tool groups profiles reference instead of listing tools | ||
instructions |
all | Overlay injected next to the server's own initialize instructions | ||
user_key |
all | Declares the per-user credential slot (BYOK); omit for key-less servers |
The legacy monolithic mcp_servers.json is auto-migrated to mcp_servers.d/ on first load. Edits take effect on server restart or a reload_tools-triggered McpManager.reload_all().
Heads-up for admin config edits: PUT /admin/agents/mcp-servers round-trips the whole model — write back the config you read, or a user_key slot added by hand is silently erased.
mcp_servers.d/*.json is tracked in git, so a credential written into one is in history for good — and GET /admin/mcp/config hands the same value to the admin client. A config therefore never carries a live credential: it names the variable that holds it.
"headers": { "Authorization": "Bearer ${NAVI_MCP_GNTODO_TOKEN}" }
headers and env values are scanned, and only the braced ${NAME} form — a bare $NAME is left alone, so header values that contain $ survive..env (chmod 600, untracked; environment variable beats the file, as with Settings). A blank NAME= in .env counts as missing. .env is re-read on every (re)connect, so a rotated token is picked up without a restart — the canonical store for the value itself is gnexus-creds (see the platform's secrets rules; deploy/env.template lists the variable names).ValueError naming the server and the variable, rather than dropping the header — a server may answer an unauthenticated request as an anonymous user instead of returning 401, so silently dropping would fail open. McpManager.load_all isolates a failing server, so this shows up as one broken entry in /mcp/status.${NAME:-fallback} (fallback may be empty). It is for a setting that tunes a server — a SearXNG URL — where being unset costs one backend instead of every tool the server provides. Writing the fallback down is the point: it is a decision in the config, not an accident of a variable nobody set. A credential never takes this form.navi/mcp/client.py), next to the project-relative path resolution. This is what keeps every stored and serialised config placeholder-only: save_mcp_servers — reached by create_mcp_server and by PUT /admin/mcp/config — cannot write a secret back into a tracked file, and a GET→PUT round-trip is lossless.A user key (user_key) is injected first and overwrites the header, so a per-user credential simply replaces the placeholder — no substitution happens.
navi-web settingsThe search/browse server has no user key, but it still has runtime settings of its own, and they reach it through the same placeholder mechanism. A stdio server does not inherit the Navi process's environment: the SDK passes a safe allowlist (HOME, PATH, USER, …) plus the config's env block, so a variable that is only in .env is invisible to it unless the config maps it.
Config env key |
.env variable |
Meaning |
|---|---|---|
NAVI_WEB_MCP_TRANSPORT |
— | stdio, declared inline |
NAVI_WEB_SEARXNG_URL |
SEARXNG_URL (via ${SEARXNG_URL:-}) |
Self-hosted SearXNG base URL — the primary backend |
NAVI_WEB_BRAVE_API_KEY |
BRAVE_SEARCH_API_KEY |
Optional third backend. Add the placeholder only once the key exists: an unset placeholder without a fallback fails the connection (see above) |
NAVI_WEB_DDG_REGION |
— | DuckDuckGo region, default ua-uk |
Without NAVI_WEB_SEARXNG_URL the server starts straight from DuckDuckGo HTML scraping — roughly an order of magnitude slower, and the first backend to be rate-limited. The ${SEARXNG_URL:-} fallback is what keeps a host with no SearXNG working: an unset value costs the backend, not the server's three tools. navi.config.Settings has no search fields: SEARXNG_URL is read here, not by Navi itself.
web_search takes one query or up to three, web_view one URL or up to three; several run concurrently, so the call costs the slowest item instead of the sum — three pages measured 17.3 s one by one and 1.9 s together, three searches 3.5 s and 0.6 s. The caps live in the server: three items, deduplicated, extras dropped with a note in the output.
The model is pointed at the feature twice — by the tool descriptions, which travel in every request, and by summary in mcp_servers.d/navi-web.json, which is the line that reaches the system prompt (instructions carries the long form for tool_manual). The wording is a trigger, not a speed plea: batch when the lookups are already known to be independent, never a follow-up whose query depends on the previous answer.
A per-item deadline is deliberate. One hung backend or page is named in the output rather than erasing what the others already returned, and the tool raises — isError in the journal — only when nothing at all came back.
This is not Navi's parallel_tool_calls: that flag runs a whole batch of independent tool calls concurrently for every tool of a profile, terminal included. The server-side fan-out is scoped to these two tools, and its caps and budgets are enforced where the work happens.
web_view hands backThe page is rendered in Chromium, then trafilatura picks the main content out by text density and the server renders that fragment as markdown: # headings, fenced code with its own indentation, table rows joined by |. This replaced innerText after a tag kill-list, which could not tell navigation from prose — 462 of the asyncio page's 1018 lines were shorter than 40 characters — collapsed tables into one line per thousand characters (news.ycombinator.com came back as four lines), marked no headings and dropped every link.
Links become a Links on this page appendix rather than [label](url) inline: at most 40, absolute, deduplicated, with self-anchors and hide/vote/login chrome filtered out. Inline links cost 3.5k characters on the docs page alone, and trafilatura drops the # from any heading that carries an anchor when they are on. Text past 20k characters is cut at a paragraph boundary, and the note names the sections that did not fit.
A page whose content is its thread — a forum, a Q&A, a code review — comes back as a bare header when comments are excluded, so when the extractor keeps less than a quarter of the page's visible text it runs again with comments. If it still finds nothing worth sending, the tool falls back to the visible-text read; metadata.extractor says which path answered (trafilatura or innerText), and metadata.links counts the appendix.
navi-web runs from mcp-servers/navi-web/.venv, which is separate from Navi's: ./mcp-servers/navi-web/.venv/bin/python -m app.mcp_server with its working directory there. deploy/install.sh rebuilds only Navi's venv, so a dependency added to mcp-servers/navi-web/pyproject.toml — trafilatura today — needs its own install on the host:
VIRTUAL_ENV=mcp-servers/navi-web/.venv uv pip install -e mcp-servers/navi-web sudo systemctl restart navi
Without it the module does not import and the server fails to start, which /mcp/status shows as a disconnected server rather than as degraded output.
McpManager (navi/mcp/manager.py) holds one McpClient per server, reconnects dead ones in a 30 s health-check loop, and is the single call_tool gateway. Connection headers/env are fixed at transport open (navi/mcp/client.py) — a shared client cannot switch credentials per call.mcp__<server>__<tool>; profiles select servers via tools.agent.mcp / tools.subagent.mcp group maps. Registered MCP tools are process-wide, regardless of which profile uses them.McpTool.execute forwards ctx.user_id (falling back to the current_user_id ContextVar) into manager.call_tool(..., user_id=...).A config with a user_key slot lets each user bring their own credential:
"user_key": { "header": "Authorization", "prefix": "Bearer " }
or, for stdio servers:
"user_key": { "env": "API_KEY" }
Exactly one destination (validator navi/mcp/config.py::McpUserKey); a header is only valid on HTTP transports, an env only on stdio.
Resolution flow (McpManager._client_for), given the caller's user_id and role:
user_key slot → the default shared client. The resolver and the database are never touched.McpClient clone built from McpServerConfig.with_user_key(key) (a deep copy with the key injected into the header/env destination). Clones are cached per key snapshot: a changed key transparently rebuilds the client.role == "admin" → the default shared client with the credential from the config file. An admin's calls behave byte-for-byte as before BYOK existed — the shared credential is theirs.RuntimeError naming the server and pointing at Settings → MCP keys. There is no quiet fallback to the owner's credential: that fallback is what made six of the owner's servers reachable by every account.KeyResolver.resolve reads the user's key (30 s in-memory TTL; every failure — DB error, missing NAVI_AUTH_ENCRYPTION_KEY — logs a warning and resolves to None, i.e. "no key", which for a non-admin is a refusal rather than a fallback).
A server the manager would refuse must not appear in the tool list either, or the model sees a tool it can never call, retries it, and records a broken tool instead of a boundary. build_tool_list is synchronous while the key store is not, so the allowed set is computed once per run and only read afterwards:
Agent._apply_mcp_scope (called at the top of run_stream) asks McpManager.visible_servers(role, user_id) and puts the answer in the current_allowed_mcp_servers ContextVar (navi/tools/_internal/base.py). It returns two tokens and both are reset on the stop and max-iterations exit paths; the normal-completion path returns without resetting, which is harmless because every run sets the variables again before anything reads them.build_tool_list (navi/core/tool_utils.py) skips any server outside that set.role == "admin", or no MCP layer at all → the ContextVar is None, meaning "no restriction", and nothing changes for the owner.visible_servers raises, the set is empty rather than unrestricted.Why a server is absent is part of the prompt. Absence alone left the model knowing the server existed, holding none of its tools, and having nothing to tell the user. _apply_mcp_scope therefore also derives the withheld servers — byok_servers() − allowed, the complement of what it kept, not a second read of the key store that could disagree with the first — and puts them in current_keyless_mcp_servers. ContextBuilder._mcp_context_msg names each of them in the [MCP servers] system message, followed by the reason and a pointer at Settings → MCP keys, so the assistant can explain the missing key itself instead of silently lacking the tools. The variable defaults to empty, so a run that never set it — a peer run out of a fresh API task — claims nothing.
Saving a key in the panel takes effect on the next turn — the resolver's on_change callback clears the manager's visibility cache along with the affected clients.
Cache bounds: hard LRU cap of 32 per-user clients; per-user clients idle longer than 30 minutes are reaped by the health-check tick. Per-user clients are outside the health-check's reconnect logic — they reconnect lazily with backoff when next called. disconnect_all (reload/shutdown) tears user clients down as well.
Key changes (PUT/DELETE) fire the resolver's on_change callback, which drops affected per-user clients and the visibility cache immediately.
mcp_user_keys table (postgres, navi/mcp/_ddl.py), Fernet-encrypted via NAVI_AUTH_ENCRYPTION_KEY (navi/mcp/keystore.py::McpKeyStore). One row per (user_id, server_name); deleting a user cascades. Keys never leave the backend through logs or REST responses.
Anon mode (NAVI_AUTH_ENABLED=false) is one shared local user — its saved key effectively becomes a system-level override, and requires NAVI_AUTH_ENCRYPTION_KEY to be set (the Fernet encryptor refuses an empty key).
Settings UI surface — see api.md: GET /mcp-keys (every profile-referenced server, keyed ones flagged, no key values), PUT /mcp-keys/{server} (save/invalidate), DELETE /mcp-keys/{server} (reset to default). The webclient lists all of them: rows for servers without a user_key slot are read-only context, and only slotted servers get a key input. Servers that declare an Authorization-style header carry a shared default in the config — as a ${...} placeholder, not a literal (see Secrets) — which an admin keeps using when they have no personal key, and which an ordinary user cannot reach at all.