diff --git a/src/main/agent-mcp-sync/host/claude-json-mcp-merge.test.ts b/src/main/agent-mcp-sync/host/claude-json-mcp-merge.test.ts new file mode 100644 index 00000000000..27b25209a6a --- /dev/null +++ b/src/main/agent-mcp-sync/host/claude-json-mcp-merge.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { mergeClaudeMcpServers } from './claude-json-mcp-merge' +import { fingerprintMcpValue } from './mcp-sync-ledger' + +const linear = { type: 'http', url: 'https://mcp.linear.app/mcp' } +const fs = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'] } + +describe('mergeClaudeMcpServers', () => { + it('adds servers and keeps every other key of the host file', () => { + const text = JSON.stringify({ numStartups: 3, projects: { '/w': { x: 1 } } }) + const merged = mergeClaudeMcpServers(text, { linear }, {}) + expect(JSON.parse(merged.text!)).toEqual({ + numStartups: 3, + projects: { '/w': { x: 1 } }, + mcpServers: { linear } + }) + expect(merged.outcomes).toEqual({ linear: 'added' }) + expect(merged.ledger).toEqual({ linear: fingerprintMcpValue(linear) }) + }) + + it('is idempotent once synced', () => { + const first = mergeClaudeMcpServers(null, { linear, fs }, {}) + const second = mergeClaudeMcpServers(first.text, { linear, fs }, first.ledger) + expect(second.text).toBeNull() + expect(second.outcomes).toEqual({ linear: 'unchanged', fs: 'unchanged' }) + }) + + it('never touches a same-named server the host user configured', () => { + const own = { command: 'my-linear' } + const text = JSON.stringify({ mcpServers: { linear: own } }) + const merged = mergeClaudeMcpServers(text, { linear }, {}) + expect(merged.text).toBeNull() + expect(merged.outcomes).toEqual({ linear: 'kept-host' }) + expect(merged.ledger).toEqual({}) + }) + + it('treats a synced entry the host user edited as theirs', () => { + const first = mergeClaudeMcpServers(null, { linear }, {}) + const edited = JSON.parse(first.text!) + edited.mcpServers.linear.headers = { Authorization: 'Bearer host-token' } + const merged = mergeClaudeMcpServers( + JSON.stringify(edited), + { linear: { ...linear, url: 'https://new.example/mcp' } }, + first.ledger + ) + expect(merged.text).toBeNull() + expect(merged.outcomes).toEqual({ linear: 'kept-host' }) + }) + + it('updates its own entry and removes ones no longer sent', () => { + const first = mergeClaudeMcpServers( + JSON.stringify({ mcpServers: { hostOnly: { command: 'x' } } }), + { linear, fs }, + {} + ) + const next = { type: 'sse', url: 'https://mcp.linear.app/sse' } + const merged = mergeClaudeMcpServers(first.text, { linear: next }, first.ledger) + expect(JSON.parse(merged.text!).mcpServers).toEqual({ + hostOnly: { command: 'x' }, + linear: next + }) + expect(merged.outcomes).toEqual({ linear: 'updated' }) + expect(Object.keys(merged.ledger)).toEqual(['linear']) + }) + + it('refuses a host file that is not a JSON object', () => { + expect(() => mergeClaudeMcpServers('{not json', { linear }, {})).toThrow() + expect(() => mergeClaudeMcpServers('[]', { linear }, {})).toThrow() + }) +}) diff --git a/src/main/agent-mcp-sync/host/claude-json-mcp-merge.ts b/src/main/agent-mcp-sync/host/claude-json-mcp-merge.ts new file mode 100644 index 00000000000..cc52024a354 --- /dev/null +++ b/src/main/agent-mcp-sync/host/claude-json-mcp-merge.ts @@ -0,0 +1,74 @@ +import type { McpSyncHostOutcome } from '../../../shared/agent-mcp-sync/mcp-sync-contract' +import { fingerprintMcpValue } from './mcp-sync-ledger' + +export type ClaudeMcpMergeResult = { + /** New file text, or null when the file needs no write. */ + text: string | null + ledger: Record + outcomes: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Merge the desktop's servers into the host's `~/.claude.json` `mcpServers`. + * An entry is the desktop's only while it still matches what the ledger says was written; anything + * else under that name is the host user's and is left exactly as is. Every other key is preserved. */ +export function mergeClaudeMcpServers( + text: string | null, + desired: Readonly>>, + ledger: Readonly> +): ClaudeMcpMergeResult { + let root: Record = {} + if (text !== null && text.trim()) { + const parsed: unknown = JSON.parse(text) + if (!isRecord(parsed)) { + throw new Error('claude_config_not_object') + } + root = parsed + } + const current = isRecord(root.mcpServers) ? root.mcpServers : {} + const servers: Record = { ...current } + const nextLedger: Record = {} + const outcomes: Record = {} + let changed = false + const ours = (name: string): boolean => + Object.hasOwn(servers, name) && + ledger[name] !== undefined && + fingerprintMcpValue(servers[name]) === ledger[name] + + for (const name of Object.keys(ledger)) { + if (!Object.hasOwn(desired, name) && ours(name)) { + delete servers[name] + changed = true + } + } + for (const [name, config] of Object.entries(desired)) { + const print = fingerprintMcpValue(config) + if (!Object.hasOwn(servers, name)) { + servers[name] = config + outcomes[name] = 'added' + changed = true + } else if (!ours(name)) { + outcomes[name] = 'kept-host' + continue + } else if (ledger[name] === print) { + outcomes[name] = 'unchanged' + } else { + servers[name] = config + outcomes[name] = 'updated' + changed = true + } + nextLedger[name] = print + } + if (!changed) { + return { text: null, ledger: nextLedger, outcomes } + } + // Claude Code itself writes this file with two-space indentation. + return { + text: `${JSON.stringify({ ...root, mcpServers: servers }, null, 2)}\n`, + ledger: nextLedger, + outcomes + } +} diff --git a/src/main/agent-mcp-sync/host/codex-toml-mcp-merge.test.ts b/src/main/agent-mcp-sync/host/codex-toml-mcp-merge.test.ts new file mode 100644 index 00000000000..62e9a550cff --- /dev/null +++ b/src/main/agent-mcp-sync/host/codex-toml-mcp-merge.test.ts @@ -0,0 +1,69 @@ +import { parse } from 'smol-toml' +import { describe, expect, it } from 'vitest' +import { CODEX_MCP_BLOCK_START, mergeCodexMcpServers } from './codex-toml-mcp-merge' + +const linear = { url: 'https://mcp.linear.app/mcp' } +const fs = { command: 'npx', args: ['-y', 'server-fs'], env: { TOKEN: 'secret' } } + +describe('mergeCodexMcpServers', () => { + it('appends a marked block after the host config and is idempotent', () => { + const host = 'model = "gpt-5"\n\n[mcp_servers.hostOnly]\ncommand = "x"\n' + const first = mergeCodexMcpServers(host, { linear, fs }, null) + expect(first.outcomes).toEqual({ linear: 'added', fs: 'added' }) + expect(first.text!.startsWith(host)).toBe(true) + expect(parse(first.text!)).toEqual({ + model: 'gpt-5', + mcp_servers: { hostOnly: { command: 'x' }, linear, fs } + }) + const second = mergeCodexMcpServers(first.text, { linear, fs }, first.blockPrint) + expect(second.text).toBeNull() + expect(second.outcomes).toEqual({ linear: 'unchanged', fs: 'unchanged' }) + }) + + it('lets servers defined outside the block win', () => { + const host = '[mcp_servers.linear]\nurl = "https://host.example/mcp"\n' + const merged = mergeCodexMcpServers(host, { linear, fs }, null) + expect(merged.outcomes).toEqual({ linear: 'kept-host', fs: 'added' }) + expect((parse(merged.text!) as { mcp_servers: unknown }).mcp_servers).toEqual({ + linear: { url: 'https://host.example/mcp' }, + fs + }) + }) + + it('rewrites only its block and removes it when nothing is sent', () => { + const first = mergeCodexMcpServers('a = 1\n', { linear, fs }, null) + const withTail = `${first.text}\n[profiles.x]\nmodel = "o3"\n` + const fsV2 = { ...fs, args: ['-y', 'v2'] } + const updated = mergeCodexMcpServers(withTail, { fs: fsV2 }, first.blockPrint) + expect(updated.outcomes).toEqual({ fs: 'updated' }) + expect(parse(updated.text!)).toEqual({ + a: 1, + mcp_servers: { fs: fsV2 }, + profiles: { x: { model: 'o3' } } + }) + const cleared = mergeCodexMcpServers(updated.text, {}, updated.blockPrint) + expect(cleared.text).toBe('a = 1\n\n[profiles.x]\nmodel = "o3"\n') + expect(cleared.blockPrint).toBeNull() + }) + + it('keeps a hand-edited block as the host user’s own config', () => { + const first = mergeCodexMcpServers('', { linear }, null) + const edited = first.text!.replace('https://mcp.linear.app/mcp', 'https://edited.example/mcp') + const merged = mergeCodexMcpServers(edited, { linear, fs }, first.blockPrint) + expect(merged.outcomes).toEqual({ linear: 'kept-host', fs: 'added' }) + const parsed = parse(merged.text!) as { mcp_servers: Record } + expect(parsed.mcp_servers.linear).toEqual({ url: 'https://edited.example/mcp' }) + expect(merged.text!.split(CODEX_MCP_BLOCK_START)).toHaveLength(2) + }) + + it('does not extend an inline mcp_servers table', () => { + const merged = mergeCodexMcpServers('mcp_servers = { a = { command = "x" } }\n', { fs }, null) + expect(merged.text).toBeNull() + expect(merged.outcomes).toEqual({ fs: 'kept-host' }) + }) + + it('refuses invalid TOML and an unterminated block', () => { + expect(() => mergeCodexMcpServers('[[broken', { fs }, null)).toThrow() + expect(() => mergeCodexMcpServers(`${CODEX_MCP_BLOCK_START}\n`, { fs }, null)).toThrow() + }) +}) diff --git a/src/main/agent-mcp-sync/host/codex-toml-mcp-merge.ts b/src/main/agent-mcp-sync/host/codex-toml-mcp-merge.ts new file mode 100644 index 00000000000..8dc97c69b41 --- /dev/null +++ b/src/main/agent-mcp-sync/host/codex-toml-mcp-merge.ts @@ -0,0 +1,102 @@ +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml' +import type { McpSyncHostOutcome } from '../../../shared/agent-mcp-sync/mcp-sync-contract' +import { readMcpServerTomlOwnership } from '../../codex/configuration/toml/config-toml-mcp-servers' +import { fingerprintMcpValue } from './mcp-sync-ledger' + +export const CODEX_MCP_BLOCK_START = + '# >>> Orca: MCP servers synced from your computer. Orca rewrites this block; add your own servers outside it.' +export const CODEX_MCP_BLOCK_END = '# <<< Orca MCP servers' + +export type CodexMcpMergeResult = { + text: string | null + /** Fingerprint of the block body written (null when no block remains). */ + blockPrint: string | null + outcomes: Record +} + +type Block = { before: string; body: string; after: string } + +function findBlock(text: string): Block | null { + const lines = text.split('\n') + const start = lines.indexOf(CODEX_MCP_BLOCK_START) + if (start === -1) { + return null + } + const end = lines.indexOf(CODEX_MCP_BLOCK_END, start + 1) + if (end === -1) { + throw new Error('codex_config_block_unterminated') + } + return { + before: lines.slice(0, start).join('\n'), + body: lines.slice(start + 1, end).join('\n'), + after: lines.slice(end + 1).join('\n') + } +} + +function blockServers(body: string): Record { + const parsed = parseToml(body) as { mcp_servers?: Record } + return parsed.mcp_servers ?? {} +} + +// Blank lines at the seams are normalized so repeated syncs leave the file byte-identical. +function joinParts(parts: string[]): string { + const text = parts + .map((part) => part.replace(/^\n+|\n+$/g, '')) + .filter((part) => part.trim() !== '') + .join('\n\n') + return text ? `${text}\n` : '' +} + +/** Merge the desktop's servers into the host's Codex `config.toml` inside one marked block. + * Names configured outside the block are the host user's and win. A block the user edited by hand + * is unmarked and kept as their own config rather than overwritten. */ +export function mergeCodexMcpServers( + text: string | null, + desired: Readonly>>, + lastBlockPrint: string | null +): CodexMcpMergeResult { + const source = text ?? '' + // Refuse to edit a file Codex itself could not load. + parseToml(source) + const block = findBlock(source) + const handEdited = block !== null && fingerprintMcpValue(block.body) !== lastBlockPrint + const outside = block + ? handEdited + ? joinParts([block.before, block.body, block.after]) + : joinParts([block.before, block.after]) + : source + const previous = block && !handEdited ? blockServers(block.body) : {} + const ownership = readMcpServerTomlOwnership(outside) + const outcomes: Record = {} + const servers: Record> = {} + for (const [name, config] of Object.entries(desired)) { + if (ownership.ownsRoot || ownership.names.has(name)) { + outcomes[name] = 'kept-host' + continue + } + servers[name] = config + outcomes[name] = !Object.hasOwn(previous, name) + ? 'added' + : fingerprintMcpValue(previous[name]) === fingerprintMcpValue(config) + ? 'unchanged' + : 'updated' + } + const body = + Object.keys(servers).length > 0 + ? stringifyToml({ mcp_servers: servers }).replace(/\n+$/, '') + : null + if (!block && !body) { + return { text: null, blockPrint: null, outcomes } + } + const blockText = body ? `${CODEX_MCP_BLOCK_START}\n${body}\n${CODEX_MCP_BLOCK_END}` : '' + const next = + block && !handEdited + ? joinParts([block.before, blockText, block.after]) + : joinParts([outside, blockText]) + if (next === source) { + return { text: null, blockPrint: body ? fingerprintMcpValue(body) : null, outcomes } + } + // A config that no longer loads would take every Codex session on the host down with it. + parseToml(next) + return { text: next, blockPrint: body ? fingerprintMcpValue(body) : null, outcomes } +} diff --git a/src/main/agent-mcp-sync/host/host-mcp-sync.ts b/src/main/agent-mcp-sync/host/host-mcp-sync.ts new file mode 100644 index 00000000000..1afa0fa253a --- /dev/null +++ b/src/main/agent-mcp-sync/host/host-mcp-sync.ts @@ -0,0 +1,234 @@ +import { constants as fsConstants } from 'node:fs' +import { access, chmod, mkdir, readFile } from 'node:fs/promises' +import { lookup } from 'node:dns/promises' +import { homedir } from 'node:os' +import { delimiter, dirname, isAbsolute, join } from 'node:path' +import type { + AgentMcpSyncParams, + AgentMcpSyncResult, + McpSyncHostOutcome +} from '../../../shared/agent-mcp-sync/mcp-sync-contract' +import { writeFileAtomicallyIfUnchanged } from '../../filesystem/atomic-file-publication' +import { mergeClaudeMcpServers } from './claude-json-mcp-merge' +import { mergeCodexMcpServers } from './codex-toml-mcp-merge' +import { parseMcpSyncLedger, type McpSyncLedger } from './mcp-sync-ledger' + +// Agent configs carry MCP env tokens and headers; keep them owner-only. +const PRIVATE_FILE = { mode: 0o600 } as const +const DNS_TIMEOUT_MS = 3_000 +// The agent CLIs rewrite ~/.claude.json often; retry a lost race instead of clobbering their write. +const WRITE_ATTEMPTS = 4 + +export type HostMcpSyncPaths = { + claudeConfig: string + codexConfig: string + ledger: string +} + +export type HostMcpSyncDeps = { + paths: HostMcpSyncPaths + commandExists?: (command: string) => Promise + resolves?: (hostname: string) => Promise +} + +export function defaultHostMcpSyncPaths(userDataPath: string): HostMcpSyncPaths { + const claudeDir = process.env.CLAUDE_CONFIG_DIR?.trim() + return { + claudeConfig: claudeDir ? join(claudeDir, '.claude.json') : join(homedir(), '.claude.json'), + codexConfig: join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'config.toml'), + ledger: join(userDataPath, 'mcp-sync-ledger.json') + } +} + +async function readText(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} + +async function writePrivate(path: string, expected: string | null, text: string): Promise { + await mkdir(dirname(path), { recursive: true }) + if (!writeFileAtomicallyIfUnchanged(path, expected, text, PRIVATE_FILE)) { + return false + } + // The guarded publish keeps the temp file's mode, but tighten an inherited looser one anyway. + await chmod(path, PRIVATE_FILE.mode) + return true +} + +/** Login shells add these to PATH; orcad's own environment may not have them yet. */ +function searchPath(): string[] { + const home = homedir() + return [ + ...(process.env.PATH ?? '').split(delimiter), + join(home, '.local', 'bin'), + join(home, '.npm-global', 'bin'), + join(home, '.bun', 'bin'), + join(home, '.cargo', 'bin'), + '/usr/local/bin', + '/usr/bin', + '/bin' + ].filter(Boolean) +} + +export async function defaultCommandExists(command: string): Promise { + const candidates = isAbsolute(command) + ? [command] + : command.includes('/') + ? [] + : searchPath().map((dir) => join(dir, command)) + for (const candidate of candidates) { + try { + await access(candidate, fsConstants.X_OK) + return true + } catch { + // Try the next PATH entry. + } + } + return false +} + +async function defaultResolves(hostname: string): Promise { + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), DNS_TIMEOUT_MS).unref?.() + }) + return Promise.race([ + lookup(hostname).then( + () => true, + () => false + ), + timeout + ]) +} + +/** Drop stdio servers whose command this host cannot run, so no broken entry gets written. */ +async function runnable( + servers: Readonly>>, + commandExists: (command: string) => Promise, + outcomes: Record +): Promise>> { + const kept: Record> = {} + for (const [name, config] of Object.entries(servers)) { + const command = typeof config.command === 'string' ? config.command : null + if (command && typeof config.url !== 'string' && !(await commandExists(command))) { + outcomes[name] = 'command-missing' + continue + } + kept[name] = config + } + return kept +} + +function remoteHostnames(params: AgentMcpSyncParams): string[] { + const names = new Set() + for (const servers of [params.claude, params.codex]) { + for (const config of Object.values(servers ?? {})) { + if (typeof config.url === 'string') { + try { + names.add(new URL(config.url).hostname) + } catch { + // The desktop only sends valid URLs; ignore anything else. + } + } + } + } + return [...names] +} + +function unreadable( + servers: Readonly> +): Record { + return Object.fromEntries(Object.keys(servers).map((name) => [name, 'config-unreadable'])) +} + +async function syncClaude( + paths: HostMcpSyncPaths, + desired: Record>, + ledger: McpSyncLedger +): Promise> { + for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) { + const text = await readText(paths.claudeConfig) + let merged: ReturnType + try { + merged = mergeClaudeMcpServers(text, desired, ledger.claude) + } catch { + return unreadable(desired) + } + if (merged.text === null || (await writePrivate(paths.claudeConfig, text, merged.text))) { + ledger.claude = merged.ledger + return merged.outcomes + } + } + return unreadable(desired) +} + +async function syncCodex( + paths: HostMcpSyncPaths, + desired: Record>, + ledger: McpSyncLedger +): Promise> { + for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) { + const text = await readText(paths.codexConfig) + let merged: ReturnType + try { + merged = mergeCodexMcpServers(text, desired, ledger.codexBlock) + } catch { + return unreadable(desired) + } + if (merged.text === null || (await writePrivate(paths.codexConfig, text, merged.text))) { + ledger.codexBlock = merged.blockPrint + return merged.outcomes + } + } + return unreadable(desired) +} + +/** Apply the desktop's MCP servers to this host's agent configs. Logs counts only, never values. */ +export async function syncHostMcpServers( + params: AgentMcpSyncParams, + deps: HostMcpSyncDeps +): Promise { + const commandExists = deps.commandExists ?? defaultCommandExists + const resolves = deps.resolves ?? defaultResolves + const ledgerText = await readText(deps.paths.ledger) + const ledger = parseMcpSyncLedger(ledgerText) + const result: AgentMcpSyncResult = {} + if (params.claude) { + const outcomes: Record = {} + const desired = await runnable(params.claude, commandExists, outcomes) + result.claude = { ...outcomes, ...(await syncClaude(deps.paths, desired, ledger)) } + } + if (params.codex) { + const outcomes: Record = {} + const desired = await runnable(params.codex, commandExists, outcomes) + result.codex = { ...outcomes, ...(await syncCodex(deps.paths, desired, ledger)) } + } + const nextLedger = `${JSON.stringify(ledger, null, 2)}\n` + if (nextLedger !== ledgerText) { + await writePrivate(deps.paths.ledger, ledgerText, nextLedger) + } + const hostnames = remoteHostnames(params) + const reachable = await Promise.all(hostnames.map(resolves)) + const unreachableHosts = hostnames.filter((_, index) => !reachable[index]) + if (unreachableHosts.length > 0) { + result.unreachableHosts = unreachableHosts + } + const count = (outcomes?: Record): string => + Object.entries( + Object.values(outcomes ?? {}).reduce>((acc, outcome) => { + acc[outcome] = (acc[outcome] ?? 0) + 1 + return acc + }, {}) + ) + .map(([outcome, n]) => `${outcome}=${n}`) + .join(',') + console.info( + `[mcp-sync] claude{${count(result.claude)}} codex{${count(result.codex)}} unresolved=${unreachableHosts.length}` + ) + return result +} diff --git a/src/main/agent-mcp-sync/host/mcp-sync-ledger.ts b/src/main/agent-mcp-sync/host/mcp-sync-ledger.ts new file mode 100644 index 00000000000..019ae5ae377 --- /dev/null +++ b/src/main/agent-mcp-sync/host/mcp-sync-ledger.ts @@ -0,0 +1,57 @@ +import { createHash } from 'node:crypto' + +/** What this host last wrote for the desktop, so later syncs can tell their own entries from the user's. */ +export type McpSyncLedger = { + version: 1 + /** Claude server name -> fingerprint of the entry written. */ + claude: Record + /** Fingerprint of the managed Codex block body as last written. */ + codexBlock: string | null +} + +export const EMPTY_MCP_SYNC_LEDGER: McpSyncLedger = { version: 1, claude: {}, codexBlock: null } + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonical) + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonical((value as Record)[key])]) + ) + } + return value +} + +/** Key-order-insensitive fingerprint; the ledger stores only this, never the values. */ +export function fingerprintMcpValue(value: unknown): string { + return createHash('sha256') + .update(typeof value === 'string' ? value : JSON.stringify(canonical(value))) + .digest('hex') +} + +export function parseMcpSyncLedger(text: string | null): McpSyncLedger { + if (!text) { + return { ...EMPTY_MCP_SYNC_LEDGER, claude: {} } + } + try { + const parsed = JSON.parse(text) as Partial + const claude: Record = {} + if (parsed.claude && typeof parsed.claude === 'object') { + for (const [name, print] of Object.entries(parsed.claude)) { + if (typeof print === 'string') { + claude[name] = print + } + } + } + return { + version: 1, + claude, + codexBlock: typeof parsed.codexBlock === 'string' ? parsed.codexBlock : null + } + } catch { + return { ...EMPTY_MCP_SYNC_LEDGER, claude: {} } + } +} diff --git a/src/main/runtime/rpc/methods/agent-config/agent-mcp-sync-method.ts b/src/main/runtime/rpc/methods/agent-config/agent-mcp-sync-method.ts new file mode 100644 index 00000000000..1ff676542c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-config/agent-mcp-sync-method.ts @@ -0,0 +1,24 @@ +import { defineMethod } from '../../core' +import { AgentMcpSyncParams } from '../../../../../shared/agent-mcp-sync/mcp-sync-contract' +import { getAppEnvironment } from '../../../../../shared/app-environment' +import { + defaultHostMcpSyncPaths, + syncHostMcpServers +} from '../../../../agent-mcp-sync/host/host-mcp-sync' + +export const AGENT_CONFIG_METHODS = [ + // The owning desktop mirrors its own MCP servers into this host's agent configs. + defineMethod({ + name: 'agentConfig.syncMcpServers', + permission: 'accounts-admin', + params: AgentMcpSyncParams, + handler: async (params, { clientKind }) => { + if (clientKind === 'mobile') { + throw new Error('Only an Orca desktop can sync its MCP servers to this host.') + } + return syncHostMcpServers(params, { + paths: defaultHostMcpSyncPaths(getAppEnvironment().getPath('userData')) + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/rpc-method-registry.ts b/src/main/runtime/rpc/methods/rpc-method-registry.ts index 15d7da0dffb..1d1f491b011 100644 --- a/src/main/runtime/rpc/methods/rpc-method-registry.ts +++ b/src/main/runtime/rpc/methods/rpc-method-registry.ts @@ -26,6 +26,7 @@ import { STATS_METHODS } from './host-management/stats' import { DIAGNOSTICS_METHODS } from './host-management/diagnostics' import { TERMINAL_DAEMON_METHODS } from './terminal/terminal-daemon' import { ACCOUNT_METHODS } from './accounts/accounts' +import { AGENT_CONFIG_METHODS } from './agent-config/agent-mcp-sync-method' import { ANTIGRAVITY_ACCOUNT_METHODS } from './accounts/antigravity-accounts' import { USAGE_CREDENTIAL_METHODS } from './accounts/usage-credentials' import { PREFLIGHT_METHODS } from './preflight/preflight' @@ -124,6 +125,7 @@ export const ALL_RPC_METHODS = [ ...DIAGNOSTICS_METHODS, ...TERMINAL_DAEMON_METHODS, ...ACCOUNT_METHODS, + ...AGENT_CONFIG_METHODS, ...ANTIGRAVITY_ACCOUNT_METHODS, ...USAGE_CREDENTIAL_METHODS, ...PREFLIGHT_METHODS, diff --git a/src/shared/agent-mcp-sync/mcp-server-portability.ts b/src/shared/agent-mcp-sync/mcp-server-portability.ts new file mode 100644 index 00000000000..25773650eae --- /dev/null +++ b/src/shared/agent-mcp-sync/mcp-server-portability.ts @@ -0,0 +1,151 @@ +import type { McpSyncAgent, McpSyncSkipReason, McpSyncTransport } from './mcp-sync-contract' + +export type PortedMcpServer = { + transport: McpSyncTransport | null + target: string | null +} & ({ ok: true; config: Record } | { ok: false; reason: McpSyncSkipReason }) + +// Launchers a Linux VM resolves on PATH; an absolute local path to one is rewritten to the bare name. +const PORTABLE_LAUNCHERS = new Set([ + 'node', + 'npx', + 'npm', + 'pnpm', + 'pnpx', + 'yarn', + 'bun', + 'bunx', + 'deno', + 'uv', + 'uvx', + 'python', + 'python3', + 'pipx', + 'docker', + 'podman', + 'go', + 'java' +]) + +// Paths that exist only on the user's own computer (macOS app bundles, Homebrew, user homes, drives). +const LOCAL_ONLY_PATH = + /^(?:\/Applications\/|\/opt\/homebrew\/|\/usr\/local\/(?:Cellar|Caskroom|opt|Homebrew)\/|\/Users\/|\/Volumes\/|\/Library\/|\/System\/|\/private\/|~\/|[A-Za-z]:[\\/]|\\\\)|\.app\/Contents\// + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1', '0.0.0.0']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function basename(path: string): string { + return path.split(/[\\/]/).pop() ?? path +} + +/** A value naming a file on this computer: local-only prefixes, or anything under this user's home. */ +export function isLocalOnlyPath(value: string, homeDir: string | null): boolean { + const trimmed = value.trim() + if (LOCAL_ONLY_PATH.test(trimmed)) { + return true + } + if (!homeDir || homeDir === '/') { + return false + } + // `--config=/home/me/x` style arguments embed the path after a flag. + return trimmed === homeDir || trimmed.includes(`${homeDir}/`) || trimmed.includes(`${homeDir}\\`) +} + +function stringValues(value: unknown): string[] { + if (typeof value === 'string') { + return [value] + } + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string') + } + if (isRecord(value)) { + return Object.values(value).filter((item): item is string => typeof item === 'string') + } + return [] +} + +function transportOf(agent: McpSyncAgent, raw: Record): McpSyncTransport | null { + const type = typeof raw.type === 'string' ? raw.type.toLowerCase() : undefined + if (agent === 'claude' && (type === 'http' || type === 'sse')) { + return type + } + if (agent === 'claude' && type === 'streamable-http') { + return 'http' + } + if (typeof raw.url === 'string') { + return 'http' + } + if (typeof raw.command === 'string') { + return 'stdio' + } + return null +} + +/** Origin only: user info, path and query (API keys often ride in `?key=`) stay on this computer. */ +function urlTarget(url: string): { origin: string; loopback: boolean } | null { + try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + return { origin: parsed.origin, loopback: LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()) } + } catch { + return null + } +} + +/** Decide whether a local server can run on a Linux host and rewrite what can be made portable. + * Remote (http/sse) servers travel as-is unless they point at this computer's loopback. */ +export function portMcpServer( + agent: McpSyncAgent, + entry: unknown, + homeDir: string | null +): PortedMcpServer { + if (!isRecord(entry)) { + return { ok: false, reason: 'invalid', transport: null, target: null } + } + const transport = transportOf(agent, entry) + if (transport === 'http' || transport === 'sse') { + const target = typeof entry.url === 'string' ? urlTarget(entry.url) : null + if (!target) { + return { ok: false, reason: 'invalid', transport, target: null } + } + if (target.loopback) { + return { ok: false, reason: 'local-url', transport, target: target.origin } + } + // Claude runs headersHelper as a command; it must exist on the host too. + const helper = typeof entry.headersHelper === 'string' ? entry.headersHelper : null + if (helper && isLocalOnlyPath(helper.split(/\s+/)[0] ?? helper, homeDir)) { + return { ok: false, reason: 'local-command', transport, target: target.origin } + } + return { ok: true, config: { ...entry }, transport, target: target.origin } + } + if (transport !== 'stdio' || typeof entry.command !== 'string' || !entry.command.trim()) { + return { ok: false, reason: 'invalid', transport, target: null } + } + const command = entry.command.trim() + const name = basename(command) + const absolute = /^(?:\/|~\/|[A-Za-z]:[\\/]|\\\\)/.test(command) + const launcher = name.replace(/\.exe$/i, '') + const portableCommand = absolute && PORTABLE_LAUNCHERS.has(launcher) ? launcher : command + if (portableCommand === command && isLocalOnlyPath(command, homeDir)) { + return { ok: false, reason: 'local-command', transport, target: name } + } + const pathValues = [ + ...stringValues(entry.args), + ...stringValues(entry.env), + ...stringValues(entry.cwd) + ] + if (pathValues.some((value) => isLocalOnlyPath(value, homeDir))) { + return { ok: false, reason: 'local-path', transport, target: basename(portableCommand) } + } + return { + ok: true, + config: { ...entry, command: portableCommand }, + transport, + target: basename(portableCommand) + } +} diff --git a/src/shared/agent-mcp-sync/mcp-sync-contract.ts b/src/shared/agent-mcp-sync/mcp-sync-contract.ts new file mode 100644 index 00000000000..33a96e065ba --- /dev/null +++ b/src/shared/agent-mcp-sync/mcp-sync-contract.ts @@ -0,0 +1,91 @@ +import { z } from 'zod' + +export type McpSyncAgent = 'claude' | 'codex' +export const MCP_SYNC_AGENTS: readonly McpSyncAgent[] = ['claude', 'codex'] + +/** Where a server came from on this computer: Claude Code user or project (local) scope, or Codex. */ +export type McpSyncSource = 'claude-user' | 'claude-project' | 'codex' +export type McpSyncTransport = 'stdio' | 'http' | 'sse' + +/** Agent CLIs and TOML tables both accept these; anything else is skipped rather than quoted. */ +export const MCP_SYNC_SERVER_NAME = /^[A-Za-z0-9_-]{1,64}$/ +export const MCP_SYNC_MAX_SERVERS_PER_AGENT = 64 +const MAX_SERVER_CONFIG_KEYS = 32 + +/** Reasons this computer does not send a server; each row shows one. */ +export type McpSyncSkipReason = + | 'turned-off' + | 'in-repo' + | 'local-command' + | 'local-path' + | 'local-url' + | 'invalid' + +/** What the host did with a server it was sent. */ +export const McpSyncHostOutcome = z.enum([ + 'added', + 'updated', + 'unchanged', + // A same-named server the user configured on the host wins and is never touched. + 'kept-host', + 'command-missing', + 'config-unreadable' +]) +export type McpSyncHostOutcome = z.infer + +const ServerConfig = z + .record(z.string(), z.unknown()) + .refine((value) => Object.keys(value).length <= MAX_SERVER_CONFIG_KEYS, 'Too many fields.') +const ServerSet = z + .record(z.string().regex(MCP_SYNC_SERVER_NAME), ServerConfig) + .refine( + (value) => Object.keys(value).length <= MCP_SYNC_MAX_SERVERS_PER_AGENT, + 'Too many servers.' + ) + +/** The complete set this computer manages for each agent named; an omitted agent is left alone. */ +export const AgentMcpSyncParams = z + .object({ + claude: ServerSet.optional(), + codex: ServerSet.optional() + }) + .strict() +export type AgentMcpSyncParams = z.infer + +export const AgentMcpSyncResult = z.object({ + claude: z.record(z.string(), McpSyncHostOutcome).optional(), + codex: z.record(z.string(), McpSyncHostOutcome).optional(), + /** Remote server hostnames this host cannot resolve (its network policy may block them). */ + unreachableHosts: z.array(z.string()).optional() +}) +export type AgentMcpSyncResult = z.infer + +export const AGENT_MCP_SYNC_METHOD = 'agentConfig.syncMcpServers' + +/** One server as the UI sees it: never args, env, headers or URL query (those often hold secrets). */ +export type McpSyncServerRow = { + key: string + agent: McpSyncAgent + name: string + source: McpSyncSource + transport: McpSyncTransport | null + /** URL origin for remote servers, command name for local ones. */ + target: string | null + included: boolean + state: 'synced' | 'skipped' | 'pending' + skipReason?: McpSyncSkipReason + outcome?: McpSyncHostOutcome + /** Synced, but the host could not resolve the server's hostname. */ + unreachable?: boolean +} + +export type McpSyncStatus = { + environmentId: string + autoSync: boolean + running: boolean + lastSyncedAt: number | null + error: 'host-outdated' | 'host-unreachable' | 'failed' | null + rows: McpSyncServerRow[] +} + +export const mcpSyncServerKey = (agent: McpSyncAgent, name: string): string => `${agent}:${name}` diff --git a/src/shared/agent-mcp-sync/mcp-sync-plan.test.ts b/src/shared/agent-mcp-sync/mcp-sync-plan.test.ts new file mode 100644 index 00000000000..81ea42d3a0d --- /dev/null +++ b/src/shared/agent-mcp-sync/mcp-sync-plan.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { applyMcpSyncResult, planMcpSync, type LocalMcpServer } from './mcp-sync-plan' + +const HOME = '/Users/me' + +function plan(servers: LocalMcpServer[], excluded: string[] = [], repo: string[] = []) { + return planMcpSync({ + servers, + excluded: new Set(excluded), + repoServerNames: new Set(repo), + homeDir: HOME + }) +} + +describe('planMcpSync', () => { + it('sends remote servers as-is and never exposes secrets in rows', () => { + const result = plan([ + { + agent: 'claude', + name: 'linear', + source: 'claude-user', + config: { + type: 'http', + url: 'https://user:pw@mcp.linear.app/mcp?key=sk-123', + headers: { Authorization: 'Bearer abc' } + } + } + ]) + expect(result.params.claude).toEqual({ + linear: { + type: 'http', + url: 'https://user:pw@mcp.linear.app/mcp?key=sk-123', + headers: { Authorization: 'Bearer abc' } + } + }) + expect(result.rows).toEqual([ + { + key: 'claude:linear', + agent: 'claude', + name: 'linear', + source: 'claude-user', + transport: 'http', + target: 'https://mcp.linear.app', + included: true, + state: 'pending' + } + ]) + expect(JSON.stringify(result.rows)).not.toMatch(/sk-123|abc|pw/) + }) + + it('rewrites absolute launcher paths and skips commands that only exist here', () => { + const result = plan([ + { + agent: 'codex', + name: 'fs', + source: 'codex', + config: { command: '/opt/homebrew/bin/npx', args: ['-y', 'server-fs', '/tmp'] } + }, + { + agent: 'codex', + name: 'figma', + source: 'codex', + config: { command: '/Applications/Figma.app/Contents/MacOS/figma-mcp' } + }, + { + agent: 'claude', + name: 'notes', + source: 'claude-user', + config: { command: 'node', args: ['/Users/me/mcp/notes.js'] } + }, + { + agent: 'claude', + name: 'dev', + source: 'claude-user', + config: { type: 'http', url: 'http://localhost:3845/mcp' } + } + ]) + expect(result.params).toEqual({ + claude: {}, + codex: { fs: { command: 'npx', args: ['-y', 'server-fs', '/tmp'] } } + }) + expect(result.rows.map((row) => [row.key, row.state, row.skipReason, row.target])).toEqual([ + ['claude:dev', 'skipped', 'local-url', 'http://localhost:3845'], + ['claude:notes', 'skipped', 'local-path', 'node'], + ['codex:figma', 'skipped', 'local-command', 'figma-mcp'], + ['codex:fs', 'pending', undefined, 'npx'] + ]) + }) + + it('honors per-server opt-out, defers to the repo .mcp.json and prefers project scope', () => { + const result = plan( + [ + { agent: 'claude', name: 'a', source: 'claude-user', config: { command: 'npx' } }, + { agent: 'claude', name: 'a', source: 'claude-project', config: { command: 'uvx' } }, + { agent: 'claude', name: 'b', source: 'claude-user', config: { command: 'npx' } }, + { agent: 'claude', name: 'c', source: 'claude-user', config: { command: 'npx' } }, + { agent: 'codex', name: 'c', source: 'codex', config: { command: 'npx' } } + ], + ['claude:b'], + ['c'] + ) + expect(result.params).toEqual({ + claude: { a: { command: 'uvx' } }, + codex: { c: { command: 'npx' } } + }) + expect(result.rows.map((row) => [row.key, row.source, row.skipReason])).toEqual([ + ['claude:a', 'claude-project', undefined], + ['claude:b', 'claude-user', 'turned-off'], + ['claude:c', 'claude-user', 'in-repo'], + ['codex:c', 'codex', undefined] + ]) + }) +}) + +describe('applyMcpSyncResult', () => { + it('marks host outcomes and unreachable remote hosts', () => { + const { rows } = plan([ + { + agent: 'claude', + name: 'linear', + source: 'claude-user', + config: { url: 'https://mcp.linear.app/mcp' } + }, + { agent: 'claude', name: 'py', source: 'claude-user', config: { command: 'uvx' } }, + { agent: 'codex', name: 'mine', source: 'codex', config: { command: 'npx' } } + ]) + const applied = applyMcpSyncResult(rows, { + claude: { linear: 'added', py: 'command-missing' }, + codex: { mine: 'kept-host' }, + unreachableHosts: ['mcp.linear.app'] + }) + expect(applied.map((row) => [row.key, row.state, row.outcome, row.unreachable])).toEqual([ + ['claude:linear', 'synced', 'added', true], + ['claude:py', 'skipped', 'command-missing', undefined], + ['codex:mine', 'skipped', 'kept-host', undefined] + ]) + }) +}) diff --git a/src/shared/agent-mcp-sync/mcp-sync-plan.ts b/src/shared/agent-mcp-sync/mcp-sync-plan.ts new file mode 100644 index 00000000000..3bc1679c80b --- /dev/null +++ b/src/shared/agent-mcp-sync/mcp-sync-plan.ts @@ -0,0 +1,124 @@ +import { + MCP_SYNC_MAX_SERVERS_PER_AGENT, + MCP_SYNC_SERVER_NAME, + mcpSyncServerKey, + type AgentMcpSyncParams, + type AgentMcpSyncResult, + type McpSyncAgent, + type McpSyncServerRow, + type McpSyncSource +} from './mcp-sync-contract' +import { portMcpServer } from './mcp-server-portability' + +/** A server as read from this computer's agent config, secrets included; never leaves main except to the host. */ +export type LocalMcpServer = { + agent: McpSyncAgent + name: string + source: McpSyncSource + config: unknown +} + +export type McpSyncPlanInput = { + servers: readonly LocalMcpServer[] + /** `agent:name` keys the user turned off. */ + excluded: ReadonlySet + /** Names the workspace repo's own `.mcp.json` declares; Claude reads that file on the host already. */ + repoServerNames: ReadonlySet + homeDir: string | null +} + +export type McpSyncPlan = { params: AgentMcpSyncParams; rows: McpSyncServerRow[] } + +// Claude merges scopes as local (project) over user; keep the one that wins locally. +function dedupe(servers: readonly LocalMcpServer[]): LocalMcpServer[] { + const byKey = new Map() + for (const server of servers) { + const key = mcpSyncServerKey(server.agent, server.name) + const existing = byKey.get(key) + if (!existing || server.source === 'claude-project') { + byKey.set(key, server) + } + } + return [...byKey.values()] +} + +export function planMcpSync(input: McpSyncPlanInput): McpSyncPlan { + const claude: Record> = {} + const codex: Record> = {} + const rows: McpSyncServerRow[] = [] + for (const server of dedupe(input.servers)) { + const key = mcpSyncServerKey(server.agent, server.name) + const ported = portMcpServer(server.agent, server.config, input.homeDir) + const included = !input.excluded.has(key) + const base = { + key, + agent: server.agent, + name: server.name, + source: server.source, + transport: ported.transport, + target: ported.target, + included + } + const skipReason = !MCP_SYNC_SERVER_NAME.test(server.name) + ? 'invalid' + : !ported.ok + ? ported.reason + : server.agent === 'claude' && input.repoServerNames.has(server.name) + ? 'in-repo' + : !included + ? 'turned-off' + : null + const bucket = server.agent === 'claude' ? claude : codex + if ( + skipReason === null && + ported.ok && + Object.keys(bucket).length < MCP_SYNC_MAX_SERVERS_PER_AGENT + ) { + bucket[server.name] = ported.config + rows.push({ ...base, state: 'pending' }) + } else { + rows.push({ ...base, state: 'skipped', skipReason: skipReason ?? 'invalid' }) + } + } + rows.sort((a, b) => a.agent.localeCompare(b.agent) || a.name.localeCompare(b.name)) + // Both agents are always named so servers turned off or removed here leave the host too. + return { params: { claude, codex }, rows } +} + +function hostnameOf(origin: string | null): string | null { + if (!origin) { + return null + } + try { + return new URL(origin).hostname + } catch { + return null + } +} + +/** Fold the host's answer into the rows: outcomes that left nothing working read as skipped. */ +export function applyMcpSyncResult( + rows: readonly McpSyncServerRow[], + result: AgentMcpSyncResult +): McpSyncServerRow[] { + const unreachable = new Set(result.unreachableHosts ?? []) + return rows.map((row) => { + if (row.state !== 'pending') { + return row + } + const outcome = result[row.agent]?.[row.name] + if (!outcome) { + return row + } + const synced = outcome === 'added' || outcome === 'updated' || outcome === 'unchanged' + const host = hostnameOf(row.target) + return { + ...row, + state: synced ? 'synced' : 'skipped', + outcome, + ...(synced && row.transport !== 'stdio' && host && unreachable.has(host) + ? { unreachable: true } + : {}) + } + }) +}