codex-cli-mcp-slim 0.1.0__py3-none-any.whl
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/__init__.py +8 -0
- codex_cli_mcp_slim/py.typed +0 -0
- codex_cli_mcp_slim/server.py +699 -0
- codex_cli_mcp_slim-0.1.0.dist-info/METADATA +354 -0
- codex_cli_mcp_slim-0.1.0.dist-info/RECORD +9 -0
- codex_cli_mcp_slim-0.1.0.dist-info/WHEEL +5 -0
- codex_cli_mcp_slim-0.1.0.dist-info/entry_points.txt +2 -0
- codex_cli_mcp_slim-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_cli_mcp_slim-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""codex_cli_mcp_slim - thin auditable MCP wrapper around the Codex CLI (`codex exec`)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _pkg_version
|
|
4
|
+
|
|
5
|
+
from .server import main
|
|
6
|
+
|
|
7
|
+
__version__ = _pkg_version("codex-cli-mcp-slim")
|
|
8
|
+
__all__ = ["__version__", "main"]
|
|
File without changes
|
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""codex_cli_mcp_slim: Thin, auditable MCP server wrapping the Codex CLI (`codex exec`).
|
|
2
|
+
|
|
3
|
+
Design goals:
|
|
4
|
+
* Auditable: a single file with only one third-party dependency (`mcp`),
|
|
5
|
+
readable end-to-end in one sitting.
|
|
6
|
+
* Same tool names: the two tools are `codex` and `codex-reply`, the names the
|
|
7
|
+
deprecated `codex mcp-server` exposed, so an MCP client configured for that
|
|
8
|
+
server keeps its server and tool names after swapping the launch command.
|
|
9
|
+
* Forward-compatible: any `codex exec` flag is reachable via `extra_args`
|
|
10
|
+
without server changes; the binary itself can be swapped via $CODEX_CMD.
|
|
11
|
+
* Safe: subprocess uses argv-list form (no shell), the prompt travels over
|
|
12
|
+
stdin rather than argv, hard timeout, explicit env merge. Each invocation
|
|
13
|
+
logs the exact argv to stderr.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import contextlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
from importlib.metadata import version as _pkg_version
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from mcp.server import Server, ServerRequestContext
|
|
29
|
+
from mcp.server.stdio import stdio_server
|
|
30
|
+
from mcp.types import (
|
|
31
|
+
CallToolRequestParams,
|
|
32
|
+
CallToolResult,
|
|
33
|
+
ListToolsResult,
|
|
34
|
+
PaginatedRequestParams,
|
|
35
|
+
TextContent,
|
|
36
|
+
Tool,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("codex_cli_mcp_slim")
|
|
40
|
+
|
|
41
|
+
CODEX_CMD = os.environ.get("CODEX_CMD", "codex")
|
|
42
|
+
DEFAULT_TIMEOUT = int(os.environ.get("CODEX_CLI_MCP_SLIM_TIMEOUT", "1800"))
|
|
43
|
+
|
|
44
|
+
_PKG_NAME = "codex-cli-mcp-slim"
|
|
45
|
+
|
|
46
|
+
# Flags placed right after `codex exec` on every invocation. main() fills this from the
|
|
47
|
+
# server's own command line, so one MCP-client entry can pin a reasoning effort, a working
|
|
48
|
+
# directory or a model for every call it makes:
|
|
49
|
+
#
|
|
50
|
+
# uvx codex-cli-mcp-slim -c model_reasoning_effort=high -C /srv/scratch
|
|
51
|
+
#
|
|
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.
|
|
56
|
+
SERVER_ARGS: list[str] = []
|
|
57
|
+
|
|
58
|
+
# The Server is built at the bottom of this file: v2 takes the handlers as constructor
|
|
59
|
+
# arguments, so they must already be defined by the time it is constructed.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_argv(
|
|
63
|
+
*,
|
|
64
|
+
thread_id: str | None = None,
|
|
65
|
+
cd: str | None = None,
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
config: list[str] | None = None,
|
|
68
|
+
sandbox: str | None = None,
|
|
69
|
+
add_dir: list[str] | None = None,
|
|
70
|
+
profile: str | None = None,
|
|
71
|
+
ephemeral: bool = False,
|
|
72
|
+
skip_git_repo_check: bool = False,
|
|
73
|
+
extra_args: list[str] | None = None,
|
|
74
|
+
server_args: list[str] | None = None,
|
|
75
|
+
) -> list[str]:
|
|
76
|
+
argv: list[str] = [CODEX_CMD, "exec"]
|
|
77
|
+
argv += SERVER_ARGS if server_args is None else server_args
|
|
78
|
+
if thread_id:
|
|
79
|
+
# `codex exec resume <id>` is a subcommand: it has to come before the per-call flags,
|
|
80
|
+
# and it accepts only a subset of them (the `codex-reply` schema advertises just that
|
|
81
|
+
# subset). Server-level flags stay in front of it on purpose: codex parses them as
|
|
82
|
+
# `exec` options there, so a server pinned to a working directory keeps that
|
|
83
|
+
# directory for replies as well.
|
|
84
|
+
argv += ["resume", thread_id]
|
|
85
|
+
if cd:
|
|
86
|
+
argv += ["-C", cd]
|
|
87
|
+
if model:
|
|
88
|
+
argv += ["-m", model]
|
|
89
|
+
for item in config or []:
|
|
90
|
+
argv += ["-c", item] # repeatable flag: one -c per key=value
|
|
91
|
+
if sandbox:
|
|
92
|
+
argv += ["--sandbox", sandbox]
|
|
93
|
+
for d in add_dir or []:
|
|
94
|
+
argv += ["--add-dir", d] # repeatable flag: one --add-dir per directory
|
|
95
|
+
if profile:
|
|
96
|
+
argv += ["-p", profile]
|
|
97
|
+
if ephemeral:
|
|
98
|
+
argv.append("--ephemeral")
|
|
99
|
+
if skip_git_repo_check:
|
|
100
|
+
argv.append("--skip-git-repo-check")
|
|
101
|
+
if extra_args:
|
|
102
|
+
argv += list(extra_args)
|
|
103
|
+
# --json is not optional for this server: the thread id, the failure of a turn and the
|
|
104
|
+
# token usage exist only in the event stream. `-` makes codex read the prompt from
|
|
105
|
+
# stdin, so the prompt never appears in the process list and is not bounded by the
|
|
106
|
+
# argv size limit. Both stay last so that nothing in extra_args can capture `-` as its
|
|
107
|
+
# value.
|
|
108
|
+
argv += ["--json", "-"]
|
|
109
|
+
return argv
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _run_codex(
|
|
113
|
+
*,
|
|
114
|
+
argv: list[str],
|
|
115
|
+
prompt: str,
|
|
116
|
+
timeout: int,
|
|
117
|
+
env_overrides: dict[str, str] | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
env = os.environ.copy()
|
|
120
|
+
if env_overrides:
|
|
121
|
+
env.update({k: str(v) for k, v in env_overrides.items()})
|
|
122
|
+
|
|
123
|
+
logger.info("exec %s (timeout=%ss, prompt=%d chars)", argv, timeout, len(prompt))
|
|
124
|
+
|
|
125
|
+
# stdin is a pipe of our own: the prompt goes down it and it is closed. The child must
|
|
126
|
+
# never inherit the parent's stdin, which under the stdio MCP transport is the JSON-RPC
|
|
127
|
+
# channel from the client; a child reading from that shared file description would
|
|
128
|
+
# corrupt the channel and silently kill the server.
|
|
129
|
+
try:
|
|
130
|
+
proc = await asyncio.create_subprocess_exec(
|
|
131
|
+
*argv,
|
|
132
|
+
stdin=asyncio.subprocess.PIPE,
|
|
133
|
+
stdout=asyncio.subprocess.PIPE,
|
|
134
|
+
stderr=asyncio.subprocess.PIPE,
|
|
135
|
+
env=env,
|
|
136
|
+
# own session, so timeout cleanup can kill the group (POSIX only, no-op elsewhere)
|
|
137
|
+
start_new_session=True,
|
|
138
|
+
)
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
return {
|
|
141
|
+
"ok": False,
|
|
142
|
+
"error": f"failed to launch codex binary: {exc} (check $CODEX_CMD)",
|
|
143
|
+
"argv": argv,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# shield the task so a first-wait timeout doesn't cancel the second wait below
|
|
147
|
+
communicate_task = asyncio.ensure_future(proc.communicate(prompt.encode()))
|
|
148
|
+
try:
|
|
149
|
+
try:
|
|
150
|
+
stdout, stderr = await asyncio.wait_for(
|
|
151
|
+
asyncio.shield(communicate_task), timeout=timeout
|
|
152
|
+
)
|
|
153
|
+
except asyncio.TimeoutError:
|
|
154
|
+
if communicate_task.done():
|
|
155
|
+
# narrow race: the process finished at the exact moment wait_for's timeout fired
|
|
156
|
+
stdout, stderr = await communicate_task
|
|
157
|
+
return {
|
|
158
|
+
"ok": proc.returncode == 0,
|
|
159
|
+
"returncode": proc.returncode,
|
|
160
|
+
"stdout": stdout.decode("utf-8", errors="replace"),
|
|
161
|
+
"stderr": stderr.decode("utf-8", errors="replace"),
|
|
162
|
+
"argv": argv,
|
|
163
|
+
}
|
|
164
|
+
_kill_process_group(proc)
|
|
165
|
+
stdout, stderr = b"", b""
|
|
166
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
167
|
+
# kill above closes the pipes, so communicate() reaches EOF with the buffered output
|
|
168
|
+
stdout, stderr = await asyncio.wait_for(
|
|
169
|
+
asyncio.shield(communicate_task), timeout=10
|
|
170
|
+
)
|
|
171
|
+
# bounded backstop: a D-state descendant can't be force-killed, so don't block forever
|
|
172
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
173
|
+
await asyncio.wait_for(proc.wait(), timeout=10)
|
|
174
|
+
return {
|
|
175
|
+
"ok": False,
|
|
176
|
+
"error": f"timeout after {timeout}s",
|
|
177
|
+
"argv": argv,
|
|
178
|
+
"returncode": proc.returncode,
|
|
179
|
+
"stdout": stdout.decode("utf-8", errors="replace"),
|
|
180
|
+
"stderr": stderr.decode("utf-8", errors="replace"),
|
|
181
|
+
}
|
|
182
|
+
except asyncio.CancelledError:
|
|
183
|
+
_kill_process_group(proc)
|
|
184
|
+
raise
|
|
185
|
+
finally:
|
|
186
|
+
if not communicate_task.done():
|
|
187
|
+
communicate_task.cancel()
|
|
188
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
189
|
+
await communicate_task
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"ok": proc.returncode == 0,
|
|
193
|
+
"returncode": proc.returncode,
|
|
194
|
+
"stdout": stdout.decode("utf-8", errors="replace"),
|
|
195
|
+
"stderr": stderr.decode("utf-8", errors="replace"),
|
|
196
|
+
"argv": argv,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _kill_process_group(proc: asyncio.subprocess.Process) -> None:
|
|
201
|
+
"""Kill codex's process group; a descendant that setsid/setpgid away from it is out of reach."""
|
|
202
|
+
if sys.platform != "win32":
|
|
203
|
+
# start_new_session=True makes codex pid==pgid, so killpg(proc.pid) works after it exits too
|
|
204
|
+
with contextlib.suppress(OSError):
|
|
205
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
206
|
+
# any killpg failure (not just the two anticipated OSError subclasses) must still fall back here
|
|
207
|
+
with contextlib.suppress(OSError):
|
|
208
|
+
proc.kill()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_events(stdout: str) -> dict[str, Any]:
|
|
212
|
+
"""Fold the `codex exec --json` event stream into the few facts this server reports.
|
|
213
|
+
|
|
214
|
+
The stream is one JSON object per line. Lines that are not JSON objects are skipped
|
|
215
|
+
rather than rejected: the events this server needs are self-describing by `type`, and
|
|
216
|
+
a future codex release adding lines it does not know must not turn every call into an
|
|
217
|
+
error.
|
|
218
|
+
"""
|
|
219
|
+
thread_id: str | None = None
|
|
220
|
+
messages: list[str] = []
|
|
221
|
+
errors: list[str] = []
|
|
222
|
+
usage: dict[str, Any] | None = None
|
|
223
|
+
turn_status: str | None = None
|
|
224
|
+
for line in stdout.splitlines():
|
|
225
|
+
line = line.strip()
|
|
226
|
+
if not line:
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
event = json.loads(line)
|
|
230
|
+
except ValueError:
|
|
231
|
+
continue
|
|
232
|
+
if not isinstance(event, dict):
|
|
233
|
+
continue
|
|
234
|
+
kind = event.get("type")
|
|
235
|
+
if kind == "thread.started":
|
|
236
|
+
thread_id = event.get("thread_id") or thread_id
|
|
237
|
+
elif kind == "item.completed":
|
|
238
|
+
item = event.get("item")
|
|
239
|
+
if isinstance(item, dict) and item.get("type") == "agent_message":
|
|
240
|
+
messages.append(str(item.get("text") or ""))
|
|
241
|
+
elif kind == "turn.completed":
|
|
242
|
+
turn_status = "completed"
|
|
243
|
+
usage = event.get("usage") if isinstance(event.get("usage"), dict) else None
|
|
244
|
+
elif kind == "turn.failed":
|
|
245
|
+
turn_status = "failed"
|
|
246
|
+
error = event.get("error")
|
|
247
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
248
|
+
errors.append(str(message or error or event))
|
|
249
|
+
elif kind == "error":
|
|
250
|
+
errors.append(str(event.get("message") or event))
|
|
251
|
+
# codex reports one failure twice, as an `error` event and again inside `turn.failed`.
|
|
252
|
+
unique_errors = list(dict.fromkeys(errors))
|
|
253
|
+
return {
|
|
254
|
+
"thread_id": thread_id,
|
|
255
|
+
"messages": messages,
|
|
256
|
+
"errors": unique_errors,
|
|
257
|
+
"usage": usage,
|
|
258
|
+
"turn_status": turn_status,
|
|
259
|
+
"saw_events": bool(thread_id or messages or unique_errors or turn_status),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _codex_trailer(parsed: dict[str, Any]) -> str:
|
|
264
|
+
"""One line of run metadata, appended under codex's own text.
|
|
265
|
+
|
|
266
|
+
`thread_id` is here because `codex-reply` needs it and the event stream is the only
|
|
267
|
+
place codex reports it. `status` is the turn's own verdict, which the exit code does
|
|
268
|
+
not carry on its own: a turn that failed inside the model API still exits 0 when the
|
|
269
|
+
process shut down cleanly (observed with an unsupported reasoning effort).
|
|
270
|
+
"""
|
|
271
|
+
bits = [f"thread_id={parsed.get('thread_id') or 'UNKNOWN'}"]
|
|
272
|
+
bits.append(f"status={parsed.get('turn_status') or 'UNKNOWN'}")
|
|
273
|
+
usage = parsed.get("usage") or {}
|
|
274
|
+
for key in ("input_tokens", "cached_input_tokens", "output_tokens"):
|
|
275
|
+
if usage.get(key) is not None:
|
|
276
|
+
bits.append(f"{key}={usage[key]}")
|
|
277
|
+
return "[codex] " + " ".join(bits)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _run_failed(result: dict[str, Any]) -> bool:
|
|
281
|
+
"""Whether the client should see this as a failed tool call.
|
|
282
|
+
|
|
283
|
+
A non-zero exit is always a failure. The event stream is consulted on top of that
|
|
284
|
+
because it is codex's own verdict on the turn rather than the shell's: a `turn.failed`
|
|
285
|
+
event can arrive with exit code 0, and an `error` event without any agent message
|
|
286
|
+
means the model never answered.
|
|
287
|
+
"""
|
|
288
|
+
if not result["ok"]:
|
|
289
|
+
return True
|
|
290
|
+
parsed = _parse_events(result.get("stdout", ""))
|
|
291
|
+
if parsed["turn_status"] == "failed":
|
|
292
|
+
return True
|
|
293
|
+
# The same emptiness test _format_result applies, so a run whose text opens with
|
|
294
|
+
# [ERROR] is never handed back with is_error unset.
|
|
295
|
+
return bool(parsed["errors"]) and not _response_text(parsed).strip()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _response_text(parsed: dict[str, Any]) -> str:
|
|
299
|
+
# codex may emit several agent messages in one turn; the last one is the answer.
|
|
300
|
+
return parsed["messages"][-1] if parsed["messages"] else ""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _format_result(result: dict[str, Any]) -> str:
|
|
304
|
+
parsed = _parse_events(result.get("stdout", ""))
|
|
305
|
+
if result["ok"] and parsed["turn_status"] != "failed":
|
|
306
|
+
response = _response_text(parsed)
|
|
307
|
+
if response.strip():
|
|
308
|
+
parts = [f"{response.rstrip()}\n\n{_codex_trailer(parsed)}"]
|
|
309
|
+
if parsed["errors"]:
|
|
310
|
+
# An error the turn recovered from: keep it visible without costing the answer.
|
|
311
|
+
parts.append("errors:\n" + "\n".join(parsed["errors"]))
|
|
312
|
+
return "\n\n".join(parts)
|
|
313
|
+
marker = "[ERROR]" if parsed["errors"] else "[WARNING]"
|
|
314
|
+
parts = [f"{marker} codex exited successfully but produced no agent message."]
|
|
315
|
+
if parsed["errors"]:
|
|
316
|
+
parts.append("errors:\n" + "\n".join(parsed["errors"]))
|
|
317
|
+
if parsed["saw_events"]:
|
|
318
|
+
parts.append(_codex_trailer(parsed))
|
|
319
|
+
elif result.get("stdout", "").strip():
|
|
320
|
+
parts.append(f"stdout:\n{result['stdout']}")
|
|
321
|
+
if result.get("stderr"):
|
|
322
|
+
parts.append(f"stderr:\n{result['stderr']}")
|
|
323
|
+
parts.append(f"argv: {result.get('argv')}")
|
|
324
|
+
return "\n\n".join(parts)
|
|
325
|
+
parts = [f"[ERROR] {result.get('error', 'codex failed')}"]
|
|
326
|
+
if "returncode" in result:
|
|
327
|
+
parts.append(f"returncode={result['returncode']}")
|
|
328
|
+
if parsed["errors"]:
|
|
329
|
+
parts.append("errors:\n" + "\n".join(parsed["errors"]))
|
|
330
|
+
if parsed["saw_events"]:
|
|
331
|
+
parts.append(_codex_trailer(parsed))
|
|
332
|
+
if parsed["messages"]:
|
|
333
|
+
parts.append(f"last agent message:\n{_response_text(parsed)}")
|
|
334
|
+
elif result.get("stdout"):
|
|
335
|
+
parts.append(f"stdout:\n{result['stdout']}")
|
|
336
|
+
if result.get("stderr"):
|
|
337
|
+
parts.append(f"stderr:\n{result['stderr']}")
|
|
338
|
+
parts.append(f"argv: {result.get('argv')}")
|
|
339
|
+
return "\n\n".join(parts)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
_SANDBOX_MODES = ("read-only", "workspace-write", "danger-full-access")
|
|
343
|
+
|
|
344
|
+
_COMMON_PROPS: dict[str, Any] = {
|
|
345
|
+
"prompt": {
|
|
346
|
+
"type": "string",
|
|
347
|
+
"minLength": 1,
|
|
348
|
+
"description": (
|
|
349
|
+
"Prompt sent verbatim to codex on stdin. codex runs its full agentic loop "
|
|
350
|
+
"and this server returns the final agent message."
|
|
351
|
+
),
|
|
352
|
+
},
|
|
353
|
+
"model": {
|
|
354
|
+
"type": "string",
|
|
355
|
+
"description": "Pass -m <MODEL>: the model slug for this call, overriding config.toml.",
|
|
356
|
+
},
|
|
357
|
+
"config": {
|
|
358
|
+
"type": "array",
|
|
359
|
+
"items": {"type": "string"},
|
|
360
|
+
"description": (
|
|
361
|
+
"Configuration overrides as key=value strings, one per element. Each entry maps "
|
|
362
|
+
'to a separate codex -c flag (for example "model_reasoning_effort=high"). '
|
|
363
|
+
"Values are parsed as TOML by codex, so quote strings that are not bare words."
|
|
364
|
+
),
|
|
365
|
+
},
|
|
366
|
+
"ephemeral": {
|
|
367
|
+
"type": "boolean",
|
|
368
|
+
"description": "Pass --ephemeral: do not persist the session's rollout file to disk.",
|
|
369
|
+
},
|
|
370
|
+
"skip_git_repo_check": {
|
|
371
|
+
"type": "boolean",
|
|
372
|
+
"description": (
|
|
373
|
+
"Pass --skip-git-repo-check: allow running in a directory that is not inside "
|
|
374
|
+
"a git repository. Without it codex refuses such a working directory."
|
|
375
|
+
),
|
|
376
|
+
},
|
|
377
|
+
"extra_args": {
|
|
378
|
+
"type": "array",
|
|
379
|
+
"items": {"type": "string"},
|
|
380
|
+
"description": (
|
|
381
|
+
"Raw CLI flags appended verbatim (one token per element). "
|
|
382
|
+
"Use to reach new or uncommon codex exec flags without updating this server. "
|
|
383
|
+
"Do not pass --json or a prompt: the server adds both."
|
|
384
|
+
),
|
|
385
|
+
},
|
|
386
|
+
"env": {
|
|
387
|
+
"type": "object",
|
|
388
|
+
"additionalProperties": {"type": "string"},
|
|
389
|
+
"description": "Extra environment variables for the codex subprocess.",
|
|
390
|
+
},
|
|
391
|
+
"timeout_seconds": {
|
|
392
|
+
"type": "integer",
|
|
393
|
+
"minimum": 30,
|
|
394
|
+
"maximum": 3600,
|
|
395
|
+
"description": (
|
|
396
|
+
"Hard wall-clock timeout for the codex subprocess in seconds "
|
|
397
|
+
f"(default {DEFAULT_TIMEOUT})."
|
|
398
|
+
),
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_CODEX_PROPS: dict[str, Any] = {
|
|
403
|
+
"prompt": _COMMON_PROPS["prompt"],
|
|
404
|
+
"cd": {
|
|
405
|
+
"type": "string",
|
|
406
|
+
"description": (
|
|
407
|
+
"Pass -C <DIR>: the working directory codex runs in. Defaults to this server's "
|
|
408
|
+
"own working directory."
|
|
409
|
+
),
|
|
410
|
+
},
|
|
411
|
+
"model": _COMMON_PROPS["model"],
|
|
412
|
+
"config": _COMMON_PROPS["config"],
|
|
413
|
+
"sandbox": {
|
|
414
|
+
"type": "string",
|
|
415
|
+
"enum": list(_SANDBOX_MODES),
|
|
416
|
+
"description": (
|
|
417
|
+
"Pass --sandbox <MODE>: read-only, workspace-write or danger-full-access. "
|
|
418
|
+
"Overrides sandbox_mode from config.toml; network access still follows the "
|
|
419
|
+
"[sandbox_workspace_write] section there."
|
|
420
|
+
),
|
|
421
|
+
},
|
|
422
|
+
"add_dir": {
|
|
423
|
+
"type": "array",
|
|
424
|
+
"items": {"type": "string"},
|
|
425
|
+
"description": (
|
|
426
|
+
"Extra writable directories. Each entry maps to a separate codex --add-dir flag "
|
|
427
|
+
"(repeatable, not comma-joined)."
|
|
428
|
+
),
|
|
429
|
+
},
|
|
430
|
+
"profile": {
|
|
431
|
+
"type": "string",
|
|
432
|
+
"description": "Pass -p <PROFILE>: the config.toml profile to load.",
|
|
433
|
+
},
|
|
434
|
+
"ephemeral": _COMMON_PROPS["ephemeral"],
|
|
435
|
+
"skip_git_repo_check": _COMMON_PROPS["skip_git_repo_check"],
|
|
436
|
+
"extra_args": _COMMON_PROPS["extra_args"],
|
|
437
|
+
"env": _COMMON_PROPS["env"],
|
|
438
|
+
"timeout_seconds": _COMMON_PROPS["timeout_seconds"],
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_REPLY_PROPS: dict[str, Any] = {
|
|
442
|
+
"thread_id": {
|
|
443
|
+
"type": "string",
|
|
444
|
+
"minLength": 1,
|
|
445
|
+
"description": (
|
|
446
|
+
"The thread to continue: the thread_id from a previous result's [codex] line. "
|
|
447
|
+
"Maps to codex exec resume <THREAD_ID>."
|
|
448
|
+
),
|
|
449
|
+
},
|
|
450
|
+
"prompt": _COMMON_PROPS["prompt"],
|
|
451
|
+
"model": _COMMON_PROPS["model"],
|
|
452
|
+
"config": _COMMON_PROPS["config"],
|
|
453
|
+
"ephemeral": _COMMON_PROPS["ephemeral"],
|
|
454
|
+
"skip_git_repo_check": _COMMON_PROPS["skip_git_repo_check"],
|
|
455
|
+
"extra_args": _COMMON_PROPS["extra_args"],
|
|
456
|
+
"env": _COMMON_PROPS["env"],
|
|
457
|
+
"timeout_seconds": _COMMON_PROPS["timeout_seconds"],
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
_CODEX_TOOL = Tool(
|
|
461
|
+
name="codex",
|
|
462
|
+
description=(
|
|
463
|
+
"Run a single non-interactive Codex session (`codex exec`) with the given prompt and "
|
|
464
|
+
"return its final message. codex is an agentic coding assistant that reads and, "
|
|
465
|
+
"depending on the sandbox, edits files inside the working directory. Forward-compatible: "
|
|
466
|
+
"unknown CLI flags can be passed via `extra_args`. The codex binary path is configurable "
|
|
467
|
+
"via $CODEX_CMD."
|
|
468
|
+
),
|
|
469
|
+
inputSchema={
|
|
470
|
+
"type": "object",
|
|
471
|
+
"properties": _CODEX_PROPS,
|
|
472
|
+
"required": ["prompt"],
|
|
473
|
+
},
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
_REPLY_TOOL = Tool(
|
|
477
|
+
name="codex-reply",
|
|
478
|
+
description=(
|
|
479
|
+
"Continue a previous Codex session (`codex exec resume <THREAD_ID>`) with a follow-up "
|
|
480
|
+
"prompt and return the new final message. Only the flags `codex exec resume` accepts "
|
|
481
|
+
"are exposed; the working directory and sandbox come from the current configuration "
|
|
482
|
+
"(this server's own flags and config.toml), not from the original session."
|
|
483
|
+
),
|
|
484
|
+
inputSchema={
|
|
485
|
+
"type": "object",
|
|
486
|
+
"properties": _REPLY_PROPS,
|
|
487
|
+
"required": ["thread_id", "prompt"],
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
_BOOL_FIELDS = ("ephemeral", "skip_git_repo_check")
|
|
492
|
+
_STR_FIELDS = ("prompt", "thread_id", "cd", "model", "sandbox", "profile")
|
|
493
|
+
_STR_ARRAY_FIELDS = ("config", "add_dir", "extra_args")
|
|
494
|
+
|
|
495
|
+
# Parameter names the deprecated `codex mcp-server` used, mapped to what this server calls
|
|
496
|
+
# them. A client migrated from that server is the most likely source of an unknown key, and
|
|
497
|
+
# a working directory or thread id it passed under the old name must not be dropped on the
|
|
498
|
+
# floor: the call would run in the wrong directory or start a new thread without a word.
|
|
499
|
+
_RENAMED_FROM_MCP_SERVER = {
|
|
500
|
+
"cwd": "cd",
|
|
501
|
+
"threadId": "thread_id",
|
|
502
|
+
"conversationId": "thread_id",
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _validate_args(
|
|
507
|
+
args: dict[str, Any], *, required: tuple[str, ...], allowed: dict[str, Any]
|
|
508
|
+
) -> str | None:
|
|
509
|
+
"""Return the first schema violation, or None when the arguments are usable.
|
|
510
|
+
|
|
511
|
+
The low-level Server advertises `inputSchema` and never applies it, so this walks the
|
|
512
|
+
properties by hand. Checking only that the required keys are present is not enough:
|
|
513
|
+
every value here reaches argv.
|
|
514
|
+
"""
|
|
515
|
+
for key in args:
|
|
516
|
+
if key in allowed:
|
|
517
|
+
continue
|
|
518
|
+
renamed = _RENAMED_FROM_MCP_SERVER.get(key)
|
|
519
|
+
if renamed in allowed:
|
|
520
|
+
return f"unknown parameter `{key}`; this server calls it `{renamed}`"
|
|
521
|
+
return f"unknown parameter `{key}`"
|
|
522
|
+
for key in required:
|
|
523
|
+
# `is None`, not `not in`: an explicit null passes a presence check but then reaches
|
|
524
|
+
# argv as the string "None".
|
|
525
|
+
if args.get(key) is None:
|
|
526
|
+
return f"`{key}` is required"
|
|
527
|
+
for key in _STR_FIELDS:
|
|
528
|
+
value = args.get(key)
|
|
529
|
+
if value is not None and not isinstance(value, str):
|
|
530
|
+
return f"`{key}` must be a string, got {type(value).__name__}"
|
|
531
|
+
for key in required:
|
|
532
|
+
if not args[key]:
|
|
533
|
+
return f"`{key}` must not be empty"
|
|
534
|
+
for key in _BOOL_FIELDS:
|
|
535
|
+
value = args.get(key)
|
|
536
|
+
# An exact type check rather than truthiness: `bool("false")` is True.
|
|
537
|
+
if value is not None and not isinstance(value, bool):
|
|
538
|
+
return f"`{key}` must be a boolean, got {type(value).__name__}"
|
|
539
|
+
for key in _STR_ARRAY_FIELDS:
|
|
540
|
+
value = args.get(key)
|
|
541
|
+
if value is None:
|
|
542
|
+
continue
|
|
543
|
+
# A bare string satisfies `for d in add_dir` and `list(extra_args)` but iterates
|
|
544
|
+
# character by character, so "docs" would expand into four --add-dir flags.
|
|
545
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
546
|
+
return f"`{key}` must be an array of strings"
|
|
547
|
+
for item in args.get("config") or []:
|
|
548
|
+
# codex parses `-c key=value`; a bare key would be rejected there with a message
|
|
549
|
+
# that no longer names the parameter the client got wrong.
|
|
550
|
+
if "=" not in item:
|
|
551
|
+
return f"`config` entries must be key=value strings, got {item!r}"
|
|
552
|
+
sandbox = args.get("sandbox")
|
|
553
|
+
if sandbox is not None and sandbox not in _SANDBOX_MODES:
|
|
554
|
+
return f"`sandbox` must be one of {', '.join(_SANDBOX_MODES)}, got {sandbox!r}"
|
|
555
|
+
env = args.get("env")
|
|
556
|
+
if env is not None and (
|
|
557
|
+
not isinstance(env, dict)
|
|
558
|
+
or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items())
|
|
559
|
+
):
|
|
560
|
+
return "`env` must be an object mapping strings to strings"
|
|
561
|
+
timeout = args.get("timeout_seconds")
|
|
562
|
+
if timeout is not None:
|
|
563
|
+
# bool subclasses int, so it has to be excluded before isinstance would accept it
|
|
564
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int):
|
|
565
|
+
return f"`timeout_seconds` must be an integer, got {type(timeout).__name__}"
|
|
566
|
+
if not 30 <= timeout <= 3600:
|
|
567
|
+
return f"`timeout_seconds` must be between 30 and 3600, got {timeout}"
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _error_result(text: str) -> CallToolResult:
|
|
572
|
+
return CallToolResult(content=[TextContent(type="text", text=f"[ERROR] {text}")], is_error=True)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
async def _invoke(arguments: dict[str, Any] | None, *, reply: bool) -> CallToolResult:
|
|
576
|
+
"""The body of both tools, taking a plain dict so callers need no SDK request object."""
|
|
577
|
+
args = dict(arguments or {})
|
|
578
|
+
|
|
579
|
+
# `codex exec resume` does not take `cd`, `sandbox`, `add_dir` or `profile`; they are
|
|
580
|
+
# absent from _REPLY_PROPS, so the unknown-key check refuses them by name, where codex's
|
|
581
|
+
# own error would only name the flag.
|
|
582
|
+
invalid = _validate_args(
|
|
583
|
+
args,
|
|
584
|
+
required=("thread_id", "prompt") if reply else ("prompt",),
|
|
585
|
+
allowed=_REPLY_PROPS if reply else _CODEX_PROPS,
|
|
586
|
+
)
|
|
587
|
+
if invalid is not None:
|
|
588
|
+
return _error_result(f"input validation: {invalid}")
|
|
589
|
+
|
|
590
|
+
argv = _build_argv(
|
|
591
|
+
thread_id=args.get("thread_id") if reply else None,
|
|
592
|
+
cd=args.get("cd"),
|
|
593
|
+
model=args.get("model"),
|
|
594
|
+
config=args.get("config"),
|
|
595
|
+
sandbox=args.get("sandbox"),
|
|
596
|
+
add_dir=args.get("add_dir"),
|
|
597
|
+
profile=args.get("profile"),
|
|
598
|
+
ephemeral=bool(args.get("ephemeral", False)),
|
|
599
|
+
skip_git_repo_check=bool(args.get("skip_git_repo_check", False)),
|
|
600
|
+
extra_args=args.get("extra_args"),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
result = await _run_codex(
|
|
604
|
+
argv=argv,
|
|
605
|
+
prompt=args["prompt"],
|
|
606
|
+
timeout=int(args.get("timeout_seconds") or DEFAULT_TIMEOUT),
|
|
607
|
+
env_overrides=args.get("env"),
|
|
608
|
+
)
|
|
609
|
+
parsed = _parse_events(result.get("stdout", ""))
|
|
610
|
+
text = _format_result(result)
|
|
611
|
+
structured: dict[str, Any] | None = None
|
|
612
|
+
if parsed["thread_id"]:
|
|
613
|
+
# The same shape the deprecated `codex mcp-server` returned, so a client that reads
|
|
614
|
+
# structuredContent.threadId keeps working.
|
|
615
|
+
structured = {"threadId": parsed["thread_id"], "content": _response_text(parsed)}
|
|
616
|
+
return CallToolResult(
|
|
617
|
+
content=[TextContent(type="text", text=text)],
|
|
618
|
+
structured_content=structured,
|
|
619
|
+
is_error=_run_failed(result),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
async def codex(arguments: dict[str, Any] | None) -> CallToolResult:
|
|
624
|
+
return await _invoke(arguments, reply=False)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
async def codex_reply(arguments: dict[str, Any] | None) -> CallToolResult:
|
|
628
|
+
return await _invoke(arguments, reply=True)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def on_list_tools(
|
|
632
|
+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
|
|
633
|
+
) -> ListToolsResult:
|
|
634
|
+
return ListToolsResult(tools=[_CODEX_TOOL, _REPLY_TOOL])
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
|
638
|
+
# An escaping exception would become a JSON-RPC error, which clients treat as a transport
|
|
639
|
+
# failure rather than as something to show the model. CancelledError derives from
|
|
640
|
+
# BaseException, so peer cancellation still propagates and _run_codex's killpg still runs.
|
|
641
|
+
try:
|
|
642
|
+
if params.name == "codex":
|
|
643
|
+
return await codex(params.arguments)
|
|
644
|
+
if params.name == "codex-reply":
|
|
645
|
+
return await codex_reply(params.arguments)
|
|
646
|
+
return _error_result(f"unknown tool: {params.name}")
|
|
647
|
+
except Exception as exc:
|
|
648
|
+
logger.exception("%s failed", params.name)
|
|
649
|
+
return _error_result(f"{type(exc).__name__}: {exc}")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
server: Server = Server(
|
|
653
|
+
_PKG_NAME,
|
|
654
|
+
version=_pkg_version(_PKG_NAME),
|
|
655
|
+
on_list_tools=on_list_tools,
|
|
656
|
+
on_call_tool=on_call_tool,
|
|
657
|
+
)
|
|
658
|
+
# v2 attaches an OpenTelemetryMiddleware by default. It is a no-op without an exporter, but
|
|
659
|
+
# it puts a tracing layer on the path of every request and reads OTEL_* environment
|
|
660
|
+
# variables in-process. This file is meant to be the whole story, so the layer is dropped
|
|
661
|
+
# rather than left implicit; delete this line to get the SDK default back.
|
|
662
|
+
server.middleware.clear()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
async def _amain() -> None:
|
|
666
|
+
logging.basicConfig(
|
|
667
|
+
level=os.environ.get("CODEX_CLI_MCP_SLIM_LOG_LEVEL", "INFO"),
|
|
668
|
+
format="[codex-cli-mcp-slim] %(levelname)s %(message)s",
|
|
669
|
+
)
|
|
670
|
+
if SERVER_ARGS:
|
|
671
|
+
logger.info("server-level codex exec flags: %s", SERVER_ARGS)
|
|
672
|
+
async with stdio_server() as (read, write):
|
|
673
|
+
await server.run(read, write, server.create_initialization_options())
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
_USAGE = """\
|
|
677
|
+
usage: codex-cli-mcp-slim [CODEX_EXEC_FLAGS...]
|
|
678
|
+
|
|
679
|
+
Start the MCP server on stdio. Every argument is placed right after `codex exec`
|
|
680
|
+
on every tool call (for example: -c model_reasoning_effort=high -C /srv/scratch).
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def main() -> None:
|
|
685
|
+
# Everything on the server's own command line is handed to codex exec, verbatim, on
|
|
686
|
+
# every call. The two exceptions exist only so that a person who runs the command by
|
|
687
|
+
# hand to see what it is does not start a stdio server waiting on a terminal.
|
|
688
|
+
if sys.argv[1:] in (["--help"], ["-h"]):
|
|
689
|
+
print(_USAGE, end="")
|
|
690
|
+
return
|
|
691
|
+
if sys.argv[1:] == ["--version"]:
|
|
692
|
+
print(f"{_PKG_NAME} {_pkg_version(_PKG_NAME)}")
|
|
693
|
+
return
|
|
694
|
+
SERVER_ARGS[:] = sys.argv[1:]
|
|
695
|
+
asyncio.run(_amain())
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
if __name__ == "__main__":
|
|
699
|
+
main()
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-cli-mcp-slim
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author-email: tksfjt1024 <tksfjt1024@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tksfjt1024/codex-cli-mcp-slim
|
|
8
|
+
Project-URL: Repository, https://github.com/tksfjt1024/codex-cli-mcp-slim
|
|
9
|
+
Project-URL: Issues, https://github.com/tksfjt1024/codex-cli-mcp-slim/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,codex,codex-cli,openai,claude,anthropic,ai,llm,coding-agent
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# codex-cli-mcp-slim
|
|
37
|
+
|
|
38
|
+
A thin, auditable [MCP](https://modelcontextprotocol.io) server wrapping the [Codex CLI](https://github.com/openai/codex) (`codex exec`).
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/codex-cli-mcp-slim/)
|
|
41
|
+
[](https://pypi.org/project/codex-cli-mcp-slim/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
[](https://github.com/tksfjt1024/codex-cli-mcp-slim/actions/workflows/ci.yml)
|
|
44
|
+
|
|
45
|
+
## Why
|
|
46
|
+
|
|
47
|
+
`codex mcp-server`, the command that let other MCP clients call Codex, is
|
|
48
|
+
deprecated, and its removal has been merged upstream
|
|
49
|
+
([openai/codex#42993](https://github.com/openai/codex/pull/42993)): releases up
|
|
50
|
+
to 0.153.x still ship it, later ones will not. Its replacement, the Codex app
|
|
51
|
+
server, speaks its own JSON-RPC protocol rather than MCP. This server keeps the old
|
|
52
|
+
integration point alive: it exposes the same two tools, `codex` and
|
|
53
|
+
`codex-reply`, and runs `codex exec` underneath. `codex exec` is the Codex CLI's
|
|
54
|
+
non-interactive mode: one prompt in, the agent works on its own, one final
|
|
55
|
+
message out.
|
|
56
|
+
|
|
57
|
+
When you add an MCP server to your AI coding tool, every prompt and code snippet
|
|
58
|
+
you send flows through that wrapper. Most CLI-wrapping MCP servers are small,
|
|
59
|
+
individually maintained packages, and recent supply-chain incidents
|
|
60
|
+
(`xz-utils`, `postmark-mcp`, the npm `chalk`/`debug` compromise) show that
|
|
61
|
+
"small and useful" is not the same as "safe to trust blindly."
|
|
62
|
+
|
|
63
|
+
This project takes the opposite stance: instead of asking you to trust it, it
|
|
64
|
+
tries to be **easy to audit**.
|
|
65
|
+
|
|
66
|
+
- **Single file** — the whole server is `src/codex_cli_mcp_slim/server.py`,
|
|
67
|
+
readable end-to-end in one sitting
|
|
68
|
+
- **One third-party dependency** (`mcp`) — minimal supply-chain surface
|
|
69
|
+
- **Faithful CLI mapping** — every typed parameter mirrors a real `codex exec`
|
|
70
|
+
flag by name, so it is obvious which flags an invocation actually sets
|
|
71
|
+
- **Prompt over stdin** — the prompt never appears in the process list and is
|
|
72
|
+
not bounded by the argv size limit
|
|
73
|
+
- **Forward-compatible** — any new or uncommon `codex exec` flag is reachable via
|
|
74
|
+
`extra_args` without touching this server
|
|
75
|
+
- **Configurable binary path** — `$CODEX_CMD` lets you swap or wrap the `codex`
|
|
76
|
+
binary
|
|
77
|
+
- **Transparent** — every invocation logs the exact argv to stderr
|
|
78
|
+
|
|
79
|
+
Read `server.py` before you install. That is the point.
|
|
80
|
+
|
|
81
|
+
## Prerequisites
|
|
82
|
+
|
|
83
|
+
- The `codex` CLI installed and on `$PATH` (or pointed to via `$CODEX_CMD`). See
|
|
84
|
+
the [official Codex CLI repository](https://github.com/openai/codex). This server
|
|
85
|
+
always passes `--json` and reads the prompt from stdin (`codex exec -`), both of
|
|
86
|
+
which `codex exec` documents.
|
|
87
|
+
- `codex` already **authenticated** — this wrapper does not manage login; it
|
|
88
|
+
surfaces `codex`'s own error output if the CLI is not ready.
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Run directly without installing
|
|
94
|
+
uvx codex-cli-mcp-slim
|
|
95
|
+
|
|
96
|
+
# Install from PyPI
|
|
97
|
+
pip install codex-cli-mcp-slim
|
|
98
|
+
|
|
99
|
+
# Run from GitHub HEAD
|
|
100
|
+
uvx --from git+https://github.com/tksfjt1024/codex-cli-mcp-slim codex-cli-mcp-slim
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Usage as an MCP server
|
|
104
|
+
|
|
105
|
+
### Claude Code
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
claude mcp add codex uvx codex-cli-mcp-slim
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or manually in `~/.claude.json`:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"mcpServers": {
|
|
116
|
+
"codex": {
|
|
117
|
+
"type": "stdio",
|
|
118
|
+
"command": "uvx",
|
|
119
|
+
"args": ["codex-cli-mcp-slim"]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If `codex` is not on the launching process's `$PATH`, point `$CODEX_CMD` at it:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"mcpServers": {
|
|
130
|
+
"codex": {
|
|
131
|
+
"type": "stdio",
|
|
132
|
+
"command": "uvx",
|
|
133
|
+
"args": ["codex-cli-mcp-slim"],
|
|
134
|
+
"env": { "CODEX_CMD": "/absolute/path/to/codex" }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Replacing `codex mcp-server`
|
|
141
|
+
|
|
142
|
+
An entry that used to launch `codex mcp-server` keeps its server name and its
|
|
143
|
+
tool names; only `command` and `args` change. Before:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"mcpServers": {
|
|
148
|
+
"codex": {
|
|
149
|
+
"type": "stdio",
|
|
150
|
+
"command": "codex",
|
|
151
|
+
"args": ["mcp-server"]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
After:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"mcpServers": {
|
|
162
|
+
"codex": {
|
|
163
|
+
"type": "stdio",
|
|
164
|
+
"command": "uvx",
|
|
165
|
+
"args": ["codex-cli-mcp-slim"]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Parameter names differ from the old server where `codex exec` names the flag
|
|
172
|
+
differently: `cwd` is now `cd` (the `-C/--cd` flag), and `codex-reply` takes
|
|
173
|
+
`thread_id` instead of `threadId`. The result's `structuredContent` field keeps
|
|
174
|
+
the shape the old server returned, `{"threadId": ..., "content": ...}`.
|
|
175
|
+
|
|
176
|
+
### Other MCP clients
|
|
177
|
+
|
|
178
|
+
Any MCP-compatible client can launch the server via stdio:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
uvx codex-cli-mcp-slim
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Server-level flags
|
|
185
|
+
|
|
186
|
+
Everything on the server's own command line is placed right after `codex exec`
|
|
187
|
+
on every invocation. One MCP-client entry can therefore pin a reasoning effort, a
|
|
188
|
+
model or a working directory for all of its calls. Two entries that differ only
|
|
189
|
+
in reasoning effort look like this:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"mcpServers": {
|
|
194
|
+
"codex-medium": {
|
|
195
|
+
"type": "stdio",
|
|
196
|
+
"command": "uvx",
|
|
197
|
+
"args": ["codex-cli-mcp-slim", "-c", "model_reasoning_effort=medium"]
|
|
198
|
+
},
|
|
199
|
+
"codex-high": {
|
|
200
|
+
"type": "stdio",
|
|
201
|
+
"command": "uvx",
|
|
202
|
+
"args": ["codex-cli-mcp-slim", "-c", "model_reasoning_effort=high"]
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
A call to `codex-high` runs
|
|
209
|
+
`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.
|
|
215
|
+
|
|
216
|
+
## Tool: `codex`
|
|
217
|
+
|
|
218
|
+
Runs a single non-interactive Codex session (`codex exec`). `codex` is an
|
|
219
|
+
agentic assistant: it reads and, depending on the sandbox, edits files in the
|
|
220
|
+
working directory to fulfil the request, then prints its final message.
|
|
221
|
+
|
|
222
|
+
The tool returns that final message followed by one metadata line:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
[codex] thread_id=019a2b3c-1d4e-7f60-8a9b-0c1d2e3f4a5b status=completed input_tokens=13894 cached_input_tokens=11904 output_tokens=612
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`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
|
|
230
|
+
client a call failed. This server sets it when `codex` exited non-zero, when the
|
|
231
|
+
subprocess timed out, and when the turn itself failed. The last case matters
|
|
232
|
+
because `codex exec` exits 0 after a failure inside the model API; the tool
|
|
233
|
+
result then carries the error text instead of coming back as a successful call:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
[ERROR] codex failed
|
|
237
|
+
|
|
238
|
+
returncode=0
|
|
239
|
+
|
|
240
|
+
errors:
|
|
241
|
+
Unsupported value: 'none' is not supported with the ... model.
|
|
242
|
+
|
|
243
|
+
[codex] thread_id=019a2b3c-... status=failed
|
|
244
|
+
|
|
245
|
+
argv: ['codex', 'exec', '--json', '-']
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Pass the `thread_id` to `codex-reply` to continue the same session.
|
|
249
|
+
|
|
250
|
+
| Parameter | Type | Description |
|
|
251
|
+
| --------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
|
252
|
+
| `prompt` (required) | string | Prompt sent verbatim to `codex` on stdin |
|
|
253
|
+
| `cd` | string | Pass `-C <DIR>`: the working directory; defaults to the server's own |
|
|
254
|
+
| `model` | string | Pass `-m <MODEL>` |
|
|
255
|
+
| `config` | string[] | `key=value` overrides; each maps to one `-c` (repeatable, last wins) |
|
|
256
|
+
| `sandbox` | string | Pass `--sandbox`: `read-only`, `workspace-write` or `danger-full-access`. See note below |
|
|
257
|
+
| `add_dir` | string[] | Extra writable directories; each maps to one `--add-dir` (repeatable, not comma-joined) |
|
|
258
|
+
| `profile` | string | Pass `-p <PROFILE>` |
|
|
259
|
+
| `ephemeral` | bool | Pass `--ephemeral` (do not write the session transcript codex keeps under `$CODEX_HOME/sessions`) |
|
|
260
|
+
| `skip_git_repo_check` | bool | Pass `--skip-git-repo-check` (allow a working directory outside a git repository) |
|
|
261
|
+
| `extra_args` | string[] | Raw CLI flags appended verbatim. Do not pass `--json` or a prompt; the server adds both |
|
|
262
|
+
| `env` | object | Extra environment variables for the `codex` subprocess |
|
|
263
|
+
| `timeout_seconds` | int | Hard wall-clock timeout for the subprocess, 30 to 3600 (default 1800) |
|
|
264
|
+
|
|
265
|
+
Unknown parameters are refused rather than ignored, so a call that still uses
|
|
266
|
+
the old server's `cwd` gets an error naming `cd` instead of running in the
|
|
267
|
+
wrong directory.
|
|
268
|
+
|
|
269
|
+
### Security note: `sandbox`
|
|
270
|
+
|
|
271
|
+
`codex exec` reads its sandbox mode from its own configuration file
|
|
272
|
+
(`~/.codex/config.toml` by default) unless `--sandbox` is given.
|
|
273
|
+
`danger-full-access` removes the filesystem and network sandbox entirely;
|
|
274
|
+
`workspace-write` makes the working directory (and any `add_dir`) writable.
|
|
275
|
+
`--sandbox` overrides only the mode; whether `workspace-write` gets network
|
|
276
|
+
access still follows the `[sandbox_workspace_write]` section of `config.toml`.
|
|
277
|
+
The parameter mirrors the flag so that whichever mode a call runs
|
|
278
|
+
under is visible in the arguments and in the logged argv. This server does not
|
|
279
|
+
pass `--dangerously-bypass-approvals-and-sandbox`; reach it via `extra_args` if
|
|
280
|
+
you really mean it.
|
|
281
|
+
|
|
282
|
+
## Tool: `codex-reply`
|
|
283
|
+
|
|
284
|
+
Continues a previous session (`codex exec resume <THREAD_ID>`) with a follow-up
|
|
285
|
+
prompt and returns the new final message. Only the flags `codex exec resume`
|
|
286
|
+
accepts are exposed, so `cd`, `sandbox`, `add_dir` and `profile` are refused
|
|
287
|
+
here. The working directory and sandbox of a reply come from the current
|
|
288
|
+
configuration, that is, the server-level flags and `config.toml`, not from the
|
|
289
|
+
original session.
|
|
290
|
+
|
|
291
|
+
| Parameter | Type | Description |
|
|
292
|
+
| ----------------------- | -------- | ----------------------------------------------------------------- |
|
|
293
|
+
| `thread_id` (required) | string | The `thread_id` from a previous result's `[codex]` line |
|
|
294
|
+
| `prompt` (required) | string | Follow-up prompt, sent on stdin |
|
|
295
|
+
| `model` | string | Pass `-m <MODEL>` |
|
|
296
|
+
| `config` | string[] | `key=value` overrides; each maps to one `-c` |
|
|
297
|
+
| `ephemeral` | bool | Pass `--ephemeral` |
|
|
298
|
+
| `skip_git_repo_check` | bool | Pass `--skip-git-repo-check` |
|
|
299
|
+
| `extra_args` | string[] | Raw CLI flags appended verbatim |
|
|
300
|
+
| `env` | object | Extra environment variables for the `codex` subprocess |
|
|
301
|
+
| `timeout_seconds` | int | Hard wall-clock timeout for the subprocess, 30 to 3600 (default 1800) |
|
|
302
|
+
|
|
303
|
+
## Timeout configuration
|
|
304
|
+
|
|
305
|
+
`timeout_seconds` is this wrapper's hard wall-clock limit (default 1800, or
|
|
306
|
+
`$CODEX_CLI_MCP_SLIM_TIMEOUT`). On timeout, the wrapper kills the subprocess's
|
|
307
|
+
whole process group and then waits up to 20 additional seconds to collect any
|
|
308
|
+
buffered output and reap the process, so the effective ceiling is
|
|
309
|
+
`timeout_seconds + 20`. A timed-out call is flagged `isError` and carries
|
|
310
|
+
whatever `codex` had printed so far.
|
|
311
|
+
|
|
312
|
+
## Forward-compatibility example
|
|
313
|
+
|
|
314
|
+
If a future `codex exec` release adds a new flag (say `--super-mode`), use it
|
|
315
|
+
immediately without updating this server:
|
|
316
|
+
|
|
317
|
+
```jsonc
|
|
318
|
+
{
|
|
319
|
+
"name": "codex",
|
|
320
|
+
"arguments": {
|
|
321
|
+
"prompt": "...",
|
|
322
|
+
"extra_args": ["--super-mode"]
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Configuration
|
|
328
|
+
|
|
329
|
+
| Environment variable | Default | Purpose |
|
|
330
|
+
| ------------------------------ | ------- | ---------------------------------------- |
|
|
331
|
+
| `CODEX_CMD` | `codex` | Path to the `codex` CLI binary |
|
|
332
|
+
| `CODEX_CLI_MCP_SLIM_TIMEOUT` | `1800` | Default subprocess timeout in seconds |
|
|
333
|
+
| `CODEX_CLI_MCP_SLIM_LOG_LEVEL` | `INFO` | Logging level for stderr diagnostics |
|
|
334
|
+
|
|
335
|
+
`codex` itself reads its configuration file and credentials from `$CODEX_HOME`
|
|
336
|
+
(`~/.codex` by default), so an MCP-client entry can point a server at a
|
|
337
|
+
dedicated configuration directory through its `env` block.
|
|
338
|
+
|
|
339
|
+
## Development
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
# Install dev dependencies
|
|
343
|
+
pip install -e ".[test,dev]"
|
|
344
|
+
|
|
345
|
+
# Lint
|
|
346
|
+
ruff check .
|
|
347
|
+
|
|
348
|
+
# Test
|
|
349
|
+
pytest
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## License
|
|
353
|
+
|
|
354
|
+
[MIT](./LICENSE) © tksfjt1024
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
codex_cli_mcp_slim/__init__.py,sha256=hbKd2iJtOvMLYuoawTIrykBsvv5VQS7IgKRgT4mQSUw,257
|
|
2
|
+
codex_cli_mcp_slim/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
codex_cli_mcp_slim/server.py,sha256=T7oApKG8LCAshQfZHC8ESgceI2fn-rETjJwNwTmTw2M,28240
|
|
4
|
+
codex_cli_mcp_slim-0.1.0.dist-info/licenses/LICENSE,sha256=Tr5DgZN5ASK_iNxUlQLjINkMg2zUJZuSh3JEStppNV4,1067
|
|
5
|
+
codex_cli_mcp_slim-0.1.0.dist-info/METADATA,sha256=nrJ9SWmVOBfOgHH7TcG_8iqbAUm16iyEVLG5Sw2dj8M,14605
|
|
6
|
+
codex_cli_mcp_slim-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
codex_cli_mcp_slim-0.1.0.dist-info/entry_points.txt,sha256=ELoQ0plLqqlCAMRqQniZO31woExkAJYBmKvPcbFbMMo,70
|
|
8
|
+
codex_cli_mcp_slim-0.1.0.dist-info/top_level.txt,sha256=ek9G3GaWoULhzBhjNfP4C5avRGJ2dh67F4xEG2KQ9ts,19
|
|
9
|
+
codex_cli_mcp_slim-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tksfjt1024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
codex_cli_mcp_slim
|