Table of Contents
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.
The chain, link by link
mcp-neovim-server— a stdio MCP server (19vim_*tools) that speaks nvim's msgpack-RPC over a Unix socket.nvim-mcp-bridge—supergatewaywrapping that stdio server as Streamable-HTTP. Asystemd.path(inotify) starts it when the nvim socket appears; a wrapperinotifywaitstops 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), thecompanionagent, 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 tooluntil 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_healththrough 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
ExecStartPostre-pins the pool — self-healing, no polling, no editor hook. initializeblocks-with-retry (~20s) so a Claude Code launch a few seconds ahead of the bridge still registers the tools.permissions.allowcarriesmcp__plugin_nvim-agentic-companion_neovimso the companion drives the editor without a prompt per call.
Operational notes
- The companion agent's
tools:and the skills'allowed-tools:enumerate all 19vim_*tools explicitly — the wildcard form does not match. - Permission to use the tools is the user's: one
permissions.allowentry,mcp__plugin_nvim-agentic-companion_neovim. A plugin cannot (and should not) ship that itself. - A change to
nvim-mcp-bridge.nixmay need a manualsystemctl restart nvim-mcp-bridge—switch-to-configurationdoes 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/neovimpinned? session restarted since the wiring landed?
See also
- The general pattern, repo-agnostic:
Ephemeral stdio MCP upstreams
on the
clusterwiki.