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:
- Multi-line arrays —
args = [ … ] 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)
- 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) {
-
-
else if (ch === '"') inString = false;
-
- }
- 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) {
-
-
-
-
if (i < text.length) result += text[i];
-
-
-
-
- }
- if (ch === '"') {
-
-
-
- }
- if (ch === ",") {
-
-
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.
Summary
readTomlServerEntryinpackages/cli/src/setup/mcp-writer.tsparses eachkey = valueline of the[mcp_servers.<name>]block withJSON.parseone line at a time. Two classes of valid TOML therefore fail to parse and the key is silently dropped from the entry:args = […]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)["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-mcpentry is detected, setup preserves the user's pinned version and flags and only swaps--api-key. But detection goes throughisStdioContext7Entry(existingEntry), which needsargsto contain the package name. Withargssilently 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), customcwd, 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):Run
ctx7 setup --codex --stdio(e.g. to rotate the API key). Expected: pin@0.6.0preserved, key swapped. Actual config afterwards:The pinned version is gone. Unit-level:
readTomlServerEntryreturns{"command":"npx"}— noargsat all.Root cause
lineRe = /^([A-Za-z_][\w-]*)\s*=\s*(.+?)\s*$/gmmatches per line; a multi-line array's first line is justargs = [, and even a complete single-line array with a trailing comma failsJSON.parse. Thecatchswallows 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):
[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).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)
/**
[mcp_servers.<serverName>]block from a TOML config[mcp_servers.<serverName>.http_headers]are ignored. Returns undefined/
+/*
[outside a double-quoted string has a matching].+function bracketsBalancedOutsideStrings(text: string): boolean {
+}
+/**
]outside double-quoted["-y", "pkg",]fails JSON.parse and the whole value would be dropped.+function stripTrailingCommasOutsideStrings(text: string): string {
+}
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 [, key, valueText] = lineMatch;
argssilently disappears from// Skip values we can't parse as JSON (e.g., bare TOML numbers like 20)
}
Verification
setup.ts's exactreadTomlServerEntry → isStdioContext7Entry → patchStdioApiKey → appendTomlServersequence: pin preserved, only key swappedtsc --noEmitcleanContext7 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.