diff --git a/README.md b/README.md
index 14e130f..eb1e37c 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,6 @@
Connect Claude Desktop (or any Model Context Protocol client) to Neovim using MCP and the official neovim/node-client JavaScript library. This server leverages Vim's native text editing commands and workflows, which Claude already understands, to create a lightweight code or general purpose AI text assistance layer.
-**Version 0.5.3** - Now with a DXT package!
-
## Features
@@ -40,7 +38,8 @@ Connect Claude Desktop (or any Model Context Protocol client) to Neovim using MC
- On error, `'nvim:errmsg'` contents are returned
- **vim_status**
- Get comprehensive Neovim status
- - Returns cursor position, mode, filename, visual selection, window layout, current tab, marks, registers, working directory, LSP client info, and plugin detection
+ - Returns cursor position, mode, filename, visual selection with enhanced detection, window layout, current tab, marks, registers, working directory, LSP client info, and plugin detection
+ - Enhanced visual selection reporting: detects visual mode type (character/line/block), provides accurate selection text, start/end positions, and last visual selection marks
- **vim_edit**
- Edit lines using insert, replace, or replaceAll modes
- Input `startLine` (number), `mode` (`"insert"` | `"replace"` | `"replaceAll"`), `lines` (string)
diff --git a/package-lock.json b/package-lock.json
index beb08ca..653f7d7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,25 +1,25 @@
{
"name": "mcp-neovim-server",
- "version": "0.5.3",
+ "version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mcp-neovim-server",
- "version": "0.5.3",
+ "version": "0.5.5",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.13.2",
+ "@modelcontextprotocol/sdk": "^1.17.4",
"neovim": "^5.3.0",
"ts-node": "^10.9.2",
- "zod": "^3.25.67"
+ "zod": "^3.25.76"
},
"bin": {
"mcp-neovim-server": "build/index.js"
},
"devDependencies": {
- "@types/node": "^24.0.6",
- "typescript": "^5.8.3"
+ "@types/node": "^24.3.0",
+ "typescript": "^5.9.2"
}
},
"node_modules/@colors/colors": {
@@ -80,9 +80,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.2.tgz",
- "integrity": "sha512-Vx7qOcmoKkR3qhaQ9qf3GxiVKCEu+zfJddHv6x3dY/9P6+uIwJnmuAur5aB+4FDXf41rRrDnOEGkviX5oYZ67w==",
+ "version": "1.17.4",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.4.tgz",
+ "integrity": "sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@@ -90,6 +90,7 @@
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
@@ -101,6 +102,15 @@
"node": ">=18"
}
},
+ "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": {
+ "version": "3.24.6",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
+ "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.24.1"
+ }
+ },
"node_modules/@msgpack/msgpack": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
@@ -135,12 +145,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "24.0.6",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.6.tgz",
- "integrity": "sha512-ZOyn+gOs749xU7ovp+Ibj0g1o3dFRqsfPnT22C2t5JzcRvgsEDpGawPbCISGKLudJk9Y0wiu9sYd6kUh0pc9TA==",
+ "version": "24.3.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
+ "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"license": "MIT",
"dependencies": {
- "undici-types": "~7.8.0"
+ "undici-types": "~7.10.0"
}
},
"node_modules/@types/triple-beam": {
@@ -1389,9 +1399,9 @@
}
},
"node_modules/typescript": {
- "version": "5.8.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -1402,9 +1412,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.8.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
- "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+ "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/unpipe": {
@@ -1513,22 +1523,13 @@
}
},
"node_modules/zod": {
- "version": "3.25.67",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
- "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
- },
- "node_modules/zod-to-json-schema": {
- "version": "3.24.6",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
- "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
- "license": "ISC",
- "peerDependencies": {
- "zod": "^3.24.1"
- }
}
}
}
diff --git a/package.json b/package.json
index d112a62..9768e1a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mcp-neovim-server",
- "version": "0.5.4",
+ "version": "0.5.5",
"description": "An MCP server for neovim",
"type": "module",
"bin": {
@@ -26,13 +26,13 @@
},
"homepage": "https://github.com/bigcodegen/mcp-neovim-server#readme",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.13.2",
+ "@modelcontextprotocol/sdk": "^1.17.4",
"neovim": "^5.3.0",
"ts-node": "^10.9.2",
- "zod": "^3.25.67"
+ "zod": "^3.25.76"
},
"devDependencies": {
- "@types/node": "^24.0.6",
- "typescript": "^5.8.3"
+ "@types/node": "^24.3.0",
+ "typescript": "^5.9.2"
}
}
diff --git a/src/index.ts b/src/index.ts
index 7a57e3f..5526868 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,7 +12,7 @@ import { z } from "zod";
const server = new McpServer(
{
name: "mcp-neovim-server",
- version: "0.5.4"
+ version: "0.5.5"
}
);
diff --git a/src/neovim.ts b/src/neovim.ts
index 38b9bd3..b8fb9b5 100644
--- a/src/neovim.ts
+++ b/src/neovim.ts
@@ -34,6 +34,14 @@ interface NeovimStatus {
cwd: string;
lspInfo?: string;
pluginInfo?: string;
+ visualInfo?: {
+ hasActiveSelection: boolean;
+ visualModeType?: string;
+ startPos?: [number, number];
+ endPos?: [number, number];
+ lastVisualStart?: [number, number];
+ lastVisualEnd?: [number, number];
+ };
}
interface BufferInfo {
@@ -200,6 +208,123 @@ export class NeovimManager {
}
}
+ private async getVisualSelectionInfo(nvim: Neovim, mode: string): Promise<{
+ hasSelection: boolean;
+ selectedText?: string;
+ startPos?: [number, number];
+ endPos?: [number, number];
+ visualModeType?: string;
+ lastVisualStart?: [number, number];
+ lastVisualEnd?: [number, number];
+ }> {
+ try {
+ const isInVisualMode = mode.includes('v') || mode.includes('V') || mode.includes('\x16');
+
+ if (isInVisualMode) {
+ // Currently in visual mode - get active selection
+ const [startPos, endPos, initialVisualModeType] = await Promise.all([
+ nvim.call('getpos', ['v']) as Promise<[number, number, number, number]>,
+ nvim.call('getpos', ['.']) as Promise<[number, number, number, number]>,
+ nvim.call('visualmode', []) as Promise
+ ]);
+
+ // Convert positions to [line, col] format
+ const start: [number, number] = [startPos[1], startPos[2]];
+ const end: [number, number] = [endPos[1], endPos[2]];
+
+ // Get the selected text using a more reliable approach
+ let selectedText = '';
+ let visualModeType = initialVisualModeType;
+ try {
+ const result = await nvim.lua(`
+ -- Get visual mode type first
+ local mode = vim.fn.visualmode()
+ if not mode or mode == '' then
+ return { text = '', mode = '' }
+ end
+
+ local start_pos = vim.fn.getpos('v')
+ local end_pos = vim.fn.getpos('.')
+ local start_line, start_col = start_pos[2], start_pos[3]
+ local end_line, end_col = end_pos[2], end_pos[3]
+
+ -- Ensure proper ordering (start should be before end)
+ if start_line > end_line or (start_line == end_line and start_col > end_col) then
+ start_line, end_line = end_line, start_line
+ start_col, end_col = end_col, start_col
+ end
+
+ local text = ''
+
+ if mode == 'v' then
+ -- Character-wise visual mode
+ if start_line == end_line then
+ local line = vim.api.nvim_buf_get_lines(0, start_line - 1, start_line, false)[1] or ''
+ text = line:sub(start_col, end_col)
+ else
+ local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
+ if #lines > 0 then
+ -- Handle first line
+ lines[1] = lines[1]:sub(start_col)
+ -- Handle last line
+ if #lines > 1 then
+ lines[#lines] = lines[#lines]:sub(1, end_col)
+ end
+ text = table.concat(lines, '\\n')
+ end
+ end
+ elseif mode == 'V' then
+ -- Line-wise visual mode
+ local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
+ text = table.concat(lines, '\\n')
+ elseif mode == '\\022' then
+ -- Block-wise visual mode (Ctrl-V)
+ local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
+ local result = {}
+ for _, line in ipairs(lines) do
+ table.insert(result, line:sub(start_col, end_col))
+ end
+ text = table.concat(result, '\\n')
+ end
+
+ return { text = text, mode = mode }
+ `) as { text: string; mode: string };
+
+ selectedText = result.text || '';
+ visualModeType = result.mode || visualModeType;
+ } catch (e) {
+ selectedText = '[Selection text unavailable]';
+ }
+
+ return {
+ hasSelection: true,
+ selectedText,
+ startPos: start,
+ endPos: end,
+ visualModeType
+ };
+ } else {
+ // Not in visual mode - get last visual selection marks
+ try {
+ const [lastStart, lastEnd] = await Promise.all([
+ nvim.call('getpos', ["'<"]) as Promise<[number, number, number, number]>,
+ nvim.call('getpos', ["'>"]) as Promise<[number, number, number, number]>
+ ]);
+
+ return {
+ hasSelection: false,
+ lastVisualStart: [lastStart[1], lastStart[2]],
+ lastVisualEnd: [lastEnd[1], lastEnd[2]]
+ };
+ } catch (e) {
+ return { hasSelection: false };
+ }
+ }
+ } catch (error) {
+ return { hasSelection: false, selectedText: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` };
+ }
+ }
+
public async getNeovimStatus(): Promise {
try {
const nvim = await this.connect();
@@ -280,10 +405,13 @@ export class NeovimManager {
pluginInfo = 'Plugin information unavailable';
}
+ // Get visual selection information using the new method
+ const visualInfo = await this.getVisualSelectionInfo(nvim, mode.mode);
+
const neovimStatus: NeovimStatus = {
cursorPosition: cursor,
mode: mode.mode,
- visualSelection: '',
+ visualSelection: visualInfo.selectedText || '',
fileName: await buffer.name,
windowLayout: JSON.stringify(layout),
currentTab,
@@ -291,77 +419,16 @@ export class NeovimManager {
registers,
cwd,
lspInfo,
- pluginInfo
- };
-
- if (mode.mode.startsWith('v') || mode.mode.startsWith('V')) {
- try {
- // Use a more reliable method to get the visual selection
- // This Lua code gets the actual selected text
- const visualText = await nvim.lua(`
- local mode = vim.fn.visualmode()
- if mode == '' then
- return ''
- end
-
- -- Save current register content
- local save_reg = vim.fn.getreg('"')
- local save_regtype = vim.fn.getregtype('"')
-
- -- Yank the visual selection to unnamed register
- vim.cmd('normal! "vy')
-
- -- Get the yanked text
- local selected_text = vim.fn.getreg('"')
-
- -- Restore the register
- vim.fn.setreg('"', save_reg, save_regtype)
-
- return selected_text
- `);
-
- neovimStatus.visualSelection = String(visualText || '');
- } catch (e) {
- // Fallback method using getpos and getline
- try {
- const start = await nvim.eval(`getpos("'<")`) as [number, number, number, number];
- const end = await nvim.eval(`getpos("'>")`) as [number, number, number, number];
-
- if (start[1] === end[1]) {
- // Single line selection
- const line = await nvim.eval(`getline(${start[1]})`) as string;
- const startCol = start[2] - 1; // Convert to 0-based
- const endCol = end[2]; // Keep 1-based for substring end
- neovimStatus.visualSelection = line.substring(startCol, endCol);
- } else {
- // Multi-line selection
- const lines = await nvim.eval(`getline(${start[1]}, ${end[1]})`) as string[];
- if (lines && lines.length > 0) {
- const result = [];
- const startCol = start[2] - 1;
- const endCol = end[2];
-
- // First line: from start column to end
- result.push(lines[0].substring(startCol));
-
- // Middle lines: complete lines
- for (let i = 1; i < lines.length - 1; i++) {
- result.push(lines[i]);
- }
-
- // Last line: from beginning to end column
- if (lines.length > 1) {
- result.push(lines[lines.length - 1].substring(0, endCol));
- }
-
- neovimStatus.visualSelection = result.join('\n');
- }
- }
- } catch (e2) {
- neovimStatus.visualSelection = '';
- }
+ pluginInfo,
+ visualInfo: {
+ hasActiveSelection: visualInfo.hasSelection,
+ visualModeType: visualInfo.visualModeType,
+ startPos: visualInfo.startPos,
+ endPos: visualInfo.endPos,
+ lastVisualStart: visualInfo.lastVisualStart,
+ lastVisualEnd: visualInfo.lastVisualEnd
}
- }
+ };
return neovimStatus;
} catch (error) {