Skip to content

Commit 2a5ca60

Browse files
anandgupta42claude
andcommitted
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>
1 parent 31d163e commit 2a5ca60

2 files changed

Lines changed: 262 additions & 14 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 91 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,65 @@ 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") continue
1389+
// Build a normalized entry — Handles both untyped and typed entries with external fields
1390+
if (entry.command || entry.args) {
1391+
const cmd = Array.isArray(entry.command)
1392+
? entry.command.map(String)
1393+
: [
1394+
String(entry.command),
1395+
...(Array.isArray(entry.args)
1396+
? entry.args.map(String)
1397+
: typeof entry.args === "string"
1398+
? [entry.args]
1399+
: []),
1400+
]
1401+
const transformed: Record<string, any> = { type: "local", command: cmd }
1402+
if (entry.env && typeof entry.env === "object") transformed.environment = entry.env
1403+
if (entry.environment && typeof entry.environment === "object") transformed.environment = entry.environment
1404+
if (typeof entry.timeout === "number") transformed.timeout = entry.timeout
1405+
if (typeof entry.enabled === "boolean") transformed.enabled = entry.enabled
1406+
servers[name] = transformed
1407+
} else if (entry.url && typeof entry.url === "string") {
1408+
const transformed: Record<string, any> = { type: "remote", url: entry.url }
1409+
if (entry.headers && typeof entry.headers === "object") transformed.headers = entry.headers
1410+
if (typeof entry.timeout === "number") transformed.timeout = entry.timeout
1411+
if (typeof entry.enabled === "boolean") transformed.enabled = entry.enabled
1412+
servers[name] = transformed
1413+
}
1414+
}
1415+
result.mcp = servers
1416+
}
1417+
return result
1418+
}
1419+
// altimate_change end
1420+
13541421
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
13551422
const original = text
13561423
const source = "path" in options ? options.path : options.source
@@ -1364,12 +1431,15 @@ export namespace Config {
13641431
if (!data || typeof data !== "object" || Array.isArray(data)) return data
13651432
const copy = { ...(data as Record<string, unknown>) }
13661433
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
1434+
if (hadLegacy) {
1435+
delete copy.theme
1436+
delete copy.keybinds
1437+
delete copy.tui
1438+
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
1439+
}
1440+
// altimate_change start — normalize mcpServers to mcp (common key used by other AI tools)
1441+
return normalizeMcpConfig(copy, source)
1442+
// altimate_change end
13731443
})()
13741444

13751445
const parsed = Info.safeParse(normalized)
@@ -1489,7 +1559,14 @@ export namespace Config {
14891559
})
14901560
}
14911561

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

14951572
throw new InvalidError({

packages/opencode/test/config/config.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,150 @@ test("handles file inclusion with replacement tokens", async () => {
343343
})
344344
})
345345

