codex-cli-mcp-slim 0.1.0__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 (20) hide show
  1. {codex_cli_mcp_slim-0.1.0/src/codex_cli_mcp_slim.egg-info → codex_cli_mcp_slim-0.1.2}/PKG-INFO +52 -8
  2. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/README.md +51 -7
  3. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/pyproject.toml +1 -1
  4. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/server.py +218 -27
  5. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2/src/codex_cli_mcp_slim.egg-info}/PKG-INFO +52 -8
  6. {codex_cli_mcp_slim-0.1.0 → 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_basic.py +346 -0
  8. codex_cli_mcp_slim-0.1.2/tests/test_progress.py +306 -0
  9. codex_cli_mcp_slim-0.1.0/tests/test_basic.py +0 -123
  10. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/LICENSE +0 -0
  11. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/setup.cfg +0 -0
  12. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/__init__.py +0 -0
  13. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim/py.typed +0 -0
  14. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/dependency_links.txt +0 -0
  15. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/entry_points.txt +0 -0
  16. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/requires.txt +0 -0
  17. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/src/codex_cli_mcp_slim.egg-info/top_level.txt +0 -0
  18. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/tests/test_call_tool.py +0 -0
  19. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.2}/tests/test_subprocess_stdin_isolation.py +0 -0
  20. {codex_cli_mcp_slim-0.1.0 → 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.0
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
@@ -171,7 +171,9 @@ After:
171
171
  Parameter names differ from the old server where `codex exec` names the flag
172
172
  differently: `cwd` is now `cd` (the `-C/--cd` flag), and `codex-reply` takes
173
173
  `thread_id` instead of `threadId`. The result's `structuredContent` field keeps
174
- the shape the old server returned, `{"threadId": ..., "content": ...}`.
174
+ the shape the old server returned, `{"threadId": ..., "content": ...}`. It
175
+ gains an `ignoredParameters` key only when the server's own flags overrode a
176
+ per-call parameter (see [Server-level flags](#server-level-flags)).
175
177
 
176
178
  ### Other MCP clients
177
179
 
@@ -207,11 +209,25 @@ in reasoning effort look like this:
207
209
 
208
210
  A call to `codex-high` runs
209
211
  `codex exec -c model_reasoning_effort=high [per-call flags] --json -`. Per-call
210
- flags come after the server-level ones, and `-c` may repeat with the last one
211
- winning, so a per-call `config` entry overrides a server-level `-c`. Single-value
212
- flags such as `-m` and `-C` may not repeat: `codex` rejects the second one, and
213
- the tool result carries that error. Keep server-level flags and per-call
214
- parameters disjoint for those.
212
+ flags come after the server-level ones. `-c` and `--add-dir` may repeat. For
213
+ `-c` the last one wins, so a per-call `config` entry overrides a server-level
214
+ `-c`.
215
+
216
+ The other typed flags (`-C`, `-m`, `--sandbox`, `-p`, `--ephemeral`,
217
+ `--skip-git-repo-check`) may appear only once: `codex` exits with "cannot be used
218
+ multiple times" on a second one. When the server's command line already sets one
219
+ of them, in any spelling (for example `-C DIR`, `-CDIR`, `--cd=DIR`), the
220
+ server-level value wins. A token that is another flag's value (the `-m` in
221
+ `-c -m`) or that comes after `--` does not count as setting a flag. The `codex`
222
+ tool leaves the matching per-call parameter (`cd`, `model`, `sandbox`,
223
+ `profile`, `ephemeral`, `skip_git_repo_check`) out of the command and names it
224
+ in the result together with the server-level value used instead: in the text
225
+ after the `[codex]` line (`cd="/other"; this server runs with -C /srv`) and in
226
+ `structuredContent.ignoredParameters`. Its schema tells the caller which
227
+ parameters the server already sets, and to what. `codex-reply` keeps such
228
+ parameters, because after `resume` they are the subcommand's own flags and
229
+ `codex` accepts them there. `extra_args` is passed through unchecked, so a flag
230
+ repeated through it still fails with `codex`'s own error.
215
231
 
216
232
  ## Tool: `codex`
217
233
 
@@ -226,7 +242,10 @@ The tool returns that final message followed by one metadata line:
226
242
  ```
227
243
 
228
244
  `thread_id` and `status` are always present; the token fields appear when the
229
- run reported them. `isError` is the flag on an MCP tool result that tells the
245
+ run reported them. A per-call parameter that a server-level flag overrode is
246
+ named after this line, with the value used instead (see
247
+ [Server-level flags](#server-level-flags)).
248
+ `isError` is the flag on an MCP tool result that tells the
230
249
  client a call failed. This server sets it when `codex` exited non-zero, when the
231
250
  subprocess timed out, and when the turn itself failed. The last case matters
232
251
  because `codex exec` exits 0 after a failure inside the model API; the tool
@@ -309,6 +328,31 @@ buffered output and reap the process, so the effective ceiling is
309
328
  `timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
310
329
  whatever `codex` had printed so far.
311
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
+
312
356
  ## Forward-compatibility example
313
357
 
314
358
  If a future `codex exec` release adds a new flag (say `--super-mode`), use it
@@ -136,7 +136,9 @@ After:
136
136
  Parameter names differ from the old server where `codex exec` names the flag
137
137
  differently: `cwd` is now `cd` (the `-C/--cd` flag), and `codex-reply` takes
138
138
  `thread_id` instead of `threadId`. The result's `structuredContent` field keeps
139
- the shape the old server returned, `{"threadId": ..., "content": ...}`.
139
+ the shape the old server returned, `{"threadId": ..., "content": ...}`. It
140
+ gains an `ignoredParameters` key only when the server's own flags overrode a
141
+ per-call parameter (see [Server-level flags](#server-level-flags)).
140
142
 
141
143
  ### Other MCP clients
142
144
 
@@ -172,11 +174,25 @@ in reasoning effort look like this:
172
174
 
173
175
  A call to `codex-high` runs
174
176
  `codex exec -c model_reasoning_effort=high [per-call flags] --json -`. Per-call
175
- flags come after the server-level ones, and `-c` may repeat with the last one
176
- winning, so a per-call `config` entry overrides a server-level `-c`. Single-value
177
- flags such as `-m` and `-C` may not repeat: `codex` rejects the second one, and
178
- the tool result carries that error. Keep server-level flags and per-call
179
- parameters disjoint for those.
177
+ flags come after the server-level ones. `-c` and `--add-dir` may repeat. For
178
+ `-c` the last one wins, so a per-call `config` entry overrides a server-level
179
+ `-c`.
180
+
181
+ The other typed flags (`-C`, `-m`, `--sandbox`, `-p`, `--ephemeral`,
182
+ `--skip-git-repo-check`) may appear only once: `codex` exits with "cannot be used
183
+ multiple times" on a second one. When the server's command line already sets one
184
+ of them, in any spelling (for example `-C DIR`, `-CDIR`, `--cd=DIR`), the
185
+ server-level value wins. A token that is another flag's value (the `-m` in
186
+ `-c -m`) or that comes after `--` does not count as setting a flag. The `codex`
187
+ tool leaves the matching per-call parameter (`cd`, `model`, `sandbox`,
188
+ `profile`, `ephemeral`, `skip_git_repo_check`) out of the command and names it
189
+ in the result together with the server-level value used instead: in the text
190
+ after the `[codex]` line (`cd="/other"; this server runs with -C /srv`) and in
191
+ `structuredContent.ignoredParameters`. Its schema tells the caller which
192
+ parameters the server already sets, and to what. `codex-reply` keeps such
193
+ parameters, because after `resume` they are the subcommand's own flags and
194
+ `codex` accepts them there. `extra_args` is passed through unchecked, so a flag
195
+ repeated through it still fails with `codex`'s own error.
180
196
 
181
197
  ## Tool: `codex`
182
198
 
@@ -191,7 +207,10 @@ The tool returns that final message followed by one metadata line:
191
207
  ```
192
208
 
193
209
  `thread_id` and `status` are always present; the token fields appear when the
194
- run reported them. `isError` is the flag on an MCP tool result that tells the
210
+ run reported them. A per-call parameter that a server-level flag overrode is
211
+ named after this line, with the value used instead (see
212
+ [Server-level flags](#server-level-flags)).
213
+ `isError` is the flag on an MCP tool result that tells the
195
214
  client a call failed. This server sets it when `codex` exited non-zero, when the
196
215
  subprocess timed out, and when the turn itself failed. The last case matters
197
216
  because `codex exec` exits 0 after a failure inside the model API; the tool
@@ -274,6 +293,31 @@ buffered output and reap the process, so the effective ceiling is
274
293
  `timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
275
294
  whatever `codex` had printed so far.
276
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
+
277
321
  ## Forward-compatibility example
278
322
 
279
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.0"
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"
@@ -20,10 +20,12 @@ import contextlib
20
20
  import json
21
21
  import logging
22
22
  import os
23
+ import shlex
23
24
  import signal
24
25
  import sys
26
+ from collections.abc import Awaitable, Callable
25
27
  from importlib.metadata import version as _pkg_version
26
- from typing import Any
28
+ from typing import Any, NamedTuple
27
29
 
28
30
  from mcp.server import Server, ServerRequestContext
29
31
  from mcp.server.stdio import stdio_server
@@ -41,6 +43,16 @@ logger = logging.getLogger("codex_cli_mcp_slim")
41
43
  CODEX_CMD = os.environ.get("CODEX_CMD", "codex")
42
44
  DEFAULT_TIMEOUT = int(os.environ.get("CODEX_CLI_MCP_SLIM_TIMEOUT", "1800"))
43
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
+
44
56
  _PKG_NAME = "codex-cli-mcp-slim"
45
57
 
46
58
  # Flags placed right after `codex exec` on every invocation. main() fills this from the
@@ -49,12 +61,89 @@ _PKG_NAME = "codex-cli-mcp-slim"
49
61
  #
50
62
  # uvx codex-cli-mcp-slim -c model_reasoning_effort=high -C /srv/scratch
51
63
  #
52
- # `-c` may repeat and the last one wins, so a per-call `config` can override a server-level
53
- # `-c`. Single-value flags such as `-m` and `-C` may not repeat: codex rejects the second
54
- # one, and the tool result carries that error. Keep server-level flags and per-call
55
- # parameters disjoint for those.
64
+ # `-c` and `--add-dir` may repeat (for `-c` the last one wins), so a per-call `config` can
65
+ # override a server-level `-c`. The flags in _SINGLE_USE_FLAGS may not: see there.
56
66
  SERVER_ARGS: list[str] = []
57
67
 
68
+ # Per-call parameters whose flag codex accepts only once, with every spelling of that flag.
69
+ # A second occurrence makes `codex exec` exit 2 with "cannot be used multiple times", so when
70
+ # the server's own command line already carries one of these flags, the per-call parameter
71
+ # is left out of argv and the server-level value is used: whoever started the server fixed
72
+ # that configuration on purpose.
73
+ _SINGLE_USE_FLAGS: dict[str, tuple[str, ...]] = {
74
+ "cd": ("-C", "--cd"),
75
+ "model": ("-m", "--model"),
76
+ "sandbox": ("-s", "--sandbox"),
77
+ "profile": ("-p", "--profile"),
78
+ "ephemeral": ("--ephemeral",),
79
+ "skip_git_repo_check": ("--skip-git-repo-check",),
80
+ }
81
+
82
+
83
+ # The `codex exec` options (codex-cli 0.159.2) that take the next token as their value, so
84
+ # _params_set_by reads that token as a value and never as a flag of its own.
85
+ _VALUE_FLAGS = frozenset(
86
+ {
87
+ *("-c", "--config", "--enable", "--disable", "-i", "--image"),
88
+ *("-m", "--model", "--local-provider", "-p", "--profile", "-s", "--sandbox"),
89
+ *("-C", "--cd", "--add-dir", "--thread-source", "--output-schema", "--color"),
90
+ *("-o", "--output-last-message"),
91
+ }
92
+ )
93
+
94
+
95
+ class _ServerSetting(NamedTuple):
96
+ """How the server's own command line sets one of the _SINGLE_USE_FLAGS parameters."""
97
+
98
+ value: str | bool # the flag's value, or True for a flag without one
99
+ written: str # the flag as that command line spells it, for example `-C /srv`
100
+
101
+
102
+ def _params_set_by(server_args: list[str]) -> dict[str, _ServerSetting]:
103
+ """The _SINGLE_USE_FLAGS parameters whose flag already appears in `server_args`.
104
+
105
+ A flag counts in every form codex parses as that flag: `--cd DIR`, `--cd=DIR`, `-C DIR`
106
+ and the attached short forms `-CDIR` and `-C=DIR`. Short flags are case-sensitive, so the
107
+ repeatable `-c` is not mistaken for `-C`. The token after a _VALUE_FLAGS option is its
108
+ value (`-m` in `-c -m` is not the model flag), and nothing after `--` is a flag.
109
+ """
110
+ held: dict[str, _ServerSetting] = {}
111
+ tokens = iter(server_args)
112
+ for token in tokens:
113
+ if token == "--":
114
+ break
115
+ if token in _VALUE_FLAGS:
116
+ value = next(tokens, None)
117
+ if value is None:
118
+ break
119
+ param = next((p for p, flags in _SINGLE_USE_FLAGS.items() if token in flags), None)
120
+ if param is not None:
121
+ held.setdefault(param, _ServerSetting(value, shlex.join([token, value])))
122
+ continue
123
+ for param, spellings in _SINGLE_USE_FLAGS.items():
124
+ for flag in spellings:
125
+ if token == flag:
126
+ held.setdefault(param, _ServerSetting(True, token))
127
+ elif len(flag) == 2 and token.startswith(flag):
128
+ value = token[2:].removeprefix("=")
129
+ held.setdefault(param, _ServerSetting(value, shlex.quote(token)))
130
+ elif token.startswith(flag + "="):
131
+ value = token[len(flag) + 1 :]
132
+ held.setdefault(param, _ServerSetting(value, shlex.quote(token)))
133
+ return held
134
+
135
+
136
+ # What SERVER_ARGS sets among the _SINGLE_USE_FLAGS parameters. _set_server_args parses it
137
+ # once, together with SERVER_ARGS, so _build_argv, _invoke and on_list_tools read one answer.
138
+ _SERVER_SET: dict[str, _ServerSetting] = {}
139
+
140
+
141
+ def _set_server_args(server_args: list[str]) -> None:
142
+ global SERVER_ARGS, _SERVER_SET
143
+ SERVER_ARGS = list(server_args)
144
+ _SERVER_SET = _params_set_by(SERVER_ARGS)
145
+
146
+
58
147
  # The Server is built at the bottom of this file: v2 takes the handlers as constructor
59
148
  # arguments, so they must already be defined by the time it is constructed.
60
149
 
@@ -73,8 +162,11 @@ def _build_argv(
73
162
  extra_args: list[str] | None = None,
74
163
  server_args: list[str] | None = None,
75
164
  ) -> list[str]:
76
- argv: list[str] = [CODEX_CMD, "exec"]
77
- argv += SERVER_ARGS if server_args is None else server_args
165
+ if server_args is None:
166
+ server_args, server_set = SERVER_ARGS, _SERVER_SET
167
+ else:
168
+ server_set = _params_set_by(server_args)
169
+ argv: list[str] = [CODEX_CMD, "exec", *server_args]
78
170
  if thread_id:
79
171
  # `codex exec resume <id>` is a subcommand: it has to come before the per-call flags,
80
172
  # and it accepts only a subset of them (the `codex-reply` schema advertises just that
@@ -82,21 +174,25 @@ def _build_argv(
82
174
  # `exec` options there, so a server pinned to a working directory keeps that
83
175
  # directory for replies as well.
84
176
  argv += ["resume", thread_id]
85
- if cd:
177
+ # Per-call flags after `resume` are the subcommand's own, and codex accepts them there
178
+ # even when the same flag sits among the server-level ones, so only a plain `exec` call
179
+ # leaves out what the server already sets.
180
+ held = {} if thread_id else server_set
181
+ if cd and "cd" not in held:
86
182
  argv += ["-C", cd]
87
- if model:
183
+ if model and "model" not in held:
88
184
  argv += ["-m", model]
89
185
  for item in config or []:
90
186
  argv += ["-c", item] # repeatable flag: one -c per key=value
91
- if sandbox:
187
+ if sandbox and "sandbox" not in held:
92
188
  argv += ["--sandbox", sandbox]
93
189
  for d in add_dir or []:
94
190
  argv += ["--add-dir", d] # repeatable flag: one --add-dir per directory
95
- if profile:
191
+ if profile and "profile" not in held:
96
192
  argv += ["-p", profile]
97
- if ephemeral:
193
+ if ephemeral and "ephemeral" not in held:
98
194
  argv.append("--ephemeral")
99
- if skip_git_repo_check:
195
+ if skip_git_repo_check and "skip_git_repo_check" not in held:
100
196
  argv.append("--skip-git-repo-check")
101
197
  if extra_args:
102
198
  argv += list(extra_args)
@@ -115,6 +211,7 @@ async def _run_codex(
115
211
  prompt: str,
116
212
  timeout: int,
117
213
  env_overrides: dict[str, str] | None = None,
214
+ report_progress: _ReportProgress | None = None,
118
215
  ) -> dict[str, Any]:
119
216
  env = os.environ.copy()
120
217
  if env_overrides:
@@ -145,6 +242,11 @@ async def _run_codex(
145
242
 
146
243
  # shield the task so a first-wait timeout doesn't cancel the second wait below
147
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
+ )
148
250
  try:
149
251
  try:
150
252
  stdout, stderr = await asyncio.wait_for(
@@ -183,10 +285,25 @@ async def _run_codex(
183
285
  _kill_process_group(proc)
184
286
  raise
185
287
  finally:
186
- if not communicate_task.done():
187
- communicate_task.cancel()
188
- with contextlib.suppress(Exception, asyncio.CancelledError):
189
- 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
190
307
 
191
308
  return {
192
309
  "ok": proc.returncode == 0,
@@ -197,6 +314,27 @@ async def _run_codex(
197
314
  }
198
315
 
199
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
+
200
338
  def _kill_process_group(proc: asyncio.subprocess.Process) -> None:
201
339
  """Kill codex's process group; a descendant that setsid/setpgid away from it is out of reach."""
202
340
  if sys.platform != "win32":
@@ -572,7 +710,12 @@ def _error_result(text: str) -> CallToolResult:
572
710
  return CallToolResult(content=[TextContent(type="text", text=f"[ERROR] {text}")], is_error=True)
573
711
 
574
712
 
575
- 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:
576
719
  """The body of both tools, taking a plain dict so callers need no SDK request object."""
577
720
  args = dict(arguments or {})
578
721
 
@@ -605,14 +748,37 @@ async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolR
605
748
  prompt=args["prompt"],
606
749
  timeout=int(args.get("timeout_seconds") or DEFAULT_TIMEOUT),
607
750
  env_overrides=args.get("env"),
751
+ report_progress=report_progress,
608
752
  )
609
753
  parsed = _parse_events(result.get("stdout", ""))
610
754
  text = _format_result(result)
755
+ # Name what _build_argv left out, and the value used instead, so a caller does not take a
756
+ # run in the server's directory (say) for a run in the one it asked for.
757
+ held = {} if reply else _SERVER_SET
758
+ dropped = [param for param in _SINGLE_USE_FLAGS if param in held and args.get(param)]
611
759
  structured: dict[str, Any] | None = None
612
760
  if parsed["thread_id"]:
613
761
  # The same shape the deprecated `codex mcp-server` returned, so a client that reads
614
762
  # structuredContent.threadId keeps working.
615
763
  structured = {"threadId": parsed["thread_id"], "content": _response_text(parsed)}
764
+ if dropped:
765
+ lines = [
766
+ f"{p}={json.dumps(args[p], ensure_ascii=False)}; "
767
+ f"this server runs with {held[p].written}"
768
+ for p in dropped
769
+ ]
770
+ logger.warning("per-call parameters ignored: %s", " | ".join(lines))
771
+ text += (
772
+ "\n\nignored, because this server's own command line already sets the flag "
773
+ "and codex takes it only once:\n" + "\n".join(lines)
774
+ )
775
+ # A client that reads only structuredContent learns it here.
776
+ structured = {
777
+ **(structured or {}),
778
+ "ignoredParameters": [
779
+ {"name": p, "value": args[p], "serverValue": held[p].value} for p in dropped
780
+ ],
781
+ }
616
782
  return CallToolResult(
617
783
  content=[TextContent(type="text", text=text)],
618
784
  structured_content=structured,
@@ -620,29 +786,54 @@ async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolR
620
786
  )
621
787
 
622
788
 
623
- async def codex(arguments: dict[str, Any] | None) -> CallToolResult:
624
- 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)
625
793
 
626
794
 
627
- async def codex_reply(arguments: dict[str, Any] | None) -> CallToolResult:
628
- 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)
629
799
 
630
800
 
631
801
  async def on_list_tools(
632
802
  ctx: ServerRequestContext, params: PaginatedRequestParams | None
633
803
  ) -> ListToolsResult:
634
- return ListToolsResult(tools=[_CODEX_TOOL, _REPLY_TOOL])
804
+ # The caller cannot see the server's command line, so the `codex` schema says which
805
+ # parameters it already fixes, rather than inviting values that _build_argv would drop.
806
+ if not _SERVER_SET:
807
+ return ListToolsResult(tools=[_CODEX_TOOL, _REPLY_TOOL])
808
+ props = dict(_CODEX_PROPS)
809
+ for param, setting in _SERVER_SET.items():
810
+ props[param] = {
811
+ **props[param],
812
+ "description": (
813
+ f"{props[param]['description']} This server's own command line already "
814
+ f"sets {setting.written}, so a value passed here is ignored."
815
+ ),
816
+ }
817
+ codex_tool = _CODEX_TOOL.model_copy(
818
+ update={"input_schema": {**_CODEX_TOOL.input_schema, "properties": props}}
819
+ )
820
+ return ListToolsResult(tools=[codex_tool, _REPLY_TOOL])
635
821
 
636
822
 
637
- async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
823
+ async def on_call_tool(
824
+ ctx: ServerRequestContext | None, params: CallToolRequestParams
825
+ ) -> CallToolResult:
638
826
  # An escaping exception would become a JSON-RPC error, which clients treat as a transport
639
827
  # failure rather than as something to show the model. CancelledError derives from
640
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
641
832
  try:
642
833
  if params.name == "codex":
643
- return await codex(params.arguments)
834
+ return await codex(params.arguments, report_progress=report_progress)
644
835
  if params.name == "codex-reply":
645
- return await codex_reply(params.arguments)
836
+ return await codex_reply(params.arguments, report_progress=report_progress)
646
837
  return _error_result(f"unknown tool: {params.name}")
647
838
  except Exception as exc:
648
839
  logger.exception("%s failed", params.name)
@@ -691,7 +882,7 @@ def main() -> None:
691
882
  if sys.argv[1:] == ["--version"]:
692
883
  print(f"{_PKG_NAME} {_pkg_version(_PKG_NAME)}")
693
884
  return
694
- SERVER_ARGS[:] = sys.argv[1:]
885
+ _set_server_args(sys.argv[1:])
695
886
  asyncio.run(_amain())
696
887
 
697
888
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-cli-mcp-slim
3
- Version: 0.1.0
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
@@ -171,7 +171,9 @@ After:
171
171
  Parameter names differ from the old server where `codex exec` names the flag
172
172
  differently: `cwd` is now `cd` (the `-C/--cd` flag), and `codex-reply` takes
173
173
  `thread_id` instead of `threadId`. The result's `structuredContent` field keeps
174
- the shape the old server returned, `{"threadId": ..., "content": ...}`.
174
+ the shape the old server returned, `{"threadId": ..., "content": ...}`. It
175
+ gains an `ignoredParameters` key only when the server's own flags overrode a
176
+ per-call parameter (see [Server-level flags](#server-level-flags)).
175
177
 
176
178
  ### Other MCP clients
177
179
 
@@ -207,11 +209,25 @@ in reasoning effort look like this:
207
209
 
208
210
  A call to `codex-high` runs
209
211
  `codex exec -c model_reasoning_effort=high [per-call flags] --json -`. Per-call
210
- flags come after the server-level ones, and `-c` may repeat with the last one
211
- winning, so a per-call `config` entry overrides a server-level `-c`. Single-value
212
- flags such as `-m` and `-C` may not repeat: `codex` rejects the second one, and
213
- the tool result carries that error. Keep server-level flags and per-call
214
- parameters disjoint for those.
212
+ flags come after the server-level ones. `-c` and `--add-dir` may repeat. For
213
+ `-c` the last one wins, so a per-call `config` entry overrides a server-level
214
+ `-c`.
215
+
216
+ The other typed flags (`-C`, `-m`, `--sandbox`, `-p`, `--ephemeral`,
217
+ `--skip-git-repo-check`) may appear only once: `codex` exits with "cannot be used
218
+ multiple times" on a second one. When the server's command line already sets one
219
+ of them, in any spelling (for example `-C DIR`, `-CDIR`, `--cd=DIR`), the
220
+ server-level value wins. A token that is another flag's value (the `-m` in
221
+ `-c -m`) or that comes after `--` does not count as setting a flag. The `codex`
222
+ tool leaves the matching per-call parameter (`cd`, `model`, `sandbox`,
223
+ `profile`, `ephemeral`, `skip_git_repo_check`) out of the command and names it
224
+ in the result together with the server-level value used instead: in the text
225
+ after the `[codex]` line (`cd="/other"; this server runs with -C /srv`) and in
226
+ `structuredContent.ignoredParameters`. Its schema tells the caller which
227
+ parameters the server already sets, and to what. `codex-reply` keeps such
228
+ parameters, because after `resume` they are the subcommand's own flags and
229
+ `codex` accepts them there. `extra_args` is passed through unchecked, so a flag
230
+ repeated through it still fails with `codex`'s own error.
215
231
 
216
232
  ## Tool: `codex`
217
233
 
@@ -226,7 +242,10 @@ The tool returns that final message followed by one metadata line:
226
242
  ```
227
243
 
228
244
  `thread_id` and `status` are always present; the token fields appear when the
229
- run reported them. `isError` is the flag on an MCP tool result that tells the
245
+ run reported them. A per-call parameter that a server-level flag overrode is
246
+ named after this line, with the value used instead (see
247
+ [Server-level flags](#server-level-flags)).
248
+ `isError` is the flag on an MCP tool result that tells the
230
249
  client a call failed. This server sets it when `codex` exited non-zero, when the
231
250
  subprocess timed out, and when the turn itself failed. The last case matters
232
251
  because `codex exec` exits 0 after a failure inside the model API; the tool
@@ -309,6 +328,31 @@ buffered output and reap the process, so the effective ceiling is
309
328
  `timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
310
329
  whatever `codex` had printed so far.
311
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
+
312
356
  ## Forward-compatibility example
313
357
 
314
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