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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,44 @@ armis-cli scan status --format json

`scan status` reports every state the API can return: `PENDING_UPLOAD`, `UPLOADED`, `INITIATED`, `IN_PROGRESS`, `COMPLETED`, `FAILED`, `STOPPED`.

### MCP Server for AI Editors

`armis-cli install` sets up the Armis AppSec MCP server and registers it with the AI editors it detects (VS Code / GitHub Copilot, Cursor, Claude Code, Windsurf, Codex CLI, and others).

```bash
armis-cli install # install and register with detected editors
armis-cli mcp update # update the server and re-register editors
armis-cli mcp doctor # diagnose why an editor doesn't see or can't use the server
```

#### Troubleshooting with `mcp doctor`

`mcp doctor` checks the whole path from install to a working tool call, and prints a `→` line with the fix under every check that fails:

- plugin files, the Python venv (including a base Python that was uninstalled or upgraded), and credentials
- every editor config: the entry still exists, its command and envFile exist, and the server starts **exactly as that editor launches it** and answers `initialize`, `tools/list`, and a diagnostic tool call
- that the credentials are accepted, and that the server's own Python runtime can reach the Armis API (proxy and TLS-inspection problems often affect it and not the CLI)
- VS Code / Copilot: VS Code Insiders and VSCodium, per-profile and workspace configs, duplicate or stale entries, settings and Windows Group Policy that disable MCP or Agent mode, and the last error in VS Code's own MCP log for the server

```bash
# Diagnose, then repair what can be repaired automatically: re-register
# editors, rebuild a broken venv, and configure the system proxy for the
# server when it works but the server isn't using it
armis-cli mcp doctor --fix

# Write a zip for Armis support (report, server stderr and logs, VS Code MCP
# log, settings excerpts). Credential values are never included.
armis-cli mcp doctor --bundle
armis-cli mcp doctor --bundle-path C:\Users\me\Desktop\armis-doctor.zip

# Structural checks only (don't start servers or make network calls)
armis-cli mcp doctor --no-handshake
```

Run it from your project folder to include that workspace's `.vscode/mcp.json` and `.vscode/settings.json`. If every check passes but Copilot still doesn't call the tools, the report ends with the remaining manual checks: Agent mode, the tools picker, **MCP: List Servers → Start Server**, and your GitHub organization's "MCP servers in Copilot" policy.

The server reads extra environment variables from `~/.armis/plugins/armis-appsec-mcp/.env`. Behind a corporate proxy or TLS inspection, `HTTPS_PROXY` and `SSL_CERT_FILE` there apply to the server only; restart the editor after changing them.

### Other Commands

```bash
Expand Down
10 changes: 10 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `mcp doctor` now runs a full MCP session (initialize, tools/list, a diagnostic tool call) using each editor's own config entry, checks credentials against the API, and checks that the server's Python runtime can reach the Armis API through the local proxy and TLS setup. Every failing check prints a remediation.
- `mcp doctor` VS Code / Copilot checks: VS Code Insiders and VSCodium, profile and workspace configs, duplicate and stale entries, `chat.mcp.*` / `chat.agent.enabled` settings, Windows Group Policy (ChatMCP, ChatAgentMode), and the last error from VS Code's MCP server log.
- `mcp doctor` Windows diagnostics: a venv whose base Python was removed, antivirus or AppLocker blocking the interpreter, slow starts, and output on stdout that corrupts the MCP stream.
- `mcp doctor --fix`: re-registers editors, rebuilds a broken venv, and writes the system proxy to the server's `.env` when the doctor has verified it works.
- `mcp doctor --bundle` / `--bundle-path`: writes a support zip with the report, server stderr and logs, and VS Code's MCP log. Credential values are excluded and scrubbed.

### Changed

- Writing credentials to the MCP server's `.env` (interactive `install`) now keeps other variables in the file, such as `HTTPS_PROXY` and `SSL_CERT_FILE`.

### Deprecated

### Removed

### Fixed

- `install` and `mcp doctor` parse VS Code's `mcp.json` / `settings.json` as JSONC. A file with comments, trailing commas, or a UTF-8 BOM was treated as empty, so `install` dropped the user's other MCP servers and `mcp doctor` reported the file as invalid.

### Security

---
Expand Down
10 changes: 8 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ func (p *AuthProvider) Expiry() time.Time {
// If ClientID and ClientSecret are set, uses JWT auth with the specified base URL.
// Otherwise falls back to legacy Basic auth with Token.
func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {
return NewAuthProviderWithContext(context.Background(), config)
}

// NewAuthProviderWithContext creates an AuthProvider from configuration,
// using ctx for the initial JWT token exchange so callers can bound or
// cancel it. Otherwise behaves like NewAuthProvider.
func NewAuthProviderWithContext(ctx context.Context, config AuthConfig) (*AuthProvider, error) {
p := &AuthProvider{
config: config,
}
Expand All @@ -169,8 +176,7 @@ func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {
}
p.authClient = authClient

// Initial token exchange (use background context for initialization)
if err := p.exchangeCredentials(context.Background()); err != nil {
if err := p.exchangeCredentials(ctx); err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
} else if config.Token != "" {
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ var mcpCmd = &cobra.Command{

Use 'armis-cli mcp doctor' to check the scanner and knowledge MCP servers:
plugin files, credentials, editor registrations, and a live handshake.
'armis-cli mcp doctor --fix' repairs what it can; '--bundle' writes a support
bundle.

Use 'armis-cli mcp update' to update them to the latest version.`,
}
Expand Down
195 changes: 177 additions & 18 deletions internal/cmd/mcp_doctor.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"

