Skip to content

Commit 49a68dc

Browse files
committed
feat: enhance MCP tools with VS Code compatibility and success fields
Change-Id: Iddb033256d1c8093e871f3a303a95e4df35ef9aa Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent bebc36a commit 49a68dc

File tree

10 files changed

+299
-26
lines changed

10 files changed

+299
-26
lines changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ The WebSocket server implements secure authentication using:
8080
- `saveDocument` - Saves document with detailed success/failure reporting
8181
- `getWorkspaceFolders` - Gets workspace folder information
8282
- `closeAllDiffTabs` - Closes all diff-related tabs and windows
83+
- `getDiagnostics` - Gets language diagnostics (errors, warnings) from the editor
8384

8485
**Internal Tools** (not exposed via MCP):
8586

@@ -108,7 +109,7 @@ The WebSocket server implements secure authentication using:
108109

109110
claudecode.nvim implements **100% feature parity** with Anthropic's official VS Code extension:
110111

111-
- **Identical Tool Set**: All 12 VS Code tools implemented
112+
- **Identical Tool Set**: All 10 VS Code tools implemented
112113
- **Compatible Formats**: Output structures match VS Code extension exactly
113114
- **Behavioral Consistency**: Same parameter handling and response patterns
114115
- **Error Compatibility**: Matching error codes and messages

lua/claudecode/tools/get_current_selection.lua

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ local schema = {
99
},
1010
}
1111

12+
--- Helper function to safely encode data as JSON with error handling.
13+
-- @param data table The data to encode as JSON.
14+
-- @param error_context string A description of what failed for error messages.
15+
-- @return string The JSON-encoded string.
16+
-- @error table A table with code, message, and data for JSON-RPC error if encoding fails.
17+
local function safe_json_encode(data, error_context)
18+
local ok, encoded = pcall(vim.json.encode, data, { indent = 2 })
19+
if not ok then
20+
error({
21+
code = -32000,
22+
message = "Internal server error",
23+
data = "Failed to encode " .. error_context .. ": " .. tostring(encoded),
24+
})
25+
end
26+
return encoded
27+
end
28+
1229
--- Handles the getCurrentSelection tool invocation.
1330
-- Gets the current text selection in the editor.
1431
-- @param params table The input parameters for the tool (currently unused).
@@ -23,13 +40,33 @@ local function handler(_params) -- Prefix unused params with underscore
2340
local selection = selection_module.get_latest_selection()
2441

2542
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.
43+
-- Check if there's an active editor/buffer
44+
local current_buf = vim.api.nvim_get_current_buf()
45+
local buf_name = vim.api.nvim_buf_get_name(current_buf)
46+
47+
if not buf_name or buf_name == "" then
48+
-- No active editor case - match VS Code format
49+
local no_editor_response = {
50+
success = false,
51+
message = "No active editor found",
52+
}
53+
54+
return {
55+
content = {
56+
{
57+
type = "text",
58+
text = safe_json_encode(no_editor_response, "no editor response"),
59+
},
60+
},
61+
}
62+
end
63+
64+
-- Valid buffer but no selection - return cursor position with success field
2965
local empty_selection = {
66+
success = true,
3067
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()),
68+
filePath = buf_name,
69+
fileUrl = "file://" .. buf_name,
3370
selection = {
3471
start = { line = 0, character = 0 },
3572
["end"] = { line = 0, character = 0 },
@@ -42,18 +79,21 @@ local function handler(_params) -- Prefix unused params with underscore
4279
content = {
4380
{
4481
type = "text",
45-
text = vim.json.encode(empty_selection, { indent = 2 }),
82+
text = safe_json_encode(empty_selection, "empty selection"),
4683
},
4784
},
4885
}
4986
end
5087

88+
-- Add success field to existing selection data
89+
local selection_with_success = vim.tbl_extend("force", selection, { success = true })
90+
5191
-- Return MCP-compliant format with JSON-stringified selection data
5292
return {
5393
content = {
5494
{
5595
type = "text",
56-
text = vim.json.encode(selection, { indent = 2 }),
96+
text = safe_json_encode(selection_with_success, "selection"),
5797
},
5898
},
5999
}

