Clone
1
The path to a working nvim companion
Oleks edited this page 2026-05-21 15:07:20 +03:00

The path to a working nvim companion

A build log: how nvim-agentic-companion went from an idea to a Claude Code agent that reliably drives a running Neovim — and every wall hit on the way, because each wall taught the final design.

The goal

One Claude Code agent that can both answer questions about and act on the user's running nvim — keymaps, buffers, diagnostics, live : commands — not a static read of the config. Plus a clean hand-off to the in-editor coder/claudecode.nvim session for buffer edits.

Final architecture

Claude Code session
  └─ .mcp.json → http://pool.localhost:12010/p/neovim/mcp
       └─ mcp-session-pool        M=1 session pin; /repin; initialize
            │                     blocks-with-retry for a slow upstream
            └─ nvim-mcp-bridge    supergateway (:12016, --stateful),
               │                  socket-gated by a systemd.path
               └─ mcp-neovim-server   stdio child
                    └─ /run/user/1000/nvim.sock   (nvim msgpack-RPC)
                         └─ the running Neovim

Three processes, each of which re-establishes on its own — that property is the whole point, and it is what took the longest to get right.

  • mcp-neovim-server — a stdio MCP server (19 vim_* tools) that speaks nvim's msgpack-RPC over a Unix socket.
  • nvim-mcp-bridgesupergateway wrapping that stdio server as Streamable-HTTP. A systemd.path (inotify) starts it when the nvim socket appears; a wrapper inotifywait stops it when the socket vanishes. So the bridge runs exactly while nvim is up.
  • mcp-session-pool — "PgBouncer for MCP". Claude Code is a sessionless client; the bridge is sessionful. The pool pins one upstream session and stamps it onto every sessionless request.
  • The plugin — ships .mcp.json (points at the pool), the companion agent, and three skills (editor-introspect, editor-act, claude-code-handoff).

What we tried, and why it changed

MetaMCP as the bridge — removed

The first design fronted the nvim MCP through MetaMCP, the existing aggregator for the stable namespaces (gitea, k8s, …). It failed twice:

  • As a STDIO child: MetaMCP spawns the stdio child once and never respawns it. nvim quits → child exits → the namespace serves Unknown tool until MetaMCP itself restarts.
  • As a STREAMABLE_HTTP upstream: MetaMCP never re-dials a restarted upstream either — it establishes the connection once, at process level, and a session re-pin does not propagate into a fresh dial.

Lesson: MetaMCP is built for always-on upstreams. An editor-bound MCP is ephemeral — it needs a layer that re-establishes. MetaMCP was dropped from this path; the pool (which does re-establish) fronts the bridge directly.

A polling health-probe — removed

An early fix added a per-pool health probe: every keepalive tick, call vim_health, re-pin if unhealthy. It worked as a detector but it was polling, and it churned re-pins. Replaced with an event-driven re-pin: the socket-appearance inotify event is the only trigger; nothing polls.

Socket-gating the bridge

mcp-neovim-server does not handle a missing socket — connect ENOENT throws an uncaught exception and the process crashes. A tool call issued while nvim was down therefore hung the caller to its timeout.

Fix: gate the bridge service on the socket. nvim down ⇒ bridge down ⇒ port 12016 closed ⇒ the caller gets an instant ECONNREFUSED instead of a hang. As a bonus, systemctl is-active nvim-mcp-bridge now means exactly "is nvim up" — observable with no probe.

First-wins socket guard

With several nvim instances, a fixed socket path means the last one launched steals it. The serverstart guard in neovim.nix now probes an occupied socket: if a live nvim holds it, leave it alone (first-wins, stable); only a genuinely stale socket is reclaimed.

Bugs hit — and the lesson each taught

Symptom Root cause Lesson
mcp__neovim__* tools never appear session predates the MCP wiring; MCP attaches once at launch restart after the upstream is up — or make initialize wait (now does)
Unknown tool through MetaMCP MetaMCP won't respawn / re-dial a restarted upstream don't front an ephemeral MCP with an aggregator built for stable ones
tool calls return empty / hang mcp-neovim-server crashes on a missing//tmp/nvim socket gate the bridge on the socket; set NVIM_SOCKET_PATH
empty tools/call, /tmp/nvim in the log a refactor dropped the environment block the regression hid because only the down path was re-tested
agent sees zero mcp__ tools agent tools: used a mcp__…__* wildcard agent tools: needs explicit tool names — no wildcard
permission prompt on every call tools allowed by the agent, not permitted by the user permissions.allow is a user decision; mcp__<server> covers all

Final working state

  • vim_health through the full chain → "Neovim connection is healthy", repeatably.
  • nvim down ⇒ fast ECONNREFUSED, no hung calls.
  • nvim restart ⇒ the socket inotify event restarts the bridge, whose ExecStartPost re-pins the pool — self-healing, no polling, no editor hook.
  • initialize blocks-with-retry (~20s) so a Claude Code launch a few seconds ahead of the bridge still registers the tools.
  • permissions.allow carries mcp__plugin_nvim-agentic-companion_neovim so the companion drives the editor without a prompt per call.

Operational notes

  • The companion agent's tools: and the skills' allowed-tools: enumerate all 19 vim_* tools explicitly — the wildcard form does not match.
  • Permission to use the tools is the user's: one permissions.allow entry, mcp__plugin_nvim-agentic-companion_neovim. A plugin cannot (and should not) ship that itself.
  • A change to nvim-mcp-bridge.nix may need a manual systemctl restart nvim-mcp-bridgeswitch-to-configuration does not always restart a path-gated service.
  • Companion runbook for "tools missing": socket exists? systemctl status nvim-mcp-bridge? http://127.0.0.1:12010/pools/neovim pinned? session restarted since the wiring landed?

See also