From 8e6c79598f7567ffa67bddff90387cf6a82ffafc Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 14 Jan 2026 09:28:42 -0500 Subject: [PATCH 1/2] feat(mcp): add interactive MCP server picker with connection toggle Add new MCP picker UI that allows users to interactively view and toggle MCP server connections. Replace the static MCP server list view with a dynamic picker interface that supports connecting/disconnecting servers via API calls. Changes: - Add mcp_picker module with format, toggle, and selection logic - Add API client methods for list_mcp_servers, connect_mcp, disconnect_mcp - Refactor base_picker to support flexible multi-part item formatting with highlights - Add mcp_picker.toggle_connection keymap () to config This should fix #168 --- README.md | 3 + lua/opencode/api.lua | 40 +----- lua/opencode/api_client.lua | 31 +++++ lua/opencode/config.lua | 3 + lua/opencode/ui/base_picker.lua | 96 ++++++++++---- lua/opencode/ui/history_picker.lua | 2 +- lua/opencode/ui/mcp_picker.lua | 190 +++++++++++++++++++++++++++ lua/opencode/ui/question_picker.lua | 2 +- lua/opencode/ui/reference_picker.lua | 2 +- lua/opencode/ui/session_picker.lua | 2 +- lua/opencode/ui/timeline_picker.lua | 2 +- 11 files changed, 306 insertions(+), 67 deletions(-) create mode 100644 lua/opencode/ui/mcp_picker.lua 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) From 97867b3a5b258a80755347415207007e74c05f90 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 14 Jan 2026 09:36:49 -0500 Subject: [PATCH 2/2] test(mcp): remove irrelevant test --- tests/unit/api_spec.lua | 67 ----------------------------------------- 1 file changed, 67 deletions(-) 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')