nvim-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+
51
+ # Translations
52
+ *.mo
53
+ *.pot
54
+
55
+ # Environments
56
+ .env
57
+ .venv
58
+ env/
59
+ venv/
60
+ ENV/
61
+ env.bak/
62
+ venv.bak/
63
+
64
+ # uv
65
+ .uv/
66
+
67
+ # mypy / type checkers
68
+ .mypy_cache/
69
+ .dmypy.json
70
+ dmypy.json
71
+ .pyre/
72
+ .pytype/
73
+ ruff_cache/
74
+
75
+ # IDEs
76
+ .idea/
77
+ .vscode/
78
+ *.swp
79
+ *.swo
80
+ *~
81
+
82
+ # OS
83
+ .DS_Store
84
+ Thumbs.db
nvim_mcp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Burgess
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: nvim-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for AI-assisted control of Neovim via pynvim
5
+ Project-URL: Homepage, https://github.com/paulburgess1357/nvim-mcp
6
+ Author: Paul Burgess
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: MacOS :: MacOS X
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: mcp[cli]
23
+ Requires-Dist: pynvim
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nvim-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for AI-assisted control of Neovim via pynvim"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Paul Burgess" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: MacOS :: MacOS X",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "mcp[cli]",
29
+ "pynvim",
30
+ ]
31
+
32
+ [project.scripts]
33
+ nvim-mcp = "nvim_mcp.server:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/paulburgess1357/nvim-mcp"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest",
41
+ ]
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/nvim_mcp"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = [
48
+ "/src",
49
+ "/LICENSE",
50
+ ]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """MCP server for Neovim control via pynvim."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,432 @@
1
+ """NeovimManager: multi-instance socket discovery, connection, and communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import stat
8
+ import subprocess
9
+ import time
10
+ from dataclasses import dataclass
11
+
12
+ import pynvim
13
+
14
+
15
+ @dataclass
16
+ class NvimInstance:
17
+ socket_path: str
18
+ pid: int
19
+ cwd: str
20
+ current_file: str
21
+
22
+
23
+ _GET_STATE_LUA = """\
24
+ local wins = {}
25
+ for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
26
+ local b = vim.api.nvim_win_get_buf(w)
27
+ wins[#wins + 1] = {
28
+ file = vim.api.nvim_buf_get_name(b),
29
+ modified = vim.bo[b].modified,
30
+ active = (w == vim.api.nvim_get_current_win()),
31
+ }
32
+ end
33
+ local modified = {}
34
+ local buf_count = 0
35
+ for _, b in ipairs(vim.api.nvim_list_bufs()) do
36
+ if vim.bo[b].buflisted and vim.api.nvim_buf_is_loaded(b) then
37
+ buf_count = buf_count + 1
38
+ if vim.bo[b].modified then
39
+ modified[#modified + 1] = vim.api.nvim_buf_get_name(b)
40
+ end
41
+ end
42
+ end
43
+ return {
44
+ file = vim.fn.expand('%:p'),
45
+ line = vim.fn.line('.'),
46
+ col = vim.fn.col('.'),
47
+ mode = vim.fn.mode(),
48
+ modified = vim.bo.modified,
49
+ filetype = vim.bo.filetype,
50
+ total_lines = vim.fn.line('$'),
51
+ cwd = vim.fn.getcwd(),
52
+ relativenumber = vim.wo.relativenumber,
53
+ windows = wins,
54
+ modified_buffers = modified,
55
+ buffer_count = buf_count,
56
+ }
57
+ """
58
+
59
+ _EXEC_COMMAND_LUA = """\
60
+ local input = ...
61
+ vim.v.errmsg = ''
62
+ local ok, result = pcall(vim.api.nvim_exec2, input, {output = true})
63
+ local output = ok and (result.output or '') or ''
64
+ local errmsg = vim.v.errmsg
65
+ if not ok then errmsg = tostring(result) end
66
+ return {output = output, errmsg = errmsg}
67
+ """
68
+
69
+
70
+ class NeovimManager:
71
+ def __init__(self) -> None:
72
+ self._nvim: pynvim.Nvim | None = None
73
+ self._socket_path: str | None = None
74
+ self._lock = asyncio.Lock()
75
+ self._discovery_cache: tuple[float, list[NvimInstance]] | None = None
76
+ self._discovery_cache_ttl = 30.0
77
+
78
+ # -- Discovery -----------------------------------------------------------
79
+
80
+ async def discover(self) -> list[NvimInstance]:
81
+ if self._discovery_cache is not None:
82
+ ts, cached = self._discovery_cache
83
+ if time.monotonic() - ts < self._discovery_cache_ttl:
84
+ return list(cached)
85
+
86
+ candidates = self._all_sockets()
87
+
88
+ async def _probe_with_timeout(sock: str) -> NvimInstance | None:
89
+ try:
90
+ return await asyncio.wait_for(
91
+ asyncio.to_thread(self._probe_socket, sock),
92
+ timeout=5.0,
93
+ )
94
+ except (asyncio.TimeoutError, Exception):
95
+ return None
96
+
97
+ results = await asyncio.gather(
98
+ *(_probe_with_timeout(s) for s in candidates)
99
+ )
100
+ instances = [r for r in results if r is not None]
101
+ self._discovery_cache = (time.monotonic(), instances)
102
+ return instances
103
+
104
+ # -- Connection ----------------------------------------------------------
105
+
106
+ async def connect(
107
+ self,
108
+ socket_path: str | None = None,
109
+ terminal_pid: int | None = None,
110
+ index: int | None = None,
111
+ ) -> str:
112
+ instances = await self.discover()
113
+
114
+ if socket_path is not None:
115
+ target = socket_path
116
+ elif terminal_pid is not None:
117
+ target = self._find_socket_for_terminal(terminal_pid, instances)
118
+ if target is None:
119
+ return (
120
+ f"Error: no Neovim instance found for terminal PID "
121
+ f"{terminal_pid}."
122
+ )
123
+ elif index is not None:
124
+ if index < 1 or index > len(instances):
125
+ return (
126
+ f"Error: index {index} out of range. "
127
+ f"Found {len(instances)} instance(s)."
128
+ )
129
+ target = instances[index - 1].socket_path
130
+ elif len(instances) == 1:
131
+ target = instances[0].socket_path
132
+ elif len(instances) == 0:
133
+ return "Error: no Neovim instances found. Is Neovim running?"
134
+ else:
135
+ return self._format_instance_list(instances)
136
+
137
+ async with self._lock:
138
+ try:
139
+ nvim = await asyncio.wait_for(
140
+ asyncio.to_thread(pynvim.attach, "socket", path=target),
141
+ timeout=5.0,
142
+ )
143
+ except (asyncio.TimeoutError, OSError) as e:
144
+ return f"Error: could not connect to {target}: {e}"
145
+
146
+ self._nvim = nvim
147
+ self._socket_path = target
148
+
149
+ try:
150
+ state = await asyncio.to_thread(self._get_state_sync)
151
+ cwd = state.get("cwd", "?")
152
+ current_file = state.get("file", "") or "(none)"
153
+ except Exception:
154
+ cwd = "?"
155
+ current_file = "?"
156
+
157
+ return f"Connected to nvim at {target} (cwd: {cwd}, file: {current_file})"
158
+
159
+ # -- Send ----------------------------------------------------------------
160
+
161
+ async def send(self, input: str, mode: str) -> str:
162
+ async with self._lock:
163
+ if self._nvim is None:
164
+ err = await self._auto_connect_unlocked()
165
+ if err is not None:
166
+ return err
167
+ try:
168
+ return await asyncio.to_thread(self._send_sync, input, mode)
169
+ except (OSError, pynvim.NvimError) as e:
170
+ if not self._is_connection_error(e):
171
+ raise
172
+ await self._reconnect_unlocked()
173
+ return await asyncio.to_thread(self._send_sync, input, mode)
174
+
175
+ def _send_sync(self, input: str, mode: str) -> str:
176
+ assert self._nvim is not None
177
+
178
+ if mode == "command":
179
+ result = self._nvim.exec_lua(_EXEC_COMMAND_LUA, input)
180
+ output = result.get("output", "") or ""
181
+ errmsg = result.get("errmsg", "") or ""
182
+ parts: list[str] = []
183
+ if output:
184
+ parts.append(output)
185
+ if errmsg:
186
+ parts.append(f"E: {errmsg}")
187
+ return "\n".join(parts) if parts else "(no output)"
188
+
189
+ if mode == "eval":
190
+ try:
191
+ result = self._nvim.eval(input)
192
+ return str(result)
193
+ except pynvim.NvimError as e:
194
+ if self._is_connection_error(e):
195
+ raise
196
+ return f"Error: {e}"
197
+
198
+ if mode == "keys":
199
+ self._nvim.input("<Esc>" + input)
200
+ return f"Keys sent: {input}"
201
+
202
+ return f"Error: unknown mode {mode!r}. Use 'command', 'eval', or 'keys'."
203
+
204
+ # -- State ---------------------------------------------------------------
205
+
206
+ async def get_state(self) -> dict:
207
+ async with self._lock:
208
+ if self._nvim is None:
209
+ err = await self._auto_connect_unlocked()
210
+ if err is not None:
211
+ raise RuntimeError(err)
212
+ try:
213
+ return await asyncio.to_thread(self._get_state_sync)
214
+ except (OSError, pynvim.NvimError) as e:
215
+ if not self._is_connection_error(e):
216
+ raise
217
+ await self._reconnect_unlocked()
218
+ return await asyncio.to_thread(self._get_state_sync)
219
+
220
+ def _get_state_sync(self) -> dict:
221
+ assert self._nvim is not None
222
+ return self._nvim.exec_lua(_GET_STATE_LUA)
223
+
224
+ # -- Auto-connect (called with lock held) --------------------------------
225
+
226
+ async def _auto_connect_unlocked(self) -> str | None:
227
+ """Auto-connect when a single instance exists.
228
+
229
+ Returns an error message if connection fails, None on success.
230
+ Must be called with ``self._lock`` held.
231
+ """
232
+ instances = await self.discover()
233
+ if len(instances) == 0:
234
+ return "Error: no Neovim instances found. Is Neovim running?"
235
+ if len(instances) > 1:
236
+ return (
237
+ "Error: multiple Neovim instances found. Connect first:\n"
238
+ + self._format_instance_list(instances)
239
+ )
240
+
241
+ target = instances[0].socket_path
242
+ try:
243
+ nvim = await asyncio.wait_for(
244
+ asyncio.to_thread(pynvim.attach, "socket", path=target),
245
+ timeout=5.0,
246
+ )
247
+ except (asyncio.TimeoutError, OSError) as e:
248
+ return f"Error: could not auto-connect to {target}: {e}"
249
+
250
+ self._nvim = nvim
251
+ self._socket_path = target
252
+ return None
253
+
254
+ # -- Reconnect (called with lock held) -----------------------------------
255
+
256
+ async def _reconnect_unlocked(self) -> None:
257
+ """Re-attach to the last-known socket.
258
+
259
+ Must be called with ``self._lock`` held.
260
+ """
261
+ if self._nvim is not None:
262
+ try:
263
+ self._nvim.close()
264
+ except Exception:
265
+ pass
266
+ self._nvim = None
267
+
268
+ if self._socket_path is None:
269
+ raise RuntimeError("Cannot reconnect: no previous socket path.")
270
+
271
+ try:
272
+ self._nvim = await asyncio.wait_for(
273
+ asyncio.to_thread(
274
+ pynvim.attach, "socket", path=self._socket_path
275
+ ),
276
+ timeout=5.0,
277
+ )
278
+ except (asyncio.TimeoutError, OSError) as e:
279
+ raise RuntimeError(
280
+ f"Reconnect to {self._socket_path} failed: {e}"
281
+ ) from e
282
+
283
+ # -- Socket discovery (synchronous) --------------------------------------
284
+
285
+ @staticmethod
286
+ def _all_sockets() -> list[str]:
287
+ override = os.environ.get("NVIM_SOCKET_PATH")
288
+ if override:
289
+ try:
290
+ real = os.path.realpath(override)
291
+ st = os.stat(real)
292
+ if stat.S_ISSOCK(st.st_mode):
293
+ return [real]
294
+ except OSError:
295
+ pass
296
+
297
+ search_dirs: list[str] = []
298
+
299
+ xdg = os.environ.get("XDG_RUNTIME_DIR")
300
+ if xdg:
301
+ search_dirs.append(xdg)
302
+
303
+ try:
304
+ run_user = f"/run/user/{os.getuid()}"
305
+ search_dirs.append(run_user)
306
+ except AttributeError:
307
+ pass # os.getuid() unavailable on Windows
308
+
309
+ tmpdir = os.environ.get("TMPDIR")
310
+ if tmpdir:
311
+ search_dirs.append(tmpdir)
312
+ search_dirs.append("/tmp")
313
+
314
+ seen: set[str] = set()
315
+ results: list[str] = []
316
+
317
+ for base_dir in search_dirs:
318
+ if not os.path.isdir(base_dir):
319
+ continue
320
+ for root, dirnames, filenames in os.walk(base_dir, followlinks=False):
321
+ rel = os.path.relpath(root, base_dir)
322
+ depth = 0 if rel == "." else rel.count(os.sep) + 1
323
+
324
+ all_entries = filenames + list(dirnames)
325
+
326
+ if depth >= 4:
327
+ dirnames.clear()
328
+
329
+ for name in all_entries:
330
+ if not name.startswith("nvim"):
331
+ continue
332
+ full = os.path.join(root, name)
333
+ try:
334
+ st = os.stat(full)
335
+ except OSError:
336
+ continue
337
+ if not stat.S_ISSOCK(st.st_mode):
338
+ continue
339
+ real = os.path.realpath(full)
340
+ if real not in seen:
341
+ seen.add(real)
342
+ results.append(full)
343
+
344
+ return results
345
+
346
+ @staticmethod
347
+ def _probe_socket(sock: str) -> NvimInstance | None:
348
+ try:
349
+ nvim = pynvim.attach("socket", path=sock)
350
+ except Exception:
351
+ return None
352
+ try:
353
+ pid: int = nvim.eval("getpid()")
354
+ cwd: str = nvim.eval("getcwd()")
355
+ current_file: str = nvim.eval("expand('%:p')")
356
+ return NvimInstance(
357
+ socket_path=sock,
358
+ pid=pid,
359
+ cwd=cwd,
360
+ current_file=current_file,
361
+ )
362
+ except Exception:
363
+ return None
364
+ finally:
365
+ try:
366
+ nvim.close()
367
+ except Exception:
368
+ pass
369
+
370
+ @staticmethod
371
+ def _find_socket_for_terminal(
372
+ terminal_pid: int, instances: list[NvimInstance]
373
+ ) -> str | None:
374
+ descendants: set[int] = set()
375
+ to_visit = [terminal_pid]
376
+ while to_visit:
377
+ pid = to_visit.pop()
378
+ if pid in descendants:
379
+ continue
380
+ descendants.add(pid)
381
+ try:
382
+ result = subprocess.run(
383
+ ["pgrep", "-P", str(pid)],
384
+ capture_output=True,
385
+ text=True,
386
+ timeout=5,
387
+ )
388
+ if result.returncode == 0:
389
+ for line in result.stdout.strip().splitlines():
390
+ child = int(line.strip())
391
+ if child not in descendants:
392
+ to_visit.append(child)
393
+ except (subprocess.TimeoutExpired, ValueError, OSError):
394
+ pass
395
+
396
+ for inst in instances:
397
+ if inst.pid in descendants:
398
+ return inst.socket_path
399
+ return None
400
+
401
+ @staticmethod
402
+ def _is_connection_error(e: Exception) -> bool:
403
+ if isinstance(e, (
404
+ BrokenPipeError,
405
+ ConnectionRefusedError,
406
+ ConnectionResetError,
407
+ ConnectionAbortedError,
408
+ )):
409
+ return True
410
+ if isinstance(e, OSError) and not isinstance(e, pynvim.NvimError):
411
+ return True
412
+ if isinstance(e, pynvim.NvimError):
413
+ msg = str(e).lower()
414
+ return any(
415
+ kw in msg
416
+ for kw in ("eof", "broken pipe", "connection", "transport", "closed")
417
+ )
418
+ return False
419
+
420
+ @staticmethod
421
+ def _format_instance_list(instances: list[NvimInstance]) -> str:
422
+ lines = ["Multiple Neovim instances found:"]
423
+ for i, inst in enumerate(instances, 1):
424
+ file_display = inst.current_file or "(none)"
425
+ lines.append(
426
+ f" {i}. {inst.socket_path} "
427
+ f"(cwd: {inst.cwd}, file: {file_display})"
428
+ )
429
+ lines.append(
430
+ "\nUse index=N, socket_path=..., or terminal_pid=... to select one."
431
+ )
432
+ return "\n".join(lines)
@@ -0,0 +1,156 @@
1
+ ## Files
2
+
3
+ - Open file: nvim_send(input="e /path/to/file", mode="command")
4
+ - Open file in new window: nvim_send(input="sp /path/to/file", mode="command")
5
+ - Open file in vertical split: nvim_send(input="vs /path/to/file", mode="command")
6
+ - Save: nvim_send(input="w", mode="command")
7
+ - Save all buffers: nvim_send(input="wa", mode="command")
8
+ - Save as: nvim_send(input="saveas /path/to/newfile", mode="command")
9
+ - Write to path without switching buffer: nvim_send(input="w /path/to/file", mode="command")
10
+ - Close buffer: nvim_send(input="bd", mode="command")
11
+ - Close buffer force (discard changes): nvim_send(input="bd!", mode="command")
12
+ - Wipe buffer: nvim_send(input="bwipe", mode="command")
13
+ - Reload from disk if unchanged: nvim_send(input="checktime", mode="command")
14
+ - Force reload from disk: nvim_send(input="e!", mode="command")
15
+ - New empty buffer: nvim_send(input="enew", mode="command")
16
+ - Alternate file: nvim_send(input="e #", mode="command")
17
+ - Print current file path: nvim_send(input="echo expand('%:p')", mode="command")
18
+ - Change directory for session: nvim_send(input="cd /path/to/dir", mode="command")
19
+
20
+ ## Navigation
21
+
22
+ - Go to line N: nvim_send(input="42", mode="command")
23
+ - Go to first line: nvim_send(input="1", mode="command")
24
+ - Go to last line: nvim_send(input="$", mode="command")
25
+ - Jump to matching bracket: nvim_send(input="%", mode="keys")
26
+ - Older jump-list position: nvim_send(input="<C-o>", mode="keys")
27
+ - Newer jump-list position: nvim_send(input="<C-i>", mode="keys")
28
+ - Older change position: nvim_send(input="g;", mode="keys")
29
+ - Newer change position: nvim_send(input="g,", mode="keys")
30
+ - Last edit line: nvim_send(input="`.", mode="keys")
31
+ - Local definition (buffer symbol): nvim_send(input="gd", mode="keys")
32
+ - Local declaration: nvim_send(input="gD", mode="keys")
33
+ - Show jump list: nvim_send(input="jumps", mode="command")
34
+ - Show change list: nvim_send(input="changes", mode="command")
35
+ - Center cursor line: nvim_send(input="zz", mode="keys")
36
+ - Scroll cursor to top: nvim_send(input="zt", mode="keys")
37
+ - Scroll cursor to bottom: nvim_send(input="zb", mode="keys")
38
+
39
+ ## Buffers
40
+
41
+ - List buffers: nvim_send(input="buffers", mode="command")
42
+ - Same as ls: nvim_send(input="ls!", mode="command")
43
+ - Switch by number: nvim_send(input="b 3", mode="command")
44
+ - Switch by name/pattern: nvim_send(input="buffer foo", mode="command")
45
+ - Next buffer: nvim_send(input="bnext", mode="command")
46
+ - Previous buffer: nvim_send(input="bprevious", mode="command")
47
+ - First buffer: nvim_send(input="bfirst", mode="command")
48
+ - Last buffer: nvim_send(input="blast", mode="command")
49
+ - Delete buffer: nvim_send(input="bdelete", mode="command")
50
+ - Delete buffer N: nvim_send(input="3bdelete", mode="command")
51
+ - Wipe buffer: nvim_send(input="bwipeout", mode="command")
52
+ - Buffer info (name, modified): nvim_send(input="echo bufname('%') .. ' ' .. (&modified ? '[+]' : '')", mode="command")
53
+ - Count lines in buffer: nvim_send(input="echo line('$')", mode="command")
54
+
55
+ ## Windows & Tabs
56
+
57
+ - Horizontal split: nvim_send(input="split", mode="command")
58
+ - Horizontal split file: nvim_send(input="split /path/to/file", mode="command")
59
+ - Vertical split: nvim_send(input="vsplit", mode="command")
60
+ - Vertical split file: nvim_send(input="vsplit /path/to/file", mode="command")
61
+ - New window: nvim_send(input="new", mode="command")
62
+ - New vertical window: nvim_send(input="vnew", mode="command")
63
+ - Close window: nvim_send(input="close", mode="command")
64
+ - Close window force: nvim_send(input="close!", mode="command")
65
+ - Only this window: nvim_send(input="only", mode="command")
66
+ - Move to next window: nvim_send(input="wincmd w", mode="command")
67
+ - Move to previous window: nvim_send(input="wincmd W", mode="command")
68
+ - Move down: nvim_send(input="wincmd j", mode="command")
69
+ - Move up: nvim_send(input="wincmd k", mode="command")
70
+ - Move left: nvim_send(input="wincmd h", mode="command")
71
+ - Move right: nvim_send(input="wincmd l", mode="command")
72
+ - Move to top-left: nvim_send(input="wincmd t", mode="command")
73
+ - Move to bottom-right: nvim_send(input="wincmd b", mode="command")
74
+ - Swap with next: nvim_send(input="wincmd x", mode="command")
75
+ - Equal width/height: nvim_send(input="wincmd =", mode="command")
76
+ - Max height: nvim_send(input="wincmd _", mode="command")
77
+ - Max width: nvim_send(input="wincmd |", mode="command")
78
+ - Resize height +N: nvim_send(input="resize +5", mode="command")
79
+ - Resize width +N: nvim_send(input="vertical resize +10", mode="command")
80
+ - New tab: nvim_send(input="tabnew", mode="command")
81
+ - New tab with file: nvim_send(input="tabnew /path/to/file", mode="command")
82
+ - Close tab: nvim_send(input="tabclose", mode="command")
83
+ - Close other tabs: nvim_send(input="tabonly", mode="command")
84
+ - Next tab: nvim_send(input="tabnext", mode="command")
85
+ - Previous tab: nvim_send(input="tabprevious", mode="command")
86
+ - Go to tab N: nvim_send(input="tabn 2", mode="command")
87
+ - List tabs: nvim_send(input="tabs", mode="command")
88
+
89
+ ## Marks
90
+
91
+ - Set local mark a at cursor: nvim_send(input="lua local r,c=vim.api.nvim_win_get_cursor(0); vim.api.nvim_buf_set_mark(0, 'a', r, c)", mode="command")
92
+ - Set global mark A (cross-file): nvim_send(input="lua local r,c=vim.api.nvim_win_get_cursor(0); vim.api.nvim_buf_set_mark(0, 'A', r, c)", mode="command")
93
+ - Jump to mark a (current buffer): nvim_send(input="'a", mode="keys")
94
+ - Jump to global mark A: nvim_send(input="'A", mode="keys")
95
+ - Jump to mark exact column: nvim_send(input="`a", mode="keys")
96
+ - List marks: nvim_send(input="marks", mode="command")
97
+ - Delete mark a: nvim_send(input="delmarks a", mode="command")
98
+ - Delete marks a-c: nvim_send(input="delmarks a b c", mode="command")
99
+ - Delete all marks in buffer: nvim_send(input="delmarks!", mode="command")
100
+ - Last insert end: nvim_send(input="'^", mode="keys")
101
+ - Last change start: nvim_send(input="'[", mode="keys")
102
+ - Last change end: nvim_send(input="']", mode="keys")
103
+ - Visual selection start: nvim_send(input="'<", mode="keys")
104
+ - Visual selection end: nvim_send(input="'>", mode="keys")
105
+
106
+ ## Registers
107
+
108
+ - Get register a as string (eval result): nvim_send(input="getreg('a')", mode="eval")
109
+ - Set register a: nvim_send(input="let @a = 'text'", mode="command")
110
+ - Append to register a: nvim_send(input="let @A = @A .. 'more'", mode="command")
111
+ - List registers: nvim_send(input="registers", mode="command")
112
+ - Yank line to register a: nvim_send(input="lua vim.fn.setreg('a', vim.fn.getline('.'))", mode="command")
113
+ - Black hole register (discard yank target): nvim_send(input="let @_ = @a", mode="command")
114
+ - System clipboard get: nvim_send(input="getreg('+')", mode="eval")
115
+ - System clipboard set: nvim_send(input="let @+ = 'text'", mode="command")
116
+ - Primary selection get (X11): nvim_send(input="getreg('*')", mode="eval")
117
+ - Unnamed register: nvim_send(input="getreg('\"')", mode="eval")
118
+ - Last yank: nvim_send(input="getreg('0')", mode="eval")
119
+ - Last insert (`.` register): nvim_send(input="getreg('.')", mode="eval")
120
+ - Last command line: nvim_send(input="getreg(':')", mode="eval")
121
+ - Last search pattern: nvim_send(input="getreg('/')", mode="eval")
122
+ - Small delete: nvim_send(input="getreg('-')", mode="eval")
123
+ - Expression register result: nvim_send(input="getreg('=')", mode="eval")
124
+
125
+ ## Folds
126
+
127
+ - Close fold at cursor: nvim_send(input="foldclose", mode="command")
128
+ - Open fold at cursor: nvim_send(input="foldopen", mode="command")
129
+ - Toggle fold: nvim_send(input="za", mode="keys")
130
+ - Close all folds (foldlevel): nvim_send(input="lua vim.o.foldlevel = 0", mode="command")
131
+ - Open all folds: nvim_send(input="lua vim.o.foldlevel = 99", mode="command")
132
+ - Create fold for range (lines 3–10): nvim_send(input="3,10fold", mode="command")
133
+ - Delete fold at cursor: nvim_send(input="zd", mode="keys")
134
+ - Delete all folds in window: nvim_send(input="normal! zE", mode="command")
135
+ - Enable folding for buffer: nvim_send(input="setlocal foldmethod=manual", mode="command")
136
+
137
+ ## LSP & Diagnostics
138
+
139
+ - Go to definition: nvim_send(input="lua vim.lsp.buf.definition()", mode="command")
140
+ - Go to declaration: nvim_send(input="lua vim.lsp.buf.declaration()", mode="command")
141
+ - Go to type definition: nvim_send(input="lua vim.lsp.buf.type_definition()", mode="command")
142
+ - Go to implementation: nvim_send(input="lua vim.lsp.buf.implementation()", mode="command")
143
+ - Find references: nvim_send(input="lua vim.lsp.buf.references()", mode="command")
144
+ - Hover documentation: nvim_send(input="lua vim.lsp.buf.hover()", mode="command")
145
+ - Rename symbol: nvim_send(input="lua vim.lsp.buf.rename()", mode="command")
146
+ - Code action: nvim_send(input="lua vim.lsp.buf.code_action()", mode="command")
147
+ - Format buffer: nvim_send(input="lua vim.lsp.buf.format({ async = true })", mode="command")
148
+ - Signature help: nvim_send(input="lua vim.lsp.buf.signature_help()", mode="command")
149
+ - Incoming calls: nvim_send(input="lua vim.lsp.buf.incoming_calls()", mode="command")
150
+ - Outgoing calls: nvim_send(input="lua vim.lsp.buf.outgoing_calls()", mode="command")
151
+ - Next diagnostic in buffer: nvim_send(input="lua vim.diagnostic.goto_next()", mode="command")
152
+ - Previous diagnostic in buffer: nvim_send(input="lua vim.diagnostic.goto_prev()", mode="command")
153
+ - Open diagnostics as quickfix: nvim_send(input="lua vim.diagnostic.setqflist()", mode="command")
154
+ - Open diagnostics for buffer in quickfix: nvim_send(input="lua vim.diagnostic.setqflist({ bufnr = 0 })", mode="command")
155
+ - List diagnostics (Lua table as message): nvim_send(input="lua vim.inspect(vim.diagnostic.get(0))", mode="command")
156
+ - Get diagnostic count: nvim_send(input="lua vim.diagnostic.count(0)", mode="command")
@@ -0,0 +1,71 @@
1
+ """Load and serve Neovim operation recipes for the nvim_recipes MCP tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import lru_cache
7
+ from importlib import resources
8
+
9
+
10
+ QUICK_REFERENCE = """Quick reference (top operations):
11
+
12
+ 1. **Open file:** `nvim_send(input="e /path/to/file", mode="command")`
13
+ 2. **Save file:** `nvim_send(input="w", mode="command")`
14
+ 3. **Go to line:** `nvim_send(input="42", mode="command")`
15
+ 4. **Reload from disk:** `nvim_send(input="checktime", mode="command")`
16
+ 5. **Close buffer:** `nvim_send(input="bd", mode="command")`
17
+ 6. **Vertical split:** `nvim_send(input="vs /path/to/file", mode="command")`
18
+ 7. **Navigate windows:** `nvim_send(input="wincmd w", mode="command")`
19
+ 8. **LSP go-to-definition:** `nvim_send(input="lua vim.lsp.buf.definition()", mode="command")`
20
+ 9. **LSP references:** `nvim_send(input="lua vim.lsp.buf.references()", mode="command")`
21
+ """
22
+
23
+
24
+ def _read_recipes_md() -> str:
25
+ return resources.files("nvim_mcp").joinpath("recipes.md").read_text(encoding="utf-8")
26
+
27
+
28
+ @lru_cache(maxsize=1)
29
+ def _parsed_recipes() -> dict[str, str]:
30
+ text = _read_recipes_md()
31
+ pattern = re.compile(r"^## (.+)$", re.MULTILINE)
32
+ parts = pattern.split(text)
33
+ result: dict[str, str] = {}
34
+ # parts[0] is preamble before first ##; then (title, body)+
35
+ i = 1
36
+ while i + 1 < len(parts):
37
+ name = parts[i].strip().lower()
38
+ body = parts[i + 1].strip()
39
+ result[name] = body
40
+ i += 2
41
+ return result
42
+
43
+
44
+ def load_recipes() -> dict[str, str]:
45
+ """Parse recipes.md into {category_name: body_text} dict.
46
+
47
+ Split on ^## headers. Each header becomes a category key (lowercased, stripped).
48
+ Category body is everything until the next ^## or EOF.
49
+ """
50
+ return dict(_parsed_recipes())
51
+
52
+
53
+ def get_recipes(category: str | None = None) -> str:
54
+ """
55
+ No category: return QUICK_REFERENCE + list of category names.
56
+ With category: return full recipes for that section.
57
+ Unknown category: return error message with valid categories listed.
58
+ """
59
+ recipes = load_recipes()
60
+ names = sorted(recipes)
61
+
62
+ if category is None:
63
+ lines = [QUICK_REFERENCE.rstrip(), "", "Categories:", *[f"- {n}" for n in names]]
64
+ return "\n".join(lines) + "\n"
65
+
66
+ key = category.strip().lower()
67
+ if key in recipes:
68
+ return f"## {category.strip()}\n\n{recipes[key]}\n"
69
+
70
+ valid = ", ".join(names)
71
+ return f"Unknown category {category!r}. Valid categories: {valid}\n"
@@ -0,0 +1,74 @@
1
+ """FastMCP entry point: tools for Neovim discovery, control, state, and recipes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from nvim_mcp.neovim import NeovimManager
10
+ from nvim_mcp.recipes import get_recipes
11
+
12
+ mcp = FastMCP("nvim-mcp")
13
+ manager = NeovimManager()
14
+
15
+
16
+ @mcp.tool()
17
+ async def nvim_connect(
18
+ socket_path: str | None = None,
19
+ terminal_pid: int | None = None,
20
+ index: int | None = None,
21
+ ) -> str:
22
+ """Connect to a Neovim instance.
23
+
24
+ No args = auto-connect if one instance, list all if multiple.
25
+ With index = pick from list (1-based).
26
+ With socket_path = direct connect.
27
+ With terminal_pid = match via process tree.
28
+ """
29
+ return await manager.connect(
30
+ socket_path=socket_path,
31
+ terminal_pid=terminal_pid,
32
+ index=index,
33
+ )
34
+
35
+
36
+ @mcp.tool()
37
+ async def nvim_send(
38
+ input: str,
39
+ mode: Literal["command", "eval", "keys"] = "command",
40
+ ) -> str:
41
+ """Send input to Neovim.
42
+
43
+ Modes:
44
+ - command: Run ex command without leading ':'. E.g. "e /path/to/file", "w", "42"
45
+ - eval: Evaluate expression, return result. E.g. "getcwd()", "line('$')"
46
+ - keys: Send keystrokes for navigation. E.g. "gg", "G", "za"
47
+
48
+ Auto-connects if only one Neovim instance exists.
49
+ """
50
+ return await manager.send(input=input, mode=mode)
51
+
52
+
53
+ @mcp.tool()
54
+ async def nvim_state() -> dict:
55
+ """Get structured Neovim state.
56
+
57
+ Returns file, line, col, mode, modified status, filetype, total lines,
58
+ cwd, relativenumber, window layout, modified buffers, and buffer count.
59
+ """
60
+ return await manager.get_state()
61
+
62
+
63
+ @mcp.tool()
64
+ async def nvim_recipes(category: str | None = None) -> str:
65
+ """Get Neovim operation recipes.
66
+
67
+ No args = quick reference + category list.
68
+ With category = full recipes for that section.
69
+ """
70
+ return get_recipes(category=category)
71
+
72
+
73
+ def main() -> None:
74
+ mcp.run(transport="stdio")