| 1 | package capdiag |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os/exec" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/boot" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/plugin" |
| 13 | ) |
| 14 | |
| 15 | func lookPath(cmd string) (string, error) { |
| 16 | return exec.LookPath(cmd) |
| 17 | } |
| 18 | |
| 19 | // probeLiveMCP starts automatic-intent servers in an isolated Host, records |
| 20 | // connection results, and always closes the Host (including stdio children). |
| 21 | // Persistence (startup stats / schema cache) is disabled so --live stays |
| 22 | // free of cache/state side effects under Reasonix home. |
| 23 | func probeLiveMCP(rep *MCPReport, cfg *config.Config, root, home, reasonixHome string, timeout time.Duration) []Issue { |
| 24 | var issues []Issue |
| 25 | if cfg == nil { |
| 26 | return issues |
| 27 | } |
| 28 | if timeout <= 0 { |
| 29 | timeout = DefaultLiveTimeout |
| 30 | } |
| 31 | if timeout < MinLiveTimeout { |
| 32 | timeout = MinLiveTimeout |
| 33 | } |
| 34 | if timeout > MaxLiveTimeout { |
| 35 | timeout = MaxLiveTimeout |
| 36 | } |
| 37 | |
| 38 | // Only probe automatic start intent servers. |
| 39 | var auto []config.PluginEntry |
| 40 | for _, p := range cfg.Plugins { |
| 41 | if p.ShouldAutoStart() { |
| 42 | auto = append(auto, p) |
| 43 | } else { |
| 44 | for i := range rep.Servers { |
| 45 | if rep.Servers[i].Name == p.Name { |
| 46 | rep.Servers[i].RuntimeStatus = "skipped" |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | if len(auto) == 0 { |
| 52 | return issues |
| 53 | } |
| 54 | |
| 55 | specs := boot.PluginSpecsForRoot(auto, root) |
| 56 | ctx, cancel := context.WithTimeout(context.Background(), timeout*time.Duration(len(specs))+timeout) |
| 57 | defer cancel() |
| 58 | |
| 59 | host, _, _ := plugin.Start(ctx, specs, plugin.StartPolicy{ |
| 60 | PerPluginTimeout: timeout, |
| 61 | Concurrency: 4, |
| 62 | AbortOnError: false, |
| 63 | SkipPersistence: true, |
| 64 | }) |
| 65 | if host == nil { |
| 66 | return issues |
| 67 | } |
| 68 | defer host.Close() |
| 69 | |
| 70 | byName := map[string]int{} |
| 71 | for i, s := range rep.Servers { |
| 72 | byName[s.Name] = i |
| 73 | } |
| 74 | |
| 75 | connected := map[string]bool{} |
| 76 | for _, s := range host.Servers() { |
| 77 | connected[s.Name] = true |
| 78 | tools := make([]MCPToolInfo, 0, len(s.ToolList)) |
| 79 | for _, t := range s.ToolList { |
| 80 | tools = append(tools, MCPToolInfo{Name: t.Name, ReadOnlyHint: t.ReadOnlyHint, DestructiveHint: t.DestructiveHint}) |
| 81 | } |
| 82 | if i, ok := byName[s.Name]; ok { |
| 83 | rep.Servers[i].RuntimeStatus = "probed" |
| 84 | rep.Servers[i].ToolCount = s.Tools |
| 85 | rep.Servers[i].Tools = tools |
| 86 | } else { |
| 87 | rep.Servers = append(rep.Servers, MCPServerInfo{ |
| 88 | Name: s.Name, Transport: s.Transport, RuntimeStatus: "probed", |
| 89 | ToolCount: s.Tools, Tools: tools, StartIntent: "automatic", |
| 90 | }) |
| 91 | } |
| 92 | if s.HasTools && s.Tools == 0 { |
| 93 | issues = append(issues, Issue{ |
| 94 | Severity: "warning", Code: "mcp.no_tools", Subsystem: "mcp", |
| 95 | Name: s.Name, Message: "live probe: MCP server connected but exposes no tools", |
| 96 | Remediation: "Check server configuration and authentication", |
| 97 | SettingsTab: "mcp", |
| 98 | }) |
| 99 | } |
| 100 | } |
| 101 | for _, f := range host.Failures() { |
| 102 | errText := sanitizeErrTextWithPaths(f.Error, root, home, reasonixHome) |
| 103 | if i, ok := byName[f.Name]; ok { |
| 104 | rep.Servers[i].RuntimeStatus = "failed" |
| 105 | rep.Servers[i].Error = errText |
| 106 | rep.Servers[i].StartupStage = f.Stage |
| 107 | rep.Servers[i].StartupElapsedMS = f.Elapsed.Milliseconds() |
| 108 | rep.Servers[i].Stderr = sanitizeErrTextWithPaths(f.Stderr, root, home, reasonixHome) |
| 109 | } |
| 110 | issues = append(issues, Issue{ |
| 111 | Severity: "error", Code: "mcp.start_failed", Subsystem: "mcp", |
| 112 | Name: f.Name, Message: "live probe failed: " + errText, |
| 113 | Remediation: "Fix command/URL/auth; re-run with --live after changes", |
| 114 | SettingsTab: "mcp", |
| 115 | }) |
| 116 | } |
| 117 | for _, p := range auto { |
| 118 | if connected[p.Name] { |
| 119 | continue |
| 120 | } |
| 121 | if i, ok := byName[p.Name]; ok { |
| 122 | if rep.Servers[i].RuntimeStatus == "failed" { |
| 123 | continue |
| 124 | } |
| 125 | if rep.Servers[i].RuntimeStatus == "" { |
| 126 | rep.Servers[i].RuntimeStatus = "failed" |
| 127 | rep.Servers[i].Error = "no connection result within timeout" |
| 128 | issues = append(issues, Issue{ |
| 129 | Severity: "error", Code: "mcp.start_failed", Subsystem: "mcp", |
| 130 | Name: p.Name, Message: fmt.Sprintf("live probe: no result within %s", timeout), |
| 131 | Remediation: "Increase --timeout or fix a hanging MCP server", |
| 132 | SettingsTab: "mcp", |
| 133 | }) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | return issues |
| 138 | } |
| 139 | |
| 140 | // HasErrorSeverity reports whether the report contains any error-level issue. |
| 141 | func HasErrorSeverity(r Report) bool { |
| 142 | for _, is := range r.Issues { |
| 143 | if is.Severity == "error" { |
| 144 | return true |
| 145 | } |
| 146 | } |
| 147 | return false |
| 148 | } |
| 149 | |
| 150 | // LiveWarningMessage is printed to stderr before CLI --live starts MCP. |
| 151 | func LiveWarningMessage() string { |
| 152 | return strings.TrimSpace(`warning: --live will start third-party MCP servers in an isolated process. |
| 153 | They may access the network and receive configured environment variables and headers. |
| 154 | Tools are not registered into the agent registry. Startup stats/schema cache writes are disabled. |
| 155 | Host is always closed after the probe.`) |
| 156 | } |
| 157 |