fix(converters): preserve user config when writing MCP servers (#479)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-01 11:46:57 -07:00
committed by GitHub
parent c56c7667df
commit c65a698d93
8 changed files with 862 additions and 71 deletions

View File

@@ -1,15 +1,11 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { renderCodexConfig } from "../targets/codex"
import { mergeCodexConfig, renderCodexConfig } from "../targets/codex"
import { writeTextSecure } from "../utils/files"
import { syncCodexCommands } from "./commands"
import { syncSkills } from "./skills"
const CURRENT_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const CURRENT_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"
export async function syncToCodex(
config: ClaudeHomeConfig,
outputRoot: string,
@@ -17,52 +13,19 @@ export async function syncToCodex(
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncCodexCommands(config, outputRoot)
// Write MCP servers to config.toml (TOML format)
if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "config.toml")
const mcpToml = renderCodexConfig(config.mcpServers)
if (!mcpToml) {
return
// Write MCP servers to config.toml, or clean up stale managed block if none remain
const configPath = path.join(outputRoot, "config.toml")
let existingContent = ""
try {
existingContent = await fs.readFile(configPath, "utf-8")
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
// Read existing config and merge idempotently
let existingContent = ""
try {
existingContent = await fs.readFile(configPath, "utf-8")
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
}
const managedBlock = [
CURRENT_START_MARKER,
mcpToml.trim(),
CURRENT_END_MARKER,
"",
].join("\n")
const withoutCurrentBlock = existingContent.replace(
new RegExp(
`${escapeForRegex(CURRENT_START_MARKER)}[\\s\\S]*?${escapeForRegex(CURRENT_END_MARKER)}\\n?`,
"g",
),
"",
).trimEnd()
const legacyMarkerIndex = withoutCurrentBlock.indexOf(LEGACY_MARKER)
const cleaned = legacyMarkerIndex === -1
? withoutCurrentBlock
: withoutCurrentBlock.slice(0, legacyMarkerIndex).trimEnd()
const newContent = cleaned
? `${cleaned}\n\n${managedBlock}`
: `${managedBlock}`
await writeTextSecure(configPath, newContent)
}
const mcpToml = renderCodexConfig(config.mcpServers)
const merged = mergeCodexConfig(existingContent, mcpToml)
if (merged !== null) {
await writeTextSecure(configPath, merged)
}
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

View File

@@ -1,9 +1,17 @@
import fs from "fs/promises"
import path from "path"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeText, writeTextSecure } from "../utils/files"
import type { CodexBundle } from "../types/codex"
import type { ClaudeMcpServer } from "../types/claude"
import { transformContentForCodex } from "../utils/codex-content"
const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block"
const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP"
const PREV_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const PREV_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"
const UNMARKED_LEGACY_MARKER = "# Generated by compound-plugin"
export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): Promise<void> {
const codexRoot = resolveCodexRoot(outputRoot)
await ensureDir(codexRoot)
@@ -35,14 +43,16 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
}
}
const config = renderCodexConfig(bundle.mcpServers)
if (config) {
const configPath = path.join(codexRoot, "config.toml")
const configPath = path.join(codexRoot, "config.toml")
const existingConfig = await readFileSafe(configPath)
const mcpToml = renderCodexConfig(bundle.mcpServers)
const merged = mergeCodexConfig(existingConfig, mcpToml)
if (merged !== null) {
const backupPath = await backupFile(configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
await writeText(configPath, config)
await writeTextSecure(configPath, merged)
}
}
@@ -53,9 +63,11 @@ function resolveCodexRoot(outputRoot: string): string {
export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>): string | null {
if (!mcpServers || Object.keys(mcpServers).length === 0) return null
const lines: string[] = ["# Generated by compound-plugin", ""]
const lines: string[] = []
for (const [name, server] of Object.entries(mcpServers)) {
if (!server.command && !server.url) continue
const key = formatTomlKey(name)
lines.push(`[mcp_servers.${key}]`)
@@ -83,7 +95,60 @@ export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>):
lines.push("")
}
return lines.join("\n")
return lines.length > 0 ? lines.join("\n") : null
}
async function readFileSafe(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, "utf-8")
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
return ""
}
}
export function mergeCodexConfig(existingContent: string, mcpToml: string | null): string | null {
// Strip current and previous managed blocks
let stripped = existingContent
for (const [start, end] of [[MANAGED_START_MARKER, MANAGED_END_MARKER], [PREV_START_MARKER, PREV_END_MARKER]]) {
stripped = stripped.replace(
new RegExp(`${escapeForRegex(start)}[\\s\\S]*?${escapeForRegex(end)}\\n?`, "g"),
"",
)
}
stripped = stripped.trimEnd()
// Strip from legacy markers to end of content (old formats wrote everything after the marker)
let cleaned = stripped
for (const marker of [LEGACY_MARKER, UNMARKED_LEGACY_MARKER]) {
const idx = cleaned.indexOf(marker)
if (idx !== -1) {
cleaned = cleaned.slice(0, idx).trimEnd()
}
}
// No MCP servers to write — return cleaned content, or null only if there was never a file
if (!mcpToml) {
if (!existingContent) return null
return cleaned
}
const managedBlock = [
MANAGED_START_MARKER,
mcpToml.trim(),
MANAGED_END_MARKER,
"",
].join("\n")
return cleaned
? `${cleaned}\n\n${managedBlock}`
: `${managedBlock}`
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function formatTomlString(value: string): string {

View File

@@ -1,5 +1,5 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJsonSecure, writeText } from "../utils/files"
import { transformContentForCopilot } from "../converters/claude-to-copilot"
import type { CopilotBundle } from "../types/copilot"
@@ -28,13 +28,67 @@ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBund
}
}
if (bundle.mcpConfig && Object.keys(bundle.mcpConfig).length > 0) {
const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
const merged = await mergeCopilotMcpConfig(mcpPath, bundle.mcpConfig ?? {})
if (merged !== null) {
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`)
}
await writeJson(mcpPath, { mcpServers: bundle.mcpConfig })
await writeJsonSecure(mcpPath, merged)
}
}
const MANAGED_KEY = "_compound_managed_mcp"
async function mergeCopilotMcpConfig(
configPath: string,
incoming: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
let existing: Record<string, unknown> = {}
if (await pathExists(configPath)) {
try {
const parsed = await readJson<unknown>(configPath)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>
}
} catch {
// Unparseable file — proceed with incoming only
}
}
const existingMcp = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
? { ...(existing.mcpServers as Record<string, unknown>) }
: {}
// Remove previously-managed plugin servers that are no longer in the bundle.
// Legacy migration: if no tracking key exists AND plugin has servers, assume all
// existing servers are plugin-managed (the old writer overwrote the entire file).
// When incoming is empty, skip pruning — there's nothing to migrate and we'd
// wrongly delete user servers from a pre-existing untracked config.
const incomingKeys = Object.keys(incoming)
const hasTrackingKey = Array.isArray(existing[MANAGED_KEY])
const prevManaged = hasTrackingKey
? existing[MANAGED_KEY] as string[]
: incomingKeys.length > 0 ? Object.keys(existingMcp) : []
for (const name of prevManaged) {
if (!(name in incoming)) {
delete existingMcp[name]
}
}
const mergedMcp = { ...existingMcp, ...incoming }
// Nothing to write — no user servers, no plugin servers, no existing file
if (Object.keys(mergedMcp).length === 0 && Object.keys(existing).length === 0) {
return null
}
// Always write tracking key (even as []) to prevent legacy fallback on future installs
return {
...existing,
mcpServers: mergedMcp,
[MANAGED_KEY]: incomingKeys,
}
}

View File

@@ -1,18 +1,19 @@
import path from "path"
import { backupFile, copyDir, ensureDir, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, copyDir, ensureDir, readJson, resolveCommandPath, sanitizePathName, pathExists, writeJsonSecure, writeText } from "../utils/files"
import type { QwenBundle, QwenExtensionConfig } from "../types/qwen"
export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise<void> {
const qwenPaths = resolveQwenPaths(outputRoot)
await ensureDir(qwenPaths.root)
// Write qwen-extension.json config
// Merge qwen-extension.json config, preserving existing user MCP servers
const configPath = qwenPaths.configPath
const backupPath = await backupFile(configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
await writeJson(configPath, bundle.config)
const merged = await mergeQwenConfig(configPath, bundle.config)
await writeJsonSecure(configPath, merged)
// Write context file (QWEN.md)
if (bundle.contextFile) {
@@ -45,6 +46,76 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P
}
}
const MANAGED_KEY = "_compound_managed_mcp"
const MANAGED_KEYS_KEY = "_compound_managed_keys"
const TRACKING_KEYS = new Set([MANAGED_KEY, MANAGED_KEYS_KEY])
async function mergeQwenConfig(
configPath: string,
incoming: QwenExtensionConfig,
): Promise<QwenExtensionConfig> {
let existing: Record<string, unknown> = {}
if (await pathExists(configPath)) {
try {
const parsed = await readJson<unknown>(configPath)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>
}
} catch {
// Unparseable file — proceed with incoming only
}
}
const existingMcp = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
? { ...(existing.mcpServers as Record<string, unknown>) }
: {}
// Remove previously-managed plugin servers that are no longer in the bundle.
// Legacy migration: if no tracking key exists AND plugin has servers, assume all
// existing servers are plugin-managed (the old writer overwrote the entire file).
// When incoming is empty, skip pruning — there's nothing to migrate and we'd
// wrongly delete user servers from a pre-existing untracked config.
const incomingMcp = incoming.mcpServers ?? {}
const hasTrackingKey = Array.isArray(existing[MANAGED_KEY])
const prevManaged = hasTrackingKey
? existing[MANAGED_KEY] as string[]
: Object.keys(incomingMcp).length > 0 ? Object.keys(existingMcp) : []
for (const name of prevManaged) {
if (!(name in incomingMcp)) {
delete existingMcp[name]
}
}
const mergedMcp = { ...existingMcp, ...incomingMcp }
const { mcpServers: _, ...incomingRest } = incoming
const incomingTopKeys = Object.keys(incomingRest).filter((k) => !TRACKING_KEYS.has(k))
// Prune top-level keys from previous installs that are no longer in the incoming bundle.
// Only prune keys we previously tracked; skip on first install (no tracking key yet).
const prevManagedKeys = Array.isArray(existing[MANAGED_KEYS_KEY])
? existing[MANAGED_KEYS_KEY] as string[]
: []
for (const key of prevManagedKeys) {
if (!incomingTopKeys.includes(key) && key in existing) {
delete existing[key]
}
}
const merged = { ...existing, ...incomingRest } as QwenExtensionConfig & Record<string, unknown>
if (Object.keys(mergedMcp).length > 0) {
merged.mcpServers = mergedMcp as QwenExtensionConfig["mcpServers"]
} else {
delete merged.mcpServers
}
// Always write tracking keys (even as []) so future installs know what to prune.
merged[MANAGED_KEY] = Object.keys(incomingMcp)
merged[MANAGED_KEYS_KEY] = incomingTopKeys
return merged as QwenExtensionConfig
}
function resolveQwenPaths(outputRoot: string) {
return {
root: outputRoot,