Skip to content

Commit c103bfd

Browse files
anandgupta42claude
andauthored
fix: normalize mcpServers config key to mcp and transform external MCP formats (#639)
* fix: normalize `mcpServers` config key to `mcp` and transform external MCP formats - Auto-normalize top-level `mcpServers` → `mcp` (used by Claude Code, Cursor, `.mcp.json`) - Transform external MCP server format: string `command` + `args` + `env` → array `command` + `environment` - Handle remote servers without explicit `type` field (infer from `url` presence) - Always delete `mcpServers` to prevent strict schema rejection (even when `mcp` exists) - Normalize typed entries that use external field names (`env`, string `command`) - Add 5 tests: string command, array command, both-keys conflict, remote server, typed entry normalization Closes #638 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add adversarial tests for `mcpServers` config normalization - Empty `mcpServers` object - Non-object `mcpServers` value (string) - Null server entries removed during normalization - Numeric/boolean args coerced to strings - String `args` field wrapped in array - Disabled server entry (`{ enabled: false }`) - `environment` takes precedence over `env` - `timeout` and `enabled` fields preserved - Mixed local and remote servers - Special characters in server names Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd0c323 commit c103bfd

2 files changed

Lines changed: 508 additions & 14 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ export namespace Config {
143143
for (const dir of unique(directories)) {
144144
// altimate_change start - support both .altimate-code and .opencode config dirs
145145
if (dir.endsWith(".altimate-code") || dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
146-
// altimate_change end
146+
// altimate_change end
147147
// altimate_change start - support altimate-code.json config filename
148148
for (const file of ["altimate-code.json", "opencode.jsonc", "opencode.json"]) {
149-
// altimate_change end
149+
// altimate_change end
150150
log.debug(`loading config from ${path.join(dir, file)}`)
151151
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
152152
// to satisfy the type checker
@@ -214,7 +214,7 @@ export namespace Config {
214214
if (existsSync(managedDir)) {
215215
// altimate_change start - support altimate-code.json config filename
216216
for (const file of ["altimate-code.json", "opencode.jsonc", "opencode.json"]) {
217-
// altimate_change end
217+
// altimate_change end
218218
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
219219
}
220220
}
@@ -1237,7 +1237,9 @@ export namespace Config {
12371237
enabled: z
12381238
.boolean()
12391239
.optional()
1240-
.describe("Enable session tracing (default: true). Traces are saved locally and can be viewed with `altimate-code trace`."),
1240+
.describe(
1241+
"Enable session tracing (default: true). Traces are saved locally and can be viewed with `altimate-code trace`.",
1242+
),
12411243
dir: z
12421244
.string()
12431245
.optional()
@@ -1247,7 +1249,9 @@ export namespace Config {
12471249
.int()
12481250
.nonnegative()
12491251
.optional()
1250-
.describe("Maximum number of trace files to keep. 0 for unlimited. Oldest files are removed when exceeded (default: 100)."),
1252+
.describe(
1253+
"Maximum number of trace files to keep. 0 for unlimited. Oldest files are removed when exceeded (default: 100).",
1254+
),
12511255
exporters: z
12521256
.array(
12531257
z.object({
@@ -1292,13 +1296,17 @@ export namespace Config {
12921296
env_fingerprint_skill_selection: z
12931297
.boolean()
12941298
.optional()
1295-
.describe("Use environment fingerprint to select relevant skills once per session (default: false). Set to true to enable LLM-based skill filtering."),
1299+
.describe(
1300+
"Use environment fingerprint to select relevant skills once per session (default: false). Set to true to enable LLM-based skill filtering.",
1301+
),
12961302
// altimate_change end
12971303
// altimate_change start - auto MCP discovery toggle
12981304
auto_mcp_discovery: z
12991305
.boolean()
13001306
.default(true)
1301-
.describe("Auto-discover MCP servers from VS Code, Claude Code, Copilot, and Gemini configs at startup. Set to false to disable."),
1307+
.describe(
1308+
"Auto-discover MCP servers from VS Code, Claude Code, Copilot, and Gemini configs at startup. Set to false to disable.",
1309+
),
13021310
// altimate_change end
13031311
})
13041312
.optional(),
@@ -1351,6 +1359,68 @@ export namespace Config {
13511359
return load(text, { path: filepath })
13521360
}
13531361

1362+
// altimate_change start — shared normalization for external MCP config formats
1363+
/**
1364+
* Normalize a raw config object to handle common misconfigurations:
1365+
* 1. Top-level "mcpServers" key → "mcp" (used by Claude Code, Cursor, etc.)
1366+
* 2. Individual server entries in external format (string command + args + env)
1367+
* → altimate-code format (command array + environment)
1368+
*
1369+
* Returns a new object with normalized config, leaving the original unchanged.
1370+
* This prevents disk mutation when configs are written back via updateGlobal().
1371+
*/
1372+
function normalizeMcpConfig(data: Record<string, unknown>, source: string): Record<string, unknown> {
1373+
const result = { ...data }
1374+
// Normalize top-level key — always delete mcpServers to prevent strict schema rejection
1375+
if ("mcpServers" in result) {
1376+
if (!("mcp" in result)) {
1377+
result.mcp = result.mcpServers
1378+
log.warn("'mcpServers' is not a valid config key; use 'mcp' instead — auto-correcting", { path: source })
1379+
} else {
1380+
log.debug("Both 'mcp' and 'mcpServers' exist; ignoring 'mcpServers'", { path: source })
1381+
}
1382+
delete result.mcpServers
1383+
}
1384+
// Normalize individual MCP server entries from external formats
1385+
if (result.mcp && typeof result.mcp === "object" && !Array.isArray(result.mcp)) {
1386+
const servers = { ...(result.mcp as Record<string, any>) }
1387+
for (const [name, entry] of Object.entries(servers)) {
1388+
if (!entry || typeof entry !== "object") {
1389+
delete servers[name]
1390+
continue
1391+
}
1392+
// Build a normalized entry — handles both untyped and typed entries with external fields
1393+
if (entry.command || entry.args) {
1394+
const cmd = Array.isArray(entry.command)
1395+
? entry.command.map(String)
1396+
: [
1397+
String(entry.command),
1398+
...(Array.isArray(entry.args)
1399+
? entry.args.map(String)
1400+
: typeof entry.args === "string"
1401+
? [entry.args]
1402+
: []),
1403+
]
1404+
const transformed: Record<string, any> = { type: "local", command: cmd }
1405+
if (entry.env && typeof entry.env === "object") transformed.environment = entry.env
1406+
if (entry.environment && typeof entry.environment === "object") transformed.environment = entry.environment
1407+
if (typeof entry.timeout === "number") transformed.timeout = entry.timeout
1408+
if (typeof entry.enabled === "boolean") transformed.enabled = entry.enabled
1409+
servers[name] = transformed
1410+
} else if (entry.url && typeof entry.url === "string") {
1411+
const transformed: Record<string, any> = { type: "remote", url: entry.url }
1412+
if (entry.headers && typeof entry.headers === "object") transformed.headers = entry.headers
1413+
if (typeof entry.timeout === "number") transformed.timeout = entry.timeout
1414+
if (typeof entry.enabled === "boolean") transformed.enabled = entry.enabled
1415+
servers[name] = transformed
1416+
}
1417+
}
1418+
result.mcp = servers
1419+
}
1420+
return result
1421+
}
1422+
// altimate_change end
1423+
13541424
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
13551425
const original = text
13561426
const source = "path" in options ? options.path : options.source
@@ -1364,12 +1434,15 @@ export namespace Config {
13641434
if (!data || typeof data !== "object" || Array.isArray(data)) return data
13651435
const copy = { ...(data as Record<string, unknown>) }
13661436
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
1367-
if (!hadLegacy) return copy
1368-
delete copy.theme
1369-
delete copy.keybinds
1370-
delete copy.tui
1371-
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
1372-
return copy
1437+
if (hadLegacy) {
1438+
delete copy.theme
1439+
delete copy.keybinds
1440+
delete copy.tui
1441+
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
1442+
}
1443+
// altimate_change start — normalize mcpServers to mcp (common key used by other AI tools)
1444+
return normalizeMcpConfig(copy, source)
1445+
// altimate_change end
13731446
})()
13741447

13751448
const parsed = Info.safeParse(normalized)
@@ -1489,7 +1562,14 @@ export namespace Config {
14891562
})
14901563
}
14911564

1492-
const parsed = Info.safeParse(data)
1565+
// altimate_change start — normalize mcpServers to mcp in parseConfig
1566+
const normalized =
1567+
data && typeof data === "object" && !Array.isArray(data)
1568+
? normalizeMcpConfig(data as Record<string, unknown>, filepath)
1569+
: data
1570+
// altimate_change end
1571+
1572+
const parsed = Info.safeParse(normalized)
14931573
if (parsed.success) return parsed.data
14941574

14951575
throw new InvalidError({

0 commit comments

Comments
 (0)