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.
- nvim_mcp-0.1.0/.gitignore +84 -0
- nvim_mcp-0.1.0/LICENSE +21 -0
- nvim_mcp-0.1.0/PKG-INFO +23 -0
- nvim_mcp-0.1.0/pyproject.toml +53 -0
- nvim_mcp-0.1.0/src/nvim_mcp/__init__.py +3 -0
- nvim_mcp-0.1.0/src/nvim_mcp/neovim.py +432 -0
- nvim_mcp-0.1.0/src/nvim_mcp/recipes.md +156 -0
- nvim_mcp-0.1.0/src/nvim_mcp/recipes.py +71 -0
- nvim_mcp-0.1.0/src/nvim_mcp/server.py +74 -0
|
@@ -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.
|
nvim_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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,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")
|