mcp-server: notifications/cancelled halts work but tools/call is never resolved

Resolved 💬 4 comments Opened May 4, 2026 by carlpaten Closed Aug 26, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.128.0

What subscription do you have?

ChatGPT Plus

Which model were you using?

Default (whatever codex mcp-server uses with no model override)

What platform is your computer?

Darwin 25.4.0 arm64 arm

What terminal emulator and version are you using (if applicable)?

N/A (probe is non-interactive Python)

What issue are you seeing?

When an MCP client sends notifications/cancelled for an in-flight codex tool call to codex mcp-server, Codex correctly halts further work (no additional shell calls are issued), but the JSON-RPC tools/call request is never resolved with a result or an error. From the client's perspective the request hangs forever.

Per the MCP spec, a cancelled request is expected to receive a final response (typically an error result) so the client can release the request slot and surface the cancellation to the user. Currently the only way for a client to recover is to kill the entire codex mcp-server process, which loses every active threadId in that server.

What steps can reproduce the bug?

Self-contained Python repro (~80 lines, uses only the standard library):

#!/usr/bin/env python3
import json, subprocess, threading, queue, time, tempfile, pathlib

class C:
    def __init__(self):
        self.p = subprocess.Popen(["codex","mcp-server"], stdin=subprocess.PIPE,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
        self.q = queue.Queue(); self.nid = 1
        threading.Thread(target=self._r, daemon=True).start()
    def _r(self):
        for line in self.p.stdout:
            try: m = json.loads(line)
            except: continue
            if "id" in m and ("result" in m or "error" in m): self.q.put(m)
    def s(self, method, params=None, notif=False):
        msg = {"jsonrpc":"2.0","method":method}
        if params: msg["params"]=params
        if not notif: msg["id"]=self.nid; self.nid+=1
        self.p.stdin.write((json.dumps(msg)+"\n").encode()); self.p.stdin.flush()
        return msg.get("id")
    def wait(self, rid, timeout):
        end = time.time()+timeout
        while time.time()<end:
            try:
                m = self.q.get(timeout=max(0.1, end-time.time()))
                if m.get("id")==rid: return m
            except queue.Empty: return None

wd = tempfile.mkdtemp(prefix="codex-cancel-")
c = C()
rid = c.s("initialize",{"protocolVersion":"2024-11-05","capabilities":{},
    "clientInfo":{"name":"probe","version":"0"}})
c.wait(rid, 10)
c.s("notifications/initialized", notif=True)

prompt = (f"Create 60 files in {wd} named file_001.txt..file_060.txt, each containing its number. "
          f"Run a separate shell command per file with `sleep 1` between each. After all 60, reply DONE.")
rid = c.s("tools/call",{"name":"codex","arguments":{
    "prompt":prompt,"cwd":wd,"sandbox":"workspace-write","approval-policy":"never"}})

# wait until at least 3 files exist (Codex is mid-shell-work), then cancel
p = pathlib.Path(wd)
while len(list(p.glob("file_*.txt"))) < 3: time.sleep(0.2)
n_at_cancel = len(list(p.glob("file_*.txt")))
print(f"cancelling with {n_at_cancel} files on disk")
c.s("notifications/cancelled",{"requestId":rid,"reason":"test"}, notif=True)
t0 = time.time()

r = c.wait(rid, 90)
print(f"response after {time.time()-t0:.1f}s: {r}")
time.sleep(5)
n_final = len(list(p.glob("file_*.txt")))
print(f"files: at_cancel={n_at_cancel}, +5s_after_cancel={n_final}, delta={n_final-n_at_cancel}")

c.p.terminate()

Output on my machine:

cancelling with 3 files on disk
response after 90.0s: None
files: at_cancel=3, +5s_after_cancel=3, delta=0

So: cancellation was honored at the runtime (zero additional files created over the next 90s+, when uncancelled the task would have produced ~50 more), but the tools/call never returned.

What is the expected behavior?

The cancelled tools/call should receive a final JSON-RPC response — per the MCP spec, typically an error result with a "request was cancelled" message — so the client can release the request and inform the user. The current behavior leaves the client unable to distinguish "cancelled but still cleaning up" from "permanently hung," and the only recovery is killing the server.

Additional information

  • 23 cancel-related notifications are emitted on the side channel during the hang, so the server is internally aware of the cancellation; the gap is specifically that the original request is never resolved.
  • This affects clients that rely on tools/call returning to track outstanding requests (e.g. orchestration use cases that need to abandon a Codex turn and start a fresh one).

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #20194

Powered by Codex Action

JYeswak · 3 months ago

Adding downstream orchestration evidence because this matches a failure class we hit from the client side.

Local environment:

  • codex-cli 0.125.0 in flywheel worker panes
  • macOS 26.3 arm64
  • MCP-heavy worker loop using Agent Mail + Socraticode + NTM dispatch/callback validation

Local signature:

  • During Agent Mail FD pressure, workers saw MCP calls stop making progress and the MCP surface looked dead until a reconnect.
  • Our Agent Mail diagnosis found the service near/well over practical FD thresholds: one run captured 209 leaked lock/archive FDs to unlinked paths, and the apply run reduced descriptor count from 269 to 57 after restart.
  • The operational symptom for the worker is the same contract shape as this issue: the runtime/substrate may stop work, but the in-flight MCP call never reaches a terminal result/error from the client perspective, so the client cannot distinguish "cancelled/aborted cleanly" from "request slot orphaned forever."

Where our current coverage is insufficient:

  • We have an agent_mail_fd_pressure doctor signal and pane capture_provenance checks.
  • Those do not cover orphaned MCP request lifecycle directly. capture_provenance=unavailable only says pane text capture failed; it does not count unresolved tools/call requests after cancellation/abort.
  • I filed a local gap bead to add orphaned_mcp_tool_call_count as a separate doctor signal: flywheel-2b0n.

Tracking on our side:

  • Upstream sibling triage: flywheel-7dkw
  • Local gap: flywheel-2b0n

No implementation ask here beyond the issue's existing contract: cancelled/aborted MCP tools/call should resolve with a terminal response so clients can release the request slot without killing the server.

MikeeI · 25 days ago

Hi, thanks for documenting this.

I noticed one detail that may be relevant to the same root cause:

  • The audited current source explains the issue's unresolved cancelled request: codex_tool_runner.rs lines 297–310 shows TurnComplete building a CallToolResult, calling active_turns.finish, sending the response, and breaking; lines 324–392 puts TurnAborted in the complete ignored arm, which does not finish or respond to the active tools/call.

Would it make sense for TurnAborted to finish the active request with an error response, analogous to the terminal TurnComplete path?

I checked all relevant issues, comments, pull requests, discussions, and release notes; this evidence is not already reported.

I am only reporting this finding and am not proposing a pull request unless a maintainer invites one.

Disclosure

Investigated thoroughly with GPT-5.6 Sol (runtime-default reasoning effort), using Oh My Pi as the agent framework.

This report is not generic or unreviewed AI-generated output. Its claims were checked against the cited evidence, and it includes the relevant detail intended to help maintainers resolve the issue.

If reports like this are not useful to the project, please let me know and I will refrain from submitting similar ones. My intent is to help without wasting maintainer time or energy or discouraging their work.

Thank you for your work.

etraut-openai contributor · 2 days ago

Note: We've announced that the mcp-server is deprecated and will be removed in a future release. We recommend using the app server interface instead. We won't be prioritizing any bug fixes in the mcp-server, so I'm going to close this issue.