346+
test("normalizes mcpServers key to mcp", async () => {
347+
await using tmp = await tmpdir({
348+
init: async (dir) => {
349+
await writeConfig(dir, {
350+
mcpServers: {
351+
gitlab: {
352+
command: "npx",
353+
args: ["-y", "@modelcontextprotocol/server-gitlab"],
354+
env: {
355+
GITLAB_PERSONAL_ACCESS_TOKEN: "my-token",
356+
GITLAB_API_URL: "https://gitlab.com/api/v4",
357+
},
358+
},
359+
},
360+
})
361+
},
362+
})
363+
await Instance.provide({
364+
directory: tmp.path,
365+
fn: async () => {
366+
const config = await Config.get()
367+
expect(config.mcp).toBeDefined()
368+
expect(config.mcp!.gitlab).toBeDefined()
369+
const gitlab = config.mcp!.gitlab as any
370+
expect(gitlab.type).toBe("local")
371+
expect(gitlab.command).toEqual(["npx", "-y", "@modelcontextprotocol/server-gitlab"])
372+
expect(gitlab.environment).toEqual({
373+
GITLAB_PERSONAL_ACCESS_TOKEN: "my-token",
374+
GITLAB_API_URL: "https://gitlab.com/api/v4",
375+
})
376+
},
377+
})
378+
})
379+
380+
test("normalizes mcpServers with array command format", async () => {
381+
await using tmp = await tmpdir({
382+
init: async (dir) => {
383+
await writeConfig(dir, {
384+
mcpServers: {
385+
myserver: {
386+
command: ["node", "server.js"],
387+
env: { PORT: "3000" },
388+
},
389+
},
390+
})
391+
},
392+
})
393+
await Instance.provide({
394+
directory: tmp.path,
395+
fn: async () => {
396+
const config = await Config.get()
397+
const server = config.mcp!.myserver as any
398+
expect(server.type).toBe("local")
399+
expect(server.command).toEqual(["node", "server.js"])
400+
expect(server.environment).toEqual({ PORT: "3000" })
401+
},
402+
})
403+
})
404+
405+
test("does not overwrite existing mcp key with mcpServers", async () => {
406+
await using tmp = await tmpdir({
407+
init: async (dir) => {
408+
await writeConfig(dir, {
409+
mcp: {
410+
existing: {
411+
type: "local",
412+
command: ["node", "existing.js"],
413+
},
414+
},
415+
mcpServers: {
416+
shouldbeignored: {
417+
command: "node",
418+
args: ["ignored.js"],
419+
},
420+
},
421+
})
422+
},
423+
})
424+
await Instance.provide({
425+
directory: tmp.path,
426+
fn: async () => {
427+
// When both mcp and mcpServers exist, mcpServers is silently dropped
428+
// and mcp is preserved. No validation error.
429+
const config = await Config.get()
430+
expect(config.mcp).toBeDefined()
431+
expect(config.mcp!.existing).toBeDefined()
432+
expect((config.mcp! as any).shouldbeignored).toBeUndefined()
433+
},
434+
})
435+
})
436+
437+
test("normalizes remote server without type field", async () => {
438+
await using tmp = await tmpdir({
439+
init: async (dir) => {
440+
await writeConfig(dir, {
441+
mcpServers: {
442+
myremote: {
443+
url: "https://example.com/mcp",
444+
headers: { Authorization: "Bearer token" },
445+
},
446+
},
447+
})
448+
},
449+
})
450+
await Instance.provide({
451+
directory: tmp.path,
452+
fn: async () => {
453+
const config = await Config.get()
454+
const server = config.mcp!.myremote as any
455+
expect(server.type).toBe("remote")
456+
expect(server.url).toBe("https://example.com/mcp")
457+
expect(server.headers).toEqual({ Authorization: "Bearer token" })
458+
},
459+
})
460+
})
461+
462+
test("normalizes typed entry with external field names", async () => {
463+
await using tmp = await tmpdir({
464+
init: async (dir) => {
465+
await writeConfig(dir, {
466+
mcpServers: {
467+
myserver: {
468+
type: "local",
469+
command: "npx",
470+
args: ["-y", "some-server"],
471+
env: { TOKEN: "abc" },
472+
},
473+
},
474+
})
475+
},
476+
})
477+
await Instance.provide({
478+
directory: tmp.path,
479+
fn: async () => {
480+
// Even with type: "local", string command + args + env should be normalized
481+
const config = await Config.get()
482+
const server = config.mcp!.myserver as any
483+
expect(server.type).toBe("local")
484+
expect(server.command).toEqual(["npx", "-y", "some-server"])
485+
expect(server.environment).toEqual({ TOKEN: "abc" })
486+
},
487+
})
488+
})
489+
346490
test("validates config schema and throws on invalid fields", async () => {
347491
await using tmp = await tmpdir({
348492
init: async (dir) => {
@@ -2087,3 +2231,30 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
20872231
}
20882232
})
20892233
})
2234+
2235+
test("normalizes native config with string command and args", async () => {
2236+
await using tmp = await tmpdir({
2237+
init: async (dir) => {
2238+
await writeConfig(dir, {
2239+
mcp: {
2240+
myserver: {
2241+
type: "local",
2242+
command: "node",
2243+
args: ["server.js"],
2244+
env: { PORT: "3000" },
2245+
},
2246+
},
2247+
})
2248+
},
2249+
})
2250+
await Instance.provide({
2251+
directory: tmp.path,
2252+
fn: async () => {
2253+
const config = await Config.get()
2254+
const server = config.mcp!.myserver as any
2255+
expect(server.type).toBe("local")
2256+
expect(server.command).toEqual(["node", "server.js"])
2257+
expect(server.environment).toEqual({ PORT: "3000" })
2258+
},
2259+
})
2260+
})

0 commit comments

Comments
 (0)