mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): host-side MCP config merge for agents (Claude ~/.claude.json, Codex config.toml) with ownership ledger
This commit is contained in:
@@ -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]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user