Skip to content

Support MCP tools in hooks#18385

Merged
abhinav-oai merged 29 commits intomainfrom
abhinav/hooks-mcp-tools-support
Apr 23, 2026
Merged

Support MCP tools in hooks#18385
abhinav-oai merged 29 commits intomainfrom
abhinav/hooks-mcp-tools-support

Conversation

@abhinav-oai
Copy link
Copy Markdown
Collaborator

@abhinav-oai abhinav-oai commented Apr 17, 2026

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.*.

abhinav-oai added a commit that referenced this pull request Apr 17, 2026
@abhinav-oai abhinav-oai requested a review from a team as a code owner April 20, 2026 17:54
@abhinav-oai
Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown
Contributor

Codex Review: Didn't find any major issues. Hooray!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Conflicts:
#	codex-rs/core/src/tools/handlers/unified_exec.rs
#	codex-rs/core/src/tools/registry.rs
#	codex-rs/core/src/unified_exec/mod.rs
Comment thread codex-rs/core/src/tools/handlers/mcp.rs Outdated
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: invocation.tool_name.display(),
tool_input: mcp_hook_tool_input(raw_arguments),
Copy link
Copy Markdown
Contributor

@eternal-openai eternal-openai Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook payload here is built from raw_arguments, but the actual dispatch path can rewrite MCP arguments before the server call, for example when openai/fileParams uploads a local file and replaces the path with the provided-file payload. The hooks spec defines PostToolUse.tool_input as the arguments sent to the tool, so this will make post hooks audit the wrong request on those paths.

Comment thread codex-rs/core/tests/suite/hooks_mcp.rs Outdated
Comment thread codex-rs/core/src/tools/registry.rs Outdated
Comment thread codex-rs/core/src/mcp_tool_call.rs Outdated
Comment thread codex-rs/core/src/mcp_tool_call.rs Outdated
Comment thread codex-rs/core/src/hook_runtime.rs Outdated
@abhinav-oai

This comment was marked as outdated.

@abhinav-oai abhinav-oai force-pushed the abhinav/hooks-mcp-tools-support branch from 74b0d89 to eb4bc8a Compare April 22, 2026 17:01
@abhinav-oai abhinav-oai force-pushed the abhinav/hooks-mcp-tools-support branch from eb4bc8a to 8414af5 Compare April 22, 2026 17:27
Align apply_patch and MCP permission payloads with the rebased HookToolName plus generic tool_input model.

