mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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(),
|
||||
|
||||
+19
@@ -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
|
||||
Reference in new issue
Block a user