Skip to content

Commit f598a35

Browse files
authored
feat: generic MCP tool formatter with input preservation (#407)
* feat: add generic MCP tool formatter and fix input preservation - Replace hardcoded sequential-thinking formatter with generic MCP formatter that auto-detects content fields (thought, content, text, query, url, input) and renders any MCP tool with server:tool title and content - Fix MCP tool input loss across three data flow paths: - event_manager.lua: preserve input during event collapsing - events.lua: preserve input when completion events omit it - helpers.lua: preserve input during session replay - Add fold_exclude config to prevent specific tools from being folded Supports both string (built-in tool) and {server, tool} (MCP tool) formats - Default fold_exclude includes sequential-thinking - Add replay test data for MCP tool rendering - Update README with fold_exclude config and correct folding_threshold default * fix: improve MCP formatter input safety and fallback - Add type guard for part.state.input before passing to find_content_field - Add JSON fallback when no known content field matches (renders full input as JSON for MCP tools with non-standard field names) - Clarify summary() boolean expression
1 parent a3e038b commit f598a35

13 files changed

Lines changed: 576 additions & 8 deletions

File tree

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,16 @@ require('opencode').setup({
238238
output = {
239239
filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output')
240240
compact_assistant_headers = false, -- 'full' (default), 'minimal' (compact if same mode), or 'hidden' (no headers for assistant)
241-
tools = {
242-
show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true)
243-
show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true)
244-
use_folds = true, -- Use folds for tool output (default: true)
245-
folding_threshold = 5, -- Number of lines to show before folding when show_output is true (default: 5)
246-
},
241+
tools = {
242+
show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true)
243+
show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true)
244+
use_folds = true, -- Use folds for tool output (default: true)
245+
folding_threshold = 25, -- Number of lines to show before folding when show_output is true (default: 25)
246+
fold_exclude = { -- Tools that should never be folded (default: sequential-thinking)
247+
'bash', -- built-in tool name (exact match)
248+
{ server = 'sequential-thinking', tool = 'sequentialthinking' }, -- MCP tool (server + tool match)
249+
},
250+
},
247251
rendering = {
248252
markdown_debounce_ms = 250, -- Debounce time for markdown rendering on new data (default: 250ms)
249253
on_data_rendered = nil, -- Called when new data is rendered; set to false to disable default RenderMarkdown/Markview behavior

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ M.defaults = {
179179
show_output = true,
180180
show_reasoning_output = true,
181181
use_folds = true,
182+
fold_exclude = { { server = 'sequential-thinking', tool = 'sequentialthinking' } },
182183
-- Reduced default threshold to make small tool outputs foldable by default.
183184
-- Users can override this in their config if they prefer the previous value.
184185
folding_threshold = 25,

lua/opencode/event_manager.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,26 @@ function EventManager:_on_drained_events(events)
424424
collapsed_events[i] = event
425425
part_update_indices[part_id] = i
426426
else
427+
-- Preserve state.input when the later event omits it. MCP tool
428+
-- completion events sometimes arrive with an empty input table,
429+
-- which would clobber the call arguments from the running event.
430+
local prev_part = collapsed_events[previous_index]
431+
and collapsed_events[previous_index].properties
432+
and collapsed_events[previous_index].properties.part
433+
if
434+
prev_part
435+
and prev_part.state
436+
and prev_part.state.input
437+
and type(prev_part.state.input) == 'table'
438+
and next(prev_part.state.input) ~= nil
439+
and event.properties.part
440+
and event.properties.part.state
441+
and event.properties.part.state.input
442+
and type(event.properties.part.state.input) == 'table'
443+
and next(event.properties.part.state.input) == nil
444+
then
445+
event.properties.part.state.input = prev_part.state.input
446+
end
427447
collapsed_events[previous_index] = event
428448
collapsed_events[i] = nil
429449
end

lua/opencode/types.lua

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,16 @@
263263
---@field event_throttle_ms number
264264
---@field event_collapsing boolean
265265

266+
---@class OpencodeUIOutputToolsConfig
267+
---@field show_output boolean
268+
---@field show_reasoning_output boolean
269+
---@field use_folds boolean
270+
---@field fold_exclude (string|{server: string, tool: string})[]|nil
271+
---@field folding_threshold number
272+
266273
---@class OpencodeUIOutputConfig
267274
---@field time_format string|nil # Custom os.date format for timestamps, e.g. '%m/%d %H:%M'. Uses fixed default when nil.
268-
---@field tools { show_output: boolean, show_reasoning_output: boolean, use_folds: boolean, folding_threshold: number }
275+
---@field tools OpencodeUIOutputToolsConfig
269276
---@field rendering OpencodeUIOutputRenderingConfig
270277
---@field max_messages integer|nil
271278
---@field always_scroll_to_bottom boolean

lua/opencode/ui/formatter.lua

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,16 @@ function M.format_tool(output, part, get_child_parts)
592592

593593
local start_line = output:get_line_count() + 1
594594

595-
local formatter = tool_formatters[tool] or tool_formatters.tool
595+
local formatter = tool_formatters[tool] or (tool:match('_') and tool_formatters.mcp) or tool_formatters.tool
596+
local fold_count = #output.fold_ranges
596597
formatter.format(output, part, get_child_parts)
597598

599+
if not format_utils.should_fold_tool(tool) then
600+
for idx = #output.fold_ranges, fold_count + 1, -1 do
601+
table.remove(output.fold_ranges, idx)
602+
end
603+
end
604+
598605
if part.state.status == 'error' and part.state.error then
599606
output:add_line('')
600607
M._format_callout(output, 'ERROR', part.state.error)

lua/opencode/ui/formatter/tools/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ return {
1212
question = require('opencode.ui.formatter.tools.question'),
1313
skill = require('opencode.ui.formatter.tools.skill'),
1414
task = require('opencode.ui.formatter.tools.task'),
15+
mcp = require('opencode.ui.formatter.tools.mcp'),
1516
tool = require('opencode.ui.formatter.tools.tool'),
1617
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
local icons = require('opencode.ui.icons')
2+
local utils = require('opencode.ui.formatter.utils')
3+
local config = require('opencode.config')
4+
5+
local M = {}
6+
7+
local CONTENT_FIELDS = { 'thought', 'content', 'text', 'query', 'url', 'input' }
8+
9+
---@param tool_name string
10+
---@return string|nil server
11+
---@return string|nil tool
12+
local function parse_mcp_tool_name(tool_name)
13+
-- MCP tool names are formatted as {server}_{tool}
14+
-- Server name can contain hyphens, so we match the last underscore
15+
local last_underscore = tool_name:reverse():find('_')
16+
if not last_underscore then
17+
return nil, nil
18+
end
19+
local split_pos = #tool_name - last_underscore + 1
20+
local server = tool_name:sub(1, split_pos - 1)
21+
local tool = tool_name:sub(split_pos + 1)
22+
return server, tool
23+
end
24+
25+
---@param input table
26+
---@return string|nil field_name
27+
---@return any field_value
28+
local function find_content_field(input)
29+
for _, field in ipairs(CONTENT_FIELDS) do
30+
if input[field] and input[field] ~= '' then
31+
return field, input[field]
32+
end
33+
end
34+
return nil, nil
35+
end
36+
37+
---@param output Output
38+
---@param part OpencodeMessagePart
39+
function M.format(output, part)
40+
local tool_name = part.tool
41+
if not tool_name then
42+
return
43+
end
44+
45+
local server, tool = parse_mcp_tool_name(tool_name)
46+
if not server or not tool then
47+
return
48+
end
49+
50+
local input = part.state and part.state.input
51+
if type(input) ~= 'table' then
52+
input = {}
53+
end
54+
55+
-- Title line (avoid **bold** markdown so highlight isn't overridden by RenderMarkdown)
56+
local title = string.format('%s %s: %s', icons.get('tool'), server, tool)
57+
local duration = utils.get_duration_text(part)
58+
if duration then
59+
title = title .. ' ' .. duration
60+
end
61+
local title_line = output:get_line_count() + 1
62+
output:add_line(title)
63+
64+
-- Content rendering (input only, not output)
65+
local content_start = nil
66+
local _, content_value = find_content_field(input)
67+
if not content_value and next(input) ~= nil then
68+
local ok, json_str = pcall(vim.json.encode, input)
69+
if ok then
70+
content_value = json_str
71+
end
72+
end
73+
if content_value then
74+
content_start = output:get_line_count() + 1
75+
output:add_empty_line()
76+
77+
if type(content_value) == 'string' then
78+
output:add_lines(vim.split(content_value, '\n'))
79+
else
80+
local ok, json_str = pcall(vim.json.encode, content_value)
81+
if ok then
82+
output:add_lines(vim.split(json_str, '\n'))
83+
end
84+
end
85+
86+
output:add_empty_line()
87+
88+
local show = config.ui.output.tools.show_output
89+
local use_folds = config.ui.output.tools.use_folds
90+
output:add_fold_with_threshold(content_start, show, use_folds)
91+
end
92+
93+
-- Apply dimmed highlight to title and all content lines
94+
local end_line = output:get_line_count()
95+
for line = title_line, end_line do
96+
output:add_extmark(line - 1, { line_hl_group = 'OpencodeHint', priority = 5000 })
97+
end
98+
end
99+
100+
---@param _ OpencodeMessagePart
101+
---@param input table
102+
---@return string, string, string
103+
function M.summary(_, input)
104+
return icons.get('tool'), 'mcp', (input and (input.query or input.url)) or ''
105+
end
106+
107+
return M

lua/opencode/ui/formatter/utils.lua

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local util = require('opencode.util')
2+
local config = require('opencode.config')
23

34
local M = {}
45

@@ -37,6 +38,35 @@ function M.format_action(output, icon, tool_type, value, duration_text)
3738
output:add_line(M.build_action_line(icon, tool_type, value, duration_text))
3839
end
3940

41+
---@param tool string|nil
42+
---@return boolean
43+
function M.should_fold_tool(tool)
44+
local tools_config = config.ui and config.ui.output and config.ui.output.tools
45+
if not tools_config or not tools_config.use_folds then
46+
return false
47+
end
48+
49+
local fold_exclude = tools_config.fold_exclude
50+
if not fold_exclude or not tool then
51+
return true
52+
end
53+
54+
for _, exclude in ipairs(fold_exclude) do
55+
if type(exclude) == 'string' then
56+
if exclude == tool then
57+
return false
58+
end
59+
elseif type(exclude) == 'table' and exclude.server and exclude.tool then
60+
local full_name = exclude.server .. '_' .. exclude.tool
61+
if full_name == tool then
62+
return false
63+
end
64+
end
65+
end
66+
67+
return true
68+
end
69+
4070
---@param output Output Output object to write to
4171
---@param lines string[]
4272
---@param language string

lua/opencode/ui/renderer/events.lua

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,29 @@ function M.on_part_updated(properties, revert_index)
330330
end
331331
end
332332

333+
-- Preserve state.input when the update omits it. MCP tool completion
334+
-- events sometimes arrive with an empty input table, clobbering the
335+
-- call arguments from the earlier running event.
336+
if
337+
part.state
338+
and part.state.input
339+
and type(part.state.input) == 'table'
340+
and next(part.state.input) == nil
341+
then
342+
local old_input = nil
343+
if existing_part_index then
344+
old_input = message.parts[existing_part_index]
345+
and message.parts[existing_part_index].state
346+
and message.parts[existing_part_index].state.input
347+
end
348+
if not old_input and part_data and part_data.part then
349+
old_input = part_data.part.state and part_data.part.state.input
350+
end
351+
if type(old_input) == 'table' and next(old_input) ~= nil then
352+
part.state.input = old_input
353+
end
354+
end
355+
333356
-- Update the part reference in the message
334357
if is_new_part then
335358
if existing_part_index then

tests/data/mcp-tool.expected.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"extmarks":[[1,1,0,{"ns_id":3,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_mcp_user1]","OpencodeHint"]],"priority":10,"virt_text_pos":"win_col","virt_text_win_col":-3}],[2,1,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_hide":false,"virt_text":[[" 2025-12-17 19:33:20","OpencodeHint"]],"priority":9,"virt_text_pos":"right_align","ns_id":3}],[3,2,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-3}],[4,3,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-3}],[5,6,0,{"ns_id":3,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],["","OpencodeHint"],[" [msg_mcp_asst1]","OpencodeHint"]],"priority":10,"virt_text_pos":"win_col","virt_text_win_col":-3}],[6,6,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_hide":false,"virt_text":[[" 2025-12-17 19:33:20","OpencodeHint"]],"priority":9,"virt_text_pos":"right_align","ns_id":3}],[7,8,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[8,8,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[9,9,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[10,9,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[11,10,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[12,10,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[13,11,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[14,11,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[15,12,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[16,12,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[17,13,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[18,13,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[19,14,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[20,14,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[21,15,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[22,15,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[23,16,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[24,16,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[25,17,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[26,17,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[27,18,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[28,18,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[29,19,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[30,19,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[31,20,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[32,20,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}],[33,21,0,{"right_gravity":true,"priority":5000,"line_hl_group":"OpencodeHint","ns_id":3}],[34,21,0,{"ns_id":3,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"virt_text_pos":"win_col","virt_text_win_col":-1}]],"actions":[],"lines":["----","","","Think about this step by step.","","----","",""," sequential-thinking: sequentialthinking","","Let me analyze this problem step by step.","","First, I need to understand the core issue.",""," sequential-thinking: sequentialthinking","","Second thought: the analysis is complete.","","Key findings:","- Finding A","- Finding B","","Based on my analysis, here is the answer.","",""]}

0 commit comments

Comments
 (0)