Skip to content

CLI setup silently drops pinned @upstash/context7-mcp version on re-setup when Codex config.toml arrays are multi-line or have trailing commas (readTomlServerEntry drops args) #3060

Description

@wallidsaydi-creator

Summary

readTomlServerEntry in packages/cli/src/setup/mcp-writer.ts parses each key = value line of the [mcp_servers.<name>] block with JSON.parse one line at a time. Two classes of valid TOML therefore fail to parse and the key is silently dropped from the entry:

  1. Multi-line arraysargs = [] split across lines (what taplo, cargo-fmt, Prettier-TOML, and many editors produce once a line exceeds the print width — which any real api-key does)
  2. Trailing commas — TOML permits ["a", "b",]; JSON does not (breaks even single-line arrays)

Impact

This breaks the documented stdio-preservation path in resolveEntryToWrite (commands/setup.ts): when an existing stdio @upstash/context7-mcp entry is detected, setup preserves the user's pinned version and flags and only swaps --api-key. But detection goes through isStdioContext7Entry(existingEntry), which needs args to contain the package name. With args silently missing, detection fails and setup falls back to a fresh canonical entry — silently wiping a user's pinned version (e.g. @upstash/context7-mcp@0.6.0 → floating @upstash/context7-mcp), custom cwd, extra flags, or env settings on re-setup.

The failure is silent on both ends: setup reports reconfigured, the user's pin is gone.

Repro (verified on main @ c324828)

~/.codex/config.toml (taplo/Prettier-formatted, api-key makes the array exceed print width):

model = "gpt-5.2-codex"

[mcp_servers.context7]
command = "npx"
args = [
  "-y",
  "@upstash/context7-mcp@0.6.0",
  "--api-key",
  "ctx7sk_live_OLD_old_old_old_old_old_old",
]

[mcp_servers.filesystem]
command = "uvx"
args = ["mcp-server-filesystem", "/tmp"]

Run ctx7 setup --codex --stdio (e.g. to rotate the API key). Expected: pin @0.6.0 preserved, key swapped. Actual config afterwards:

[mcp_servers.context7]
command = "npx"
args = ["-y","@upstash/context7-mcp","--api-key","ctx7sk_live_NEW"]

The pinned version is gone. Unit-level: readTomlServerEntry returns {"command":"npx"} — no args at all.

Root cause

lineRe = /^([A-Za-z_][\w-]*)\s*=\s*(.+?)\s*$/gm matches per line; a multi-line array's first line is just args = [, and even a complete single-line array with a trailing comma fails JSON.parse. The catch swallows it, so the key vanishes instead of erroring.

Fix