Co-authored-by: Codex <noreply@openai.com>
@abhinav-oai abhinav-oai force-pushed the abhinav/hooks-mcp-tools-support branch from 8414af5 to 9d32374 Compare April 22, 2026 17:35
Comment thread codex-rs/core/src/tools/handlers/mcp.rs
} else {
format!(
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
tool_name.name()
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also show the tool call with the input?

@abhinav-oai abhinav-oai force-pushed the abhinav/hooks-mcp-tools-support branch from 8f7e88a to 9d32374 Compare April 22, 2026 19:00
@abhinav-oai abhinav-oai changed the base branch from main to eternal/hooks_unified_exec_post_tool_use April 22, 2026 19:02
@abhinav-oai abhinav-oai changed the base branch from eternal/hooks_unified_exec_post_tool_use to main April 22, 2026 19:12
@abhinav-oai abhinav-oai changed the base branch from main to eternal/hooks_unified_exec_post_tool_use April 22, 2026 21:27
…tool_use' into abhinav/hooks-mcp-tools-support

# Conflicts:
#	codex-rs/core/src/tools/handlers/apply_patch.rs
#	codex-rs/core/src/tools/handlers/apply_patch_tests.rs
#	codex-rs/core/src/tools/handlers/shell.rs
#	codex-rs/core/src/tools/handlers/shell_tests.rs
#	codex-rs/core/src/tools/handlers/unified_exec.rs
#	codex-rs/core/src/tools/handlers/unified_exec_tests.rs
#	codex-rs/core/src/tools/registry.rs
#	codex-rs/hooks/src/events/common.rs
Comment thread codex-rs/core/src/tools/handlers/mcp.rs Outdated
pub(crate) tool_input: JsonValue,
}

async fn handle_approved_mcp_tool_call(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pulled this into a helper because we had two “allowed to execute” paths doing the same work

  1. explicit approval (Accept*)
  2. no-approval-needed (None)

also when rewrite_mcp_tool_arguments_for_openai_files rewrites file params, the same rewritten args are sent to the MCP server and exposed to PostToolUse; otherwise we fall back to the original model args which was the issue @eternal-openai pointed out

Base automatically changed from eternal/hooks_unified_exec_post_tool_use to main April 23, 2026 00:14
…ls-support

# Conflicts:
#	codex-rs/core/src/tools/handlers/apply_patch.rs
#	codex-rs/core/src/tools/handlers/apply_patch_tests.rs
#	codex-rs/core/src/tools/handlers/shell.rs
#	codex-rs/core/src/tools/handlers/shell_tests.rs
#	codex-rs/core/src/tools/handlers/unified_exec.rs
#	codex-rs/core/src/tools/handlers/unified_exec_tests.rs
#	codex-rs/core/src/tools/registry.rs
#	codex-rs/hooks/src/events/common.rs
@abhinav-oai
Copy link
Copy Markdown
Collaborator Author

PR 18385 MCP Hook Evidence

Generated from a live codex exec run using the current source tree, an isolated CODEX_HOME, and the bundled test_stdio_server MCP server.

Setup

  • Repo: /Users/abhinav/code/codex
  • Codex binary: /Users/abhinav/code/codex/codex-rs/target/debug/codex
  • MCP stdio server: /Users/abhinav/code/codex/codex-rs/target/debug/test_stdio_server
  • Harness root: /tmp/codex-pr18385-mcp-hooks
  • Tests were not run; this is live binary evidence only.
  • MCP server name: rmcp; tool namespace: mcp__rmcp__.
  • Hook matchers installed for each of PreToolUse, PermissionRequest, and PostToolUse: ^mcp__rmcp__echo$, ^mcp__rmcp__.*, *, ^mcp__other__.*, and ^mcp__rmcp__echo_tool$.

Summary Matrix

Scenario Result What it proved PreToolUse matchers PermissionRequest matchers PostToolUse matchers
SCENARIO_PRE_ALLOW PASS Baseline MCP echo with server default approval=approve. {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1} {} {'namespace_rmcp': 1, 'exact_echo': 1, 'wildcard': 1}
SCENARIO_MATCHER_CWD PASS MCP cwd tool should match namespace and wildcard but not echo exact. {'namespace_rmcp': 1, 'wildcard': 1} {} {'namespace_rmcp': 1, 'wildcard': 1}
SCENARIO_DASH_TOOL PASS MCP tool with a dash in the raw name should still use the canonical full hook name. {'wildcard': 1, 'namespace_rmcp': 1, 'exact_dash_tool': 1} {} {'namespace_rmcp': 1, 'exact_dash_tool': 1, 'wildcard': 1}
SCENARIO_PRE_DENY PASS PreToolUse denial should block MCP execution before PostToolUse. {'namespace_rmcp': 1, 'exact_echo': 1, 'wildcard': 1} {} {}
SCENARIO_PERMISSION_ALLOW PASS PermissionRequest hook allow should approve a prompted MCP tool without UI approval. {'wildcard': 1, 'namespace_rmcp': 1, 'exact_echo': 1} {'exact_echo': 1, 'wildcard': 1, 'namespace_rmcp': 1} {'exact_echo': 1, 'wildcard': 1, 'namespace_rmcp': 1}
SCENARIO_PERMISSION_DENY PASS PermissionRequest hook deny should reject a prompted MCP tool. {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1} {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1} {}
SCENARIO_POST_CONTEXT PASS PostToolUse should see the MCP response and feed additional context into the next model request. {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1} {} {'exact_echo': 1, 'wildcard': 1, 'namespace_rmcp': 1}

Scenario Evidence

SCENARIO_PRE_ALLOW

  • Result: PASS
  • Expected: PreToolUse and PostToolUse exact/namespace/wildcard matchers fire; PermissionRequest does not.
  • Exit code: 0
  • PreToolUse: {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1}
  • PermissionRequest: {}
  • PostToolUse: {'namespace_rmcp': 1, 'exact_echo': 1, 'wildcard': 1}
  • PostToolUse response sample: {"content": [], "isError": false, "structuredContent": {"echo": "ECHOING: SCENARIO_PRE_ALLOW", "env": null}}

Mini chat transcript:

User: SCENARIO_PRE_ALLOW: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PostToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: MCP tool output was sent back to the model.
Assistant: final assistant response for SCENARIO_PRE_ALLOW

SCENARIO_MATCHER_CWD

  • Result: PASS
  • Expected: Matcher discrimination works for another tool in the same server namespace.
  • Exit code: 0
  • PreToolUse: {'namespace_rmcp': 1, 'wildcard': 1}
  • PermissionRequest: {}
  • PostToolUse: {'namespace_rmcp': 1, 'wildcard': 1}
  • PostToolUse response sample: {"content": [], "isError": false, "structuredContent": {"cwd": "/private/tmp/codex-pr18385-mcp-hooks"}}

Mini chat transcript:

User: SCENARIO_MATCHER_CWD: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PostToolUse matched [('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: MCP tool output was sent back to the model.
Assistant: final assistant response for SCENARIO_MATCHER_CWD

SCENARIO_DASH_TOOL

  • Result: PASS
  • Expected: Raw MCP tool echo-tool is exposed to the model and hooks as mcp__rmcp__echo_tool; dash-specific normalized matcher fires.
  • Exit code: 0
  • PreToolUse: {'wildcard': 1, 'namespace_rmcp': 1, 'exact_dash_tool': 1}
  • PermissionRequest: {}
  • PostToolUse: {'namespace_rmcp': 1, 'exact_dash_tool': 1, 'wildcard': 1}
  • PostToolUse response sample: {"content": [], "isError": false, "structuredContent": {"echo": "ECHOING: SCENARIO_DASH_TOOL", "env": null}}

Mini chat transcript:

User: SCENARIO_DASH_TOOL: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('exact_dash_tool', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PostToolUse matched [('exact_dash_tool', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: MCP tool output was sent back to the model.
Assistant: final assistant response for SCENARIO_DASH_TOOL

SCENARIO_PRE_DENY

  • Result: PASS
  • Expected: PreToolUse returns deny, tool output reports hook block, PostToolUse does not run.
  • Exit code: 0
  • PreToolUse: {'namespace_rmcp': 1, 'exact_echo': 1, 'wildcard': 1}
  • PermissionRequest: {}
  • PostToolUse: {}

Mini chat transcript:

User: SCENARIO_PRE_DENY: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: blocked before MCP execution by PreToolUse deny.
Assistant: final assistant response for SCENARIO_PRE_DENY

SCENARIO_PERMISSION_ALLOW

  • Result: PASS
  • Expected: PermissionRequest exact/namespace/wildcard matchers fire and allow; tool executes; PostToolUse runs.
  • Exit code: 0
  • PreToolUse: {'wildcard': 1, 'namespace_rmcp': 1, 'exact_echo': 1}
  • PermissionRequest: {'exact_echo': 1, 'wildcard': 1, 'namespace_rmcp': 1}
  • PostToolUse: {'exact_echo': 1, 'wildcard': 1, 'namespace_rmcp': 1}
  • PostToolUse response sample: {"content": [], "isError": false, "structuredContent": {"echo": "ECHOING: SCENARIO_PERMISSION_ALLOW", "env": null}}

Mini chat transcript:

User: SCENARIO_PERMISSION_ALLOW: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PermissionRequest matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PostToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: MCP tool output was sent back to the model.
Assistant: final assistant response for SCENARIO_PERMISSION_ALLOW

SCENARIO_PERMISSION_DENY

  • Result: PASS
  • Expected: PermissionRequest deny wins; tool does not execute; PostToolUse does not run.
  • Exit code: 0
  • PreToolUse: {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1}
  • PermissionRequest: {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1}
  • PostToolUse: {}

Mini chat transcript:

User: SCENARIO_PERMISSION_DENY: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PermissionRequest matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: denied by PermissionRequest before MCP execution.
Assistant: final assistant response for SCENARIO_PERMISSION_DENY

SCENARIO_POST_CONTEXT

  • Result: PASS
  • Expected: PostToolUse logs structuredContent and the follow-up request contains POST_CONTEXT_MARKER.
  • Exit code: 0
  • PreToolUse: {'namespace_rmcp': 1, 'wildcard': 1, 'exact_echo': 1}
  • PermissionRequest: {}
  • PostToolUse: {'exact_echo': 1, 'wildcard': 1, 'namespace_rmcp': 1}
  • PostToolUse response sample: {"content": [], "isError": false, "structuredContent": {"echo": "ECHOING: SCENARIO_POST_CONTEXT", "env": null}}

Mini chat transcript:

User: SCENARIO_POST_CONTEXT: call the configured rmcp MCP tool exactly once.
Assistant/tool request: mock Responses emitted one mcp__rmcp__ function_call for this scenario.
Hooks: PreToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Hooks: PostToolUse matched [('exact_echo', 1), ('namespace_rmcp', 1), ('wildcard', 1)].
Tool result: MCP tool output was sent back to the model.
Follow-up request: contained POST_CONTEXT_MARKER from PostToolUse additionalContext.
Assistant: final assistant response for SCENARIO_POST_CONTEXT

Raw Evidence Files

  • Mock request log: /tmp/codex-pr18385-mcp-hooks/mock_requests.jsonl
  • Approve home evidence: /tmp/codex-pr18385-mcp-hooks/codex-home-approve/evidence
  • Prompt home evidence: /tmp/codex-pr18385-mcp-hooks/codex-home-prompt/evidence
  • Per-scenario codex exec --json output files are in the harness root.

Notes

  • ^mcp__other__.* never appears in the matcher counts, which is the negative-control evidence for matcher filtering.
  • SCENARIO_PRE_DENY has no PostToolUse entries, which shows the MCP call was blocked before execution.
  • SCENARIO_PERMISSION_DENY has no PostToolUse entries, which shows approval denial prevented execution.
  • SCENARIO_POST_CONTEXT records POST_CONTEXT_MARKER in the second Responses request, proving PostToolUse additional context was injected into the model continuation.

@abhinav-oai abhinav-oai enabled auto-merge (squash) April 23, 2026 04:49
@abhinav-oai abhinav-oai merged commit 305825a into main Apr 23, 2026
25 checks passed
@abhinav-oai abhinav-oai deleted the abhinav/hooks-mcp-tools-support branch April 23, 2026 07:34
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants