mcp-agents 0.8.0 → 0.10.1

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 (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +176 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-agents",
3
- "version": "0.8.0",
3
+ "version": "0.10.1",
4
4
  "description": "MCP server that wraps AI CLI tools (Claude Code, Gemini CLI, Codex CLI) for use by any MCP client",
5
5
  "type": "module",
6
6
  "bin": {
package/server.js CHANGED
@@ -29,11 +29,36 @@ const VERSION = JSON.parse(
29
29
  const DEFAULT_TIMEOUT_MS = 300_000;
30
30
  const DEFAULT_CODEX_MODEL = "gpt-5.5";
31
31
  const DEFAULT_CODEX_MODEL_REASONING_EFFORT = "xhigh";
32
+ const DEFAULT_CODEX_SANDBOX_MODE = "workspace-write";
33
+ const DEFAULT_CODEX_APPROVAL_POLICY = "never";
32
34
  const DEFAULT_CLAUDE_MODEL = "claude-opus-4-8";
33
35
  const DEFAULT_CLAUDE_EFFORT = "xhigh";
34
36
  // tools/call argument keys stripped from the codex pass-through so callers
35
- // cannot override the pinned model/effort (or the read-only/never config).
36
- const CODEX_STRIPPED_TOOL_ARGS = ["model", "config"];
37
+ // cannot override the pinned model/effort. sandbox/cwd/approval-policy are
38
+ // intentionally left intact so callers can steer them per call.
39
+ // - top-level: only the dedicated `model` arg (there is no top-level
40
+ // model_reasoning_effort/profile arg in the codex tool schema)
41
+ // - inside the `config` override map: model/effort plus every other
42
+ // model-envelope vector — a `profile`/`profiles` can carry its own
43
+ // model/effort, provider/base-url keys re-point the same model name to a
44
+ // different backend, and the plan/review variants carry their own
45
+ // model/effort; all are stripped so the pin cannot be bypassed. Matched on
46
+ // each config key's HEAD segment so dotted overrides (codex accepts paths
47
+ // like `profiles.x.model`) are caught too, not just exact keys.
48
+ const CODEX_STRIPPED_TOP_LEVEL_ARGS = ["model"];
49
+ const CODEX_STRIPPED_CONFIG_KEYS = [
50
+ "model",
51
+ "model_reasoning_effort",
52
+ "profile",
53
+ "profiles",
54
+ "model_provider",
55
+ "model_providers",
56
+ "openai_base_url",
57
+ "chatgpt_base_url",
58
+ "model_catalog_json",
59
+ "plan_mode_reasoning_effort",
60
+ "review_model",
61
+ ];
37
62
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
38
63
  const CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS = 2;
39
64
  const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
@@ -103,6 +128,9 @@ function toStringArg(value) {
103
128
 
104
129
  /**
105
130
  * Normalize provider output and parse Claude's JSON print format when present.
131
+ * `--output-format json` emits either a single `{type:"result"}` object or
132
+ * (newer CLIs, e.g. 2.1.x) an array of stream events whose final
133
+ * `type:"result"` entry holds the answer; both are supported.
106
134
  * @param {string} provider
107
135
  * @param {string} output
108
136
  * @returns {{ text: string, isError: boolean }}
@@ -115,10 +143,24 @@ function normalizeToolOutput(provider, output) {
115
143
 
116
144
  try {
117
145
  const parsed = JSON.parse(trimmed);
118
- if (parsed && typeof parsed === "object" && parsed.type === "result") {
146
+ // Resolve the result event from either shape. Scanning from the end finds
147
+ // the terminal result without depending on Array.prototype.findLast
148
+ // (keeps the Node >=18 floor — see engines).
149
+ let result = parsed;
150
+ if (Array.isArray(parsed)) {
151
+ result = null;
152
+ for (let i = parsed.length - 1; i >= 0; i--) {
153
+ const event = parsed[i];
154
+ if (event && typeof event === "object" && event.type === "result") {
155
+ result = event;
156
+ break;
157
+ }
158
+ }
159
+ }
160
+ if (result && typeof result === "object" && result.type === "result") {
119
161
  return {
120
- text: toStringArg(parsed.result),
121
- isError: parsed.is_error === true,
162
+ text: toStringArg(result.result),
163
+ isError: result.is_error === true,
122
164
  };
123
165
  }
124
166
  } catch {
@@ -141,6 +183,10 @@ Options:
141
183
  --provider <name> CLI backend to use (${providers}) [default: codex]
142
184
  --model <model> Codex model [default: ${DEFAULT_CODEX_MODEL}]
143
185
  --model_reasoning_effort <e> Codex reasoning effort [default: ${DEFAULT_CODEX_MODEL_REASONING_EFFORT}]
186
+ --sandbox_mode <mode> Codex sandbox mode: read-only, workspace-write,
187
+ danger-full-access [default: ${DEFAULT_CODEX_SANDBOX_MODE}]
188
+ --approval_policy <policy> Codex approval policy: untrusted, on-failure,
189
+ on-request, never [default: ${DEFAULT_CODEX_APPROVAL_POLICY}]
144
190
  --timeout <seconds> Default timeout per call [default: 300]
145
191
  --help, -h Show this help message
146
192
  --version, -v Show version number`);
@@ -148,14 +194,17 @@ Options:
148
194
 
149
195
  /**
150
196
  * Parse CLI flags from process.argv.
151
- * Handles --help, --version, --provider, --model, --model_reasoning_effort, and unknown flags.
152
- * @returns {{ provider: string, model?: string, modelReasoningEffort?: string, defaultTimeoutMs?: number }}
197
+ * Handles --help, --version, --provider, --model, --model_reasoning_effort,
198
+ * --sandbox_mode, --approval_policy, and unknown flags.
199
+ * @returns {{ provider: string, model?: string, modelReasoningEffort?: string, sandboxMode?: string, approvalPolicy?: string, defaultTimeoutMs?: number }}
153
200
  */
154
201
  function parseArgs() {
155
202
  const args = process.argv.slice(2);
156
203
  let provider = "codex";
157
204
  let model;
158
205
  let modelReasoningEffort;
206
+ let sandboxMode;
207
+ let approvalPolicy;
159
208
  let defaultTimeoutMs;
160
209
 
161
210
  for (let i = 0; i < args.length; i++) {
@@ -193,6 +242,20 @@ function parseArgs() {
193
242
  }
194
243
  modelReasoningEffort = args[++i];
195
244
  break;
245
+ case "--sandbox_mode":
246
+ if (i + 1 >= args.length) {
247
+ process.stderr.write("error: --sandbox_mode requires a value\n");
248
+ process.exit(1);
249
+ }
250
+ sandboxMode = args[++i];
251
+ break;
252
+ case "--approval_policy":
253
+ if (i + 1 >= args.length) {
254
+ process.stderr.write("error: --approval_policy requires a value\n");
255
+ process.exit(1);
256
+ }
257
+ approvalPolicy = args[++i];
258
+ break;
196
259
  case "--timeout": {
197
260
  if (i + 1 >= args.length) {
198
261
  process.stderr.write("error: --timeout requires a value\n");
@@ -212,7 +275,14 @@ function parseArgs() {
212
275
  }
213
276
  }
214
277
 
215
- return { provider, model, modelReasoningEffort, defaultTimeoutMs };
278
+ return {
279
+ provider,
280
+ model,
281
+ modelReasoningEffort,
282
+ sandboxMode,
283
+ approvalPolicy,
284
+ defaultTimeoutMs,
285
+ };
216
286
  }
217
287
 
218
288
  /**
@@ -356,15 +426,20 @@ function toTomlString(value) {
356
426
 
357
427
  /**
358
428
  * Build the minimal config for the isolated Codex bridge runtime.
359
- * @param {{ model: string, modelReasoningEffort: string }} opts
429
+ * @param {{ model: string, modelReasoningEffort: string, sandboxMode: string, approvalPolicy: string }} opts
360
430
  * @returns {string}
361
431
  */
362
- function buildCodexBridgeConfig({ model, modelReasoningEffort }) {
432
+ function buildCodexBridgeConfig({
433
+ model,
434
+ modelReasoningEffort,
435
+ sandboxMode,
436
+ approvalPolicy,
437
+ }) {
363
438
  return [
364
439
  `model = ${toTomlString(model)}`,
365
440
  `model_reasoning_effort = ${toTomlString(modelReasoningEffort)}`,
366
- 'approval_policy = "never"',
367
- 'sandbox_mode = "read-only"',
441
+ `approval_policy = ${toTomlString(approvalPolicy)}`,
442
+ `sandbox_mode = ${toTomlString(sandboxMode)}`,
368
443
  "",
369
444
  "[features]",
370
445
  "multi_agent = false",
@@ -374,10 +449,15 @@ function buildCodexBridgeConfig({ model, modelReasoningEffort }) {
374
449
 
375
450
  /**
376
451
  * Create an isolated Codex home that preserves auth but strips inherited MCP servers.
377
- * @param {{ model: string, modelReasoningEffort: string }} opts
452
+ * @param {{ model: string, modelReasoningEffort: string, sandboxMode: string, approvalPolicy: string }} opts
378
453
  * @returns {string}
379
454
  */
380
- function createIsolatedCodexHome({ model, modelReasoningEffort }) {
455
+ function createIsolatedCodexHome({
456
+ model,
457
+ modelReasoningEffort,
458
+ sandboxMode,
459
+ approvalPolicy,
460
+ }) {
381
461
  const codexHome = mkdtempSync(join(tmpdir(), "mcp-agents-codex-"));
382
462
  const sourceAuthPath = join(resolveCodexHome(), "auth.json");
383
463
  const targetAuthPath = join(codexHome, "auth.json");
@@ -389,7 +469,12 @@ function createIsolatedCodexHome({ model, modelReasoningEffort }) {
389
469
 
390
470
  writeFileSync(
391
471
  configPath,
392
- buildCodexBridgeConfig({ model, modelReasoningEffort }),
472
+ buildCodexBridgeConfig({
473
+ model,
474
+ modelReasoningEffort,
475
+ sandboxMode,
476
+ approvalPolicy,
477
+ }),
393
478
  "utf8",
394
479
  );
395
480
 
@@ -398,10 +483,13 @@ function createIsolatedCodexHome({ model, modelReasoningEffort }) {
398
483
 
399
484
  /**
400
485
  * Filter a single newline-delimited JSON-RPC message on its way to the codex
401
- * pass-through. Strips per-call model/config overrides from `tools/call` so the
402
- * client cannot escape the pinned model/effort (or the read-only/never config).
403
- * Non-`tools/call` and unparseable lines are returned byte-for-byte unchanged so
404
- * the MCP framing is preserved.
486
+ * pass-through. Strips per-call model/effort overrides from `tools/call` so the
487
+ * client cannot escape the pinned model/effort both the top-level `model` arg
488
+ * and the model-envelope keys inside a `config` override map. sandbox/cwd/
489
+ * approval-policy (top-level and inside `config`) are intentionally left intact
490
+ * so callers can steer them per call. Non-`tools/call`, unparseable, and
491
+ * nothing-to-strip lines are returned byte-for-byte unchanged so the MCP framing
492
+ * is preserved.
405
493
  * @param {string} line
406
494
  * @returns {string}
407
495
  */
@@ -422,12 +510,36 @@ function filterCodexToolCall(line) {
422
510
  : null;
423
511
  if (!args || typeof args !== "object") return line;
424
512
 
425
- const stripped = CODEX_STRIPPED_TOOL_ARGS.filter((key) => key in args);
426
- if (stripped.length === 0) return line;
513
+ const removed = [];
514
+
515
+ for (const key of CODEX_STRIPPED_TOP_LEVEL_ARGS) {
516
+ if (key in args) {
517
+ delete args[key];
518
+ removed.push(key);
519
+ }
520
+ }
521
+
522
+ // Per-call `config` overrides beat CODEX_HOME/config.toml, so the pinned
523
+ // model/effort must be stripped from here too; everything else (sandbox_mode,
524
+ // approval_policy, cwd, sandbox_workspace_write, …) is left untouched. codex
525
+ // config overrides also accept dotted paths (e.g. "profiles.x.model"), so
526
+ // match each key on its HEAD segment, not the exact key.
527
+ const cfg = args.config;
528
+ if (cfg && typeof cfg === "object" && !Array.isArray(cfg)) {
529
+ for (const key of Object.keys(cfg)) {
530
+ if (CODEX_STRIPPED_CONFIG_KEYS.includes(key.split(".")[0])) {
531
+ delete cfg[key];
532
+ removed.push(`config.${key}`);
533
+ }
534
+ }
535
+ // Drop a now-empty override map so codex never receives a bare `config: {}`.
536
+ if (Object.keys(cfg).length === 0) delete args.config;
537
+ }
538
+
539
+ if (removed.length === 0) return line; // nothing pinned to strip — keep framing
427
540
 
428
- for (const key of stripped) delete args[key];
429
541
  logErr(
430
- `[mcp-agents] codex passthrough: ignoring per-call overrides: ${stripped.join(", ")}`,
542
+ `[mcp-agents] codex passthrough: pinning model/effort, stripped: ${removed.join(", ")}`,
431
543
  );
432
544
  return JSON.stringify(msg);
433
545
  }
@@ -436,18 +548,27 @@ function filterCodexToolCall(line) {
436
548
  * Spawn codex mcp-server as a pass-through. stdout/stderr flow straight back to
437
549
  * the client, but the client's stdin is intercepted line-by-line so per-call
438
550
  * model/config overrides are stripped before reaching codex.
439
- * @param {{ model?: string, modelReasoningEffort?: string }} opts
551
+ * @param {{ model?: string, modelReasoningEffort?: string, sandboxMode?: string, approvalPolicy?: string }} opts
440
552
  */
441
- function runCodexPassthrough({ model, modelReasoningEffort }) {
553
+ function runCodexPassthrough({
554
+ model,
555
+ modelReasoningEffort,
556
+ sandboxMode,
557
+ approvalPolicy,
558
+ }) {
442
559
  const resolvedModel = model || DEFAULT_CODEX_MODEL;
443
560
  const resolvedModelReasoningEffort =
444
561
  modelReasoningEffort || DEFAULT_CODEX_MODEL_REASONING_EFFORT;
562
+ const resolvedSandboxMode = sandboxMode || DEFAULT_CODEX_SANDBOX_MODE;
563
+ const resolvedApprovalPolicy = approvalPolicy || DEFAULT_CODEX_APPROVAL_POLICY;
445
564
  let isolatedCodexHome;
446
565
 
447
566
  try {
448
567
  isolatedCodexHome = createIsolatedCodexHome({
449
568
  model: resolvedModel,
450
569
  modelReasoningEffort: resolvedModelReasoningEffort,
570
+ sandboxMode: resolvedSandboxMode,
571
+ approvalPolicy: resolvedApprovalPolicy,
451
572
  });
452
573
  } catch (err) {
453
574
  const msg = err instanceof Error ? err.message : String(err);
@@ -472,7 +593,9 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
472
593
 
473
594
  logErr(
474
595
  `[mcp-agents] passthrough: codex ${args.join(" ")} ` +
475
- `(model=${resolvedModel}, reasoning_effort=${resolvedModelReasoningEffort}, isolated_home=true)`,
596
+ `(model=${resolvedModel}, reasoning_effort=${resolvedModelReasoningEffort}, ` +
597
+ `sandbox_mode=${resolvedSandboxMode}, approval_policy=${resolvedApprovalPolicy}, ` +
598
+ `isolated_home=true)`,
476
599
  );
477
600
 
478
601
  const child = spawn("codex", args, {
@@ -486,22 +609,27 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
486
609
  logErr(`[codex] ${chunk.toString().trimEnd()}`);
487
610
  });
488
611
 
489
- // Pump client stdin -> codex stdin, splitting on newlines (MCP stdio framing)
490
- // so each JSON-RPC message can be filtered before forwarding.
612
+ // Pump client stdin -> codex stdin, splitting on the newline BYTE (0x0a) that
613
+ // delimits MCP stdio JSON-RPC frames. Buffering raw bytes (not per-chunk
614
+ // strings) avoids corrupting a multibyte UTF-8 sequence that straddles two
615
+ // read chunks, which would otherwise break the byte-for-byte passthrough.
491
616
  child.stdin.on("error", () => {}); // ignore EPIPE if codex exits early
492
- let stdinBuf = "";
617
+ const NEWLINE = 0x0a;
618
+ let stdinBuf = Buffer.alloc(0);
493
619
  process.stdin.on("data", (chunk) => {
494
- stdinBuf += chunk.toString("utf8");
620
+ stdinBuf = stdinBuf.length ? Buffer.concat([stdinBuf, chunk]) : chunk;
495
621
  let nl;
496
- while ((nl = stdinBuf.indexOf("\n")) !== -1) {
497
- const line = stdinBuf.slice(0, nl);
498
- stdinBuf = stdinBuf.slice(nl + 1);
622
+ while ((nl = stdinBuf.indexOf(NEWLINE)) !== -1) {
623
+ const line = stdinBuf.subarray(0, nl).toString("utf8");
624
+ stdinBuf = stdinBuf.subarray(nl + 1);
499
625
  child.stdin.write(`${filterCodexToolCall(line)}\n`);
500
626
  }
501
627
  });
502
628
  process.stdin.on("error", () => {});
503
629
  process.stdin.on("end", () => {
504
- if (stdinBuf.length > 0) child.stdin.write(filterCodexToolCall(stdinBuf));
630
+ if (stdinBuf.length > 0) {
631
+ child.stdin.write(filterCodexToolCall(stdinBuf.toString("utf8")));
632
+ }
505
633
  child.stdin.end();
506
634
  });
507
635
 
@@ -539,7 +667,14 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
539
667
  // ---------------------------------------------------------------------------
540
668
 
541
669
  async function main() {
542
- const { provider: providerName, model, modelReasoningEffort, defaultTimeoutMs } = parseArgs();
670
+ const {
671
+ provider: providerName,
672
+ model,
673
+ modelReasoningEffort,
674
+ sandboxMode,
675
+ approvalPolicy,
676
+ defaultTimeoutMs,
677
+ } = parseArgs();
543
678
  const backend = CLI_BACKENDS[providerName];
544
679
 
545
680
  if (!backend) {
@@ -550,7 +685,12 @@ async function main() {
550
685
  }
551
686
 
552
687
  if (backend.passthrough) {
553
- runCodexPassthrough({ model, modelReasoningEffort });
688
+ runCodexPassthrough({
689
+ model,
690
+ modelReasoningEffort,
691
+ sandboxMode,
692
+ approvalPolicy,
693
+ });
554
694
  return;
555
695
  }
556
696