Skip to content

Commit 8e6c795

Browse files
committed
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 (<C-t>) to config This should fix #168
1 parent a8e53d5 commit 8e6c795

File tree

11 files changed

+306
-67
lines changed

11 files changed

+306
-67
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ require('opencode').setup({
195195
model_picker = {
196196
toggle_favorite = { '<C-f>', mode = { 'i', 'n' } },
197197
},
198+
mcp_picker = {
199+
toggle_connection = { '<C-t>', mode = { 'i', 'n' } }, -- Toggle MCP server connection in the MCP picker
200+
},
198201
},
199202
ui = {
200203
position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output.

lua/opencode/api.lua

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -518,44 +518,8 @@ function M.help()
518518
end
519519

520520
M.mcp = Promise.async(function()
521-
local mcp = config_file.get_mcp_servers():await()
522-
if not mcp then
523-
vim.notify('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN)
524-
return
525-
end
526-
527-
state.display_route = '/mcp'
528-
M.open_input()
529-
530-
local msg = M.with_header({
531-
'### Available MCP servers',
532-
'',
533-
'| Name | Type | cmd/url |',
534-
'|--------|------|---------|',
535-
})
536-
537-
for name, def in pairs(mcp) do
538-
local cmd_or_url
539-
if def.type == 'local' then
540-
cmd_or_url = def.command and table.concat(def.command, ' ')
541-
elseif def.type == 'remote' then
542-
cmd_or_url = def.url
543-
end
544-
545-
table.insert(
546-
msg,
547-
string.format(
548-
'| %s %-10s | %s | %s |',
549-
(def.enabled and icons.get('status_on') or icons.get('status_off')),
550-
name,
551-
def.type,
552-
cmd_or_url
553-
)
554-
)
555-
end
556-
557-
table.insert(msg, '')
558-
ui.render_lines(msg)
521+
local mcp_picker = require('opencode.ui.mcp_picker')
522+
mcp_picker.pick()
559523
end)
560524

561525
M.commands_list = Promise.async(function()

lua/opencode/api_client.lua

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,37 @@ function OpencodeApiClient:list_tools(provider, model, directory)
460460
})
461461
end
462462

463+
-- MCP endpoints
464+
465+
--- List all MCP servers
466+
--- @param directory string|nil Directory path
467+
--- @return Promise<table<string, table>>
468+
function OpencodeApiClient:list_mcp_servers(directory)
469+
return self:_call('/mcp', 'GET', nil, { directory = directory })
470+
end
471+
472+
--- Connect an MCP server
473+
--- @param name string MCP server name (required)
474+
--- @param directory string|nil Directory path
475+
--- @return Promise<boolean>
476+
function OpencodeApiClient:connect_mcp(name, directory)
477+
if not name or name == '' then
478+
return require('opencode.promise').new():reject('MCP server name is required')
479+
end
480+
return self:_call('/mcp/' .. name .. '/connect', 'POST', nil, { directory = directory })
481+
end
482+
483+
--- Disconnect an MCP server
484+
--- @param name string MCP server name (required)
485+
--- @param directory string|nil Directory path
486+
--- @return Promise<boolean>
487+
function OpencodeApiClient:disconnect_mcp(name, directory)
488+
if not name or name == '' then
489+
return require('opencode.promise').new():reject('MCP server name is required')
490+
end
491+
return self:_call('/mcp/' .. name .. '/disconnect', 'POST', nil, { directory = directory })
492+
end
493+
463494
--- Create a factory function for the module
464495
--- @param base_url? string The base URL of the opencode server
465496
--- @return OpencodeApiClient

lua/opencode/config.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ M.defaults = {
101101
model_picker = {
102102
toggle_favorite = { '<C-f>', mode = { 'i', 'n' } },
103103
},
104+
mcp_picker = {
105+
toggle_connection = { '<C-t>', mode = { 'i', 'n' } },
106+
},
104107
quick_chat = {
105108
cancel = { '<C-c>', mode = { 'i', 'n' } },
106109
},

lua/opencode/ui/base_picker.lua

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ local Promise = require('opencode.promise')
4343
---@class MiniPickSelected
4444
---@field current MiniPickItem?
4545

46+
---@class PickerItemPart
47+
---@field text string The text content
48+
---@field highlight? string Optional highlight group
49+
4650
---@class PickerItem
47-
---@field content string Main content text
48-
---@field time_text? string Optional time text
49-
---@field debug_text? string Optional debug text
51+
---@field parts PickerItemPart[] Array of text parts with optional highlights
5052
---@field to_string fun(self: PickerItem): string
5153
---@field to_formatted_text fun(self: PickerItem): table
5254

@@ -80,24 +82,35 @@ local function telescope_ui(opts)
8082
local action_state = require('telescope.actions.state')
8183
local action_utils = require('telescope.actions.utils')
8284
local entry_display = require('telescope.pickers.entry_display')
83-
local displayer = entry_display.create({
84-
separator = ' ',
85-
items = { {}, {}, config.debug.show_ids and {} or nil },
86-
})
85+
86+
-- Create displayer dynamically based on number of parts
87+
local function create_displayer(picker_item)
88+
local items = {}
89+
for _ in ipairs(picker_item.parts) do
90+
table.insert(items, {})
91+
end
92+
return entry_display.create({
93+
separator = ' ',
94+
items = items,
95+
})
96+
end
8797

8898
local current_picker
8999

90100
---Creates entry maker function for telescope
91101
---@param item any
92102
---@return TelescopeEntry
93103
local function make_entry(item)
104+
local picker_item = opts.format_fn(item)
105+
local displayer = create_displayer(picker_item)
106+
94107
local entry = {
95108
value = item,
96109
display = function(entry)
97110
local formatted = opts.format_fn(entry.value):to_formatted_text()
98111
return displayer(formatted)
99112
end,
100-
ordinal = opts.format_fn(item):to_string(),
113+
ordinal = picker_item:to_string(),
101114
}
102115

103116
if type(item) == 'table' then
@@ -549,35 +562,70 @@ function M.align(text, width, opts)
549562
end
550563

551564
---Creates a generic picker item that can format itself for different pickers
552-
---@param text string Array of text parts to join
553-
---@param time? number Optional time text to highlight
565+
---@param parts PickerItemPart[] Array of text parts with optional highlights
566+
---@return PickerItem
567+
function M.create_picker_item(parts)
568+
local item = {
569+
parts = parts,
570+
}
571+
572+
function item:to_string()
573+
local texts = {}
574+
for _, part in ipairs(self.parts) do
575+
table.insert(texts, part.text)
576+
end
577+
return table.concat(texts, ' ')
578+
end
579+
580+
function item:to_formatted_text()
581+
local formatted = {}
582+
for _, part in ipairs(self.parts) do
583+
if part.highlight then
584+
table.insert(formatted, { ' ' .. part.text, part.highlight })
585+
else
586+
table.insert(formatted, { part.text })
587+
end
588+
end
589+
return formatted
590+
end
591+
592+
return item
593+
end
594+
595+
---Helper function to create a simple picker item with content, time, and debug text
596+
---This is a convenience wrapper around create_picker_item for common use cases
597+
---@param text string Main content text
598+
---@param time? number Optional time to format
554599
---@param debug_text? string Optional debug text to append
555600
---@param width? number Optional width override
556601
---@return PickerItem
557-
function M.create_picker_item(text, time, debug_text, width)
602+
function M.create_time_picker_item(text, time, debug_text, width)
558603
local time_width = time and #util.format_time(time) + 1 or 0
559604
local debug_width = config.debug.show_ids and debug_text and #debug_text + 1 or 0
560605
local item_width = width or vim.api.nvim_win_get_width(0)
561606
local text_width = item_width - (debug_width + time_width)
562-
local item = {
563-
content = M.align(text, text_width --[[@as integer]], { truncate = true }),
564-
time_text = time and M.align(util.format_time(time), time_width, { align = 'right' }),
565-
debug_text = config.debug.show_ids and debug_text or nil,
607+
608+
local parts = {
609+
{
610+
text = M.align(text, text_width --[[@as integer]], { truncate = true }),
611+
},
566612
}
567613

568-
function item:to_string()
569-
return table.concat({ self.content, self.time_text or '', self.debug_text or '' }, ' ')
614+
if time then
615+
table.insert(parts, {
616+
text = M.align(util.format_time(time), time_width, { align = 'right' }),
617+
highlight = 'OpencodePickerTime',
618+
})
570619
end
571620

572-
function item:to_formatted_text()
573-
return {
574-
{ self.content },
575-
self.time_text and { ' ' .. self.time_text, 'OpencodePickerTime' } or { '' },
576-
self.debug_text and { ' ' .. self.debug_text, 'OpencodeDebugText' } or { '' },
577-
}
621+
if config.debug.show_ids and debug_text then
622+
table.insert(parts, {
623+
text = debug_text,
624+
highlight = 'OpencodeDebugText',
625+
})
578626
end
579627

580-
return item
628+
return M.create_picker_item(parts)
581629
end
582630

583631
---Generic picker that abstracts common logic for different picker UIs

lua/opencode/ui/history_picker.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ local history = require('opencode.history')
1111
local function format_history_item(item, width)
1212
local entry = item.content or item.text or ''
1313

14-
return base_picker.create_picker_item(entry:gsub('\n', ''), nil, 'ID: ' .. item.id, width)
14+
return base_picker.create_time_picker_item(entry:gsub('\n', ''), nil, 'ID: ' .. item.id, width)
1515
end
1616

1717
function M.pick(callback)

0 commit comments

Comments
 (0)