diff --git a/langgraph-plan-review/scripts/__pycache__/agents.cpython-311.pyc b/langgraph-plan-review/scripts/__pycache__/agents.cpython-311.pyc deleted file mode 100644 index 44ceaa2..0000000 Binary files a/langgraph-plan-review/scripts/__pycache__/agents.cpython-311.pyc and /dev/null differ diff --git a/langgraph-plan-review/scripts/__pycache__/graph.cpython-311.pyc b/langgraph-plan-review/scripts/__pycache__/graph.cpython-311.pyc deleted file mode 100644 index 150b3d2..0000000 Binary files a/langgraph-plan-review/scripts/__pycache__/graph.cpython-311.pyc and /dev/null differ diff --git a/langgraph-plan-review/scripts/__pycache__/workflow.cpython-311.pyc b/langgraph-plan-review/scripts/__pycache__/workflow.cpython-311.pyc deleted file mode 100644 index e61694a..0000000 Binary files a/langgraph-plan-review/scripts/__pycache__/workflow.cpython-311.pyc and /dev/null differ diff --git a/tools-sync/SKILL.md b/tools-sync/SKILL.md index f152447..4f1ce92 100644 --- a/tools-sync/SKILL.md +++ b/tools-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: tools-sync -description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills、MCP server 和开发环境:将 ~\.claude\skills 和 ~\.codex\skills 与 git.rpchannel.com 上的 bowei/skills-win 仓库保持同步,审计 skills 中的硬编码参数并迁移到本机 ~\skills_env.md,校验 langgraph conda 环境,并确保 E:\MCP_design 下所有 MCP 都已在 Claude Code 和 Codex 中注册。当用户提到"同步skills"、"skills同步"、"tools sync"、"tools-sync"、"同步codex"、"同步MCP"、"环境同步"、"skills-win"、"把skills推上去/拉下来"、"新电脑配置claude/codex环境"、"检查MCP注册"、"skills_env"时,必须使用此技能,即使用户没有明确说"skill"二字。 +description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills、MCP server 和开发环境:将 ~\.claude\skills 和 ~\.codex\skills 与 git.rpchannel.com 上的 bowei/skills-win 仓库保持同步,审计 skills 中的硬编码参数并迁移到本机 ~\skills_env.md,校验 langgraph conda 环境,双向发现并补齐 Claude Code 与 Codex 的用户 MCP 注册,同时确保 E:\MCP_design 下所有 MCP 均已注册。当用户提到"同步skills"、"skills同步"、"tools sync"、"tools-sync"、"同步codex"、"同步MCP"、"环境同步"、"skills-win"、"把skills推上去/拉下来"、"新电脑配置claude/codex环境"、"检查MCP注册"、"skills_env"时,必须使用此技能,即使用户没有明确说"skill"二字。 --- # tools-sync:Claude Code / Codex 工具链同步 @@ -116,10 +116,41 @@ commit + push 一次(junction 模型下 codex 侧自动同步,无需额外 失败(环境不存在或包缺失)→ 调用 `langgraph-vivado-install` skill 完成安装, 装好后把实际的 python 路径回填到 `~\skills_env.md`。 -## 阶段 5 — MCP server 注册校验(Claude Code + Codex 两边) +## 阶段 5 — MCP server 双向发现、注册与校验(Claude Code ↔ Codex) -MCP 的清单真源是 `E:\MCP_design\mcp_list.md`(路径见 skills_env.md 的 -`MCP_DESIGN_ROOT`),每行一个 MCP 工程目录。对清单里的每一项: +先运行双向审计脚本。它读取 Claude Code 用户级 `~\.claude.json` 的 `mcpServers` 和 +Codex 用户级 `~\.codex\config.toml` 的 `[mcp_servers.*]`,对用户可配置 MCP 做并集: + +```powershell +# 只审计;发现差集或漂移时退出码为 2,不修改配置 +powershell -File \scripts\sync_mcp_registrations.ps1 + +# 双向补齐单边缺失项;同名漂移默认只报告,不覆盖 +powershell -File \scripts\sync_mcp_registrations.ps1 -Apply + +# 经核实后才可选择一侧作为同名漂移项的权威配置 +powershell -File \scripts\sync_mcp_registrations.ps1 -Apply -ConflictAuthority Claude +# 或:-ConflictAuthority Codex +``` + +脚本必须遵守以下规则: + +- Claude 独有的用户 MCP 补到 Codex;Codex 独有的用户 MCP 补到 Claude,实现双向同步。 +- 云端账号连接器、插件动态 MCP、Codex 自带运行时/插件 MCP 不属于可移植用户配置, + 必须排除,不能复制到另一侧。额外的机器特有项可用 `-ExcludeName ` 排除。 +- 用户级配置才自动双向同步。Claude 的项目级 `.mcp.json` / local scope 不自动提升成 + Codex 全局配置;如确需提升,先向用户说明作用域扩大,再手工处理。 +- 同名 MCP 要规范化比较 transport、command、args、env、URL 和 headers。 + Codex stdio 专用的 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1` 是允许的平台差异, + 比较时忽略;Claude → Codex 时自动补上,Codex → Claude 时自动移除。 +- 同名配置漂移时默认只报告,不能猜哪侧正确。结合 `~\skills_env.md`、工程实际路径和 + 用户意图核实后,才可显式指定 `-ConflictAuthority Claude` 或 `Codex` 覆盖另一侧。 +- 修改 Codex 配置前脚本必须创建带时间戳的备份。含无法无损转换的 HTTP headers、 + bearer-token 设置等配置时应停止该项并报告,不得静默丢字段。 + +然后用 `E:\MCP_design\mcp_list.md`(路径见 skills_env.md 的 `MCP_DESIGN_ROOT`)做 +第二层完整性校验。每行一个 MCP 工程目录;它能发现“工程存在但两侧都没注册”的项目, +这是仅比较两个注册表的并集无法发现的。对清单里的每一项: 1. **Claude Code 侧**:`claude mcp list` 或读 `~\.claude.json` 的 `mcpServers`, 确认存在对应条目。缺失的用 `claude mcp add ...` 注册,命令、解释器 @@ -132,9 +163,62 @@ MCP 的清单真源是 `E:\MCP_design\mcp_list.md`(路径见 skills_env.md 的 确认进程能起来、没有立刻报 import/路径错误(stdio MCP 起来后会静默等 输入,这就算通过,Ctrl-C 掉即可)。 -注意两侧配置语义相同但格式不同(JSON vs TOML),同一个 MCP 在两边的 -command/args/env 应当一致;发现两边漂移(比如只有一边改了 VIVADO_PATH)时, -以 skills_env.md 记录的值为准改齐两边。 +注意两侧配置语义相同但格式不同(JSON vs TOML)。先由双向脚本发现差集和漂移,再用 +`mcp_list.md` 查漏,最后以 skills_env.md 和实际可启动结果判定正确值。不要因为某项当前 +只在一侧出现,就假设另一侧没有安装需求。 + +### MATLAB / Simulink Agentic Toolkit 专项检查与安装 + +每次执行 MCP 同步时,还要专项检查 MathWorks Agentic Toolkits。它们不一定位于 +`MCP_DESIGN_ROOT`,因此不能只依赖 `mcp_list.md`。MATLAB 与 Simulink 共用一个名为 +`matlab` 的 MCP server;Simulink 能力通过 `--extension-file=.../simulink/tools/tools.json` +加载,不应错误地要求再注册一个独立的 `simulink` MCP。 + +检查以下项目,任一缺失均视为未完整安装: + +1. 本机存在 MATLAB;MATLAB Toolkit 要求 R2020b+,Simulink Toolkit 要求 R2023a+ + 且已安装 Simulink。实际 MATLAB 根目录记录到 `~\skills_env.md` 的 `MATLAB_ROOT`。 +2. `~\.matlab\agentic-toolkits\bin\matlab-mcp-server.exe` 存在且 `--version` 可运行; + `config.json` 的 `toolkits` 同时包含 `matlab` 与 `simulink`。 +3. Claude `~\.claude.json` 与 Codex `~\.codex\config.toml` 均有用户级 `matlab` MCP, + command 指向同一二进制,args 同时包含 `--matlab-session-mode=existing` 和正确的 + `--extension-file`。Claude 条目应有 `alwaysLoad=true`。 +4. Codex 的 `[mcp_servers.matlab]` 还必须有 `tool_timeout_sec = 600` 和 Windows 所需的 + `env_vars = ["WINDIR"]`;缺少 `WINDIR` 时 Simulink 子进程可能无法正常工作。 +5. `~\.agents\skills` 中存在指向两套 Toolkit 的 junction;Claude Code 的 MATLAB / + Simulink plugin 或等效 skill 注册存在。 + +缺失时使用 MathWorks 官方统一安装器,不使用旧版 agent-driven 安装法: + +```powershell +$installer = Join-Path $env:TEMP "mathworks-agentic-installer\agenticToolkitInstaller.mltbx" +Invoke-WebRequest -UseBasicParsing ` + "https://github.com/matlab/simulink-agentic-toolkit/releases/latest/download/agenticToolkitInstaller.mltbx" ` + -OutFile $installer + +# MATLAB 中先安装 installer,再分别执行 install 和 configure;Prompt=false 可无交互运行。 +matlab.addons.toolbox.installToolbox(""); +setupAgenticToolkit("install", Toolkit=["matlab","simulink"], ... + SessionMode="existing", Prompt=false); +setupAgenticToolkit("configure", Agents=["claude-code","codex"], ... + Scope="global", SessionMode="existing", Prompt=false); +``` + +注意事项: + +- `install` 与 `configure` 是两个独立动作,两者都必须成功;仅看到 Toolkit 文件落盘不代表 + MCP 已注册。每次修改 agent 配置前先备份 Claude JSON 与 Codex TOML。 +- 如果新 MATLAB 进程找不到 `setupAgenticToolkit`,先确认 “MathWorks Agentic Toolkit + Setup” Add-On 已启用;必要时把其实际 Toolbox 安装目录 `addpath` 后再执行。如果 + R2025b 的 Add-On 管理器能列出 Toolbox 却无法启用,则将 “MathWorks Agentic Toolkit + Setup” 与 “MATLAB MCP Server Toolbox” 两个官方 Toolbox 根目录加入 path 并 + `savepath`,再用全新 MATLAB 进程通过 `which setupAgenticToolkit`、 + `which shareMATLABSession` 验证。 +- 安装后重新运行双向审计脚本;`matlab` 必须显示为 Matched。再直接解析两边配置,确认 + Simulink extension、Codex timeout 与 `WINDIR` 未被规范化比较遗漏。 +- 使用 Simulink 工具前,每个 MATLAB 会话运行 + `addpath("~/.matlab/agentic-toolkits/simulink"); satk_initialize`,然后重启 Claude Code / + Codex 会话。MCP 健康检查在没有共享 MATLAB 会话时可能显示连接失败,这不等于未安装。 ## 阶段 6 — 输出同步报告 @@ -150,7 +234,7 @@ command/args/env 应当一致;发现两边漂移(比如只有一边改了 VI - 遗留待改: <清单> ## 环境 - langgraph env: OK / 已重装 / 失败原因 -- MCP 注册: <两侧各缺了什么、补了什么、抽查结果> +- MCP 注册: ## 需要用户决定的事项 - <冲突、密码泄露、codex 独有 skill 去留……没有则写"无"> ``` diff --git a/tools-sync/references/skills_env_template.md b/tools-sync/references/skills_env_template.md index 499169d..b0f2d2d 100644 --- a/tools-sync/references/skills_env_template.md +++ b/tools-sync/references/skills_env_template.md @@ -30,7 +30,9 @@ CONDA_LANGGRAPH_PYTHON = C:\Users\\.conda\envs\langgraph\python.exe VIVADO_ROOT = VIVADO_PATH = <上一行 + \bin\vivado.bat> KICAD_PYTHONPATH = C:\Program Files\KiCad\10.0\bin\Lib\site-packages -MATLAB_MCP_EXE = C:\Users\\.matlab\agentic-toolkits\bin\matlab-mcp-server.exe +MATLAB_ROOT = +MATLAB_AGENTIC_ROOT = C:\Users\\.matlab\agentic-toolkits +MATLAB_MCP_EXE = {MATLAB_AGENTIC_ROOT}\bin\matlab-mcp-server.exe ``` ## 板卡 / 仪器 IP @@ -48,6 +50,9 @@ MCP_DESIGN_ROOT = E:\MCP_design 每个 MCP 在 Claude Code(`~\.claude.json` 的 `mcpServers`)和 Codex (`~\.codex\config.toml` 的 `[mcp_servers.*]`)中应使用**同一份** command/args/env。 +同步时还要对两侧用户级注册表取并集,确保任一侧新增的可移植 MCP 都同步到另一侧; +`mcp_list.md` 继续用于发现“两侧都漏装”的本地工程。托管连接器、插件动态 MCP、 +Codex 内置运行时 MCP 不进入这个并集。 标准配置如下(路径变量引用上文条目): | name | command | args | env | @@ -60,6 +65,8 @@ MCP_DESIGN_ROOT = E:\MCP_design | matlab | {MATLAB_MCP_EXE} | --matlab-session-mode=existing --extension-file=...\simulink\tools\tools.json | | Codex 侧所有 stdio MCP 额外加 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`。 +MathWorks `matlab` MCP 是特例:Codex 还要设置 `tool_timeout_sec=600` 与 +`env_vars=["WINDIR"]`;Claude 侧要设置 `alwaysLoad=true`。MATLAB 与 Simulink 共用该条目。 ## 本机状态备注(同步时更新) diff --git a/tools-sync/scripts/sync_mcp_registrations.ps1 b/tools-sync/scripts/sync_mcp_registrations.ps1 new file mode 100644 index 0000000..b87d912 --- /dev/null +++ b/tools-sync/scripts/sync_mcp_registrations.ps1 @@ -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 ===" diff --git a/tools-sync/tests/test_sync_mcp_registrations.ps1 b/tools-sync/tests/test_sync_mcp_registrations.ps1 new file mode 100644 index 0000000..a9c838b --- /dev/null +++ b/tools-sync/tests/test_sync_mcp_registrations.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = "Stop" +$scriptUnderTest = Join-Path (Split-Path $PSScriptRoot -Parent) "scripts\sync_mcp_registrations.ps1" +$testDir = Join-Path ([IO.Path]::GetTempPath()) ("tools-sync-mcp-test-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $testDir | Out-Null +$claudeConfig = Join-Path $testDir "claude.json" +$codexConfig = Join-Path $testDir "config.toml" + +try { + $claudeText = @' +{ + "mcpServers": { + "claude-only": {"type":"stdio","command":"python","args":["a.py"],"env":{"A":"1"}}, + "same": {"type":"stdio","command":"python","args":["same.py"],"env":{}}, + "drifted": {"type":"stdio","command":"python","args":["claude.py"],"env":{}} + } +} +'@ + [IO.File]::WriteAllText($claudeConfig, $claudeText, [Text.UTF8Encoding]::new($false)) + + $codexText = @' +[mcp_servers.codex-only] +command = "python" +args = ["b.py"] + +[mcp_servers.same] +command = "python" +args = ["same.py"] +env = { PYTHONIOENCODING = "utf-8", PYTHONUTF8 = "1" } + +[mcp_servers.drifted] +command = "python" +args = ["codex.py"] + +[mcp_servers.node_repl] +command = "C:\\Users\\tester\\.codex\\runtimes\\node_repl.exe" +'@ + [IO.File]::WriteAllText($codexConfig, $codexText, [Text.UTF8Encoding]::new($false)) + + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptUnderTest ` + -ClaudeConfig $claudeConfig -CodexConfig $codexConfig -Verbose 2>&1 | Out-String + $code = $LASTEXITCODE + $ErrorActionPreference = $oldErrorActionPreference + if ($code -ne 2) { throw "expected audit exit 2, got $code`n$output" } + if ($output -notmatch 'Claude only\s*:\s*claude-only') { throw "Claude-only difference not detected`n$output" } + if ($output -notmatch 'Codex only\s*:\s*codex-only') { throw "Codex-only difference not detected`n$output" } + if ($output -notmatch 'Drift\s*:\s*drifted') { throw "drift not detected`n$output" } + if ($output -notmatch 'Matched\s*:\s*same') { throw "Codex-only UTF-8 env should not cause drift`n$output" } + if ($output -match 'Codex only\s*:.*node_repl') { throw "managed Codex MCP was not excluded`n$output" } + Write-Host "PASS: bidirectional MCP audit fixtures" +} finally { + Remove-Item -LiteralPath $claudeConfig -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $codexConfig -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $testDir -Force -ErrorAction SilentlyContinue +}