mcp-agents 0.12.0 → 0.12.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 (3) hide show
  1. package/README.md +15 -5
  2. package/package.json +1 -1
  3. package/server.js +272 -24
package/README.md CHANGED
@@ -88,7 +88,9 @@ or Gemini during bridge calls.
88
88
 
89
89
  Other startup defaults: `sandbox_mode=workspace-write`, `approval_policy=never`
90
90
  (both configurable via `--sandbox_mode` / `--approval_policy`, and steerable per
91
- call); `features.multi_agent=false` is fixed.
91
+ call); `features.multi_agent=false`, `features.apps=false`, and
92
+ `features.plugins=false` are fixed (the latter two keep ChatGPT app/plugin
93
+ skills — Figma, Gmail, Presentations, etc. — out of the bridged session context).
92
94
 
93
95
  Startup flags (`--model`, `--model_reasoning_effort`) set the model and effort for the native Codex MCP server. Per-call `model` and the model/effort keys inside a `config` override are stripped from `tools/call` before they reach Codex, so a client cannot override the pinned model/effort for a single call (`sandbox`, `cwd`, and `approval-policy` — top-level and the matching `config` keys — are intentionally left steerable per call). For example, this request:
94
96
 
@@ -127,10 +129,18 @@ a concise `Reminder — standing objective for this thread: …` preamble on the
127
129
  prompt. Any caller-supplied `developer-instructions` are preserved, with the
128
130
  objective merged ahead of them.
129
131
 
