The nornir-mcp relay client — architecture, the local lock, and running several agents
The one fact that trips everyone up: even in client mode (
NORNIR_SERVERset), the relay client still opens one local single-writer redb on disk — the database behind nornir'sfunnel_*idea-intake/planning tools, at~/.nornir/funnelby default. redb is single-writer, so only one relay client can run per store path per machine. Two agents (Claude Code + Copilot CLI) both pointed at the default path → the second dies withredb database error: Database already open. Cannot acquire lock.
This is the code-derived design behind that failure, why nornir's MCP is not a network endpoint, and the pattern that lets several agents share one nornir-server from the same machine. Copy-paste lives in mcp-second-agent-for-idiots.md.
1. The topology — what speaks to what
Agent (Copilot CLI / Claude Code / …)
│ MCP = JSON-RPC 2.0, TEXT, over STDIO ← the agent spawns a LOCAL child process
▼
nornir-mcp (relay client) ← the MCP layer; runs on the agent's machine
│ gRPC = HTTP/2, BINARY/protobuf, over the NETWORK → oden:7878
▼
nornir-server ← owns the iceberg/redb warehouse
- The agent ↔ relay-client hop is stdio text (
src/bin/nornir-mcp.rs→serve(stdio())).nornir-mcpis built with rmcp'stransport-iofeature only (Cargo.toml, thermcpdep), so there is no SSE/HTTP server transport. You therefore cannot registerhttp://oden:7878(or any URL) as a remote MCP endpoint — the relay client must run locally, beside the agent. - The relay-client ↔ server hop is gRPC (binary/protobuf).
:7878is nornir's internal RPC — not MCP, not for agents. Don'tcurlit. The relay client is the translator: MCP tools in → gRPC verbs out. - One relay-client process per session, not per tool.
serve(stdio()).awaitthenserver.waiting().awaitblocks for the whole session; every tool call is a JSON-RPC message on the same pipe. The repeated HTTP/2 connects you see underRUST_LOG=debugare the relay→server gRPC hop (one channel per RPC), not new processes.
2. Why client mode is not stateless — the local redb lock
NornirServer::new opens that local store unconditionally, before any mode branch (src/bin/nornir-mcp.rs):
let store_root = FunnelStore::resolve_root(
&loaded.workspace_root, &loaded.nornir.storage.local_path, None);
let store = FunnelStore::open_async(&store_root).await
.with_context(|| format!("open funnel warehouse at {}", store_root.display()))?;
That store backs nornir's idea intake → planning feature (the funnel_* MCP tools). It is the only local state a relay client keeps — every other tool routes to the server over gRPC (the telemetry path even special-cases server_target().is_some()). But because it is a redb database and redb takes an exclusive lock on open:
One live relay client per store path. The default path is
<home>/.nornir/funnel(src/funnel/store.rs,resolve_root: an empty[storage].local_path→ the home-derived default).
So a second relay client for the same user and same store path fails on startup — regardless of client/embedded mode. This is the root cause of the "Database already open" symptom in henrik-bug-nornir-mcp-install.md whenever two agents (or an orphaned relay client + a fresh one) overlap.
3. Coexistence pattern (today) — one store path per relay client
resolve_root honours [storage].local_path (absolute = verbatim; relative = under the config dir). So give each agent's relay client its own store path via a tiny NORNIR_CONFIG, and they stop fighting:
# ~/.nornir-copilot/nornir.toml — the Copilot CLI relay client's private store
[storage]
local_path = "/home/you/.nornir-copilot" # → store at .../.nornir-copilot/warehouse
Launch env: NORNIR_SERVER=http://oden:7878, NORNIR_SERVER_TOKEN=<tok>, NORNIR_CONFIG=~/.nornir-copilot/nornir.toml. Result (verified 2026-06-19): Copilot CLI's relay client and Claude Code's relay client run side by side, each serving the full 68-tool surface over gRPC to oden:7878, with isolated local stores:
Claude → ~/.nornir/funnel (default path)
Copilot → ~/.nornir-copilot/warehouse (NORNIR_CONFIG local_path)
All real data (repos, knowledge, jobs, bench, search) is identical — it is the server's one warehouse. Only the per-agent idea-intake/planning store is separate.
4. The proper fix (future) — make client relays truly stateless
The one-store-path-per-client pattern is a workaround; the clean design is to not open a local store in client mode at all:
- guard the open behind embedded mode — skip
FunnelStore::open_asyncwhenserver_target().is_some()(the telemetry path already branches on exactly this), and - serve the
funnel_*reads/writes over gRPC (Funnel.*verbs) so the ideas/plans land in the server's warehouse like every other fact.
Then any number of client relays run with zero local lock — and a real network MCP gateway (rmcp transport-streamable-http-server) becomes possible: one nornir-mcp --http <addr> on the host, agents attach over the wire, no local binary. See cli-client-modes.md for the parallel fat/thin split on the CLI side, and mcp-from-claude-code.md for the basic wiring.
Verified 2026-06-19 on oden (nornir 0.4.49): two relay clients (Copilot CLI + Claude Code), separate local store paths, both tools/list → 68 tools over http://oden:7878, no lock contention.