From 6f207d7599afaa2548b7982efd852f1ab96d4964 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 16:50:29 +0300 Subject: [PATCH 01/13] feat(mcp): add `mcp doctor` command to diagnose MCP setup Users installing armis-cli's MCP servers had no way to debug a broken setup beyond re-running install blindly. `mcp doctor` inspects plugin files, credentials, editor registrations, Claude Code's plugin registry, and Codex's config.toml, and optionally performs a live initialize handshake against each server. `install` now points users at it when credentials are configured or warnings occur. --- internal/cmd/install.go | 10 +- internal/cmd/mcp.go | 18 ++ internal/cmd/mcp_doctor.go | 108 +++++++ internal/install/doctor.go | 537 ++++++++++++++++++++++++++++++++ internal/install/doctor_test.go | 361 +++++++++++++++++++++ 5 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/mcp.go create mode 100644 internal/cmd/mcp_doctor.go create mode 100644 internal/install/doctor.go create mode 100644 internal/install/doctor_test.go diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 50f1533..1189a39 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -59,7 +59,10 @@ Not auto-configurable (manual setup required): armis-cli install claude # Check installed version - armis-cli install --version`, + armis-cli install --version + + # Debug a broken or missing MCP setup + armis-cli mcp doctor`, RunE: runInstall, } @@ -396,6 +399,7 @@ func installTargets(targets []string, force, withKnowledge bool) error { if ci.HasExistingEnv() { fmt.Fprintln(os.Stderr, "Credentials configured. Restart Claude Code to pick up the updated plugin.") + fmt.Fprintln(os.Stderr, "Run 'armis-cli mcp doctor' to verify the setup works.") } else { fmt.Fprintln(os.Stderr, "Next steps:") fmt.Fprintf(os.Stderr, " 1. Set your credentials in %s:\n", ci.EnvFilePath()) @@ -426,6 +430,7 @@ func installTargets(targets []string, force, withKnowledge bool) error { func printCredentialStatus(ei *install.EditorInstaller) { if ei.HasExistingEnv() { fmt.Fprintln(os.Stderr, "Credentials configured. Restart your editors to use the MCP server.") + fmt.Fprintln(os.Stderr, "Run 'armis-cli mcp doctor' to verify the setup works.") } else { fmt.Fprintln(os.Stderr, "Next steps:") fmt.Fprintf(os.Stderr, " 1. Set your credentials in %s:\n", ei.EnvFilePath()) @@ -459,4 +464,7 @@ func printKnowledgeResult(res knowledgeResult) { } else if !res.skipped { fmt.Fprintln(os.Stderr, " ⚠ Knowledge was not registered in any editor.") } + if len(res.warnings) > 0 { + fmt.Fprintln(os.Stderr, "Run 'armis-cli mcp doctor' to see what's wrong.") + } } diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go new file mode 100644 index 0000000..64cbadf --- /dev/null +++ b/internal/cmd/mcp.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Inspect and debug MCP server setup", + Long: `Inspect and debug the MCP servers armis-cli install registered. + +Use 'armis-cli mcp doctor' to check the scanner and knowledge MCP servers: +plugin files, credentials, editor registrations, and a live handshake.`, +} + +func init() { + rootCmd.AddCommand(mcpCmd) +} diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go new file mode 100644 index 0000000..91810aa --- /dev/null +++ b/internal/cmd/mcp_doctor.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/ArmisSecurity/armis-cli/internal/install" + "github.com/spf13/cobra" +) + +var ( + mcpDoctorFormat string + mcpDoctorNoHandshake bool + mcpDoctorTimeout time.Duration +) + +var mcpDoctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose the installed MCP servers and their editor registrations", + Long: `Diagnose everything 'armis-cli install' may have set up: the scanner and +knowledge MCP servers' plugin files and credentials, whether each registered +editor's config still contains the entry, Claude Code's plugin registry, and +Codex CLI's config.toml — then, unless --no-handshake is set, spawns each +server and performs a live MCP "initialize" handshake to confirm it actually +starts and responds. + +Exits non-zero if any check fails.`, + Example: ` # Full diagnostic, including live handshake + armis-cli mcp doctor + + # Structural checks only, skip spawning the servers + armis-cli mcp doctor --no-handshake + + # Machine-readable output + armis-cli mcp doctor --format json`, + Args: cobra.NoArgs, + RunE: runMCPDoctor, +} + +func init() { + mcpCmd.AddCommand(mcpDoctorCmd) + mcpDoctorCmd.Flags().StringVarP(&mcpDoctorFormat, "format", "f", agentFormatPlain, "Output format: plain, json") + mcpDoctorCmd.Flags().BoolVar(&mcpDoctorNoHandshake, "no-handshake", false, "Skip spawning MCP servers for a live handshake check") + mcpDoctorCmd.Flags().DurationVar(&mcpDoctorTimeout, "timeout", install.DefaultHandshakeTimeout, "Timeout for the live handshake check") +} + +func runMCPDoctor(cmd *cobra.Command, _ []string) error { + switch mcpDoctorFormat { + case agentFormatPlain, agentFormatJSON: + default: + return fmt.Errorf("invalid --format value %q: must be plain or json", mcpDoctorFormat) + } + + report := install.RunDoctor(install.DoctorOptions{ + Handshake: !mcpDoctorNoHandshake, + Timeout: mcpDoctorTimeout, + }) + + switch mcpDoctorFormat { + case agentFormatJSON: + if err := printMCPDoctorJSON(cmd, report); err != nil { + return err + } + default: + printMCPDoctorPlain(cmd, report) + } + + if report.HasFailures() { + return fmt.Errorf("mcp doctor found failing checks — see output above") + } + return nil +} + +func printMCPDoctorJSON(cmd *cobra.Command, report *install.DoctorReport) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +func printMCPDoctorPlain(cmd *cobra.Command, report *install.DoctorReport) { + out := cmd.OutOrStdout() + + if len(report.Checks) == 0 { + fmt.Fprintln(out, "No checks produced any output.") + return + } + + var lastComponent string + for _, c := range report.Checks { + if c.Component != lastComponent { + fmt.Fprintf(out, "%s:\n", c.Component) + lastComponent = c.Component + } + fmt.Fprintf(out, " %s %-20s %s\n", statusSymbol(c.Status), c.Name, c.Detail) + } +} + +func statusSymbol(s install.CheckStatus) string { + switch s { + case install.StatusOK: + return "✓" + case install.StatusWarn: + return "⚠" + default: + return "✗" + } +} diff --git a/internal/install/doctor.go b/internal/install/doctor.go new file mode 100644 index 0000000..d447573 --- /dev/null +++ b/internal/install/doctor.go @@ -0,0 +1,537 @@ +package install + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// knowledgeJSONIdentifier and knowledgeCodexIdentifier are the substrings used +// to spot a knowledge-bridge entry in an editor config, mirroring how +// mcpServerName/codexMCPServerName identify the scanner. The knowledge server +// name carries an env suffix (e.g. "armis-knowledge-dev"), so exact-key checks +// don't work here — a substring match does, the same way agentdetect matches +// the scanner's identifier. +const ( + knowledgeJSONIdentifier = "armis-knowledge" + knowledgeCodexIdentifier = "armis_knowledge" +) + +// DefaultHandshakeTimeout bounds how long RunDoctor waits for an MCP server to +// answer the initialize handshake before reporting it as unresponsive. +const DefaultHandshakeTimeout = 10 * time.Second + +// CheckStatus is the outcome of a single doctor check. +type CheckStatus string + +const ( + StatusOK CheckStatus = "ok" + StatusWarn CheckStatus = "warn" + StatusFail CheckStatus = "fail" +) + +// DoctorCheck is one diagnostic result reported by RunDoctor. +type DoctorCheck struct { + Component string `json:"component"` + Name string `json:"name"` + Status CheckStatus `json:"status"` + Detail string `json:"detail"` +} + +// DoctorReport is the full set of diagnostic results from RunDoctor. +type DoctorReport struct { + Checks []DoctorCheck `json:"checks"` +} + +func (r *DoctorReport) add(component, name string, status CheckStatus, detail string) { + r.Checks = append(r.Checks, DoctorCheck{Component: component, Name: name, Status: status, Detail: detail}) +} + +// HasFailures reports whether any check in the report failed. +func (r *DoctorReport) HasFailures() bool { + for _, c := range r.Checks { + if c.Status == StatusFail { + return true + } + } + return false +} + +// DoctorOptions configures RunDoctor. +type DoctorOptions struct { + // Handshake, when true, spawns each registered MCP server and performs a + // live JSON-RPC initialize handshake over stdio. + Handshake bool + // Timeout bounds how long the handshake waits for a response. Defaults to + // DefaultHandshakeTimeout when zero. + Timeout time.Duration +} + +// RunDoctor inspects everything armis-cli install may have registered — the +// shared scanner plugin, the knowledge bridge, and every editor config +// recorded in the install manifest — and, when requested, spawns each MCP +// server to confirm it actually answers a protocol handshake. +func RunDoctor(opts DoctorOptions) *DoctorReport { + report := &DoctorReport{} + + ei := NewEditorInstaller() + manifest := ReadManifest(ei.PluginDir()) + if manifest == nil { + report.add("install", "manifest", StatusFail, + fmt.Sprintf("no install manifest found at %s — run: armis-cli install", ei.PluginDir())) + return report + } + + checkScannerPlugin(report, ei, opts) + checkManifestEditors(report, "scanner", mcpServerName, manifest.Editors) + checkClaudeSection(report, "scanner", manifest.Claude, pluginName) + checkCodexSection(report, "scanner", manifest.Codex, codexMCPServerName) + + if manifest.Knowledge != nil { + checkKnowledgePlugin(report, manifest.Knowledge, opts) + checkManifestEditors(report, "knowledge", knowledgeJSONIdentifier, manifest.Knowledge.Editors) + checkClaudeSection(report, "knowledge", manifest.Knowledge.Claude, knowledgeJSONIdentifier) + checkCodexSection(report, "knowledge", manifest.Knowledge.Codex, knowledgeCodexIdentifier) + } + + return report +} + +func checkScannerPlugin(report *DoctorReport, ei *EditorInstaller, opts DoctorOptions) { + const component = "scanner" + + if v := ei.GetInstalledVersion(); v == "" { + report.add(component, "plugin version", StatusWarn, "no installed version recorded") + } else { + report.add(component, "plugin version", StatusOK, "v"+v) + } + + pythonPath := venvPython(ei.PluginDir()) + if !isExecutableFile(pythonPath) { + report.add(component, "python venv", StatusFail, fmt.Sprintf("missing or not executable: %s", pythonPath)) + return + } + report.add(component, "python venv", StatusOK, pythonPath) + + serverPy := filepath.Join(ei.PluginDir(), "server.py") + if _, err := os.Stat(serverPy); err != nil { + report.add(component, "server script", StatusFail, fmt.Sprintf("missing: %s", serverPy)) + return + } + report.add(component, "server script", StatusOK, serverPy) + + env := checkCredentials(report, component, ei.EnvFilePath()) + + if opts.Handshake { + runHandshakeCheck(report, component, pythonPath, []string{serverPy}, env, opts.Timeout) + } +} + +func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts DoctorOptions) { + const component = "knowledge" + + if k.SHA != "" { + report.add(component, "bridge commit", StatusOK, k.SHA) + } + + found := false + for _, sub := range []string{"prod", "stage", "dev"} { + envDir := filepath.Join(k.PluginDir, sub) + bridge := filepath.Join(envDir, "bridge.py") + if _, err := os.Stat(bridge); err != nil { + continue + } + found = true + + pythonPath := venvPython(envDir) + if !isExecutableFile(pythonPath) { + report.add(component, sub+" python venv", StatusFail, fmt.Sprintf("missing or not executable: %s", pythonPath)) + continue + } + report.add(component, sub+" python venv", StatusOK, pythonPath) + + env := checkCredentials(report, component+" "+sub, filepath.Join(envDir, ".env")) + + if opts.Handshake { + runHandshakeCheck(report, component+" "+sub, pythonPath, []string{bridge}, env, opts.Timeout) + } + } + if !found { + report.add(component, "bridge", StatusFail, fmt.Sprintf("no bridge.py found under %s", k.PluginDir)) + } +} + +// checkCredentials validates envFile carries both required credentials and +// returns its contents for reuse by a following live handshake. +func checkCredentials(report *DoctorReport, component, envFile string) map[string]string { + env, err := parseEnvFile(envFile) + if err != nil || env["ARMIS_CLIENT_ID"] == "" || env["ARMIS_CLIENT_SECRET"] == "" { + report.add(component, "credentials", StatusWarn, + fmt.Sprintf("ARMIS_CLIENT_ID/ARMIS_CLIENT_SECRET not set in %s", envFile)) + return env + } + report.add(component, "credentials", StatusOK, "configured") + return env +} + +// checkManifestEditors verifies, for every editor the manifest recorded a +// registration for, that the config file still exists, still contains an +// entry matching identifier, and that the entry's command still exists on +// disk. That last check matters most on Windows, where a profile rename, a +// drive-letter change, or a reinstall into a new plugin dir leaves editors +// pointing at a command path that no longer resolves — the entry is still +// present by name, so a name-only check would report this as healthy. +func checkManifestEditors(report *DoctorReport, component, identifier string, editors map[EditorID]ManifestEntry) { + for id, entry := range editors { + name := string(id) + if ed, ok := EditorByID(id); ok { + name = ed.Name + } + + if _, err := os.Stat(entry.ConfigFile); err != nil { + report.add(component, name, StatusFail, fmt.Sprintf("config file missing: %s", entry.ConfigFile)) + continue + } + + command, found := lookupEntryCommand(entry.ConfigFile, entry.Format, identifier) + if !found { + report.add(component, name, StatusWarn, + fmt.Sprintf("registered at %s but entry not found — was it edited or removed?", entry.ConfigFile)) + continue + } + if command != "" && !isExecutableFile(command) { + report.add(component, name, StatusFail, + fmt.Sprintf("entry found in %s but its command does not exist: %s — likely stale after a reinstall or profile/home directory change; re-run armis-cli install", entry.ConfigFile, command)) + continue + } + report.add(component, name, StatusOK, entry.ConfigFile) + } +} + +func checkClaudeSection(report *DoctorReport, component string, claude *ManifestClaude, pluginKeyPrefix string) { + if claude == nil { + return + } + if _, err := os.Stat(claude.CacheDir); err != nil { + report.add(component, "Claude Code", StatusFail, fmt.Sprintf("cache dir missing: %s", claude.CacheDir)) + return + } + + installed, enabled := claudeRegistryStatus(homeDir(".claude"), pluginKeyPrefix) + switch { + case !installed: + report.add(component, "Claude Code", StatusWarn, "not found in installed_plugins.json — re-run install") + case !enabled: + report.add(component, "Claude Code", StatusWarn, "installed but not enabled in settings.json") + default: + report.add(component, "Claude Code", StatusOK, claude.CacheDir) + } +} + +// claudeRegistryStatus reports whether any plugin key containing +// pluginKeyPrefix is recorded as installed and/or enabled in Claude Code's +// own registry files. +func claudeRegistryStatus(claudeDir, pluginKeyPrefix string) (installed, enabled bool) { + prefix := strings.ToLower(pluginKeyPrefix) + + instFile := filepath.Join(claudeDir, "plugins", "installed_plugins.json") + // armis:ignore cwe:770 reason:reads bounded JSON config file from user's ~/.claude dir; not unbounded input + if b, err := os.ReadFile(filepath.Clean(instFile)); err == nil { + var data struct { + Plugins map[string]json.RawMessage `json:"plugins"` + } + if json.Unmarshal(b, &data) == nil { + for k := range data.Plugins { + if strings.Contains(strings.ToLower(k), prefix) { + installed = true + break + } + } + } + } + + settingsFile := filepath.Join(claudeDir, "settings.json") + // armis:ignore cwe:770 reason:reads bounded JSON config file from user's ~/.claude dir; not unbounded input + if b, err := os.ReadFile(filepath.Clean(settingsFile)); err == nil { + var data struct { + EnabledPlugins map[string]bool `json:"enabledPlugins"` + } + if json.Unmarshal(b, &data) == nil { + for k, v := range data.EnabledPlugins { + if v && strings.Contains(strings.ToLower(k), prefix) { + enabled = true + break + } + } + } + } + return installed, enabled +} + +func checkCodexSection(report *DoctorReport, component string, codex *ManifestCodex, identifier string) { + if codex == nil { + return + } + // armis:ignore cwe:770 reason:reads bounded local config file (~/.codex/config.toml) + content, err := os.ReadFile(filepath.Clean(codex.ConfigFile)) + if err != nil { + report.add(component, "Codex CLI", StatusFail, fmt.Sprintf("config file missing: %s", codex.ConfigFile)) + return + } + if !strings.Contains(strings.ToLower(string(content)), strings.ToLower(identifier)) { + report.add(component, "Codex CLI", StatusWarn, + fmt.Sprintf("registered at %s but entry not found — was it edited or removed?", codex.ConfigFile)) + return + } + report.add(component, "Codex CLI", StatusOK, codex.ConfigFile) +} + +// lookupEntryCommand finds the server entry matching identifier in configFile +// (read per the manifest's recorded format) and returns the command path it +// declares. found is true as soon as a matching entry name exists, even when +// command comes back empty because the format stores it somewhere this +// function doesn't understand — callers must treat an empty command as +// "unknown", not "missing". +func lookupEntryCommand(configFile, format, identifier string) (command string, found bool) { + identifier = strings.ToLower(identifier) + + matchEntry := func(servers map[string]interface{}) (map[string]interface{}, bool) { + for k, v := range servers { + if strings.Contains(strings.ToLower(k), identifier) { + m, _ := v.(map[string]interface{}) + return m, true + } + } + return nil, false + } + + switch format { + case "vscode-servers": + servers, _ := readJSONFileAsMap(configFile)["servers"].(map[string]interface{}) + entry, ok := matchEntry(servers) + if !ok { + return "", false + } + cmd, _ := entry[jsonKeyCommand].(string) + return cmd, true + case "zed-context_servers": + servers, _ := readJSONFileAsMap(configFile)["context_servers"].(map[string]interface{}) + entry, ok := matchEntry(servers) + if !ok { + return "", false + } + cmdObj, _ := entry[jsonKeyCommand].(map[string]interface{}) + cmd, _ := cmdObj[jsonKeyPath].(string) + return cmd, true + case "continue-yaml": + list, _ := readYAMLFileAsMap(configFile)["mcpServers"].([]interface{}) + for _, item := range list { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + if n, _ := m["name"].(string); strings.Contains(strings.ToLower(n), identifier) { + cmd, _ := m[jsonKeyCommand].(string) + return cmd, true + } + } + return "", false + default: // "mcpServers" + servers, _ := readJSONFileAsMap(configFile)["mcpServers"].(map[string]interface{}) + entry, ok := matchEntry(servers) + if !ok { + return "", false + } + cmd, _ := entry[jsonKeyCommand].(string) + return cmd, true + } +} + +func isExecutableFile(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == osWindows { + return true + } + return info.Mode()&0o111 != 0 +} + +// parseEnvFile reads a "KEY=VALUE" per line .env file, as written by +// writeEnvFromEnvironment/WriteEnvFromValues. +func parseEnvFile(path string) (map[string]string, error) { + // armis:ignore cwe:770 reason:reads a local plugin .env file the CLI itself wrote, always small + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, err + } + env := make(map[string]string) + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + env[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return env, nil +} + +// handshakeResult carries the identity the MCP server reported in its +// initialize response. +type handshakeResult struct { + ServerName string + ServerVersion string +} + +type mcpInitResult struct { + ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"serverInfo"` +} + +func runHandshakeCheck(report *DoctorReport, component, command string, args []string, env map[string]string, timeout time.Duration) { + res, stderrTail, err := mcpHandshake(command, args, env, timeout) + if err != nil { + detail := err.Error() + if stderrTail != "" { + detail += " — stderr: " + stderrTail + } + report.add(component, "live handshake", StatusFail, detail) + return + } + detail := "responded to initialize" + if res.ServerName != "" { + detail = fmt.Sprintf("%s v%s responded", res.ServerName, res.ServerVersion) + } + report.add(component, "live handshake", StatusOK, detail) +} + +// mcpHandshake spawns command as an MCP stdio server, sends a single +// "initialize" JSON-RPC request, and waits up to timeout for a response line. +// The process is always killed and waited-on before returning, so stderr can +// be read back safely (os/exec only finishes copying stderr into the buffer +// once Wait returns). +func mcpHandshake(command string, args []string, env map[string]string, timeout time.Duration) (*handshakeResult, string, error) { + if timeout <= 0 { + timeout = DefaultHandshakeTimeout + } + + // armis:ignore cwe:78 cwe:88 reason:command/args come from the CLI's own recorded install paths (venv interpreter + server script), not user input + cmd := exec.Command(command, args...) //nolint:gosec // command/args are the CLI's own recorded install paths + cmd.Env = os.Environ() + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, "", fmt.Errorf("opening stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, "", fmt.Errorf("opening stdout: %w", err) + } + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return nil, "", fmt.Errorf("starting process: %w", err) + } + + result, opErr := communicateInitialize(stdin, stdout, timeout) + + // armis:ignore cwe:404 reason:best-effort cleanup of a short-lived diagnostic subprocess we just spawned + _ = cmd.Process.Kill() + _ = cmd.Wait() + + if opErr != nil { + return nil, stderrTail(&stderrBuf), opErr + } + return result, "", nil +} + +func communicateInitialize(stdin io.WriteCloser, stdout io.ReadCloser, timeout time.Duration) (*handshakeResult, error) { + req := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]interface{}{"name": "armis-cli-doctor", "version": "1.0"}, + }, + } + line, err := json.Marshal(req) + if err != nil { + return nil, err + } + + type readOutcome struct { + line []byte + err error + } + lineCh := make(chan readOutcome, 1) + go func() { + reader := bufio.NewReader(stdout) + l, rerr := reader.ReadBytes('\n') + lineCh <- readOutcome{l, rerr} + }() + + if _, err := stdin.Write(append(line, '\n')); err != nil { + return nil, fmt.Errorf("writing initialize request: %w", err) + } + + select { + case <-time.After(timeout): + return nil, fmt.Errorf("timed out waiting for response after %s", timeout) + case out := <-lineCh: + if len(out.line) == 0 { + if out.err != nil { + return nil, fmt.Errorf("no response: %w", out.err) + } + return nil, fmt.Errorf("no response") + } + var rpc struct { + Result *mcpInitResult `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(out.line, &rpc); err != nil { + return nil, fmt.Errorf("invalid response: %w", err) + } + if rpc.Error != nil { + return nil, fmt.Errorf("server returned error: %s", rpc.Error.Message) + } + if rpc.Result == nil { + return nil, fmt.Errorf("response missing result") + } + return &handshakeResult{ + ServerName: rpc.Result.ServerInfo.Name, + ServerVersion: rpc.Result.ServerInfo.Version, + }, nil + } +} + +func stderrTail(buf *bytes.Buffer) string { + s := strings.TrimSpace(buf.String()) + const maxLen = 300 + if len(s) > maxLen { + s = s[len(s)-maxLen:] + } + return s +} diff --git a/internal/install/doctor_test.go b/internal/install/doctor_test.go new file mode 100644 index 0000000..be47ec5 --- /dev/null +++ b/internal/install/doctor_test.go @@ -0,0 +1,361 @@ +package install + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// TestMain intercepts re-exec calls used by the mcpHandshake tests: when +// ARMIS_TEST_MCP_HELPER is set, this process acts as a fake MCP stdio server +// instead of running the test suite. +func TestMain(m *testing.M) { + if mode := os.Getenv("ARMIS_TEST_MCP_HELPER"); mode != "" { + runMCPHelperProcess(mode) + return + } + os.Exit(m.Run()) +} + +func runMCPHelperProcess(mode string) { + switch mode { + case "hang": + select {} + case "garbage": + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + fmt.Fprintln(os.Stdout, "not json") + case "error": + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"boom"}}`) + default: // "ok" + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"fake-mcp","version":"9.9.9"}}}`) + } +} + +func TestMCPHandshakeSuccess(t *testing.T) { + res, tail, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "ok"}, 5*time.Second) + if err != nil { + t.Fatalf("mcpHandshake() error = %v (stderr: %s)", err, tail) + } + if res.ServerName != "fake-mcp" || res.ServerVersion != "9.9.9" { + t.Errorf("mcpHandshake() result = %+v, want fake-mcp v9.9.9", res) + } +} + +func TestMCPHandshakeServerError(t *testing.T) { + _, _, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "error"}, 5*time.Second) + if err == nil { + t.Fatal("mcpHandshake() error = nil, want error from server's JSON-RPC error response") + } +} + +func TestMCPHandshakeInvalidResponse(t *testing.T) { + _, _, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "garbage"}, 5*time.Second) + if err == nil { + t.Fatal("mcpHandshake() error = nil, want error from invalid JSON response") + } +} + +func TestMCPHandshakeTimeout(t *testing.T) { + _, _, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "hang"}, 300*time.Millisecond) + if err == nil { + t.Fatal("mcpHandshake() error = nil, want timeout error") + } +} + +func TestParseEnvFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + content := "ARMIS_CLIENT_ID=abc123\nARMIS_CLIENT_SECRET=s3cr3t\n# comment\n\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + env, err := parseEnvFile(path) + if err != nil { + t.Fatalf("parseEnvFile() error = %v", err) + } + if env["ARMIS_CLIENT_ID"] != "abc123" || env["ARMIS_CLIENT_SECRET"] != "s3cr3t" { + t.Errorf("parseEnvFile() = %+v, want ARMIS_CLIENT_ID/ARMIS_CLIENT_SECRET set", env) + } +} + +func TestParseEnvFileMissing(t *testing.T) { + if _, err := parseEnvFile(filepath.Join(t.TempDir(), "missing.env")); err == nil { + t.Fatal("parseEnvFile() error = nil, want error for missing file") + } +} + +func TestLookupEntryCommand(t *testing.T) { + dir := t.TempDir() + + tests := []struct { + name string + format string + content string + fileName string + identifier string + wantFound bool + wantCommand string + }{ + { + name: "mcpServers format match", + format: "mcpServers", + content: `{"mcpServers":{"armis-appsec":{"command":"/bin/python"}}}`, + fileName: "mcp.json", + identifier: "armis-appsec", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "mcpServers format no match", + format: "mcpServers", + content: `{"mcpServers":{"other":{"command":"/bin/python"}}}`, + fileName: "mcp2.json", + identifier: "armis-appsec", + wantFound: false, + }, + { + name: "vscode-servers format match", + format: "vscode-servers", + content: `{"servers":{"armis-appsec":{"type":"stdio","command":"/bin/python"}}}`, + fileName: "vscode.json", + identifier: "armis-appsec", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "zed-context_servers format match", + format: "zed-context_servers", + content: `{"context_servers":{"armis-appsec":{"command":{"path":"/bin/python","args":[]}}}}`, + fileName: "zed.json", + identifier: "armis-appsec", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "continue-yaml format match", + format: "continue-yaml", + content: "mcpServers:\n - name: armis-knowledge\n command: /bin/python\n", + fileName: "continue.yaml", + identifier: "armis-knowledge", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "continue-yaml format no match", + format: "continue-yaml", + content: "mcpServers:\n - name: other\n command: /bin/python\n", + fileName: "continue2.yaml", + identifier: "armis-knowledge", + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(dir, tt.fileName) + if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { + t.Fatal(err) + } + gotCommand, gotFound := lookupEntryCommand(path, tt.format, tt.identifier) + if gotFound != tt.wantFound { + t.Errorf("lookupEntryCommand() found = %v, want %v", gotFound, tt.wantFound) + } + if gotCommand != tt.wantCommand { + t.Errorf("lookupEntryCommand() command = %q, want %q", gotCommand, tt.wantCommand) + } + }) + } +} + +func TestCheckManifestEditors(t *testing.T) { + dir := t.TempDir() + + realCommand := filepath.Join(dir, "python") + _ = os.WriteFile(realCommand, []byte("x"), 0o700) + + presentFile := filepath.Join(dir, "present.json") + mustWriteJSON(t, presentFile, map[string]interface{}{ + "mcpServers": map[string]interface{}{"armis-appsec": map[string]interface{}{"command": realCommand}}, + }) + + staleFile := filepath.Join(dir, "stale.json") + _ = os.WriteFile(staleFile, []byte(`{"mcpServers":{}}`), 0o600) + + missingFile := filepath.Join(dir, "does-not-exist.json") + + // Simulates the exact bug found by exercising the tool: the entry is + // still present by name (e.g. VS Code's mcp.json survived), but the + // command path it points to no longer exists — the kind of drift a + // Windows profile rename or a reinstall into a new plugin dir causes. + deadCommandFile := filepath.Join(dir, "dead-command.json") + mustWriteJSON(t, deadCommandFile, map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "armis-appsec": map[string]interface{}{ + "command": filepath.Join(dir, "no-longer-exists", "python"), + }, + }, + }) + + editors := map[EditorID]ManifestEntry{ + EditorCursor: {ConfigFile: presentFile, Format: "mcpServers"}, + EditorWindsurf: {ConfigFile: staleFile, Format: "mcpServers"}, + EditorZed: {ConfigFile: missingFile, Format: "mcpServers"}, + EditorVSCode: {ConfigFile: deadCommandFile, Format: "mcpServers"}, + } + + report := &DoctorReport{} + checkManifestEditors(report, "scanner", "armis-appsec", editors) + + statuses := make(map[string]CheckStatus) + for _, c := range report.Checks { + statuses[c.Name] = c.Status + } + + if statuses["Cursor"] != StatusOK { + t.Errorf("Cursor status = %v, want ok", statuses["Cursor"]) + } + if statuses["Windsurf"] != StatusWarn { + t.Errorf("Windsurf status = %v, want warn (entry removed)", statuses["Windsurf"]) + } + if statuses["Zed"] != StatusFail { + t.Errorf("Zed status = %v, want fail (config missing)", statuses["Zed"]) + } + if statuses["VS Code"] != StatusFail { + t.Errorf("VS Code status = %v, want fail (command path dead)", statuses["VS Code"]) + } +} + +func TestClaudeRegistryStatus(t *testing.T) { + dir := t.TempDir() + pluginsDir := filepath.Join(dir, "plugins") + _ = os.MkdirAll(pluginsDir, 0o750) + + mustWriteJSON(t, filepath.Join(pluginsDir, "installed_plugins.json"), map[string]interface{}{ + "plugins": map[string]interface{}{ + "armis-appsec@armis-appsec-mcp": []interface{}{}, + }, + }) + mustWriteJSON(t, filepath.Join(dir, "settings.json"), map[string]interface{}{ + "enabledPlugins": map[string]interface{}{ + "armis-appsec@armis-appsec-mcp": true, + }, + }) + + installed, enabled := claudeRegistryStatus(dir, "armis-appsec") + if !installed || !enabled { + t.Errorf("claudeRegistryStatus() = (%v, %v), want (true, true)", installed, enabled) + } + + installed, enabled = claudeRegistryStatus(dir, "armis-knowledge") + if installed || enabled { + t.Errorf("claudeRegistryStatus() for unrelated prefix = (%v, %v), want (false, false)", installed, enabled) + } +} + +func TestCheckCodexSection(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.toml") + _ = os.WriteFile(configFile, []byte("[mcp_servers.armis_scanner]\ncommand = \"/bin/python\"\n"), 0o600) + + report := &DoctorReport{} + checkCodexSection(report, "scanner", &ManifestCodex{ConfigFile: configFile}, "armis_scanner") + if len(report.Checks) != 1 || report.Checks[0].Status != StatusOK { + t.Errorf("checkCodexSection() = %+v, want single ok check", report.Checks) + } + + report2 := &DoctorReport{} + checkCodexSection(report2, "scanner", &ManifestCodex{ConfigFile: configFile}, "armis_knowledge") + if len(report2.Checks) != 1 || report2.Checks[0].Status != StatusWarn { + t.Errorf("checkCodexSection() with missing identifier = %+v, want single warn check", report2.Checks) + } +} + +func TestIsExecutableFile(t *testing.T) { + dir := t.TempDir() + if isExecutableFile(filepath.Join(dir, "missing")) { + t.Error("isExecutableFile() = true for a missing file") + } + + regular := filepath.Join(dir, "not-executable") + _ = os.WriteFile(regular, []byte("x"), 0o600) + if runtime.GOOS != osWindows && isExecutableFile(regular) { + t.Error("isExecutableFile() = true for a non-executable file") + } + + executable := filepath.Join(dir, "executable") + _ = os.WriteFile(executable, []byte("x"), 0o700) + if !isExecutableFile(executable) { + t.Error("isExecutableFile() = false for an executable file") + } +} + +func TestRunDoctorNoManifest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + report := RunDoctor(DoctorOptions{Handshake: false}) + if !report.HasFailures() { + t.Fatal("RunDoctor() with no manifest should report a failure") + } + if len(report.Checks) != 1 || report.Checks[0].Component != "install" { + t.Errorf("RunDoctor() checks = %+v, want a single install/manifest failure", report.Checks) + } +} + +func TestRunDoctorStructuralChecks(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + pluginDir := filepath.Join(home, ".armis", "plugins", "armis-appsec-mcp") + writeFakeVenv(t, pluginDir) + _ = os.WriteFile(filepath.Join(pluginDir, "server.py"), []byte("# server"), 0o600) + _ = os.WriteFile(filepath.Join(pluginDir, ".env"), + []byte("ARMIS_CLIENT_ID=id\nARMIS_CLIENT_SECRET=secret\n"), 0o600) + + editorConfig := filepath.Join(home, "editor-mcp.json") + _ = os.WriteFile(editorConfig, []byte(`{"mcpServers":{"armis-appsec":{}}}`), 0o600) + + manifest := NewManifest(pluginDir, "1.2.3") + manifest.AddEditor(EditorCursor, editorConfig, "mcpServers") + if err := WriteManifest(manifest); err != nil { + t.Fatalf("WriteManifest() error = %v", err) + } + + report := RunDoctor(DoctorOptions{Handshake: false}) + if report.HasFailures() { + t.Fatalf("RunDoctor() unexpected failures: %+v", report.Checks) + } + + found := false + for _, c := range report.Checks { + if c.Component == "scanner" && c.Name == "Cursor" && c.Status == StatusOK { + found = true + } + } + if !found { + t.Errorf("RunDoctor() checks = %+v, want ok check for scanner/Cursor", report.Checks) + } +} + +// writeFakeVenv creates a fake venv python executable so structural checks +// (which only stat for existence + executable bit) pass without a real +// Python install. +func writeFakeVenv(t *testing.T, pluginDir string) { + t.Helper() + python := venvPython(pluginDir) + if err := os.MkdirAll(filepath.Dir(python), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(python, []byte("#!/bin/sh\n"), 0o700); err != nil { + t.Fatal(err) + } +} From ba2f14353eb32fc60f9ab039c30b1237b2818b5e Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 16:58:29 +0300 Subject: [PATCH 02/13] feat(mcp): add `mcp update` command to update installed servers Reads the install manifest to update the plugin and re-register it across all previously configured editors, without requiring the user to re-specify editor names. --- internal/cmd/mcp.go | 4 +- internal/cmd/mcp_update.go | 160 ++++++++++++++++++++++++++++++++ internal/cmd/mcp_update_test.go | 51 ++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/mcp_update.go create mode 100644 internal/cmd/mcp_update_test.go diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 64cbadf..89833d3 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -10,7 +10,9 @@ var mcpCmd = &cobra.Command{ Long: `Inspect and debug the MCP servers armis-cli install registered. Use 'armis-cli mcp doctor' to check the scanner and knowledge MCP servers: -plugin files, credentials, editor registrations, and a live handshake.`, +plugin files, credentials, editor registrations, and a live handshake. + +Use 'armis-cli mcp update' to update them to the latest version.`, } func init() { diff --git a/internal/cmd/mcp_update.go b/internal/cmd/mcp_update.go new file mode 100644 index 0000000..d806188 --- /dev/null +++ b/internal/cmd/mcp_update.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/ArmisSecurity/armis-cli/internal/install" + "github.com/spf13/cobra" +) + +var mcpUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update the installed MCP servers to the latest version", + Long: `Update the Armis AppSec MCP server, and every editor it's registered in, to +the latest version. + +Reads the install manifest written by 'armis-cli install' to find out what's +already registered — no editor names needed. Armis Knowledge is updated too +if it was previously installed, or if --with-knowledge is passed.`, + Example: ` # Update everything the manifest knows about + armis-cli mcp update + + # Also install/update Armis Knowledge, even if it wasn't set up before + armis-cli mcp update --with-knowledge + + # Force a reinstall even if already current + armis-cli mcp update --force`, + Args: cobra.NoArgs, + RunE: runMCPUpdate, +} + +func init() { + mcpCmd.AddCommand(mcpUpdateCmd) + mcpUpdateCmd.Flags().Bool("force", false, "Force reinstall even if already up to date") + mcpUpdateCmd.Flags().Bool("with-knowledge", false, "Also update Armis Knowledge for the same targets, even if not previously installed") +} + +func runMCPUpdate(cmd *cobra.Command, _ []string) error { + force, err := cmd.Flags().GetBool("force") + if err != nil { + return fmt.Errorf("reading --force flag: %w", err) + } + withKnowledgeFlag, err := cmd.Flags().GetBool("with-knowledge") + if err != nil { + return fmt.Errorf("reading --with-knowledge flag: %w", err) + } + + ei := install.NewEditorInstaller() + manifest := install.ReadManifest(ei.PluginDir()) + if manifest == nil { + return fmt.Errorf("Armis AppSec MCP server is not installed — run: armis-cli install") //nolint:staticcheck // proper noun + } + + fmt.Fprintln(os.Stderr, "Checking for updates...") + if err := ei.FetchPlugin(force); err != nil { + if errors.Is(err, install.ErrAlreadyCurrent) { + fmt.Fprintf(os.Stderr, "Armis AppSec MCP server v%s is already up to date.\n\n", ei.InstalledVersion()) + } else { + return fmt.Errorf("update failed: %w", err) + } + } else { + fmt.Fprintf(os.Stderr, "MCP server updated to v%s.\n\n", ei.InstalledVersion()) + } + manifest.PluginVersion = ei.InstalledVersion() + + var registered []string + var failed []string + var kt knowledgeTargets + + editorIDs := make([]install.EditorID, 0, len(manifest.Editors)) + for id := range manifest.Editors { + editorIDs = append(editorIDs, id) + } + sort.Slice(editorIDs, func(i, j int) bool { return editorIDs[i] < editorIDs[j] }) + + for _, id := range editorIDs { + e, ok := install.EditorByID(id) + if !ok { + continue + } + if err := e.Register(ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ✗ %s: %v\n", e.Name, err) + failed = append(failed, e.Name) + } else { + fmt.Fprintf(os.Stderr, " ✓ %s\n", e.Name) + registered = append(registered, e.Name) + manifest.AddEditor(e.ID, e.ConfigPath(), install.ConfigFormat(e.ID)) + kt.editors = append(kt.editors, e) + } + + if hc, ok := install.HookClientByID(install.HookClientID(id)); ok { + if err := install.InstallNativeHook(hc, ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ⚠ %s (hooks): %v\n", e.Name, err) + } + } + } + + if manifest.Claude != nil { + ci, ciErr := install.NewClaudeInstaller() + if ciErr != nil { + fmt.Fprintf(os.Stderr, " ✗ Claude Code: %v\n", ciErr) + failed = append(failed, "Claude Code") + } else if err := ci.Install(); err != nil { + fmt.Fprintf(os.Stderr, " ✗ Claude Code: %v\n", err) + failed = append(failed, "Claude Code") + } else { + fmt.Fprintf(os.Stderr, " ✓ Claude Code v%s\n", ci.InstalledVersion()) + registered = append(registered, "Claude Code") + manifest.SetClaude(ci.PluginCacheDir()) + kt.claude = true + } + } + + if manifest.Codex != nil { + if err := install.RegisterCodexMCP(ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ✗ Codex CLI (MCP): %v\n", err) + failed = append(failed, "Codex CLI") + } else { + fmt.Fprintf(os.Stderr, " ✓ Codex CLI (MCP)\n") + registered = append(registered, "Codex CLI") + manifest.SetCodex(install.CodexConfigPath()) + kt.codex = true + } + if hc, ok := install.HookClientByID(install.HookClientCodex); ok { + if err := install.InstallNativeHook(hc, ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ⚠ Codex CLI (hooks): %v\n", err) + } else { + fmt.Fprintf(os.Stderr, " ✓ Codex CLI (hooks)\n") + } + } + } + + withKnowledge := withKnowledgeFlag || manifest.Knowledge != nil + var kres knowledgeResult + if withKnowledge { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Updating Armis Knowledge...") + kres = installKnowledgeFor(kt, force, manifest) + } + + if err := install.WriteManifest(manifest); err != nil { + fmt.Fprintf(os.Stderr, " ⚠ Could not write install manifest: %v\n", err) + } + + fmt.Fprintln(os.Stderr, "") + if len(registered) > 0 { + fmt.Fprintf(os.Stderr, "Updated: %s\n", strings.Join(registered, ", ")) + } + if len(failed) > 0 { + fmt.Fprintf(os.Stderr, "Failed: %s\n", strings.Join(failed, ", ")) + } + if withKnowledge { + printKnowledgeResult(kres) + } + + return nil +} diff --git a/internal/cmd/mcp_update_test.go b/internal/cmd/mcp_update_test.go new file mode 100644 index 0000000..33d4792 --- /dev/null +++ b/internal/cmd/mcp_update_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "strings" + "testing" +) + +// TestRunMCPUpdateNoManifest pins the guard that stops runMCPUpdate before it +// ever calls FetchPlugin (a real network call) when nothing is installed yet. +func TestRunMCPUpdateNoManifest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + err := runMCPUpdate(mcpUpdateCmd, nil) + if err == nil { + t.Fatal("expected error when no manifest is present") + } + if !strings.Contains(err.Error(), "armis-cli install") { + t.Errorf("error should point at 'armis-cli install', got: %v", err) + } +} + +func TestMCPUpdateHasForceFlag(t *testing.T) { + f := mcpUpdateCmd.Flags().Lookup("force") + if f == nil { + t.Fatal("mcp update command is missing the --force flag") + } + if f.DefValue != "false" { + t.Errorf("--force default = %q, want false", f.DefValue) + } +} + +func TestMCPUpdateHasWithKnowledgeFlag(t *testing.T) { + f := mcpUpdateCmd.Flags().Lookup("with-knowledge") + if f == nil { + t.Fatal("mcp update command is missing the --with-knowledge flag") + } + if f.DefValue != "false" { + t.Errorf("--with-knowledge default = %q, want false (only auto-enabled via the manifest)", f.DefValue) + } +} + +func TestMCPUpdateRegisteredUnderMCPCommand(t *testing.T) { + for _, c := range mcpCmd.Commands() { + if c.Name() == "update" { + return + } + } + t.Fatal("'update' is not registered under the 'mcp' command") +} From 9ad283aa04ca631cb0db5fb95234f3527383a126 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 17:22:57 +0300 Subject: [PATCH 03/13] fix(mcp): correct doctor status output and false-positive checks - Fix statusSymbol to respect accessible/no-color mode with [OK]/[WARN]/[FAIL] text instead of always styling with lipgloss - Skip knowledge plugin sub-envs that were never installed (no .venv/), avoiding false failures from sibling envs extracted by Fetch - Report unsupported editor IDs during mcp update instead of silently skipping them - Bound config file reads (claude registry, codex config, env files) via a shared readBoundedConfigFile helper to enforce regular-file and size checks - Close stdout pipe before cmd.Wait() on the handshake timeout path to avoid racing exec's internal pipe cleanup --- internal/cmd/mcp_doctor.go | 28 +++++++++++++--- internal/cmd/mcp_update.go | 2 ++ internal/install/doctor.go | 59 ++++++++++++++++++++++++++------- internal/install/doctor_test.go | 38 +++++++++++++++++++++ 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go index 91810aa..ae9fc9c 100644 --- a/internal/cmd/mcp_doctor.go +++ b/internal/cmd/mcp_doctor.go @@ -5,7 +5,10 @@ import ( "fmt" "time" + "github.com/ArmisSecurity/armis-cli/internal/cli" + "github.com/ArmisSecurity/armis-cli/internal/cmd/cmdutil" "github.com/ArmisSecurity/armis-cli/internal/install" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -86,23 +89,38 @@ func printMCPDoctorPlain(cmd *cobra.Command, report *install.DoctorReport) { return } + accessible := !cli.ColorsEnabled() var lastComponent string for _, c := range report.Checks { if c.Component != lastComponent { fmt.Fprintf(out, "%s:\n", c.Component) lastComponent = c.Component } - fmt.Fprintf(out, " %s %-20s %s\n", statusSymbol(c.Status), c.Name, c.Detail) + fmt.Fprintf(out, " %s %-20s %s\n", statusSymbol(c.Status, accessible), c.Name, c.Detail) } } -func statusSymbol(s install.CheckStatus) string { +// statusSymbol renders a check's status, matching the color/theme handling +// (cli.ColorsEnabled) and ASCII fallback used by the rest of the install/ +// uninstall output (see install_interactive.go, uninstall.go) so `mcp doctor` +// doesn't diverge from the CLI's centralized styling. +func statusSymbol(s install.CheckStatus, accessible bool) string { + if accessible { + switch s { + case install.StatusOK: + return "[OK]" + case install.StatusWarn: + return "[WARN]" + default: + return "[FAIL]" + } + } switch s { case install.StatusOK: - return "✓" + return lipgloss.NewStyle().Foreground(cmdutil.BrandSuccess).Render("✓") case install.StatusWarn: - return "⚠" + return lipgloss.NewStyle().Foreground(cmdutil.BrandWarn).Render("⚠") default: - return "✗" + return lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗") } } diff --git a/internal/cmd/mcp_update.go b/internal/cmd/mcp_update.go index d806188..9960626 100644 --- a/internal/cmd/mcp_update.go +++ b/internal/cmd/mcp_update.go @@ -79,6 +79,8 @@ func runMCPUpdate(cmd *cobra.Command, _ []string) error { for _, id := range editorIDs { e, ok := install.EditorByID(id) if !ok { + fmt.Fprintf(os.Stderr, " ⚠ %s: no longer supported by this CLI version — skipping\n", id) + failed = append(failed, string(id)) continue } if err := e.Register(ei.PluginDir()); err != nil { diff --git a/internal/install/doctor.go b/internal/install/doctor.go index d447573..398d31d 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -151,17 +151,28 @@ func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts Docto } found = true + // Fetch extracts the whole knowledge repo (see EnvDir's doc comment), + // so every environment's bridge.py lands on disk even though only the + // one the user actually chose gets a venv (createPluginVenv is only + // called for that EnvDir). A sibling env with bridge.py but no .venv/ + // at all was never installed here — skip it rather than reporting a + // false failure; only flag a venv as broken once it was set up. + if _, err := os.Stat(filepath.Join(envDir, ".venv")); err != nil { + continue + } + + subComponent := component + " " + sub pythonPath := venvPython(envDir) if !isExecutableFile(pythonPath) { - report.add(component, sub+" python venv", StatusFail, fmt.Sprintf("missing or not executable: %s", pythonPath)) + report.add(subComponent, "python venv", StatusFail, fmt.Sprintf("missing or not executable: %s", pythonPath)) continue } - report.add(component, sub+" python venv", StatusOK, pythonPath) + report.add(subComponent, "python venv", StatusOK, pythonPath) - env := checkCredentials(report, component+" "+sub, filepath.Join(envDir, ".env")) + env := checkCredentials(report, subComponent, filepath.Join(envDir, ".env")) if opts.Handshake { - runHandshakeCheck(report, component+" "+sub, pythonPath, []string{bridge}, env, opts.Timeout) + runHandshakeCheck(report, subComponent, pythonPath, []string{bridge}, env, opts.Timeout) } } if !found { @@ -236,6 +247,27 @@ func checkClaudeSection(report *DoctorReport, component string, claude *Manifest } } +// readBoundedConfigFile reads path the same way editors.go's +// readJSONFileAsMap/readYAMLFileAsMap do: reject non-regular files (devices, +// FIFOs, symlinks to either) and cap the size at maxEditorConfigSize before +// reading, so a doctor check can't block or exhaust memory on a config path +// that isn't the plain file it's expected to be (CWE-770). +func readBoundedConfigFile(path string) ([]byte, error) { + clean := filepath.Clean(path) + info, err := os.Stat(clean) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", clean) + } + if info.Size() > maxEditorConfigSize { + return nil, fmt.Errorf("%s exceeds %d bytes", clean, maxEditorConfigSize) + } + // armis:ignore cwe:22 cwe:770 reason:path from the install manifest/known config locations; regular-file and size checks above bound the read + return os.ReadFile(clean) //nolint:gosec +} + // claudeRegistryStatus reports whether any plugin key containing // pluginKeyPrefix is recorded as installed and/or enabled in Claude Code's // own registry files. @@ -243,8 +275,7 @@ func claudeRegistryStatus(claudeDir, pluginKeyPrefix string) (installed, enabled prefix := strings.ToLower(pluginKeyPrefix) instFile := filepath.Join(claudeDir, "plugins", "installed_plugins.json") - // armis:ignore cwe:770 reason:reads bounded JSON config file from user's ~/.claude dir; not unbounded input - if b, err := os.ReadFile(filepath.Clean(instFile)); err == nil { + if b, err := readBoundedConfigFile(instFile); err == nil { var data struct { Plugins map[string]json.RawMessage `json:"plugins"` } @@ -259,8 +290,7 @@ func claudeRegistryStatus(claudeDir, pluginKeyPrefix string) (installed, enabled } settingsFile := filepath.Join(claudeDir, "settings.json") - // armis:ignore cwe:770 reason:reads bounded JSON config file from user's ~/.claude dir; not unbounded input - if b, err := os.ReadFile(filepath.Clean(settingsFile)); err == nil { + if b, err := readBoundedConfigFile(settingsFile); err == nil { var data struct { EnabledPlugins map[string]bool `json:"enabledPlugins"` } @@ -280,8 +310,7 @@ func checkCodexSection(report *DoctorReport, component string, codex *ManifestCo if codex == nil { return } - // armis:ignore cwe:770 reason:reads bounded local config file (~/.codex/config.toml) - content, err := os.ReadFile(filepath.Clean(codex.ConfigFile)) + content, err := readBoundedConfigFile(codex.ConfigFile) if err != nil { report.add(component, "Codex CLI", StatusFail, fmt.Sprintf("config file missing: %s", codex.ConfigFile)) return @@ -369,8 +398,7 @@ func isExecutableFile(path string) bool { // parseEnvFile reads a "KEY=VALUE" per line .env file, as written by // writeEnvFromEnvironment/WriteEnvFromValues. func parseEnvFile(path string) (map[string]string, error) { - // armis:ignore cwe:770 reason:reads a local plugin .env file the CLI itself wrote, always small - b, err := os.ReadFile(filepath.Clean(path)) + b, err := readBoundedConfigFile(path) if err != nil { return nil, err } @@ -456,6 +484,13 @@ func mcpHandshake(command string, args []string, env map[string]string, timeout // armis:ignore cwe:404 reason:best-effort cleanup of a short-lived diagnostic subprocess we just spawned _ = cmd.Process.Kill() + // On the timeout path, communicateInitialize's reader goroutine may still + // be blocked reading stdout when we get here. os/exec's docs warn it is + // "incorrect to call Wait before all reads from the pipe have completed" + // because Wait closes this same pipe as part of its own cleanup — close + // it here first so the unblock is explicit and ordered rather than racing + // Wait's internal close. + _ = stdout.Close() _ = cmd.Wait() if opErr != nil { diff --git a/internal/install/doctor_test.go b/internal/install/doctor_test.go index be47ec5..0b7f817 100644 --- a/internal/install/doctor_test.go +++ b/internal/install/doctor_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" ) @@ -346,6 +347,43 @@ func TestRunDoctorStructuralChecks(t *testing.T) { } } +// TestCheckKnowledgePluginSkipsUninstalledSiblingEnv pins the fix for a false +// failure: Fetch extracts the whole knowledge repo, so every env's bridge.py +// (prod/stage/dev) lands on disk even though only the chosen env gets a venv. +// A sibling env with bridge.py but no .venv/ was never installed and must not +// be reported as a failure. +func TestCheckKnowledgePluginSkipsUninstalledSiblingEnv(t *testing.T) { + dir := t.TempDir() + + // "prod" is the env the user actually installed: bridge.py + a real venv. + writeFakeVenv(t, filepath.Join(dir, "prod")) + _ = os.WriteFile(filepath.Join(dir, "prod", "bridge.py"), []byte("# bridge"), 0o600) + + // "dev" is a sibling extracted alongside it, with no venv ever created. + _ = os.MkdirAll(filepath.Join(dir, "dev"), 0o750) + _ = os.WriteFile(filepath.Join(dir, "dev", "bridge.py"), []byte("# bridge"), 0o600) + + report := &DoctorReport{} + checkKnowledgePlugin(report, &ManifestKnowledge{PluginDir: dir}, DoctorOptions{Handshake: false}) + + if report.HasFailures() { + t.Errorf("checkKnowledgePlugin() unexpected failures for uninstalled sibling env: %+v", report.Checks) + } + + var sawProdOK bool + for _, c := range report.Checks { + if c.Component == "knowledge prod" && c.Name == "python venv" && c.Status == StatusOK { + sawProdOK = true + } + if c.Component == "knowledge dev" || strings.HasPrefix(c.Name, "dev ") { + t.Errorf("checkKnowledgePlugin() reported a check for uninstalled sibling env dev: %+v", c) + } + } + if !sawProdOK { + t.Errorf("checkKnowledgePlugin() checks = %+v, want ok check for knowledge prod / python venv", report.Checks) + } +} + // writeFakeVenv creates a fake venv python executable so structural checks // (which only stat for existence + executable bit) pass without a real // Python install. From 317c68838012c5bc9c989548d5948dac8fab0575 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 17:30:17 +0300 Subject: [PATCH 04/13] fix(lint): resolve errcheck, goconst, and gosec findings in mcp doctor --- internal/cmd/mcp_doctor.go | 6 +++--- internal/install/doctor.go | 6 +++--- internal/install/doctor_test.go | 12 ++++++------ internal/install/editors.go | 7 +++++++ internal/install/manifest.go | 6 +++--- internal/install/uninstall.go | 6 +++--- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go index ae9fc9c..7f8ef86 100644 --- a/internal/cmd/mcp_doctor.go +++ b/internal/cmd/mcp_doctor.go @@ -85,7 +85,7 @@ func printMCPDoctorPlain(cmd *cobra.Command, report *install.DoctorReport) { out := cmd.OutOrStdout() if len(report.Checks) == 0 { - fmt.Fprintln(out, "No checks produced any output.") + _, _ = fmt.Fprintln(out, "No checks produced any output.") return } @@ -93,10 +93,10 @@ func printMCPDoctorPlain(cmd *cobra.Command, report *install.DoctorReport) { var lastComponent string for _, c := range report.Checks { if c.Component != lastComponent { - fmt.Fprintf(out, "%s:\n", c.Component) + _, _ = fmt.Fprintf(out, "%s:\n", c.Component) lastComponent = c.Component } - fmt.Fprintf(out, " %s %-20s %s\n", statusSymbol(c.Status, accessible), c.Name, c.Detail) + _, _ = fmt.Fprintf(out, " %s %-20s %s\n", statusSymbol(c.Status, accessible), c.Name, c.Detail) } } diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 398d31d..5403a40 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -343,7 +343,7 @@ func lookupEntryCommand(configFile, format, identifier string) (command string, } switch format { - case "vscode-servers": + case configFormatVSCode: servers, _ := readJSONFileAsMap(configFile)["servers"].(map[string]interface{}) entry, ok := matchEntry(servers) if !ok { @@ -351,7 +351,7 @@ func lookupEntryCommand(configFile, format, identifier string) (command string, } cmd, _ := entry[jsonKeyCommand].(string) return cmd, true - case "zed-context_servers": + case configFormatZed: servers, _ := readJSONFileAsMap(configFile)["context_servers"].(map[string]interface{}) entry, ok := matchEntry(servers) if !ok { @@ -360,7 +360,7 @@ func lookupEntryCommand(configFile, format, identifier string) (command string, cmdObj, _ := entry[jsonKeyCommand].(map[string]interface{}) cmd, _ := cmdObj[jsonKeyPath].(string) return cmd, true - case "continue-yaml": + case configFormatContinue: list, _ := readYAMLFileAsMap(configFile)["mcpServers"].([]interface{}) for _, item := range list { m, ok := item.(map[string]interface{}) diff --git a/internal/install/doctor_test.go b/internal/install/doctor_test.go index 0b7f817..67d1963 100644 --- a/internal/install/doctor_test.go +++ b/internal/install/doctor_test.go @@ -28,13 +28,13 @@ func runMCPHelperProcess(mode string) { select {} case "garbage": _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') - fmt.Fprintln(os.Stdout, "not json") + _, _ = fmt.Fprintln(os.Stdout, "not json") case "error": _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') - fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"boom"}}`) + _, _ = fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"boom"}}`) default: // "ok" _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') - fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"fake-mcp","version":"9.9.9"}}}`) + _, _ = fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"fake-mcp","version":"9.9.9"}}}`) } } @@ -179,7 +179,7 @@ func TestCheckManifestEditors(t *testing.T) { dir := t.TempDir() realCommand := filepath.Join(dir, "python") - _ = os.WriteFile(realCommand, []byte("x"), 0o700) + _ = os.WriteFile(realCommand, []byte("x"), 0o700) // #nosec G306 -- needs exec bit for isExecutableFile checks presentFile := filepath.Join(dir, "present.json") mustWriteJSON(t, presentFile, map[string]interface{}{ @@ -291,7 +291,7 @@ func TestIsExecutableFile(t *testing.T) { } executable := filepath.Join(dir, "executable") - _ = os.WriteFile(executable, []byte("x"), 0o700) + _ = os.WriteFile(executable, []byte("x"), 0o700) // #nosec G306 -- needs exec bit for isExecutableFile checks if !isExecutableFile(executable) { t.Error("isExecutableFile() = false for an executable file") } @@ -393,7 +393,7 @@ func writeFakeVenv(t *testing.T, pluginDir string) { if err := os.MkdirAll(filepath.Dir(python), 0o750); err != nil { t.Fatal(err) } - if err := os.WriteFile(python, []byte("#!/bin/sh\n"), 0o700); err != nil { + if err := os.WriteFile(python, []byte("#!/bin/sh\n"), 0o700); err != nil { // #nosec G306 -- needs exec bit to run as fake venv python t.Fatal(err) } } diff --git a/internal/install/editors.go b/internal/install/editors.go index 0dc534c..4c4890d 100644 --- a/internal/install/editors.go +++ b/internal/install/editors.go @@ -32,6 +32,13 @@ const ( jsonTypeCommand = "command" ) +// Config format identifiers returned by ConfigFormat, keyed by editor. +const ( + configFormatVSCode = "vscode-servers" + configFormatZed = "zed-context_servers" + configFormatContinue = "continue-yaml" +) + // EditorID identifies a supported editor. type EditorID string diff --git a/internal/install/manifest.go b/internal/install/manifest.go index 2084bf5..859272f 100644 --- a/internal/install/manifest.go +++ b/internal/install/manifest.go @@ -170,11 +170,11 @@ func (k *ManifestKnowledge) SetCodex(configFile string) { func ConfigFormat(id EditorID) string { switch id { case EditorVSCode: - return "vscode-servers" + return configFormatVSCode case EditorZed: - return "zed-context_servers" + return configFormatZed case EditorContinue: - return "continue-yaml" + return configFormatContinue default: return "mcpServers" } diff --git a/internal/install/uninstall.go b/internal/install/uninstall.go index 67fd9df..b39ce85 100644 --- a/internal/install/uninstall.go +++ b/internal/install/uninstall.go @@ -514,11 +514,11 @@ func knowledgeServerNames() []string { func deregisterServerFromFile(configFile, format string, names []string) error { key := "mcpServers" switch format { - case "vscode-servers": + case configFormatVSCode: key = "servers" - case "zed-context_servers": + case configFormatZed: key = "context_servers" - case "continue-yaml": + case configFormatContinue: // Continue's servers are a YAML list, not a JSON map. return removeContinueEntry(configFile, names) } From 13de7bd6d18878cd07e51ac6659dcb4707a966f6 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 17:33:38 +0300 Subject: [PATCH 05/13] fix(mcp): surface actual error when codex config file read fails --- internal/install/doctor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 5403a40..16b7216 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -312,7 +312,7 @@ func checkCodexSection(report *DoctorReport, component string, codex *ManifestCo } content, err := readBoundedConfigFile(codex.ConfigFile) if err != nil { - report.add(component, "Codex CLI", StatusFail, fmt.Sprintf("config file missing: %s", codex.ConfigFile)) + report.add(component, "Codex CLI", StatusFail, fmt.Sprintf("config file %s: %v", codex.ConfigFile, err)) return } if !strings.Contains(strings.ToLower(string(content)), strings.ToLower(identifier)) { From 7a8a3f08c7a144833d9c0bd168e70c9cd808e86c Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 17:42:10 +0300 Subject: [PATCH 06/13] fix(mcp): surface underlying error in credential and manifest doctor checks --- internal/install/doctor.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 16b7216..5b70c57 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -184,7 +184,11 @@ func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts Docto // returns its contents for reuse by a following live handshake. func checkCredentials(report *DoctorReport, component, envFile string) map[string]string { env, err := parseEnvFile(envFile) - if err != nil || env["ARMIS_CLIENT_ID"] == "" || env["ARMIS_CLIENT_SECRET"] == "" { + if err != nil { + report.add(component, "credentials", StatusWarn, fmt.Sprintf("%s: %v", envFile, err)) + return env + } + if env["ARMIS_CLIENT_ID"] == "" || env["ARMIS_CLIENT_SECRET"] == "" { report.add(component, "credentials", StatusWarn, fmt.Sprintf("ARMIS_CLIENT_ID/ARMIS_CLIENT_SECRET not set in %s", envFile)) return env @@ -208,7 +212,7 @@ func checkManifestEditors(report *DoctorReport, component, identifier string, ed } if _, err := os.Stat(entry.ConfigFile); err != nil { - report.add(component, name, StatusFail, fmt.Sprintf("config file missing: %s", entry.ConfigFile)) + report.add(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)) continue } From 04a75fc01b10425f561cfb8240e0dce7856c6847 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 17:50:40 +0300 Subject: [PATCH 07/13] fix(mcp): detect broken knowledge venv installs and reject invalid editor configs --- internal/install/doctor.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 5b70c57..6baae20 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -143,6 +143,7 @@ func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts Docto } found := false + venvFound := false for _, sub := range []string{"prod", "stage", "dev"} { envDir := filepath.Join(k.PluginDir, sub) bridge := filepath.Join(envDir, "bridge.py") @@ -160,6 +161,7 @@ func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts Docto if _, err := os.Stat(filepath.Join(envDir, ".venv")); err != nil { continue } + venvFound = true subComponent := component + " " + sub pythonPath := venvPython(envDir) @@ -175,8 +177,11 @@ func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts Docto runHandshakeCheck(report, subComponent, pythonPath, []string{bridge}, env, opts.Timeout) } } - if !found { + switch { + case !found: report.add(component, "bridge", StatusFail, fmt.Sprintf("no bridge.py found under %s", k.PluginDir)) + case !venvFound: + report.add(component, "python venv", StatusFail, fmt.Sprintf("bridge.py found under %s but no environment has a .venv — install may have failed", k.PluginDir)) } } @@ -211,7 +216,11 @@ func checkManifestEditors(report *DoctorReport, component, identifier string, ed name = ed.Name } - if _, err := os.Stat(entry.ConfigFile); err != nil { + // readBoundedConfigFile applies the same regular-file and size guards as + // readJSONFileAsMap/readYAMLFileAsMap, so a non-regular or oversized + // config is reported here rather than silently read as empty by + // lookupEntryCommand below and misreported as "entry not found". + if _, err := readBoundedConfigFile(entry.ConfigFile); err != nil { report.add(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)) continue } From fb59929e650209252ae675fbb971819030ec6193 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 17:56:46 +0300 Subject: [PATCH 08/13] fix(mcp): detect corrupted editor configs and close handshake pipes on start failure --- internal/install/doctor.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 6baae20..46712f2 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -220,10 +220,19 @@ func checkManifestEditors(report *DoctorReport, component, identifier string, ed // readJSONFileAsMap/readYAMLFileAsMap, so a non-regular or oversized // config is reported here rather than silently read as empty by // lookupEntryCommand below and misreported as "entry not found". - if _, err := readBoundedConfigFile(entry.ConfigFile); err != nil { + content, err := readBoundedConfigFile(entry.ConfigFile) + if err != nil { report.add(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)) continue } + // readJSONFileAsMap (used by every format except configFormatContinue) + // also returns an empty map on a parse error, so a corrupted JSON + // config would otherwise fall through to the same "entry not found" + // warning as a genuinely edited-out entry. Catch that case explicitly. + if entry.Format != configFormatContinue && !json.Valid(content) { + report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid JSON", entry.ConfigFile)) + continue + } command, found := lookupEntryCommand(entry.ConfigFile, entry.Format, identifier) if !found { @@ -484,12 +493,18 @@ func mcpHandshake(command string, args []string, env map[string]string, timeout } stdout, err := cmd.StdoutPipe() if err != nil { + _ = stdin.Close() return nil, "", fmt.Errorf("opening stdout: %w", err) } var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf + // Start() failing means Wait() will never run to close these pipes for us + // (that cleanup is documented as conditional on a successful Start), so + // close them ourselves rather than leaking the file descriptors. if err := cmd.Start(); err != nil { + _ = stdin.Close() + _ = stdout.Close() return nil, "", fmt.Errorf("starting process: %w", err) } From 2170c603dfc766301975921db350ac1a7631a47b Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 18:04:08 +0300 Subject: [PATCH 09/13] fix(mcp): bound handshake response read and print plain doctor output to stderr --- internal/cmd/mcp_doctor.go | 2 +- internal/install/doctor.go | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go index 7f8ef86..66b76ac 100644 --- a/internal/cmd/mcp_doctor.go +++ b/internal/cmd/mcp_doctor.go @@ -82,7 +82,7 @@ func printMCPDoctorJSON(cmd *cobra.Command, report *install.DoctorReport) error } func printMCPDoctorPlain(cmd *cobra.Command, report *install.DoctorReport) { - out := cmd.OutOrStdout() + out := cmd.ErrOrStderr() if len(report.Checks) == 0 { _, _ = fmt.Fprintln(out, "No checks produced any output.") diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 46712f2..0dc4d2c 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" "io" "os" @@ -14,6 +15,12 @@ import ( "time" ) +// maxHandshakeLineSize bounds a single line read from a spawned MCP server's +// stdout during the doctor's live handshake — generous for a JSON-RPC +// initialize response, but small enough to stop a broken or hostile server +// process from growing the read buffer without limit (CWE-770). +const maxHandshakeLineSize = 1 << 20 // 1 MB + // knowledgeJSONIdentifier and knowledgeCodexIdentifier are the substrings used // to spot a knowledge-bridge entry in an editor config, mirroring how // mcpServerName/codexMCPServerName identify the scanner. The knowledge server @@ -549,9 +556,13 @@ func communicateInitialize(stdin io.WriteCloser, stdout io.ReadCloser, timeout t } lineCh := make(chan readOutcome, 1) go func() { - reader := bufio.NewReader(stdout) - l, rerr := reader.ReadBytes('\n') - lineCh <- readOutcome{l, rerr} + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), maxHandshakeLineSize) + if scanner.Scan() { + lineCh <- readOutcome{append([]byte(nil), scanner.Bytes()...), nil} + return + } + lineCh <- readOutcome{nil, scanner.Err()} }() if _, err := stdin.Write(append(line, '\n')); err != nil { @@ -563,6 +574,9 @@ func communicateInitialize(stdin io.WriteCloser, stdout io.ReadCloser, timeout t return nil, fmt.Errorf("timed out waiting for response after %s", timeout) case out := <-lineCh: if len(out.line) == 0 { + if errors.Is(out.err, bufio.ErrTooLong) { + return nil, fmt.Errorf("response exceeded %d bytes", maxHandshakeLineSize) + } if out.err != nil { return nil, fmt.Errorf("no response: %w", out.err) } From dec5b6e37ccb5db97d225e83b48a496fe207c9cc Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 18:12:07 +0300 Subject: [PATCH 10/13] fix(mcp): reject non-object JSON editor configs; use format constants in tests --- internal/install/doctor.go | 16 ++++++++++------ internal/install/doctor_test.go | 8 ++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 0dc4d2c..5a6412f 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -233,12 +233,16 @@ func checkManifestEditors(report *DoctorReport, component, identifier string, ed continue } // readJSONFileAsMap (used by every format except configFormatContinue) - // also returns an empty map on a parse error, so a corrupted JSON - // config would otherwise fall through to the same "entry not found" - // warning as a genuinely edited-out entry. Catch that case explicitly. - if entry.Format != configFormatContinue && !json.Valid(content) { - report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid JSON", entry.ConfigFile)) - continue + // also returns an empty map on a parse error, so a corrupted or + // non-object JSON config (null, an array, ...) would otherwise fall + // through to the same "entry not found" warning as a genuinely + // edited-out entry. Catch that case explicitly. + if entry.Format != configFormatContinue { + var obj map[string]interface{} + if err := json.Unmarshal(content, &obj); err != nil { + report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid JSON: %v", entry.ConfigFile, err)) + continue + } } command, found := lookupEntryCommand(entry.ConfigFile, entry.Format, identifier) diff --git a/internal/install/doctor_test.go b/internal/install/doctor_test.go index 67d1963..92e2f41 100644 --- a/internal/install/doctor_test.go +++ b/internal/install/doctor_test.go @@ -123,7 +123,7 @@ func TestLookupEntryCommand(t *testing.T) { }, { name: "vscode-servers format match", - format: "vscode-servers", + format: configFormatVSCode, content: `{"servers":{"armis-appsec":{"type":"stdio","command":"/bin/python"}}}`, fileName: "vscode.json", identifier: "armis-appsec", @@ -132,7 +132,7 @@ func TestLookupEntryCommand(t *testing.T) { }, { name: "zed-context_servers format match", - format: "zed-context_servers", + format: configFormatZed, content: `{"context_servers":{"armis-appsec":{"command":{"path":"/bin/python","args":[]}}}}`, fileName: "zed.json", identifier: "armis-appsec", @@ -141,7 +141,7 @@ func TestLookupEntryCommand(t *testing.T) { }, { name: "continue-yaml format match", - format: "continue-yaml", + format: configFormatContinue, content: "mcpServers:\n - name: armis-knowledge\n command: /bin/python\n", fileName: "continue.yaml", identifier: "armis-knowledge", @@ -150,7 +150,7 @@ func TestLookupEntryCommand(t *testing.T) { }, { name: "continue-yaml format no match", - format: "continue-yaml", + format: configFormatContinue, content: "mcpServers:\n - name: other\n command: /bin/python\n", fileName: "continue2.yaml", identifier: "armis-knowledge", From b397df6f20d1dcedf2b52896b50f16b65cd52607 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 18:17:00 +0300 Subject: [PATCH 11/13] fix(mcp): reject top-level null JSON in editor config validation --- internal/install/doctor.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 5a6412f..57002e1 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -239,7 +239,14 @@ func checkManifestEditors(report *DoctorReport, component, identifier string, ed // edited-out entry. Catch that case explicitly. if entry.Format != configFormatContinue { var obj map[string]interface{} - if err := json.Unmarshal(content, &obj); err != nil { + err := json.Unmarshal(content, &obj) + // json.Unmarshal accepts a top-level `null` into obj without + // error (obj just stays nil), so an explicit err-only check + // would miss it — require a non-nil object too. + if err == nil && obj == nil { + err = fmt.Errorf("top-level value is not a JSON object") + } + if err != nil { report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid JSON: %v", entry.ConfigFile, err)) continue } From db46b6f1054e84d68c15aeb9056cb3ad4f010589 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 18:24:17 +0300 Subject: [PATCH 12/13] fix(mcp): reject invalid YAML editor configs, close handshake stdin, tidy version-less detail --- internal/install/doctor.go | 47 +++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 57002e1..de3a346 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -13,6 +13,8 @@ import ( "runtime" "strings" "time" + + "gopkg.in/yaml.v3" ) // maxHandshakeLineSize bounds a single line read from a spawned MCP server's @@ -232,24 +234,27 @@ func checkManifestEditors(report *DoctorReport, component, identifier string, ed report.add(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)) continue } - // readJSONFileAsMap (used by every format except configFormatContinue) - // also returns an empty map on a parse error, so a corrupted or - // non-object JSON config (null, an array, ...) would otherwise fall - // through to the same "entry not found" warning as a genuinely - // edited-out entry. Catch that case explicitly. - if entry.Format != configFormatContinue { - var obj map[string]interface{} - err := json.Unmarshal(content, &obj) - // json.Unmarshal accepts a top-level `null` into obj without - // error (obj just stays nil), so an explicit err-only check - // would miss it — require a non-nil object too. - if err == nil && obj == nil { - err = fmt.Errorf("top-level value is not a JSON object") - } - if err != nil { - report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid JSON: %v", entry.ConfigFile, err)) - continue - } + // readJSONFileAsMap/readYAMLFileAsMap also return an empty map on a + // parse error, so a corrupted or non-object config (null, an array, + // invalid YAML, ...) would otherwise fall through to the same "entry + // not found" warning as a genuinely edited-out entry. Catch that case + // explicitly. + var obj map[string]interface{} + var parseErr error + if entry.Format == configFormatContinue { + parseErr = yaml.Unmarshal(content, &obj) + } else { + parseErr = json.Unmarshal(content, &obj) + } + // Both unmarshalers accept a top-level `null` without error (obj just + // stays nil), so an err-only check would miss it — require a non-nil + // object too. + if parseErr == nil && obj == nil { + parseErr = fmt.Errorf("top-level value is not an object") + } + if parseErr != nil { + report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid: %v", entry.ConfigFile, parseErr)) + continue } command, found := lookupEntryCommand(entry.ConfigFile, entry.Format, identifier) @@ -483,7 +488,10 @@ func runHandshakeCheck(report *DoctorReport, component, command string, args []s } detail := "responded to initialize" if res.ServerName != "" { - detail = fmt.Sprintf("%s v%s responded", res.ServerName, res.ServerVersion) + detail = res.ServerName + " responded" + if res.ServerVersion != "" { + detail = fmt.Sprintf("%s v%s responded", res.ServerName, res.ServerVersion) + } } report.add(component, "live handshake", StatusOK, detail) } @@ -537,6 +545,7 @@ func mcpHandshake(command string, args []string, env map[string]string, timeout // it here first so the unblock is explicit and ordered rather than racing // Wait's internal close. _ = stdout.Close() + _ = stdin.Close() _ = cmd.Wait() if opErr != nil { From 2d16cecbb2f7a785d220cb39d07a6155219bf1a1 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 22 Sep 2026 18:29:51 +0300 Subject: [PATCH 13/13] fix(mcp): guard claudeRegistryStatus against an unresolved home directory --- internal/install/doctor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index de3a346..5f3019b 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -317,6 +317,9 @@ func readBoundedConfigFile(path string) ([]byte, error) { // pluginKeyPrefix is recorded as installed and/or enabled in Claude Code's // own registry files. func claudeRegistryStatus(claudeDir, pluginKeyPrefix string) (installed, enabled bool) { + if claudeDir == "" { + return false, false + } prefix := strings.ToLower(pluginKeyPrefix) instFile := filepath.Join(claudeDir, "plugins", "installed_plugins.json")