mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
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:
1 parent
aa3730aace
commit
331841f482
5 files changed
+101
-8
No files matched your search
@@ -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([
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user