diff --git a/LICENSE b/LICENSE index 91ce5bd..cc34c0e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Derek White +Copyright (c) 2024, 2025 Derek White Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 357b4ba..47ad4b6 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,11 @@ A proof of concept integration between Claude Desktop (or any client) and Neovim - Get the status of the VIM editor - Status contains cursor position, mode, filename, visual selection, window layout, current tab, marks, registers, and working directory - **vim_edit** - - Edit lines using insert or replace in the VIM editor - - Input `startLine` (number), `mode` (`"insert"` | `"replace"`), `lines` (string) - - insert will insert lines at startLine. replace will replace lines starting at the startLine to the end of the buffer + - Edit lines using insert, replace, or replaceAll in the VIM editor + - Input `startLine` (number), `mode` (`"insert"` | `"replace"` | `"replaceAll"`), `lines` (string) + - insert will insert lines at startLine + - replace will replace lines starting at startLine + - replaceAll will replace the entire buffer contents - **vim_window** - Manipulate Neovim windows (split, vsplit, close, navigate) - Input `command` (string: "split", "vsplit", "only", "close", "wincmd h/j/k/l") diff --git a/package-lock.json b/package-lock.json index 24064d2..7abbde4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcp-neovim-server", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-neovim-server", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", @@ -79,9 +79,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.6.1.tgz", - "integrity": "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz", + "integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -132,9 +132,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.23", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.23.tgz", - "integrity": "sha512-8PCGZ1ZJbEZuYNTMqywO+Sj4vSKjSjT6Ua+6RFOYlEvIvKQABPtrNkoVSLSKDb4obYcMhspVKmsw8Cm10NFRUg==", + "version": "20.17.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.25.tgz", + "integrity": "sha512-bT+r2haIlplJUYtlZrEanFHdPIZTeiMeh/fSOEbOOfWf9uTn+lg8g0KU6Q3iMgjd9FLuuMAgfCNSkjUbxL6E3Q==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -886,9 +886,9 @@ } }, "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1530,9 +1530,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.3.tgz", - "integrity": "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==", + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", "license": "ISC", "peerDependencies": { "zod": "^3.24.1" diff --git a/package.json b/package.json index d5dfa50..aa7d04b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-neovim-server", - "version": "0.3.2", + "version": "0.4.0", "description": "An MCP server for neovim", "type": "module", "bin": { diff --git a/src/index.ts b/src/index.ts index f57729d..8424cb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,379 +1,228 @@ #!/usr/bin/env node /** - * This is an MCP server that connect to neovim. + * This is an MCP server that connects to neovim. */ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema, - Tool -} from "@modelcontextprotocol/sdk/types.js"; import { NeovimManager } from "./neovim.js"; +import { z } from "zod"; -const server = new Server( +const server = new McpServer( { name: "mcp-neovim-server", - version: "0.3.1" - }, - { - capabilities: { - resources: {}, - tools: {} - }, + version: "0.4.0" } ); const neovimManager = NeovimManager.getInstance(); -server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { - resources: [ - { - uri: `nvim://session`, +// Register resources +server.resource( + "session", + new ResourceTemplate("nvim://session", { + list: () => ({ + resources: [{ + uri: "nvim://session", mimeType: "text/plain", name: "Current neovim session", - description: `Current neovim text editor session` - }, - { - uri: `nvim://buffers`, - mimeType: "application/json", - name: "Open Neovim buffers", - description: "List of all open buffers in the current Neovim session" - } - ] - }; -}); - - -server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - if (!request.params.uri.startsWith("nvim://")) { - throw new Error("Invalid resource URI"); - } - - const resourcePath = request.params.uri.substring(6); // Remove "nvim://" - - if (resourcePath === "session") { + description: "Current neovim text editor session" + }] + }) + }), + async (uri) => { const bufferContents = await neovimManager.getBufferContents(); return { contents: [{ - uri: request.params.uri, + uri: uri.href, mimeType: "text/plain", text: Array.from(bufferContents.entries()) .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) .join('\n') }] }; - } else if (resourcePath === "buffers") { + } +); + +server.resource( + "buffers", + new ResourceTemplate("nvim://buffers", { + list: () => ({ + resources: [{ + uri: "nvim://buffers", + mimeType: "application/json", + name: "Open Neovim buffers", + description: "List of all open buffers in the current Neovim session" + }] + }) + }), + async (uri) => { const openBuffers = await neovimManager.getOpenBuffers(); return { contents: [{ - uri: request.params.uri, + uri: uri.href, mimeType: "application/json", text: JSON.stringify(openBuffers, null, 2) }] }; } +); - throw new Error("Invalid resource path"); -}); - -const VIM_BUFFER: Tool = { - name: "vim_buffer", - description: "Current VIM text editor buffer with line numbers shown", - inputSchema: { - type: "object", - properties: { - filename: { - type: "string", - description: "File name to edit (can be empty, assume buffer is already open)" - } - }, - required: [] - } -}; - -const VIM_COMMAND: Tool = { - name: "vim_command", - description: "Send a command to VIM for navigation, spot editing, and line deletion. For shell commands like ls, use without the leading colon (e.g. '!ls' not ':!ls').", - inputSchema: { - type: "object", - properties: { - command: { - type: "string", - description: "Neovim command to enter for navigation and spot editing. For shell commands use without leading colon (e.g. '!ls'). Insert to return to NORMAL mode. It is possible to send multiple commands separated with ." - } - }, - required: ["command"] +// Register tools with proper parameter schemas +server.tool( + "vim_buffer", + { filename: z.string().optional().describe("Optional file name to view a specific buffer") }, + async ({ filename }) => { + const bufferContents = await neovimManager.getBufferContents(); + return { + content: [{ + type: "text", + text: Array.from(bufferContents.entries()) + .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) + .join('\n') + }] + }; } -}; +); -const VIM_STATUS: Tool = { - name: "vim_status", - description: "Get the status of the VIM editor", - inputSchema: { - type: "object", - properties: { - filename: { - type: "string", - description: "File name to get status for (can be empty, assume buffer is already open)" +server.tool( + "vim_command", + { command: z.string().describe("Vim command to execute (use ! prefix for shell commands if enabled)") }, + async ({ command }) => { + console.error(`Executing command: ${command}`); + + // Check if this is a shell command + if (command.startsWith('!')) { + const allowShellCommands = process.env.ALLOW_SHELL_COMMANDS === 'true'; + if (!allowShellCommands) { + return { + content: [{ + type: "text", + text: "Shell command execution is disabled. Set ALLOW_SHELL_COMMANDS=true environment variable to enable shell commands." + }] + }; } - }, - required: [] - } -}; + } -const VIM_EDIT: Tool = { - name: "vim_edit", - description: "Edit lines using insert or replace in the VIM editor.", - inputSchema: { - type: "object", - properties: { - startLine: { - type: "number", - description: "Line number to start editing" - }, - mode: { - type: "string", - enum: ["insert", "replace"], - description: "Mode for editing lines. insert will insert lines at startLine. replace will replace lines starting at the startLine to the end of the buffer." - }, - lines: { - type: "string", - description: "Lines of strings to insert or replace" - } - }, - required: ["startLine", "mode", "lines"] + const result = await neovimManager.sendCommand(command); + return { + content: [{ + type: "text", + text: result + }] + }; } -}; +); -const VIM_WINDOW: Tool = { - name: "vim_window", - description: "Manipulate Neovim windows (split, close, navigate)", - inputSchema: { - type: "object", - properties: { - command: { - type: "string", - description: "Window command (split, vsplit, only, close, wincmd h/j/k/l)", - enum: ["split", "vsplit", "only", "close", "wincmd h", "wincmd j", "wincmd k", "wincmd l"] - } - }, - required: ["command"] +server.tool( + "vim_status", + { filename: z.string().optional().describe("Optional file name to get status for a specific buffer") }, + async () => { + const status = await neovimManager.getNeovimStatus(); + return { + content: [{ + type: "text", + text: JSON.stringify(status) + }] + }; } -}; +); -const VIM_MARK: Tool = { - name: "vim_mark", - description: "Set a mark at a specific position", - inputSchema: { - type: "object", - properties: { - mark: { - type: "string", - description: "Mark name (a-z)", - pattern: "^[a-z]$" - }, - line: { - type: "number", - description: "Line number" - }, - column: { - type: "number", - description: "Column number" - } - }, - required: ["mark", "line", "column"] +server.tool( + "vim_edit", + { + startLine: z.number().describe("The line number where editing should begin (1-indexed)"), + mode: z.enum(["insert", "replace", "replaceAll"]).describe("Whether to insert new content, replace existing content, or replace entire buffer"), + lines: z.string().describe("The text content to insert or use as replacement") + }, + async ({ startLine, mode, lines }) => { + console.error(`Editing lines: ${startLine}, ${mode}, ${lines}`); + const result = await neovimManager.editLines(startLine, mode, lines); + return { + content: [{ + type: "text", + text: result + }] + }; } -}; +); -const VIM_REGISTER: Tool = { - name: "vim_register", - description: "Set content of a register", - inputSchema: { - type: "object", - properties: { - register: { - type: "string", - description: "Register name (a-z or \")", - pattern: "^[a-z\"]$" - }, - content: { - type: "string", - description: "Content to store in register" - } - }, - required: ["register", "content"] +server.tool( + "vim_window", + { + command: z.enum(["split", "vsplit", "only", "close", "wincmd h", "wincmd j", "wincmd k", "wincmd l"]) + .describe("Window manipulation command: split or vsplit to create new window, only to keep just current window, close to close current window, or wincmd with h/j/k/l to navigate between windows") + }, + async ({ command }) => { + const result = await neovimManager.manipulateWindow(command); + return { + content: [{ + type: "text", + text: result + }] + }; } -}; +); -const VIM_VISUAL: Tool = { - name: "vim_visual", - description: "Make a visual selection", - inputSchema: { - type: "object", - properties: { - startLine: { - type: "number", - description: "Starting line number" - }, - startColumn: { - type: "number", - description: "Starting column number" - }, - endLine: { - type: "number", - description: "Ending line number" - }, - endColumn: { - type: "number", - description: "Ending column number" - } - }, - required: ["startLine", "startColumn", "endLine", "endColumn"] +server.tool( + "vim_mark", + { + mark: z.string().regex(/^[a-z]$/).describe("Single lowercase letter [a-z] to use as the mark name"), + line: z.number().describe("The line number where the mark should be placed (1-indexed)"), + column: z.number().describe("The column number where the mark should be placed (0-indexed)") + }, + async ({ mark, line, column }) => { + const result = await neovimManager.setMark(mark, line, column); + return { + content: [{ + type: "text", + text: result + }] + }; } -}; - -const NEOVIM_TOOLS = [ - VIM_BUFFER, - VIM_COMMAND, - VIM_STATUS, - VIM_EDIT, - VIM_WINDOW, - VIM_MARK, - VIM_REGISTER, - VIM_VISUAL -] as const; - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: NEOVIM_TOOLS, -})); - -server.setRequestHandler(CallToolRequestSchema, async (request) => { - switch (request.params.name) { - case "vim_buffer": { - return await handleBuffer(); - } - case "vim_command": { - const command = (request.params.arguments as { command: string }).command; +); - return await handleCommand(command); - } - case "vim_status": { - return await handleStatus(); - } - case "vim_edit": { - const { startLine, mode, lines } = request.params.arguments as { startLine: number, mode: 'insert' | 'replace', lines: string }; - console.error(`Editing lines: ${startLine}, ${mode}, ${lines}`); - const result = await neovimManager.editLines(startLine, mode, lines); - return { - content: [{ - type: "text", - text: result - }] - }; - } - case "vim_window": { - const { command } = request.params.arguments as { command: string }; - const result = await neovimManager.manipulateWindow(command); - return { - content: [{ - type: "text", - text: result - }] - }; - } - case "vim_mark": { - const { mark, line, column } = request.params.arguments as { mark: string; line: number; column: number }; - const result = await neovimManager.setMark(mark, line, column); - return { - content: [{ - type: "text", - text: result - }] - }; - } - case "vim_register": { - const { register, content } = request.params.arguments as { register: string; content: string }; - const result = await neovimManager.setRegister(register, content); - return { - content: [{ - type: "text", - text: result - }] - }; - } - case "vim_visual": { - const { startLine, startColumn, endLine, endColumn } = request.params.arguments as { - startLine: number; - startColumn: number; - endLine: number; - endColumn: number; - }; - const result = await neovimManager.visualSelect(startLine, startColumn, endLine, endColumn); - return { - content: [{ - type: "text", - text: result - }] - }; - } - default: - throw new Error("Unknown tool"); +server.tool( + "vim_register", + { + register: z.string().regex(/^[a-z\"]$/).describe("Register name - a lowercase letter [a-z] or double-quote [\"] for the unnamed register"), + content: z.string().describe("The text content to store in the specified register") + }, + async ({ register, content }) => { + const result = await neovimManager.setRegister(register, content); + return { + content: [{ + type: "text", + text: result + }] + }; } -}); +); -async function handleCommand(command: string) { - console.error(`Executing command: ${command}`); - - // Check if this is a shell command - if (command.startsWith('!')) { - const allowShellCommands = process.env.ALLOW_SHELL_COMMANDS === 'true'; - if (!allowShellCommands) { - return { - content: [{ - type: "text", - text: "Shell command execution is disabled. Set ALLOW_SHELL_COMMANDS=true environment variable to enable shell commands." - }] - }; - } +server.tool( + "vim_visual", + { + startLine: z.number().describe("The starting line number for visual selection (1-indexed)"), + startColumn: z.number().describe("The starting column number for visual selection (0-indexed)"), + endLine: z.number().describe("The ending line number for visual selection (1-indexed)"), + endColumn: z.number().describe("The ending column number for visual selection (0-indexed)") + }, + async ({ startLine, startColumn, endLine, endColumn }) => { + const result = await neovimManager.visualSelect(startLine, startColumn, endLine, endColumn); + return { + content: [{ + type: "text", + text: result + }] + }; } +); - const result = await neovimManager.sendCommand(command); - return { - content: [{ - type: "text", - text: result - }] - }; -} - -async function handleBuffer() { - const bufferContents = await neovimManager.getBufferContents(); - - return { - content: [{ - type: "text", - text: Array.from(bufferContents.entries()) - .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) - .join('\n') - }] - }; -} - -async function handleStatus() { - const status = await neovimManager.getNeovimStatus(); - return { - content: [{ - type: "text", - text: JSON.stringify(status) - }] - }; -} +// Register an empty prompts list since we don't support any prompts. Clients still ask. +server.prompt("empty", {}, () => ({ + messages: [] +})); /** * Start the server using stdio transport. diff --git a/src/neovim.ts b/src/neovim.ts index 45761ad..10222b6 100644 --- a/src/neovim.ts +++ b/src/neovim.ts @@ -186,13 +186,20 @@ export class NeovimManager { } } - public async editLines(startLine: number, mode: 'replace' | 'insert', newText: string): Promise { + public async editLines(startLine: number, mode: 'replace' | 'insert' | 'replaceAll', newText: string): Promise { try { const nvim = await this.connect(); const splitByLines = newText.split('\n'); const buffer = await nvim.buffer; - if (mode === 'replace') { + if (mode === 'replaceAll') { + // Handle full buffer replacement + const lineCount = await buffer.length; + // Delete all lines and then append new content + await buffer.remove(0, lineCount, true); + await buffer.insert(splitByLines, 0); + return 'Buffer completely replaced'; + } else if (mode === 'replace') { await buffer.replace(splitByLines, startLine - 1); return 'Lines replaced successfully'; } else if (mode === 'insert') {