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! - mcp-neovim-server MCP server ## 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) {