fix(mcp-sync): read Gemini bare url as http, OpenCode V2 mcp.servers, and opencode.jsonc

Gemini v0.63.0 treats a bare url as streamable HTTP first, OpenCode V2 nests
servers under mcp.servers with disabled instead of enabled, and a config kept only
as opencode.jsonc was read as "no OpenCode config". Fix all three read gaps;
the write path is unchanged. Fixes #586.
This commit is contained in:
DDT committed 2026-10-11 00:28:44 +08:00
1 parent aa3730aace
commit 331841f482
5 files changed
+101 -8

No files matched your search

+2
View File
@@ -443,6 +443,8 @@ const capabilitiesSchema = z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Home-relative fallback read when `path` is absent (never written).
altPath: mcpRelativePath.optional(),
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
+1
View File
@@ -536,6 +536,7 @@ const OPENCODE: CliEntry = {
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
altPath: '.config/opencode/opencode.jsonc',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
+6 -1
View File
@@ -644,7 +644,12 @@ export interface CliCapabilities {
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
mcpConfig?: {
path: string;
altPath?: string;
format: McpConfigFormat;
relocation?: { envVar: string; path: string };
};
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+53 -5
View File
@@ -40,7 +40,7 @@
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { basename, dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
@@ -66,6 +66,8 @@ export interface McpSyncTarget {
label: string;
/** Home-relative default location of the config file. */
path: string;
/** Home-relative fallback file read when `path` is absent (never written). */
altPath?: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
@@ -197,11 +199,12 @@ function toClaude(s: McpServer): Record<string, unknown> {
function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
// today. A bare `url` with no type used to be the legacy SSE form, but gemini-cli v0.63.0 tries
// it as streamable HTTP first (and logs `httpUrl` as deprecated), so map it to 'http'.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
return clean({ transport: raw.type === 'sse' ? 'sse' : 'http', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
@@ -255,7 +258,7 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
const disabled = raw.enabled === false || raw.disabled === true;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
@@ -311,6 +314,8 @@ function toCopilot(s: McpServer): Record<string, unknown> {
interface JsonDialect {
/** Key holding the server table. */
key: string;
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
nestedKey?: string;
from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
@@ -334,6 +339,7 @@ const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
},
'opencode-json': {
key: 'mcp',
nestedKey: 'servers',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
@@ -405,7 +411,17 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
if (!dialect.nestedKey) return table;
const nested = table[dialect.nestedKey];
if (nested === undefined) 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.
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];
return merged;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
@@ -593,6 +609,29 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
/**
* The alternate config file for a target (`McpSyncTarget.altPath`), honouring the same relocation
* as the primary file: the alt lives in the same directory, so the relocated path is the primary's
* directory plus the alt filename. Like the primary, it is only ever read, never written.
*/
function resolveAltFile(
t: McpSyncTarget,
altPath: string,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
if (!rel || dir === undefined || dir === '') return { file: join(home, altPath) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/…`,
skip: `${rel.envVar} is set to a relative path, so the alt file cannot be located safely`,
};
}
return { file: join(dir, dirname(rel.path), basename(altPath)) };
}
/**
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
* disabled, so they are not copied. If that file cannot be read as intended the target is
@@ -667,6 +706,15 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
// A config kept under an alternate name (OpenCode's opencode.jsonc) is read when the primary
// file is absent, so its servers are synced rather than silently treated as "not installed".
if (s.t.altPath && !(await exists(s.file))) {
const alt = resolveAltFile(s.t, s.t.altPath, home, env);
if (!alt.skip && (await exists(alt.file))) {
s.file = alt.file;
s.res.file = alt.file;
}
}
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
+39 -2
View File
@@ -98,7 +98,7 @@ describe('dialect parsing', () => {
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
});
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
it('reads gemini bare url as http (v0.63), httpUrl / type sse, and opencode local/remote', () => {
const g = parseServers(
'gemini-json',
JSON.stringify({
@@ -107,13 +107,15 @@ describe('dialect parsing', () => {
b: { httpUrl: 'https://b' },
c: { command: 'c', args: ['1'] },
d: { url: 'https://d', type: 'http' },
e: { url: 'https://e', type: 'sse' },
},
})
);
expect(g.a.transport).toBe('sse');
expect(g.a.transport).toBe('http');
expect(g.b.transport).toBe('http');
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
expect(g.d.transport).toBe('http');
expect(g.e.transport).toBe('sse');
const o = parseServers(
'opencode-json',
JSON.stringify({
@@ -127,6 +129,24 @@ describe('dialect parsing', () => {
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
});
it('reads OpenCode V2 servers nested under mcp.servers, with disabled', () => {
const o = parseServers(
'opencode-json',
JSON.stringify({
mcp: {
servers: {
off: { type: 'remote', url: 'https://off', disabled: true },
nested: { type: 'local', command: ['npx', '-y', 'x'] },
},
direct: { type: 'remote', url: 'https://direct' },
},
})
);
expect(o.off).toEqual({ transport: 'http', url: 'https://off', disabled: true });
expect(o.nested).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'] });
expect(o.direct).toEqual({ transport: 'http', url: 'https://direct' });
});
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();
@@ -325,6 +345,23 @@ describe('syncMcpServers', () => {
put('.gemini/config/mcp_config.json', '{}');
};
it('reads opencode.jsonc when opencode.json is absent', async () => {
put('.config/opencode/opencode.jsonc', JSON.stringify({ mcp: { fs: { type: 'local', command: ['a'] } } }));
const opencode = target('opencode', { altPath: '.config/opencode/opencode.jsonc' });
const r = await syncMcpServers([opencode], { apply: false, home });
const o = result(r, 'opencode');
expect(o.status).toBe('ok');
expect(o.servers).toContain('fs');
});
it('reports opencode.jsonc with comments as unreadable, not absent', async () => {
put('.config/opencode/opencode.jsonc', '{\n // jsonc comment\n "mcp": {}\n}');
const opencode = target('opencode', { altPath: '.config/opencode/opencode.jsonc' });
const r = await syncMcpServers([opencode], { apply: false, home });
const o = result(r, 'opencode');
expect(o.status).toBe('unreadable');
});
it('previews without writing and never leaks env values', async () => {
setUpAll();
put('.claude.json', claudeFile);