codex-cli-mcp-slim 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {codex_cli_mcp_slim-0.1.1/src/codex_cli_mcp_slim.egg-info → codex_cli_mcp_slim-0.1.2}/PKG-INFO +26 -1
  2. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/README.md +25 -0
  3. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/pyproject.toml +1 -1
  4. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/server.py +80 -12
  5. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2/src/codex_cli_mcp_slim.egg-info}/PKG-INFO +26 -1
  6. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/SOURCES.txt +1 -0
  7. codex_cli_mcp_slim-0.1.2/tests/test_progress.py +306 -0
  8. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/LICENSE +0 -0
  9. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/setup.cfg +0 -0
  10. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/__init__.py +0 -0
  11. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/py.typed +0 -0
  12. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/dependency_links.txt +0 -0
  13. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/entry_points.txt +0 -0
  14. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/requires.txt +0 -0
  15. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/top_level.txt +0 -0
  16. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_basic.py +0 -0
  17. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_call_tool.py +0 -0
  18. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_subprocess_stdin_isolation.py +0 -0
  19. {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_timeout_handling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-cli-mcp-slim
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A thin, auditable MCP server wrapping the Codex CLI (codex exec). Same tools as the deprecated `codex mcp-server`; forward-compatible with future CLI flags via extra_args passthrough.
5
5
  Author-email: tksfjt1024 <tksfjt1024@gmail.com>
6
6
  License: MIT
@@ -328,6 +328,31 @@ buffered output and reap the process, so the effective ceiling is
328
328
  `timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
329
329
  whatever `codex` had printed so far.
330
330
 
331
+ An MCP client may give up on a call that has sent neither a response nor a
332
+ progress notification for a while. Claude Code 2.1.318 does so after 1800
333
+ seconds by default for a stdio server ("sent no response or progress for
334
+ 1800s"), and a long `codex` turn can take longer than that. While `codex` runs,
335
+ the server therefore sends a `notifications/progress` message every 60 seconds,
336
+ so the call lasts until `codex` finishes or `timeout_seconds` runs out. A client
337
+ gets these only when it sent a `progressToken` with the call, as Claude Code
338
+ does; a client that gives up after less than 60 seconds without progress is not
339
+ covered. With the default `timeout_seconds` of 1800 the call still ends after
340
+ about 30 minutes, at the server's own timeout, so to let `codex` run longer,
341
+ raise `timeout_seconds` (30 to 3600 per call) or `$CODEX_CLI_MCP_SLIM_TIMEOUT`,
342
+ which sets the default. In the notification's `params`, `progress` counts the
343
+ notifications sent so far and `message` says how long `codex` has been running:
344
+
345
+ ```json
346
+ {
347
+ "jsonrpc": "2.0",
348
+ "method": "notifications/progress",
349
+ "params": {"progressToken": 7, "progress": 2, "message": "codex running for 120s"}
350
+ }
351
+ ```
352
+
353
+ If sending a notification raises an error, the server logs it to stderr and the
354
+ call and its result are unaffected.
355
+
331
356
  ## Forward-compatibility example
332
357
 
333
358
  If a future `codex exec` release adds a new flag (say `--super-mode`), use it
@@ -293,6 +293,31 @@ buffered output and reap the process, so the effective ceiling is
293
293
  `timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
294
294
  whatever `codex` had printed so far.
295
295
 
296
+ An MCP client may give up on a call that has sent neither a response nor a
297
+ progress notification for a while. Claude Code 2.1.318 does so after 1800
298
+ seconds by default for a stdio server ("sent no response or progress for
299
+ 1800s"), and a long `codex` turn can take longer than that. While `codex` runs,
300
+ the server therefore sends a `notifications/progress` message every 60 seconds,
301
+ so the call lasts until `codex` finishes or `timeout_seconds` runs out. A client
302
+ gets these only when it sent a `progressToken` with the call, as Claude Code
303
+ does; a client that gives up after less than 60 seconds without progress is not
304
+ covered. With the default `timeout_seconds` of 1800 the call still ends after
305
+ about 30 minutes, at the server's own timeout, so to let `codex` run longer,
306
+ raise `timeout_seconds` (30 to 3600 per call) or `$CODEX_CLI_MCP_SLIM_TIMEOUT`,
307
+ which sets the default. In the notification's `params`, `progress` counts the
308
+ notifications sent so far and `message` says how long `codex` has been running:
309
+
310
+ ```json
311
+ {
312
+ "jsonrpc": "2.0",
313
+ "method": "notifications/progress",
314
+ "params": {"progressToken": 7, "progress": 2, "message": "codex running for 120s"}
315
+ }
316
+ ```
317
+
318
+ If sending a notification raises an error, the server logs it to stderr and the
319
+ call and its result are unaffected.
320
+
296
321
  ## Forward-compatibility example
297
322
 
298
323
  If a future `codex exec` release adds a new flag (say `--super-mode`), use it
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-cli-mcp-slim"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "A thin, auditable MCP server wrapping the Codex CLI (codex exec). Same tools as the deprecated `codex mcp-server`; forward-compatible with future CLI flags via extra_args passthrough."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -23,6 +23,7 @@ import os
23
23
  import shlex
24
24
  import signal
25
25
  import sys
26
+ from collections.abc import Awaitable, Callable
26
27
  from importlib.metadata import version as _pkg_version
27
28
  from typing import Any, NamedTuple
28
29
 
@@ -42,6 +43,16 @@ logger = logging.getLogger("codex_cli_mcp_slim")
42
43
  CODEX_CMD = os.environ.get("CODEX_CMD", "codex")
43
44
  DEFAULT_TIMEOUT = int(os.environ.get("CODEX_CLI_MCP_SLIM_TIMEOUT", "1800"))
44
45
 
46
+ # Seconds between the progress notifications _run_codex sends while codex runs. An MCP client
47
+ # may abort a call that sends neither a response nor progress for a while (Claude Code does
48
+ # so after 1800s by default: "sent no response or progress for 1800s"), and a long codex turn
49
+ # outlasts that; the notifications are what keeps such a call alive.
50
+ PROGRESS_INTERVAL = 60
51
+
52
+ # ServerSession.report_progress, or anything called the same way. It does nothing when the
53
+ # client sent no progress token with the call.
54
+ _ReportProgress = Callable[..., Awaitable[None]]
55
+
45
56
  _PKG_NAME = "codex-cli-mcp-slim"
46
57
 
47
58
  # Flags placed right after `codex exec` on every invocation. main() fills this from the
@@ -200,6 +211,7 @@ async def _run_codex(
200
211
  prompt: str,
201
212
  timeout: int,
202
213
  env_overrides: dict[str, str] | None = None,
214
+ report_progress: _ReportProgress | None = None,
203
215
  ) -> dict[str, Any]:
204
216
  env = os.environ.copy()
205
217
  if env_overrides:
@@ -230,6 +242,11 @@ async def _run_codex(
230
242
 
231
243
  # shield the task so a first-wait timeout doesn't cancel the second wait below
232
244
  communicate_task = asyncio.ensure_future(proc.communicate(prompt.encode()))
245
+ progress_task = (
246
+ asyncio.ensure_future(_report_progress_every(PROGRESS_INTERVAL, report_progress))
247
+ if report_progress is not None
248
+ else None
249
+ )
233
250
  try:
234
251
  try:
235
252
  stdout, stderr = await asyncio.wait_for(
@@ -268,10 +285,25 @@ async def _run_codex(
268
285
  _kill_process_group(proc)
269
286
  raise
270
287
  finally:
271
- if not communicate_task.done():
272
- communicate_task.cancel()
273
- with contextlib.suppress(Exception, asyncio.CancelledError):
274
- await communicate_task
288
+ # The progress task ends before the result goes back, since the protocol has progress
289
+ # stop once the call completes; cancelling it also ends a notification stuck on a
290
+ # client that has stopped reading. Both tasks are cancelled before anything is
291
+ # awaited, and the wait is repeated until they have finished. The SDK cancels a call
292
+ # through an anyio cancel scope, which cancels this coroutine again at every await, so
293
+ # a single await here could be cut short and leave communicate() running for as long
294
+ # as a descendant outside the process group keeps the pipes open. A cancellation that
295
+ # arrives during the wait is raised once the tasks are done.
296
+ leftovers = {t for t in (progress_task, communicate_task) if t is not None and not t.done()}
297
+ for task in leftovers:
298
+ task.cancel()
299
+ interrupted: asyncio.CancelledError | None = None
300
+ while leftovers := {t for t in leftovers if not t.done()}:
301
+ try:
302
+ await asyncio.wait(leftovers)
303
+ except asyncio.CancelledError as exc:
304
+ interrupted = exc
305
+ if interrupted is not None:
306
+ raise interrupted
275
307
 
276
308
  return {
277
309
  "ok": proc.returncode == 0,
@@ -282,6 +314,27 @@ async def _run_codex(
282
314
  }
283
315
 
284
316
 
317
+ async def _report_progress_every(interval: float, report_progress: _ReportProgress) -> None:
318
+ """Send a progress notification every `interval` seconds until cancelled.
319
+
320
+ `progress` counts the notifications, so it rises with each one as the protocol requires;
321
+ the message carries the time codex has been running. A notification that raises is logged
322
+ and the next one is still sent: it must not affect the codex run or its result. (The SDK
323
+ itself drops a notification on a closed transport, with a debug log and no exception.)
324
+ """
325
+ loop = asyncio.get_running_loop()
326
+ started = loop.time()
327
+ count = 0
328
+ while True:
329
+ await asyncio.sleep(interval)
330
+ count += 1
331
+ elapsed = round(loop.time() - started)
332
+ try:
333
+ await report_progress(count, message=f"codex running for {elapsed}s")
334
+ except Exception as exc:
335
+ logger.warning("progress notification failed: %s: %s", type(exc).__name__, exc)
336
+
337
+
285
338
  def _kill_process_group(proc: asyncio.subprocess.Process) -> None:
286
339
  """Kill codex's process group; a descendant that setsid/setpgid away from it is out of reach."""
287
340
  if sys.platform != "win32":
@@ -657,7 +710,12 @@ def _error_result(text: str) -> CallToolResult:
657
710
  return CallToolResult(content=[TextContent(type="text", text=f"[ERROR] {text}")], is_error=True)
658
711
 
659
712
 
660
- async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolResult:
713
+ async def _invoke(
714
+ arguments: dict[str, Any] | None,
715
+ *,
716
+ reply: bool,
717
+ report_progress: _ReportProgress | None = None,
718
+ ) -> CallToolResult:
661
719
  """The body of both tools, taking a plain dict so callers need no SDK request object."""
662
720
  args = dict(arguments or {})
663
721
 
@@ -690,6 +748,7 @@ async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolR
690
748
  prompt=args["prompt"],
691
749
  timeout=int(args.get("timeout_seconds") or DEFAULT_TIMEOUT),
692
750
  env_overrides=args.get("env"),
751
+ report_progress=report_progress,
693
752
  )
694
753
  parsed = _parse_events(result.get("stdout", ""))
695
754
  text = _format_result(result)
@@ -727,12 +786,16 @@ async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolR
727
786
  )
728
787
 
729
788
 
730
- async def codex(arguments: dict[str, Any] | None) -> CallToolResult:
731
- return await _invoke(arguments, reply=False)
789
+ async def codex(
790
+ arguments: dict[str, Any] | None, *, report_progress: _ReportProgress | None = None
791
+ ) -> CallToolResult:
792
+ return await _invoke(arguments, reply=False, report_progress=report_progress)
732
793
 
733
794
 
734
- async def codex_reply(arguments: dict[str, Any] | None) -> CallToolResult:
735
- return await _invoke(arguments, reply=True)
795
+ async def codex_reply(
796
+ arguments: dict[str, Any] | None, *, report_progress: _ReportProgress | None = None
797
+ ) -> CallToolResult:
798
+ return await _invoke(arguments, reply=True, report_progress=report_progress)
736
799
 
737
800
 
738
801
  async def on_list_tools(
@@ -757,15 +820,20 @@ async def on_list_tools(
757
820
  return ListToolsResult(tools=[codex_tool, _REPLY_TOOL])
758
821
 
759
822
 
760
- async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
823
+ async def on_call_tool(
824
+ ctx: ServerRequestContext | None, params: CallToolRequestParams
825
+ ) -> CallToolResult:
761
826
  # An escaping exception would become a JSON-RPC error, which clients treat as a transport
762
827
  # failure rather than as something to show the model. CancelledError derives from
763
828
  # BaseException, so peer cancellation still propagates and _run_codex's killpg still runs.
829
+ # The session is scoped to this request, so its progress goes out against this call's
830
+ # token. Tests and direct callers pass no context; such a call simply sends no progress.
831
+ report_progress = ctx.session.report_progress if ctx is not None else None
764
832
  try:
765
833
  if params.name == "codex":
766
- return await codex(params.arguments)
834
+ return await codex(params.arguments, report_progress=report_progress)
767
835
  if params.name == "codex-reply":
768
- return await codex_reply(params.arguments)
836
+ return await codex_reply(params.arguments, report_progress=report_progress)
769
837
  return _error_result(f"unknown tool: {params.name}")
770
838
  except Exception as exc:
771
839
  logger.exception("%s failed", params.name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-cli-mcp-slim
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A thin, auditable MCP server wrapping the Codex CLI (codex exec). Same tools as the deprecated `codex mcp-server`; forward-compatible with future CLI flags via extra_args passthrough.
5
5
  Author-email: tksfjt1024 <tksfjt1024@gmail.com>
6
6
  License: MIT
@@ -328,6 +328,31 @@ buffered output and reap the process, so the effective ceiling is
328
328
  `timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
329
329
  whatever `codex` had printed so far.
330
330
 
331
+ An MCP client may give up on a call that has sent neither a response nor a
332
+ progress notification for a while. Claude Code 2.1.318 does so after 1800
333
+ seconds by default for a stdio server ("sent no response or progress for
334
+ 1800s"), and a long `codex` turn can take longer than that. While `codex` runs,
335
+ the server therefore sends a `notifications/progress` message every 60 seconds,
336
+ so the call lasts until `codex` finishes or `timeout_seconds` runs out. A client
337
+ gets these only when it sent a `progressToken` with the call, as Claude Code
338
+ does; a client that gives up after less than 60 seconds without progress is not
339
+ covered. With the default `timeout_seconds` of 1800 the call still ends after
340
+ about 30 minutes, at the server's own timeout, so to let `codex` run longer,
341
+ raise `timeout_seconds` (30 to 3600 per call) or `$CODEX_CLI_MCP_SLIM_TIMEOUT`,
342
+ which sets the default. In the notification's `params`, `progress` counts the
343
+ notifications sent so far and `message` says how long `codex` has been running:
344
+
345
+ ```json
346
+ {
347
+ "jsonrpc": "2.0",
348
+ "method": "notifications/progress",
349
+ "params": {"progressToken": 7, "progress": 2, "message": "codex running for 120s"}
350
+ }
351
+ ```
352
+
353
+ If sending a notification raises an error, the server logs it to stderr and the
354
+ call and its result are unaffected.
355
+
331
356
  ## Forward-compatibility example
332
357
 
333
358
  If a future `codex exec` release adds a new flag (say `--super-mode`), use it
@@ -12,5 +12,6 @@ src/codex_cli_mcp_slim.egg-info/requires.txt
12
12
  src/codex_cli_mcp_slim.egg-info/top_level.txt
13
13
  tests/test_basic.py
14
14
  tests/test_call_tool.py
15
+ tests/test_progress.py
15
16
  tests/test_subprocess_stdin_isolation.py
16
17
  tests/test_timeout_handling.py
@@ -0,0 +1,306 @@
1
+ """Tests for the progress notifications sent while codex runs.
2
+
3
+ An MCP client may abort a tool call that sends neither a response nor a progress
4
+ notification for too long (Claude Code: "sent no response or progress for 1800s"), and a
5
+ codex turn can outlast that. These tests run a fake codex binary through the tool and
6
+ record what reaches the request context's `session.report_progress`, or, through the SDK's
7
+ in-process client, what reaches the client's progress callback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from types import SimpleNamespace
21
+
22
+ import anyio
23
+ import pytest
24
+ from mcp.client import Client
25
+ from mcp.types import CallToolRequestParams
26
+
27
+ from codex_cli_mcp_slim import server
28
+ from codex_cli_mcp_slim.server import _run_codex, on_call_tool
29
+
30
+ EVENTS = "".join(
31
+ json.dumps(event) + "\n"
32
+ for event in (
33
+ {"type": "thread.started", "thread_id": "thread-1"},
34
+ {"type": "item.completed", "item": {"type": "agent_message", "text": "done"}},
35
+ {"type": "turn.completed", "usage": {"input_tokens": 1, "output_tokens": 1}},
36
+ )
37
+ )
38
+
39
+ CALLS = [
40
+ ("codex", {"prompt": "p"}),
41
+ ("codex-reply", {"thread_id": "thread-1", "prompt": "p"}),
42
+ ]
43
+
44
+
45
+ @pytest.fixture
46
+ def fake_codex(tmp_path: Path, monkeypatch):
47
+ """Point CODEX_CMD at a script that reads the prompt, sleeps, then prints EVENTS."""
48
+
49
+ def make(sleep: float) -> None:
50
+ script = tmp_path / "codex"
51
+ script.write_text(
52
+ f"#!{sys.executable}\n"
53
+ "import sys, time\n"
54
+ "sys.stdin.read()\n"
55
+ f"time.sleep({sleep})\n"
56
+ f"sys.stdout.write({EVENTS!r})\n"
57
+ )
58
+ script.chmod(0o755)
59
+ monkeypatch.setattr(server, "CODEX_CMD", str(script))
60
+
61
+ return make
62
+
63
+
64
+ class Recorder:
65
+ """Stands in for `ServerSession.report_progress`, recording each call."""
66
+
67
+ def __init__(self, error: Exception | None = None, hang: bool = False) -> None:
68
+ self.calls: list[tuple[float, float | None, str | None]] = []
69
+ self.error = error
70
+ self.hang = hang
71
+
72
+ async def report_progress(
73
+ self, progress: float, total: float | None = None, message: str | None = None
74
+ ) -> None:
75
+ self.calls.append((progress, total, message))
76
+ if self.hang:
77
+ await asyncio.Event().wait()
78
+ if self.error is not None:
79
+ raise self.error
80
+
81
+
82
+ def _ctx(recorder: Recorder) -> SimpleNamespace:
83
+ return SimpleNamespace(session=recorder)
84
+
85
+
86
+ @pytest.mark.parametrize(("name", "arguments"), CALLS)
87
+ async def test_progress_is_reported_every_interval_while_codex_runs(
88
+ fake_codex, monkeypatch, name, arguments
89
+ ) -> None:
90
+ # codex finishes halfway between the second and third notification, so a second of
91
+ # scheduling jitter either way leaves the count alone. The elapsed time in the message
92
+ # moves with that jitter, so only its form is checked.
93
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 2)
94
+ fake_codex(5)
95
+ recorder = Recorder()
96
+
97
+ result = await on_call_tool(
98
+ _ctx(recorder), CallToolRequestParams(name=name, arguments=arguments)
99
+ )
100
+
101
+ assert result.is_error is False
102
+ assert result.content[0].text.startswith("done\n\n[codex] thread_id=thread-1")
103
+ assert [(progress, total) for progress, total, _ in recorder.calls] == [(1, None), (2, None)]
104
+ assert all(re.fullmatch(r"codex running for \d+s", message) for _, _, message in recorder.calls)
105
+ # Nothing is sent once the result is back.
106
+ await asyncio.sleep(2.5)
107
+ assert len(recorder.calls) == 2
108
+
109
+
110
+ async def test_progress_reaches_the_client_that_asked_for_it(fake_codex, monkeypatch) -> None:
111
+ # Through the SDK's own request context rather than a stand-in: the notifications reach
112
+ # the callback of the call that passed one, and a call without a callback gets none.
113
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.25)
114
+ fake_codex(1)
115
+ received: list[tuple[float, float | None, str | None]] = []
116
+
117
+ async def on_progress(progress: float, total: float | None, message: str | None) -> None:
118
+ received.append((progress, total, message))
119
+
120
+ async with Client(server.server) as client:
121
+ result = await client.call_tool("codex", {"prompt": "p"}, progress_callback=on_progress)
122
+ assert result.is_error is False
123
+ assert received, "no progress reached the callback"
124
+ assert [progress for progress, _, _ in received] == list(range(1, len(received) + 1))
125
+ assert all(total is None for _, total, _ in received)
126
+ assert all(re.fullmatch(r"codex running for \d+s", message) for _, _, message in received)
127
+
128
+ received.clear()
129
+ result = await client.call_tool("codex", {"prompt": "p"})
130
+ assert result.is_error is False
131
+ assert received == []
132
+
133
+
134
+ async def test_no_progress_when_codex_finishes_within_the_interval(fake_codex) -> None:
135
+ fake_codex(0)
136
+ recorder = Recorder()
137
+
138
+ result = await on_call_tool(
139
+ _ctx(recorder), CallToolRequestParams(name="codex", arguments={"prompt": "p"})
140
+ )
141
+
142
+ assert result.is_error is False
143
+ assert recorder.calls == []
144
+
145
+
146
+ async def test_a_call_without_a_request_context_still_runs(fake_codex, monkeypatch) -> None:
147
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.1)
148
+ fake_codex(0.5)
149
+
150
+ result = await on_call_tool(
151
+ None, CallToolRequestParams(name="codex", arguments={"prompt": "p"})
152
+ )
153
+
154
+ assert result.is_error is False
155
+ assert result.content[0].text.startswith("done\n\n[codex] thread_id=thread-1")
156
+
157
+
158
+ async def test_a_failing_progress_notification_leaves_the_result_alone(
159
+ fake_codex, monkeypatch, caplog
160
+ ) -> None:
161
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.1)
162
+ fake_codex(0.5)
163
+ recorder = Recorder(error=RuntimeError("report_progress raised"))
164
+
165
+ with caplog.at_level(logging.WARNING, logger="codex_cli_mcp_slim"):
166
+ result = await on_call_tool(
167
+ _ctx(recorder), CallToolRequestParams(name="codex", arguments={"prompt": "p"})
168
+ )
169
+
170
+ assert result.is_error is False
171
+ assert result.content[0].text.startswith("done\n\n[codex] thread_id=thread-1")
172
+ # A failed notification does not stop the next one.
173
+ assert len(recorder.calls) >= 2
174
+ assert "progress notification failed: RuntimeError: report_progress raised" in caplog.text
175
+
176
+
177
+ async def test_a_hanging_progress_notification_does_not_hold_the_result(
178
+ fake_codex, monkeypatch
179
+ ) -> None:
180
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.1)
181
+ fake_codex(0.3)
182
+ recorder = Recorder(hang=True)
183
+
184
+ before = asyncio.all_tasks()
185
+ result = await asyncio.wait_for(
186
+ on_call_tool(
187
+ _ctx(recorder), CallToolRequestParams(name="codex", arguments={"prompt": "p"})
188
+ ),
189
+ timeout=5,
190
+ )
191
+
192
+ assert result.is_error is False
193
+ assert len(recorder.calls) == 1
194
+ leaked = {t for t in asyncio.all_tasks() - before if not t.done()}
195
+ assert not leaked, f"the stuck notification was left running: {leaked}"
196
+
197
+
198
+ async def test_timeout_still_reports_and_stops_the_progress_task(monkeypatch) -> None:
199
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.2)
200
+ recorder = Recorder()
201
+ argv = [sys.executable, "-c", "import time; time.sleep(60)"]
202
+
203
+ before = asyncio.all_tasks()
204
+ result = await _run_codex(
205
+ prompt="p", argv=argv, timeout=1, report_progress=recorder.report_progress
206
+ )
207
+
208
+ assert not result["ok"]
209
+ assert result["error"] == "timeout after 1s"
210
+ assert recorder.calls
211
+ leaked = {t for t in asyncio.all_tasks() - before if not t.done()}
212
+ assert not leaked, f"tasks left running after the timeout: {leaked}"
213
+
214
+
215
+ async def test_cancellation_kills_codex_and_stops_the_progress_task(
216
+ tmp_path: Path, monkeypatch
217
+ ) -> None:
218
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.1)
219
+ recorder = Recorder()
220
+ pid_file = tmp_path / "proc.pid"
221
+ argv = [
222
+ sys.executable,
223
+ "-c",
224
+ 'import os, time; open(os.environ["PID_FILE"], "w").write(str(os.getpid())); '
225
+ "time.sleep(30)",
226
+ ]
227
+
228
+ before = asyncio.all_tasks()
229
+ task = asyncio.ensure_future(
230
+ _run_codex(
231
+ prompt="p",
232
+ argv=argv,
233
+ timeout=20,
234
+ env_overrides={"PID_FILE": str(pid_file)},
235
+ report_progress=recorder.report_progress,
236
+ )
237
+ )
238
+ for _ in range(50):
239
+ if pid_file.exists() and recorder.calls:
240
+ break
241
+ await asyncio.sleep(0.1)
242
+ assert pid_file.exists(), "process never started"
243
+ assert recorder.calls, "no progress before the cancellation"
244
+ proc_pid = int(pid_file.read_text())
245
+
246
+ task.cancel()
247
+ with pytest.raises(asyncio.CancelledError):
248
+ await task
249
+
250
+ leaked = {t for t in asyncio.all_tasks() - before - {task} if not t.done()}
251
+ assert not leaked, f"tasks left running after the cancellation: {leaked}"
252
+ for _ in range(30):
253
+ try:
254
+ os.kill(proc_pid, 0)
255
+ except ProcessLookupError:
256
+ break
257
+ await asyncio.sleep(0.1)
258
+ else:
259
+ with contextlib.suppress(ProcessLookupError):
260
+ os.kill(proc_pid, 9)
261
+ raise AssertionError(f"process {proc_pid} still alive after caller cancellation")
262
+
263
+
264
+ async def test_cancellation_through_an_anyio_scope_still_cleans_up_communicate(
265
+ tmp_path: Path, monkeypatch
266
+ ) -> None:
267
+ # The SDK cancels a call through an anyio cancel scope, which cancels the task again at
268
+ # every await. The cleanup in _run_codex's `finally` awaits too, so a second
269
+ # cancellation must not cut it short: here a descendant that left codex's process group
270
+ # keeps the pipes open, and a communicate() task nobody cancelled would outlive the call
271
+ # for as long as that descendant runs.
272
+ monkeypatch.setattr(server, "PROGRESS_INTERVAL", 0.1)
273
+ recorder = Recorder()
274
+ pid_file = tmp_path / "escaped.pid"
275
+ escaped_code = (
276
+ 'import os, time; open(os.environ["PID_FILE"], "w").write(str(os.getpid())); time.sleep(30)'
277
+ )
278
+ leader_code = (
279
+ "import subprocess, sys, time; "
280
+ f"subprocess.Popen([sys.executable, '-c', {escaped_code!r}], start_new_session=True); "
281
+ "time.sleep(30)"
282
+ )
283
+ argv = [sys.executable, "-c", leader_code]
284
+
285
+ before = asyncio.all_tasks()
286
+ try:
287
+ with anyio.CancelScope() as scope:
288
+ asyncio.get_running_loop().call_later(0.5, scope.cancel)
289
+ await _run_codex(
290
+ prompt="p",
291
+ argv=argv,
292
+ timeout=20,
293
+ env_overrides={"PID_FILE": str(pid_file)},
294
+ report_progress=recorder.report_progress,
295
+ )
296
+ assert scope.cancelled_caught, "the cancellation did not reach _run_codex"
297
+ leaked = {t for t in asyncio.all_tasks() - before if not t.done()}
298
+ assert not leaked, f"tasks left running after the cancellation: {leaked}"
299
+ finally:
300
+ for _ in range(20):
301
+ if pid_file.exists():
302
+ break
303
+ await asyncio.sleep(0.1)
304
+ if pid_file.exists():
305
+ with contextlib.suppress(ProcessLookupError):
306
+ os.kill(int(pid_file.read_text()), 9)