Support MCP tools in hooks (#18385)

## Summary

Lifecycle hooks currently treat `PreToolUse`, `PostToolUse`, and
`PermissionRequest` as Bash-only flows
- hook schema constrains `tool_name` to `Bash`
- hook input assumes a command-shaped `tool_input`
- core hook dispatch path passes only shell command strings

That means hooks cannot target MCP tools even though MCP tool names are
model-visible and stable

This change generalizes those hook paths so they can match and receive
payloads for MCP tools while preserving the existing Bash behavior.

## Reviewer Notes

I think these are the key files
- `codex-rs/core/src/tools/handlers/mcp.rs`
- `codex-rs/core/src/mcp_tool_call.rs`

Otherwise the changes across apply_patch, shell, and unified_exec are
mainly to rewire everything to be `tool_input` based instead of just
`command` so that it'll make sense for MCP tools.

## Changes

- Allow `PreToolUse`, `PostToolUse`, and `PermissionRequest` hook inputs
to carry arbitrary `tool_name` and `tool_input` values instead of
hard-coding `Bash` and command-only payloads.
- Add MCP hook payload support through `McpHandler`, using the
model-visible tool name from `ToolInvocation` and the raw MCP arguments
as `tool_input`.
- Include MCP tool responses in `PostToolUse` by serializing
`McpToolOutput` into the hook response payload.
- Run `PermissionRequest` hooks for MCP approval requests after
remembered approval checks and before falling back to user-facing MCP
elicitation.
- Preserve exact matching for literal hook matchers like `Bash` and
`mcp__memory__create_entities`, while keeping regex matcher support for
patterns like `mcp__memory__.*` and `mcp__.*__write.*`.

---------

Co-authored-by: Andrei Eternal <eternal@openai.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-23 07:33:57 +00:00
committed by GitHub
co-authored by Andrei Eternal Codex
parent 8bc667b07b
commit 305825abd9
34 changed files with 1293 additions and 361 deletions
+4 -4
View File
@@ -125,7 +125,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source_path, managed_dir);
@@ -141,7 +141,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
})
.await;
@@ -212,7 +212,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
})
.is_empty()
);
@@ -318,7 +318,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 2);
assert!(engine.handlers.iter().all(|handler| !handler.is_managed));