fix(mcp-sync): read mcp.servers as a V1 server when it has a V1 type (#599 merge-time fix)

mcpTable took any record at `mcp.servers` as OpenCode's V2 container.
OpenCode itself does so only when the value is not a V1 server (`type`
local or remote). So a server named "servers" copied from another CLI was
written as V1, re-parsed as a container, failed the read-back guard and
failed the whole OpenCode target, and an existing V1 "servers" stopped
being read. The V2 entry now also wins a name collision, as in OpenCode;
sync never writes a name already defined, so the read-back is unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:28:15 +02:00
1 parent a8b8530722
commit 1f41eacd87
2 files changed
+35 -5

No files matched your search

+5 -5
View File
@@ -413,14 +413,14 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
if (!dialect.nestedKey) return table;
const nested = table[dialect.nestedKey];
if (nested === undefined) return table;
// OpenCode reads `mcp.servers` as a V1 server named "servers" when it carries a V1 `type`.
if (nested === undefined || (isRecord(nested) && (nested.type === 'local' || nested.type === 'remote'))) return table;
if (!isRecord(nested)) throw new McpConfigError(`"${dialect.key}.${dialect.nestedKey}" is not an object`);
// Merge the nested (V2) table with the direct (V1) one. V1 entries win on a name collision so
// the write path — which appends to `doc[key]` and leaves the nested table in place — reads back
// identically.
// Merge the direct (V1) table with the nested (V2) one. Like OpenCode, the native V2 entry wins a
// name collision; sync never writes a name already defined, so this does not affect the read-back.
const merged = dict<unknown>();
for (const k of safeKeys(nested)) merged[k] = nested[k];
for (const k of safeKeys(table)) if (k !== dialect.nestedKey) merged[k] = table[k];
for (const k of safeKeys(nested)) merged[k] = nested[k];
return merged;
}
+30
View File
@@ -147,6 +147,36 @@ describe('dialect parsing', () => {
expect(o.direct).toEqual({ transport: 'http', url: 'https://direct' });
});
it('reads `mcp.servers` as a V1 server named "servers" when it carries a V1 type, as OpenCode does', () => {
const o = parseServers(
'opencode-json',
JSON.stringify({
mcp: { servers: { type: 'local', command: ['npx', 'srv'] }, other: { type: 'remote', url: 'https://o' } },
})
);
expect(Object.keys(o).sort()).toEqual(['other', 'servers']);
expect(o.servers).toEqual({ transport: 'stdio', command: 'npx', args: ['srv'] });
});
it('writes a server named "servers" into OpenCode, and it reads back as one', () => {
const out = addServers('opencode-json', JSON.stringify({ mcp: {} }), {
servers: { transport: 'stdio', command: 'npx', args: ['srv'] },
fs: { transport: 'stdio', command: 'npx', args: ['fs'] },
});
const back = parseServers('opencode-json', out);
expect(Object.keys(back).sort()).toEqual(['fs', 'servers']);
});
it('lets the native V2 entry win a name collision, like OpenCode', () => {
const o = parseServers(
'opencode-json',
JSON.stringify({
mcp: { dup: { type: 'remote', url: 'https://v1' }, servers: { dup: { type: 'remote', url: 'https://v2' } } },
})
);
expect(o.dup).toEqual({ transport: 'http', url: 'https://v2' });
});
it('throws on unparseable files so they are never written', () => {
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();