This document provides essential context for developers (human or AI) working on this codebase.
mcp-tools.nvim exposes NeoVim Lua functions as MCP (Model Context Protocol) tools for AI assistants. The architecture has two main components:
- Lua Plugin (
lua/mcp-tools/) - Tool registration, async task management, bridge lifecycle - TypeScript Bridge (
bridge/) - MCP protocol server, NeoVim RPC client
Core Design Principle: The TypeScript bridge is generic and should NEVER need modification when adding new tools. All tool definitions live in Lua.
AI Assistant (OpenCode/Claude Code)
│
▼ HTTP POST / SSE
┌─────────────────────────────────────┐
│ TypeScript Bridge (bridge/src/) │
│ - Express server on dynamic port │
│ - MCP SDK for protocol handling │
│ - neovim package for RPC │
└─────────────────────────────────────┘
│
▼ msgpack-rpc via unix socket
┌─────────────────────────────────────┐
│ NeoVim Instance │
│ - luaeval() executes registry.* │
│ - Tools run on main thread │
│ - Async results stored in registry │
└─────────────────────────────────────┘
The registry implements a sync/async hybrid execution model:
-- Internal state
M._tools = {} -- Tool definitions keyed by name
M._pending_tasks = {} -- Async task results keyed by task_id
M._next_task_id = 1 -- Monotonic task ID counterExecution Flow:
execute(name, args)is called via RPC- Tool's
execute(cb, args)is invoked - If
cb()is called synchronously (during the pcall), returns{done=true, result=...} - If
cb()is NOT called synchronously, returns{pending=true, task_id=...} - Bridge polls
get_result(task_id)untildone=true
Key insight: The registry detects sync vs async by tracking whether cb() was called during the is_sync_phase flag (lines 106-131 in registry.lua).
Transport Support:
- Streamable HTTP (
/endpoint) - Modern MCP transport, session-based - SSE (
/sse+/messagesendpoints) - Legacy transport, deprecated but supported
Tool Discovery: On every tools/list request, the bridge queries NeoVim:
const tools = await nvim.call("luaeval", [
"require('mcp-tools.registry').list()"
]);Tool Execution:
const response = await nvim.call("luaeval", [
`require('mcp-tools.registry').execute(_A.name, _A.args)`,
{ name: toolName, args: args || {} },
]);
if (response.pending && response.task_id) {
return await pollForResult(nvim, response.task_id); // Polls every 100ms
}Logging: The neovim package hijacks console.*. Use process.stderr.write() or the log() helper for any output.
Everything runs on NeoVim's single main thread. This affects all development:
-- BAD: Hangs NeoVim forever
vim.wait(-1, function() return false end)
-- GOOD: Always have timeout
vim.wait(5000, function() return completed end, 100)-- BAD: May crash from wrong context
session:request("stackTrace", {}, function(err, result)
vim.notify("Done") -- NOT SAFE
end)
-- GOOD: Schedule to main loop
session:request("stackTrace", {}, function(err, result)
vim.schedule(function()
vim.notify("Done")
end)
end)-- BAD: Double callback
execute = function(cb, args)
if error then cb(nil, "error") end
cb(result) -- Called even on error!
end
-- GOOD: Return after callback
execute = function(cb, args)
if error then
cb(nil, "error")
return -- STOP HERE
end
cb(result)
end- Open the relevant file in
lua/mcp-tools/tools/ - Add a new
registry.register({...})call - That's it - bridge discovers tools dynamically
- Create
lua/mcp-tools/tools/yourcat.lua:
local registry = require("mcp-tools.registry")
registry.register({
name = "yourcat_toolname",
description = "What it does",
args = { ... },
execute = function(cb, args)
cb({ result = "data" })
end,
})- Add config option in
file://lua/mcp-tools/config.lua:
M.defaults = {
tools = {
...
yourcat = false, -- Add this
},
...
}- Load conditionally in
file://lua/mcp-tools/init.lua:
if config.get("tools.yourcat") then
pcall(require, "mcp-tools.tools.yourcat")
end- Edit
file://bridge/src/index.ts - Test locally:
NVIM_LISTEN_ADDRESS=/path/to/socket MCP_PORT=9999 bun run bridge/src/index.ts - Bridge auto-restarts when NeoVim plugin restarts it
- Create
lua/mcp-tools/integrations/yourintegration.luawith asetup()function - Add config:
integrations = { yourintegration = false }in config.lua - Load in init.lua similar to opencode integration
local config = require("mcp-tools.config")
config.get("tools.dap") -- Returns boolean
config.get("bridge.port") -- Returns number
config.get("integrations.opencode") -- Returns booleanlocal function debug_notify(msg, level)
local config = require("mcp-tools.config")
if config.get("debug") then
vim.notify(msg, level)
end
endlocal session = dap.session()
if not session then
cb(nil, "No active debug session")
return
endlocal response, req_err, completed = nil, nil, false
session:request("stackTrace", { threadId = id }, function(err, result)
req_err, response, completed = err, result, true
end)
local success = vim.wait(5000, function() return completed end, 100)
if not success then
cb(nil, "Request timed out")
return
endThe smart_buffer_management() function handles opening files for debugging without disrupting special buffers (AI chat windows, DAP UI, etc.). Study this pattern for any tool that needs to manipulate buffers.
| Function | Purpose | Returns |
|---|---|---|
registry.list() |
Get all tools | {name: {name, description, args}} |
registry.execute(name, args) |
Run a tool | {done?, pending?, task_id?, result?, error?} |
registry.get_result(task_id) |
Poll async result | {done, result?, error?} |
registry.cancel_task(task_id) |
Cancel pending task | {cancelled: true} |
{
name = "string", -- Becomes nvim_{name} in MCP
description = "string", -- Shown to AI
args = {
arg_name = {
type = "string|number|boolean|object|array",
description = "string",
required = boolean, -- Optional, default false
default = any, -- Optional
},
},
execute = function(cb, args) end,
}-- Sync success
{ done = true, result = any }
-- Sync error
{ done = true, error = "message" }
-- Async (bridge must poll)
{ pending = true, task_id = "123" }:checkhealth mcp-tools:lua require('mcp-tools').register({name='test', description='Test', args={}, execute=function(cb) cb({ok=true}) end})
:lua print(vim.inspect(require('mcp-tools.registry').list()))
:lua print(vim.inspect(require('mcp-tools.registry').execute('test', {})))# Get NeoVim socket path
nvim --headless -c 'echo v:servername' -c 'q'
# Run bridge manually
NVIM_LISTEN_ADDRESS=/run/user/1000/nvim.12345.0 \
MCP_PORT=9999 \
MCP_LOG_FILE=/tmp/mcp-bridge.log \
bun run bridge/src/index.ts
# Test endpoints
curl http://localhost:9999/healthSet bridge.log_file in config to capture all bridge logs:
require("mcp-tools").setup({
bridge = { log_file = "/tmp/mcp-bridge.log" },
debug = true,
})"No active debug session" - DAP session not started or already terminated. Check require('dap').session().
Tool timeout - Bridge polls for 5 minutes max. Check if cb() is actually being called.
"Unknown tool" - Tool not registered. Check if the tool category is enabled in config and the file is being loaded.
Port detection failure - Bridge must print MCP server listening on port XXXX to stdout (not stderr). The Lua code parses this exact format.
| File | Lines | Purpose |
|---|---|---|
lua/mcp-tools/init.lua |
~84 | Entry point, setup(), public API |
lua/mcp-tools/registry.lua |
~206 | Tool storage, sync/async execution |
lua/mcp-tools/bridge.lua |
~151 | Child process management |
lua/mcp-tools/config.lua |
~62 | Configuration with dot-notation |
lua/mcp-tools/health.lua |
~82 | :checkhealth implementation |
lua/mcp-tools/integrations/opencode.lua |
~166 | OpenCode auto-registration |
lua/mcp-tools/tools/dap.lua |
~1206 | DAP tools (largest, most complex) |
lua/mcp-tools/tools/lsp.lua |
~100 | LSP hover/symbols |
lua/mcp-tools/tools/diagnostics.lua |
~49 | LSP diagnostics |
lua/mcp-tools/tools/undo.lua |
~27 | Undo tree |
bridge/src/index.ts |
~403 | MCP server, NeoVim RPC |
- NeoVim 0.9+ (requires
vim.system()) - Optional: nvim-dap, opencode.nvim
- Bun 1.0+ or Node.js 18+ with npx
@modelcontextprotocol/sdk- MCP protocol (note: some types are deprecated)neovim- msgpack-rpc clientexpress- HTTP server
Install: cd bridge && npm install
- Lua uses snake_case for functions and variables
- TypeScript uses camelCase
- All tools disabled by default (explicit opt-in)
- Error messages should be actionable ("No active debug session" not "Error")
- Use
cb(nil, "error message")noterror()orassert()in tools