Newer
Older
navi-1 / docs / mcp.md

MCP (Model Context Protocol)

How Navi connects to external MCP servers, how their tools are exposed, and how per-user credentials (BYOK) work.

Configs

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.

Secrets

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}" }
  • Only headers and env values are scanned, and only the braced ${NAME} form — a bare $NAME is left alone, so header values that contain $ survive.
  • The value comes from the process environment over the project's .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).
  • A missing variable fails the connection with 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.
  • The explicit escape hatch is ${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.
  • Resolution happens at transport open (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 settings

The 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 NAVI_MCP_NAVI_WEB_BRAVE_KEY (via ${NAVI_MCP_NAVI_WEB_BRAVE_KEY:-}) Optional third backend. Empty means off; the key is optional, so the placeholder carries a fallback (see above)
NAVI_WEB_DDG_REGION — DuckDuckGo region, default ua-uk

Without NAVI_WEB_SEARXNG_URL the server starts straight at the ddgs merge — the engines pinned in search._DDG_BACKENDS (DuckDuckGo, Brave, Mojeek, Yahoo, Wikipedia, Grokipedia; Yandex is excluded on purpose). It is slower and the first thing 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.

Two details in the SearXNG request are deliberate. The engines list is pinned (google,duckduckgo) because the instance's own defaults are mostly suspended, and bing is excluded: from a host it cannot match, Bing answers with trending/homepage blocks, and a non-empty list of noise otherwise wins the chain over a working fallback. The language is all, not a fixed tag — a pinned uk-UA made the instance's DuckDuckGo engine return CAPTCHA on every request (kl=ua-uk) and skewed English queries. The same trap exists in ddgs v9: backend takes engine names, and an unknown name does not fail — it silently means "all engines".

Batching is the latency lever

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.

What web_view hands back

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

The server's own venv

Each in-repo server runs from mcp-servers/<name>/.venv, which is separate from Navi's and is what mcp_servers.d/<name>.json points command at: ./mcp-servers/navi-web/.venv/bin/python -m app.mcp_server with its working directory there. Navi's venv does not carry those dependencies, so a dependency added to mcp-servers/<name>/pyproject.toml needs an install into that venv — deploy/sync-mcp-venvs.sh does it for every server, and deploy/install.sh calls it. It is stamped by a hash of each pyproject.toml, so an unchanged server is skipped; run it on its own after a git pull (see deploy/UPDATE.md).

bash deploy/sync-mcp-venvs.sh    # all servers, no-op for unchanged ones
sudo systemctl restart navi

For a one-off install outside a deploy checkout, the same thing by hand:

VIRTUAL_ENV=mcp-servers/navi-web/.venv uv pip install -e mcp-servers/navi-web

Skipping it is silent: the module does not import, McpManager cannot connect, and build_tool_list reports the server's tools as missing — the model simply never sees them. /admin/mcp/status and the MCP server %r failed to connect line in the journal are where it shows up.

Runtime

  • 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.
  • Tool names follow 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=...).

Per-user keys (BYOK)

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:

  1. The config has no user_key slot → the default shared client. The resolver and the database are never touched.
  2. The config has a slot and the caller has a saved key → a per-user 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.
  3. The config has a slot and the caller has no key:
    • 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.
    • any other role → refused, with a 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).

Visibility — a gated server is absent, not merely refused

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.
  • Fail closed: if 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.

Storage

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

REST

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.