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
2026-08-15 05:53:54 +00:00
committed by copyberry
parent 3685a61dad
commit 85fc4def35
33 changed files with 1351 additions and 182 deletions
@@ -2099,6 +2099,7 @@
"HookHandlerType": {
"enum": [
"command",
"mcpTool",
"prompt",
"agent"
],
@@ -11871,12 +11871,89 @@
"HookHandlerType": {
"enum": [
"command",
"mcpTool",
"prompt",
"agent"
],
"type": "string"
},
"HookMetadata": {
"oneOf": [
{
"properties": {
"async": {
"default": false,
"type": "boolean"
},
"command": {
"type": "string"
},
"handlerType": {
"enum": [
"command"
],
"type": "string"
}
},
"required": [
"command",
"handlerType"
],
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"mcpTool"
],
"type": "string"
},
"server": {
"type": "string"
},
"tool": {
"type": "string"
}
},
"required": [
"handlerType",
"server",
"tool"
],
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"prompt"
],
"type": "string"
}
},
"required": [
"handlerType"
],
"title": "PromptHookMetadata",
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"agent"
],
"type": "string"
}
},
"required": [
"handlerType"
],
"title": "AgentHookMetadata",
"type": "object"
}
],
"properties": {
"additionalContextLimit": {
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
@@ -11887,12 +11964,6 @@
"null"
]
},
"command": {
"type": [
"string",
"null"
]
},
"currentHash": {
"type": "string"
},
@@ -11906,17 +11977,6 @@
"eventName": {
"$ref": "#/definitions/v2/HookEventName"
},
"executionMode": {
"allOf": [
{
"$ref": "#/definitions/v2/HookExecutionMode"
}
],
"default": "sync"
},
"handlerType": {
"$ref": "#/definitions/v2/HookHandlerType"
},
"isManaged": {
"type": "boolean"
},
@@ -11961,7 +12021,6 @@
"displayOrder",
"enabled",
"eventName",
"handlerType",
"isManaged",
"key",
"source",
@@ -8100,12 +8100,89 @@
"HookHandlerType": {
"enum": [
"command",
"mcpTool",
"prompt",
"agent"
],
"type": "string"
},
"HookMetadata": {
"oneOf": [
{
"properties": {
"async": {
"default": false,
"type": "boolean"
},
"command": {
"type": "string"
},
"handlerType": {
"enum": [
"command"
],
"type": "string"
}
},
"required": [
"command",
"handlerType"
],
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"mcpTool"
],
"type": "string"
},
"server": {
"type": "string"
},
"tool": {
"type": "string"
}
},
"required": [
"handlerType",
"server",
"tool"
],
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"prompt"
],
"type": "string"
}
},
"required": [
"handlerType"
],
"title": "PromptHookMetadata",
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"agent"
],
"type": "string"
}
},
"required": [
"handlerType"
],
"title": "AgentHookMetadata",
"type": "object"
}
],
"properties": {
"additionalContextLimit": {
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
@@ -8116,12 +8193,6 @@
"null"
]
},
"command": {
"type": [
"string",
"null"
]
},
"currentHash": {
"type": "string"
},
@@ -8135,17 +8206,6 @@
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"executionMode": {
"allOf": [
{
"$ref": "#/definitions/HookExecutionMode"
}
],
"default": "sync"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"isManaged": {
"type": "boolean"
},
@@ -8190,7 +8250,6 @@
"displayOrder",
"enabled",
"eventName",
"handlerType",
"isManaged",
"key",
"source",
@@ -31,6 +31,7 @@
"HookHandlerType": {
"enum": [
"command",
"mcpTool",
"prompt",
"agent"
],
@@ -31,6 +31,7 @@
"HookHandlerType": {
"enum": [
"command",
"mcpTool",
"prompt",
"agent"
],
@@ -36,22 +36,83 @@
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookMetadata": {
"oneOf": [
{
"properties": {
"async": {
"default": false,
"type": "boolean"
},
"command": {
"type": "string"
},
"handlerType": {
"enum": [
"command"
],
"type": "string"
}
},
"required": [
"command",
"handlerType"
],
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"mcpTool"
],
"type": "string"
},
"server": {
"type": "string"
},
"tool": {
"type": "string"
}
},
"required": [
"handlerType",
"server",
"tool"
],
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"prompt"
],
"type": "string"
}
},
"required": [
"handlerType"
],
"title": "PromptHookMetadata",
"type": "object"
},
{
"properties": {
"handlerType": {
"enum": [
"agent"
],
"type": "string"
}
},
"required": [
"handlerType"
],
"title": "AgentHookMetadata",
"type": "object"
}
],
"properties": {
"additionalContextLimit": {
"description": "Configured `additionalContext` spill threshold. `null` uses 2,500 tokens; `0` disables spilling.",
@@ -62,12 +123,6 @@
"null"
]
},
"command": {
"type": [
"string",
"null"
]
},
"currentHash": {
"type": "string"
},
@@ -81,17 +136,6 @@
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"executionMode": {
"allOf": [
{
"$ref": "#/definitions/HookExecutionMode"
}
],
"default": "sync"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"isManaged": {
"type": "boolean"
},
@@ -136,7 +180,6 @@
"displayOrder",
"enabled",
"eventName",
"handlerType",
"isManaged",
"key",
"source",
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookHandlerType = "command" | "prompt" | "agent";
export type HookHandlerType = "command" | "mcpTool" | "prompt" | "agent";
@@ -3,14 +3,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { HookEventName } from "./HookEventName";
import type { HookExecutionMode } from "./HookExecutionMode";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookSource } from "./HookSource";
import type { HookTrustStatus } from "./HookTrustStatus";
export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null,
export type HookMetadata = { key: string, eventName: HookEventName, matcher: string | null, timeoutSec: bigint, statusMessage: string | null,
/**
* Configured `additionalContext` spill threshold.
* `null` uses 2,500 tokens; `0` disables spilling.
*/
additionalContextLimit: number | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, };
additionalContextLimit: number | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, } & ({ "handlerType": "command", command: string, async: boolean, } | { "handlerType": "mcpTool", server: string, tool: string, } | { "handlerType": "prompt", } | { "handlerType": "agent", });
@@ -23,7 +23,7 @@ v2_enum_from_core!(
v2_enum_from_core!(
pub enum HookHandlerType from CoreHookHandlerType {
Command, Prompt, Agent
Command, McpTool, Prompt, Agent
}
);
@@ -1,7 +1,5 @@
use super::AppSummary;
use super::HookEventName;
use super::HookExecutionMode;
use super::HookHandlerType;
use super::HookSource;
use super::HookTrustStatus;
use crate::JsonSchema;
@@ -518,17 +516,32 @@ pub struct HooksListEntry {
pub errors: Vec<HookErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "handlerType", rename_all = "camelCase")]
#[ts(tag = "handlerType", export_to = "v2/")]
pub enum HookHandlerMetadata {
Command {
command: String,
#[serde(default)]
r#async: bool,
},
McpTool {
server: String,
tool: String,
},
Prompt {},
Agent {},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookMetadata {
pub key: String,
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
#[serde(default)]
pub execution_mode: HookExecutionMode,
#[serde(flatten)]
pub handler: HookHandlerMetadata,
pub matcher: Option<String>,
pub command: Option<String>,
pub timeout_sec: u64,
pub status_message: Option<String>,
/// Configured `additionalContext` spill threshold.
@@ -3396,6 +3396,45 @@ fn user_input_into_core_preserves_media_fields() {
);
}
#[test]
fn hook_handler_metadata_only_exposes_async_for_commands() {
assert_eq!(
serde_json::to_value(HookHandlerMetadata::Command {
command: "echo hello".to_string(),
r#async: true,
})
.unwrap(),
json!({
"handlerType": "command",
"command": "echo hello",
"async": true,
}),
);
assert_eq!(
serde_json::from_value::<HookHandlerMetadata>(json!({
"handlerType": "command",
"command": "echo hello",
}))
.unwrap(),
HookHandlerMetadata::Command {
command: "echo hello".to_string(),
r#async: false,
},
);
assert_eq!(
serde_json::to_value(HookHandlerMetadata::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
})
.unwrap(),
json!({
"handlerType": "mcpTool",
"server": "security",
"tool": "scan",
}),
);
}
#[test]
fn skills_list_params_serialization_uses_force_reload() {
assert_eq!(
+4 -2
View File
@@ -1961,10 +1961,12 @@ For linked Git worktrees, project hook declarations come from the matching `.cod
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
`executionMode` reports how a command hook runs. `sync` hooks participate in the current operation, while `async` hooks run in the background and deliver informational output through the existing steer-based injection path. Output is injected immediately into an active turn or persisted without starting a new turn when the session is idle.
A command hook's `async` field reports its effective execution behavior. Hooks with `async: false` participate in the current operation, while hooks with `async: true` run in the background and deliver informational output through the existing steer-based injection path. Output is injected immediately into an active turn or persisted without starting a new turn when the session is idle. MCP tool hooks do not have an `async` field and always run synchronously. Lifecycle notifications continue to report `executionMode` on hook run summaries.
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
MCP tool hooks appear with `handlerType: "mcpTool"`. Their `server` and `tool` fields identify the configured MCP target. Command hooks instead include a `command` field.
```json
{
"method": "hooks/list",
@@ -1985,7 +1987,7 @@ For unmanaged hooks, `currentHash` and `trustStatus` describe whether the curren
"key": "/Users/me/.codex/config.toml:pre_tool_use:0:0",
"eventName": "pre_tool_use",
"handlerType": "command",
"executionMode": "sync",
"async": false,
"isManaged": false,
"matcher": "Bash",
"command": "python3 /Users/me/hook.py",
@@ -90,6 +90,7 @@ use codex_app_server_protocol::GetWorkspaceMessagesResponse;
use codex_app_server_protocol::GitDiffToRemoteParams;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HookHandlerMetadata;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
@@ -1,5 +1,6 @@
use super::*;
use codex_core::config::permission_profile_catalog;
use codex_hooks::HookListEntryHandler;
use futures::StreamExt;
#[derive(Clone)]
@@ -66,24 +67,36 @@ fn skills_to_info(
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
hooks
.iter()
.map(|hook| HookMetadata {
key: hook.key.clone(),
event_name: hook.event_name.into(),
handler_type: hook.handler_type.into(),
execution_mode: hook.execution_mode.into(),
matcher: hook.matcher.clone(),
command: hook.command.clone(),
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
additional_context_limit: hook.additional_context_limit,
source_path: hook.source_path.clone(),
source: hook.source.into(),
plugin_id: hook.plugin_id.clone(),
display_order: hook.display_order,
enabled: hook.enabled,
is_managed: hook.is_managed,
current_hash: hook.current_hash.clone(),
trust_status: hook.trust_status.into(),
.map(|hook| {
let handler = match &hook.handler {
HookListEntryHandler::Command { command, r#async } => {
HookHandlerMetadata::Command {
command: command.clone(),
r#async: *r#async,
}
}
HookListEntryHandler::McpTool { server, tool } => HookHandlerMetadata::McpTool {
server: server.clone(),
tool: tool.clone(),
},
};
HookMetadata {
key: hook.key.clone(),
event_name: hook.event_name.into(),
handler,
matcher: hook.matcher.clone(),
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
additional_context_limit: hook.additional_context_limit,
source_path: hook.source_path.clone(),
source: hook.source.into(),
plugin_id: hook.plugin_id.clone(),
display_order: hook.display_order,
enabled: hook.enabled,
is_managed: hook.is_managed,
current_hash: hook.current_hash.clone(),
trust_status: hook.trust_status.into(),
}
})
.collect()
}
+136 -22
View File
@@ -8,8 +8,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
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;
@@ -51,7 +50,7 @@ fn command_hook_hash(
matcher: Option<&str>,
command: &str,
timeout_sec: u64,
execution_mode: HookExecutionMode,
r#async: bool,
status_message: Option<&str>,
additional_context_limit: Option<usize>,
) -> String {
@@ -63,7 +62,7 @@ fn command_hook_hash(
command: command.to_string(),
command_windows: None,
timeout_sec: Some(timeout_sec),
r#async: execution_mode == HookExecutionMode::Async,
r#async,
status_message: status_message.map(ToOwned::to_owned),
additional_context_limit,
}],
@@ -213,10 +212,11 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
hooks: vec![HookMetadata {
key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()),
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Async,
handler: HookHandlerMetadata::Command {
command: "python3 /tmp/listed-hook.py".to_string(),
r#async: true,
},
matcher: Some("Bash".to_string()),
command: Some("python3 /tmp/listed-hook.py".to_string()),
timeout_sec: 5,
status_message: Some("running listed hook".to_string()),
additional_context_limit: Some(4_096),
@@ -231,7 +231,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
Some("Bash"),
"python3 /tmp/listed-hook.py",
/*timeout_sec*/ 5,
HookExecutionMode::Async,
/*async*/ true,
Some("running listed hook"),
/*additional_context_limit*/ Some(4_096),
),
@@ -294,10 +294,11 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
hooks: vec![HookMetadata {
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
handler: HookHandlerMetadata::Command {
command: "echo plugin hook".to_string(),
r#async: false,
},
matcher: Some("Bash".to_string()),
command: Some("echo plugin hook".to_string()),
timeout_sec: 7,
status_message: Some("running plugin hook".to_string()),
additional_context_limit: None,
@@ -312,7 +313,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
Some("Bash"),
"echo plugin hook",
/*timeout_sec*/ 7,
HookExecutionMode::Sync,
/*async*/ false,
Some("running plugin hook"),
/*additional_context_limit*/ None,
),
@@ -512,8 +513,11 @@ async fn plugin_upgrade_refreshes_hook_runtime_for_loaded_session() -> Result<()
.find(|hook| hook.event_name == HookEventName::UserPromptSubmit)
.expect("plugin should register a user-prompt hook");
assert_eq!(
hook.command,
Some(format!("python3 {}", expected_hook_path.display()))
hook.handler,
HookHandlerMetadata::Command {
command: format!("python3 {}", expected_hook_path.display()),
r#async: false,
}
);
assert!(!hook.enabled);
@@ -691,8 +695,11 @@ source = "{}"
.join("hooks/log_version.py")
.canonicalize()?;
assert_eq!(
data[0].hooks[0].command,
Some(format!("python3 {}", expected_hook_path.display()))
data[0].hooks[0].handler,
HookHandlerMetadata::Command {
command: format!("python3 {}", expected_hook_path.display()),
r#async: false,
}
);
let turn_id = mcp
@@ -717,6 +724,100 @@ source = "{}"
Ok(())
}
#[tokio::test]
async fn hooks_list_shows_discovered_plugin_mcp_tool_hook() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
write_plugin_hook_config(
codex_home.path(),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "mcp_tool",
"server": "security",
"tool": "inspect",
"input": {"path": "${tool_input.path}"},
"timeout": 9,
"statusMessage": "checking security policy"
}
]
}
]
}
}"#,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![cwd.path().to_path_buf()],
})
.await?;
let HooksListResponse { data } =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
let source_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home
.path()
.join("plugins/cache/test/demo/local/hooks/hooks.json"),
)?)?;
let identity = NormalizedHookIdentity {
event_name: "pre_tool_use",
group: codex_config::MatcherGroup {
matcher: Some("Bash".to_string()),
hooks: vec![codex_config::HookHandlerConfig::McpTool {
server: "security".to_string(),
tool: "inspect".to_string(),
input: serde_json::from_value(serde_json::json!({
"path": "${tool_input.path}",
}))?,
timeout_sec: Some(9),
status_message: Some("checking security policy".to_string()),
}],
},
};
let identity = codex_config::TomlValue::try_from(identity)?;
assert_eq!(
data,
vec![HooksListEntry {
cwd: cwd.path().to_path_buf(),
hooks: vec![HookMetadata {
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
event_name: HookEventName::PreToolUse,
handler: HookHandlerMetadata::McpTool {
server: "security".to_string(),
tool: "inspect".to_string(),
},
matcher: Some("Bash".to_string()),
timeout_sec: 9,
status_message: Some("checking security policy".to_string()),
additional_context_limit: None,
source_path,
source: HookSource::Plugin,
plugin_id: Some("demo@test".to_string()),
display_order: 0,
enabled: true,
is_managed: false,
current_hash: codex_config::version_for_toml(&identity),
trust_status: HookTrustStatus::Untrusted,
}],
warnings: Vec::new(),
errors: Vec::new(),
}]
);
Ok(())
}
#[tokio::test]
async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -884,10 +985,11 @@ timeout = 5
project_config_path.as_path().display()
),
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
handler: HookHandlerMetadata::Command {
command: "echo project hook".to_string(),
r#async: false,
},
matcher: Some("Bash".to_string()),
command: Some("echo project hook".to_string()),
timeout_sec: 5,
status_message: None,
additional_context_limit: None,
@@ -902,7 +1004,7 @@ timeout = 5
Some("Bash"),
"echo project hook",
/*timeout_sec*/ 5,
HookExecutionMode::Sync,
/*async*/ false,
/*status_message*/ None,
/*additional_context_limit*/ None,
),
@@ -951,8 +1053,20 @@ async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> {
let repo_config_path =
AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?;
assert_eq!(repo_hook.command.as_deref(), Some("echo root hook"));
assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook"));
assert_eq!(
repo_hook.handler,
HookHandlerMetadata::Command {
command: "echo root hook".to_string(),
r#async: false,
}
);
assert_eq!(
worktree_hook.handler,
HookHandlerMetadata::Command {
command: "echo root hook".to_string(),
r#async: false,
}
);
assert_eq!(repo_hook.key, worktree_hook.key);
assert_eq!(repo_hook.source_path, repo_config_path);
assert_eq!(worktree_hook.source_path, repo_config_path);
+1
View File
@@ -4172,6 +4172,7 @@ async fn build_hooks_config(
plugin_hook_load_warnings,
shell_program: hook_shell_program,
shell_args: hook_shell_argv,
mcp_executor: None,
}
}
@@ -121,6 +121,7 @@ impl CommandHookRuntime {
ConfiguredHandlerKind::Command { command, env, .. } => {
run_command(&runtime, &handler, command, env, &input_json, &cwd).await
}
ConfiguredHandlerKind::McpTool { .. } => return,
};
let mut hook_result = parse(&handler, result, turn_id).completed;
let mut entries = Vec::new();
@@ -156,6 +156,7 @@ async fn schedule(runtime: &CommandHookRuntime, handler: ConfiguredHandler, cwd:
warnings: Vec::new(),
required_load_errors: Vec::new(),
command_runtime: runtime.clone(),
mcp_executor: None,
};
engine
.run_user_prompt_submit(UserPromptSubmitRequest {
+168 -21
View File
@@ -24,6 +24,7 @@ use serde::Serialize;
use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HookListEntry;
use super::HookListEntryHandler;
use crate::config_rules::hook_states_from_stack;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
@@ -31,8 +32,6 @@ use crate::events::session_end::SESSION_END_DEFAULT_TIMEOUT_SEC;
use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC;
use crate::output_spill::AdditionalContextLimit;
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookTrustStatus;
@@ -577,15 +576,63 @@ fn append_matcher_groups(
additional_context_limit,
}
}
HookHandlerConfig::McpTool { .. } => {
source.record_load_failure(
format!(
"skipping MCP tool hook in {}: MCP tool hooks are not supported yet",
source.path.display()
),
warnings,
);
continue;
HookHandlerConfig::McpTool {
server,
tool,
input,
timeout_sec,
status_message,
} => {
if event_name == codex_protocol::protocol::HookEventName::SessionEnd {
source.record_load_failure(
format!(
"skipping MCP tool hook in {}: SessionEnd MCP hooks are not supported",
source.path.display()
),
warnings,
);
continue;
}
if server.trim().is_empty() || tool.trim().is_empty() {
source.record_load_failure(
format!(
"skipping MCP tool hook in {}: server and tool must not be empty",
source.path.display()
),
warnings,
);
continue;
}
if matches!(&source.requirement, HookRequirement::Required(_)) {
source.record_load_failure(
format!(
"skipping MCP tool hook in {}: MCP tool hooks are not supported yet",
source.path.display()
),
warnings,
);
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
let config = HookHandlerConfig::McpTool {
server: server.clone(),
tool: tool.clone(),
input: input.clone(),
timeout_sec: Some(timeout_sec),
status_message: status_message.clone(),
};
NormalizedHandler {
config,
kind: ConfiguredHandlerKind::McpTool {
server,
tool,
input,
},
timeout_sec,
status_message,
additional_context_limit: None,
}
}
HookHandlerConfig::Prompt {} => {
source.record_load_failure(
@@ -622,20 +669,26 @@ fn append_matcher_groups(
let enabled = hook_enabled(source.is_managed, state);
let trusted_hash = hook_trusted_hash(source.is_managed, state);
let trust_status = hook_trust_status(source.is_managed, &current_hash, trusted_hash);
let ConfiguredHandlerKind::Command { command, .. } = &kind;
let execution_mode =
if matches!(kind, ConfiguredHandlerKind::Command { r#async: true, .. }) {
HookExecutionMode::Async
} else {
HookExecutionMode::Sync
};
let handler = match &kind {
ConfiguredHandlerKind::Command {
command, r#async, ..
} => HookListEntryHandler::Command {
command: command.clone(),
r#async: *r#async,
},
ConfiguredHandlerKind::McpTool { server, tool, .. } => {
HookListEntryHandler::McpTool {
server: server.clone(),
tool: tool.clone(),
}
}
};
hook_entries.push(HookListEntry {
key,
event_name,
handler_type: HookHandlerType::Command,
handler,
matcher: matcher.map(ToOwned::to_owned),
command: Some(command.clone()),
timeout_sec,
status_message: status_message.clone(),
additional_context_limit,
@@ -647,7 +700,6 @@ fn append_matcher_groups(
is_managed: source.is_managed,
current_hash,
trust_status,
execution_mode,
});
if enabled
&& (source.bypass_hook_trust
@@ -809,6 +861,7 @@ mod tests {
use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HookListEntry;
use super::HookListEntryHandler;
use super::append_matcher_groups;
use crate::output_spill::AdditionalContextLimit;
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
@@ -926,6 +979,91 @@ mod tests {
}
}
#[test]
fn mcp_tool_hooks_preserve_argument_templates_and_list_their_target() {
let source_path = source_path();
let hook_states = std::collections::HashMap::new();
let input = serde_json::from_value(serde_json::json!({
"file_path": "${tool_input.file_path}",
"optional": "${tool_input.optional}",
}))
.expect("MCP hook input should be an object");
let mut handlers = Vec::new();
let mut entries = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_matcher_groups(
&mut handlers,
&mut entries,
&mut warnings,
&mut display_order,
&mut hook_handler_source(&source_path, &hook_states),
HookEventName::PostToolUse,
vec![MatcherGroup {
matcher: Some("Write|Edit".to_string()),
hooks: vec![HookHandlerConfig::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input,
timeout_sec: Some(30),
status_message: Some("Scanning file".to_string()),
}],
}],
);
assert!(warnings.is_empty());
assert_eq!(handlers.len(), 1);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].handler,
HookListEntryHandler::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
}
);
assert!(entries[0].current_hash.starts_with("sha256:"));
}
#[test]
fn session_end_mcp_tool_hooks_are_warned_and_skipped() {
let source_path = source_path();
let hook_states = std::collections::HashMap::new();
let mut handlers = Vec::new();
let mut entries = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_matcher_groups(
&mut handlers,
&mut entries,
&mut warnings,
&mut display_order,
&mut hook_handler_source(&source_path, &hook_states),
HookEventName::SessionEnd,
vec![MatcherGroup {
matcher: None,
hooks: vec![HookHandlerConfig::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::Map::new(),
timeout_sec: None,
status_message: None,
}],
}],
);
assert!(handlers.is_empty());
assert!(entries.is_empty());
assert_eq!(
warnings,
vec![format!(
"skipping MCP tool hook in {}: SessionEnd MCP hooks are not supported",
source_path.display()
)]
);
}
fn discover_command(
event_name: HookEventName,
additional_context_limit: Option<usize>,
@@ -1136,6 +1274,11 @@ mod tests {
.collect::<Vec<_>>(),
vec![1, 3]
);
assert!(
handlers
.iter()
.all(ConfiguredHandler::can_apply_control_effects)
);
assert_eq!(
handlers
.iter()
@@ -1150,6 +1293,10 @@ mod tests {
.collect::<Vec<_>>(),
vec![1, 3]
);
assert!(hook_entries.iter().all(|entry| matches!(
entry.handler,
HookListEntryHandler::Command { r#async: false, .. }
)));
assert_eq!(
hook_entries
.iter()
+15 -2
View File
@@ -16,6 +16,7 @@ use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HandlerRunResult;
use super::command_runner::run_command;
use super::mcp_runner::run_mcp_tool;
use crate::events::common::matches_matcher;
#[derive(Debug)]
@@ -123,14 +124,25 @@ pub(crate) async fn execute_handlers<T: 'static>(
)
.await
}
ConfiguredHandlerKind::McpTool {
server,
tool,
input,
} => {
let executor = engine.mcp_executor.as_deref()?;
run_mcp_tool(executor, &handler, server, tool, input, &input_json).await
}
};
(configured_order, parse(&handler, result, turn_id))
Some((configured_order, parse(&handler, result, turn_id)))
});
}
let mut completed = Vec::new();
let mut completion_order = 0;
while let Some((configured_order, mut parsed)) = pending.next().await {
while let Some(result) = pending.next().await {
let Some((configured_order, mut parsed)) = result else {
continue;
};
parsed.completion_order = completion_order;
completion_order += 1;
completed.push((configured_order, parsed));
@@ -205,6 +217,7 @@ pub(crate) fn hook_execution_mode_label(mode: HookExecutionMode) -> &'static str
pub(crate) fn hook_handler_type_label(handler_type: HookHandlerType) -> &'static str {
match handler_type {
HookHandlerType::Command => "command",
HookHandlerType::McpTool => "mcp_tool",
HookHandlerType::Prompt => "prompt",
HookHandlerType::Agent => "agent",
}
+140
View File
@@ -0,0 +1,140 @@
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use regex::Regex;
use serde_json::Map;
use serde_json::Value;
use super::ConfiguredHandler;
use super::HandlerRunResult;
use crate::mcp::HookMcpCall;
use crate::mcp::HookMcpExecutor;
/// Expands an MCP argument template against this hook event and invokes the configured tool.
#[tracing::instrument(
name = "codex.hooks.mcp_tool",
level = "trace",
skip_all,
fields(
hook.server = server,
hook.tool = tool,
hook.timeout_sec = handler.timeout_sec,
)
)]
pub(crate) async fn run_mcp_tool(
executor: &dyn HookMcpExecutor,
handler: &ConfiguredHandler,
server: &str,
tool: &str,
argument_template: &Map<String, Value>,
hook_event_json: &str,
) -> HandlerRunResult {
let started_at = chrono::Utc::now().timestamp();
let started = Instant::now();
let result = async {
let hook_event: Value =
serde_json::from_str(hook_event_json).context("failed to parse hook event input")?;
let input = expand_mcp_argument_template(argument_template, &hook_event)?;
executor
.execute(HookMcpCall {
server: server.to_string(),
tool: tool.to_string(),
input,
timeout: Duration::from_secs(handler.timeout_sec),
})
.await
}
.await;
let (exit_code, stdout, error) = match result {
Ok(output) => (Some(0), output, None),
Err(error) => (None, String::new(), Some(error.to_string())),
};
HandlerRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code,
stdout,
stderr: String::new(),
error,
}
}
/// Recursively substitutes `${field.nested}` placeholders using values from a hook event.
///
/// A complete placeholder preserves its JSON type; a placeholder embedded in surrounding
/// text is rendered as a string. Missing fields fail the hook instead of passing unresolved
/// arguments to the server. For example, the template `{"count":"${tool_input.count}"}`
/// becomes `{"count":3}` when the event contains `{"tool_input":{"count":3}}`.
fn expand_mcp_argument_template(
argument_template: &Map<String, Value>,
hook_event: &Value,
) -> Result<Map<String, Value>> {
argument_template
.iter()
.map(|(key, value)| Ok((key.clone(), resolve_value(value, hook_event)?)))
.collect()
}
fn resolve_value(value: &Value, hook_event: &Value) -> Result<Value> {
match value {
Value::Object(input) => Ok(Value::Object(expand_mcp_argument_template(
input, hook_event,
)?)),
Value::Array(items) => items
.iter()
.map(|item| resolve_value(item, hook_event))
.collect::<Result<Vec<_>>>()
.map(Value::Array),
Value::String(text) => resolve_string(text, hook_event),
_ => Ok(value.clone()),
}
}
fn resolve_string(text: &str, hook_event: &Value) -> Result<Value> {
let pattern = Regex::new(r"\$\{([^{}]+)\}")?;
let captures = pattern.captures_iter(text).collect::<Vec<_>>();
if captures.is_empty() {
return Ok(Value::String(text.to_string()));
}
if let [capture] = captures.as_slice()
&& let Some(placeholder) = capture.get(0)
&& placeholder.start() == 0
&& placeholder.end() == text.len()
{
return resolve_path(hook_event, &capture[1]).cloned();
}
let mut resolved = String::new();
let mut previous_end = 0;
for capture in captures {
let Some(placeholder) = capture.get(0) else {
continue;
};
resolved.push_str(&text[previous_end..placeholder.start()]);
let value = resolve_path(hook_event, &capture[1])?;
match value {
Value::String(value) => resolved.push_str(value),
_ => resolved.push_str(&serde_json::to_string(value)?),
}
previous_end = placeholder.end();
}
resolved.push_str(&text[previous_end..]);
Ok(Value::String(resolved))
}
fn resolve_path<'a>(hook_event: &'a Value, path: &str) -> Result<&'a Value> {
path.split('.').try_fold(hook_event, |value, field| {
value
.get(field)
.with_context(|| format!("hook input placeholder `${{{path}}}` was not found"))
})
}
#[cfg(test)]
#[path = "mcp_runner_tests.rs"]
mod tests;
@@ -0,0 +1,137 @@
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use futures::FutureExt;
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use serde_json::Map;
use serde_json::Value;
use serde_json::json;
use super::expand_mcp_argument_template;
use super::run_mcp_tool;
use crate::engine::ConfiguredHandler;
use crate::engine::ConfiguredHandlerKind;
use crate::mcp::HookMcpCall;
use crate::mcp::HookMcpExecutor;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
struct RecordingExecutor {
calls: Arc<Mutex<Vec<HookMcpCall>>>,
output: String,
}
impl HookMcpExecutor for RecordingExecutor {
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>> {
async move {
self.calls.lock().expect("lock calls").push(call);
Ok(self.output.clone())
}
.boxed()
}
}
#[test]
fn placeholders_preserve_json_types_and_expand_nested_inputs() {
let event_input = json!({
"tool_input": {
"file_path": "/tmp/example.rs",
"count": 3,
"optional": null,
"metadata": { "language": "rust" },
},
});
let input = serde_json::from_value::<Map<String, Value>>(json!({
"path": "${tool_input.file_path}",
"message": "scan ${tool_input.file_path}",
"count": "${tool_input.count}",
"optional": "${tool_input.optional}",
"nested": ["${tool_input.metadata}", { "literal": true }],
}))
.expect("object input");
assert_eq!(
Value::Object(
expand_mcp_argument_template(&input, &event_input).expect("expand MCP arguments")
),
json!({
"path": "/tmp/example.rs",
"message": "scan /tmp/example.rs",
"count": 3,
"optional": null,
"nested": [{ "language": "rust" }, { "literal": true }],
})
);
}
#[test]
fn missing_placeholder_fails_without_passing_unresolved_input() {
let input = serde_json::from_value::<Map<String, Value>>(json!({
"path": "${tool_input.missing}",
}))
.expect("object input");
let error = expand_mcp_argument_template(&input, &json!({ "tool_input": {} }))
.expect_err("missing placeholder should fail");
assert_eq!(
error.to_string(),
"hook input placeholder `${tool_input.missing}` was not found"
);
}
#[tokio::test]
async fn mcp_tool_results_use_command_hook_output_contract() {
let configured_input = serde_json::from_value::<Map<String, Value>>(json!({
"file_path": "${tool_input.file_path}",
}))
.expect("object input");
let handler = ConfiguredHandler {
event_name: HookEventName::PostToolUse,
matcher: None,
timeout_sec: 30,
status_message: None,
additional_context_limit: Default::default(),
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: HookSource::User,
display_order: 0,
kind: ConfiguredHandlerKind::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input: configured_input.clone(),
},
};
let calls = Arc::new(Mutex::new(Vec::new()));
let executor = RecordingExecutor {
calls: Arc::clone(&calls),
output: r#"{"decision":"block","reason":"unsafe file"}"#.to_string(),
};
let result = run_mcp_tool(
&executor,
&handler,
"security",
"scan",
&configured_input,
r#"{"tool_input":{"file_path":"/tmp/example.rs"}}"#,
)
.await;
assert_eq!(result.exit_code, Some(0));
assert_eq!(result.stdout, executor.output);
assert_eq!(result.error, None);
assert_eq!(
*calls.lock().expect("lock calls"),
vec![HookMcpCall {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::from_value(json!({ "file_path": "/tmp/example.rs" }))
.expect("object input"),
timeout: Duration::from_secs(30),
}]
);
}
+36 -6
View File
@@ -1,6 +1,7 @@
pub(crate) mod command_runner;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
pub(crate) mod mcp_runner;
pub(crate) mod output_parser;
pub(crate) mod schema_loader;
@@ -22,6 +23,7 @@ use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
use crate::mcp::HookMcpExecutor;
use crate::output_spill::AdditionalContextLimit;
use codex_config::ConfigLayerStack;
use codex_plugin::PluginHookSource;
@@ -33,6 +35,7 @@ use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookTrustStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use command_runner::CommandHookRuntime;
@@ -63,6 +66,11 @@ pub(crate) enum ConfiguredHandlerKind {
env: HashMap<String, String>,
r#async: bool,
},
McpTool {
server: String,
tool: String,
input: serde_json::Map<String, serde_json::Value>,
},
}
#[derive(Debug)]
@@ -80,7 +88,8 @@ impl ConfiguredHandler {
pub(crate) fn execution_mode(&self) -> HookExecutionMode {
match self.kind {
ConfiguredHandlerKind::Command { r#async: true, .. } => HookExecutionMode::Async,
ConfiguredHandlerKind::Command { r#async: false, .. } => HookExecutionMode::Sync,
ConfiguredHandlerKind::Command { r#async: false, .. }
| ConfiguredHandlerKind::McpTool { .. } => HookExecutionMode::Sync,
}
}
@@ -117,17 +126,23 @@ impl ConfiguredHandler {
fn handler_type(&self) -> HookHandlerType {
match &self.kind {
ConfiguredHandlerKind::Command { .. } => HookHandlerType::Command,
ConfiguredHandlerKind::McpTool { .. } => HookHandlerType::McpTool,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookListEntryHandler {
Command { command: String, r#async: bool },
McpTool { server: String, tool: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookListEntry {
pub key: String,
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub handler: HookListEntryHandler,
pub matcher: Option<String>,
pub command: Option<String>,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub additional_context_limit: Option<usize>,
@@ -139,7 +154,6 @@ pub struct HookListEntry {
pub is_managed: bool,
pub current_hash: String,
pub trust_status: HookTrustStatus,
pub execution_mode: codex_protocol::protocol::HookExecutionMode,
}
#[derive(Clone)]
@@ -148,6 +162,7 @@ pub(crate) struct ClaudeHooksEngine {
warnings: Vec<String>,
required_load_errors: Vec<String>,
pub(crate) command_runtime: CommandHookRuntime,
mcp_executor: Option<Arc<dyn HookMcpExecutor>>,
}
impl ClaudeHooksEngine {
@@ -158,6 +173,7 @@ impl ClaudeHooksEngine {
plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>,
command_runtime: CommandHookRuntime,
mcp_executor: Option<Arc<dyn HookMcpExecutor>>,
) -> Self {
if !enabled {
return Self {
@@ -165,22 +181,36 @@ impl ClaudeHooksEngine {
warnings: Vec::new(),
required_load_errors: Vec::new(),
command_runtime,
mcp_executor,
};
}
let _ = schema_loader::generated_hook_schemas();
let discovered = discovery::discover_handlers(
let mut discovered = discovery::discover_handlers(
config_layer_stack,
plugin_hook_sources,
plugin_hook_load_warnings,
bypass_hook_trust,
);
if mcp_executor.is_none() {
discovered.handlers.retain(|handler| {
if matches!(&handler.kind, ConfiguredHandlerKind::McpTool { .. }) {
discovered.warnings.push(format!(
"skipping MCP tool hook in {}: MCP invocation is not available yet",
handler.source_path.display()
));
false
} else {
true
}
});
}
Self {
handlers: discovered.handlers,
warnings: discovered.warnings,
required_load_errors: discovered.required_load_errors,
command_runtime,
mcp_executor,
}
}
+252 -4
View File
@@ -1,6 +1,8 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use codex_config::AbsolutePathBuf;
@@ -22,11 +24,14 @@ use codex_plugin::PluginHookSource;
use codex_plugin::PluginId;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::HookTrustStatus;
use futures::FutureExt;
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -35,7 +40,10 @@ use super::CommandHookRuntime;
use super::CommandShell;
use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HookListEntryHandler;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::mcp::HookMcpCall;
use crate::mcp::HookMcpExecutor;
fn cwd() -> AbsolutePathBuf {
AbsolutePathBuf::current_dir().expect("current dir")
@@ -58,6 +66,7 @@ fn permission_request_timeout_only_counts_synchronous_handlers() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
let command = "echo synchronous permission hook";
let synchronous_handler = ConfiguredHandler {
@@ -354,6 +363,84 @@ fn required_managed_hooks_reject_unsupported_handler_types() {
);
}
#[test]
fn required_managed_mcp_hooks_reject_empty_targets() {
let temp = tempdir().expect("create temp dir");
let events = HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::McpTool {
server: "policy".to_string(),
tool: " ".to_string(),
input: Default::default(),
timeout_sec: None,
status_message: None,
}],
}],
..Default::default()
};
let config_layer_stack = required_hooks_stack(
managed_hooks_for_current_platform(temp.path(), events),
RequirementSource::LegacyManagedConfigTomlFromMdm,
);
let error = crate::Hooks::new(
crate::HooksConfig {
feature_enabled: true,
config_layer_stack: Some(config_layer_stack),
..Default::default()
},
ThreadId::new(),
)
.err()
.expect("invalid required managed MCP hook should reject startup");
assert!(
error
.to_string()
.contains("server and tool must not be empty")
);
}
#[test]
fn required_managed_session_end_mcp_hooks_reject_startup() {
let temp = tempdir().expect("create temp dir");
let events = HookEventsToml {
session_end: vec![MatcherGroup {
matcher: None,
hooks: vec![HookHandlerConfig::McpTool {
server: "policy".to_string(),
tool: "check".to_string(),
input: Default::default(),
timeout_sec: None,
status_message: None,
}],
}],
..Default::default()
};
let config_layer_stack = required_hooks_stack(
managed_hooks_for_current_platform(temp.path(), events),
RequirementSource::LegacyManagedConfigTomlFromMdm,
);
let error = crate::Hooks::new(
crate::HooksConfig {
feature_enabled: true,
config_layer_stack: Some(config_layer_stack),
..Default::default()
},
ThreadId::new(),
)
.err()
.expect("required managed SessionEnd MCP hook should reject startup");
assert!(
error
.to_string()
.contains("SessionEnd MCP hooks are not supported")
);
}
#[test]
fn required_managed_hooks_with_unknown_source_still_reject_discovery_failures() {
let temp = tempdir().expect("create temp dir");
@@ -493,6 +580,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().is_empty());
@@ -510,6 +598,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
mcp_executor: None,
});
assert!(listed.hooks[0].is_managed);
let cwd = cwd();
@@ -600,6 +689,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
let outcome = engine
@@ -680,6 +770,7 @@ fn unknown_requirement_source_hooks_stay_managed() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert_eq!(engine.handlers.len(), 1);
@@ -763,6 +854,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert_eq!(engine.handlers.len(), 1);
@@ -829,6 +921,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert_eq!(engine.handlers.len(), 1);
@@ -991,6 +1084,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().is_empty());
@@ -1054,6 +1148,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().is_empty());
@@ -1067,8 +1162,11 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
assert_eq!(discovered.hook_entries.len(), 1);
assert!(!discovered.hook_entries[0].is_managed);
assert_eq!(
discovered.hook_entries[0].command.as_deref(),
Some("python3 /tmp/user-hook.py")
discovered.hook_entries[0].handler,
HookListEntryHandler::Command {
command: "python3 /tmp/user-hook.py".to_string(),
r#async: false,
}
);
}
@@ -1108,6 +1206,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().is_empty());
@@ -1121,8 +1220,11 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
assert_eq!(discovered.hook_entries.len(), 1);
assert!(!discovered.hook_entries[0].is_managed);
assert_eq!(
discovered.hook_entries[0].command.as_deref(),
Some("python3 /tmp/user-hook.py")
discovered.hook_entries[0].handler,
HookListEntryHandler::Command {
command: "python3 /tmp/user-hook.py".to_string(),
r#async: false,
}
);
}
@@ -1178,6 +1280,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.handlers.is_empty());
@@ -1217,6 +1320,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.handlers.is_empty());
@@ -1289,6 +1393,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().is_empty());
@@ -1298,6 +1403,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
.iter()
.map(|handler| match &handler.kind {
ConfiguredHandlerKind::Command { command, .. } => Some(command.as_str()),
ConfiguredHandlerKind::McpTool { .. } => None,
})
.collect::<Vec<_>>(),
vec![
@@ -1401,6 +1507,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().iter().any(|warning| {
@@ -1496,6 +1603,7 @@ fn profile_user_layers_load_shared_hooks_json_once() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.warnings().is_empty());
@@ -1570,6 +1678,7 @@ fn malformed_hooks_json_is_reported_as_startup_warning() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(engine.handlers.is_empty());
@@ -1642,6 +1751,7 @@ print(json.dumps({
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
@@ -1669,6 +1779,7 @@ print(json.dumps({
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
mcp_executor: None,
});
assert_eq!(
listed.hooks[0].plugin_id.as_deref(),
@@ -1762,6 +1873,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert_eq!(
@@ -1806,7 +1918,143 @@ fn plugin_hook_load_warnings_are_startup_warnings() {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);
}
struct StaticMcpExecutor {
calls: Arc<Mutex<Vec<HookMcpCall>>>,
output: String,
}
impl HookMcpExecutor for StaticMcpExecutor {
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>> {
async move {
self.calls.lock().expect("lock MCP calls").push(call);
Ok(self.output.clone())
}
.boxed()
}
}
#[tokio::test]
async fn mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
fs::write(
temp.path().join("hooks.json"),
serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "mcp_tool",
"server": "security",
"tool": "scan",
"input": { "command": "${tool_input.command}" },
"timeout": 20,
}],
}],
},
})
.to_string(),
)
.expect("write MCP hooks.json");
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_path,
profile: None,
},
TomlValue::Table(Default::default()),
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let unavailable = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
command_runtime(CommandShell {
program: String::new(),
args: Vec::new(),
}),
/*mcp_executor*/ None,
);
assert!(unavailable.handlers.is_empty());
assert_eq!(unavailable.warnings().len(), 1);
assert!(unavailable.warnings()[0].contains("MCP invocation is not available"));
let calls = Arc::new(Mutex::new(Vec::new()));
let executor = StaticMcpExecutor {
calls: Arc::clone(&calls),
output: serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "blocked by MCP scanner",
},
})
.to_string(),
};
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
command_runtime(CommandShell {
program: String::new(),
args: Vec::new(),
}),
Some(Arc::new(executor)),
);
let outcome = engine
.run_pre_tool_use(PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "rm important.txt" }),
})
.await;
assert!(outcome.should_block);
assert_eq!(
outcome.block_reason.as_deref(),
Some("blocked by MCP scanner")
);
assert_eq!(
outcome.hook_events[0].run.handler_type,
HookHandlerType::McpTool
);
assert_eq!(
outcome.hook_events[0].run.execution_mode,
codex_protocol::protocol::HookExecutionMode::Sync
);
assert_eq!(
*calls.lock().expect("lock MCP calls"),
vec![HookMcpCall {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::from_value(serde_json::json!({
"command": "rm important.txt",
}))
.expect("object input"),
timeout: Duration::from_secs(20),
}]
);
}
+4
View File
@@ -3,6 +3,7 @@ mod declarations;
mod engine;
pub(crate) mod events;
mod legacy_notify;
mod mcp;
mod output_spill;
mod registry;
mod schema;
@@ -14,6 +15,7 @@ pub use config_rules::hook_states_from_stack;
pub use declarations::PluginHookDeclaration;
pub use declarations::plugin_hook_declarations;
pub use engine::HookListEntry;
pub use engine::HookListEntryHandler;
pub use events::common::SubagentHookContext;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 11] = [
@@ -71,6 +73,8 @@ pub use events::user_prompt_submit::UserPromptSubmitOutcome;
pub use events::user_prompt_submit::UserPromptSubmitRequest;
pub use legacy_notify::legacy_notify_json;
pub use legacy_notify::notify_hook;
pub use mcp::HookMcpCall;
pub use mcp::HookMcpExecutor;
pub use registry::HookListOutcome;
pub use registry::Hooks;
pub use registry::HooksConfig;
+22
View File
@@ -0,0 +1,22 @@
use std::time::Duration;
use futures::future::BoxFuture;
use serde_json::Map;
use serde_json::Value;
/// One MCP tool call requested by a configured hook handler.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookMcpCall {
pub server: String,
pub tool: String,
pub input: Map<String, Value>,
pub timeout: Duration,
}
/// Executes already-connected MCP tools on behalf of hooks without coupling this crate to core.
///
/// Implementations own server readiness, policy enforcement, timeout handling, and elicitation.
pub trait HookMcpExecutor: Send + Sync {
/// Returns text that is interpreted using ordinary command-hook output semantics.
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>>;
}
+4
View File
@@ -20,6 +20,7 @@ use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
use crate::mcp::HookMcpExecutor;
use crate::types::Hook;
use crate::types::HookEvent;
use crate::types::HookPayload;
@@ -29,6 +30,7 @@ use codex_config::ConfigLayerStack;
use codex_plugin::PluginHookSource;
use codex_protocol::ThreadId;
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
@@ -42,6 +44,7 @@ pub struct HooksConfig {
pub plugin_hook_load_warnings: Vec<String>,
pub shell_program: Option<String>,
pub shell_args: Vec<String>,
pub mcp_executor: Option<Arc<dyn HookMcpExecutor>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -105,6 +108,7 @@ impl Hooks {
config.plugin_hook_sources,
config.plugin_hook_load_warnings,
command_runtime,
config.mcp_executor,
);
Self {
after_agent,
+1
View File
@@ -1514,6 +1514,7 @@ pub enum HookEventName {
#[serde(rename_all = "snake_case")]
pub enum HookHandlerType {
Command,
McpTool,
Prompt,
Agent,
}
@@ -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
+5 -5
View File
@@ -306,8 +306,7 @@ mod tests {
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
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;
@@ -321,11 +320,12 @@ mod tests {
HookMetadata {
key: key.to_string(),
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
handler: HookHandlerMetadata::Command {
command: "/tmp/hook.sh".to_string(),
r#async: false,
},
is_managed: false,
matcher: Some("Bash".to_string()),
command: Some("/tmp/hook.sh".to_string()),
timeout_sec: 30,
status_message: None,
additional_context_limit: None,