mcp-agents 0.9.0 → 0.10.2

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 +3 -3
  2. package/server.js +93 -22
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "mcp-agents",
3
- "version": "0.9.0",
3
+ "version": "0.10.2",
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": {
7
- "mcp-agents": "./server.js"
7
+ "mcp-agents": "server.js"
8
8
  },
9
9
  "files": [
10
10
  "server.js",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "https://github.com/thomaswitt/mcp-agents"
26
+ "url": "git+https://github.com/thomaswitt/mcp-agents.git"
27
27
  },
28
28
  "author": "Thomas Witt",
29
29
  "dependencies": {
package/server.js CHANGED
@@ -34,9 +34,31 @@ const DEFAULT_CODEX_APPROVAL_POLICY = "never";
34
34
  const DEFAULT_CLAUDE_MODEL = "claude-opus-4-8";
35
35
  const DEFAULT_CLAUDE_EFFORT = "xhigh";
36
36
  // tools/call argument keys stripped from the codex pass-through so callers
37
- // cannot override the pinned model/effort (or the server's sandbox/approval
38
- // config) for a single call.
39
- 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
+ ];
40
62
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
41
63
  const CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS = 2;
42
64
  const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
@@ -106,6 +128,9 @@ function toStringArg(value) {
106
128
 
107
129
  /**
108
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.
109
134
  * @param {string} provider
110
135
  * @param {string} output
111
136
  * @returns {{ text: string, isError: boolean }}
@@ -118,10 +143,24 @@ function normalizeToolOutput(provider, output) {
118
143
 
119
144
  try {
120
145
  const parsed = JSON.parse(trimmed);
121
- 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") {
122
161
  return {
123
- text: toStringArg(parsed.result),
124
- isError: parsed.is_error === true,
162
+ text: toStringArg(result.result),
163
+ isError: result.is_error === true,
125
164
  };
126
165
  }
127
166
  } catch {
@@ -444,10 +483,13 @@ function createIsolatedCodexHome({
444
483
 
445
484
  /**
446
485
  * Filter a single newline-delimited JSON-RPC message on its way to the codex
447
- * pass-through. Strips per-call model/config overrides from `tools/call` so the
448
- * client cannot escape the pinned model/effort (or the sandbox/approval config).
449
- * Non-`tools/call` and unparseable lines are returned byte-for-byte unchanged so
450
- * 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.
451
493
  * @param {string} line
452
494
  * @returns {string}
453
495
  */
@@ -468,12 +510,36 @@ function filterCodexToolCall(line) {
468
510
  : null;
469
511
  if (!args || typeof args !== "object") return line;
470
512
 
471
- const stripped = CODEX_STRIPPED_TOOL_ARGS.filter((key) => key in args);
472
- 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
473
540
 
474
- for (const key of stripped) delete args[key];
475
541
  logErr(
476
- `[mcp-agents] codex passthrough: ignoring per-call overrides: ${stripped.join(", ")}`,
542
+ `[mcp-agents] codex passthrough: pinning model/effort, stripped: ${removed.join(", ")}`,
477
543
  );
478
544
  return JSON.stringify(msg);
479
545
  }
@@ -543,22 +609,27 @@ function runCodexPassthrough({
543
609
  logErr(`[codex] ${chunk.toString().trimEnd()}`);
544
610
  });
545
611
 
546
- // Pump client stdin -> codex stdin, splitting on newlines (MCP stdio framing)
547
- // 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.
548
616
  child.stdin.on("error", () => {}); // ignore EPIPE if codex exits early
549
- let stdinBuf = "";
617
+ const NEWLINE = 0x0a;
618
+ let stdinBuf = Buffer.alloc(0);
550
619
  process.stdin.on("data", (chunk) => {
551
- stdinBuf += chunk.toString("utf8");
620
+ stdinBuf = stdinBuf.length ? Buffer.concat([stdinBuf, chunk]) : chunk;
552
621
  let nl;
553
- while ((nl = stdinBuf.indexOf("\n")) !== -1) {
554
- const line = stdinBuf.slice(0, nl);
555
- 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);
556
625
  child.stdin.write(`${filterCodexToolCall(line)}\n`);
557
626
  }
558
627
  });
559
628
  process.stdin.on("error", () => {});
560
629
  process.stdin.on("end", () => {
561
- if (stdinBuf.length > 0) child.stdin.write(filterCodexToolCall(stdinBuf));
630
+ if (stdinBuf.length > 0) {
631
+ child.stdin.write(filterCodexToolCall(stdinBuf.toString("utf8")));
632
+ }
562
633
  child.stdin.end();
563
634
  });
564
635