130
- The wrapper-only `goal` argument is always stripped before it reaches Codex (its
131
- schema has no `goal`). A per-call `goal` overrides the `--goal` default for that
132
- call; a per-call empty `goal` (`""`) suppresses the default for that one call; a
133
- non-string `goal` is ignored (the `--goal` default still applies).
132
+ The wrapper-only `goal` argument is always stripped before it reaches Codex (it
133
+ is never a native Codex parameter). A per-call `goal` overrides the `--goal`
134
+ default for that call; a per-call empty `goal` (`""`) suppresses the default for
135
+ that one call; a non-string `goal` is ignored (the `--goal` default still
136
+ applies).
137
+
138
+ So a client's model knows it can pass `goal`, the pass-through advertises it: it
139
+ rewrites its own `tools/list` response to declare an optional `goal` property on
140
+ the `codex` and `codex-reply` tool schemas (models only generate arguments
141
+ declared in a tool's `inputSchema`). Only `properties` is augmented — `required`
142
+ and `additionalProperties` are left intact — and the rewrite touches only the
143
+ `tools/list` response; every other frame is forwarded byte-for-byte.
134
144
 
135
145
  **Precedence within a thread.** The objective set on the initial `codex` call is
136
146
  a developer-role message and persists for the whole thread, so it takes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-agents",
3
- "version": "0.12.0",
3
+ "version": "0.12.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": {
package/server.js CHANGED
@@ -480,6 +480,8 @@ function buildCodexBridgeConfig({
480
480
  `sandbox_mode = ${toTomlString(sandboxMode)}`,
481
481
  "",
482
482
  "[features]",
483
+ "apps = false",
484
+ "plugins = false",
483
485
  "multi_agent = false",
484
486
  "",
485
487
  ].join("\n");
@@ -683,6 +685,48 @@ function filterCodexToolCall(line, opts = {}) {
683
685
  return JSON.stringify(msg);
684
686
  }
685
687
 
688
+ // Tools whose advertised inputSchema gains a wrapper-only `goal` property so a
689
+ // client's model knows it can pass one (the model only emits args declared in
690
+ // the schema). The arg is stripped inbound by filterCodexToolCall before it
691
+ // reaches codex; advertising it here is purely for discoverability.
692
+ const CODEX_GOAL_TOOLS = new Set(["codex", "codex-reply"]);
693
+ const CODEX_GOAL_PROPERTY_DESCRIPTION =
694
+ "Optional standing objective for this Codex session. mcp-agents injects it as " +
695
+ "`developer-instructions` (codex) or a prompt reminder (codex-reply); it is not a " +
696
+ "native Codex parameter. Overrides the server-wide --goal default for this call; " +
697
+ "pass an empty string to suppress that default.";
698
+
699
+ /**
700
+ * Mutate a parsed `tools/list` RESPONSE in place, adding a `goal` property to the
701
+ * advertised inputSchema of the `codex` and `codex-reply` tools. Returns true iff
702
+ * it added `goal` to at least one tool. Only `properties` is touched — `required`
703
+ * and `additionalProperties` are left intact (a declared property is not an
704
+ * "additional" one, so `additionalProperties:false` stays valid). Best-effort per
705
+ * tool: a target tool that already declares `goal` (idempotent) or whose
706
+ * inputSchema.properties is missing/malformed (drifted schema) is simply skipped;
707
+ * other valid targets are still augmented. Returns false (→ the caller forwards the
708
+ * original bytes byte-for-byte) for an error response, a non-array `result.tools`,
709
+ * or when no `codex`/`codex-reply` target was augmentable.
710
+ * @param {any} msg
711
+ * @returns {boolean}
712
+ */
713
+ function injectGoalIntoToolsListMessage(msg) {
714
+ const tools = msg?.result?.tools;
715
+ if (!Array.isArray(tools)) return false;
716
+ let changed = false;
717
+ for (const tool of tools) {
718
+ if (!tool || typeof tool !== "object" || !CODEX_GOAL_TOOLS.has(tool.name)) continue;
719
+ const schema = tool.inputSchema;
720
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) continue;
721
+ const props = schema.properties;
722
+ if (!props || typeof props !== "object" || Array.isArray(props)) continue;
723
+ if ("goal" in props) continue; // idempotent — respect an existing declaration
724
+ props.goal = { type: "string", description: CODEX_GOAL_PROPERTY_DESCRIPTION };
725
+ changed = true;
726
+ }
727
+ return changed;
728
+ }
729
+
686
730
  /**
687
731
  * Spawn codex mcp-server as a pass-through. codex stdout is forwarded back to
688
732
  * the client byte-for-byte, but the client's stdin is intercepted line-by-line
@@ -812,6 +856,18 @@ function runCodexPassthrough({
812
856
  let droppedFrameResponseId; // partial oversized frame's classified id (cleared at its newline)
813
857
  let observationDropLogged = false; // log the first observation-cap drop only
814
858
 
859
+ // ── tools/list goal-advertising rewrite (contained latch) ────────────────
860
+ // While a `tools/list` request id is outstanding the forwarder switches from
861
+ // raw passthrough to buffer-and-rewrite, injecting a `goal` property into the
862
+ // advertised codex/codex-reply schemas of that one response, then returns to
863
+ // raw. Observation above stays the SOLE authority for inFlight/the watchdog;
864
+ // this path only changes HOW bytes reach the wire.
865
+ const pendingToolsListIds = new Set(); // idKey(id) of outstanding tools/list requests (the latch)
866
+ let rewriteBuf = Buffer.alloc(0); // buffer-mode accumulator; holds ≤1 trailing partial after a flush
867
+ let rewriteSkipUntilNewline = false; // forwarding raw to the next newline (oversized frame or mode-boundary align)
868
+ let rewriteSkipReleaseId; // idKey to release when the skipped frame's newline lands (oversized response only)
869
+ let oversizedToolsListLogged = false; // log the first rewrite-cap drop only
870
+
815
871
  const killGroup = (signal) => {
816
872
  try {
817
873
  if (child.pid) process.kill(-child.pid, signal);
@@ -996,10 +1052,53 @@ function runCodexPassthrough({
996
1052
  killGroup("SIGKILL");
997
1053
 
998
1054
  if (emit && hasEmittableInFlight()) {
999
- // Framing recovery: if codex left a dangling partial line on the wire, try
1000
- // to parse it (it may itself be the real response) and terminate it with a
1001
- // newline so the synthetic frame cannot glue onto a half-written line.
1002
- if (stdoutObsBuf.length > 0) {
1055
+ // Framing recovery. Precedence handles bytes WITHHELD by buffer mode (which
1056
+ // the plain stdoutObsBuf recovery would mis-handle). EVERY write here is
1057
+ // try/catch-guarded: finalize runs synchronously from close/exit/idle/signal
1058
+ // handlers, so an unguarded EPIPE would escape into uncaughtException ->
1059
+ // fatalShutdown -> a re-entrant finalize early-return, skipping
1060
+ // flushThenExit/process.exit and hanging the wrapper.
1061
+ if (rewriteSkipUntilNewline) {
1062
+ // Oversized/align mid-skip: head already forwarded raw, remainder
1063
+ // unrecoverable. Discard; the -32001 loop covers the still-open id.
1064
+ rewriteBuf = Buffer.alloc(0);
1065
+ rewriteSkipUntilNewline = false;
1066
+ stdoutObsBuf = Buffer.alloc(0);
1067
+ if (!lastForwardedByteWasNewline) {
1068
+ try { process.stdout.write("\n"); } catch {}
1069
+ lastForwardedByteWasNewline = true;
1070
+ }
1071
+ } else if (rewriteBuf.length > 0) {
1072
+ // A withheld buffered partial (never forwarded). If it parses as a COMPLETE
1073
+ // message (only its trailing newline missing) — possible only when the whole
1074
+ // frame arrived post-latch, so NONE of it is on the wire — deliver it
1075
+ // (rewritten if a pending tools/list response, else raw) + "\n" and clear its
1076
+ // id (no -32001). Otherwise (a mode-boundary tail — pre-empted by the
1077
+ // align-skip — or codex died mid-frame) discard; the -32001 loop covers it.
1078
+ const frameStr = rewriteBuf.toString("utf8");
1079
+ let outStr = null;
1080
+ try {
1081
+ const m = JSON.parse(frameStr);
1082
+ outStr = frameStr;
1083
+ if (
1084
+ m && typeof m === "object" && "id" in m &&
1085
+ ("result" in m || "error" in m) &&
1086
+ pendingToolsListIds.has(idKey(m.id)) && injectGoalIntoToolsListMessage(m)
1087
+ ) {
1088
+ outStr = JSON.stringify(m);
1089
+ }
1090
+ } catch { outStr = null; }
1091
+ rewriteBuf = Buffer.alloc(0);
1092
+ stdoutObsBuf = Buffer.alloc(0);
1093
+ if (outStr !== null) {
1094
+ try { process.stdout.write(`${outStr}\n`); } catch {}
1095
+ observeOutgoingLine(frameStr); // clear its id -> no synthetic error for it
1096
+ lastForwardedByteWasNewline = true;
1097
+ } else if (!lastForwardedByteWasNewline) {
1098
+ try { process.stdout.write("\n"); } catch {}
1099
+ lastForwardedByteWasNewline = true;
1100
+ }
1101
+ } else if (stdoutObsBuf.length > 0) {
1003
1102
  observeOutgoingLine(stdoutObsBuf.toString("utf8"));
1004
1103
  stdoutObsBuf = Buffer.alloc(0);
1005
1104
  try { process.stdout.write("\n"); } catch {}
@@ -1026,6 +1125,12 @@ function runCodexPassthrough({
1026
1125
  }
1027
1126
  }
1028
1127
 
1128
+ // Hygiene: drop the rewrite latch/skip state (forwarding has stopped).
1129
+ pendingToolsListIds.clear();
1130
+ rewriteSkipUntilNewline = false;
1131
+ rewriteSkipReleaseId = undefined;
1132
+ rewriteBuf = Buffer.alloc(0);
1133
+
1029
1134
  flushThenExit(exitCode);
1030
1135
  };
1031
1136
 
@@ -1065,35 +1170,153 @@ function runCodexPassthrough({
1065
1170
  logErr(`[codex] ${chunk.toString().trimEnd()}`);
1066
1171
  });
1067
1172
 
1068
- // Forward codex stdout to the client byte-for-byte (raw Buffer) and keep a
1069
- // parallel observation buffer (split on the newline BYTE) to clear in-flight
1070
- // ids as their responses land. Raw chunks are forwarded; reconstructed lines
1071
- // are never written back.
1173
+ const logRewriteDropOnce = () => {
1174
+ if (!oversizedToolsListLogged) {
1175
+ logErr(
1176
+ "[mcp-agents] codex passthrough: tools/list-window frame exceeded rewrite cap; " +
1177
+ "forwarding raw (goal not advertised on this response)",
1178
+ );
1179
+ oversizedToolsListLogged = true;
1180
+ }
1181
+ };
1182
+
1183
+ // Raw forward of one buffer plus the existing first-`!ok` backpressure handling
1184
+ // (pause codex + suspend the watchdog until drain). Returns the write result.
1185
+ // Used by BOTH the raw fast path and buffer mode, so the wire-state tracking and
1186
+ // backpressure contract live in exactly one place.
1187
+ const forwardChunk = (buf) => {
1188
+ if (buf.length === 0) return true;
1189
+ lastForwardedByteWasNewline = buf[buf.length - 1] === NEWLINE;
1190
+ const ok = process.stdout.write(buf);
1191
+ if (!ok && !stdoutPaused) {
1192
+ // Downstream full: pause codex and suspend the idle watchdog until the
1193
+ // client drains, so a slow reader is never mistaken for a stalled codex.
1194
+ stdoutPaused = true;
1195
+ clearIdle();
1196
+ child.stdout.pause();
1197
+ }
1198
+ return ok;
1199
+ };
1200
+
1201
+ // Once no tools/list id is outstanding (and not mid-skip), a trailing partial in
1202
+ // rewriteBuf is a NON-tools/list frame (no response expected), so it must not stay
1203
+ // withheld in buffer mode — raw mode forwards partials as they arrive, and
1204
+ // withholding it would byte-lose it if codex dies before its newline. Forward it
1205
+ // raw and drop back to the fast path. Called from BOTH paths that can clear the
1206
+ // latch: the end of flushRewriteBuf (a response completed) and noteInbound's
1207
+ // cancel branch (a tools/list was canceled on stdin, which never runs the flush).
1208
+ const returnToRawIfLatchClear = () => {
1209
+ if (
1210
+ !finalizing && pendingToolsListIds.size === 0 &&
1211
+ !rewriteSkipUntilNewline && rewriteBuf.length > 0
1212
+ ) {
1213
+ forwardChunk(rewriteBuf);
1214
+ rewriteBuf = Buffer.alloc(0);
1215
+ }
1216
+ };
1217
+
1218
+ // Flush every COMPLETE frame from rewriteBuf, rewriting only the matched
1219
+ // tools/list response and forwarding everything else byte-for-byte. NEVER
1220
+ // early-returns on backpressure: forwardChunk pauses codex on the first `!ok`,
1221
+ // but this chunk's frames are all queued (Node buffers regardless), so no
1222
+ // COMPLETE frame is ever stranded — exactly today's "one write(chunk), then
1223
+ // pause the source" semantics. After this returns rewriteBuf holds at most one
1224
+ // trailing INCOMPLETE partial.
1225
+ const flushRewriteBuf = () => {
1226
+ if (rewriteSkipUntilNewline) {
1227
+ const nl = rewriteBuf.indexOf(NEWLINE);
1228
+ if (nl === -1) {
1229
+ // Still inside the skipped/aligned frame: forward it all raw, stay skipping.
1230
+ forwardChunk(rewriteBuf);
1231
+ rewriteBuf = Buffer.alloc(0);
1232
+ return;
1233
+ }
1234
+ forwardChunk(rewriteBuf.subarray(0, nl + 1)); // forward through the newline raw
1235
+ rewriteBuf = rewriteBuf.subarray(nl + 1);
1236
+ if (rewriteSkipReleaseId !== undefined) {
1237
+ pendingToolsListIds.delete(rewriteSkipReleaseId);
1238
+ rewriteSkipReleaseId = undefined;
1239
+ }
1240
+ rewriteSkipUntilNewline = false;
1241
+ }
1242
+ let nl;
1243
+ while ((nl = rewriteBuf.indexOf(NEWLINE)) !== -1) {
1244
+ const frameBytes = rewriteBuf.subarray(0, nl + 1); // original bytes incl. delimiter
1245
+ rewriteBuf = rewriteBuf.subarray(nl + 1); // consume-first: never re-forward, never wedge
1246
+ if (nl > MAX_BUFFER_BYTES) {
1247
+ // Complete frame larger than the cap: forward raw without parsing (mirrors
1248
+ // observeOutgoing's oversized branch), releasing only a matching pending id.
1249
+ logRewriteDropOnce();
1250
+ const pid = peekResponseId(frameBytes);
1251
+ if (pid !== undefined && pendingToolsListIds.has(idKey(pid))) {
1252
+ pendingToolsListIds.delete(idKey(pid));
1253
+ }
1254
+ forwardChunk(frameBytes);
1255
+ continue;
1256
+ }
1257
+ let outBuf = frameBytes; // default: byte-for-byte
1258
+ try {
1259
+ const msg = JSON.parse(
1260
+ frameBytes.subarray(0, frameBytes.length - 1).toString("utf8"),
1261
+ );
1262
+ if (
1263
+ msg && typeof msg === "object" && "id" in msg &&
1264
+ ("result" in msg || "error" in msg) &&
1265
+ pendingToolsListIds.has(idKey(msg.id))
1266
+ ) {
1267
+ pendingToolsListIds.delete(idKey(msg.id));
1268
+ if (injectGoalIntoToolsListMessage(msg)) {
1269
+ outBuf = Buffer.from(`${JSON.stringify(msg)}\n`, "utf8");
1270
+ }
1271
+ }
1272
+ } catch {
1273
+ outBuf = frameBytes; // unparseable (mode-boundary tail / partial) — forward original bytes
1274
+ }
1275
+ forwardChunk(outBuf);
1276
+ }
1277
+ if (rewriteBuf.length > MAX_BUFFER_BYTES) {
1278
+ // Partial frame already past the cap with no newline: abandon rewriting for
1279
+ // THIS frame, forward what we have raw, and skip to its newline. Release only
1280
+ // a matching id, deferred to that newline.
1281
+ logRewriteDropOnce();
1282
+ const pid = peekResponseId(rewriteBuf);
1283
+ rewriteSkipReleaseId =
1284
+ pid !== undefined && pendingToolsListIds.has(idKey(pid)) ? idKey(pid) : undefined;
1285
+ forwardChunk(rewriteBuf);
1286
+ rewriteBuf = Buffer.alloc(0);
1287
+ rewriteSkipUntilNewline = true;
1288
+ }
1289
+ // Latch boundary: a response just completed may have emptied the latch — if so,
1290
+ // flush any trailing NON-tools/list partial raw and return to the fast path.
1291
+ returnToRawIfLatchClear();
1292
+ };
1293
+ const bufferModeForward = (chunk) => {
1294
+ rewriteBuf = rewriteBuf.length ? Buffer.concat([rewriteBuf, chunk]) : chunk;
1295
+ flushRewriteBuf();
1296
+ };
1297
+
1298
+ // Forward codex stdout to the client. Steady state is a byte-for-byte raw
1299
+ // passthrough (forwardChunk); while a tools/list response is pending the
1300
+ // forwarder buffers and rewrites that one frame (bufferModeForward) to advertise
1301
+ // `goal`. Observation runs on the ORIGINAL bytes and stays the sole authority for
1302
+ // clearing in-flight ids — by the time it runs, every complete frame in this
1303
+ // chunk was already forwarded/queued, so it never leads forwarding.
1072
1304
  child.stdout.on("data", (chunk) => {
1073
1305
  if (finalizing) return; // stream ownership has been taken over
1074
- resetIdle();
1306
+ resetIdle(); // UNCONDITIONAL, before the mode branch — buffer-mode activity must keep the watchdog alive
1075
1307
 
1076
- // Forward the raw bytes FIRST so a bug in observation can never affect the
1077
- // byte-for-byte passthrough (observation is best-effort id-tracking only).
1078
- if (chunk.length > 0) {
1079
- lastForwardedByteWasNewline = chunk[chunk.length - 1] === NEWLINE;
1308
+ if (pendingToolsListIds.size > 0 || rewriteBuf.length > 0 || rewriteSkipUntilNewline) {
1309
+ bufferModeForward(chunk);
1310
+ } else {
1311
+ forwardChunk(chunk);
1080
1312
  }
1081
- const ok = process.stdout.write(chunk);
1313
+
1082
1314
  try {
1083
1315
  observeOutgoing(chunk); // bounded parse-for-ids; never alters forwarded bytes
1084
1316
  } catch (err) {
1085
1317
  const msg = err instanceof Error ? err.message : String(err);
1086
1318
  logErr(`[mcp-agents] codex passthrough: stdout observation error (ignored): ${msg}`);
1087
1319
  }
1088
- if (!ok) {
1089
- // Downstream full: pause codex and suspend the idle watchdog until the
1090
- // client drains, so a slow reader is never mistaken for a stalled codex.
1091
- // Trade-off: a client that never drains keeps the request open with no
1092
- // watchdog — but a synthetic error could not be delivered to it anyway.
1093
- stdoutPaused = true;
1094
- clearIdle();
1095
- child.stdout.pause();
1096
- }
1097
1320
  });
1098
1321
 
1099
1322
  process.stdout.on("drain", () => {
@@ -1130,7 +1353,16 @@ function runCodexPassthrough({
1130
1353
  // so even an elicitation response — bare id, no method — keeps a healthy
1131
1354
  // interactive flow alive.)
1132
1355
  if (msg.method === "notifications/cancelled") {
1133
- cancelInFlight(msg.params?.requestId);
1356
+ const rid = msg.params?.requestId;
1357
+ cancelInFlight(rid);
1358
+ // A canceled/never-answered tools/list must not wedge buffer mode open. If
1359
+ // this cancel cleared the last pending tools/list id while a NON-tools/list
1360
+ // partial is withheld in rewriteBuf, flush it raw — otherwise a codex exit
1361
+ // with only-canceled work would drop those bytes (finalize skips recovery).
1362
+ if (rid != null) {
1363
+ pendingToolsListIds.delete(idKey(rid));
1364
+ returnToRawIfLatchClear();
1365
+ }
1134
1366
  return;
1135
1367
  }
1136
1368
  // A client message awaits a response iff it carries BOTH an id and a method.
@@ -1138,6 +1370,22 @@ function runCodexPassthrough({
1138
1370
  // for in-flight tracking.
1139
1371
  if (msg.id != null && typeof msg.method === "string") {
1140
1372
  addInFlight(msg.id);
1373
+ if (msg.method === "tools/list") {
1374
+ // Arm the goal-advertising rewrite latch for this tools/list response. If
1375
+ // buffer mode would START mid-frame (a pre-latch frame's head was already
1376
+ // raw-forwarded and its newline hasn't arrived), first align by raw-skipping
1377
+ // the orphan tail to its next newline — so the tail is forwarded
1378
+ // byte-for-byte and never mis-parsed as a standalone frame nor byte-lost at
1379
+ // finalize. Equivalent to today's raw behaviour for that straddled frame.
1380
+ if (
1381
+ pendingToolsListIds.size === 0 && rewriteBuf.length === 0 &&
1382
+ !rewriteSkipUntilNewline && !lastForwardedByteWasNewline
1383
+ ) {
1384
+ rewriteSkipUntilNewline = true;
1385
+ rewriteSkipReleaseId = undefined;
1386
+ }
1387
+ pendingToolsListIds.add(idKey(msg.id));
1388
+ }
1141
1389
  }
1142
1390
  };
1143
1391