Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion internal/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.")
}
}
20 changes: 20 additions & 0 deletions internal/cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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.

Use 'armis-cli mcp update' to update them to the latest version.`,
}

func init() {
rootCmd.AddCommand(mcpCmd)
}
126 changes: 126 additions & 0 deletions internal/cmd/mcp_doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package cmd

import (
"encoding/json"
"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"
)

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.ErrOrStderr()

if len(report.Checks) == 0 {
_, _ = fmt.Fprintln(out, "No checks produced any output.")
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, accessible), c.Name, c.Detail)
}
}

// 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 lipgloss.NewStyle().Foreground(cmdutil.BrandSuccess).Render("✓")
case install.StatusWarn:
return lipgloss.NewStyle().Foreground(cmdutil.BrandWarn).Render("⚠")
default:
return lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗")
}
}
162 changes: 162 additions & 0 deletions internal/cmd/mcp_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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 {
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 {
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
}
51 changes: 51 additions & 0 deletions internal/cmd/mcp_update_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading