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