"github.com/ArmisSecurity/armis-cli/internal/auth"
"github.com/ArmisSecurity/armis-cli/internal/cli"
"github.com/ArmisSecurity/armis-cli/internal/cmd/cmdutil"
"github.com/ArmisSecurity/armis-cli/internal/install"
Expand All @@ -16,23 +20,48 @@ var (
mcpDoctorFormat string
mcpDoctorNoHandshake bool
mcpDoctorTimeout time.Duration
mcpDoctorFix bool
mcpDoctorBundle bool
mcpDoctorBundlePath string
)

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.
knowledge MCP servers' plugin files, venv, and credentials; whether each
registered editor's config still contains the entry; Claude Code's plugin
registry; and Codex CLI's config.toml.

Unless --no-handshake is set, it also:
- launches each server exactly as each editor's config says and runs a live
MCP session (initialize, tools/list, and a diagnostic tool call)
- checks that the client credentials are accepted by the Armis API
- checks that the server's own Python runtime can reach the Armis API,
which catches proxy and TLS-inspection problems the CLI itself doesn't hit

For VS Code (GitHub Copilot) it additionally checks VS Code Insiders and
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.

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.
--bundle writes a zip with the full diagnostics, credentials removed, to send
to support.

Exits non-zero if any check fails.`,
Example: ` # Full diagnostic, including live handshake
armis-cli mcp doctor

# Structural checks only, skip spawning the servers
# Diagnose and repair what can be repaired automatically
armis-cli mcp doctor --fix

# Write a support bundle (armis-mcp-doctor-<timestamp>.zip)
armis-cli mcp doctor --bundle

# Structural checks only, skip spawning servers and network checks
armis-cli mcp doctor --no-handshake

