diff --git a/README.md b/README.md index e977fc3a..b62a7b33 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,9 @@ require('opencode').setup({ model_picker = { toggle_favorite = { '', mode = { 'i', 'n' } }, }, + mcp_picker = { + toggle_connection = { '', mode = { 'i', 'n' } }, -- Toggle MCP server connection in the MCP picker + }, }, ui = { position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output. diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 73fe0ab7..c85947de 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -518,44 +518,8 @@ function M.help() end M.mcp = Promise.async(function() - local mcp = config_file.get_mcp_servers():await() - if not mcp then - vim.notify('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN) - return - end - - state.display_route = '/mcp' - M.open_input() - - local msg = M.with_header({ - '### Available MCP servers', - '', - '| Name | Type | cmd/url |', - '|--------|------|---------|', - }) - - for name, def in pairs(mcp) do - local cmd_or_url - if def.type == 'local' then - cmd_or_url = def.command and table.concat(def.command, ' ') - elseif def.type == 'remote' then - cmd_or_url = def.url - end - - table.insert( - msg, - string.format( - '| %s %-10s | %s | %s |', - (def.enabled and icons.get('status_on') or icons.get('status_off')), - name, - def.type, - cmd_or_url - ) - ) - end - - table.insert(msg, '') - ui.render_lines(msg) + local mcp_picker = require('opencode.ui.mcp_picker') + mcp_picker.pick() end) M.commands_list = Promise.async(function() diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 4f384ee0..b0aa156f 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -460,6 +460,37 @@ function OpencodeApiClient:list_tools(provider, model, directory) }) end +-- MCP endpoints + +--- List all MCP servers +--- @param directory string|nil Directory path +--- @return Promise> +function OpencodeApiClient:list_mcp_servers(directory) + return self:_call('/mcp', 'GET', nil, { directory = directory }) +end + +--- Connect an MCP server +--- @param name string MCP server name (required) +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:connect_mcp(name, directory) + if not name or name == '' then + return require('opencode.promise').new():reject('MCP server name is required') + end + return self:_call('/mcp/' .. name .. '/connect', 'POST', nil, { directory = directory }) +end + +--- Disconnect an MCP server +--- @param name string MCP server name (required) +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:disconnect_mcp(name, directory) + if not name or name == '' then + return require('opencode.promise').new():reject('MCP server name is required') + end + return self:_call('/mcp/' .. name .. '/disconnect', 'POST', nil, { directory = directory }) +end + --- Create a factory function for the module --- @param base_url? string The base URL of the opencode server --- @return OpencodeApiClient diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index f85dcbee..43a22eaa 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -101,6 +101,9 @@ M.defaults = { model_picker = { toggle_favorite = { '', mode = { 'i', 'n' } }, }, + mcp_picker = { + toggle_connection = { '', mode = { 'i', 'n' } }, + }, quick_chat = { cancel = { '', mode = { 'i', 'n' } }, }, diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 152e2964..7eb4c43d 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -43,10 +43,12 @@ local Promise = require('opencode.promise') ---@class MiniPickSelected ---@field current MiniPickItem? +---@class PickerItemPart +---@field text string The text content +---@field highlight? string Optional highlight group + ---@class PickerItem ----@field content string Main content text ----@field time_text? string Optional time text ----@field debug_text? string Optional debug text +---@field parts PickerItemPart[] Array of text parts with optional highlights ---@field to_string fun(self: PickerItem): string ---@field to_formatted_text fun(self: PickerItem): table @@ -80,10 +82,18 @@ local function telescope_ui(opts) local action_state = require('telescope.actions.state') local action_utils = require('telescope.actions.utils') local entry_display = require('telescope.pickers.entry_display') - local displayer = entry_display.create({ - separator = ' ', - items = { {}, {}, config.debug.show_ids and {} or nil }, - }) + + -- Create displayer dynamically based on number of parts + local function create_displayer(picker_item) + local items = {} + for _ in ipairs(picker_item.parts) do + table.insert(items, {}) + end + return entry_display.create({ + separator = ' ', + items = items, + }) + end local current_picker @@ -91,13 +101,16 @@ local function telescope_ui(opts) ---@param item any ---@return TelescopeEntry local function make_entry(item) + local picker_item = opts.format_fn(item) + local displayer = create_displayer(picker_item) + local entry = { value = item, display = function(entry) local formatted = opts.format_fn(entry.value):to_formatted_text() return displayer(formatted) end, - ordinal = opts.format_fn(item):to_string(), + ordinal = picker_item:to_string(), } if type(item) == 'table' then @@ -549,35 +562,70 @@ function M.align(text, width, opts) end ---Creates a generic picker item that can format itself for different pickers ----@param text string Array of text parts to join ----@param time? number Optional time text to highlight +---@param parts PickerItemPart[] Array of text parts with optional highlights +---@return PickerItem +function M.create_picker_item(parts) + local item = { + parts = parts, + } + + function item:to_string() + local texts = {} + for _, part in ipairs(self.parts) do + table.insert(texts, part.text) + end + return table.concat(texts, ' ') + end + + function item:to_formatted_text() + local formatted = {} + for _, part in ipairs(self.parts) do + if part.highlight then + table.insert(formatted, { ' ' .. part.text, part.highlight }) + else + table.insert(formatted, { part.text }) + end + end + return formatted + end + + return item +end + +---Helper function to create a simple picker item with content, time, and debug text +---This is a convenience wrapper around create_picker_item for common use cases +---@param text string Main content text +---@param time? number Optional time to format ---@param debug_text? string Optional debug text to append ---@param width? number Optional width override ---@return PickerItem -function M.create_picker_item(text, time, debug_text, width) +function M.create_time_picker_item(text, time, debug_text, width) local time_width = time and #util.format_time(time) + 1 or 0 local debug_width = config.debug.show_ids and debug_text and #debug_text + 1 or 0 local item_width = width or vim.api.nvim_win_get_width(0) local text_width = item_width - (debug_width + time_width) - local item = { - content = M.align(text, text_width --[[@as integer]], { truncate = true }), - time_text = time and M.align(util.format_time(time), time_width, { align = 'right' }), - debug_text = config.debug.show_ids and debug_text or nil, + + local parts = { + { + text = M.align(text, text_width --[[@as integer]], { truncate = true }), + }, } - function item:to_string() - return table.concat({ self.content, self.time_text or '', self.debug_text or '' }, ' ') + if time then + table.insert(parts, { + text = M.align(util.format_time(time), time_width, { align = 'right' }), + highlight = 'OpencodePickerTime', + }) end - function item:to_formatted_text() - return { - { self.content }, - self.time_text and { ' ' .. self.time_text, 'OpencodePickerTime' } or { '' }, - self.debug_text and { ' ' .. self.debug_text, 'OpencodeDebugText' } or { '' }, - } + if config.debug.show_ids and debug_text then + table.insert(parts, { + text = debug_text, + highlight = 'OpencodeDebugText', + }) end - return item + return M.create_picker_item(parts) end ---Generic picker that abstracts common logic for different picker UIs diff --git a/lua/opencode/ui/history_picker.lua b/lua/opencode/ui/history_picker.lua index d706cde2..b3e3e1c5 100644 --- a/lua/opencode/ui/history_picker.lua +++ b/lua/opencode/ui/history_picker.lua @@ -11,7 +11,7 @@ local history = require('opencode.history') local function format_history_item(item, width) local entry = item.content or item.text or '' - return base_picker.create_picker_item(entry:gsub('\n', '↵'), nil, 'ID: ' .. item.id, width) + return base_picker.create_time_picker_item(entry:gsub('\n', '↵'), nil, 'ID: ' .. item.id, width) end function M.pick(callback) diff --git a/lua/opencode/ui/mcp_picker.lua b/lua/opencode/ui/mcp_picker.lua new file mode 100644 index 00000000..fe69dbb9 --- /dev/null +++ b/lua/opencode/ui/mcp_picker.lua @@ -0,0 +1,190 @@ +local M = {} +local base_picker = require('opencode.ui.base_picker') +local icons = require('opencode.ui.icons') +local Promise = require('opencode.promise') + +---Format MCP server item for picker +---@param mcp_item table MCP server definition +---@param width number Window width +---@return PickerItem +local function format_mcp_item(mcp_item, width) + local is_connected = mcp_item.status == 'connected' + local is_failed = mcp_item.status == 'failed' + local status_icon = is_connected and icons.get('status_on') or icons.get('status_off') + + local item_width = width or vim.api.nvim_win_get_width(0) + local icon_width = vim.api.nvim_strwidth(status_icon) + 1 + local content_width = item_width - icon_width --[[@as number]] + + local icon_highlight + local name_highlight + + if is_connected then + icon_highlight = 'OpencodeContextSwitchOn' + name_highlight = 'OpencodeContextSwitchOn' + elseif is_failed then + icon_highlight = 'OpencodeContextError' + name_highlight = 'OpencodeContextError' + else + icon_highlight = 'OpencodeHint' + name_highlight = 'OpencodeHint' + end + + return base_picker.create_picker_item({ + { text = base_picker.align(mcp_item.name, content_width, { truncate = true }), highlight = name_highlight }, + { text = base_picker.align(status_icon, icon_width, { align = 'right' }), highlight = icon_highlight }, + }) +end + +---Show MCP servers picker with connect/disconnect actions +---@param callback function? +function M.pick(callback) + local state = require('opencode.state') + local config = require('opencode.config') + + local get_mcp_servers = Promise.async(function() + local ok, mcp_list = pcall(function() + return state.api_client:list_mcp_servers():await() + end) + + if not ok then + vim.notify('Failed to fetch MCP servers: ' .. tostring(mcp_list), vim.log.levels.ERROR) + return {} + end + + if not mcp_list then + return {} + end + + local items = {} + for name, def in pairs(mcp_list) do + table.insert(items, { + name = name, + type = def.type, + enabled = def.enabled, + status = def.status or 'disconnected', + error = def.error, + command = def.command, + url = def.url, + }) + end + + table.sort(items, function(a, b) + local status_priority = { + connected = 1, + failed = 2, + disabled = 3, + disconnected = 4, + } + local a_priority = status_priority[a.status] or 5 + local b_priority = status_priority[b.status] or 5 + + if a_priority ~= b_priority then + return a_priority < b_priority + end + return a.name < b.name + end) + + return items + end) + + local initial_items = get_mcp_servers():await() + + if #initial_items == 0 then + vim.notify('No MCP servers configured', vim.log.levels.WARN) + return + end + + -- Shared toggle connection logic + local toggle_mcp_connection = Promise.async(function(selected) + if not selected then + return nil + end + + local is_connected = selected.status == 'connected' + local action = is_connected and 'disconnect' or 'connect' + + vim.notify( + string.format('%s MCP server: %s...', is_connected and 'Disconnecting' or 'Connecting', selected.name), + vim.log.levels.INFO + ) + + if is_connected then + state.api_client:disconnect_mcp(selected.name):await() + else + state.api_client:connect_mcp(selected.name):await() + end + + local updated_servers = get_mcp_servers():await() + local updated_server = vim.tbl_filter(function(s) + return s.name == selected.name + end, updated_servers)[1] + + if updated_server then + local actual_status = updated_server.status + local succeeded = (action == 'disconnect' and actual_status ~= 'connected') + or (action == 'connect' and actual_status == 'connected') + + if succeeded then + vim.notify( + string.format( + 'Successfully %s MCP server: %s', + action == 'connect' and 'connected' or 'disconnected', + updated_server.name + ), + vim.log.levels.INFO + ) + else + vim.notify( + string.format( + 'Failed to %s MCP server: %s%s', + action, + updated_server.name, + updated_server.error and (' > ' .. updated_server.error) or '' + ), + vim.log.levels.ERROR + ) + end + end + + return updated_servers + end) + + local actions = { + toggle_connection = { + key = config.keymap.mcp_picker.toggle_connection, + label = 'toggle connection', + fn = Promise.async(function(selected, opts) + return toggle_mcp_connection(selected):await() + end), + reload = true, + }, + } + + local default_callback = function(selected) + if not selected then + if callback then + callback(nil) + end + return + end + + Promise.async(function() + toggle_mcp_connection(selected):await() + if callback then + callback(selected) + end + end)() + end + + return base_picker.pick({ + items = initial_items, + format_fn = format_mcp_item, + actions = actions, + callback = default_callback, + title = 'MCP Servers', + width = 65, + }) +end + +return M diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua index fc1821f9..fdfaafe9 100644 --- a/lua/opencode/ui/question_picker.lua +++ b/lua/opencode/ui/question_picker.lua @@ -17,7 +17,7 @@ local function format_option(item, width) if item.description and item.description ~= '' then text = text .. ' - ' .. item.description end - return base_picker.create_picker_item(text, nil, nil, width) + return base_picker.create_time_picker_item(text, nil, nil, width) end --- Show a question picker for the user to answer diff --git a/lua/opencode/ui/reference_picker.lua b/lua/opencode/ui/reference_picker.lua index 58c5f854..575a4e93 100644 --- a/lua/opencode/ui/reference_picker.lua +++ b/lua/opencode/ui/reference_picker.lua @@ -197,7 +197,7 @@ local function format_reference_item(ref, width) local display_text = icon .. ' ' .. location - return base_picker.create_picker_item(display_text, nil, nil, width) + return base_picker.create_time_picker_item(display_text, nil, nil, width) end ---Open the reference picker diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 817cbc6f..0ad0d279 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -11,7 +11,7 @@ local Promise = require('opencode.promise') function format_session_item(session, width) local debug_text = 'ID: ' .. (session.id or 'N/A') local updated_time = (session.time and session.time.updated) or 'N/A' - return base_picker.create_picker_item(session.title, updated_time, debug_text, width) + return base_picker.create_time_picker_item(session.title, updated_time, debug_text, width) end function M.pick(sessions, callback) diff --git a/lua/opencode/ui/timeline_picker.lua b/lua/opencode/ui/timeline_picker.lua index 4fff3f0f..7caba0d6 100644 --- a/lua/opencode/ui/timeline_picker.lua +++ b/lua/opencode/ui/timeline_picker.lua @@ -11,7 +11,7 @@ function format_message_item(msg, width) local debug_text = 'ID: ' .. (msg.info.id or 'N/A') - return base_picker.create_picker_item(vim.trim(preview), msg.info.time.created, debug_text, width) + return base_picker.create_time_picker_item(vim.trim(preview), msg.info.time.created, debug_text, width) end function M.pick(messages, callback) diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 5115d2ee..e60e6e63 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -227,73 +227,6 @@ describe('opencode.api', function() end) end) - describe('/mcp command', function() - it('displays MCP server configuration when available', function() - local config_file = require('opencode.config_file') - local Promise = require('opencode.promise') - local original_get_mcp_servers = config_file.get_mcp_servers - - config_file.get_mcp_servers = function() - local p = Promise.new() - p:resolve({ - filesystem = { - type = 'local', - enabled = true, - command = { 'npx', '-y', '@modelcontextprotocol/server-filesystem' }, - }, - github = { - type = 'remote', - enabled = false, - url = 'https://example.com/mcp', - }, - }) - return p - end - - stub(ui, 'render_lines') - stub(api, 'open_input') - - api.mcp():wait() - - assert.stub(api.open_input).was_called() - assert.stub(ui.render_lines).was_called() - - local render_args = ui.render_lines.calls[1].refs[1] - local rendered_text = table.concat(render_args, '\n') - - assert.truthy(rendered_text:match('Available MCP servers')) - assert.truthy(rendered_text:match('filesystem')) - assert.truthy(rendered_text:match('github')) - assert.truthy(rendered_text:match('local')) - assert.truthy(rendered_text:match('remote')) - - config_file.get_mcp_servers = original_get_mcp_servers - end) - - it('shows warning when no MCP configuration exists', function() - local config_file = require('opencode.config_file') - local Promise = require('opencode.promise') - local original_get_mcp_servers = config_file.get_mcp_servers - - config_file.get_mcp_servers = function() - local p = Promise.new() - p:resolve(nil) - return p - end - - local notify_stub = stub(vim, 'notify') - - api.mcp():wait() - - assert - .stub(notify_stub) - .was_called_with('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN) - - config_file.get_mcp_servers = original_get_mcp_servers - notify_stub:revert() - end) - end) - describe('/commands command', function() it('displays user commands when available', function() local config_file = require('opencode.config_file')