feat(cloud): host-side MCP config merge for agents (Claude ~/.claude.json, Codex config.toml) with ownership ledger

This commit is contained in:
Neil
2026-11-09 07:56:55 -08:00
parent e753298c72
commit 52039adecc
12 changed files with 1136 additions and 0 deletions
@@ -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()
})
})
@@ -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<string, string>
outcomes: Record<string, McpSyncHostOutcome>
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<Record<string, Record<string, unknown>>>,
ledger: Readonly<Record<string, string>>
): ClaudeMcpMergeResult {
let root: Record<string, unknown> = {}
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<string, unknown> = { ...current }
const nextLedger: Record<string, string> = {}
const outcomes: Record<string, McpSyncHostOutcome> = {}
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
}
}
@@ -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<string, unknown> }
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()
})
})
@@ -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<string, McpSyncHostOutcome>
}
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<string, unknown> {
const parsed = parseToml(body) as { mcp_servers?: Record<string, unknown> }
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<Record<string, Record<string, unknown>>>,
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<string, McpSyncHostOutcome> = {}
const servers: Record<string, Record<string, unknown>> = {}
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 }
}
@@ -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<boolean>
resolves?: (hostname: string) => Promise<boolean>
}
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<string | null> {
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<boolean> {
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<boolean> {
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<boolean> {
const timeout = new Promise<boolean>((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<Record<string, Record<string, unknown>>>,
commandExists: (command: string) => Promise<boolean>,
outcomes: Record<string, McpSyncHostOutcome>
): Promise<Record<string, Record<string, unknown>>> {
const kept: Record<string, Record<string, unknown>> = {}
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<string>()
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<string, unknown>>
): Record<string, McpSyncHostOutcome> {
return Object.fromEntries(Object.keys(servers).map((name) => [name, 'config-unreadable']))
}
async function syncClaude(
paths: HostMcpSyncPaths,
desired: Record<string, Record<string, unknown>>,
ledger: McpSyncLedger
): Promise<Record<string, McpSyncHostOutcome>> {
for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) {
const text = await readText(paths.claudeConfig)
let merged: ReturnType<typeof mergeClaudeMcpServers>
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<string, Record<string, unknown>>,
ledger: McpSyncLedger
): Promise<Record<string, McpSyncHostOutcome>> {
for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) {
const text = await readText(paths.codexConfig)
let merged: ReturnType<typeof mergeCodexMcpServers>
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<AgentMcpSyncResult> {
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<string, McpSyncHostOutcome> = {}
const desired = await runnable(params.claude, commandExists, outcomes)
result.claude = { ...outcomes, ...(await syncClaude(deps.paths, desired, ledger)) }
}
if (params.codex) {
const outcomes: Record<string, McpSyncHostOutcome> = {}
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, McpSyncHostOutcome>): string =>
Object.entries(
Object.values(outcomes ?? {}).reduce<Record<string, number>>((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
}
@@ -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<string, string>
/** 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<string, unknown>)[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<McpSyncLedger>
const claude: Record<string, string> = {}
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: {} }
}
}
@@ -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'))
})
}
})
]
@@ -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,
@@ -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<string, unknown> } | { 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<string, unknown> {
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<string, unknown>): 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)
}
}
@@ -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<typeof McpSyncHostOutcome>
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<typeof AgentMcpSyncParams>
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<typeof AgentMcpSyncResult>
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}`
@@ -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]
])
})
})
+124
View File
@@ -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<string>
/** Names the workspace repo's own `.mcp.json` declares; Claude reads that file on the host already. */
repoServerNames: ReadonlySet<string>
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<string, LocalMcpServer>()
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<string, Record<string, unknown>> = {}
const codex: Record<string, Record<string, unknown>> = {}
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 }
: {})
}
})
}