# Machine-readable output
Expand All @@ -44,8 +73,11 @@ Exits non-zero if any check fails.`,
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")
mcpDoctorCmd.Flags().BoolVar(&mcpDoctorNoHandshake, "no-handshake", false, "Skip spawning MCP servers and the network checks")
mcpDoctorCmd.Flags().DurationVar(&mcpDoctorTimeout, "timeout", install.DefaultHandshakeTimeout, "Timeout for each live handshake")
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)")
}

func runMCPDoctor(cmd *cobra.Command, _ []string) error {
Expand All @@ -55,18 +87,47 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("invalid --format value %q: must be plain or json", mcpDoctorFormat)
}

report := install.RunDoctor(install.DoctorOptions{
opts := install.DoctorOptions{
Handshake: !mcpDoctorNoHandshake,
Timeout: mcpDoctorTimeout,
})
AuthCheck: doctorAuthCheck,
}
stderr := cmd.ErrOrStderr()

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

if mcpDoctorFix {
fixed, err := applyDoctorFixes(stderr, report)
if err != nil {
return err
}
if fixed {
_, _ = fmt.Fprintln(stderr, "\nRe-running checks...")
report = install.RunDoctor(opts)
if mcpDoctorFormat == agentFormatPlain {
printMCPDoctorPlain(stderr, report, false)
}
}
}

if mcpDoctorFormat == agentFormatJSON {
if err := printMCPDoctorJSON(cmd, report); err != nil {
return err
}
default:
printMCPDoctorPlain(cmd, report)
}

if mcpDoctorBundle || mcpDoctorBundlePath != "" {
path := mcpDoctorBundlePath
if path == "" {
path = install.DefaultBundleName()
}
if err := install.WriteSupportBundle(report, path, version); err != nil {
return fmt.Errorf("writing support bundle: %w", err)
}
_, _ = fmt.Fprintf(stderr, "\nSupport bundle written to %s — attach it to your support request. It contains no credentials.\n", path)
}

if report.HasFailures() {
Expand All @@ -75,28 +136,122 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error {
return nil
}

// doctorAuthCheck exchanges client credentials for a token against the same
// API base URL the rest of the CLI uses.
func doctorAuthCheck(ctx context.Context, id, secret string) error {
_, err := auth.NewAuthProviderWithContext(ctx, auth.AuthConfig{
ClientID: id,
ClientSecret: secret,
BaseURL: getAPIBaseURL(),
Region: region,
})
return err
}

// applyDoctorFixes performs the repairs the report calls for and reports
// whether anything was attempted.
func applyDoctorFixes(out io.Writer, report *install.DoctorReport) (bool, error) {
for _, c := range report.Checks {
if c.Component == install.ComponentInstall && c.Name == "manifest" && c.Status == install.StatusFail {
_, _ = fmt.Fprintln(out, "\nNothing is installed yet, so there is nothing to repair. Run: armis-cli install")
return false, nil
}
}

fixes := report.Fixes()
if report.HasBlockedRegistration() {
_, _ = fmt.Fprintln(out, "\nSkipping editor re-registration: at least one editor's config file couldn't be parsed, and rewriting it would drop the other servers configured there. Fix the syntax (see the hint above), then re-run --fix.")
}
if len(fixes) == 0 {
if report.HasProblems() {
_, _ = fmt.Fprintln(out, "\nNone of the remaining problems can be fixed automatically — follow the → hints above.")
}
return false, nil
}

for _, f := range fixes {
switch f {
case install.FixSetProxy:
desc, err := report.ApplyEnvFix()
if err != nil {
return true, fmt.Errorf("repair failed: %w", err)
}
_, _ = fmt.Fprintf(out, "\nConfigured the MCP server's proxy: %s\nRestart VS Code (or your editor) so the server picks it up.\n", desc)
case install.FixReinstall, install.FixReregister:
force := f == install.FixReinstall
if force {
_, _ = fmt.Fprintln(out, "\nReinstalling the MCP server and re-registering editors...")
} else {
_, _ = fmt.Fprintln(out, "\nRe-registering editors...")
}
if err := mcpDoctorUpdate(force, false); err != nil {
return true, fmt.Errorf("repair failed: %w", err)
}
}
Comment thread
yiftach-armis marked this conversation as resolved.
}
return true, nil
}

// mcpDoctorUpdate is performMCPUpdate, swappable in tests.
var mcpDoctorUpdate = performMCPUpdate

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

func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix 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 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 %-20s %s\n", statusSymbol(c.Status, accessible), c.Name, c.Detail)
_, _ = 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++
case install.StatusWarn:
warned++
case install.StatusFail:
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.")
}
}

// printRemediation prints a check's hint under it, indenting continuation
// lines so multi-line hints (steps, JSON snippets) stay readable.
func printRemediation(out io.Writer, remediation string) {
lines := strings.Split(strings.TrimRight(remediation, "\n"), "\n")
for i, line := range lines {
prefix := " "
if i == 0 {
prefix = " → "
}
_, _ = fmt.Fprintln(out, prefix+line)
}
}

Expand All @@ -111,6 +266,8 @@ func statusSymbol(s install.CheckStatus, accessible bool) string {
return "[OK]"
case install.StatusWarn:
return "[WARN]"
case install.StatusInfo:
return "[INFO]"
default:
return "[FAIL]"
}
Expand All @@ -120,6 +277,8 @@ func statusSymbol(s install.CheckStatus, accessible bool) string {
return lipgloss.NewStyle().Foreground(cmdutil.BrandSuccess).Render("✓")
case install.StatusWarn:
return lipgloss.NewStyle().Foreground(cmdutil.BrandWarn).Render("⚠")
case install.StatusInfo:
return "ℹ"
default:
return lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗")
}
Expand Down
Loading
Loading