返回 DeepSeek-Reasonix
mcp_view.go
根目录 / internal / cli / mcp_view.go
1 package cli
2
3 import (
4 "fmt"
5 "sort"
6 "strings"
7 "unicode"
8
9 "github.com/charmbracelet/x/ansi"
10
11 "reasonix/internal/plugin"
12 )
13
14 const mcpMaxItemsPerSection = 6
15
16 func renderMCPStatus(width int, servers []plugin.ServerStatus, prompts []plugin.Prompt, resources []plugin.Resource, failures []plugin.Failure, views []plugin.CapabilityView) string {
17 var b strings.Builder
18 fmt.Fprintf(&b, "%s\n", viewHeader("MCP servers (%d)", len(servers)))
19 if len(views) > 0 {
20 b.WriteString(viewHeader("Capability matrix"))
21 b.WriteString("\n")
22 b.WriteString(plugin.FormatCapabilityViews(views))
23 b.WriteString("\n")
24 }
25
26 promptsByServer := map[string][]plugin.Prompt{}
27 for _, p := range prompts {
28 promptsByServer[p.Server] = append(promptsByServer[p.Server], p)
29 }
30 resourcesByServer := map[string][]plugin.Resource{}
31 for _, r := range resources {
32 resourcesByServer[r.Server] = append(resourcesByServer[r.Server], r)
33 }
34
35 seen := map[string]bool{}
36 for _, s := range servers {
37 seen[s.Name] = true
38 writeMCPServer(&b, width, s, promptsByServer[s.Name], resourcesByServer[s.Name])
39 }
40 for _, name := range extraMCPServers(seen, promptsByServer, resourcesByServer) {
41 writeMCPServer(&b, width, plugin.ServerStatus{Name: name}, promptsByServer[name], resourcesByServer[name])
42 }
43 for _, f := range failures {
44 writeMCPFailure(&b, width, f)
45 }
46 return strings.TrimRight(b.String(), "\n")
47 }
48
49 func extraMCPServers(seen map[string]bool, prompts map[string][]plugin.Prompt, resources map[string][]plugin.Resource) []string {
50 set := map[string]bool{}
51 for name := range prompts {
52 if !seen[name] {
53 set[name] = true
54 }
55 }
56 for name := range resources {
57 if !seen[name] {
58 set[name] = true
59 }
60 }
61 var names []string
62 for name := range set {
63 names = append(names, name)
64 }
65 sort.Strings(names)
66 return names
67 }
68
69 func writeMCPServer(b *strings.Builder, width int, s plugin.ServerStatus, prompts []plugin.Prompt, resources []plugin.Resource) {
70 transport := s.Transport
71 if transport == "" {
72 transport = "unknown"
73 }
74 meta := fmt.Sprintf("(%s) %s · %s · %s", transport, countText(s.Tools, "tool"), countText(len(prompts), "prompt"), countText(len(resources), "resource"))
75 if src := sanitizeExternalDisplayText(s.ConfigSource); src != "" {
76 meta += " · source=" + src
77 }
78 if protocol := sanitizeExternalDisplayText(s.ProtocolVersion); protocol != "" {
79 meta += " · protocol=" + protocol
80 }
81 if state := sanitizeExternalDisplayText(string(s.SessionState)); state != "" {
82 meta += " · session=" + state
83 }
84 if s.ReconnectAttempts > 0 {
85 meta += fmt.Sprintf(" · reconnect=%d/5", s.ReconnectAttempts)
86 }
87 if kind := sanitizeExternalDisplayText(string(s.LastErrorKind)); kind != "" {
88 meta += " · error=" + kind
89 }
90 invalidTools := invalidMCPTools(s.ToolList)
91 availableTools := validMCPTools(s.ToolList)
92 if len(invalidTools) > 0 {
93 meta += " · " + countText(len(invalidTools), "unavailable tool")
94 }
95 name := viewCompactText(sanitizeExternalDisplayText(s.Name), viewBudget(width, 4+2+1+visibleWidth(meta)))
96 fmt.Fprintf(b, " %s %s %s\n", accent("✓"), bold(name), viewMeta(meta))
97 if len(availableTools) > 0 {
98 writeMCPToolList(b, width, s, availableTools)
99 }
100 if len(prompts) > 0 {
101 writeMCPPromptList(b, width, prompts)
102 }
103 if len(resources) > 0 {
104 writeMCPResourceList(b, width, resources)
105 }
106 if len(invalidTools) > 0 {
107 b.WriteString(viewSubhead(" unavailable tools") + "\n")
108 limit := min(len(invalidTools), mcpMaxItemsPerSection)
109 for _, t := range invalidTools[:limit] {
110 writeMCPItem(b, width, " ", sanitizeExternalDisplayText(t.Name), sanitizeExternalDisplayText(t.SchemaError))
111 }
112 if extra := len(invalidTools) - limit; extra > 0 {
113 fmt.Fprintf(b, " %s\n", viewMore(extra, "unavailable tools"))
114 }
115 }
116 }
117
118 // writeMCPToolList lists valid connected tools under the server, tagging the
119 // config-plane source when known so operators can trace a tool back to its
120 // registration (#6578 / Integration D/E). Callers pass pre-filtered tools
121 // (SchemaError == "") so quarantined tools only appear under unavailable.
122 func writeMCPToolList(b *strings.Builder, width int, s plugin.ServerStatus, tools []plugin.ToolInfo) {
123 if len(tools) == 0 {
124 return
125 }
126 b.WriteString(viewSubhead(" tools") + "\n")
127 limit := min(len(tools), mcpMaxItemsPerSection)
128 src := sanitizeExternalDisplayText(s.ConfigSource)
129 for _, t := range tools[:limit] {
130 detail := sanitizeExternalDisplayText(t.Description)
131 if src != "" {
132 if detail != "" {
133 detail = detail + " · source=" + src
134 } else {
135 detail = "source=" + src
136 }
137 }
138 writeMCPItem(b, width, " ", sanitizeExternalDisplayText(t.Name), detail)
139 }
140 if extra := len(tools) - limit; extra > 0 {
141 fmt.Fprintf(b, " %s\n", viewMore(extra, "tools"))
142 }
143 }
144
145 func invalidMCPTools(tools []plugin.ToolInfo) []plugin.ToolInfo {
146 out := make([]plugin.ToolInfo, 0)
147 for _, t := range tools {
148 if t.SchemaError != "" {
149 out = append(out, t)
150 }
151 }
152 return out
153 }
154
155 func validMCPTools(tools []plugin.ToolInfo) []plugin.ToolInfo {
156 out := make([]plugin.ToolInfo, 0, len(tools))
157 for _, t := range tools {
158 if t.SchemaError == "" {
159 out = append(out, t)
160 }
161 }
162 return out
163 }
164
165 func writeMCPFailure(b *strings.Builder, width int, f plugin.Failure) {
166 transport := f.Transport
167 if transport == "" {
168 transport = "unknown"
169 }
170 meta := fmt.Sprintf("(%s) %s", transport, sanitizeExternalDisplayText(f.Error))
171 name := viewCompactText(sanitizeExternalDisplayText(f.Name), viewBudget(width, 4+2+1+visibleWidth(meta)))
172 fmt.Fprintf(b, " %s %s %s\n", yellow("!"), bold(name), viewMeta(viewCompactText(meta, viewBudget(width, 10+visibleWidth(name)))))
173 }
174
175 func writeMCPPromptList(b *strings.Builder, width int, prompts []plugin.Prompt) {
176 b.WriteString(viewSubhead(" prompts") + "\n")
177 limit := min(len(prompts), mcpMaxItemsPerSection)
178 for _, p := range prompts[:limit] {
179 writeMCPItem(b, width, " ", "/"+sanitizeExternalDisplayText(p.Name), sanitizeExternalDisplayText(p.Description))
180 }
181 if extra := len(prompts) - limit; extra > 0 {
182 fmt.Fprintf(b, " %s\n", viewMore(extra, "prompts"))
183 }
184 }
185
186 func writeMCPResourceList(b *strings.Builder, width int, resources []plugin.Resource) {
187 b.WriteString(viewSubhead(" resources") + "\n")
188 limit := min(len(resources), mcpMaxItemsPerSection)
189 for _, r := range resources[:limit] {
190 label := sanitizeExternalDisplayText(r.Name)
191 if label == "" {
192 label = sanitizeExternalDisplayText(r.Description)
193 }
194 if r.MimeType != "" {
195 mime := sanitizeExternalDisplayText(r.MimeType)
196 if label == "" {
197 label = mime
198 } else {
199 label += " [" + mime + "]"
200 }
201 }
202 writeMCPItem(b, width, " ", "@"+sanitizeExternalDisplayText(r.Server)+":"+sanitizeExternalDisplayText(r.URI), label)
203 }
204 if extra := len(resources) - limit; extra > 0 {
205 fmt.Fprintf(b, " %s\n", viewMore(extra, "resources"))
206 }
207 }
208
209 func writeMCPItem(b *strings.Builder, width int, indent, ref, desc string) {
210 desc = sanitizeExternalDisplayText(desc)
211 ref = sanitizeExternalDisplayText(ref)
212 available := viewBudget(width, visibleWidth(indent))
213 if desc == "" || available < 30 {
214 b.WriteString(indent + compactMiddle(ref, available))
215 b.WriteByte('\n')
216 return
217 }
218 descBudget := min(40, max(12, available/2))
219 refBudget := available - 2 - descBudget
220 if refBudget < 16 {
221 refBudget = min(16, available)
222 descBudget = available - refBudget - 2
223 }
224 line := indent + compactMiddle(ref, refBudget)
225 if descBudget >= 12 {
226 line += " " + viewMeta(viewCompactText(desc, descBudget))
227 }
228 b.WriteString(line)
229 b.WriteByte('\n')
230 }
231
232 // sanitizeExternalDisplayText strips ANSI/OSC/C0 control sequences from text
233 // that originated outside Reasonix (MCP tool/prompt/resource fields, source
234 // labels, failure messages) before it is rendered into the TUI. TrimSpace and
235 // Fields alone leave CSI sequences intact and would let a malicious MCP rewrite
236 // the terminal, spoof chrome, or poke the clipboard.
237 func sanitizeExternalDisplayText(s string) string {
238 s = ansi.Strip(s)
239 var b strings.Builder
240 b.Grow(len(s))
241 for _, r := range s {
242 switch {
243 case r == '\t' || r == '\n' || r == '\r':
244 b.WriteByte(' ')
245 case r < 0x20 || r == 0x7f:
246 // Drop remaining C0 controls and DEL.
247 case r >= 0x80 && r <= 0x9f:
248 // Drop C1 controls (including after partial decode).
249 case unicode.Is(unicode.Cc, r):
250 // Other control categories.
251 default:
252 b.WriteRune(r)
253 }
254 }
255 return strings.Join(strings.Fields(b.String()), " ")
256 }
257
258 func oneLineText(s string) string {
259 return sanitizeExternalDisplayText(s)
260 }
261
262 func countText(n int, noun string) string {
263 if n == 1 {
264 return "1 " + noun
265 }
266 return fmt.Sprintf("%d %ss", n, noun)
267 }
268
269 func compactEnd(s string, maxWidth int) string {
270 if maxWidth <= 0 || visibleWidth(s) <= maxWidth {
271 return s
272 }
273 if maxWidth <= 1 {
274 return "…"
275 }
276 var out strings.Builder
277 for _, r := range s {
278 next := out.String() + string(r)
279 if visibleWidth(next)+1 > maxWidth {
280 break
281 }
282 out.WriteRune(r)
283 }
284 return out.String() + "…"
285 }
286
287 func compactMiddle(s string, maxWidth int) string {
288 if maxWidth <= 0 || visibleWidth(s) <= maxWidth {
289 return s
290 }
291 if maxWidth <= 3 {
292 return compactEnd(s, maxWidth)
293 }
294 keep := maxWidth - 1
295 leftWidth := keep / 2
296 rightWidth := keep - leftWidth
297 left := takeLeftWidth(s, leftWidth)
298 right := takeRightWidth(s, rightWidth)
299 return left + "…" + right
300 }
301
302 func takeLeftWidth(s string, maxWidth int) string {
303 var out strings.Builder
304 for _, r := range s {
305 next := out.String() + string(r)
306 if visibleWidth(next) > maxWidth {
307 break
308 }
309 out.WriteRune(r)
310 }
311 return out.String()
312 }
313
314 func takeRightWidth(s string, maxWidth int) string {
315 var out []rune
316 width := 0
317 for _, r := range reverseRunes([]rune(s)) {
318 w := visibleWidth(string(r))
319 if width+w > maxWidth {
320 break
321 }
322 out = append(out, r)
323 width += w
324 }
325 for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
326 out[i], out[j] = out[j], out[i]
327 }
328 return string(out)
329 }
330
331 func reverseRunes(in []rune) []rune {
332 out := append([]rune(nil), in...)
333 for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
334 out[i], out[j] = out[j], out[i]
335 }
336 return out
337 }
338
338 lines GO