codex-cli-mcp-slim 0.1.0__tar.gz → 0.1.1__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.0/src/codex_cli_mcp_slim.egg-info → codex_cli_mcp_slim-0.1.1}/PKG-INFO +27 -8
  2. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/README.md +26 -7
  3. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/pyproject.toml +1 -1
  4. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim/server.py +138 -15
  5. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1/src/codex_cli_mcp_slim.egg-info}/PKG-INFO +27 -8
  6. codex_cli_mcp_slim-0.1.1/tests/test_basic.py +346 -0
  7. codex_cli_mcp_slim-0.1.0/tests/test_basic.py +0 -123
  8. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/LICENSE +0 -0
  9. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/setup.cfg +0 -0
  10. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim/__init__.py +0 -0
  11. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim/py.typed +0 -0
  12. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim.egg-info/SOURCES.txt +0 -0
  13. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim.egg-info/dependency_links.txt +0 -0
  14. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim.egg-info/entry_points.txt +0 -0
  15. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim.egg-info/requires.txt +0 -0
  16. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/src/codex_cli_mcp_slim.egg-info/top_level.txt +0 -0
  17. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/tests/test_call_tool.py +0 -0
  18. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/tests/test_subprocess_stdin_isolation.py +0 -0
  19. {codex_cli_mcp_slim-0.1.0 → codex_cli_mcp_slim-0.1.1}/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.1
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
@@ -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
@@ -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.1"
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,11 @@ 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
25
26
  from importlib.metadata import version as _pkg_version
26
- from typing import Any
27
+ from typing import Any, NamedTuple
27
28
 
28
29
  from mcp.server import Server, ServerRequestContext
29
30
  from mcp.server.stdio import stdio_server
@@ -49,12 +50,89 @@ _PKG_NAME = "codex-cli-mcp-slim"
49
50
  #
50
51
  # uvx codex-cli-mcp-slim -c model_reasoning_effort=high -C /srv/scratch
51
52
  #
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.
53
+ # `-c` and `--add-dir` may repeat (for `-c` the last one wins), so a per-call `config` can
54
+ # override a server-level `-c`. The flags in _SINGLE_USE_FLAGS may not: see there.
56
55
  SERVER_ARGS: list[str] = []
57
56
 
57
+ # Per-call parameters whose flag codex accepts only once, with every spelling of that flag.
58
+ # A second occurrence makes `codex exec` exit 2 with "cannot be used multiple times", so when
59
+ # the server's own command line already carries one of these flags, the per-call parameter
60
+ # is left out of argv and the server-level value is used: whoever started the server fixed
61
+ # that configuration on purpose.
62
+ _SINGLE_USE_FLAGS: dict[str, tuple[str, ...]] = {
63
+ "cd": ("-C", "--cd"),
64
+ "model": ("-m", "--model"),
65
+ "sandbox": ("-s", "--sandbox"),
66
+ "profile": ("-p", "--profile"),
67
+ "ephemeral": ("--ephemeral",),
68
+ "skip_git_repo_check": ("--skip-git-repo-check",),
69
+ }
70
+
71
+
72
+ # The `codex exec` options (codex-cli 0.159.2) that take the next token as their value, so
73
+ # _params_set_by reads that token as a value and never as a flag of its own.
74
+ _VALUE_FLAGS = frozenset(
75
+ {
76
+ *("-c", "--config", "--enable", "--disable", "-i", "--image"),
77
+ *("-m", "--model", "--local-provider", "-p", "--profile", "-s", "--sandbox"),
78
+ *("-C", "--cd", "--add-dir", "--thread-source", "--output-schema", "--color"),
79
+ *("-o", "--output-last-message"),
80
+ }
81
+ )
82
+
83
+
84
+ class _ServerSetting(NamedTuple):
85
+ """How the server's own command line sets one of the _SINGLE_USE_FLAGS parameters."""
86
+
87
+ value: str | bool # the flag's value, or True for a flag without one
88
+ written: str # the flag as that command line spells it, for example `-C /srv`
89
+
90
+
91
+ def _params_set_by(server_args: list[str]) -> dict[str, _ServerSetting]:
92
+ """The _SINGLE_USE_FLAGS parameters whose flag already appears in `server_args`.
93
+
94
+ A flag counts in every form codex parses as that flag: `--cd DIR`, `--cd=DIR`, `-C DIR`
95
+ and the attached short forms `-CDIR` and `-C=DIR`. Short flags are case-sensitive, so the
96
+ repeatable `-c` is not mistaken for `-C`. The token after a _VALUE_FLAGS option is its
97
+ value (`-m` in `-c -m` is not the model flag), and nothing after `--` is a flag.
98
+ """
99
+ held: dict[str, _ServerSetting] = {}
100
+ tokens = iter(server_args)
101
+ for token in tokens:
102
+ if token == "--":
103
+ break
104
+ if token in _VALUE_FLAGS:
105
+ value = next(tokens, None)
106
+ if value is None:
107
+ break
108
+ param = next((p for p, flags in _SINGLE_USE_FLAGS.items() if token in flags), None)
109
+ if param is not None:
110
+ held.setdefault(param, _ServerSetting(value, shlex.join([token, value])))
111
+ continue
112
+ for param, spellings in _SINGLE_USE_FLAGS.items():
113
+ for flag in spellings:
114
+ if token == flag:
115
+ held.setdefault(param, _ServerSetting(True, token))
116
+ elif len(flag) == 2 and token.startswith(flag):
117
+ value = token[2:].removeprefix("=")
118
+ held.setdefault(param, _ServerSetting(value, shlex.quote(token)))
119
+ elif token.startswith(flag + "="):
120
+ value = token[len(flag) + 1 :]
121
+ held.setdefault(param, _ServerSetting(value, shlex.quote(token)))
122
+ return held
123
+
124
+
125
+ # What SERVER_ARGS sets among the _SINGLE_USE_FLAGS parameters. _set_server_args parses it
126
+ # once, together with SERVER_ARGS, so _build_argv, _invoke and on_list_tools read one answer.
127
+ _SERVER_SET: dict[str, _ServerSetting] = {}
128
+
129
+
130
+ def _set_server_args(server_args: list[str]) -> None:
131
+ global SERVER_ARGS, _SERVER_SET
132
+ SERVER_ARGS = list(server_args)
133
+ _SERVER_SET = _params_set_by(SERVER_ARGS)
134
+
135
+
58
136
  # The Server is built at the bottom of this file: v2 takes the handlers as constructor
59
137
  # arguments, so they must already be defined by the time it is constructed.
60
138
 
@@ -73,8 +151,11 @@ def _build_argv(
73
151
  extra_args: list[str] | None = None,
74
152
  server_args: list[str] | None = None,
75
153
  ) -> list[str]:
76
- argv: list[str] = [CODEX_CMD, "exec"]
77
- argv += SERVER_ARGS if server_args is None else server_args
154
+ if server_args is None:
155
+ server_args, server_set = SERVER_ARGS, _SERVER_SET
156
+ else:
157
+ server_set = _params_set_by(server_args)
158
+ argv: list[str] = [CODEX_CMD, "exec", *server_args]
78
159
  if thread_id:
79
160
  # `codex exec resume <id>` is a subcommand: it has to come before the per-call flags,
80
161
  # and it accepts only a subset of them (the `codex-reply` schema advertises just that
@@ -82,21 +163,25 @@ def _build_argv(
82
163
  # `exec` options there, so a server pinned to a working directory keeps that
83
164
  # directory for replies as well.
84
165
  argv += ["resume", thread_id]
85
- if cd:
166
+ # Per-call flags after `resume` are the subcommand's own, and codex accepts them there
167
+ # even when the same flag sits among the server-level ones, so only a plain `exec` call
168
+ # leaves out what the server already sets.
169
+ held = {} if thread_id else server_set
170
+ if cd and "cd" not in held:
86
171
  argv += ["-C", cd]
87
- if model:
172
+ if model and "model" not in held:
88
173
  argv += ["-m", model]
89
174
  for item in config or []:
90
175
  argv += ["-c", item] # repeatable flag: one -c per key=value
91
- if sandbox:
176
+ if sandbox and "sandbox" not in held:
92
177
  argv += ["--sandbox", sandbox]
93
178
  for d in add_dir or []:
94
179
  argv += ["--add-dir", d] # repeatable flag: one --add-dir per directory
95
- if profile:
180
+ if profile and "profile" not in held:
96
181
  argv += ["-p", profile]
97
- if ephemeral:
182
+ if ephemeral and "ephemeral" not in held:
98
183
  argv.append("--ephemeral")
99
- if skip_git_repo_check:
184
+ if skip_git_repo_check and "skip_git_repo_check" not in held:
100
185
  argv.append("--skip-git-repo-check")
101
186
  if extra_args:
102
187
  argv += list(extra_args)
@@ -608,11 +693,33 @@ async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolR
608
693
  )
