@alvarosw/codex-mcp 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # Codex MCP
2
+
3
+ A lightweight [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes the local [Codex CLI](https://github.com/openai/codex) (`codex`) to MCP-compatible coding agents.
4
+
5
+ The project intentionally keeps the architecture small: MCP tools validate inputs, a thin CLI adapter executes `codex exec` (and friends), and `codex_raw` provides an escape hatch for CLI options that are added in future Codex releases.
6
+
7
+ ## Features
8
+
9
+ - Run Codex as a local coding agent through MCP (`codex exec`).
10
+ - Run non-interactive code reviews (`codex exec review`).
11
+ - Resume or fork previous sessions (`codex exec resume` / `codex exec fork`).
12
+ - Control common CLI options such as model, sandbox policy, approval behavior, extra directories, images, and output handling.
13
+ - Inspect version, login status, and diagnostics (`codex doctor`).
14
+ - Pass arbitrary CLI arguments through `codex_raw` for forward compatibility.
15
+ - No shell execution: arguments are passed directly to the `codex` process (`shell: false`).
16
+ - Optional server-wide model override via an environment variable (see below).
17
+
18
+ ## Requirements
19
+
20
+ - Node.js 18+
21
+ - Codex CLI installed and authenticated (`codex login`)
22
+ - `codex` available on `PATH`
23
+ - An MCP-compatible client
24
+
25
+ If `codex` is not on `PATH`, set `CODEX_MCP_CMD` to the executable path.
26
+
27
+ ```bash
28
+ CODEX_MCP_CMD=/custom/path/codex
29
+ ```
30
+
31
+ Windows PowerShell:
32
+
33
+ ```powershell
34
+ $env:CODEX_MCP_CMD = "C:\path\to\codex.exe"
35
+ ```
36
+
37
+ ## Forcing a specific model
38
+
39
+ Every tool that runs the agent (`codex_run`, `codex_review`, `codex_resume`, `codex_fork`, and `codex_raw` when its first argument is `exec`) accepts a `model` input. If you set the `CODEX_MCP_MODEL` environment variable on the MCP server process, it overrides the model for **every** such call, regardless of what the caller (or a raw argument list) requests. This is useful when you want to pin the server to a single model — for cost control, quota limits, or consistency — no matter what any individual tool call asks for.
40
+
41
+ ```bash
42
+ CODEX_MCP_MODEL=your-model-id
43
+ ```
44
+
45
+ The override is applied last, after stripping any `-m`/`--model` flag or `-c model=...` config override already present in the constructed arguments, so it always wins. It is never applied to subcommands that don't accept a model (`--version`, `login status`, `help`, `doctor`, ...), so those keep working normally even when the override is set.
46
+
47
+ ## Quick Start
48
+
49
+ The recommended setup is through npm. You do not need to clone this repository or install the MCP server manually.
50
+
51
+ ### Claude Code
52
+
53
+ ```bash
54
+ claude mcp add --scope user codex -- npx -y codex-mcp
55
+ ```
56
+
57
+ Verify the server:
58
+
59
+ ```bash
60
+ claude mcp list
61
+ ```
62
+
63
+ If `codex` is not on `PATH`, or you want to pin the model:
64
+
65
+ ```bash
66
+ claude mcp add --scope user \
67
+ --env CODEX_MCP_CMD=/custom/path/codex \
68
+ --env CODEX_MCP_MODEL=your-model-id \
69
+ codex -- npx -y codex-mcp
70
+ ```
71
+
72
+ ### Gemini CLI
73
+
74
+ ```bash
75
+ gemini mcp add --scope user codex npx -y codex-mcp
76
+ ```
77
+
78
+ Verify:
79
+
80
+ ```bash
81
+ gemini mcp list
82
+ ```
83
+
84
+ ### Cursor
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "codex": {
90
+ "command": "npx",
91
+ "args": ["-y", "codex-mcp"]
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ### Windsurf
98
+
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "codex": {
103
+ "command": "npx",
104
+ "args": ["-y", "codex-mcp"]
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ ### Cline / Roo Code / Other MCP Clients
111
+
112
+ ```json
113
+ {
114
+ "command": "npx",
115
+ "args": ["-y", "codex-mcp"]
116
+ }
117
+ ```
118
+
119
+ If the client supports environment variables, `CODEX_MCP_CMD` and `CODEX_MCP_MODEL` can be set there as well.
120
+
121
+ ## Local Development
122
+
123
+ ```bash
124
+ git clone https://github.com/alvarosw/codex-mcp.git
125
+ cd codex-mcp
126
+ npm install
127
+ npm start
128
+ ```
129
+
130
+ No build step is required.
131
+
132
+ ### Testing
133
+
134
+ `npm test` runs a static syntax check only — it makes no network calls and costs nothing.
135
+
136
+ `npm run test:live` drives the real MCP server end to end over stdio against a real, authenticated Codex CLI (in a throwaway temp git repo it creates and cleans up). It exercises every tool, including verifying that `CODEX_MCP_MODEL` (if set) wins over a deliberately wrong model passed in a tool call or smuggled into `codex_raw` arguments. This makes real model calls and is not run automatically — you need `codex login` completed first, and it will consume real quota/tokens against whichever model resolves for the call:
137
+
138
+ ```bash
139
+ CODEX_MCP_MODEL=your-model-id npm run test:live
140
+ ```
141
+
142
+ `CODEX_MCP_MODEL` is optional for this script; without it, the override-specific assertions are skipped and the rest of the suite still runs against your account's default configured model.
143
+
144
+ ## Tools
145
+
146
+ ### `codex_run`
147
+
148
+ Run Codex as an agent non-interactively (`codex exec`) with common CLI controls: prompt, model, sandbox policy, approval routing, extra directories, images, working directory, ephemeral/persisted sessions, and raw passthrough args.
149
+
150
+ Example:
151
+
152
+ ```json
153
+ {
154
+ "prompt": "Review the authentication implementation and identify security issues.",
155
+ "cwd": "/workspace/project",
156
+ "sandbox": "workspace-write",
157
+ "addDirs": ["/workspace/shared"]
158
+ }
159
+ ```
160
+
161
+ ### `codex_review`
162
+
163
+ Runs `codex exec review` non-interactively against the current repository. Supports `uncommitted`, `base`, `commit`, and `title`, plus a custom review `prompt`.
164
+
165
+ ### `codex_resume`
166
+
167
+ Resumes a previous session (`codex exec resume`) by `sessionId`, or the most recent one if omitted, optionally sending a new `prompt`.
168
+
169
+ ### `codex_fork`
170
+
171
+ Forks a previous session (`codex exec fork`) by `sessionId` into a new session, optionally sending a `prompt`.
172
+
173
+ ### `codex_version`
174
+
175
+ Returns the installed Codex CLI version.
176
+
177
+ ### `codex_login_status`
178
+
179
+ Runs `codex login status` to check authentication state.
180
+
181
+ ### `codex_doctor`
182
+
183
+ Runs `codex doctor --json` for install, auth, config, and connectivity diagnostics.
184
+
185
+ ### `codex_help`
186
+
187
+ Shows CLI help. A command can be provided for command-specific help.
188
+
189
+ ### `codex_raw`
190
+
191
+ Runs `codex` with an arbitrary argument array. This is the compatibility escape hatch for flags or commands not covered by the convenience tools. The model override, if set, still applies when the first argument is `exec`.
192
+
193
+ Example:
194
+
195
+ ```json
196
+ {
197
+ "args": ["mcp", "list"]
198
+ }
199
+ ```
200
+
201
+ ## Architecture
202
+
203
+ ```text
204
+ src/
205
+ ├── index.js # MCP server and tool registration
206
+ ├── tools.js # Tool behavior and response formatting (JSONL event parsing)
207
+ └── codex.js # Thin process adapter for the codex CLI + model-override enforcement
208
+ ```
209
+
210
+ Dependency direction:
211
+
212
+ ```text
213
+ MCP transport
214
+
215
+ tool handlers
216
+
217
+ codex CLI adapter
218
+
219
+ local codex executable
220
+ ```
221
+
222
+ There is intentionally no service container, repository layer, or framework abstraction. The project has one external process boundary and keeps that boundary explicit.
223
+
224
+ Tools that invoke `codex exec*` request `--json` internally so output can be parsed reliably; each tool's text response is the agent's final message(s), with the full parsed event stream, command executions, token usage, and thread id available in `structuredContent` for programmatic consumers.
225
+
226
+ ## Security Notes
227
+
228
+ `codex_raw` can execute arbitrary Codex CLI arguments with the permissions of the user running the MCP server. The server itself does not invoke a shell, so tool arguments are not shell-interpreted, but `codex` still has whatever permissions its sandbox and approval settings grant it.
229
+
230
+ `dangerouslyBypassApprovalsAndSandbox` skips all confirmation prompts and sandboxing. Only use it when you explicitly trust the task and an already-isolated workspace.
231
+
232
+ Environment variables passed through the `env` field are inherited by the `codex` process. Avoid sending secrets through MCP tool arguments unless necessary.
233
+
234
+ ## License
235
+
236
+ MIT
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@alvarosw/codex-mcp",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight MCP server that exposes the local Codex CLI to MCP-compatible coding agents.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "codex-mcp": "src/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "test": "node --check src/index.js && node --check src/codex.js && node --check src/tools.js",
13
+ "test:live": "node test/smoke.mjs"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "codex",
18
+ "openai",
19
+ "claude-code",
20
+ "agent"
21
+ ],
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.17.5",
28
+ "zod": "^3.23.8"
29
+ }
30
+ }
package/src/codex.js ADDED
@@ -0,0 +1,176 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ function commandName() {
4
+ return process.env.CODEX_MCP_CMD || "codex";
5
+ }
6
+
7
+ function forcedModel() {
8
+ const value = process.env.CODEX_MCP_MODEL;
9
+ return value && value.trim() ? value.trim() : null;
10
+ }
11
+
12
+ /**
13
+ * Strips any user- or tool-supplied model selection (-m/--model, or a
14
+ * `-c model=...` / `--config model=...` config override) from an argument
15
+ * list, then appends a single `-m <override>` at the end. A trailing -m
16
+ * wins over an earlier -c model=... override, and clap resolves flags
17
+ * placed after positional arguments fine, so appending at the very end
18
+ * is sufficient to make the override authoritative for every call.
19
+ */
20
+ export function applyModelOverride(args) {
21
+ const override = forcedModel();
22
+ if (!override) return args;
23
+
24
+ const out = [];
25
+ for (let i = 0; i < args.length; i++) {
26
+ const a = args[i];
27
+
28
+ if (a === "-m" || a === "--model") {
29
+ i++; // also skip its value
30
+ continue;
31
+ }
32
+ if (a.startsWith("--model=")) continue;
33
+
34
+ if (a === "-c" || a === "--config") {
35
+ const val = args[i + 1] ?? "";
36
+ if (/^model\s*=/.test(val)) {
37
+ i++; // also skip its value
38
+ continue;
39
+ }
40
+ out.push(a);
41
+ continue;
42
+ }
43
+ if (a.startsWith("-c=") || a.startsWith("--config=")) {
44
+ const val = a.slice(a.indexOf("=") + 1);
45
+ if (/^model\s*=/.test(val)) continue;
46
+ }
47
+
48
+ out.push(a);
49
+ }
50
+
51
+ out.push("-m", override);
52
+ return out;
53
+ }
54
+
55
+ export function runCodex(codexArgs, options = {}) {
56
+ const args = Array.isArray(codexArgs) ? codexArgs : [];
57
+ const cwd = options.cwd || process.cwd();
58
+ const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
59
+ const env = { ...process.env, ...(options.env || {}) };
60
+
61
+ return new Promise((resolve, reject) => {
62
+ const child = spawn(commandName(), args, {
63
+ cwd,
64
+ env,
65
+ shell: false,
66
+ windowsHide: true,
67
+ stdio: ["ignore", "pipe", "pipe"]
68
+ });
69
+
70
+ let stdout = "";
71
+ let stderr = "";
72
+ let settled = false;
73
+
74
+ const finish = (fn, value) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ fn(value);
78
+ };
79
+
80
+ const timer = setTimeout(() => {
81
+ child.kill();
82
+ finish(reject, new Error(`codex timed out after ${timeoutMs} ms`));
83
+ }, timeoutMs);
84
+
85
+ child.stdout.on("data", (chunk) => {
86
+ stdout += chunk.toString();
87
+ });
88
+
89
+ child.stderr.on("data", (chunk) => {
90
+ stderr += chunk.toString();
91
+ });
92
+
93
+ child.on("error", (error) => {
94
+ clearTimeout(timer);
95
+ finish(reject, error);
96
+ });
97
+
98
+ child.on("close", (code, signal) => {
99
+ clearTimeout(timer);
100
+ const result = {
101
+ command: commandName(),
102
+ args,
103
+ cwd,
104
+ exitCode: code,
105
+ signal,
106
+ stdout: stdout.trim(),
107
+ stderr: stderr.trim()
108
+ };
109
+ finish(resolve, result);
110
+ });
111
+ });
112
+ }
113
+
114
+ // Flags shared by every `codex exec*` subcommand (run, review, resume, fork).
115
+ // NOTE: -C/--cd, --add-dir, and -i/--image are deliberately NOT here — per
116
+ // `codex exec <subcommand> --help`, -C and --add-dir only exist on bare
117
+ // `codex exec`, and -i doesn't exist on `codex exec review` at all. They are
118
+ // added individually only where the CLI actually accepts them.
119
+ function pushCommon(args, input) {
120
+ if (input.ephemeral !== false) args.push("--ephemeral");
121
+ if (input.skipGitRepoCheck !== false) args.push("--skip-git-repo-check");
122
+ if (input.ignoreRules === true) args.push("--ignore-rules");
123
+ if (input.model) args.push("-m", input.model);
124
+ if (input.outputSchema) args.push("--output-schema", input.outputSchema);
125
+ if (input.outputLastMessage) args.push("-o", input.outputLastMessage);
126
+ if (input.dangerouslyBypassApprovalsAndSandbox === true) {
127
+ args.push("--dangerously-bypass-approvals-and-sandbox");
128
+ }
129
+ if (Array.isArray(input.extraArgs)) args.push(...input.extraArgs);
130
+ }
131
+
132
+ export function buildRunArgs(input) {
133
+ const args = ["exec", "--json"];
134
+ if (input.sandbox) args.push("--sandbox", input.sandbox);
135
+ if (input.approveForMe === true) args.push("--approve-for-me");
136
+ if (input.cd) args.push("-C", input.cd);
137
+ if (Array.isArray(input.addDirs)) {
138
+ for (const dir of input.addDirs) args.push("--add-dir", dir);
139
+ }
140
+ if (Array.isArray(input.images) && input.images.length) {
141
+ args.push("-i", ...input.images);
142
+ }
143
+ pushCommon(args, input);
144
+ if (input.prompt) args.push(input.prompt);
145
+ return applyModelOverride(args);
146
+ }
147
+
148
+ export function buildReviewArgs(input) {
149
+ const args = ["exec", "review", "--json"];
150
+ if (input.uncommitted === true) args.push("--uncommitted");
151
+ if (input.base) args.push("--base", input.base);
152
+ if (input.commit) args.push("--commit", input.commit);
153
+ if (input.title) args.push("--title", input.title);
154
+ pushCommon(args, { ...input, skipGitRepoCheck: input.skipGitRepoCheck ?? false });
155
+ if (input.prompt) args.push(input.prompt);
156
+ return applyModelOverride(args);
157
+ }
158
+
159
+ export function buildResumeArgs(input) {
160
+ const args = ["exec", "resume", "--json"];
161
+ if (input.all === true) args.push("--all");
162
+ pushCommon(args, input);
163
+ if (input.sessionId) args.push(input.sessionId);
164
+ else args.push("--last");
165
+ if (input.prompt) args.push(input.prompt);
166
+ return applyModelOverride(args);
167
+ }
168
+
169
+ export function buildForkArgs(input) {
170
+ if (!input.sessionId) throw new Error("sessionId is required to fork a session");
171
+ const args = ["exec", "fork", "--json"];
172
+ pushCommon(args, input);
173
+ args.push(input.sessionId);
174
+ if (input.prompt) args.push(input.prompt);
175
+ return applyModelOverride(args);
176
+ }
package/src/index.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import {
7
+ codexDoctor,
8
+ codexFork,
9
+ codexHelp,
10
+ codexLoginStatus,
11
+ codexResume,
12
+ codexReview,
13
+ codexRun,
14
+ codexRaw,
15
+ codexVersion
16
+ } from "./tools.js";
17
+
18
+ const server = new McpServer({
19
+ name: "codex-mcp",
20
+ version: "1.0.0"
21
+ });
22
+
23
+ const common = {
24
+ cwd: z.string().optional().describe("Working directory for the codex process."),
25
+ timeoutMs: z.number().int().positive().optional().describe("Process timeout in milliseconds."),
26
+ env: z.record(z.string()).optional().describe("Additional environment variables for codex.")
27
+ };
28
+
29
+ // Flags shared by every `codex exec*` subcommand (run, review, resume, fork).
30
+ // cd/addDirs/images are intentionally excluded here — the underlying CLI
31
+ // only accepts -C/--cd and --add-dir on bare `codex exec`, and doesn't
32
+ // accept -i/--image on `codex exec review` at all. Those are declared only
33
+ // on the tool schemas that actually support them.
34
+ const runControls = {
35
+ model: z.string().optional().describe("Model the agent should use. May be forced by the server's model-override environment variable."),
36
+ ephemeral: z.boolean().optional().describe("Skip persisting session files to disk. Defaults to true."),
37
+ skipGitRepoCheck: z.boolean().optional().describe("Allow running outside a Git repository. Defaults to true."),
38
+ ignoreRules: z.boolean().optional().describe("Do not load user or project execpolicy .rules files."),
39
+ outputSchema: z.string().optional().describe("Path to a JSON Schema file describing the expected final response shape."),
40
+ outputLastMessage: z.string().optional().describe("Path to a file where the final agent message should be written."),
41
+ dangerouslyBypassApprovalsAndSandbox: z.boolean().optional().describe("Skip all confirmation prompts and sandboxing. Only use in an already-isolated environment."),
42
+ extraArgs: z.array(z.string()).optional().describe("Additional raw codex CLI arguments appended to the command.")
43
+ };
44
+
45
+ server.registerTool("codex_run", {
46
+ description: "Run the Codex agent non-interactively (`codex exec`) with common CLI controls.",
47
+ inputSchema: {
48
+ prompt: z.string().describe("Task for the Codex agent."),
49
+ sandbox: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional().describe("Sandbox policy for model-generated shell commands."),
50
+ approveForMe: z.boolean().optional().describe("Route approval requests through automatic review using the workspace-write sandbox."),
51
+ cd: z.string().optional().describe("Directory codex should treat as its working root (--cd)."),
52
+ addDirs: z.array(z.string()).optional().describe("Additional directories to expose to the agent (--add-dir)."),
53
+ images: z.array(z.string()).optional().describe("Image file paths to attach to the prompt."),
54
+ ...runControls,
55
+ ...common
56
+ }
57
+ }, codexRun);
58
+
59
+ server.registerTool("codex_review", {
60
+ description: "Run a non-interactive Codex code review (`codex exec review`) against the current repository.",
61
+ inputSchema: {
62
+ prompt: z.string().optional().describe("Custom review instructions."),
63
+ uncommitted: z.boolean().optional().describe("Review staged, unstaged, and untracked changes."),
64
+ base: z.string().optional().describe("Review changes against the given base branch."),
65
+ commit: z.string().optional().describe("Review the changes introduced by a single commit."),
66
+ title: z.string().optional().describe("Optional commit title to display in the review summary."),
67
+ ...runControls,
68
+ ...common
69
+ }
70
+ }, codexReview);
71
+
72
+ server.registerTool("codex_resume", {
73
+ description: "Resume a previous Codex session non-interactively (`codex exec resume`) and optionally send a new prompt.",
74
+ inputSchema: {
75
+ sessionId: z.string().optional().describe("Conversation/session id (UUID) or thread name. Omit to resume the most recent session."),
76
+ all: z.boolean().optional().describe("Search across all directories, not just the current one."),
77
+ prompt: z.string().optional().describe("Prompt to send after resuming the session."),
78
+ ...runControls,
79
+ ...common
80
+ }
81
+ }, codexResume);
82
+
83
+ server.registerTool("codex_fork", {
84
+ description: "Fork a previous Codex session (`codex exec fork`) into a new session and optionally send a prompt.",
85
+ inputSchema: {
86
+ sessionId: z.string().describe("Conversation/session id (UUID) or thread name to fork."),
87
+ prompt: z.string().optional().describe("Optional prompt to send after forking."),
88
+ ...runControls,
89
+ ...common
90
+ }
91
+ }, codexFork);
92
+
93
+ server.registerTool("codex_version", {
94
+ description: "Return the installed Codex CLI version.",
95
+ inputSchema: common
96
+ }, codexVersion);
97
+
98
+ server.registerTool("codex_login_status", {
99
+ description: "Check Codex CLI authentication status (`codex login status`).",
100
+ inputSchema: common
101
+ }, codexLoginStatus);
102
+
103
+ server.registerTool("codex_doctor", {
104
+ description: "Run Codex's built-in diagnostics (`codex doctor --json`): install, auth, config, and connectivity health.",
105
+ inputSchema: common
106
+ }, codexDoctor);
107
+
108
+ server.registerTool("codex_help", {
109
+ description: "Show Codex CLI help, optionally for a specific subcommand.",
110
+ inputSchema: {
111
+ command: z.string().optional(),
112
+ ...common
113
+ }
114
+ }, codexHelp);
115
+
116
+ server.registerTool("codex_raw", {
117
+ description: "Run the Codex CLI with an arbitrary argument array. Escape hatch for options not covered by the other tools. The server's model-override environment variable, if set, still applies.",
118
+ inputSchema: {
119
+ args: z.array(z.string()),
120
+ ...common
121
+ }
122
+ }, codexRaw);
123
+
124
+ const transport = new StdioServerTransport();
125
+ await server.connect(transport);
package/src/tools.js ADDED
@@ -0,0 +1,178 @@
1
+ import {
2
+ applyModelOverride,
3
+ buildForkArgs,
4
+ buildResumeArgs,
5
+ buildReviewArgs,
6
+ buildRunArgs,
7
+ runCodex
8
+ } from "./codex.js";
9
+
10
+ function textResult(text, structuredContent) {
11
+ return {
12
+ content: [{ type: "text", text }],
13
+ ...(structuredContent ? { structuredContent } : {})
14
+ };
15
+ }
16
+
17
+ function parseJsonl(output) {
18
+ const events = [];
19
+ for (const line of output.split("\n")) {
20
+ const trimmed = line.trim();
21
+ if (!trimmed) continue;
22
+ try {
23
+ events.push(JSON.parse(trimmed));
24
+ } catch {
25
+ events.push({ type: "raw", line: trimmed });
26
+ }
27
+ }
28
+ return events;
29
+ }
30
+
31
+ function summarizeEvents(events) {
32
+ const summary = {
33
+ threadId: null,
34
+ agentMessages: [],
35
+ commandExecutions: [],
36
+ errors: [],
37
+ usage: null,
38
+ turnStatus: "unknown"
39
+ };
40
+
41
+ for (const event of events) {
42
+ switch (event.type) {
43
+ case "thread.started":
44
+ summary.threadId = event.thread_id ?? summary.threadId;
45
+ break;
46
+ case "turn.completed":
47
+ summary.turnStatus = "completed";
48
+ summary.usage = event.usage ?? summary.usage;
49
+ break;
50
+ case "turn.failed":
51
+ summary.turnStatus = "failed";
52
+ if (event.error) summary.errors.push(event.error.message ?? String(event.error));
53
+ break;
54
+ case "error":
55
+ summary.errors.push(event.message ?? "unknown error");
56
+ break;
57
+ case "item.completed":
58
+ case "item.started": {
59
+ const item = event.item;
60
+ if (!item) break;
61
+ if (item.type === "agent_message" && item.text) {
62
+ summary.agentMessages.push(item.text);
63
+ } else if (item.type === "command_execution") {
64
+ summary.commandExecutions.push({
65
+ command: item.command,
66
+ exitCode: item.exit_code,
67
+ status: item.status,
68
+ output: item.aggregated_output
69
+ });
70
+ } else if (item.type === "error" && item.message) {
71
+ summary.errors.push(item.message);
72
+ }
73
+ break;
74
+ }
75
+ default:
76
+ break;
77
+ }
78
+ }
79
+
80
+ return summary;
81
+ }
82
+
83
+ /** Formats the JSONL output of a `codex exec*` invocation into a readable
84
+ * text reply plus a structured payload for programmatic consumers. */
85
+ function formatExecResult(result) {
86
+ const events = parseJsonl(result.stdout);
87
+ const summary = summarizeEvents(events);
88
+
89
+ const parts = [];
90
+ if (summary.agentMessages.length) {
91
+ parts.push(summary.agentMessages.join("\n\n"));
92
+ }
93
+ if (summary.errors.length) {
94
+ parts.push(`Errors:\n${summary.errors.join("\n")}`);
95
+ }
96
+ if (!parts.length) {
97
+ parts.push(result.stdout || result.stderr || "(no output)");
98
+ }
99
+ if (result.exitCode !== 0) {
100
+ parts.push(`(codex exited with code ${result.exitCode})`);
101
+ }
102
+
103
+ return textResult(parts.join("\n\n"), {
104
+ threadId: summary.threadId,
105
+ turnStatus: summary.turnStatus,
106
+ usage: summary.usage,
107
+ commandExecutions: summary.commandExecutions,
108
+ errors: summary.errors,
109
+ exitCode: result.exitCode,
110
+ events
111
+ });
112
+ }
113
+
114
+ function formatPlainResult(result) {
115
+ const output = result.stdout || result.stderr || "";
116
+ return textResult(output, { exitCode: result.exitCode, stderr: result.stderr });
117
+ }
118
+
119
+ function processOptions(input) {
120
+ return { cwd: input.cwd, timeoutMs: input.timeoutMs, env: input.env };
121
+ }
122
+
123
+ export async function codexRun(input) {
124
+ if (!input.prompt?.trim()) throw new Error("prompt is required");
125
+ const args = buildRunArgs(input);
126
+ const result = await runCodex(args, processOptions(input));
127
+ return formatExecResult(result);
128
+ }
129
+
130
+ export async function codexReview(input = {}) {
131
+ const args = buildReviewArgs(input);
132
+ const result = await runCodex(args, processOptions(input));
133
+ return formatExecResult(result);
134
+ }
135
+
136
+ export async function codexResume(input = {}) {
137
+ const args = buildResumeArgs(input);
138
+ const result = await runCodex(args, processOptions(input));
139
+ return formatExecResult(result);
140
+ }
141
+
142
+ export async function codexFork(input) {
143
+ if (!input.sessionId?.trim()) throw new Error("sessionId is required");
144
+ const args = buildForkArgs(input);
145
+ const result = await runCodex(args, processOptions(input));
146
+ return formatExecResult(result);
147
+ }
148
+
149
+ export async function codexVersion(input = {}) {
150
+ const result = await runCodex(["--version"], processOptions(input));
151
+ return formatPlainResult(result);
152
+ }
153
+
154
+ export async function codexLoginStatus(input = {}) {
155
+ const result = await runCodex(["login", "status"], processOptions(input));
156
+ return formatPlainResult(result);
157
+ }
158
+
159
+ export async function codexDoctor(input = {}) {
160
+ const result = await runCodex(["doctor", "--json"], processOptions(input));
161
+ return formatPlainResult(result);
162
+ }
163
+
164
+ export async function codexHelp(input = {}) {
165
+ const args = input.command ? ["help", input.command] : ["help"];
166
+ const result = await runCodex(args, processOptions(input));
167
+ return formatPlainResult(result);
168
+ }
169
+
170
+ export async function codexRaw(input) {
171
+ if (!Array.isArray(input.args)) throw new Error("args must be an array");
172
+ // Only exec-family invocations accept -m/--model; leave other subcommands
173
+ // (login, doctor, help, mcp, ...) untouched so the override never injects
174
+ // an unrecognized flag into a command that doesn't take one.
175
+ const args = input.args[0] === "exec" ? applyModelOverride(input.args) : input.args;
176
+ const result = await runCodex(args, processOptions(input));
177
+ return formatPlainResult(result);
178
+ }
package/test/smoke.mjs ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Manual, live end-to-end smoke test for codex-mcp.
3
+ //
4
+ // This spawns the real MCP server and drives it over stdio with the real
5
+ // @modelcontextprotocol/sdk Client, exercising every registered tool against
6
+ // a real, authenticated Codex CLI. It makes real model calls and is NOT run
7
+ // by `npm test` (which only does a static syntax check) because it costs
8
+ // real quota/tokens against whatever model resolves for the caller.
9
+ //
10
+ // Usage:
11
+ // CODEX_MCP_MODEL=<your-model> node test/smoke.mjs
12
+ //
13
+ // Setting CODEX_MCP_MODEL is optional but strongly recommended: it proves
14
+ // the override wins even when a tool call or raw arg list requests a
15
+ // different (here, intentionally bogus) model.
16
+
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { execFileSync } from "node:child_process";
21
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
23
+
24
+ const overrideModel = process.env.CODEX_MCP_MODEL;
25
+ const badModel = "definitely-not-a-real-model"; // must never resolve to any real, billable provider
26
+
27
+ function assert(condition, message) {
28
+ if (!condition) throw new Error(`ASSERTION FAILED: ${message}`);
29
+ console.log(` ok - ${message}`);
30
+ }
31
+
32
+ const repoDir = mkdtempSync(join(tmpdir(), "codex-mcp-smoke-"));
33
+ execFileSync("git", ["init", "-q"], { cwd: repoDir });
34
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir });
35
+ execFileSync("git", ["config", "user.name", "test"], { cwd: repoDir });
36
+ writeFileSync(
37
+ join(repoDir, "math.js"),
38
+ "function add(a, b) {\n return a - b; // bug: should be a + b\n}\nmodule.exports = { add };\n"
39
+ );
40
+ execFileSync("git", ["add", "math.js"], { cwd: repoDir });
41
+
42
+ const transport = new StdioClientTransport({
43
+ command: "node",
44
+ args: [new URL("../src/index.js", import.meta.url).pathname],
45
+ env: { ...process.env }
46
+ });
47
+ const client = new Client({ name: "smoke-test", version: "1.0.0" });
48
+ await client.connect(transport);
49
+
50
+ try {
51
+ console.log("\n=== tools/list ===");
52
+ const tools = await client.listTools();
53
+ const names = tools.tools.map((t) => t.name);
54
+ console.log(names.join(", "));
55
+ for (const expected of ["codex_run", "codex_review", "codex_resume", "codex_fork", "codex_version", "codex_login_status", "codex_doctor", "codex_help", "codex_raw"]) {
56
+ assert(names.includes(expected), `${expected} is registered`);
57
+ }
58
+
59
+ console.log("\n=== codex_version ===");
60
+ const version = await client.callTool({ name: "codex_version", arguments: {} });
61
+ assert(!version.isError, "codex_version did not error");
62
+ console.log(version.content[0].text);
63
+
64
+ console.log("\n=== codex_login_status ===");
65
+ const login = await client.callTool({ name: "codex_login_status", arguments: {} });
66
+ assert(!login.isError, "codex_login_status did not error");
67
+ console.log(login.content[0].text);
68
+
69
+ console.log(`\n=== codex_run: tool arg requests bogus model "${badModel}"${overrideModel ? "; env override must win" : "; no override set, expect failure" } ===`);
70
+ const run = await client.callTool({
71
+ name: "codex_run",
72
+ arguments: {
73
+ prompt: "Reply with exactly the single word: PONG",
74
+ model: badModel,
75
+ ephemeral: false,
76
+ cwd: repoDir,
77
+ timeoutMs: 60000
78
+ }
79
+ });
80
+ console.log("TEXT:", run.content[0].text);
81
+ if (overrideModel) {
82
+ assert(!run.isError && run.structuredContent?.turnStatus === "completed", "override forced a working model despite bogus tool-supplied model");
83
+ assert(run.content[0].text.includes("PONG"), "agent replied PONG using the overridden model");
84
+ } else {
85
+ console.log(" (no CODEX_MCP_MODEL set - skipping override assertion)");
86
+ }
87
+ const threadId = run.structuredContent?.threadId;
88
+
89
+ console.log("\n=== codex_raw: raw args also sneak in a bogus -m; override must still win when set ===");
90
+ const raw = await client.callTool({
91
+ name: "codex_raw",
92
+ arguments: {
93
+ args: ["exec", "--json", "--ephemeral", "--skip-git-repo-check", "-m", badModel, "Reply with exactly the single word: RAWPONG"],
94
+ cwd: repoDir,
95
+ timeoutMs: 60000
96
+ }
97
+ });
98
+ console.log("TEXT:", raw.content[0].text);
99
+ if (overrideModel) {
100
+ assert(!raw.isError && raw.content[0].text.includes("RAWPONG"), "override stripped the bogus -m from raw args and still succeeded");
101
+ }
102
+
103
+ console.log("\n=== codex_raw: override must NOT be injected into non-exec subcommands ===");
104
+ const rawHelp = await client.callTool({ name: "codex_raw", arguments: { args: ["--version"] } });
105
+ assert(!rawHelp.isError, "codex_raw --version unaffected by model override scoping");
106
+ console.log(rawHelp.content[0].text);
107
+
108
+ console.log("\n=== codex_help ===");
109
+ const help = await client.callTool({ name: "codex_help", arguments: {} });
110
+ assert(!help.isError, "codex_help did not error");
111
+ console.log(help.content[0].text.slice(0, 120));
112
+
113
+ console.log("\n=== codex_doctor ===");
114
+ const doctor = await client.callTool({ name: "codex_doctor", arguments: {} });
115
+ assert(!doctor.isError, "codex_doctor did not error");
116
+ console.log(doctor.content[0].text.slice(0, 120), "...");
117
+
118
+ console.log("\n=== codex_review: uncommitted change with a deliberate bug ===");
119
+ const review = await client.callTool({
120
+ name: "codex_review",
121
+ arguments: { uncommitted: true, cwd: repoDir, timeoutMs: 90000 }
122
+ });
123
+ assert(!review.isError, "codex_review did not error");
124
+ console.log("TEXT:", review.content[0].text.slice(0, 400));
125
+
126
+ console.log("\n=== codex_fork: missing required sessionId must surface as a tool error ===");
127
+ const fork = await client.callTool({ name: "codex_fork", arguments: {} });
128
+ assert(fork.isError === true, "codex_fork without sessionId reports isError");
129
+ console.log(fork.content[0].text);
130
+
131
+ if (threadId) {
132
+ console.log("\n=== codex_resume: recall context from the earlier codex_run ===");
133
+ const resume = await client.callTool({
134
+ name: "codex_resume",
135
+ arguments: {
136
+ sessionId: threadId,
137
+ prompt: "What single word did you just reply with in your previous message? Answer with just that one word.",
138
+ cwd: repoDir,
139
+ timeoutMs: 60000
140
+ }
141
+ });
142
+ console.log("TEXT:", resume.content[0].text);
143
+ if (overrideModel) assert(!resume.isError, "codex_resume did not error");
144
+ }
145
+
146
+ console.log("\nALL CHECKS PASSED");
147
+ } finally {
148
+ await client.close();
149
+ rmSync(repoDir, { recursive: true, force: true });
150
+ }