lua/claudecode/tools/get_open_editors.lua

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ local function handler(_params) -- Prefix unused params with underscore
1717
local tabs = {}
1818
local buffers = vim.api.nvim_list_bufs()
1919
local current_buf = vim.api.nvim_get_current_buf()
20+
local current_tabpage = vim.api.nvim_get_current_tabpage()
21+
22+
-- Get selection for active editor if available
23+
local active_selection = nil
24+
local selection_module_ok, selection_module = pcall(require, "claudecode.selection")
25+
if selection_module_ok then
26+
active_selection = selection_module.get_latest_selection()
27+
end
2028

2129
for _, bufnr in ipairs(buffers) do
2230
-- Only include loaded, listed buffers with a file path
@@ -25,21 +33,70 @@ local function handler(_params) -- Prefix unused params with underscore
2533

2634
if file_path and file_path ~= "" then
2735
-- Get the filename for the label
28-
local label = vim.fn.fnamemodify(file_path, ":t")
36+
local ok_label, label = pcall(vim.fn.fnamemodify, file_path, ":t")
37+
if not ok_label then
38+
label = file_path -- Fallback to full path
39+
end
2940

3041
-- Get language ID (filetype)
31-
local language_id = vim.api.nvim_buf_get_option(bufnr, "filetype")
32-
if language_id == "" then
42+
local ok_lang, language_id = pcall(vim.api.nvim_buf_get_option, bufnr, "filetype")
43+
if not ok_lang or language_id == nil or language_id == "" then
3344
language_id = "plaintext"
3445
end
3546

36-
table.insert(tabs, {
47+
-- Get line count
48+
local line_count = 0
49+
local ok_lines, lines_result = pcall(vim.api.nvim_buf_line_count, bufnr)
50+
if ok_lines then
51+
line_count = lines_result
52+
end
53+
54+
-- Check if untitled (no file path or special buffer)
55+
local is_untitled = (
56+
not file_path
57+
or file_path == ""
58+
or string.match(file_path, "^%s*$") ~= nil
59+
or string.match(file_path, "^term://") ~= nil
60+
or string.match(file_path, "^%[.*%]$") ~= nil
61+
)
62+
63+
-- Get tabpage info for this buffer
64+
-- For simplicity, use current tabpage as the "group" for all buffers
65+
-- In a more complex implementation, we could track which tabpage last showed each buffer
66+
local group_index = current_tabpage - 1 -- 0-based
67+
local view_column = current_tabpage -- 1-based
68+
local is_group_active = true -- Current tabpage is always active
69+
70+
-- Build tab object with all VS Code fields
71+
local tab = {
3772
uri = "file://" .. file_path,
3873
isActive = bufnr == current_buf,
74+
isPinned = false, -- Neovim doesn't have pinned tabs
75+
isPreview = false, -- Neovim doesn't have preview tabs
76+
isDirty = (function()
77+
local ok, modified = pcall(vim.api.nvim_buf_get_option, bufnr, "modified")
78+
return ok and modified or false
79+
end)(),
3980
label = label,
81+
groupIndex = group_index,
82+
viewColumn = view_column,
83+
isGroupActive = is_group_active,
84+
fileName = file_path,
4085
languageId = language_id,
41-
isDirty = vim.api.nvim_buf_get_option(bufnr, "modified"),
42-
})
86+
lineCount = line_count,
87+
isUntitled = is_untitled,
88+
}
89+
90+
-- Add selection info for active editor
91+
if bufnr == current_buf and active_selection and active_selection.selection then
92+
tab.selection = {
93+
start = active_selection.selection.start,
94+
["end"] = active_selection.selection["end"],
95+
isReversed = false, -- Neovim doesn't track reversed selections like VS Code
96+
}
97+
end
98+
99+
table.insert(tabs, tab)
43100
end
44101
end
45102
end

