sync: Shanghai-U9 2026-07-22 tools-sync MCP双向同步脚本+MATLAB专项节;撤出误入库的__pycache__
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# 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 -eq "node_repl") { return $true }
|
||||
$command = [string]$Mcp.command
|
||||
return ($command -match '(?i)[\\/]\.codex[\\/](runtimes|plugins[\\/]cache)[\\/]')
|
||||
}
|
||||
|
||||
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 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]
|
||||
}
|
||||
$value = [ordered]@{
|
||||
transport = $Mcp.transport
|
||||
command = $Mcp.command
|
||||
args = @($Mcp.args)
|
||||
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) {
|
||||
if ($Mcp.transport -eq "stdio") {
|
||||
$envMap = [ordered]@{}
|
||||
foreach ($key in $Mcp.env.Keys) {
|
||||
if ($CodexOnlyEnv -notcontains $key) { $envMap[$key] = $Mcp.env[$key] }
|
||||
}
|
||||
$cfg = [ordered]@{ type = "stdio"; command = $Mcp.command; args = @($Mcp.args); env = $envMap }
|
||||
} else {
|
||||
if ($Mcp.bearer_token_env_var) {
|
||||
throw "Codex bearer_token_env_var has no lossless Claude JSON translation: $($Mcp.name)"
|
||||
}
|
||||
$cfg = [ordered]@{ type = $Mcp.transport; url = $Mcp.url }
|
||||
if ($Mcp.headers.Count -gt 0) { $cfg.headers = $Mcp.headers }
|
||||
}
|
||||
$json = $cfg | ConvertTo-Json -Depth 8 -Compress
|
||||
Invoke-External "claude add $($Mcp.name)" "claude" @("mcp", "add-json", "--scope", "user", $Mcp.name, $json)
|
||||
}
|
||||
|
||||
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 ==="
|
||||
Reference in New Issue
Block a user