# tools-sync: audit and reconcile user-configured MCP registrations between # Claude Code and Codex. Managed connectors, plugins, and Codex runtime MCPs are # intentionally excluded because they are owned by their host application. [CmdletBinding()] param( [string]$ClaudeConfig = "$env:USERPROFILE\.claude.json", [string]$CodexConfig = "$env:USERPROFILE\.codex\config.toml", [switch]$Apply, [ValidateSet("None", "Claude", "Codex")] [string]$ConflictAuthority = "None", [string[]]$ExcludeName = @(), [string]$PythonCommand = "python" ) $ErrorActionPreference = "Stop" $script:CodexBackupMade = $false $script:Failures = @() $CodexOnlyEnv = @("PYTHONIOENCODING", "PYTHONUTF8") function ConvertTo-StringMap($Object) { $result = [ordered]@{} if ($null -eq $Object) { return $result } foreach ($property in $Object.psobject.Properties | Sort-Object Name) { $result[$property.Name] = [string]$property.Value } return $result } function ConvertFrom-DictionaryToStringMap($Dictionary) { $result = [ordered]@{} if ($null -eq $Dictionary) { return $result } foreach ($key in @($Dictionary.Keys) | Sort-Object) { $result[[string]$key] = [string]$Dictionary[$key] } return $result } function New-CanonicalMcp([string]$Name, [string]$Transport, [string]$Command, [object[]]$CommandArgs, $EnvMap, [string]$Url, $Headers, [string]$BearerTokenEnvVar, [string]$Source) { [pscustomobject][ordered]@{ name = $Name transport = $Transport command = if ($Command) { $Command } else { "" } args = @($CommandArgs | ForEach-Object { [string]$_ }) env = $EnvMap url = if ($Url) { $Url } else { "" } headers = $Headers bearer_token_env_var = if ($BearerTokenEnvVar) { $BearerTokenEnvVar } else { "" } source = $Source } } function Get-ClaudeMcps { if (-not (Test-Path -LiteralPath $ClaudeConfig)) { throw "Claude Code config not found: $ClaudeConfig" } $root = Get-Content -Raw -LiteralPath $ClaudeConfig | ConvertFrom-Json $result = @{} if ($null -eq $root.mcpServers) { return $result } foreach ($property in $root.mcpServers.psobject.Properties) { $name = $property.Name $cfg = $property.Value $transport = if ($cfg.type -in @("http", "sse")) { [string]$cfg.type } else { "stdio" } $result[$name] = New-CanonicalMcp $name $transport ([string]$cfg.command) @($cfg.args) ` (ConvertTo-StringMap $cfg.env) ([string]$cfg.url) ` (ConvertTo-StringMap $cfg.headers) "" "Claude" } return $result } function Get-CodexMcps { if (-not (Test-Path -LiteralPath $CodexConfig)) { throw "Codex config not found: $CodexConfig" } $python = @' import json, sys, tomllib with open(sys.argv[1], "rb") as f: data = tomllib.load(f) print(json.dumps(data.get("mcp_servers", {}), ensure_ascii=False)) '@ $parseOutput = @($python | & $PythonCommand - $CodexConfig 2>&1) if ($LASTEXITCODE -ne 0) { $detail = ($parseOutput | ForEach-Object { "$_" }) -join "`n" throw "Failed to parse Codex TOML with $PythonCommand`: $detail" } $json = $parseOutput -join "`n" $root = $json | ConvertFrom-Json $result = @{} foreach ($property in $root.psobject.Properties) { $name = $property.Name $cfg = $property.Value $transport = if ($cfg.url) { "http" } else { "stdio" } $result[$name] = New-CanonicalMcp $name $transport ([string]$cfg.command) @($cfg.args) ` (ConvertTo-StringMap $cfg.env) ([string]$cfg.url) ` (ConvertTo-StringMap $cfg.http_headers) ([string]$cfg.bearer_token_env_var) "Codex" } return $result } function Test-CodexManaged($Mcp) { if ($Mcp.name -in @("node_repl", "cua_repl")) { return $true } $command = [string]$Mcp.command if ($command -match '(?i)[\\/]\.codex[\\/](runtimes|plugins[\\/]cache)[\\/]') { return $true } # The Codex desktop app also bundles MCPs from its own install dir (MSIX # WindowsApps, or AppData\Local\OpenAI\Codex). Host-owned, not portable. return ($command -match '(?i)[\\/](WindowsApps[\\/]OpenAI\.Codex|OpenAI[\\/]Codex[\\/])') } function Get-PortableMcps($Map, [string]$Side) { $result = @{} foreach ($name in $Map.Keys) { if ($ExcludeName -contains $name) { continue } $mcp = $Map[$name] if ($Side -eq "Codex" -and (Test-CodexManaged $mcp)) { continue } $result[$name] = $mcp } return $result } function ConvertTo-PortablePath([string]$Value) { # `claude mcp add-json` rejects Windows backslash paths ("Invalid configuration: # : Invalid input"); forward slashes work on Windows and are what the CLI itself # writes. Only rewrite strings that actually carry a drive-letter path, so flags # and non-path args are left alone (`--extension-file=C:\..` is rewritten too). if ($null -eq $Value) { return $Value } if ($Value -match '[A-Za-z]:\\') { return ($Value -replace '\\', '/') } return $Value } function Get-ComparableJson($Mcp) { $envMap = [ordered]@{} foreach ($key in @($Mcp.env.Keys) | Sort-Object) { if ($CodexOnlyEnv -contains $key) { continue } $envMap[$key] = [string]$Mcp.env[$key] } $headers = [ordered]@{} foreach ($key in @($Mcp.headers.Keys) | Sort-Object) { $headers[$key] = [string]$Mcp.headers[$key] } # `C:\x\y` and `C:/x/y` are the same target on Windows -- the two CLIs write # different separators, so compare on a single normalized form or every path # arg reads as drift. $value = [ordered]@{ transport = $Mcp.transport command = (ConvertTo-PortablePath $Mcp.command) args = @(@($Mcp.args) | ForEach-Object { ConvertTo-PortablePath $_ }) env = $envMap url = $Mcp.url headers = $headers bearer_token_env_var = $Mcp.bearer_token_env_var } return ($value | ConvertTo-Json -Depth 8 -Compress) } function Backup-CodexConfig { if ($script:CodexBackupMade) { return } $stamp = Get-Date -Format "yyyyMMdd-HHmmss" $backup = "$CodexConfig.bak-$stamp" Copy-Item -LiteralPath $CodexConfig -Destination $backup $script:CodexBackupMade = $true Write-Host "backup: $backup" } function Invoke-External([string]$Label, [string]$Exe, [string[]]$Arguments) { & $Exe @Arguments if ($LASTEXITCODE -ne 0) { throw "$Label failed (exit $LASTEXITCODE)" } } function Add-ToCodex($Mcp) { if ($Mcp.transport -eq "stdio") { Backup-CodexConfig $arguments = @("mcp", "add", $Mcp.name) $envMap = [ordered]@{} foreach ($key in $Mcp.env.Keys) { $envMap[$key] = $Mcp.env[$key] } $envMap["PYTHONIOENCODING"] = "utf-8" $envMap["PYTHONUTF8"] = "1" foreach ($key in $envMap.Keys) { $arguments += @("--env", "$key=$($envMap[$key])") } $arguments += "--" $arguments += $Mcp.command $arguments += @($Mcp.args) Invoke-External "codex add $($Mcp.name)" "codex" $arguments return } if ($Mcp.headers.Count -gt 0) { throw "HTTP headers cannot be translated safely by 'codex mcp add': $($Mcp.name)" } Backup-CodexConfig Invoke-External "codex add $($Mcp.name)" "codex" @("mcp", "add", $Mcp.name, "--url", $Mcp.url) } function Add-ToClaude($Mcp) { # Deliberately NOT `claude mcp add-json`: Windows PowerShell 5.1 strips the # double quotes out of a JSON argument on its way to a native exe, so claude.exe # sees malformed JSON and dies with "Invalid configuration: : Invalid input". # The flag form takes plain tokens and is immune. (Separately, add-json rejects # backslash paths even when quoting is clean -- hence ConvertTo-PortablePath.) if ($Mcp.transport -eq "stdio") { $arguments = @("mcp", "add", $Mcp.name, "--scope", "user") foreach ($key in $Mcp.env.Keys) { if ($CodexOnlyEnv -contains $key) { continue } $arguments += @("--env", "$key=$($Mcp.env[$key])") } $arguments += "--" $arguments += (ConvertTo-PortablePath $Mcp.command) foreach ($arg in @($Mcp.args)) { $arguments += (ConvertTo-PortablePath $arg) } Invoke-External "claude add $($Mcp.name)" "claude" $arguments return } if ($Mcp.bearer_token_env_var) { throw "Codex bearer_token_env_var has no lossless Claude translation: $($Mcp.name)" } $arguments = @("mcp", "add", $Mcp.name, "--scope", "user", "--transport", $Mcp.transport, $Mcp.url) foreach ($key in $Mcp.headers.Keys) { $arguments += @("--header", "$key`: $($Mcp.headers[$key])") } Invoke-External "claude add $($Mcp.name)" "claude" $arguments } function Remove-FromClaude([string]$Name) { Invoke-External "claude remove $Name" "claude" @("mcp", "remove", "--scope", "user", $Name) } function Remove-FromCodex([string]$Name) { Backup-CodexConfig Invoke-External "codex remove $Name" "codex" @("mcp", "remove", $Name) } function Try-Action([string]$Description, [scriptblock]$Action) { try { & $Action Write-Host "OK: $Description" } catch { $script:Failures += "$Description`: $($_.Exception.Message)" Write-Warning $script:Failures[-1] } } $claude = Get-PortableMcps (Get-ClaudeMcps) "Claude" $codex = Get-PortableMcps (Get-CodexMcps) "Codex" $onlyClaude = @($claude.Keys | Where-Object { -not $codex.ContainsKey($_) } | Sort-Object) $onlyCodex = @($codex.Keys | Where-Object { -not $claude.ContainsKey($_) } | Sort-Object) $drift = @($claude.Keys | Where-Object { $codex.ContainsKey($_) -and (Get-ComparableJson $claude[$_] ) -ne (Get-ComparableJson $codex[$_]) } | Sort-Object) $matched = @($claude.Keys | Where-Object { $codex.ContainsKey($_) -and (Get-ComparableJson $claude[$_] ) -eq (Get-ComparableJson $codex[$_]) } | Sort-Object) foreach ($name in @($claude.Keys | Where-Object { $codex.ContainsKey($_) } | Sort-Object)) { Write-Verbose "$name Claude canonical: $(Get-ComparableJson $claude[$name])" Write-Verbose "$name Codex canonical: $(Get-ComparableJson $codex[$name])" } Write-Host "=== MCP bidirectional audit ===" Write-Host "Claude only : $($onlyClaude -join ', ')" Write-Host "Codex only : $($onlyCodex -join ', ')" Write-Host "Drift : $($drift -join ', ')" Write-Host "Matched : $($matched -join ', ')" if ($Apply) { foreach ($name in $onlyClaude) { Try-Action "copied $name from Claude to Codex" { Add-ToCodex $claude[$name] } } foreach ($name in $onlyCodex) { Try-Action "copied $name from Codex to Claude" { Add-ToClaude $codex[$name] } } foreach ($name in $drift) { if ($ConflictAuthority -eq "Claude") { Try-Action "replaced Codex $name from Claude authority" { Remove-FromCodex $name Add-ToCodex $claude[$name] } } elseif ($ConflictAuthority -eq "Codex") { Try-Action "replaced Claude $name from Codex authority" { Remove-FromClaude $name Add-ToClaude $codex[$name] } } else { Write-Warning "not overwriting drifted MCP '$name'; choose -ConflictAuthority Claude or Codex" } } } if ($script:Failures.Count -gt 0) { Write-Host "=== failures ===" $script:Failures | ForEach-Object { Write-Host "- $_" } exit 1 } if (-not $Apply -and ($onlyClaude.Count + $onlyCodex.Count + $drift.Count -gt 0)) { exit 2 } Write-Host "=== MCP audit complete ==="