lua/claudecode/tools/save_document.lua

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ local schema = {
2424
-- @error table A table with code, message, and data for JSON-RPC error if failed.
2525
local function handler(params)
2626
if not params.filePath then
27-
error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" })
27+
error({
28+
code = -32602,
29+
message = "Invalid params",
30+
data = "Missing filePath parameter",
31+
})
2832
end
2933

3034
local bufnr = vim.fn.bufnr(params.filePath)

tests/busted_setup.lua

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,13 @@ _G.json_encode = function(data)
113113
if type(data) == "table" then
114114
local parts = {}
115115
local is_array = true
116-
local array_index = 1
117116

118-
-- Check if it's an array or object
117+
-- Check if it's an array (all numeric, positive keys) or an object
119118
for k, _ in pairs(data) do
120-
if type(k) ~= "number" or k ~= array_index then
119+
if type(k) ~= "number" or k <= 0 or math.floor(k) ~= k then
121120
is_array = false
122121
break
123122
end
124-
array_index = array_index + 1
125123
end
126124

127125
if is_array then

tests/mocks/vim.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,22 @@ local vim = {
564564
},
565565
},
566566

567+
-- Add tbl_extend function for compatibility
568+
tbl_extend = function(behavior, ...)
569+
local tables = { ... }
570+
local result = {}
571+
572+
for _, tbl in ipairs(tables) do
573+
for k, v in pairs(tbl) do
574+
if behavior == "force" or result[k] == nil then
575+
result[k] = v
576+
end
577+
end
578+
end
579+
580+
return result
581+
end,
582+
567583
notify = function(msg, level, opts)
568584
-- Store the last notification for test assertions
569585
vim._last_notify = {

tests/unit/tools/get_current_selection_spec.lua

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ describe("Tool: get_current_selection", function()
5757
expect(result.content[1].type).to_be("text")
5858

5959
local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text)
60+
expect(parsed_result.success).to_be_true() -- New success field
6061
expect(parsed_result.text).to_be("")
6162
expect(parsed_result.filePath).to_be("/current/file.lua")
6263
expect(parsed_result.selection.isEmpty).to_be_true()
@@ -88,10 +89,38 @@ describe("Tool: get_current_selection", function()
8889
expect(result.content[1].type).to_be("text")
8990

9091
local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text)
91-
assert.are.same(mock_sel_data, parsed_result) -- Should return the exact data as JSON
92+
-- Should return the selection data with success field added
93+
local expected_result = vim.tbl_extend("force", mock_sel_data, { success = true })
94+
assert.are.same(expected_result, parsed_result)
9295
assert.spy(mock_selection_module.get_latest_selection).was_called()
9396
end)
9497

98+
it("should return error format when no active editor is found", function()
99+
mock_selection_module.get_latest_selection = spy.new(function()
100+
return nil
101+
end)
102+
103+
-- Mock empty buffer name to simulate no active editor
104+
_G.vim.api.nvim_buf_get_name = spy.new(function()
105+
return ""
106+
end)
107+
108+
local success, result = pcall(get_current_selection_handler, {})
109+
expect(success).to_be_true()
110+
expect(result).to_be_table()
111+
expect(result.content).to_be_table()
112+
expect(result.content[1]).to_be_table()
113+
expect(result.content[1].type).to_be("text")
114+
115+
local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text)
116+
expect(parsed_result.success).to_be_false()
117+
expect(parsed_result.message).to_be("No active editor found")
118+
-- Should not have other fields when success is false
119+
expect(parsed_result.text).to_be_nil()
120+
expect(parsed_result.filePath).to_be_nil()
121+
expect(parsed_result.selection).to_be_nil()
122+
end)
123+
95124
it("should handle pcall failure when requiring selection module", function()
96125
-- Simulate require failing
97126
package.loaded["claudecode.selection"] = nil -- Ensure it's not cached

tests/unit/tools/get_latest_selection_spec.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe("Tool: get_latest_selection", function()
9494
expect(success).to_be_false()
9595
expect(err).to_be_table()
9696
expect(err.code).to_be(-32000)
97-
assert_contains(err.message, "Internal server error")
98-
assert_contains(err.data, "Failed to load selection module")
97+
expect(err.message).to_be("Internal server error")
98+
expect(err.data).to_be("Failed to load selection module")
9999
end)
100100
end)

0 commit comments

Comments
 (0)