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
179 changes: 163 additions & 16 deletions internal/cmd/mcp_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"time"

Expand All @@ -23,6 +24,7 @@ var (
mcpDoctorFix bool
mcpDoctorBundle bool
mcpDoctorBundlePath string
mcpDoctorVerbose bool
)

var mcpDoctorCmd = &cobra.Command{
Expand All @@ -45,6 +47,9 @@ VSCodium, per-profile and workspace configs, duplicate entries, settings and
Windows Group Policy that disable MCP or Agent mode, and VS Code's own MCP log
for the server.

By default only a summary and the checks that need attention are shown;
--verbose lists every check.

Every failing check prints how to fix it. --fix repairs what the CLI can
(stale or missing registrations, a broken venv, a system proxy the server
isn't using) and re-runs the checks.
Expand All @@ -55,6 +60,9 @@ Exits non-zero if any check fails.`,
Example: ` # Full diagnostic, including live handshake
armis-cli mcp doctor

# Show every check, not just the summary
armis-cli mcp doctor --verbose

# Diagnose and repair what can be repaired automatically
armis-cli mcp doctor --fix

Expand All @@ -78,6 +86,7 @@ func init() {
mcpDoctorCmd.Flags().BoolVar(&mcpDoctorFix, "fix", false, "Repair fixable problems (re-register editors, rebuild the venv), then re-check")
mcpDoctorCmd.Flags().BoolVar(&mcpDoctorBundle, "bundle", false, "Write a support bundle zip (credentials removed) for Armis support")
mcpDoctorCmd.Flags().StringVar(&mcpDoctorBundlePath, "bundle-path", "", "Where to write the support bundle (implies --bundle; default: ./armis-mcp-doctor-<timestamp>.zip)")
mcpDoctorCmd.Flags().BoolVarP(&mcpDoctorVerbose, "verbose", "v", false, "List every check, including passing ones and informational notes")
}

func runMCPDoctor(cmd *cobra.Command, _ []string) error {
Expand All @@ -96,7 +105,7 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error {

report := install.RunDoctor(opts)
if mcpDoctorFormat == agentFormatPlain {
printMCPDoctorPlain(stderr, report, !mcpDoctorFix)
printMCPDoctorPlain(stderr, report, !mcpDoctorFix, mcpDoctorVerbose)
}

if mcpDoctorFix {
Expand All @@ -108,7 +117,7 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error {
_, _ = fmt.Fprintln(stderr, "\nRe-running checks...")
report = install.RunDoctor(opts)
if mcpDoctorFormat == agentFormatPlain {
printMCPDoctorPlain(stderr, report, false)
printMCPDoctorPlain(stderr, report, false, mcpDoctorVerbose)
}
}
}
Expand Down Expand Up @@ -201,28 +210,22 @@ func printMCPDoctorJSON(cmd *cobra.Command, report *install.DoctorReport) error
return enc.Encode(report)
}

func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix bool) {
func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix, verbose bool) {
if len(report.Checks) == 0 {
_, _ = fmt.Fprintln(out, "No checks produced any output.")
return
}

accessible := !cli.ColorsEnabled()
width := 20
for _, c := range report.Checks {
width = max(width, len(c.Name))
var hiddenNotes []string
if verbose {
printDoctorChecksVerbose(out, report.Checks, accessible)
} else {
hiddenNotes = printDoctorChecksCompact(out, report.Checks, accessible)
}
var lastComponent string

var passed, warned, failed int
for _, c := range report.Checks {
if c.Component != lastComponent {
_, _ = fmt.Fprintf(out, "%s:\n", c.Component)
lastComponent = c.Component
}
_, _ = fmt.Fprintf(out, " %s %-*s %s\n", statusSymbol(c.Status, accessible), width, c.Name, c.Detail)
if c.Remediation != "" && c.Status != install.StatusOK {
printRemediation(out, c.Remediation)
}
switch c.Status {
case install.StatusOK:
passed++
Expand All @@ -232,14 +235,158 @@ func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix
failed++
}
}

_, _ = fmt.Fprintf(out, "\n%d passed, %d warnings, %d failed\n", passed, warned, failed)
if suggestFix && len(report.Fixes()) > 0 {
_, _ = fmt.Fprintln(out, "Some of these can be repaired automatically: armis-cli mcp doctor --fix")
}
if warned+failed > 0 {
_, _ = fmt.Fprintln(out, "Still stuck? Run 'armis-cli mcp doctor --bundle' and send the zip to Armis support.")
}
if !verbose {
if len(hiddenNotes) > 0 {
_, _ = fmt.Fprintf(out, "Every check, plus notes on %s: armis-cli mcp doctor --verbose\n", strings.Join(hiddenNotes, ", "))
} else {
_, _ = fmt.Fprintln(out, "Every check: armis-cli mcp doctor --verbose")
}
}
}

// printDoctorChecksVerbose prints every check, grouped by component.
func printDoctorChecksVerbose(out io.Writer, checks []install.DoctorCheck, accessible bool) {
width := 20
for _, c := range checks {
width = max(width, len(c.Name))
}
var lastComponent string
for _, c := range checks {
if c.Component != lastComponent {
_, _ = fmt.Fprintf(out, "%s:\n", c.Component)
lastComponent = c.Component
}
_, _ = fmt.Fprintf(out, " %s %-*s %s\n", statusSymbol(c.Status, accessible), width, c.Name, c.Detail)
if c.Remediation != "" && c.Status != install.StatusOK {
printRemediation(out, c.Remediation)
}
}
}

// doctorRow is one line of the compact view: a passing summary, or a single
// problem check with its hint.
type doctorRow struct {
status install.CheckStatus
label string
text string
check *install.DoctorCheck // set for problem rows
}

// printDoctorChecksCompact prints one line per component and one for its
// editors when everything passes, and only the failing or warning checks
// otherwise. It returns the names of the informational checks it left out.
func printDoctorChecksCompact(out io.Writer, checks []install.DoctorCheck, accessible bool) []string {
var components []string
byComponent := make(map[string][]install.DoctorCheck)
for _, c := range checks {
if _, ok := byComponent[c.Component]; !ok {
components = append(components, c.Component)
}
byComponent[c.Component] = append(byComponent[c.Component], c)
}

var rows []doctorRow
var notes []string
for _, comp := range components {
var server, editors []install.DoctorCheck
for _, c := range byComponent[comp] {
switch {
case c.Status == install.StatusInfo:
notes = append(notes, c.Name)
case c.Editor != "":
Comment on lines +295 to +303
editors = append(editors, c)
default:
server = append(server, c)
}
}
rows = append(rows, compactServerRows(comp, server)...)
label := "editors"
if comp != "scanner" {
label = comp + " editors"
}
rows = append(rows, compactEditorRows(label, editors)...)
}

width := 0
for _, r := range rows {
width = max(width, len(r.label))
}
for _, r := range rows {
_, _ = fmt.Fprintf(out, " %s %-*s %s\n", statusSymbol(r.status, accessible), width, r.label, r.text)
if r.check != nil && r.check.Remediation != "" {
printRemediation(out, r.check.Remediation)
}
}
return notes
}

func compactServerRows(label string, checks []install.DoctorCheck) []doctorRow {
if len(checks) == 0 {
return nil
}
if problems := problemRows(label, checks); len(problems) > 0 {
return problems
}
var parts []string
for _, c := range checks {
if c.Summary != "" {
parts = append(parts, c.Summary)
}
}
text := strings.Join(parts, " · ")
switch {
case text != "":
case len(checks) == 1:
text = checks[0].Detail
default:
text = fmt.Sprintf("%d checks passed", len(checks))
}
return []doctorRow{{status: install.StatusOK, label: label, text: text}}
}

// compactEditorRows lists every editor whose checks all pass on one line,
// followed by the problem checks of the rest.
func compactEditorRows(label string, checks []install.DoctorCheck) []doctorRow {
failing := make(map[string]bool)
for _, c := range checks {
if c.Status != install.StatusOK {
failing[c.Editor] = true
}
}
var healthy []string
seen := make(map[string]bool)
for _, c := range checks {
if !failing[c.Editor] && !seen[c.Editor] {
seen[c.Editor] = true
healthy = append(healthy, c.Editor)
}
}
sort.Strings(healthy)

var rows []doctorRow
if len(healthy) > 0 {
rows = append(rows, doctorRow{status: install.StatusOK, label: label, text: strings.Join(healthy, ", ")})
}
return append(rows, problemRows(label, checks)...)
}

func problemRows(label string, checks []install.DoctorCheck) []doctorRow {
var rows []doctorRow
for i := range checks {
c := &checks[i]
if c.Status == install.StatusOK {
continue
}
rows = append(rows, doctorRow{status: c.Status, label: label, text: c.Name + ": " + c.Detail, check: c})
Comment on lines +383 to +387
}
return rows
}

// printRemediation prints a check's hint under it, indenting continuation
Expand Down
70 changes: 67 additions & 3 deletions internal/cmd/mcp_doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestPrintMCPDoctorPlain(t *testing.T) {
}}

var out bytes.Buffer
printMCPDoctorPlain(&out, report, true)
printMCPDoctorPlain(&out, report, true, true)
got := out.String()

for _, want := range []string{
Expand All @@ -43,13 +43,77 @@ func TestPrintMCPDoctorPlain(t *testing.T) {
t.Errorf("columns not aligned to the longest name:\n%s", got)
}

if strings.Contains(got, "--verbose") {
t.Error("--verbose suggested in verbose output")
}

out.Reset()
printMCPDoctorPlain(&out, report, false)
printMCPDoctorPlain(&out, report, false, true)
if strings.Contains(out.String(), "--fix") {
t.Error("--fix suggested after --fix already ran")
}
}

func TestPrintMCPDoctorPlainCompact(t *testing.T) {
healthy := []install.DoctorCheck{
{Component: "scanner", Name: "plugin version", Status: install.StatusOK, Detail: "v1.2.2", Summary: "v1.2.2"},
{Component: "scanner", Name: "python venv", Status: install.StatusOK, Detail: "/venv/python"},
{Component: "scanner", Name: "tools", Status: install.StatusOK, Detail: "5 tools: a, b", Summary: "5 tools"},
{Component: "scanner", Name: "Cursor", Editor: "Cursor", Status: install.StatusOK, Detail: "/cursor/mcp.json"},
{Component: "scanner", Name: "Cursor launch", Editor: "Cursor", Status: install.StatusOK, Detail: "same launch command"},
{Component: "scanner", Name: "Antigravity", Editor: "Antigravity", Status: install.StatusOK, Detail: "/ag/mcp_config.json"},
{Component: "vscode", Name: "VS Code settings", Status: install.StatusOK, Detail: "MCP and Agent mode not disabled"},
{Component: "vscode", Name: "Copilot", Status: install.StatusInfo, Detail: "can't be verified", Remediation: "1. Switch to Agent mode"},
}

t.Run("all passing", func(t *testing.T) {
var out bytes.Buffer
printMCPDoctorPlain(&out, &install.DoctorReport{Checks: healthy}, true, false)
got := out.String()
for _, want := range []string{
"scanner v1.2.2 · 5 tools\n",
"editors Antigravity, Cursor\n",
"vscode MCP and Agent mode not disabled\n",
"7 passed, 0 warnings, 0 failed",
"notes on Copilot: armis-cli mcp doctor --verbose",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
for _, hidden := range []string{"/venv/python", "same launch command", "Agent mode\n", "--bundle"} {
if strings.Contains(got, hidden) {
t.Errorf("compact output shows %q:\n%s", hidden, got)
}
}
})

t.Run("problems expand", func(t *testing.T) {
checks := append([]install.DoctorCheck(nil), healthy...)
checks[1] = install.DoctorCheck{Component: "scanner", Name: "python venv", Status: install.StatusFail,
Detail: "missing", Remediation: "Rebuild the venv"}
checks[4] = install.DoctorCheck{Component: "scanner", Name: "Cursor launch", Editor: "Cursor", Status: install.StatusFail,
Detail: "exited", Remediation: "Check Cursor"}
var out bytes.Buffer
printMCPDoctorPlain(&out, &install.DoctorReport{Checks: checks}, true, false)
got := out.String()
for _, want := range []string{
"scanner python venv: missing\n → Rebuild the venv\n",
"editors Antigravity\n",
"editors Cursor launch: exited\n → Check Cursor\n",
"--bundle",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
// A failing component shows its problems, not its summary.
if strings.Contains(got, "v1.2.2 · 5 tools") {
t.Errorf("summary shown for a failing component:\n%s", got)
}
})
}

func stubDoctorUpdate(t *testing.T) *[]bool {
t.Helper()
var calls []bool
Expand Down Expand Up @@ -163,7 +227,7 @@ func TestRunMCPDoctorWritesBundle(t *testing.T) {
}

func TestMCPDoctorFlags(t *testing.T) {
for name, def := range map[string]string{"fix": "false", "bundle": "false", "bundle-path": "", "no-handshake": "false"} {
for name, def := range map[string]string{"fix": "false", "bundle": "false", "bundle-path": "", "no-handshake": "false", "verbose": "false"} {
f := mcpDoctorCmd.Flags().Lookup(name)
if f == nil {
t.Errorf("mcp doctor is missing --%s", name)
Expand Down
Loading
Loading