mcp_tool Stop hook fails open when MCP server is missing or cannot start

Open 💬 2 comments Opened Aug 21, 2026 by yangtzu520

What version of Codex CLI is running?

codex-cli 0.149.0

What subscription do you have?

ChatGPT Pro

Which model were you using?

Default OpenAI model selected by Codex CLI.

What platform is your computer?

Linux 7.0.0-29-generic x86_64 x86_64 (Linux Mint 22.3)

What terminal emulator and version are you using?

Kitty on Linux; reproduced with non-interactive codex exec --ephemeral.

Codex doctor report

Redacted summary: overallStatus ok; Codex 0.149.0; installation and update target consistent; config loaded; MCP configuration locally consistent; provider HTTP and WebSocket checks pass; state databases healthy. The full report contains only local-path details and does not show a failing prerequisite.

What issue are you seeing?

An mcp_tool Stop hook works when its configured stdio MCP server is available, but fails open when that MCP server is unavailable.

Positive control on 0.149.0: the Stop hook successfully invoked two MCP tools in sequence and the second tool wrote a valid terminal-seal fixture. This confirms that the MCP hook executor is wired in this release.

Negative controls:

  1. The Stop hook references an MCP server name that is not registered.
  2. The referenced MCP server is registered, but its command points to a nonexistent executable and cannot start.

In both cases Codex emitted the requested agent message followed by turn.completed. There was no hook failure event in JSONL output and the Stop hook did not block completion.

This makes an MCP-backed validation, compliance, or release gate fail open precisely when its validator is unavailable.

What steps can reproduce the bug?

  1. Enable the stable hooks feature.
  2. Configure a Stop hook similar to:
{
  "hooks": {
    "Stop": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "repro_validator",
            "tool": "validate",
            "input": {},
            "timeout": 20
          }
        ]
      }
    ]
  }
}
  1. First register a working stdio server named repro_validator whose validate tool returns a successful Stop-hook response. Run:
codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-hook-trust --json "Reply exactly POSITIVE_CONTROL"

Confirm the MCP tool is invoked. This positive control passes on 0.149.0.

  1. Remove repro_validator from the MCP configuration while leaving the hook enabled. Run the same command. The output still ends with turn.completed.
  1. Register repro_validator again with a nonexistent command, for example /__missing_mcp_server__, and run the same command. The output again ends with turn.completed.

Observed negative-control shape:

{"type":"item.completed","item":{"type":"agent_message","text":"..."}}
{"type":"turn.completed","usage":{}}

No successful MCP validation occurred in either negative control.

What is the expected behavior?

If an enabled mcp_tool Stop hook cannot resolve, start, or call its MCP server, Stop should fail closed. Codex should block completion or return a non-success terminal result that callers cannot mistake for a completed turn. JSONL output should identify the failed hook and MCP failure.

Additional information

The same fixture was also exercised with ten validator fault-injection cases; all validator-side cases pass. The remaining issue is specifically runtime behavior when the MCP hook dependency is missing or cannot start.

This is reproducible after a clean Canary rollback: the test hook and MCP registration were removed, and no production configuration or data is involved.

View original on GitHub ↗

2 Comments

swayamg20 · 4 days ago

I reproduced both negative controls on fresh main:

  • An unregistered MCP server produces HookCompleted with Stop / Failed (unknown MCP server), followed by TurnComplete.
  • A registered server whose executable cannot start reports the startup failure, then produces HookCompleted with Stop / Failed (server is not connected), again followed by TurnComplete.

The MCP failure is therefore detected correctly. mcp_runner converts it into HandlerRunResult.error, and the Stop parser records HookRunStatus::Failed. However, a failed result carries no control effect, aggregation only considers explicit Stopped or Blocked results, and codex exec --json currently discards HookStarted and HookCompleted notifications.

This seems like two separate concerns:

  1. Expose structured hook.started and hook.completed events in exec JSONL without changing existing behavior.
  2. Add an explicit failure policy for synchronous local hooks, perhaps failureMode = "continue" | "failTurn", retaining continue as the compatibility default and applying it consistently to command and MCP handlers.

I think failTurn is preferable to translating infrastructure failure into block, because blocking re-enters the model loop while the validator remains unavailable. Async and executor-scoped hooks should remain non-controlling.

Does this match the intended hook contract, and is there a preferred configuration surface? I’d be happy to prepare the implementation if the team wants an external patch.

yangtzu520 · 3 days ago

Thanks for reproducing both negative controls on fresh main. Your
findings match what we observed: the MCP failure is visible in the
hook lifecycle, but it has no control effect, so the turn can still
complete without the required validator having run.

The proposed opt-in failureMode = continue | failTurn for
synchronous local hooks fits our use case well.

Our Stop hook invokes a Validator MCP as a completion gate. The
required invariant is:

No machine-verifiable validator PASS, no COMPLETED state.

For that reason, an unknown MCP or an unstartable registered MCP must
not fall through to normal TurnComplete. We agree that failTurn is
a better representation of this infrastructure failure than
translating it into Blocked: the validator was unavailable, so there
is no meaningful model action that can resolve the condition, and
re-entering the model loop could create retries without restoring the
missing validation authority.

Keeping continue as the compatibility default also seems
appropriate, while allowing explicitly configured synchronous local
command/MCP hooks to be controlling. Keeping asynchronous or
executor-scoped hooks non-controlling is consistent with the boundary
we need.

One distinction we would want to preserve is between:

  1. hook infrastructure failure — unknown MCP, server startup failure,

transport/execution failure — which should trigger failTurn; and

  1. a successfully executed validator returning a negative validation

result — which should continue to use the validator’s structured
result/blocking contract.

Structured hook.started / hook.completed JSONL events would also
be useful for independently verifying which path occurred.

So, from our Stop Hook → Validator use case, this direction addresses
the current gap. We would be happy to test the implementation against
our two negative controls and the normal validator-success path, but
we are not making any assumptions about implementation or merge
decisions.