diff --git a/.gitignore b/.gitignore index 7254c4a..8d5aa54 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ issues-index.txt skills/tool-index.md skills/tool-index.json +# 本机就绪报告(含用户路径 / 客户端配置,不入库) +LOCAL-READINESS.md + # 编译产物 *.exe *.dll diff --git a/CHANGELOG.md b/CHANGELOG.md index a24ffa4..915b7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed +- **IDA MCP keep-alive** — `start.ps1` reuses a healthy HTTP server, launches `idalib_supervisor` via windowless Python, never `taskkill`s `ida.exe` (no `/T`). A listening 13337 with `tools/list` timeout is treated as busy, not dead, so the 1-minute watchdog cannot kill a supervisor mid-`idb_open`. `open.ps1` talks ida-pro-mcp 2.x `idb_open`/`idb_list`. +- **IDA discovery** — `ToolDiscovery.ps1` now catalogs `idalib-mcp`, `ida-pro-mcp`, and `ida` with Program Files + per-user Python fallbacks. + +### Added +- `ida-reverse` watchdog / scheduled-task installer / GUI launcher / supervisor wrapper (`watchdog.ps1`, `install-autostart.ps1`, `start-gui.ps1`, `run-supervisor.py`) plus portable `LOCAL-SETUP.md`. + ## [1.0.1] — 2026-08-08 ### Added - **Routing single source of truth** — `skills/config/routing.json` (R0–R39 keyword rules with `must` / `mustAll` / `exclude` semantics). `master-route.ps1` now reads this file; hardcoded routing tables removed from scripts. Routing knowledge lives in one place. diff --git a/skills/ida-reverse/LOCAL-SETUP.md b/skills/ida-reverse/LOCAL-SETUP.md new file mode 100644 index 0000000..5070140 --- /dev/null +++ b/skills/ida-reverse/LOCAL-SETUP.md @@ -0,0 +1,66 @@ +# IDA ↔ reverse-skill 对接(可移植) + +本页是通用步骤,不含某台机器的绝对路径。本机就绪报告留在仓库根目录的 `LOCAL-READINESS.md`(已 gitignore)。 + +## 目标形态 + +| 项 | 约定 | +|----|------| +| IDA 安装目录 | 环境变量 `IDADIR`(目录内有 `ida.exe` 或 `ida.dll`) | +| HTTP MCP | `http://127.0.0.1:13337/mcp` | +| 客户端服务器名 | 只留 **`idapro`**(不要同时注册 `ida-pro-mcp`) | +| 启动 | `scripts/start.ps1`(`--unsafe`,无 `?ext=dbg`) | +| 开库 | 大文件优先 `scripts/open.ps1`,不要经部分客户端直调 `idb_open` | + +两个 MCP 名字指向同一 13337 会把工具注册两遍,并和 idalib worker 抢端口。 + +## 安装 + +```powershell +setx IDADIR "<你的 IDA 安装目录>" + +# 必须用 mrexodia/ida-pro-mcp,不要装 PyPI 的 ida-mcp +python -m pip install "git+https://github.com/mrexodia/ida-pro-mcp.git" + +# 激活 idalib(路径按本机 IDA 调整) +python "\idalib\python\py-activate-idalib.py" -d "" + +# 装插件 + 客户端配置 +python -m ida_pro_mcp --install --transport streamable-http --scope global +``` + +## 启动与保活 + +`type: http` 的 MCP 条目不会代为拉起进程。13337 没监听时,客户端全部报 error。 + +| 脚本 | 作用 | +|------|------| +| `scripts/start.ps1` | 健康则 `OK::reuse`;端口在听但 RPC 超时视为忙,不杀;只在无人监听或缺 `py_eval` 时替换 managed supervisor;永不杀 `ida.exe` | +| `scripts/watchdog.ps1` | 每分钟巡检;忙/健康则 reuse;只有 down/stale 才调 `start.ps1` | +| `scripts/install-autostart.ps1` | 注册计划任务 `reverse-skill-ida-mcp`(登录 + 每分钟) | +| `scripts/start-gui.ps1` | idalib license 失败时开 GUI 插件 | +| `scripts/open.ps1` | HTTP 直调 `idb_open`,绕过部分客户端 schema 校验 | + +日志:`%LOCALAPPDATA%\reverse-skill\ida-mcp\supervisor.log` 与 `watchdog.log`。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "skills\ida-reverse\scripts\start.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "skills\ida-reverse\scripts\open.ps1" -Path "C:\path\to\target.exe" -TimeoutSeconds 600 +powershell -NoProfile -ExecutionPolicy Bypass -File "skills\ida-reverse\scripts\install-autostart.ps1" +``` + +GUI 占用 13337 但一时没回包时,`start.ps1` 输出 `WARN:gui_busy` 并退出,避免把正在分析的 IDA 干掉。 + +## 客户端 + +全部指向 Streamable HTTP:`http://127.0.0.1:13337/mcp`,服务器名 `idapro`。 + +改配置后必须新开会话。Cursor 在启动时若端口未监听,事后把服务拉起来也**不会自动重连**,需要在 MCP 面板手动刷新。 + +## 已知注意点 + +1. System32 文件:`open.ps1` 会复制到临时路径(输出带 `(temp copy)`) +2. `idb_open` 勿经部分客户端 MCP 直调 +3. `start.ps1` 优先 `python -m ida_pro_mcp.idalib_supervisor`,比 `.cmd` 包装更稳 +4. 正式安装与桌面便携包并存时,以 `IDADIR` 为准 +5. 不要加 `?ext=dbg`(默认不暴露调试器工具) diff --git a/skills/ida-reverse/SKILL.md b/skills/ida-reverse/SKILL.md index 8a36921..1aebc76 100644 --- a/skills/ida-reverse/SKILL.md +++ b/skills/ida-reverse/SKILL.md @@ -22,11 +22,12 @@ description: | ### 踩过的坑 -1. **`idalib_open` 不能通过 部分代码 AI 客户端 MCP 直接调用** - - 部分代码 AI 客户端 的 MCP 客户端对 `idalib_open` 的 output schema 校验有 BUG +1. **`idb_open`(旧名 `idalib_open`)不要直接靠部分 AI 客户端 MCP 调用** + - 部分代码 AI 客户端 的 MCP 客户端对 open 类工具的 output schema 校验有 BUG - 报错:`Structured content does not match the tool's output schema` - **解决办法**:使用 `scripts/open.ps1` 脚本通过 HTTP API 直调,绕过 MCP 校验层 - - 文件打开后,数据库绑定到共享上下文,其他所有 `idapro_*` 工具可直接使用 + - 当前 ida-pro-mcp 2.x 工具名为 `idb_open` / `idb_list` / `idb_save`(不再是 `idalib_*`) + - 文件打开后返回 `session_id`(database),后续工具调用需带该 session 2. **`C:\Windows\System32\` 文件无权限打开** - idalib 无法直接读取 System32 目录下的文件 @@ -52,10 +53,11 @@ description: | - 已安装最新 `main` 分支版本 7. **idalib 超时留下孤儿 worker 进程锁文件** - - 第一次 `open.ps1` 超时后,idalib 的 python worker 子进程变成孤儿进程,咬着 `.id0`/`.id1`/`.nam` 不放 + - 第一次 `open.ps1` 超时后,idalib 的 python worker 子进程可能变成孤儿,咬着 `.id0`/`.id1`/`.nam` 不放 - 后续任何工具或手动拖入 IDA GUI 都会报"权限不足" - - **解决办法**:`start.ps1` 改用 `taskkill /F /T` 杀进程树,不再留孤儿 - - **兜底**:`open.ps1` 加了自动降级,检测到旧库被锁自动复制到 Temp 并加 GUID 前缀 + - **禁止** `taskkill /F /T` 杀进程树——`/T` 会把 GUI `ida.exe` 子进程一起干掉 + - **解决办法**:`start.ps1` 只在端口无人监听、或 `tools/list` 快速返回但缺 `py_eval`(旧 supervisor)时替换 managed supervisor;RPC 超时且 13337 仍在听视为忙,不杀 + - **兜底**:`open.ps1` 检测到旧库被锁自动复制到 Temp 并加 GUID 前缀 8. **带自动分析打开看起来像卡死** - `idalib_open(run_auto_analysis=true)` 可能长时间不回包,但后端实际上仍在继续打开和分析 @@ -63,13 +65,19 @@ description: | - **当前解决办法**:`open.ps1` 新增 `-TimeoutSeconds`,并改为后台请求 + 前台轮询 + 定时进度输出 - 轮询到会话已就绪时会提前返回 `OK:文件名:session_id`,超时则返回 `ERR:open_timeout_xxs` +9. **HTTP MCP 会在登录后静默退出** + - Cursor/Claude 的 `type: http` 不会代为拉起进程;旧计划任务只在登录时跑一次 + - `pythonw` 无控制台,崩溃时 Application 日志也是空的 + - **解决办法**:`start.ps1` 默认健康则复用;`watchdog.ps1` 每分钟巡检;日志在 `%LOCALAPPDATA%\reverse-skill\ida-mcp\` + - 安装:`scripts/install-autostart.ps1`。Cursor 若启动时端口还没起来,仍需在 MCP 面板手动刷新一次 + ### 工作流程原则 | 步骤 | 做什么 | 用什么 | |------|--------|--------| | 1 | 确保 HTTP 服务器在运行 | `scripts/start.ps1`(无参数) | | 2 | 打开目标二进制文件 | `scripts/open.ps1 -Path "xxx.exe"` | -| 3 | 使用所有 72 个 MCP 工具 | 直接调用 `idapro_*` 工具 | +| 3 | 使用 MCP 分析工具 | 直接调用 `idapro_*` / HTTP tools(约 65 个,视版本而定) | | 4 | 分析完毕 | 工具自动可用 | ## 脚本资源 @@ -78,8 +86,14 @@ description: | 路径:`scripts/start.ps1` -- 用 `taskkill /F /T` 杀旧进程树(连 worker 子进程一起清理)→ 后台启动 `idalib-mcp` → 等待就绪(最多 15 秒) -- 成功输出 `OK:72`,失败输出 `ERR:timeout` +- 自动解析 `IDADIR`(环境变量 / 便携版桌面路径 / 常见安装路径) +- 优先用 IDA 自带 `Python314\python.exe -m ida_pro_mcp.idalib_supervisor` +- 默认先探测 `http://127.0.0.1:13337/mcp`,健康则输出 `OK::reuse` 并退出 +- 13337 在听但 `tools/list` 超时 → `WARN:busy` / `OK:busy:reuse`,**不杀**(supervisor 单线程,开库时无法回包) +- 仅在端口无人监听、或快速返回且缺 `py_eval` 时替换 managed supervisor;**永不杀 `ida.exe`,不用 `taskkill /T`** +- GUI 占用 13337 时输出 `WARN:gui_busy` 并退出,不另起 supervisor +- 成功输出 `OK:<工具数>`(当前约 66),失败输出 `ERR:timeout` +- supervisor 日志:`%LOCALAPPDATA%\reverse-skill\ida-mcp\supervisor.log` - 服务器在后台运行,不阻塞对话 **调用方式**: @@ -87,11 +101,17 @@ description: | powershell -File "\ida-reverse\scripts\start.ps1" ``` +### watchdog.ps1 / install-autostart.ps1 — 保活 + +- `watchdog.ps1`:探测 13337,健康则 `OK::reuse`,挂了才调用 `start.ps1` +- `install-autostart.ps1`:注册计划任务 `reverse-skill-ida-mcp`(登录 + 每分钟) +- 日志:`%LOCALAPPDATA%\reverse-skill\ida-mcp\watchdog.log` + ### open.ps1 — 打开二进制文件 路径:`scripts/open.ps1` -- 通过 HTTP API 直调 `idalib_open`,绕过 MCP schema 校验 +- 通过 HTTP API 直调 `idb_open`,绕过 MCP schema 校验 - 自动检测 System32 路径并复制到临时目录 - 自动清理同名旧数据库文件(`.id0`/`.id1`/`.nam`/`.til`/`.i64`) - 旧库被锁时自动降级:复制到 Temp 加 GUID 前缀后打开,不报错 @@ -203,14 +223,11 @@ ERR:open_timeout_600s - `idapro_open_file(file_path)` — 在 GUI IDA 实例中打开文件 - 调试器工具默认隐藏,可通过 URL 参数 `?ext=dbg` 启用 -### 会话管理 -- `idapro_idalib_open(input_path)` — ⚠️ 有 schema 校验 BUG,改用 `open.ps1` 脚本 -- `idapro_idalib_list()` — 列出所有 session -- `idapro_idalib_current()` — 当前上下文绑定的 session -- `idapro_idalib_switch(session_id)` — 切换到其他 session -- `idapro_idalib_close(session_id)` — 关闭 session -- `idapro_idalib_save(path)` — 保存数据库 -- `idapro_idalib_health(session_id)` — 检查 worker 健康状态 +### 会话管理(ida-pro-mcp 2.x) +- `idapro_idb_open` / HTTP `idb_open` — ⚠️ 建议用 `open.ps1` 打开 +- `idapro_idb_list` / HTTP `idb_list` — 列出所有 session +- `idapro_idb_save` / HTTP `idb_save` — 保存数据库 +- 多数分析工具需要 `database=` 参数(open.ps1 输出的 session) ### 其他 - `idapro_int_convert(inputs)` — 进制转换(**必须用这个,不要自己算进制!**) @@ -222,18 +239,31 @@ ERR:open_timeout_600s ## 逆向分析完整工作流 ### Step 1: 启动服务器 -确保 HTTP 服务在后台运行。 + +**路径 A — Headless idalib(需要有效 license)** ``` powershell -File "scripts/start.ps1" ``` -输出 `OK:72` 表示就绪。 +输出 `OK:<工具数>`(当前约 65)表示就绪。 + +**路径 B — GUI + 插件(idalib license 失败或需要交互分析时)** +``` +powershell -File "scripts/start-gui.ps1" -Path "C:\目标.exe" +``` +或双击便携版 `Launch-IDA-Pro.cmd`,在 IDA 中打开样本。 +确认 Output 窗口出现 `[MCP] ... port=13337` 后,MCP 工具即可用。 +通用对接步骤见 `LOCAL-SETUP.md`。 ### Step 2: 打开文件 + +Headless: ``` powershell -File "scripts/open.ps1" -Path "C:\目标.exe" -TimeoutSeconds 600 ``` -输出 `OK:文件名:session_id` 表示成功(后带 `(temp copy)` 表示自动降级到临时副本)。 -若分析时间较长,会周期性输出 `INFO:opening:...`;若达到超时则输出 `ERR:open_timeout_xxs`。 +输出 `OK:文件名:session_id` 表示成功(后带 `(temp copy)` 表示自动降级到临时副本)。 +若出现 `ERR:idalib_license:...`,改用路径 B(GUI 模式),不要反复重试 open.ps1。 + +GUI 模式:在 IDA 里直接 Open 样本即可,无需 open.ps1。 ### Step 3: 全局概览(含导入表硬门) ``` @@ -346,7 +376,11 @@ ida-pro-mcp --config ### 前置条件 - IDA Pro 已安装且 `IDADIR` 环境变量已设置(或脚本内默认路径正确) -- Python 已安装(idalib-mcp 依赖 Python) +- 推荐使用 IDA 自带 Python314 中的 `ida-pro-mcp`(便携版已内置) +- 常见本机配置: + - User env `IDADIR` → IDA 安装目录(含 `ida.exe`) + - 可选 `~\Tools\bin\idalib-mcp.cmd` / `ida-pro-mcp.cmd` 包装器 + - 客户端 MCP 服务器名只留 `idapro` → `http://127.0.0.1:13337/mcp` ## 任务完成自检(声称完成前 MUST 通过) diff --git a/skills/ida-reverse/references/ida-mcp-cheatsheet.md b/skills/ida-reverse/references/ida-mcp-cheatsheet.md index bd4ed85..595749d 100644 --- a/skills/ida-reverse/references/ida-mcp-cheatsheet.md +++ b/skills/ida-reverse/references/ida-mcp-cheatsheet.md @@ -1,7 +1,7 @@ # IDA Pro MCP 工具速查 -> 72 个 MCP 工具按功能分类,附常用参数和典型用法。 -> 服务器名:`idapro`,工具前缀:`idapro_*`,HTTP 模式运行。 +> ida-pro-mcp 2.x 工具按功能分类,附常用参数和典型用法。 +> 服务器名:`idapro`,工具前缀:`idapro_*`,HTTP 模式运行。工具数随版本变化(约 66,含 `py_eval`)。 --- @@ -10,9 +10,9 @@ ### 服务器启动 ```powershell -# 启动 MCP HTTP 服务器(后台静默) +# 启动 MCP HTTP 服务器(后台静默;健康则 OK::reuse) powershell -File "scripts/start.ps1" -# 输出 OK:72 表示就绪 +# 输出 OK:<工具数> 表示就绪(约 66,含 py_eval) # 打开目标文件(绕过 schema 校验) powershell -File "scripts/open.ps1" -Path "C:\target.exe" @@ -29,12 +29,12 @@ powershell -File "scripts/open.ps1" -Path "C:\huge.sys" -NoAutoAnalysis | 工具 | 用途 | 示例 | |------|------|------| -| `idapro_idalib_list()` | 列出所有 session | — | -| `idapro_idalib_current()` | 当前绑定的 session | — | -| `idapro_idalib_switch(session_id)` | 切换 session | 多文件对比时 | -| `idapro_idalib_close(session_id)` | 关闭 session | 释放资源 | -| `idapro_idalib_save(path)` | 保存数据库 | 保存分析进度 | -| `idapro_idalib_health(session_id)` | 检查 worker 状态 | 排查卡死 | +| `idapro_idb_list()` / HTTP `idb_list` | 列出所有 session | — | +| `idapro_idb_open()` / HTTP `idb_open` | 打开数据库(优先用 `open.ps1`) | 大文件走脚本 | +| `idapro_idb_save(path)` / HTTP `idb_save` | 保存数据库 | 保存分析进度 | +| `idapro_idb_current()` | 当前绑定的 session(若版本提供) | — | +| `idapro_idb_switch(session_id)` | 切换 session | 多文件对比时 | +| `idapro_idb_close(session_id)` | 关闭 session | 释放资源 | | `idapro_server_health()` | 服务器健康检查 | — | | `idapro_server_warmup()` | 预热子系统 | 首次使用前 | @@ -508,7 +508,7 @@ idapro_py_eval(code="import ida_funcs; f=ida_funcs.get_func(0x401000); print(f.s |------|------|------| | "No database bound" | 没有打开文件 | 执行 `open.ps1` | | "Failed to open database" | 旧库被锁 | `open.ps1` 自动降级到 Temp | -| schema 校验失败 | MCP 客户端 BUG | 用 `open.ps1` 代替 `idalib_open` | +| schema 校验失败 | MCP 客户端 BUG | 用 `open.ps1` 代替 `idb_open` | | 工具超时 | 大文件分析中 | 加 `-TimeoutSeconds 600` | | "ERR:timeout" (start.ps1) | 服务器启动失败 | 检查 Python/idalib-mcp 安装 | | 进制转换错误 | 手动计算出错 | 用 `idapro_int_convert` | diff --git a/skills/ida-reverse/scripts/install-autostart.ps1 b/skills/ida-reverse/scripts/install-autostart.ps1 new file mode 100644 index 0000000..ba7b813 --- /dev/null +++ b/skills/ida-reverse/scripts/install-autostart.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS +Register a keep-alive scheduled task for IDA Pro MCP HTTP. + +.DESCRIPTION +Replaces the old logon-only reverse-skill-ida-mcp task with: +- At logon + 30s delay +- Once-from-now trigger repeating every 1 minute for 3650 days + (this host cannot write -Daily.Repetition) +- Restart the task up to 3 times if the script itself fails +- Action runs watchdog.ps1 (reuse if healthy, start only if down; + never kill ida.exe when the GUI plugin owns 13337) + +Usage: + powershell -File install-autostart.ps1 + powershell -File install-autostart.ps1 -Unregister +#> + +param( + [switch]$Unregister +) + +$ErrorActionPreference = 'Stop' + +$taskName = 'reverse-skill-ida-mcp' +$watchdog = Join-Path $PSScriptRoot 'watchdog.ps1' +if (-not (Test-Path -LiteralPath $watchdog)) { + Write-Output "ERR:missing $watchdog" + exit 1 +} + +if ($Unregister) { + $existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($existing) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + } + Write-Output 'OK:unregistered' + exit 0 +} + +$arg = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$watchdog`"" +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arg -WorkingDirectory $PSScriptRoot + +$logon = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME +$logon.Delay = 'PT30S' + +# -Daily.Repetition is not writable on this host; -Once + long duration is. +$repeat = New-ScheduledTaskTrigger -Once -At (Get-Date) ` + -RepetitionInterval (New-TimeSpan -Minutes 1) ` + -RepetitionDuration (New-TimeSpan -Days 3650) + +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 5) ` + -MultipleInstances IgnoreNew ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) +$settings.Hidden = $true + +$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited + +Register-ScheduledTask ` + -TaskName $taskName ` + -Action $action ` + -Trigger @($logon, $repeat) ` + -Settings $settings ` + -Principal $principal ` + -Force ` + -Description 'Keep IDA Pro MCP HTTP (127.0.0.1:13337) alive. Reuses a healthy server; starts idalib_supervisor only when down.' ` + | Out-Null + +Start-ScheduledTask -TaskName $taskName +Write-Output "OK:registered:$taskName" +Write-Output 'INFO:triggers=AtLogOn+30s, every 1 min' +Write-Output ("INFO:watchdog={0}" -f $watchdog) diff --git a/skills/ida-reverse/scripts/open.ps1 b/skills/ida-reverse/scripts/open.ps1 index 6a28e90..0081a09 100644 --- a/skills/ida-reverse/scripts/open.ps1 +++ b/skills/ida-reverse/scripts/open.ps1 @@ -1,14 +1,17 @@ -<# +<# .SYNOPSIS Open binary file via IDA HTTP API (bypass MCP schema issue) + .PARAMETER Path Binary file path (required) .PARAMETER SessionId -Session ID (optional, auto-generated) +Preferred session ID (optional, auto-generated) .PARAMETER NoAutoAnalysis Skip automatic analysis (faster open for large files) .PARAMETER TimeoutSeconds Open timeout in seconds, returns timeout instead of blocking forever +.PARAMETER Port +MCP HTTP port (default 13337) #> param( @@ -16,39 +19,89 @@ param( [string]$Path, [string]$SessionId = "", [switch]$NoAutoAnalysis = $false, - [int]$TimeoutSeconds = 120 + [int]$TimeoutSeconds = 120, + [int]$Port = 13337 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -if (-not [string]::IsNullOrWhiteSpace($env:IDADIR)) { - $env:IDADIR = $env:IDADIR +function Test-IdaInstallDir { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + if (-not (Test-Path -LiteralPath $Path)) { return $false } + return (Test-Path -LiteralPath (Join-Path $Path 'ida.exe')) -or (Test-Path -LiteralPath (Join-Path $Path 'ida.dll')) +} + +if (-not [string]::IsNullOrWhiteSpace($env:IDADIR) -and (Test-IdaInstallDir -Path $env:IDADIR)) { + $env:IDADIR = [System.IO.Path]::GetFullPath($env:IDADIR) } else { - # Fallback: check common IDA installation paths - $idaCandidates = @( - 'C:\Program Files\IDA Pro', - 'C:\IDA Pro', - 'D:\IDA', - (Join-Path $env:USERPROFILE 'Tools\IDA') - ) - $foundIda = $idaCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 - if ($foundIda) { - $env:IDADIR = $foundIda - } else { - Write-Error "ERR:IDADIR not set and IDA Pro not found at common paths. Set IDADIR environment variable to your IDA installation directory." - exit 1 + $persisted = [Environment]::GetEnvironmentVariable('IDADIR', 'User') + if ([string]::IsNullOrWhiteSpace($persisted)) { + $persisted = [Environment]::GetEnvironmentVariable('IDADIR', 'Machine') + } + if (-not [string]::IsNullOrWhiteSpace($persisted) -and (Test-IdaInstallDir -Path $persisted)) { + $env:IDADIR = [System.IO.Path]::GetFullPath($persisted) + } + else { + $desktop = [Environment]::GetFolderPath('Desktop') + $idaCandidates = @( + (Join-Path $desktop 'IDA Pro 9.4\App\IDA Pro'), + (Join-Path $env:USERPROFILE 'Desktop\IDA Pro 9.4\App\IDA Pro'), + (Join-Path $env:USERPROFILE 'Tools\IDA Pro 9.4\App\IDA Pro'), + (Join-Path $env:USERPROFILE 'Tools\IDA'), + 'C:\Program Files\IDA Professional 9.4', + 'C:\Program Files\IDA Pro 9.4', + 'C:\Program Files\IDA Pro', + 'C:\IDA Pro', + 'D:\IDA', + 'D:\Tools\IDA Pro 9.4\App\IDA Pro' + ) + $foundIda = $idaCandidates | Where-Object { Test-IdaInstallDir -Path $_ } | Select-Object -First 1 + if ($foundIda) { + $env:IDADIR = [System.IO.Path]::GetFullPath($foundIda) + } else { + Write-Error "ERR:IDADIR not set and IDA Pro not found at common paths. Set IDADIR environment variable to your IDA installation directory." + exit 1 + } } } -$Port = 13337 -$TempDir = Join-Path $env:TEMP 'reverse-skill' + +# Prefer a short temp root to avoid MAX_PATH issues on Windows. +$TempDir = 'C:\rs-ida' if (-not (Test-Path -LiteralPath $TempDir)) { - New-Item -ItemType Directory -Path $TempDir -Force | Out-Null + try { + New-Item -ItemType Directory -Path $TempDir -Force | Out-Null + } catch { + $TempDir = Join-Path $env:TEMP 'rs-ida' + if (-not (Test-Path -LiteralPath $TempDir)) { + New-Item -ItemType Directory -Path $TempDir -Force | Out-Null + } + } } $PollIntervalMs = 2000 $ProgressIntervalSeconds = 10 +function Invoke-IdaMcp { + param( + [string]$Method, + [hashtable]$Params = @{}, + [int]$RequestPort, + [int]$TimeoutSec = 30 + ) + + $payload = @{ + jsonrpc = '2.0' + id = 1 + method = $Method + params = $Params + } | ConvertTo-Json -Depth 12 -Compress + + return Invoke-RestMethod "http://127.0.0.1:$RequestPort/mcp" -Method Post -Body $payload ` + -ContentType 'application/json' -TimeoutSec $TimeoutSec -ErrorAction Stop +} + function Get-OpenReadySession { param( [string]$ExpectedSessionId, @@ -56,20 +109,46 @@ function Get-OpenReadySession { [int]$RequestPort ) - $listBody = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"idalib_list","arguments":{}}}' - $listResult = Invoke-RestMethod "http://127.0.0.1:$RequestPort/mcp" -Method Post -Body $listBody ` - -ContentType "application/json" -TimeoutSec 10 -ErrorAction Stop + $listResult = Invoke-IdaMcp -Method 'tools/call' -Params @{ + name = 'idb_list' + arguments = @{} + } -RequestPort $RequestPort -TimeoutSec 10 - $sessions = @($listResult.result.structuredContent.sessions) - foreach ($candidate in $sessions) { - if (-not $candidate) { - continue + $content = $listResult.result.structuredContent + if (-not $content) { + # Some MCP servers put content in result.content[0].text JSON + $text = $listResult.result.content | Where-Object { $_.type -eq 'text' } | Select-Object -First 1 -ExpandProperty text + if ($text) { + try { $content = $text | ConvertFrom-Json } catch { $content = $null } } + } + + $sessions = @() + if ($content -and $content.sessions) { + $sessions = @($content.sessions) + } + + foreach ($candidate in $sessions) { + if (-not $candidate) { continue } $sameSession = $candidate.session_id -eq $ExpectedSessionId - $samePath = $candidate.input_path -eq $ExpectedPath - $sameFile = [System.IO.Path]::GetFileName($candidate.input_path) -eq [System.IO.Path]::GetFileName($ExpectedPath) - if (($sameSession -or $samePath -or $sameFile) -and $candidate.is_analyzing -eq $false) { + $samePath = $false + if ($candidate.input_path) { + try { + $samePath = [System.IO.Path]::GetFullPath([string]$candidate.input_path) -eq [System.IO.Path]::GetFullPath($ExpectedPath) + } catch { + $samePath = [string]$candidate.input_path -eq $ExpectedPath + } + } + $isAnalyzing = $false + if ($null -ne $candidate.PSObject.Properties['is_analyzing']) { + $isAnalyzing = [bool]$candidate.is_analyzing + } + + $sid = [string]$candidate.session_id + if ([string]::IsNullOrWhiteSpace($sid)) { continue } + + if (($sameSession -or $samePath) -and -not $isAnalyzing) { return $candidate } } @@ -77,77 +156,92 @@ function Get-OpenReadySession { return $null } -if (-not (Test-Path $Path)) { +if (-not (Test-Path -LiteralPath $Path)) { Write-Output "ERR:file_not_found" exit 1 } +# Normalize to absolute path +$Path = [System.IO.Path]::GetFullPath($Path) + if ($TimeoutSeconds -le 0) { Write-Output "ERR:invalid_timeout" exit 1 } -# 判断是否用了临时副本(避免递归复制) +# Probe server first +try { + $null = Invoke-IdaMcp -Method 'tools/list' -Params @{} -RequestPort $Port -TimeoutSec 5 +} catch { + Write-Output "ERR:server_not_ready:$($_.Exception.Message)" + Write-Output "HINT: run scripts/start.ps1 first" + exit 1 +} + +# System32 / locked DB -> temp copy $isTempCopy = $Path.StartsWith($TempDir, [StringComparison]::OrdinalIgnoreCase) -# System32 文件自动复制到 Temp if (-not $isTempCopy -and $Path -match "C:\\Windows\\System32") { $Filename = [System.IO.Path]::GetFileName($Path) - $TempPath = "$TempDir\$Filename" - Copy-Item $Path $TempPath -Force -ErrorAction SilentlyContinue + $TempPath = Join-Path $TempDir $Filename + Copy-Item -LiteralPath $Path -Destination $TempPath -Force -ErrorAction SilentlyContinue if ($?) { $Path = $TempPath $isTempCopy = $true } } -# 清理同名旧数据库文件(只在非 Temp 副本时尝试) if (-not $isTempCopy) { $dir = [System.IO.Path]::GetDirectoryName($Path) $base = [System.IO.Path]::GetFileNameWithoutExtension($Path) - $oldExts = @(".id0", ".id1", ".id2", ".nam", ".til", ".i64") + $oldExts = @('.id0', '.id1', '.id2', '.nam', '.til', '.i64', '.idb') $hasLocked = $false foreach ($ext in $oldExts) { $f = Join-Path $dir "$base$ext" - if (Test-Path $f) { - Remove-Item $f -Force -ErrorAction SilentlyContinue - if (Test-Path $f) { $hasLocked = $true } + if (Test-Path -LiteralPath $f) { + Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $f) { $hasLocked = $true } } } - # 旧数据库文件被锁,自动用 Temp 副本 if ($hasLocked) { - $guid = [System.Guid]::NewGuid().ToString("N").Substring(0, 8) + $guid = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) $newName = "$guid-$([System.IO.Path]::GetFileName($Path))" - $TempPath = "$TempDir\$newName" - Copy-Item $Path $TempPath -Force + $TempPath = Join-Path $TempDir $newName + Copy-Item -LiteralPath $Path -Destination $TempPath -Force $Path = $TempPath $isTempCopy = $true } } -$autoAnalysis = if ($NoAutoAnalysis) { "false" } else { "true" } -$escapedPath = $Path -replace '\\', '\\' - -# 始终使用明确的会话 ID,便于超时时轮询会话状态判断是否已成功打开 +$autoAnalysis = -not $NoAutoAnalysis if (-not $SessionId) { - $SessionId = [System.Guid]::NewGuid().ToString("N").Substring(0, 8) + $SessionId = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) } - $body = @" -{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"idalib_open","arguments":{"input_path":"$escapedPath","run_auto_analysis":$autoAnalysis,"session_id":null}}} -"@ -if ($SessionId) { - $body = @" -{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"idalib_open","arguments":{"input_path":"$escapedPath","run_auto_analysis":$autoAnalysis,"session_id":"$SessionId"}}} -"@ +$arguments = @{ + input_path = $Path + run_auto_analysis = $autoAnalysis + preferred_session_id = $SessionId + mode = 'prefer_headless' } +$argJson = $arguments | ConvertTo-Json -Compress +# Keep boolean literals as JSON bools for background job +$bodyObj = @{ + jsonrpc = '2.0' + id = 1 + method = 'tools/call' + params = @{ + name = 'idb_open' + arguments = $arguments + } +} +$body = $bodyObj | ConvertTo-Json -Depth 12 -Compress -# 将打开请求放到后台,避免 HTTP 长时间不回包时阻塞整个脚本 $openJob = Start-Job -ScriptBlock { param($RequestBody, $RequestPort) try { Invoke-RestMethod "http://127.0.0.1:$RequestPort/mcp" -Method Post -Body $RequestBody ` - -ContentType "application/json" -ErrorAction Stop + -ContentType 'application/json' -TimeoutSec 0 -ErrorAction Stop } catch { $_.Exception.Message } @@ -168,10 +262,12 @@ try { try { $session = Get-OpenReadySession -ExpectedSessionId $SessionId -ExpectedPath $Path -RequestPort $Port if ($session) { - $tag = if ($isTempCopy) { " (temp copy)" } else { "" } + $tag = if ($isTempCopy) { ' (temp copy)' } else { '' } Stop-Job -Job $openJob -ErrorAction SilentlyContinue Remove-Job -Job $openJob -Force -ErrorAction SilentlyContinue - Write-Output "OK:$($session.filename):$($session.session_id)$tag" + $name = if ($session.filename) { $session.filename } else { [System.IO.Path]::GetFileName($Path) } + $sid = if ($session.session_id) { $session.session_id } else { $SessionId } + Write-Output "OK:${name}:${sid}${tag}" exit 0 } } catch {} @@ -193,8 +289,10 @@ try { try { $session = Get-OpenReadySession -ExpectedSessionId $SessionId -ExpectedPath $Path -RequestPort $Port if ($session) { - $tag = if ($isTempCopy) { " (temp copy)" } else { "" } - Write-Output "OK:$($session.filename):$($session.session_id)$tag" + $tag = if ($isTempCopy) { ' (temp copy)' } else { '' } + $name = if ($session.filename) { $session.filename } else { [System.IO.Path]::GetFileName($Path) } + $sid = if ($session.session_id) { $session.session_id } else { $SessionId } + Write-Output "OK:${name}:${sid}${tag}" exit 0 } } catch {} @@ -211,22 +309,71 @@ try { exit 1 } - if ($jobResult.result.structuredContent.success -eq $true) { - $session = $jobResult.result.structuredContent.session - $tag = if ($isTempCopy) { " (temp copy)" } else { "" } - Write-Output "OK:$($session.filename):$($session.session_id)$tag" - } else { - # 自动降级:非 Temp 副本失败时,复制到 Temp 重试 - if (-not $isTempCopy) { - $guid = [System.Guid]::NewGuid().ToString("N").Substring(0, 8) - $newName = "$guid-$([System.IO.Path]::GetFileName($Path))" - $TempPath = "$TempDir\$newName" - Copy-Item $Path $TempPath -Force - & $PSCommandPath -Path $TempPath -SessionId $SessionId -NoAutoAnalysis:$NoAutoAnalysis -TimeoutSeconds $TimeoutSeconds - } else { - Write-Output "ERR:$($jobResult.result.structuredContent.error)" + $structured = $jobResult.result.structuredContent + if (-not $structured) { + $text = $jobResult.result.content | Where-Object { $_.type -eq 'text' } | Select-Object -First 1 -ExpandProperty text + if ($text) { + try { $structured = $text | ConvertFrom-Json } catch { $structured = $null } } } + + $success = $false + $session = $null + $errMsg = $null + if ($structured) { + if ($null -ne $structured.PSObject.Properties['success'] -and $structured.success -eq $true) { + $success = $true + $session = $structured.session + } + if ($null -ne $structured.PSObject.Properties['error'] -and $structured.error) { + $errMsg = [string]$structured.error + } + if ($null -ne $structured.PSObject.Properties['session'] -and $structured.session -and -not $success) { + # Some builds return session without success flag + $success = $true + $session = $structured.session + } + } + + if ($success -and $session) { + $tag = if ($isTempCopy) { ' (temp copy)' } else { '' } + $name = if ($session.filename) { $session.filename } else { [System.IO.Path]::GetFileName($Path) } + $sid = if ($session.session_id) { $session.session_id } else { $SessionId } + Write-Output "OK:${name}:${sid}${tag}" + exit 0 + } + + # Auto-fallback: temp copy retry (skip if license / idalib hard failure) + $hardFail = $false + if ($errMsg -and ($errMsg -match 'license|EULA|Cannot continue without a valid license|batch mode')) { + $hardFail = $true + } + if (-not $isTempCopy -and -not $hardFail) { + try { + $guid = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + $baseName = [System.IO.Path]::GetFileName($Path) + if ($baseName.Length -gt 40) { $baseName = $baseName.Substring(0, 40) } + $newName = "$guid-$baseName" + $TempPath = Join-Path $TempDir $newName + Copy-Item -LiteralPath $Path -Destination $TempPath -Force + & $PSCommandPath -Path $TempPath -SessionId $SessionId -NoAutoAnalysis:$NoAutoAnalysis -TimeoutSeconds $TimeoutSeconds -Port $Port + exit $LASTEXITCODE + } catch { + Write-Output "ERR:temp_copy_failed:$($_.Exception.Message)" + if ($errMsg) { Write-Output "ERR:open:$errMsg" } + exit 1 + } + } + + if ($hardFail) { + Write-Output "ERR:idalib_license:$errMsg" + Write-Output "HINT: This machine's hexlic cannot open databases in headless idalib mode. Use GUI mode: Launch-IDA-Pro.cmd, open the binary, then use idapro MCP tools against the GUI plugin on port 13337. See LOCAL-SETUP.md." + exit 1 + } + + $err = if ($errMsg) { $errMsg } elseif ($structured) { ($structured | ConvertTo-Json -Compress) } else { 'open_failed' } + Write-Output "ERR:$err" + exit 1 } finally { if ($openJob) { Stop-Job -Job $openJob -ErrorAction SilentlyContinue diff --git a/skills/ida-reverse/scripts/run-supervisor.py b/skills/ida-reverse/scripts/run-supervisor.py new file mode 100644 index 0000000..7fd92ce --- /dev/null +++ b/skills/ida-reverse/scripts/run-supervisor.py @@ -0,0 +1,46 @@ +"""Hidden idalib supervisor launcher with file logging. + +Started via pythonw so no console window appears. stdout/stderr and the +logging module both go to %LOCALAPPDATA%\\reverse-skill\\ida-mcp\\supervisor.log. +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime +from pathlib import Path + + +def _log_path() -> Path: + root = Path(os.environ.get("LOCALAPPDATA") or ".") / "reverse-skill" / "ida-mcp" + root.mkdir(parents=True, exist_ok=True) + return root / "supervisor.log" + + +def _rotate(path: Path, max_bytes: int = 5 * 1024 * 1024) -> None: + if path.exists() and path.stat().st_size > max_bytes: + bak = path.with_name(path.name + ".1") + if bak.exists(): + bak.unlink() + path.replace(bak) + + +def main() -> None: + log_path = _log_path() + _rotate(log_path) + log_fp = open(log_path, "a", encoding="utf-8", buffering=1, errors="replace") + sys.stdout = log_fp + sys.stderr = log_fp + print( + f"==== start {datetime.now():%Y-%m-%d %H:%M:%S} pid={os.getpid()} ====", + flush=True, + ) + + from ida_pro_mcp.idalib_supervisor import main as supervisor_main + + supervisor_main() + + +if __name__ == "__main__": + main() diff --git a/skills/ida-reverse/scripts/start-gui.ps1 b/skills/ida-reverse/scripts/start-gui.ps1 new file mode 100644 index 0000000..59956e7 --- /dev/null +++ b/skills/ida-reverse/scripts/start-gui.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS +Launch portable IDA Pro GUI (with MCP plugin). Prefer this when headless idalib license fails. + +.DESCRIPTION +1. Resolve IDADIR +2. Optionally open a binary path +3. Start IDA GUI so the ida_mcp plugin can autostart HTTP on 127.0.0.1:13337 + +Usage: + powershell -File start-gui.ps1 + powershell -File start-gui.ps1 -Path C:\target.exe +#> + +param( + [string]$Path, + [string]$IdaDir, + [switch]$UsePortableLauncher +) + +$ErrorActionPreference = 'Stop' + +function Test-IdaInstallDir { + param([string]$Candidate) + if ([string]::IsNullOrWhiteSpace($Candidate)) { return $false } + if (-not (Test-Path -LiteralPath $Candidate)) { return $false } + $idaExe = Join-Path $Candidate 'ida.exe' + $idaDll = Join-Path $Candidate 'ida.dll' + return (Test-Path -LiteralPath $idaExe) -or (Test-Path -LiteralPath $idaDll) +} + +if ([string]::IsNullOrWhiteSpace($IdaDir)) { + if (-not [string]::IsNullOrWhiteSpace($env:IDADIR) -and (Test-IdaInstallDir $env:IDADIR)) { + $IdaDir = $env:IDADIR + } else { + $persisted = [Environment]::GetEnvironmentVariable('IDADIR', 'User') + if (Test-IdaInstallDir $persisted) { + $IdaDir = $persisted + } else { + $candidates = @( + 'C:\Program Files\IDA Professional 9.4', + 'C:\Program Files\IDA Pro 9.4', + 'C:\Program Files\IDA Pro', + (Join-Path $env:USERPROFILE 'Tools\IDAPro94'), + (Join-Path $env:USERPROFILE 'Tools\IDA Pro 9.4\App\IDA Pro'), + (Join-Path ([Environment]::GetFolderPath('Desktop')) 'IDA Pro 9.4\App\IDA Pro'), + (Join-Path $env:USERPROFILE 'Desktop\IDA Pro 9.4\App\IDA Pro') + ) + $IdaDir = $candidates | Where-Object { Test-IdaInstallDir $_ } | Select-Object -First 1 + } + } +} + +if (-not (Test-IdaInstallDir $IdaDir)) { + Write-Output 'ERR:IDADIR_not_found' + exit 1 +} + +$env:IDADIR = [System.IO.Path]::GetFullPath($IdaDir) +$portableRoot = Split-Path (Split-Path $env:IDADIR -Parent) -Parent +# Desktop\IDA Pro 9.4\App\IDA Pro -> Desktop\IDA Pro 9.4 +if ((Split-Path $env:IDADIR -Leaf) -eq 'IDA Pro') { + $maybe = Split-Path (Split-Path $env:IDADIR -Parent) -Parent + if (Test-Path (Join-Path $maybe 'Launch-IDA-Pro.cmd')) { + $portableRoot = $maybe + } +} + +Write-Output "INFO:IDADIR=$env:IDADIR" + +if ($UsePortableLauncher -or (Test-Path (Join-Path $portableRoot 'Launch-IDA-Pro.cmd'))) { + $launcher = Join-Path $portableRoot 'Launch-IDA-Pro.cmd' + if (Test-Path -LiteralPath $launcher) { + Write-Output "INFO:launcher=$launcher" + if (-not [string]::IsNullOrWhiteSpace($Path)) { + if (-not (Test-Path -LiteralPath $Path)) { + Write-Output 'ERR:file_not_found' + exit 1 + } + # Portable launcher starts IDA; then user/file can be passed to ida.exe directly instead + $target = [System.IO.Path]::GetFullPath($Path) + Start-Process -FilePath (Join-Path $env:IDADIR 'ida.exe') -ArgumentList @('"' + $target + '"') -WorkingDirectory $env:IDADIR + } else { + Start-Process -FilePath $launcher -WorkingDirectory $portableRoot + } + Write-Output 'OK:gui_started' + Write-Output 'HINT: Open a binary in IDA, confirm Output shows [MCP] port=13337, then use idapro MCP tools.' + exit 0 + } +} + +$idaExe = Join-Path $env:IDADIR 'ida.exe' +if (-not [string]::IsNullOrWhiteSpace($Path)) { + if (-not (Test-Path -LiteralPath $Path)) { + Write-Output 'ERR:file_not_found' + exit 1 + } + $target = [System.IO.Path]::GetFullPath($Path) + Start-Process -FilePath $idaExe -ArgumentList @('"' + $target + '"') -WorkingDirectory $env:IDADIR +} else { + Start-Process -FilePath $idaExe -WorkingDirectory $env:IDADIR +} + +Write-Output 'OK:gui_started' +Write-Output 'HINT: Open a binary in IDA, confirm Output shows [MCP] port=13337, then use idapro MCP tools.' diff --git a/skills/ida-reverse/scripts/start.ps1 b/skills/ida-reverse/scripts/start.ps1 index 90bce7a..f384362 100644 --- a/skills/ida-reverse/scripts/start.ps1 +++ b/skills/ida-reverse/scripts/start.ps1 @@ -3,10 +3,12 @@ Start IDA Pro MCP HTTP server (background, non-blocking) .DESCRIPTION -1. Kill old process -2. Start idalib-mcp HTTP server in hidden window mode -3. Wait for service ready (max 15 seconds) -4. Output result +1. Resolve IDADIR (env / portable IDA / registry / common paths) +2. Resolve idalib-mcp (PATH, IDA Python314 Scripts, or python -m) +3. If HTTP already healthy, reuse it (unless -Force) +4. Otherwise kill stale listeners and start a logged supervisor +5. Wait for service ready (max 30 seconds) +6. Output OK:, OK::reuse, or ERR:... Usage: run without parameters #> @@ -14,9 +16,164 @@ Usage: run without parameters param( [string]$IdaDir, [int]$Port = 13337, - [string]$ServerPath + [string]$ServerPath, + [int]$WaitSeconds = 30, + [switch]$Force ) +$ErrorActionPreference = 'Stop' + +function Get-IdaMcpLogDir { + $dir = Join-Path $env:LOCALAPPDATA 'reverse-skill\ida-mcp' + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + return $dir +} + +function Rotate-IdaMcpLog { + param( + [string]$Path, + [int]$MaxBytes = 5MB + ) + if (-not (Test-Path -LiteralPath $Path)) { return } + if ((Get-Item -LiteralPath $Path).Length -le $MaxBytes) { return } + $bak = "$Path.1" + if (Test-Path -LiteralPath $bak) { + Remove-Item -LiteralPath $bak -Force + } + Move-Item -LiteralPath $Path -Destination $bak -Force +} + +function Test-IdaMcpHealth { + param([int]$Port) + $probe = Probe-IdaMcp -Port $Port + if ($probe.Status -eq 'healthy') { return $probe.Count } + return 0 +} + +function Probe-IdaMcp { + param([int]$Port) + $owners = @(Get-IdaMcpPortOwners -Port $Port) + $guiOwners = @($owners | Where-Object { Test-IdaGuiProcess -ProcessId $_ }) + $listening = $owners.Count -gt 0 + + try { + $r = Invoke-RestMethod "http://127.0.0.1:$Port/mcp" -Method Post ` + -Body '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ` + -ContentType 'application/json' -TimeoutSec 3 -ErrorAction Stop + $tools = @($r.result.tools) + $count = $tools.Count + $names = @($tools | ForEach-Object { $_.name }) + # Supervisor without --unsafe is "up" but missing py_eval. Treat as stale. + if ($count -gt 0 -and ($names -contains 'py_eval')) { + return @{ Status = 'healthy'; Count = $count; Owners = $owners; GuiOwners = $guiOwners } + } + if ($count -gt 0) { + return @{ Status = 'stale'; Count = $count; Owners = $owners; GuiOwners = $guiOwners } + } + } catch {} + + if ($guiOwners.Count -gt 0) { + return @{ Status = 'gui_busy'; Count = 0; Owners = $owners; GuiOwners = $guiOwners } + } + # Single-threaded supervisor cannot answer tools/list during idb_open. + # A listen socket is busy, not dead — never taskkill on RPC timeout. + if ($listening) { + return @{ Status = 'busy'; Count = 0; Owners = $owners; GuiOwners = $guiOwners } + } + return @{ Status = 'down'; Count = 0; Owners = $owners; GuiOwners = $guiOwners } +} + +function Get-IdaMcpPortOwners { + param([int]$Port) + try { + return @( + Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | + Where-Object { $_ -and $_ -gt 0 } + ) + } catch { + return @() + } +} + +function Get-IdaMcpProcessInfo { + param([int]$ProcessId) + return Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction SilentlyContinue +} + +function Test-IdaGuiProcess { + param([int]$ProcessId) + $proc = Get-IdaMcpProcessInfo -ProcessId $ProcessId + if (-not $proc) { return $false } + return [string]$proc.Name -match '(?i)^(ida|ida64|idaq|idaq64)\.exe$' +} + +function Test-ManagedSupervisorProcess { + param([int]$ProcessId) + $proc = Get-IdaMcpProcessInfo -ProcessId $ProcessId + if (-not $proc) { return $false } + $name = [string]$proc.Name + $cmd = [string]$proc.CommandLine + if ($name -match '(?i)^(ida|ida64|idaq|idaq64)\.exe$') { return $false } + if ($name -match '(?i)^(python|pythonw|cmd)\.exe$') { + return $cmd -match '(?i)(run-supervisor\.py|idalib_supervisor|idalib-mcp|ida-pro-mcp)' + } + return $name -match '(?i)^(idalib-mcp|ida-pro-mcp|idalib_supervisor)' +} + +function Get-ManagedSupervisorProcessIds { + $ids = New-Object System.Collections.Generic.List[int] + foreach ($proc in @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) { + if (-not $proc.ProcessId) { continue } + $candidateId = [int]$proc.ProcessId + if (Test-ManagedSupervisorProcess -ProcessId $candidateId) { + [void]$ids.Add($candidateId) + } + } + return @($ids | Select-Object -Unique) +} + +function Stop-IdaMcpManagedProcess { + param([int]$ProcessId) + if ($ProcessId -le 0) { return } + if (Test-IdaGuiProcess -ProcessId $ProcessId) { return } + # No /T: force_gui may have spawned ida.exe as a child of the supervisor. + & taskkill.exe /F /PID $ProcessId 2>$null | Out-Null +} + +function Get-PortableIdaCandidates { + $desktop = [Environment]::GetFolderPath('Desktop') + $userProfile = $env:USERPROFILE + return @( + (Join-Path $desktop 'IDA Pro 9.4\App\IDA Pro'), + (Join-Path $desktop 'IDA Pro\App\IDA Pro'), + (Join-Path $userProfile 'Desktop\IDA Pro 9.4\App\IDA Pro'), + (Join-Path $userProfile 'Tools\IDA Pro 9.4\App\IDA Pro'), + (Join-Path $userProfile 'Tools\IDA Pro\App\IDA Pro'), + (Join-Path $userProfile 'Tools\IDA'), + 'C:\Program Files\IDA Professional 9.4', + 'C:\Program Files\IDA Pro 9.4', + 'C:\Program Files\IDA Pro', + 'C:\Program Files\IDA', + 'C:\IDA Pro', + 'C:\IDA', + 'D:\IDA', + 'D:\Tools\IDA Pro 9.4\App\IDA Pro', + 'E:\Program Files\IDA' + ) +} + +function Test-IdaInstallDir { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $idaExe = Join-Path $Path 'ida.exe' + $idaDll = Join-Path $Path 'ida.dll' + return (Test-Path -LiteralPath $idaExe) -or (Test-Path -LiteralPath $idaDll) +} + function Get-InstalledIdaDir { $registryPaths = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', @@ -31,7 +188,7 @@ function Get-InstalledIdaDir { Where-Object { ($_.DisplayName -match 'IDA|Hex-Rays') -and -not [string]::IsNullOrWhiteSpace($_.InstallLocation) -and - (Test-Path -LiteralPath $_.InstallLocation) + (Test-IdaInstallDir -Path $_.InstallLocation) } | Select-Object -ExpandProperty InstallLocation -First 1 @@ -39,133 +196,312 @@ function Get-InstalledIdaDir { return $fromRegistry } - $idaCandidates = @( - 'C:\Program Files\IDA Pro', - 'C:\Program Files\IDA', - 'C:\IDA Pro', - 'C:\IDA', - 'D:\IDA', - 'E:\Program Files\IDA', - (Join-Path $env:USERPROFILE 'Tools\IDA') - ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - - return $idaCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + return Get-PortableIdaCandidates | Where-Object { Test-IdaInstallDir -Path $_ } | Select-Object -First 1 } -if ([string]::IsNullOrWhiteSpace($IdaDir)) { - if (-not [string]::IsNullOrWhiteSpace($env:IDADIR)) { - $IdaDir = $env:IDADIR - } else { - $persistedIdaDir = [Environment]::GetEnvironmentVariable('IDADIR', 'User') - if ([string]::IsNullOrWhiteSpace($persistedIdaDir)) { - $persistedIdaDir = [Environment]::GetEnvironmentVariable('IDADIR', 'Machine') - } +function Resolve-IdaDir { + param([string]$Preferred) - if (-not [string]::IsNullOrWhiteSpace($persistedIdaDir) -and (Test-Path -LiteralPath $persistedIdaDir)) { - $IdaDir = $persistedIdaDir - } else { - # Search registry install records and common fallback paths - $foundIda = Get-InstalledIdaDir - if ($foundIda) { - $IdaDir = $foundIda - } else { - Write-Output "ERR:IDADIR not set and IDA Pro not found. Set IDADIR environment variable." - exit 1 - } + if (-not [string]::IsNullOrWhiteSpace($Preferred) -and (Test-IdaInstallDir -Path $Preferred)) { + return [System.IO.Path]::GetFullPath($Preferred) + } + + if (-not [string]::IsNullOrWhiteSpace($env:IDADIR) -and (Test-IdaInstallDir -Path $env:IDADIR)) { + return [System.IO.Path]::GetFullPath($env:IDADIR) + } + + foreach ($scope in @('User', 'Machine')) { + $persisted = [Environment]::GetEnvironmentVariable('IDADIR', $scope) + if (-not [string]::IsNullOrWhiteSpace($persisted) -and (Test-IdaInstallDir -Path $persisted)) { + return [System.IO.Path]::GetFullPath($persisted) } } + + $found = Get-InstalledIdaDir + if ($found) { + return [System.IO.Path]::GetFullPath($found) + } + + return $null } + +function Test-PythonHasIdaProMcp { + param([string]$PythonExe) + if ([string]::IsNullOrWhiteSpace($PythonExe) -or -not (Test-Path -LiteralPath $PythonExe)) { + return $false + } + # pythonw.exe has no console — probe with sibling python.exe when possible + $probeExe = $PythonExe + if ($PythonExe -match '(?i)pythonw\.exe$') { + $sibling = Join-Path (Split-Path $PythonExe -Parent) 'python.exe' + if (Test-Path -LiteralPath $sibling) { $probeExe = $sibling } + } + try { + $check = & $probeExe -c "import ida_pro_mcp; print('ok')" 2>$null + return ($LASTEXITCODE -eq 0) -or ($check -match 'ok') + } catch { + return $false + } +} + +function Get-WindowlessPythonPath { + param([string]$PythonExe) + if ([string]::IsNullOrWhiteSpace($PythonExe)) { return $PythonExe } + if ($PythonExe -match '(?i)pythonw\.exe$') { return $PythonExe } + if ($PythonExe -match '(?i)python\.exe$') { + $pythonw = Join-Path (Split-Path $PythonExe -Parent) 'pythonw.exe' + if (Test-Path -LiteralPath $pythonw) { return $pythonw } + } + return $PythonExe +} + +function Find-IdalibServer { + param( + [string]$IdaDirPath, + [string]$PreferredServerPath + ) + + if (-not [string]::IsNullOrWhiteSpace($PreferredServerPath) -and (Test-Path -LiteralPath $PreferredServerPath)) { + return @{ Mode = 'exe'; Path = $PreferredServerPath } + } + + # Prefer direct `pythonw -m ida_pro_mcp.idalib_supervisor` (no console window; more stable than .cmd) + $pythonCandidates = @( + (Join-Path $IdaDirPath 'Python314\pythonw.exe'), + (Join-Path $IdaDirPath 'Python314\python.exe'), + (Join-Path $IdaDirPath 'python\pythonw.exe'), + (Join-Path $IdaDirPath 'python\python.exe'), + (Join-Path $env:LOCALAPPDATA 'Python\pythoncore-3.14-64\pythonw.exe'), + (Join-Path $env:LOCALAPPDATA 'Python\pythoncore-3.14-64\python.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python314\pythonw.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python314\python.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\pythonw.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe'), + (Get-Command pythonw -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -First 1), + (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -First 1) + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + + foreach ($py in $pythonCandidates) { + # `py` launcher is not a real interpreter for -m in Start-Process; skip bare py.exe + if ($py -match '\\py\.exe$') { continue } + if (Test-PythonHasIdaProMcp -PythonExe $py) { + $launch = Get-WindowlessPythonPath -PythonExe $py + return @{ Mode = 'module'; Path = $launch; Module = 'ida_pro_mcp.idalib_supervisor' } + } + } + + # Prefer native .exe entrypoints over .cmd wrappers + $scriptCandidates = @( + (Join-Path $env:LOCALAPPDATA 'Python\pythoncore-3.14-64\Scripts\idalib-mcp.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\Scripts\idalib-mcp.exe'), + (Join-Path $IdaDirPath 'Python314\Scripts\idalib-mcp.exe'), + (Join-Path $IdaDirPath 'Python314\Scripts\ida-pro-mcp.exe') + ) + foreach ($candidate in $scriptCandidates) { + if (Test-Path -LiteralPath $candidate) { + return @{ Mode = 'exe'; Path = $candidate } + } + } + + foreach ($name in @('idalib-mcp', 'ida-pro-mcp')) { + $resolved = Get-Command $name -ErrorAction SilentlyContinue + if ($resolved) { + return @{ Mode = 'exe'; Path = $resolved.Source } + } + } + + foreach ($candidate in @( + (Join-Path $env:USERPROFILE 'Tools\bin\idalib-mcp.cmd'), + (Join-Path $env:USERPROFILE 'Tools\bin\ida-pro-mcp.cmd') + )) { + if (Test-Path -LiteralPath $candidate) { + return @{ Mode = 'exe'; Path = $candidate } + } + } + + $roamingPython = Join-Path $env:APPDATA 'Python' + if (Test-Path -LiteralPath $roamingPython) { + $candidate = Get-ChildItem -LiteralPath $roamingPython -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + $scripts = Join-Path $_.FullName 'Scripts' + @('idalib-mcp.exe', 'ida-pro-mcp.exe') | ForEach-Object { Join-Path $scripts $_ } + } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + if ($candidate) { + return @{ Mode = 'exe'; Path = $candidate } + } + } + + return $null +} + +$probe = Probe-IdaMcp -Port $Port +$guiOwners = @($probe.GuiOwners) + +if ($guiOwners.Count -gt 0) { + Write-Output ("WARN:gui_busy:port={0} pid={1}" -f $Port, ($guiOwners -join ',')) + Write-Output 'HINT: IDA GUI owns 13337; not killing ida.exe. Wait for analysis or close IDA.' + exit 0 +} + +if (-not $Force) { + if ($probe.Status -eq 'healthy') { + Write-Output "OK:$($probe.Count)`:reuse" + exit 0 + } + if ($probe.Status -eq 'busy') { + Write-Output ("WARN:busy:port={0} pid={1}" -f $Port, ($probe.Owners -join ',')) + Write-Output 'HINT: 13337 is listening but tools/list timed out (likely idb_open). Not killing supervisor.' + exit 0 + } +} + +$resolvedIdaDir = Resolve-IdaDir -Preferred $IdaDir +if (-not $resolvedIdaDir) { + Write-Output "ERR:IDADIR not set and IDA Pro not found. Set IDADIR to your IDA install dir (folder containing ida.exe)." + exit 1 +} + +$IdaDir = $resolvedIdaDir $env:IDADIR = $IdaDir -if ([string]::IsNullOrWhiteSpace($ServerPath)) { - # Try both possible executable names (idalib-mcp is the HTTP server, ida-pro-mcp is the installer CLI) - $resolved = Get-Command idalib-mcp -ErrorAction SilentlyContinue - if (-not $resolved) { - $resolved = Get-Command ida-pro-mcp -ErrorAction SilentlyContinue - } - if ($resolved) { - $ServerPath = $resolved.Source - } - else { - $roamingPython = Join-Path $env:APPDATA 'Python' - if (Test-Path -LiteralPath $roamingPython) { - $candidate = Get-ChildItem -LiteralPath $roamingPython -Directory -ErrorAction SilentlyContinue | - ForEach-Object { - $scripts = Join-Path $_.FullName 'Scripts' - @('idalib-mcp.exe', 'ida-pro-mcp.exe') | ForEach-Object { Join-Path $scripts $_ } - } | - Where-Object { Test-Path -LiteralPath $_ } | - Select-Object -First 1 - if ($candidate) { - $ServerPath = $candidate - } - } - } +# Ensure IDA bins + bundled Python are visible to the child process +$idaPythonDir = Join-Path $IdaDir 'Python314' +if (-not (Test-Path -LiteralPath $idaPythonDir)) { + $idaPythonDir = Join-Path $IdaDir 'python' } +$env:PATH = "$IdaDir;$idaPythonDir;$(Join-Path $idaPythonDir 'Scripts');$env:PATH" -# Auto-bootstrap if still not found -if ([string]::IsNullOrWhiteSpace($ServerPath)) { +$server = Find-IdalibServer -IdaDirPath $IdaDir -PreferredServerPath $ServerPath + +# Auto-bootstrap only if still missing +if (-not $server) { $bootstrapScript = Join-Path $PSScriptRoot '..\..\scripts\bootstrap-reverse.ps1' if (Test-Path -LiteralPath $bootstrapScript) { Write-Output "INFO: ida-pro-mcp not found, attempting auto-bootstrap (installing mrexodia/ida-pro-mcp)..." & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $bootstrapScript -Capability @('idalib-mcp') -SkipRefresh - $resolved = Get-Command ida-pro-mcp -ErrorAction SilentlyContinue - if (-not $resolved) { - $resolved = Get-Command idalib-mcp -ErrorAction SilentlyContinue - } - if ($resolved) { - $ServerPath = $resolved.Source - } - else { - $roamingPython = Join-Path $env:APPDATA 'Python' - if (Test-Path -LiteralPath $roamingPython) { - $candidate = Get-ChildItem -LiteralPath $roamingPython -Directory -ErrorAction SilentlyContinue | - ForEach-Object { - $scripts = Join-Path $_.FullName 'Scripts' - @('ida-pro-mcp.exe', 'idalib-mcp.exe') | ForEach-Object { Join-Path $scripts $_ } - } | - Where-Object { Test-Path -LiteralPath $_ } | - Select-Object -First 1 - if ($candidate) { - $ServerPath = $candidate - } - } - } + $server = Find-IdalibServer -IdaDirPath $IdaDir -PreferredServerPath $ServerPath } } -if ([string]::IsNullOrWhiteSpace($ServerPath)) { - throw 'Missing required CLI tool: ida-pro-mcp — auto-bootstrap failed. Install manually: pip install git+https://github.com/mrexodia/ida-pro-mcp.git && ida-pro-mcp --install' +if (-not $server) { + Write-Output 'ERR:Missing idalib-mcp — install into IDA Python: \Python314\python.exe -m pip install git+https://github.com/mrexodia/ida-pro-mcp.git' + exit 1 } -# 清理旧进程(杀进程树,包括 worker 子进程) -$old = Get-Process -Name "ida-pro-mcp" -ErrorAction SilentlyContinue -if (-not $old) { $old = Get-Process -Name "idalib-mcp" -ErrorAction SilentlyContinue } -if ($old) { - foreach ($process in @($old)) { - & taskkill.exe /F /T /PID $process.Id 2>$null | Out-Null +# Replace only when down or stale (no py_eval). Busy/gui already exited above +# unless -Force. Never taskkill ida.exe; never use /T. +foreach ($procName in @('ida-pro-mcp', 'idalib-mcp', 'idalib_supervisor')) { + $old = Get-Process -Name $procName -ErrorAction SilentlyContinue + if ($old) { + foreach ($process in @($old)) { + Stop-IdaMcpManagedProcess -ProcessId $process.Id + } } - Start-Sleep 2 +} +foreach ($managedPid in @(Get-ManagedSupervisorProcessIds)) { + Stop-IdaMcpManagedProcess -ProcessId $managedPid +} +Start-Sleep -Seconds 1 + +$wrapper = Join-Path $PSScriptRoot 'run-supervisor.py' +# --unsafe exposes py_eval / py_exec_file. dbg_* stay hidden (need ?ext=dbg; do not add). +$argList = @('--host', '127.0.0.1', '--port', "$Port", '--unsafe') +if (Test-Path -LiteralPath $wrapper) { + $filePath = $server.Path + if ($filePath -match '(?i)python\.exe$') { + $pythonw = Join-Path (Split-Path $filePath -Parent) 'pythonw.exe' + if (Test-Path -LiteralPath $pythonw) { $filePath = $pythonw } + } + if ($filePath -notmatch '(?i)pythonw?\.exe$') { + $filePath = $server.Path + if ($server.Mode -eq 'module') { + $argList = @('-u', '-m', $server.Module) + $argList + } + } else { + $argList = @('-u', $wrapper) + $argList + } +} elseif ($server.Mode -eq 'module') { + $filePath = $server.Path + $argList = @('-u', '-m', $server.Module) + $argList +} else { + $filePath = $server.Path } -# 后台启动 -Start-Process -WindowStyle Hidden -FilePath $ServerPath -ArgumentList "--host 127.0.0.1 --port $Port" +$logDir = Get-IdaMcpLogDir +$logFile = Join-Path $logDir 'supervisor.log' +Rotate-IdaMcpLog -Path $logFile -# 等待就绪 -$ready = $false -for ($i = 0; $i -lt 15; $i++) { - Start-Sleep -Seconds 1 +Write-Output "INFO:IDADIR=$IdaDir" +Write-Output "INFO:server=$filePath $($argList -join ' ')" +Write-Output "INFO:log=$logFile" +Write-Output 'INFO:window=hidden (pythonw / no console)' + +# Detached + hidden start: +# pythonw + run-supervisor.py keeps logs without a cmd.exe window. +# Win32_Process.Create so the server survives agent/terminal Job Objects. +$quotedArgs = foreach ($a in $argList) { + if ($a -match '[\s"]') { '"' + ($a -replace '"', '\"') + '"' } else { $a } +} +$useCmd = $filePath -match '(?i)\.cmd$' +if ($useCmd) { + $commandLine = 'cmd.exe /d /c "' + $filePath + '" ' + ($quotedArgs -join ' ') +} else { + $commandLine = '"' + $filePath + '" ' + ($quotedArgs -join ' ') +} +$procId = 0 + +try { + $create = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ + CommandLine = $commandLine + CurrentDirectory = $IdaDir + } + if ($create -and $create.ReturnValue -eq 0 -and $create.ProcessId) { + $procId = [int]$create.ProcessId + } +} catch {} + +if ($procId -le 0) { try { - $r = Invoke-RestMethod "http://127.0.0.1:$Port/mcp" -Method Post ` - -Body '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ` - -ContentType "application/json" -ErrorAction Stop - if ($r.result.tools.Count -gt 0) { - Write-Output "OK:$($r.result.tools.Count)" - $ready = $true - break + $psi = New-Object System.Diagnostics.ProcessStartInfo + if ($useCmd) { + $psi.FileName = 'cmd.exe' + $psi.Arguments = '/d /c "' + $filePath + '" ' + ($quotedArgs -join ' ') + } else { + $psi.FileName = $filePath + $psi.Arguments = ($quotedArgs -join ' ') } + $psi.WorkingDirectory = $IdaDir + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden + $p = [System.Diagnostics.Process]::Start($psi) + if ($p) { $procId = $p.Id } } catch {} } + +if ($procId -le 0) { + Write-Output 'ERR:failed_to_start_process' + exit 1 +} +Write-Output "INFO:pid=$procId" + +# Wait for readiness via MCP tools/list +$ready = $false +$toolCount = 0 +for ($i = 0; $i -lt $WaitSeconds; $i++) { + Start-Sleep -Seconds 1 + $toolCount = Test-IdaMcpHealth -Port $Port + if ($toolCount -gt 0) { + Write-Output "OK:$toolCount" + $ready = $true + break + } +} + if (-not $ready) { Write-Output "ERR:timeout" + Write-Output "HINT:check $logFile" + exit 1 } diff --git a/skills/ida-reverse/scripts/watchdog.ps1 b/skills/ida-reverse/scripts/watchdog.ps1 new file mode 100644 index 0000000..02f2933 --- /dev/null +++ b/skills/ida-reverse/scripts/watchdog.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS +Ensure IDA Pro MCP HTTP is healthy; start it only when down. + +.DESCRIPTION +One-shot health check used by the scheduled task. Safe to run every minute: +a live server is reused, a dead listener is replaced via start.ps1. + +Usage: + powershell -File watchdog.ps1 +#> + +param( + [int]$Port = 13337 +) + +$ErrorActionPreference = 'Stop' + +$logDir = Join-Path $env:LOCALAPPDATA 'reverse-skill\ida-mcp' +if (-not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null +} +$logFile = Join-Path $logDir 'watchdog.log' +if ((Test-Path -LiteralPath $logFile) -and ((Get-Item -LiteralPath $logFile).Length -gt 2MB)) { + $bak = "$logFile.1" + if (Test-Path -LiteralPath $bak) { Remove-Item -LiteralPath $bak -Force } + Move-Item -LiteralPath $logFile -Destination $bak -Force +} + +function Write-WatchLog { + param([string]$Message) + $line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message + Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 + Write-Output $Message +} + +function Get-IdaMcpPortOwners { + param([int]$Port) + try { + return @( + Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | + Where-Object { $_ -and $_ -gt 0 } + ) + } catch { + return @() + } +} + +function Probe-IdaMcp { + param([int]$Port) + $owners = @(Get-IdaMcpPortOwners -Port $Port) + try { + $r = Invoke-RestMethod "http://127.0.0.1:$Port/mcp" -Method Post ` + -Body '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ` + -ContentType 'application/json' -TimeoutSec 3 -ErrorAction Stop + $tools = @($r.result.tools) + $count = $tools.Count + $names = @($tools | ForEach-Object { $_.name }) + if ($count -gt 0 -and ($names -contains 'py_eval')) { + return @{ Status = 'healthy'; Count = $count } + } + if ($count -gt 0) { + return @{ Status = 'stale'; Count = $count } + } + } catch {} + # Listen + RPC timeout = busy (single-threaded supervisor during idb_open). + # Only "nothing listening" or a quick stale (no py_eval) list is down. + if ($owners.Count -gt 0) { + return @{ Status = 'busy'; Count = 0 } + } + return @{ Status = 'down'; Count = 0 } +} + +$probe = Probe-IdaMcp -Port $Port +if ($probe.Status -eq 'healthy') { + Write-WatchLog "OK:$($probe.Count)`:reuse" + exit 0 +} +if ($probe.Status -eq 'busy') { + Write-WatchLog 'OK:busy:reuse' + exit 0 +} + +Write-WatchLog ("INFO:{0}, calling start.ps1" -f $probe.Status) +$startScript = Join-Path $PSScriptRoot 'start.ps1' +$startOutput = & $startScript -Port $Port +foreach ($line in @($startOutput)) { + Write-WatchLog "START:$line" +} + +$last = (@($startOutput) | Where-Object { $_ -match '^(OK|ERR|WARN):' } | Select-Object -Last 1) +if ($last -match '^(OK:|WARN:gui_busy|WARN:busy)') { + exit 0 +} +exit 1 diff --git a/skills/scripts/lib/ToolDiscovery.ps1 b/skills/scripts/lib/ToolDiscovery.ps1 index f81129b..e8ef164 100644 --- a/skills/scripts/lib/ToolDiscovery.ps1 +++ b/skills/scripts/lib/ToolDiscovery.ps1 @@ -105,6 +105,55 @@ function Get-ReverseToolCatalog { [pscustomobject]@{ Type = 'command'; Value = 'zipalign' } ) } + [pscustomobject]@{ + Name = 'idalib-mcp' + Skill = 'ida-reverse' + Purpose = 'IDA Pro idalib MCP HTTP/stdio 服务器' + FixedVersion = 'v0.5.0' + VersionArgs = @('--help') + Fallbacks = @( + [pscustomobject]@{ Type = 'command'; Value = 'idalib-mcp' }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Tools\bin\idalib-mcp.cmd') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-ReverseOptionalPath -Path $localAppData -ChildPath 'Python\pythoncore-3.14-64\Scripts\idalib-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-ReverseOptionalPath -Path $localAppData -ChildPath 'Programs\Python\Python314\Scripts\idalib-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-ReverseOptionalPath -Path $localAppData -ChildPath 'Programs\Python\Python312\Scripts\idalib-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Desktop\IDA Pro 9.4\App\IDA Pro\Python314\Scripts\idalib-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path ([Environment]::GetFolderPath('Desktop')) 'IDA Pro 9.4\App\IDA Pro\Python314\Scripts\idalib-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Tools\IDA Pro 9.4\App\IDA Pro\Python314\Scripts\idalib-mcp.exe') } + ) + } + [pscustomobject]@{ + Name = 'ida-pro-mcp' + Skill = 'ida-reverse' + Purpose = 'IDA Pro MCP CLI / 插件安装器' + FixedVersion = 'v0.5.0' + VersionArgs = @('--help') + Fallbacks = @( + [pscustomobject]@{ Type = 'command'; Value = 'ida-pro-mcp' }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Tools\bin\ida-pro-mcp.cmd') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-ReverseOptionalPath -Path $localAppData -ChildPath 'Python\pythoncore-3.14-64\Scripts\ida-pro-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-ReverseOptionalPath -Path $localAppData -ChildPath 'Programs\Python\Python314\Scripts\ida-pro-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-ReverseOptionalPath -Path $localAppData -ChildPath 'Programs\Python\Python312\Scripts\ida-pro-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Desktop\IDA Pro 9.4\App\IDA Pro\Python314\Scripts\ida-pro-mcp.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path ([Environment]::GetFolderPath('Desktop')) 'IDA Pro 9.4\App\IDA Pro\Python314\Scripts\ida-pro-mcp.exe') } + ) + } + [pscustomobject]@{ + Name = 'ida' + Skill = 'ida-reverse' + Purpose = 'IDA Pro 主程序' + FixedVersion = 'v0.5.0' + VersionArgs = @() + Fallbacks = @( + [pscustomobject]@{ Type = 'command'; Value = 'ida' }, + [pscustomobject]@{ Type = 'path'; Value = 'C:\Program Files\IDA Professional 9.4\ida.exe' }, + [pscustomobject]@{ Type = 'path'; Value = 'C:\Program Files\IDA Pro 9.4\ida.exe' }, + [pscustomobject]@{ Type = 'path'; Value = 'C:\Program Files\IDA Pro\ida.exe' }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Desktop\IDA Pro 9.4\App\IDA Pro\ida.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path ([Environment]::GetFolderPath('Desktop')) 'IDA Pro 9.4\App\IDA Pro\ida.exe') }, + [pscustomobject]@{ Type = 'path'; Value = (Join-Path $userProfile 'Tools\IDA Pro 9.4\App\IDA Pro\ida.exe') } + ) + } [pscustomobject]@{ Name = 'frida' Skill = 'apk-reverse' diff --git a/skills/scripts/smoke.ps1 b/skills/scripts/smoke.ps1 index 60b67ad..a3afd79 100644 --- a/skills/scripts/smoke.ps1 +++ b/skills/scripts/smoke.ps1 @@ -99,6 +99,35 @@ foreach ($name in $scripts) { [void]$parseLog.Add("OK $name") } } +$idaScripts = @( + 'ida-reverse\scripts\start.ps1', + 'ida-reverse\scripts\open.ps1', + 'ida-reverse\scripts\watchdog.ps1', + 'ida-reverse\scripts\install-autostart.ps1', + 'ida-reverse\scripts\start-gui.ps1' +) +foreach ($rel in $idaScripts) { + $p = Join-Path $skillsRoot $rel + $name = $rel + if (-not (Test-Path -LiteralPath $p)) { + Bad ("script missing: {0}" -f $name) + $parseFail++ + [void]$parseLog.Add("MISSING $name") + continue + } + $errs = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($p, [ref]$tokens, [ref]$errs) + if ($errs -and $errs.Count -gt 0) { + Bad ("parse fail {0}: {1}" -f $name, $errs[0].Message) + $parseFail++ + [void]$parseLog.Add("FAIL $name $($errs[0].Message)") + } else { + Ok ("parse {0}" -f $name) + $parseOk++ + [void]$parseLog.Add("OK $name") + } +} $parseLog -join [Environment]::NewLine | Set-Content (Join-Path $LogDir '02-parse.txt') -Encoding UTF8 # --- 3) master-route sample matrix ---