Add MCP tool handler support to the hooks engine (#38705)

## What changed

- Discover synchronous `mcp_tool` hook handlers and invoke their configured MCP server and tool through a supplied executor.
- Expand nested hook-event placeholders in MCP tool inputs while preserving JSON types, and process tool output through the existing hook output contract.
- Represent hook details as handler-specific metadata in `hooks/list`, including MCP server and tool fields, and show those details in the TUI hooks browser.
- Skip unsupported `SessionEnd` MCP hooks and runtimes without MCP invocation support with startup warnings.

## Testing

- Cover argument expansion, missing placeholders, MCP invocation and hook decisions, discovery warnings, `hooks/list` metadata, and TUI rendering.

GitOrigin-RevId: 295b845471fe92bd7ad7cd272fbcd2c3713912e0
This commit is contained in:
Abhinav authored and copyberry committed 2026-08-15 05:53:54 +00:00
1 parent 3685a61dad
commit 85fc4def35
33 files changed
+1351 -182

No files matched your search

@@ -1,5 +1,5 @@
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookExecutionMode;
use codex_app_server_protocol::HookHandlerMetadata;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HookTrustStatus;
@@ -487,17 +487,34 @@ impl HooksBrowserView {
width,
/*max_lines*/ None,
));
lines.extend(detail_wrapped_lines(
"Command",
hook.command.as_deref().unwrap_or("-"),
width,
Some(MAX_COMMAND_DETAIL_LINES),
));
let execution_mode = match hook.execution_mode {
HookExecutionMode::Sync => "Sync",
HookExecutionMode::Async => "Async",
};
lines.push(detail_line("Mode", execution_mode));
match &hook.handler {
HookHandlerMetadata::Command { command, r#async } => {
lines.extend(detail_wrapped_lines(
"Command",
command,
width,
Some(MAX_COMMAND_DETAIL_LINES),
));
lines.push(detail_line("Mode", if *r#async { "Async" } else { "Sync" }));
}
HookHandlerMetadata::McpTool { server, tool } => {
lines.extend(detail_wrapped_lines(
"MCP Server",
server,
width,
/*max_lines*/ None,
));
lines.extend(detail_wrapped_lines(
"MCP Tool", tool, width, /*max_lines*/ None,
));
}
HookHandlerMetadata::Prompt {} => {
lines.push(detail_line("Handler", "Prompt"));
}
HookHandlerMetadata::Agent {} => {
lines.push(detail_line("Handler", "Agent"));
}
}
lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec)));
if let Some(limit) = hook.additional_context_limit {
let value = if limit == 0 {
@@ -839,13 +856,18 @@ fn detail_wrapped_lines(
width: usize,
max_lines: Option<usize>,
) -> Vec<Line<'static>> {
let prefix = format!("{label:<10}");
let label_width = label.width().saturating_add(1).max(10);
let prefix = format!("{label:<label_width$}");
let available = width.saturating_sub(prefix.width()).max(1);
let mut wrapped = textwrap::wrap(value, available).into_iter();
let first = wrapped.next().unwrap_or_default().into_owned();
let mut lines = vec![Line::from(vec![prefix.into(), first.dim()])];
lines
.extend(wrapped.map(|line| Line::from(vec![" ".into(), line.into_owned().dim()])));
lines.extend(wrapped.map(|line| {
Line::from(vec![
" ".repeat(label_width).into(),
line.into_owned().dim(),
])
}));
let Some(max_lines) = max_lines else {
return lines;
};
@@ -889,8 +911,7 @@ mod tests {
use crate::test_support::test_path_display;
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookExecutionMode;
use codex_app_server_protocol::HookHandlerType;
use codex_app_server_protocol::HookHandlerMetadata;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HookTrustStatus;
@@ -953,11 +974,12 @@ mod tests {
HookMetadata {
key: key.to_string(),
event_name,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
handler: HookHandlerMetadata::Command {
command: command.to_string(),
r#async: false,
},
is_managed,
matcher: Some("Bash".to_string()),
command: Some(command.to_string()),
timeout_sec: 30,
status_message: None,
additional_context_limit: None,
@@ -1107,6 +1129,38 @@ mod tests {
assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112));
}
#[test]
fn renders_mcp_tool_handler_with_server_and_tool_details() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let mut configured_hook = hook(
"plugin:security-scanner",
HookEventName::PreToolUse,
HookSource::Plugin,
Some("security-tools@openai-curated"),
"",
/*enabled*/ true,
/*is_managed*/ false,
/*display_order*/ 0,
);
configured_hook.handler = HookHandlerMetadata::McpTool {
server: "scanner".to_string(),
tool: "scan_file".to_string(),
};
let mut view = HooksBrowserView::new(
vec![configured_hook],
Vec::new(),
Vec::new(),
AppEventSender::new(tx_raw),
);
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_snapshot!(
"hooks_browser_mcp_tool_handler",
render_lines(&view, /*width*/ 112)
);
}
#[test]
fn renders_handler_additional_context_limit() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -1150,7 +1204,10 @@ mod tests {
/*display_order*/ 0,
);
untrusted_hook.trust_status = HookTrustStatus::Untrusted;
untrusted_hook.execution_mode = HookExecutionMode::Async;
let HookHandlerMetadata::Command { r#async, .. } = &mut untrusted_hook.handler else {
panic!("expected command hook");
};
*r#async = true;
let mut view = HooksBrowserView::new(
vec![untrusted_hook],
Vec::new(),
@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
Event PreToolUse
Matcher Bash
Source Plugin - security-tools@openai-curated
MCP Server scanner
MCP Tool scan_file
Timeout 30s
Trust Trusted
Press space or enter to toggle; esc to go back