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