Skip to content

Commit b6d3d44

Browse files
committed
feat: implement modular MCP tool system with JSON-RPC error handling and comprehensive test coverage
Change-Id: I5fabf430287944e2ff2d6df64d4a1bc75cc1503a Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 095bff6 commit b6d3d44

26 files changed

+1776
-715
lines changed

.luacheckrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
-- Set global variable names
44
globals = {
55
"vim",
6+
"expect",
7+
"assert_contains",
8+
"assert_not_contains",
9+
"spy", -- For luassert.spy and spy.any
610
}
711

812
-- Ignore warnings for unused self parameters

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Add the following to your plugins configuration:
6060
keys = {
6161
{ "<leader>a", nil, mode = { "n", "v" }, desc = "AI/Claude Code" },
6262
{ "<leader>ac", "<cmd>ClaudeCode<cr>", mode = { "n", "v" }, desc = "Toggle Claude Terminal" },
63-
{ "<leader>ak", "<cmd>ClaudeCodeSend<cr>", mode = { "n", "v" }, desc = "Send to Claude Code" },
63+
{ "<leader>ak", "<cmd>ClaudeCodeSend<cr>", mode = { "v" }, desc = "Send to Claude Code" },
6464
{ "<leader>ao", "<cmd>ClaudeCodeOpen<cr>", mode = { "n", "v" }, desc = "Open/Focus Claude Terminal" },
6565
{ "<leader>ax", "<cmd>ClaudeCodeClose<cr>", mode = { "n", "v" }, desc = "Close Claude Terminal" },
6666
},
@@ -131,7 +131,7 @@ return {
131131
keys = {
132132
{ "<leader>a", nil, mode = { "n", "v" }, desc = "AI/Claude Code" },
133133
{ "<leader>ac", "<cmd>ClaudeCode<cr>", mode = { "n", "v" }, desc = "Toggle Claude Terminal" },
134-
{ "<leader>ak", "<cmd>ClaudeCodeSend<cr>", mode = { "n", "v" }, desc = "Send to Claude Code" },
134+
{ "<leader>ak", "<cmd>ClaudeCodeSend<cr>", mode = { "v" }, desc = "Send to Claude Code" },
135135
{ "<leader>ao", "<cmd>ClaudeCodeOpen<cr>", mode = { "n", "v" }, desc = "Open/Focus Claude Terminal" },
136136
{ "<leader>ax", "<cmd>ClaudeCodeClose<cr>", mode = { "n", "v" }, desc = "Close Claude Terminal" },
137137
},
@@ -261,7 +261,7 @@ No default keymaps are provided. Add your own in your configuration:
261261

262262
```lua
263263
vim.keymap.set({"n", "v"}, "<leader>ac", "<cmd>ClaudeCode<cr>", { desc = "Toggle Claude Terminal" })
264-
vim.keymap.set({"n", "v"}, "<leader>ak", "<cmd>ClaudeCodeSend<cr>", { desc = "Send to Claude Code" })
264+
vim.keymap.set({"v"}, "<leader>ak", "<cmd>ClaudeCodeSend<cr>", { desc = "Send to Claude Code" })
265265

266266
-- Or more specific maps:
267267
vim.keymap.set({"n", "v"}, "<leader>ao", "<cmd>ClaudeCodeOpen<cr>", { desc = "Open/Focus Claude Terminal" })

lua/claudecode/diff.lua

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ function M._create_temp_file(content, filename)
8484
final_base_dir = base_dir_temp
8585
end
8686

87-
local session_id_base = vim.fn.fnamemodify(vim.fn.tempname(), ":t") .. "_" .. tostring(os.time()) .. "_" .. tostring(math.random(1000, 9999))
87+
local session_id_base = vim.fn.fnamemodify(vim.fn.tempname(), ":t")
88+
.. "_"
89+
.. tostring(os.time())
90+
.. "_"
91+
.. tostring(math.random(1000, 9999))
8892
local session_id = session_id_base:gsub("[^A-Za-z0-9_-]", "")
8993
if session_id == "" then -- Fallback if all characters were problematic, ensuring a directory can be made.
9094
session_id = "claudecode_session"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
--- Tool implementation for checking if a document is dirty.
2+
3+
--- Handles the checkDocumentDirty tool invocation.
4+
-- Checks if the specified file (buffer) has unsaved changes.
5+
-- @param params table The input parameters for the tool.
6+
-- @field params.filePath string Path to the file to check.
7+
-- @return table A table indicating if the document is dirty.
8+
-- @error table A table with code, message, and data for JSON-RPC error if failed.
9+
local function handler(params)
10+
if not params.filePath then
11+
error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" })
12+
end
13+
14+
local bufnr = vim.fn.bufnr(params.filePath)
15+
16+
if bufnr == -1 then
17+
-- It's debatable if this is an "error" or if it should return { isDirty = false }
18+
-- For now, treating as an operational error as the file isn't actively managed by a buffer.
19+
error({
20+
code = -32000,
21+
message = "File operation error",
22+
data = "File not open in editor: " .. params.filePath,
23+
})
24+
end
25+
26+
local is_dirty = vim.api.nvim_buf_get_option(bufnr, "modified")
27+
28+
return { isDirty = is_dirty }
29+
end
30+
31+
return {
32+
name = "checkDocumentDirty",
33+
schema = nil, -- Internal tool
34+
handler = handler,
35+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
--- Tool implementation for closing a buffer by its name.
2+
3+
local function handler(params)
4+
if not params.buffer_name then
5+
error({ code = -32602, message = "Invalid params", data = "Missing buffer_name parameter" })
6+
end
7+
8+
local bufnr = vim.fn.bufnr(params.buffer_name)
9+
10+
if bufnr == -1 then
11+
error({
12+
code = -32000,
13+
message = "Buffer operation error",
14+
data = "Buffer not found: " .. params.buffer_name,
15+
})
16+
end
17+
18+
local success, err = pcall(vim.api.nvim_buf_delete, bufnr, { force = false })
19+
20+
if not success then
21+
error({
22+
code = -32000,
23+
message = "Buffer operation error",
24+
data = "Failed to close buffer " .. params.buffer_name .. ": " .. tostring(err),
25+
})
26+
end
27+
28+
return { message = "Buffer closed: " .. params.buffer_name }
29+
end
30+
31+
return {
32+
name = "closeBufferByName",
33+
schema = nil, -- Internal tool
34+
handler = handler,
35+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
--- Tool implementation for getting the current selection.
2+
3+
local schema = {
4+
description = "Get the current text selection in the editor",
5+
inputSchema = {
6+
type = "object",
7+
additionalProperties = false,
8+
["$schema"] = "http://json-schema.org/draft-07/schema#",
9+
},
10+
}
11+
12+
--- Handles the getCurrentSelection tool invocation.
13+
-- Gets the current text selection in the editor.
14+
-- @param params table The input parameters for the tool (currently unused).
15+
-- @return table The selection data.
16+
-- @error table A table with code, message, and data for JSON-RPC error if failed.
17+
local function handler(_params) -- Prefix unused params with underscore
18+
local selection_module_ok, selection_module = pcall(require, "claudecode.selection")
19+
if not selection_module_ok then
20+
error({ code = -32000, message = "Internal server error", data = "Failed to load selection module" })
21+
end
22+
23+
local selection = selection_module.get_latest_selection()
24+
25+
if not selection then
26+
-- Consider if "no selection" is an error or a valid state returning empty/specific data.
27+
-- For now, returning an empty object or specific structure might be better than an error.
28+
-- Let's assume it's valid to have no selection and return a structure indicating that.
29+
return {
30+
text = "",
31+
filePath = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()),
32+
fileUrl = "file://" .. vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()),
33+
selection = {
34+
start = { line = 0, character = 0 },
35+
["end"] = { line = 0, character = 0 },
36+
isEmpty = true,
37+
},
38+
}
39+
end
40+
41+
return selection -- Directly return the selection data
42+
end
43+
44+
return {
45+
name = "getCurrentSelection",
46+
schema = schema,
47+
handler = handler,
48+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
--- Tool implementation for getting diagnostics.
2+
3+
--- Handles the getDiagnostics tool invocation.
4+
-- Retrieves diagnostics from Neovim's diagnostic system.
5+
-- @param _params table The input parameters for the tool (currently unused).
6+
-- @return table A table containing the list of diagnostics.
7+
-- @error table A table with code, message, and data for JSON-RPC error if failed.
8+
local function handler(_params) -- Prefix unused params with underscore
9+
if not vim.lsp or not vim.diagnostic or not vim.diagnostic.get then
10+
-- This tool is internal, so returning an error might be too strong.
11+
-- Returning an empty list or a specific status could be an alternative.
12+
-- For now, let's align with the error pattern for consistency if the feature is unavailable.
13+
error({
14+
code = -32000,
15+
message = "Feature unavailable",
16+
data = "LSP or vim.diagnostic.get not available in this Neovim version/configuration.",
17+
})
18+
end
19+
20+
local all_diagnostics = vim.diagnostic.get(0) -- Get for all buffers
21+
22+
local formatted_diagnostics = {}
23+
for _, diagnostic in ipairs(all_diagnostics) do
24+
local file_path = vim.api.nvim_buf_get_name(diagnostic.bufnr)
25+
-- Ensure we only include diagnostics with valid file paths
26+
if file_path and file_path ~= "" then
27+
table.insert(formatted_diagnostics, {
28+
file = file_path,
29+
line = diagnostic.lnum, -- 0-indexed from vim.diagnostic.get
30+
character = diagnostic.col, -- 0-indexed from vim.diagnostic.get
31+
severity = diagnostic.severity, -- e.g., vim.diagnostic.severity.ERROR
32+
message = diagnostic.message,
33+
source = diagnostic.source,
34+
})
35+
end
36+
end
37+
38+
return { diagnostics = formatted_diagnostics }
39+
end
40+
41+
return {
42+
name = "getDiagnostics",
43+
schema = nil, -- Internal tool
44+
handler = handler,
45+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
--- Tool implementation for getting a list of open editors.
2+
3+
local schema = {
4+
description = "Get list of currently open files",
5+
inputSchema = {
6+
type = "object",
7+
additionalProperties = false,
8+
["$schema"] = "http://json-schema.org/draft-07/schema#",
9+
},
10+
}
11+
12+
--- Handles the getOpenEditors tool invocation.
13+
-- Gets a list of currently open and listed files in Neovim.
14+
-- @param _params table The input parameters for the tool (currently unused).
15+
-- @return table A list of open editor information.
16+
local function handler(_params) -- Prefix unused params with underscore
17+
local editors = {}
18+
local buffers = vim.api.nvim_list_bufs()
19+
20+
for _, bufnr in ipairs(buffers) do
21+
-- Only include loaded, listed buffers with a file path
22+
if vim.api.nvim_buf_is_loaded(bufnr) and vim.fn.buflisted(bufnr) == 1 then
23+
local file_path = vim.api.nvim_buf_get_name(bufnr)
24+
25+
if file_path and file_path ~= "" then
26+
table.insert(editors, {
27+
filePath = file_path,
28+
fileUrl = "file://" .. file_path,
29+
isDirty = vim.api.nvim_buf_get_option(bufnr, "modified"),
30+
})
31+
end
32+
end
33+
end
34+
35+
-- The MCP spec for tools/list implies the result should be the direct data.
36+
-- The 'content' and 'isError' fields were an internal convention that is
37+
-- now handled by the main M.handle_invoke in tools/init.lua.
38+
return { editors = editors }
39+
end
40+
41+
return {
42+
name = "getOpenEditors",
43+
schema = schema,
44+
handler = handler,
45+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
--- Tool implementation for getting workspace folders.
2+
3+
--- Handles the getWorkspaceFolders tool invocation.
4+
-- Retrieves workspace folders, currently defaulting to CWD and attempting LSP integration.
5+
-- @param _params table The input parameters for the tool (currently unused).
6+
-- @return table A table containing the list of workspace folders.
7+
local function handler(_params) -- Prefix unused params with underscore
8+
local cwd = vim.fn.getcwd()
9+
10+
-- TODO: Enhance integration with LSP workspace folders if available,
11+
-- similar to how it's done in claudecode.lockfile.get_workspace_folders.
12+
-- For now, this is a simplified version as per the original tool's direct implementation.
13+
14+
local folders = {
15+
{
16+
name = vim.fn.fnamemodify(cwd, ":t"),
17+
uri = "file://" .. cwd,
18+
path = cwd,
19+
},
20+
}
21+
22+
-- A more complete version would replicate the logic from claudecode.lockfile:
23+
-- local lsp_folders = get_lsp_workspace_folders_logic_here()
24+
-- for _, folder_path in ipairs(lsp_folders) do
25+
-- local already_exists = false
26+
-- for _, existing_folder in ipairs(folders) do
27+
-- if existing_folder.path == folder_path then
28+
-- already_exists = true
29+
-- break
30+
-- end
31+
-- end
32+
-- if not already_exists then
33+
-- table.insert(folders, {
34+
-- name = vim.fn.fnamemodify(folder_path, ":t"),
35+
-- uri = "file://" .. folder_path,
36+
-- path = folder_path,
37+
-- })
38+
-- end
39+
-- end
40+
41+
return { workspaceFolders = folders }
42+
end
43+
44+
return {
45+
name = "getWorkspaceFolders",
46+
schema = nil, -- Internal tool
47+
handler = handler,
48+
}

0 commit comments

Comments
 (0)