609
694
  parsed = _parse_events(result.get("stdout", ""))
610
695
  text = _format_result(result)
696
+ # Name what _build_argv left out, and the value used instead, so a caller does not take a
697
+ # run in the server's directory (say) for a run in the one it asked for.
698
+ held = {} if reply else _SERVER_SET
699
+ dropped = [param for param in _SINGLE_USE_FLAGS if param in held and args.get(param)]
611
700
  structured: dict[str, Any] | None = None
612
701
  if parsed["thread_id"]:
613
702
  # The same shape the deprecated `codex mcp-server` returned, so a client that reads
614
703
  # structuredContent.threadId keeps working.
615
704
  structured = {"threadId": parsed["thread_id"], "content": _response_text(parsed)}
705
+ if dropped:
706
+ lines = [
707
+ f"{p}={json.dumps(args[p], ensure_ascii=False)}; "
708
+ f"this server runs with {held[p].written}"
709
+ for p in dropped
710
+ ]
711
+ logger.warning("per-call parameters ignored: %s", " | ".join(lines))
712
+ text += (
713
+ "\n\nignored, because this server's own command line already sets the flag "
714
+ "and codex takes it only once:\n" + "\n".join(lines)
715
+ )
716
+ # A client that reads only structuredContent learns it here.
717
+ structured = {
718
+ **(structured or {}),
719
+ "ignoredParameters": [
720
+ {"name": p, "value": args[p], "serverValue": held[p].value} for p in dropped
721
+ ],
722
+ }
616
723
  return CallToolResult(
617
724
  content=[TextContent(type="text", text=text)],
618
725
  structured_content=structured,
@@ -631,7 +738,23 @@ async def codex_reply(arguments: dict[str, Any] | None) -> CallToolResult:
631
738
  async def on_list_tools(
632
739
  ctx: ServerRequestContext, params: PaginatedRequestParams | None
633
740
  ) -> ListToolsResult:
634
- return ListToolsResult(tools=[_CODEX_TOOL, _REPLY_TOOL])
741
+ # The caller cannot see the server's command line, so the `codex` schema says which
742
+ # parameters it already fixes, rather than inviting values that _build_argv would drop.
743
+ if not _SERVER_SET:
744
+ return ListToolsResult(tools=[_CODEX_TOOL, _REPLY_TOOL])
745
+ props = dict(_CODEX_PROPS)
746
+ for param, setting in _SERVER_SET.items():
747
+ props[param] = {
748
+ **props[param],
749
+ "description": (
750
+ f"{props[param]['description']} This server's own command line already "
751
+ f"sets {setting.written}, so a value passed here is ignored."
752
+ ),
753
+ }
754
+ codex_tool = _CODEX_TOOL.model_copy(
755
+ update={"input_schema": {**_CODEX_TOOL.input_schema, "properties": props}}
756
+ )
757
+ return ListToolsResult(tools=[codex_tool, _REPLY_TOOL])
635
758
 
636
759
 
637
760
  async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
@@ -691,7 +814,7 @@ def main() -> None:
691
814
  if sys.argv[1:] == ["--version"]:
692
815
  print(f"{_PKG_NAME} {_pkg_version(_PKG_NAME)}")
693
816
  return
694
- SERVER_ARGS[:] = sys.argv[1:]
817
+ _set_server_args(sys.argv[1:])
695
818
  asyncio.run(_amain())
696
819
 
697
820
 
@@ -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.1
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
@@ -0,0 +1,346 @@
1
+ """Tests for argv construction, including how per-call flags meet server-level ones.
2
+
3
+ The subprocess is never started here; the tests that go through the tool replace
4
+ `_run_codex` with a stub that records the argv it was handed.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from codex_cli_mcp_slim import server
10
+ from codex_cli_mcp_slim.server import CODEX_CMD, _build_argv
11
+
12
+
13
+ def _ok_run(**kwargs):
14
+ stdout = (
15
+ '{"type": "thread.started", "thread_id": "t"}\n'
16
+ '{"type": "item.completed", "item": {"type": "agent_message", "text": "answer"}}\n'
17
+ '{"type": "turn.completed", "usage": {}}\n'
18
+ )
19
+ return {"ok": True, "returncode": 0, "stdout": stdout, "stderr": "", "argv": kwargs["argv"]}
20
+
21
+
22
+ @pytest.fixture
23
+ def start_server_with(monkeypatch):
24
+ """Set the server's own command line the way main() does, restored after the test."""
25
+ monkeypatch.setattr(server, "SERVER_ARGS", server.SERVER_ARGS)
26
+ monkeypatch.setattr(server, "_SERVER_SET", server._SERVER_SET)
27
+ return server._set_server_args
28
+
29
+
30
+ def test_build_argv_minimal():
31
+ argv = _build_argv(server_args=[])
32
+ assert argv == [CODEX_CMD, "exec", "--json", "-"]
33
+
34
+
35
+ def test_build_argv_ends_with_json_and_stdin_prompt():
36
+ # --json and `-` stay last so nothing in extra_args can capture `-` as its own value,
37
+ # and so the prompt is read from stdin rather than taken from argv.
38
+ argv = _build_argv(server_args=[], extra_args=["--output-schema", "/tmp/schema.json"])
39
+ assert argv[-2:] == ["--json", "-"]
40
+ assert argv[-4:-2] == ["--output-schema", "/tmp/schema.json"]
41
+
42
+
43
+ def test_build_argv_server_args_come_right_after_exec():
44
+ server_args = ["-c", "model_reasoning_effort=high", "-C", "/srv/scratch"]
45
+ argv = _build_argv(server_args=server_args)
46
+ assert argv[:6] == [CODEX_CMD, "exec", *server_args]
47
+
48
+
49
+ def test_build_argv_per_call_config_follows_server_args_so_it_wins():
50
+ # codex applies repeated -c flags in order, last one wins; a per-call override therefore
51
+ # has to come after the server-level one.
52
+ argv = _build_argv(
53
+ server_args=["-c", "model_reasoning_effort=high"],
54
+ config=["model_reasoning_effort=low"],
55
+ )
56
+ positions = [i for i, tok in enumerate(argv) if tok == "-c"]
57
+ assert argv[positions[0] + 1] == "model_reasoning_effort=high"
58
+ assert argv[positions[1] + 1] == "model_reasoning_effort=low"
59
+
60
+
61
+ def test_build_argv_resume_is_a_subcommand_before_per_call_flags():
62
+ argv = _build_argv(server_args=["-c", "k=v"], thread_id="thread-1", model="m")
63
+ assert argv[:6] == [CODEX_CMD, "exec", "-c", "k=v", "resume", "thread-1"]
64
+ assert argv[6:8] == ["-m", "m"]
65
+
66
+
67
+ def test_build_argv_cd_maps_to_dash_c_upper():
68
+ argv = _build_argv(server_args=[], cd="/repo")
69
+ assert argv[argv.index("-C") + 1] == "/repo"
70
+
71
+
72
+ def test_build_argv_model_maps_to_dash_m():
73
+ argv = _build_argv(server_args=[], model="gpt-example")
74
+ assert argv[argv.index("-m") + 1] == "gpt-example"
75
+
76
+
77
+ def test_build_argv_config_is_repeated_not_comma_joined():
78
+ argv = _build_argv(server_args=[], config=["a=1", "b=2", "c=3"])
79
+ assert argv.count("-c") == 3
80
+ for item in ("a=1", "b=2", "c=3"):
81
+ assert argv[argv.index(item) - 1] == "-c"
82
+ assert "a=1,b=2,c=3" not in argv
83
+
84
+
85
+ def test_build_argv_add_dir_is_repeated_not_comma_joined():
86
+ argv = _build_argv(server_args=[], add_dir=["/a", "/b", "/c"])
87
+ assert argv.count("--add-dir") == 3
88
+ for d in ("/a", "/b", "/c"):
89
+ assert argv[argv.index(d) - 1] == "--add-dir"
90
+ assert "/a,/b,/c" not in argv
91
+
92
+
93
+ def test_build_argv_sandbox():
94
+ argv = _build_argv(server_args=[], sandbox="read-only")
95
+ assert argv[argv.index("--sandbox") + 1] == "read-only"
96
+
97
+
98
+ def test_build_argv_profile():
99
+ argv = _build_argv(server_args=[], profile="research")
100
+ assert argv[argv.index("-p") + 1] == "research"
101
+
102
+
103
+ def test_build_argv_boolean_flags():
104
+ argv = _build_argv(server_args=[], ephemeral=True, skip_git_repo_check=True)
105
+ assert "--ephemeral" in argv
106
+ assert "--skip-git-repo-check" in argv
107
+ argv = _build_argv(server_args=[])
108
+ assert "--ephemeral" not in argv
109
+ assert "--skip-git-repo-check" not in argv
110
+
111
+
112
+ def test_build_argv_extra_args_appended_verbatim_before_json():
113
+ argv = _build_argv(server_args=[], extra_args=["--ignore-rules", "--enable", "x"])
114
+ assert argv[-5:-2] == ["--ignore-rules", "--enable", "x"]
115
+
116
+
117
+ def test_build_argv_all_typed_flags_combine():
118
+ argv = _build_argv(
119
+ server_args=[],
120
+ cd="/repo",
121
+ model="m",
122
+ config=["k=v"],
123
+ sandbox="workspace-write",
124
+ add_dir=["/x"],
125
+ profile="p",
126
+ ephemeral=True,
127
+ skip_git_repo_check=True,
128
+ extra_args=["--custom"],
129
+ )
130
+ for token in (
131
+ "-C",
132
+ "/repo",
133
+ "-m",
134
+ "m",
135
+ "-c",
136
+ "k=v",
137
+ "--sandbox",
138
+ "workspace-write",
139
+ "--add-dir",
140
+ "/x",
141
+ "-p",
142
+ "p",
143
+ "--ephemeral",
144
+ "--skip-git-repo-check",
145
+ "--custom",
146
+ ):
147
+ assert token in argv
148
+
149
+
150
+ # codex rejects a second -C, -m, -s, -p, --ephemeral or --skip-git-repo-check with "cannot
151
+ # be used multiple times" (exit 2), so a per-call parameter whose flag the server's own
152
+ # command line already sets is left out and the server-level value is used.
153
+
154
+
155
+ def _value_after(argv, *flags):
156
+ return [argv[i + 1] for i, tok in enumerate(argv) if tok in flags]
157
+
158
+
159
+ def test_build_argv_drops_per_call_flags_the_server_already_sets():
160
+ argv = _build_argv(
161
+ server_args=["-C", "/srv/scratch", "--skip-git-repo-check"],
162
+ cd="/other",
163
+ skip_git_repo_check=True,
164
+ )
165
+ assert argv.count("--skip-git-repo-check") == 1
166
+ assert _value_after(argv, "-C", "--cd") == ["/srv/scratch"]
167
+ assert "/other" not in argv
168
+
169
+
170
+ @pytest.mark.parametrize(
171
+ ("server_args", "param", "value"),
172
+ [
173
+ (["--cd", "/srv"], "cd", "/other"),
174
+ (["--cd=/srv"], "cd", "/other"),
175
+ (["-C/srv"], "cd", "/other"),
176
+ (["-C=/srv"], "cd", "/other"),
177
+ (["-m", "a"], "model", "b"),
178
+ (["--model=a"], "model", "b"),
179
+ (["-ma"], "model", "b"),
180
+ (["-s", "read-only"], "sandbox", "workspace-write"),
181
+ (["--sandbox=read-only"], "sandbox", "workspace-write"),
182
+ (["-p", "a"], "profile", "b"),
183
+ (["--profile", "a"], "profile", "b"),
184
+ (["--ephemeral"], "ephemeral", True),
185
+ (["--skip-git-repo-check"], "skip_git_repo_check", True),
186
+ ],
187
+ )
188
+ def test_build_argv_recognises_every_spelling_of_a_server_level_flag(server_args, param, value):
189
+ argv = _build_argv(server_args=server_args, **{param: value})
190
+ assert argv == [CODEX_CMD, "exec", *server_args, "--json", "-"]
191
+
192
+
193
+ @pytest.mark.parametrize(
194
+ "server_args",
195
+ [
196
+ ["-c", "-model=1"], # the value of -c, not the model flag
197
+ ["--", "-m"], # past the end of the flags
198
+ ],
199
+ )
200
+ def test_build_argv_reads_neither_a_value_nor_a_token_after_double_dash_as_a_flag(server_args):
201
+ assert "model" not in server._params_set_by(server_args)
202
+ argv = _build_argv(server_args=server_args, model="b")
203
+ assert argv == [CODEX_CMD, "exec", *server_args, "-m", "b", "--json", "-"]
204
+
205
+
206
+ @pytest.mark.parametrize(
207
+ ("server_args", "value", "written"),
208
+ [
209
+ (["-C", "/srv"], "/srv", "-C /srv"),
210
+ (["-c", "k=v", "--cd", "/srv"], "/srv", "--cd /srv"),
211
+ (["--cd=/srv"], "/srv", "--cd=/srv"),
212
+ (["-C/srv"], "/srv", "-C/srv"),
213
+ (["-C=/srv"], "/srv", "-C=/srv"),
214
+ (["-C", "/my dir"], "/my dir", "-C '/my dir'"),
215
+ ],
216
+ )
217
+ def test_params_set_by_keeps_the_server_level_value_and_its_spelling(server_args, value, written):
218
+ assert server._params_set_by(server_args) == {"cd": (value, written)}
219
+
220
+
221
+ def test_params_set_by_reports_a_flag_without_a_value_as_true():
222
+ assert server._params_set_by(["--ephemeral"]) == {"ephemeral": (True, "--ephemeral")}
223
+
224
+
225
+ def test_build_argv_keeps_per_call_flags_the_server_does_not_set():
226
+ argv = _build_argv(
227
+ server_args=["--skip-git-repo-check"],
228
+ cd="/repo",
229
+ model="m",
230
+ sandbox="read-only",
231
+ profile="p",
232
+ ephemeral=True,
233
+ )
234
+ assert _value_after(argv, "-C") == ["/repo"]
235
+ assert _value_after(argv, "-m") == ["m"]
236
+ assert _value_after(argv, "--sandbox") == ["read-only"]
237
+ assert _value_after(argv, "-p") == ["p"]
238
+ assert "--ephemeral" in argv
239
+
240
+
241
+ def test_build_argv_server_level_dash_c_lower_is_not_mistaken_for_cd():
242
+ argv = _build_argv(server_args=["-c", "model_reasoning_effort=high"], cd="/repo")
243
+ assert _value_after(argv, "-C") == ["/repo"]
244
+
245
+
246
+ def test_build_argv_repeatable_flags_still_stack_on_server_level_ones():
247
+ argv = _build_argv(
248
+ server_args=["-c", "k=1", "--add-dir", "/a"],
249
+ config=["k=2"],
250
+ add_dir=["/b"],
251
+ )
252
+ assert _value_after(argv, "-c") == ["k=1", "k=2"]
253
+ assert _value_after(argv, "--add-dir") == ["/a", "/b"]
254
+
255
+
256
+ def test_build_argv_reply_keeps_per_call_flags_after_resume():
257
+ # After `resume` the per-call flags belong to the subcommand, which codex parses on its
258
+ # own, so a repeat of a server-level flag is accepted there and nothing is dropped.
259
+ argv = _build_argv(
260
+ server_args=["--skip-git-repo-check", "-m", "a"],
261
+ thread_id="t",
262
+ skip_git_repo_check=True,
263
+ model="b",
264
+ )
265
+ after_resume = argv[argv.index("resume") + 2 :]
266
+ assert "--skip-git-repo-check" in after_resume
267
+ assert _value_after(after_resume, "-m") == ["b"]
268
+
269
+
270
+ async def test_tool_reports_the_per_call_parameters_it_dropped(monkeypatch, start_server_with):
271
+ seen = {}
272
+
273
+ async def fake_run(**kwargs):
274
+ seen.update(kwargs)
275
+ return _ok_run(**kwargs)
276
+
277
+ start_server_with(["-C", "/srv", "--skip-git-repo-check"])
278
+ monkeypatch.setattr(server, "_run_codex", fake_run)
279
+ result = await server.codex(
280
+ {"prompt": "p", "cd": "/other", "skip_git_repo_check": True, "model": "m"}
281
+ )
282
+ assert seen["argv"].count("--skip-git-repo-check") == 1
283
+ assert _value_after(seen["argv"], "-C") == ["/srv"]
284
+ assert _value_after(seen["argv"], "-m") == ["m"]
285
+ text = result.content[0].text
286
+ assert result.is_error is False
287
+ assert text.startswith("answer\n\n[codex] thread_id=t")
288
+ assert 'cd="/other"; this server runs with -C /srv' in text
289
+ assert "skip_git_repo_check=true; this server runs with --skip-git-repo-check" in text
290
+ assert "model=" not in text
291
+ assert result.structured_content == {
292
+ "threadId": "t",
293
+ "content": "answer",
294
+ "ignoredParameters": [
295
+ {"name": "cd", "value": "/other", "serverValue": "/srv"},
296
+ {"name": "skip_git_repo_check", "value": True, "serverValue": True},
297
+ ],
298
+ }
299
+
300
+
301
+ async def test_tool_reports_dropped_parameters_in_structured_content_without_a_thread(
302
+ monkeypatch, start_server_with
303
+ ):
304
+ async def fake_run(**kwargs):
305
+ return {"ok": False, "error": "boom", "argv": kwargs["argv"]}
306
+
307
+ start_server_with(["--model=a"])
308
+ monkeypatch.setattr(server, "_run_codex", fake_run)
309
+ result = await server.codex({"prompt": "p", "model": "b"})
310
+ assert result.is_error is True
311
+ assert result.content[0].text.startswith("[ERROR] boom")
312
+ assert 'model="b"; this server runs with --model=a' in result.content[0].text
313
+ assert result.structured_content == {
314
+ "ignoredParameters": [{"name": "model", "value": "b", "serverValue": "a"}]
315
+ }
316
+
317
+
318
+ async def test_tool_adds_nothing_when_no_per_call_parameter_was_dropped(
319
+ monkeypatch, start_server_with
320
+ ):
321
+ async def fake_run(**kwargs):
322
+ return _ok_run(**kwargs)
323
+
324
+ start_server_with(["--skip-git-repo-check"])
325
+ monkeypatch.setattr(server, "_run_codex", fake_run)
326
+ result = await server.codex({"prompt": "p", "cd": "/repo"})
327
+ assert result.content[0].text == "answer\n\n[codex] thread_id=t status=completed"
328
+ assert result.structured_content == {"threadId": "t", "content": "answer"}
329
+
330
+
331
+ async def test_tool_schema_names_the_parameters_the_server_already_sets(start_server_with):
332
+ def props():
333
+ return {tool.name: tool.input_schema["properties"] for tool in listed.tools}
334
+
335
+ start_server_with(["--skip-git-repo-check", "-C", "/srv"])
336
+ listed = await server.on_list_tools(None, None)
337
+ assert (
338
+ "already sets --skip-git-repo-check"
339
+ in props()["codex"]["skip_git_repo_check"]["description"]
340
+ )
341
+ assert "already sets -C /srv," in props()["codex"]["cd"]["description"]
342
+ assert "already" not in props()["codex"]["model"]["description"]
343
+
344
+ start_server_with([])
345
+ listed = await server.on_list_tools(None, None)
346
+ assert "already" not in props()["codex"]["skip_git_repo_check"]["description"]
@@ -1,123 +0,0 @@
1
- """Pure-function tests for argv construction. Subprocess execution is not tested here."""
2
-
3
- from codex_cli_mcp_slim.server import CODEX_CMD, _build_argv
4
-
5
-
6
- def test_build_argv_minimal():
7
- argv = _build_argv(server_args=[])
8
- assert argv == [CODEX_CMD, "exec", "--json", "-"]
9
-
10
-
11
- def test_build_argv_ends_with_json_and_stdin_prompt():
12
- # --json and `-` stay last so nothing in extra_args can capture `-` as its own value,
13
- # and so the prompt is read from stdin rather than taken from argv.
14
- argv = _build_argv(server_args=[], extra_args=["--output-schema", "/tmp/schema.json"])
15
- assert argv[-2:] == ["--json", "-"]
16
- assert argv[-4:-2] == ["--output-schema", "/tmp/schema.json"]
17
-
18
-
19
- def test_build_argv_server_args_come_right_after_exec():
20
- server_args = ["-c", "model_reasoning_effort=high", "-C", "/srv/scratch"]
21
- argv = _build_argv(server_args=server_args)
22
- assert argv[:6] == [CODEX_CMD, "exec", *server_args]
23
-
24
-
25
- def test_build_argv_per_call_config_follows_server_args_so_it_wins():
26
- # codex applies repeated -c flags in order, last one wins; a per-call override therefore
27
- # has to come after the server-level one.
28
- argv = _build_argv(
29
- server_args=["-c", "model_reasoning_effort=high"],
30
- config=["model_reasoning_effort=low"],
31
- )
32
- positions = [i for i, tok in enumerate(argv) if tok == "-c"]
33
- assert argv[positions[0] + 1] == "model_reasoning_effort=high"
34
- assert argv[positions[1] + 1] == "model_reasoning_effort=low"
35
-
36
-
37
- def test_build_argv_resume_is_a_subcommand_before_per_call_flags():
38
- argv = _build_argv(server_args=["-c", "k=v"], thread_id="thread-1", model="m")
39
- assert argv[:6] == [CODEX_CMD, "exec", "-c", "k=v", "resume", "thread-1"]
40
- assert argv[6:8] == ["-m", "m"]
41
-
42
-
43
- def test_build_argv_cd_maps_to_dash_c_upper():
44
- argv = _build_argv(server_args=[], cd="/repo")
45
- assert argv[argv.index("-C") + 1] == "/repo"
46
-
47
-
48
- def test_build_argv_model_maps_to_dash_m():
49
- argv = _build_argv(server_args=[], model="gpt-example")
50
- assert argv[argv.index("-m") + 1] == "gpt-example"
51
-
52
-
53
- def test_build_argv_config_is_repeated_not_comma_joined():
54
- argv = _build_argv(server_args=[], config=["a=1", "b=2", "c=3"])
55
- assert argv.count("-c") == 3
56
- for item in ("a=1", "b=2", "c=3"):
57
- assert argv[argv.index(item) - 1] == "-c"
58
- assert "a=1,b=2,c=3" not in argv
59
-
60
-
61
- def test_build_argv_add_dir_is_repeated_not_comma_joined():
62
- argv = _build_argv(server_args=[], add_dir=["/a", "/b", "/c"])
63
- assert argv.count("--add-dir") == 3
64
- for d in ("/a", "/b", "/c"):
65
- assert argv[argv.index(d) - 1] == "--add-dir"
66
- assert "/a,/b,/c" not in argv
67
-
68
-
69
- def test_build_argv_sandbox():
70
- argv = _build_argv(server_args=[], sandbox="read-only")
71
- assert argv[argv.index("--sandbox") + 1] == "read-only"
72
-
73
-
74
- def test_build_argv_profile():
75
- argv = _build_argv(server_args=[], profile="research")
76
- assert argv[argv.index("-p") + 1] == "research"
77
-
78
-
79
- def test_build_argv_boolean_flags():
80
- argv = _build_argv(server_args=[], ephemeral=True, skip_git_repo_check=True)
81
- assert "--ephemeral" in argv
82
- assert "--skip-git-repo-check" in argv
83
- argv = _build_argv(server_args=[])
84
- assert "--ephemeral" not in argv
85
- assert "--skip-git-repo-check" not in argv
86
-
87
-
88
- def test_build_argv_extra_args_appended_verbatim_before_json():
89
- argv = _build_argv(server_args=[], extra_args=["--ignore-rules", "--enable", "x"])
90
- assert argv[-5:-2] == ["--ignore-rules", "--enable", "x"]
91
-
92
-
93
- def test_build_argv_all_typed_flags_combine():
94
- argv = _build_argv(
95
- server_args=[],
96
- cd="/repo",
97
- model="m",
98
- config=["k=v"],
99
- sandbox="workspace-write",
100
- add_dir=["/x"],
101
- profile="p",
102
- ephemeral=True,
103
- skip_git_repo_check=True,
104
- extra_args=["--custom"],
105
- )
106
- for token in (
107
- "-C",
108
- "/repo",
109
- "-m",
110
- "m",
111
- "-c",
112
- "k=v",
113
- "--sandbox",
114
- "workspace-write",
115
- "--add-dir",
116
- "/x",
117
- "-p",
118
- "p",
119
- "--ephemeral",
120
- "--skip-git-repo-check",
121
- "--custom",
122
- ):
123
- assert token in argv