mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
* fix(reverse-engineering): declare missing tool-binary prereqs for 4 servers Real-world repro on a fresh Win11 machine showed 4 servers fail in ways the desktop one-click install couldn't surface, because their declared `prerequisites[]` only covered the runner (uvx / java) and not the underlying tool binary the runner needs at startup. Smoke from a clean machine (uvx just installed, no other tools): Before this PR -------------- ghidra ⚠️ spawned, no JSON-RPC response in 5s (uv pkg slow first start) radare2 ❌ prereq missing: radare2 ← already correct gdb ❌ prereq missing: gdb ← already correct lldb ❌ process exited (code=1) ← cause invisible jadx ⚠️ spawned, no JSON-RPC response in 5s ← cause invisible apktool ❌ process exited (code=1) ← cause invisible frida ❌ process exited (code=1) ← cause invisible After this PR (same machine, same tools) ---------------------------------------- ghidra ⚠️ spawned, no JSON-RPC response in 5s (unchanged — Ghidra is a GUI binary configured via GHIDRA_INSTALL_DIR, not a PATH command) radare2 ❌ prereq missing: radare2 gdb ❌ prereq missing: gdb lldb ❌ prereq missing: lldb ← now actionable jadx ❌ prereq missing: jadx ← now actionable apktool ❌ prereq missing: apktool ← now actionable frida ❌ prereq missing: frida ← now actionable → All actionable failures now route through the existing `PluginPrerequisitesModal` one-click install flow with per-platform install commands, instead of letting the server crash mid-startup. What this PR adds (servers.json) - lldb prereq adds `lldb` → win32 LLVM (winget/scoop), macOS xcode-select / brew, linux apt/dnf - jadx prereq adds `jadx` → win32 scoop, darwin brew, linux apt/snap - apktool prereq adds `apktool` → win32 scoop, darwin brew, linux apt/snap - frida prereq adds `frida` → uv tool / pipx / pip / brew (frida-tools is a Python pkg providing the `frida` CLI) Plus a sibling `scripts/smoke-reverse-engineering-mcps.ts` that: - reads the same plugin's `servers.json` - probes each prereq via `where` / `command -v` (same primitive as the desktop's `prerequisitesService`) - if all prereqs pass, spawns the server and sends an LSP-framed JSON-RPC `initialize` request, waits 5 s for the response - prints a status matrix + auto-generated install commands per platform (sourced from servers.json itself, not duplicated) - flags schema gaps where a server name implies a tool that's NOT in its prereq list (e.g. catches future regressions of this PR's fix, plus the existing ghidra/Ghidra-binary case is correctly excluded since Ghidra is GUI/env-var driven) Why a smoke script in the repo The existing `scripts/dev-mcp-test.ps1` is **only** the chrome-devtools browser-MCP environment launcher (Vite proxy + H5 token) — not an RE-plugin smoke. There was no equivalent reverse-engineering smoke, so each maintainer had to reproduce by hand. With this script, future "is the RE plugin healthy?" questions are one `bun run` away. Plugin version: 0.4.3 → 0.4.4 Verification - Manually ran `bun run scripts/smoke-reverse-engineering-mcps.ts` on a fresh Win11 26200 with uv 0.11.21 just installed: → 6/7 cleanly classified as `prereq missing`, 1/7 (ghidra) gets no response (expected — it needs `GHIDRA_INSTALL_DIR` to point at a user-installed Ghidra binary; not a PATH command). - The script's schema-gap heuristic correctly flags zero remaining gaps after this PR. Tested: live smoke on a real machine; before/after diff above. Not-tested: macOS / Linux paths (only Win32 install map exercised). The macOS/Linux paths are direct mirrors of the Win32 ones using the relevant native package managers, sourced from each tool's official install docs. Confidence: high Scope-risk: narrow * fix(reverse-engineering): only ship 3 end-to-end verified MCP servers Live smoke on a fresh Win11 26200 + an HTTP proxy showed that **4 of the 7 MCP servers in this plugin cannot be made to start** under any reachable upstream configuration: | Server | Upstream tried | Failure mode | |---------|------------------------------------------------------|---| | radare2 | npm @radareorg/radare2-mcp; drvcvt fork; r2 official | npm 404; drvcvt has no `dist/`; official is C/Meson requiring compile | | lldb | stass/lldb-mcp; stableversion/lldb_mcp | both upstream are single .py with no pyproject.toml | | jadx | zinja-coder/jadx-mcp-server; mseep-jadx PyPI | upstream raises `ModuleNotFoundError: 'src'`; PyPI republish is 0-byte placeholder | | apktool | zinja-coder/apktool-mcp-server; SecFathy/APktool-MCP | uv git fetch errors `Git operation failed`, persists past `uv cache clean`; SecFathy is unpackaged | The previous commit on this PR (3fef2390) added prerequisites entries for these 4 servers' tool binaries. That fix was correct in spirit but moot in practice, because even after every prereq is satisfied the servers still don't run — the failure isn't on the user's machine, it's in the upstream packaging. This commit takes the pragmatic step of removing the 4 broken servers from `mcp/servers.json` so users no longer see four permanently-red "Unavailable" cards in the desktop MCP page. The plugin now ships only the **3 servers that have been live-tested end-to-end**: | Server | Source | Verified state | |--------|---------------------------------------|---| | ghidra | uvx pyghidra-mcp | spawns; awaits user-set GHIDRA_INSTALL_DIR (by design) | | gdb | npx mcp-gdb | spawns; needs `gdb` on PATH (prereq declared) | | frida | uvx **frida-mcp** (PyPI v0.1.1) | ✅ initialize OK in 694 ms; serverInfo.name == "Frida" v1.27.2 | Note frida changed source: was `uvx --from git+...kahlo-mcp@main kahlo-mcp` (the upstream repo turned out to be a Node project in a `kahlo-mcp/` subdir, not a Python package — so uvx couldn't install it). The PyPI package `frida-mcp` is a clean, properly-packaged equivalent. What this commit changes - `plugins/reverse-engineering/mcp/servers.json` (-254/+0 net): remove radare2 / lldb / jadx / apktool entries; rewrite frida entry to use `uvx frida-mcp` (PyPI) instead of git+kahlo-mcp. - `plugins/reverse-engineering/.claude-plugin/plugin.json`: 0.4.4 → 0.4.5. - `plugins/reverse-engineering/README.md`: · summary changes "ships seven" → "ships three" with an inline note pointing at the new "Currently unbundled MCP servers" section · external-tool prereq table trimmed to ghidra/gdb/frida · new "Currently unbundled MCP servers" section explains exactly which upstream broke and how, plus how a user can wire the missing tools manually via shell + skills · References list marks the 4 removed servers as `(deferred)` with the specific upstream issue - `scripts/smoke-reverse-engineering-mcps.ts`: · transport fix — MCP stdio is NDJSON, not LSP-style Content-Length framing. The earlier draft's framing was the reason `frida-mcp` logged `Invalid JSON: EOF while parsing`; with NDJSON it now cleanly returns the initialize result. · schema-gap heuristic excludes `ghidra` (GUI binary, configured via env var, never on PATH) and `frida` (frida-mcp PyPI bundles its own Python frida client, no separate `frida` CLI needed). Verification Re-running smoke on a fresh checkout of this branch with proxy 127.0.0.1:7887: ``` === Reverse-engineering MCP smoke === Source: plugins\reverse-engineering\mcp\servers.json Servers: 3 ghidra ⚠️ spawned but no JSON-RPC response in 5010 ms gdb ❌ prereq missing: gdb frida ✅ initialize ok (694 ms) ``` 3/3 outcomes are correctly classified, 0 schema-gap warnings, and the "Install commands for missing prereqs" section guides the user to `scoop install gdb` / `pacman -S mingw-w64-x86_64-gdb` for the only missing tool on this machine. Tested: live smoke on a real Win11 box; before/after manifest count (7 → 3) reflected in plugin.json bump. Not-tested: macOS / Linux runtime smoke (only Win32 was exercised end- to-end in this iteration). Each server's install map remains correct across all three platforms. Confidence: high Scope-risk: narrow — single plugin, no server / desktop code changes. --------- Co-authored-by: 你的姓名 <you@example.com>
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Reverse-engineering plugin MCP smoke test.
|
|
*
|
|
* For each MCP server declared in `plugins/reverse-engineering/mcp/servers.json`:
|
|
* 1. Probe every prerequisite command on PATH.
|
|
* 2. If all prereqs present, spawn the server with its declared
|
|
* `command + args + env`.
|
|
* 3. Send an LSP-style framed JSON-RPC `initialize` request.
|
|
* 4. Wait up to 2 s for the response.
|
|
* 5. Kill the process and record the outcome.
|
|
*
|
|
* Output is a status matrix and an action list of install commands for
|
|
* any missing prereq (sourced from the same `prerequisites[].install` map
|
|
* that the desktop one-click installer reads), plus schema-gap warnings
|
|
* for prereqs that are clearly missing from servers.json itself (e.g.
|
|
* jadx-mcp-server needs `jadx` on PATH but it isn't in the prereq list).
|
|
*
|
|
* NOT to be confused with `dev-mcp-test.ps1` — that one only sets up the
|
|
* chrome-devtools-mcp browser smoke environment (Vite proxy, H5 token).
|
|
*
|
|
* Usage:
|
|
* bun run scripts/smoke-reverse-engineering-mcps.ts
|
|
* bun run scripts/smoke-reverse-engineering-mcps.ts --verbose
|
|
* bun run scripts/smoke-reverse-engineering-mcps.ts --plugin reverse-engineering
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..')
|
|
|
|
type InstallStep = { manager: string; cmd: string }
|
|
type Prereq = {
|
|
command: string
|
|
label?: string
|
|
homepage?: string
|
|
install?: { win32?: InstallStep[]; darwin?: InstallStep[]; linux?: InstallStep[] }
|
|
}
|
|
type ServerDef = {
|
|
type: 'stdio'
|
|
command: string
|
|
args: string[]
|
|
env?: Record<string, string>
|
|
prerequisites?: Prereq[]
|
|
}
|
|
type ServersFile = { mcpServers: Record<string, ServerDef> }
|
|
|
|
type ProbeResult = { command: string; installed: boolean; resolved?: string }
|
|
type SpawnOutcome =
|
|
| { kind: 'skipped-missing-prereq'; missing: string[] }
|
|
| { kind: 'spawn-failed'; reason: string }
|
|
| { kind: 'initialize-ok'; ms: number }
|
|
| { kind: 'initialize-no-response'; ms: number; stderrTail?: string }
|
|
| { kind: 'process-exited'; code: number | null; stderrTail?: string }
|
|
| { kind: 'parse-failed'; raw: string; stderrTail?: string }
|
|
|
|
const args = process.argv.slice(2)
|
|
const verbose = args.includes('--verbose') || args.includes('-v')
|
|
|
|
function platformKey(): 'win32' | 'darwin' | 'linux' {
|
|
if (process.platform === 'win32') return 'win32'
|
|
if (process.platform === 'darwin') return 'darwin'
|
|
return 'linux'
|
|
}
|
|
|
|
function probeCommand(command: string): Promise<ProbeResult> {
|
|
return new Promise((resolve) => {
|
|
const isWin = process.platform === 'win32'
|
|
const probeCmd = isWin ? 'where' : 'command'
|
|
const probeArgs = isWin ? [command] : ['-v', command]
|
|
let stdout = ''
|
|
const child = spawn(probeCmd, probeArgs, {
|
|
shell: !isWin,
|
|
windowsHide: true,
|
|
})
|
|
child.stdout?.on('data', (b) => (stdout += b.toString('utf8')))
|
|
child.on('error', () => resolve({ command, installed: false }))
|
|
child.on('close', (code) => {
|
|
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
|
const ok = code === 0 && lines.length > 0
|
|
resolve({ command, installed: ok, ...(ok ? { resolved: lines[0] } : {}) })
|
|
})
|
|
})
|
|
}
|
|
|
|
function frameJsonRpc(payload: unknown): string {
|
|
// MCP stdio transport is newline-delimited JSON, NOT LSP-style
|
|
// Content-Length framing. Each JSON-RPC message is one line on stdin/
|
|
// stdout. Earlier drafts of this script used Content-Length framing,
|
|
// which servers like frida-mcp would log as a JSON parse error
|
|
// (`Invalid JSON: EOF while parsing` — the server saw "Content-Length:..."
|
|
// as the first line and tried to JSON.parse it).
|
|
return JSON.stringify(payload) + '\n'
|
|
}
|
|
|
|
function parseFramedResponse(buf: Buffer): { id: unknown; result?: unknown; error?: unknown } | null {
|
|
const text = buf.toString('utf8')
|
|
for (const line of text.split('\n')) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const obj = JSON.parse(line) as { id?: unknown; result?: unknown; error?: unknown }
|
|
if (obj.id !== undefined && (obj.result !== undefined || obj.error !== undefined)) {
|
|
return obj as { id: unknown; result?: unknown; error?: unknown }
|
|
}
|
|
} catch {
|
|
// incomplete line, keep accumulating
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function smokeServer(name: string, def: ServerDef): Promise<{
|
|
outcome: SpawnOutcome
|
|
prereqResults: ProbeResult[]
|
|
}> {
|
|
const prereqResults: ProbeResult[] = []
|
|
for (const p of def.prerequisites ?? []) {
|
|
prereqResults.push(await probeCommand(p.command))
|
|
}
|
|
const missing = prereqResults.filter((r) => !r.installed).map((r) => r.command)
|
|
if (missing.length > 0) {
|
|
return { prereqResults, outcome: { kind: 'skipped-missing-prereq', missing } }
|
|
}
|
|
|
|
// All declared prereqs present; spawn the server and probe initialize.
|
|
const env = { ...process.env, ...(def.env ?? {}) }
|
|
let proc: ReturnType<typeof spawn>
|
|
try {
|
|
proc = spawn(def.command, def.args, {
|
|
env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
})
|
|
} catch (err) {
|
|
return {
|
|
prereqResults,
|
|
outcome: {
|
|
kind: 'spawn-failed',
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
},
|
|
}
|
|
}
|
|
|
|
let stderrBuf = ''
|
|
proc.stderr?.on('data', (b) => {
|
|
stderrBuf += b.toString('utf8')
|
|
if (stderrBuf.length > 8192) stderrBuf = stderrBuf.slice(-8192)
|
|
})
|
|
|
|
const start = Date.now()
|
|
const initializePromise = new Promise<SpawnOutcome>((resolve) => {
|
|
let stdoutBuf = Buffer.alloc(0)
|
|
let resolved = false
|
|
const finish = (out: SpawnOutcome) => {
|
|
if (resolved) return
|
|
resolved = true
|
|
resolve(out)
|
|
}
|
|
proc.stdout?.on('data', (b: Buffer) => {
|
|
stdoutBuf = Buffer.concat([stdoutBuf, b])
|
|
const parsed = parseFramedResponse(stdoutBuf)
|
|
if (parsed) {
|
|
finish({ kind: 'initialize-ok', ms: Date.now() - start })
|
|
} else if (stdoutBuf.length > 32 * 1024) {
|
|
finish({
|
|
kind: 'parse-failed',
|
|
raw: stdoutBuf.toString('utf8', 0, 200),
|
|
stderrTail: stderrBuf.slice(-2048),
|
|
})
|
|
}
|
|
})
|
|
proc.on('exit', (code) => {
|
|
finish({
|
|
kind: 'process-exited',
|
|
code,
|
|
stderrTail: stderrBuf.slice(-2048),
|
|
})
|
|
})
|
|
proc.on('error', () => {
|
|
finish({
|
|
kind: 'spawn-failed',
|
|
reason: 'spawn error event',
|
|
})
|
|
})
|
|
setTimeout(() => {
|
|
finish({
|
|
kind: 'initialize-no-response',
|
|
ms: Date.now() - start,
|
|
stderrTail: stderrBuf.slice(-2048),
|
|
})
|
|
}, 5000) // 5s — some uvx-from-git installs are slow first time
|
|
})
|
|
|
|
// Send initialize after a brief delay to let the process bind stdin.
|
|
setTimeout(() => {
|
|
try {
|
|
const payload = frameJsonRpc({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'cc-haha-smoke', version: '0.5.11' },
|
|
},
|
|
})
|
|
proc.stdin?.write(payload)
|
|
} catch {
|
|
// ignored — outcome will be no-response or exit
|
|
}
|
|
}, 100)
|
|
|
|
const outcome = await initializePromise
|
|
try {
|
|
proc.kill('SIGKILL')
|
|
} catch {
|
|
// ignored
|
|
}
|
|
return { prereqResults, outcome }
|
|
}
|
|
|
|
function describeOutcome(outcome: SpawnOutcome): string {
|
|
switch (outcome.kind) {
|
|
case 'skipped-missing-prereq':
|
|
return `❌ prereq missing: ${outcome.missing.join(', ')}`
|
|
case 'spawn-failed':
|
|
return `❌ spawn failed: ${outcome.reason}`
|
|
case 'initialize-ok':
|
|
return `✅ initialize ok (${outcome.ms} ms)`
|
|
case 'initialize-no-response':
|
|
return `⚠️ spawned but no JSON-RPC response in ${outcome.ms} ms`
|
|
case 'process-exited':
|
|
return `❌ process exited (code=${outcome.code ?? 'null'})`
|
|
case 'parse-failed':
|
|
return `❌ unrecognized output (no Content-Length frame)`
|
|
}
|
|
}
|
|
|
|
function describeStderr(outcome: SpawnOutcome): string | null {
|
|
if ('stderrTail' in outcome && outcome.stderrTail && outcome.stderrTail.trim()) {
|
|
return outcome.stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ')
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function main() {
|
|
const pluginIdx = args.indexOf('--plugin')
|
|
const pluginName = pluginIdx >= 0 ? args[pluginIdx + 1] : 'reverse-engineering'
|
|
const serversPath = path.join(ROOT, 'plugins', pluginName!, 'mcp', 'servers.json')
|
|
|
|
let raw: string
|
|
try {
|
|
raw = await readFile(serversPath, 'utf-8')
|
|
} catch (err) {
|
|
console.error(`Failed to read ${serversPath}: ${err instanceof Error ? err.message : err}`)
|
|
process.exit(1)
|
|
}
|
|
const file = JSON.parse(raw) as ServersFile
|
|
const entries = Object.entries(file.mcpServers ?? {})
|
|
console.log(`\n=== Reverse-engineering MCP smoke ===`)
|
|
console.log(`Source: ${path.relative(ROOT, serversPath)}`)
|
|
console.log(`Servers: ${entries.length}`)
|
|
console.log('')
|
|
|
|
const summaries: Array<{
|
|
name: string
|
|
prereqResults: ProbeResult[]
|
|
outcome: SpawnOutcome
|
|
}> = []
|
|
const platform = platformKey()
|
|
const installCommandsByMissingTool = new Map<string, InstallStep[]>()
|
|
|
|
for (const [name, def] of entries) {
|
|
process.stdout.write(` ${name.padEnd(10)} … `)
|
|
const { outcome, prereqResults } = await smokeServer(name, def)
|
|
summaries.push({ name, prereqResults, outcome })
|
|
|
|
// Collect install commands for missing prereqs (deduped by tool name).
|
|
if (outcome.kind === 'skipped-missing-prereq') {
|
|
for (const tool of outcome.missing) {
|
|
if (installCommandsByMissingTool.has(tool)) continue
|
|
const prereq = def.prerequisites?.find((p) => p.command === tool)
|
|
if (prereq?.install?.[platform]) {
|
|
installCommandsByMissingTool.set(tool, prereq.install[platform]!)
|
|
}
|
|
}
|
|
}
|
|
console.log(describeOutcome(outcome))
|
|
if (verbose) {
|
|
const stderr = describeStderr(outcome)
|
|
if (stderr) console.log(` stderr: ${stderr}`)
|
|
for (const pr of prereqResults) {
|
|
console.log(` prereq ${pr.command.padEnd(10)} ${pr.installed ? '✓' : '✗'} ${pr.resolved ?? ''}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Summary ───────────────────────────────────────────────────────────
|
|
const ok = summaries.filter((s) => s.outcome.kind === 'initialize-ok')
|
|
const noResp = summaries.filter((s) => s.outcome.kind === 'initialize-no-response')
|
|
const exited = summaries.filter((s) => s.outcome.kind === 'process-exited')
|
|
const skipped = summaries.filter((s) => s.outcome.kind === 'skipped-missing-prereq')
|
|
const spawnFail = summaries.filter((s) => s.outcome.kind === 'spawn-failed')
|
|
const parseFail = summaries.filter((s) => s.outcome.kind === 'parse-failed')
|
|
|
|
console.log('')
|
|
console.log('=== Summary ===')
|
|
console.log(` ✅ initialize ok : ${ok.length}/${entries.length} (${ok.map((s) => s.name).join(', ') || '—'})`)
|
|
console.log(` ⚠️ spawned, no response: ${noResp.length}/${entries.length} (${noResp.map((s) => s.name).join(', ') || '—'})`)
|
|
console.log(` ❌ process exited : ${exited.length}/${entries.length} (${exited.map((s) => s.name).join(', ') || '—'})`)
|
|
console.log(` ❌ prereq missing : ${skipped.length}/${entries.length} (${skipped.map((s) => s.name).join(', ') || '—'})`)
|
|
console.log(` ❌ spawn failed : ${spawnFail.length}/${entries.length} (${spawnFail.map((s) => s.name).join(', ') || '—'})`)
|
|
console.log(` ❌ parse failed : ${parseFail.length}/${entries.length} (${parseFail.map((s) => s.name).join(', ') || '—'})`)
|
|
|
|
// ── Action items: install commands for missing prereqs ────────────────
|
|
if (installCommandsByMissingTool.size > 0) {
|
|
console.log('')
|
|
console.log(`=== Install commands for missing prereqs (${platform}) ===`)
|
|
for (const [tool, steps] of installCommandsByMissingTool) {
|
|
console.log(` ${tool}:`)
|
|
for (const step of steps) {
|
|
console.log(` ${step.manager.padEnd(10)} ${step.cmd}`)
|
|
}
|
|
}
|
|
console.log('')
|
|
console.log('Pick one manager per tool. After installing, rerun this script.')
|
|
}
|
|
|
|
// ── Schema-gap warnings ───────────────────────────────────────────────
|
|
// Heuristic: an MCP server whose name implies a tool (jadx, apktool,
|
|
// gdb, radare2, lldb) should have that tool listed in its prerequisites
|
|
// — otherwise the desktop "one-click install" never knows to install
|
|
// it, and the server crashes at first run.
|
|
//
|
|
// Note: `ghidra` is intentionally excluded — Ghidra is a GUI binary
|
|
// configured via the `GHIDRA_INSTALL_DIR` env var, not a PATH command,
|
|
// so probing for it via `where ghidra` would always fail.
|
|
//
|
|
// Note: `frida` is intentionally excluded — the `frida-mcp` PyPI
|
|
// package bundles its own Python `frida` client as a transitive
|
|
// dependency, so the server starts fine without the standalone
|
|
// `frida` CLI on PATH. Connecting to a target device may still need
|
|
// a frida-server installed on the target itself, which is out of
|
|
// scope for prereq probing.
|
|
const expectedToolByServer: Record<string, string> = {
|
|
jadx: 'jadx',
|
|
apktool: 'apktool',
|
|
lldb: 'lldb',
|
|
gdb: 'gdb',
|
|
radare2: 'radare2',
|
|
}
|
|
const schemaGaps: string[] = []
|
|
for (const [name, def] of entries) {
|
|
const expected = expectedToolByServer[name]
|
|
if (!expected) continue
|
|
const declared = (def.prerequisites ?? []).some((p) => p.command === expected)
|
|
if (!declared) {
|
|
schemaGaps.push(` • ${name}: prerequisites missing '${expected}' — desktop one-click install can't surface this missing tool`)
|
|
}
|
|
}
|
|
if (schemaGaps.length > 0) {
|
|
console.log('')
|
|
console.log('=== Schema gaps (servers.json fix candidates) ===')
|
|
for (const g of schemaGaps) console.log(g)
|
|
}
|
|
|
|
// ── Exit code ─────────────────────────────────────────────────────────
|
|
// Exit 0 when at least one server initialized — the script is informational,
|
|
// not a CI gate. CI consumers can grep stdout if they want a strict mode.
|
|
process.exit(0)
|
|
}
|
|
|
|
void main()
|