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.
- {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
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/README.md +25 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/pyproject.toml +1 -1
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/server.py +80 -12
- {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
- {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
- codex_cli_mcp_slim-0.1.2/tests/test_progress.py +306 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/LICENSE +0 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/setup.cfg +0 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/__init__.py +0 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/py.typed +0 -0
- {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
- {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
- {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
- {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
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_basic.py +0 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_call_tool.py +0 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_subprocess_stdin_isolation.py +0 -0
- {codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_timeout_handling.py +0 -0
{codex_cli_mcp_slim-0.1.1/src/codex_cli_mcp_slim.egg-info → codex_cli_mcp_slim-0.1.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-cli-mcp-slim
|
|
3
|
-
Version: 0.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.
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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(
|
|
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(
|
|
731
|
-
|
|
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(
|
|
735
|
-
|
|
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(
|
|
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)
|
{codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2/src/codex_cli_mcp_slim.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-cli-mcp-slim
|
|
3
|
-
Version: 0.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
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/requires.txt
RENAMED
|
File without changes
|
{codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_cli_mcp_slim-0.1.1 → codex_cli_mcp_slim-0.1.2}/tests/test_subprocess_stdin_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|