Minimal, no new dependency (the CLI has no TOML parser dep and the doc comment already scopes this function to string/array values):

  • If a value starts with [ and brackets (outside double-quoted strings) aren't balanced on the line, consume subsequent lines until they balance, then parse the joined text (JSON allows newlines as whitespace inside arrays).
  • Strip string-aware trailing commas before JSON.parse (TOML allows them, JSON doesn't).

Both helpers track double-quoted-string state, so brackets/commas inside values like "NODE_ENV=[global]" are not misread.

diff

diff --git a/packages/cli/src/setup/mcp-writer.ts b/packages/cli/src/setup/mcp-writer.ts
index 1c9ddf1..c16236f 100644
--- a/packages/cli/src/setup/mcp-writer.ts
+++ b/packages/cli/src/setup/mcp-writer.ts
@@ -119,10 +119,69 @@ export async function readTomlServerExists(filePath: string, serverName: string)
/**

  • Reads the top-level [mcp_servers.<serverName>] block from a TOML config
  • file and parses its key-value lines into a JS object. Handles string and
    • array values (TOML array syntax is JSON-compatible). Sub-tables like
    • array values (TOML array syntax is JSON-compatible), including arrays
    • formatted across multiple lines. Sub-tables like
    • [mcp_servers.<serverName>.http_headers] are ignored. Returns undefined
    • if the file or section is missing.
      /
      +/
      *
    • True when every [ outside a double-quoted string has a matching ].
    • Multi-line arrays (taplo / cargo-fmt style) only parse once closed; TOML
    • basic strings cannot contain an unescaped quote, so tracking string state
    • is enough to avoid mis-counting brackets inside values like "[global]".
  • */
    +function bracketsBalancedOutsideStrings(text: string): boolean {
  • let inString = false;
  • let depth = 0;
  • for (let i = 0; i < text.length; i++) {
  • const ch = text[i];
  • if (inString) {
  •  if (ch === "\\") i++;
    
  •  else if (ch === '"') inString = false;
    
  •  continue;
    
  • }
  • if (ch === '"') inString = true;
  • else if (ch === "[") depth++;
  • else if (ch === "]") depth--;
  • }
  • return depth <= 0 && !inString;
    +}

+/**

    • Removes commas that directly precede a closing ] outside double-quoted
    • strings. TOML permits trailing commas in arrays; JSON does not, so
    • ["-y", "pkg",] fails JSON.parse and the whole value would be dropped.
  • */
    +function stripTrailingCommasOutsideStrings(text: string): string {
  • let result = "";
  • let inString = false;
  • for (let i = 0; i < text.length; i++) {
  • const ch = text[i];
  • if (inString) {
  •  result += ch;
    
  •  if (ch === "\\") {
    
  •    i++;
    
  •    if (i < text.length) result += text[i];
    
  •  } else if (ch === '"') {
    
  •    inString = false;
    
  •  }
    
  •  continue;
    
  • }
  • if (ch === '"') {
  •  inString = true;
    
  •  result += ch;
    
  •  continue;
    
  • }
  • if (ch === ",") {
  •  let j = i + 1;
    
  •  while (j < text.length && /\s/.test(text[j])) j++;
    
  •  if (j < text.length && text[j] === "]") continue; // drop the comma
    
  • }
  • result += ch;
  • }
  • return result;
    +}

export async function readTomlServerEntry(
filePath: string,
serverName: string
@@ -147,12 +206,29 @@ export async function readTomlServerEntry(
const block = nextHeader ? rest.slice(0, nextHeader.index) : rest;

const entry: Record<string, unknown> = {};

  • const lineRe = /^([A-Za-z_][\w-])\s=\s*(.+?)\s*$/gm;
  • let lineMatch: RegExpExecArray | null;
  • while ((lineMatch = lineRe.exec(block)) !== null) {
  • const lineRe = /^([A-Za-z_][\w-])\s=\s*(.+?)\s*$/;
  • const lines = block.split("\n");
  • for (let i = 0; i < lines.length; i++) {
  • const lineMatch = lineRe.exec(lines[i]);
  • if (!lineMatch) continue;
    const [, key, valueText] = lineMatch;
  • // Multi-line array (formatted across lines by taplo, cargo fmt, or by
  • // hand): consume lines until brackets balance, then parse the joined
  • // text. JSON allows newlines as whitespace inside arrays, so the joined
  • // text parses directly. Without this, args silently disappears from
  • // the entry and callers treat the server as freshly configured.
  • let fullText = valueText;
  • if (valueText.startsWith("[") && !bracketsBalancedOutsideStrings(valueText)) {
  •  const parts = [valueText];
    
  •  while (i + 1 < lines.length && !bracketsBalancedOutsideStrings(parts.join("\n"))) {
    
  •    parts.push(lines[++i].trim());
    
  •  }
    
  •  fullText = parts.join("\n");
    
  • }
  • try {
  •  entry[key] = JSON.parse(valueText);
    
  •  entry[key] = JSON.parse(stripTrailingCommasOutsideStrings(fullText));
    
    } catch {
    // Skip values we can't parse as JSON (e.g., bare TOML numbers like 20)
    }

Verification

  • Repro test (multi-line + single-line control) fails on main, passes with fix
  • End-to-end test replicating setup.ts's exact readTomlServerEntry → isStdioContext7Entry → patchStdioApiKey → appendTomlServer sequence: pin preserved, only key swapped
  • Edge tests: single-line trailing comma (also silently broken on main), bracket-inside-string regression guard
  • Full CLI suite: 254/254 pass; tsc --noEmit clean

Context7 is my daily docs-fetch layer, so this one stung — kept the diff minimal and scoped to the parser. If useful, I also do repo-specific context-pack audits (AGENTS.md/CLAUDE.md freshness, deprecated-pattern traps, verification commands) — FreshContext Pack, $5, sample: deploy-foorge-team.vercel.app/sample-fresh-context-pack.html.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions