From 331841f48275736b9a931be2009ba7ea15d43cf1 Mon Sep 17 00:00:00 2001 From: DDT <1786035110@qq.com> Date: Sun, 11 Oct 2026 00:28:44 +0800 Subject: [PATCH] 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. --- src/config/cli-registry/schema.ts | 2 ++ src/config/cli-registry/stock.ts | 1 + src/config/cli-registry/types.ts | 7 +++- src/mcp-sync.ts | 58 ++++++++++++++++++++++++++++--- test/mcp-sync.test.ts | 41 ++++++++++++++++++++-- 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 18cb81fb..dc2dae75 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -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([ diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ae497207..f077fb75 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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' }, }, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 2fb689a4..afc53574 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -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 diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts index de9ad0a8..4ccc1279 100644 --- a/src/mcp-sync.ts +++ b/src/mcp-sync.ts @@ -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 { 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 | 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 { 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 | null; /** Top-level keys to seed when creating the file from nothing. */ @@ -334,6 +339,7 @@ const JSON_DIALECTS: Record, 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(); 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(); + 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 +): { 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; diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts index 9d552223..5f988a73 100644 --- a/test/mcp-sync.test.ts +++ b/test/mcp-sync.test.ts @@ -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);