Skip to content

Commit 31b9c04

Browse files
committed
feat: add mcphub doctor - MCP server health check and diagnostics
New command: `mcphub doctor` diagnoses all installed MCP servers and client configurations. Checks performed: - Client config validation (JSON syntax, missing command/url fields) - Stdio server: spawn process, send MCP initialize, verify handshake - Remote server: HTTP reachability check - Runtime dependency detection (node/python/docker) - Environment variable validation Features: - Color-coded output (✓ OK, ! warning, ✗ error) - Actionable fix suggestions for every issue - --server flag to check a single server - --json flag for machine-readable output - Also available as MCP tool (doctor) in mcphub-mcp Tests: 13 new tests covering remote checks, stdio checks, config validation, command suggestions, and mock MCP server handshake.
1 parent 9afd332 commit 31b9c04

5 files changed

Lines changed: 946 additions & 0 deletions

File tree

internal/cli/doctor.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"time"
7+
8+
"github.com/Ricardo-M-L/mcphub/internal/config"
9+
"github.com/Ricardo-M-L/mcphub/internal/health"
10+
"github.com/Ricardo-M-L/mcphub/internal/store"
11+
"github.com/Ricardo-M-L/mcphub/internal/ui"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var doctorJSON bool
16+
var doctorServer string
17+
18+
var doctorCmd = &cobra.Command{
19+
Use: "doctor",
20+
Short: "Diagnose MCP server health and configuration issues",
21+
Long: `Check all installed MCP servers and client configurations for problems.
22+
23+
Performs:
24+
- Client config validation (JSON syntax, missing fields)
25+
- Server connectivity tests (spawn + MCP handshake for stdio, HTTP for remote)
26+
- Environment variable checks
27+
- Runtime dependency checks (node, python, docker)
28+
29+
Examples:
30+
mcphub doctor # Check everything
31+
mcphub doctor --server xxx # Check a specific server
32+
mcphub doctor --json # JSON output`,
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
start := time.Now()
35+
36+
if !doctorJSON {
37+
fmt.Println()
38+
fmt.Printf(" %s\n\n", ui.Bold("MCP Hub Doctor"))
39+
}
40+
41+
var allReports []*health.Report
42+
var allConfigIssues []health.Issue
43+
44+
// 1. Check client configurations
45+
if !doctorJSON {
46+
fmt.Printf(" %s Checking client configurations...\n", ui.Cyan("i"))
47+
}
48+
49+
clients := config.KnownClients()
50+
for _, c := range clients {
51+
issues := health.CheckClientConfig(c.Name, c.Path, c.Key)
52+
if len(issues) > 0 {
53+
allConfigIssues = append(allConfigIssues, issues...)
54+
}
55+
if !doctorJSON {
56+
hasError := false
57+
for _, issue := range issues {
58+
if issue.Severity == health.StatusError {
59+
hasError = true
60+
}
61+
}
62+
if hasError {
63+
fmt.Printf(" %s %s\n", ui.Red("✗"), c.Name)
64+
for _, issue := range issues {
65+
printIssue(issue)
66+
}
67+
} else {
68+
fmt.Printf(" %s %s\n", ui.Green("✓"), c.Name)
69+
}
70+
}
71+
}
72+
73+
if !doctorJSON {
74+
fmt.Println()
75+
}
76+
77+
// 2. Check installed servers
78+
lf, err := store.Load()
79+
if err != nil {
80+
return fmt.Errorf("failed to load lockfile: %w", err)
81+
}
82+
83+
if len(lf.Packages) == 0 {
84+
if !doctorJSON {
85+
fmt.Printf(" %s No MCP servers installed. Run 'mcphub search' to find servers.\n\n", ui.Dim("i"))
86+
}
87+
return nil
88+
}
89+
90+
if !doctorJSON {
91+
fmt.Printf(" %s Checking %d installed server(s)...\n\n", ui.Cyan("i"), len(lf.Packages))
92+
}
93+
94+
for name, pkg := range lf.Packages {
95+
// Skip if --server flag is set and doesn't match
96+
if doctorServer != "" && name != doctorServer && pkg.Name != doctorServer {
97+
continue
98+
}
99+
100+
var report *health.Report
101+
102+
if pkg.Transport.Type == "streamable-http" || pkg.Transport.Type == "sse" {
103+
// Remote server — HTTP check
104+
report = health.CheckRemoteServer(name, pkg.Transport.URL)
105+
} else {
106+
// Stdio server — spawn and test
107+
command := ""
108+
var args []string
109+
110+
// Reconstruct command from what we know
111+
switch pkg.RuntimeHint {
112+
case "npx":
113+
command = "npx"
114+
args = []string{"-y", pkg.Identifier}
115+
case "uvx":
116+
command = "uvx"
117+
args = []string{pkg.Identifier}
118+
default:
119+
if pkg.Identifier != "" {
120+
command = "npx"
121+
args = []string{"-y", pkg.Identifier}
122+
} else {
123+
report = &health.Report{
124+
Name: name,
125+
Status: health.StatusWarning,
126+
Transport: "stdio",
127+
Issues: []health.Issue{{
128+
Severity: health.StatusWarning,
129+
Message: "Cannot determine how to start this server",
130+
Suggestion: "Re-install with: mcphub install " + name,
131+
}},
132+
}
133+
}
134+
}
135+
136+
if report == nil {
137+
report = health.CheckStdioServer(name, command, args, pkg.EnvVars)
138+
}
139+
}
140+
141+
allReports = append(allReports, report)
142+
143+
if !doctorJSON {
144+
printReport(report)
145+
}
146+
}
147+
148+
// JSON output
149+
if doctorJSON {
150+
output := map[string]interface{}{
151+
"configIssues": allConfigIssues,
152+
"servers": allReports,
153+
"duration": time.Since(start).String(),
154+
}
155+
data, _ := json.MarshalIndent(output, "", " ")
156+
fmt.Println(string(data))
157+
return nil
158+
}
159+
160+
// Summary
161+
fmt.Println()
162+
okCount := 0
163+
warnCount := 0
164+
errCount := 0
165+
for _, r := range allReports {
166+
switch r.Status {
167+
case health.StatusOK:
168+
okCount++
169+
case health.StatusWarning:
170+
warnCount++
171+
case health.StatusError:
172+
errCount++
173+
}
174+
}
175+
176+
fmt.Printf(" %s %s healthy", ui.Bold("Summary:"), ui.Green(fmt.Sprintf("%d", okCount)))
177+
if warnCount > 0 {
178+
fmt.Printf(", %s warnings", ui.Yellow(fmt.Sprintf("%d", warnCount)))
179+
}
180+
if errCount > 0 {
181+
fmt.Printf(", %s errors", ui.Red(fmt.Sprintf("%d", errCount)))
182+
}
183+
fmt.Printf(" (%s)\n\n", time.Since(start).Round(time.Millisecond))
184+
185+
return nil
186+
},
187+
}
188+
189+
func printReport(r *health.Report) {
190+
var icon string
191+
switch r.Status {
192+
case health.StatusOK:
193+
icon = ui.Green("✓")
194+
case health.StatusWarning:
195+
icon = ui.Yellow("!")
196+
case health.StatusError:
197+
icon = ui.Red("✗")
198+
}
199+
200+
fmt.Printf(" %s %s %s (%s)\n", icon, ui.Bold(r.Name), ui.Dim("["+r.Transport+"]"), r.Duration.Round(time.Millisecond))
201+
202+
for _, issue := range r.Issues {
203+
printIssue(issue)
204+
}
205+
fmt.Println()
206+
}
207+
208+
func printIssue(issue health.Issue) {
209+
switch issue.Severity {
210+
case health.StatusOK:
211+
fmt.Printf(" %s %s\n", ui.Green("✓"), issue.Message)
212+
case health.StatusWarning:
213+
fmt.Printf(" %s %s\n", ui.Yellow("!"), issue.Message)
214+
if issue.Suggestion != "" {
215+
fmt.Printf(" %s %s\n", ui.Dim("→"), issue.Suggestion)
216+
}
217+
case health.StatusError:
218+
fmt.Printf(" %s %s\n", ui.Red("✗"), issue.Message)
219+
if issue.Suggestion != "" {
220+
fmt.Printf(" %s %s\n", ui.Dim("→"), issue.Suggestion)
221+
}
222+
}
223+
}
224+
225+
func init() {
226+
doctorCmd.Flags().BoolVar(&doctorJSON, "json", false, "Output as JSON")
227+
doctorCmd.Flags().StringVar(&doctorServer, "server", "", "Check a specific server only")
228+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func init() {
2929
rootCmd.AddCommand(infoCmd)
3030
rootCmd.AddCommand(publishCmd)
3131
rootCmd.AddCommand(initCmd)
32+
rootCmd.AddCommand(doctorCmd)
3233
rootCmd.AddCommand(versionCmd)
3334
}
3435

0 commit comments

Comments
 (0)