1pub mod traffic;
7pub use traffic::RequestResponseRenderer;
8
9use fastmcp_protocol::{ClientCapabilities, ClientInfo};
10use rich_rust::r#box::ROUNDED;
11use rich_rust::prelude::*;
12
13use crate::console::FastMcpConsole;
14use crate::detection::DisplayContext;
15use crate::theme::FastMcpTheme;
16
17#[derive(Debug, Clone)]
22pub struct ClientInfoRenderer {
23 theme: &'static FastMcpTheme,
24 context: DisplayContext,
25}
26
27impl ClientInfoRenderer {
28 #[must_use]
30 pub fn new(context: DisplayContext) -> Self {
31 Self {
32 theme: crate::theme::theme(),
33 context,
34 }
35 }
36
37 #[must_use]
39 pub fn detect() -> Self {
40 Self::new(DisplayContext::detect())
41 }
42
43 pub fn render_connected(&self, client: &ClientInfo, console: &FastMcpConsole) {
45 if !self.should_use_rich(console) {
46 self.render_connected_plain(client, console);
47 return;
48 }
49
50 console.print(&format!(
51 "\n[green bold]Client Connected[/]: [cyan]{}[/] [dim]v{}[/]",
52 client.name, client.version
53 ));
54 }
55
56 pub fn render_connected_with_caps(
58 &self,
59 client: &ClientInfo,
60 capabilities: &ClientCapabilities,
61 console: &FastMcpConsole,
62 ) {
63 if !self.should_use_rich(console) {
64 self.render_connected_with_caps_plain(client, capabilities, console);
65 return;
66 }
67
68 console.print(&format!(
69 "\n[green bold]Client Connected[/]: [cyan]{}[/] [dim]v{}[/]",
70 client.name, client.version
71 ));
72
73 let caps = self.format_capabilities(capabilities);
74 if !caps.is_empty() {
75 console.print(&format!(" [dim]Capabilities:[/] {}", caps));
76 }
77 }
78
79 pub fn render_disconnected(&self, client: &ClientInfo, console: &FastMcpConsole) {
81 if !self.should_use_rich(console) {
82 self.render_disconnected_plain(client, None, console);
83 return;
84 }
85
86 console.print(&format!(
87 "[yellow]Client Disconnected[/]: [cyan]{}[/]",
88 client.name
89 ));
90 }
91
92 pub fn render_disconnected_with_reason(
94 &self,
95 client: &ClientInfo,
96 reason: &str,
97 console: &FastMcpConsole,
98 ) {
99 if !self.should_use_rich(console) {
100 self.render_disconnected_plain(client, Some(reason), console);
101 return;
102 }
103
104 console.print(&format!(
105 "[yellow]Client Disconnected[/]: [cyan]{}[/] [dim]({})[/]",
106 client.name, reason
107 ));
108 }
109
110 pub fn render_detail(&self, client: &ClientInfo, console: &FastMcpConsole) {
112 if !self.should_use_rich(console) {
113 self.render_detail_plain(client, console);
114 return;
115 }
116
117 let mut table = Table::new()
118 .title("Connected Client")
119 .title_style(self.theme.header_style.clone())
120 .box_style(&ROUNDED)
121 .border_style(self.theme.border_style.clone())
122 .show_header(true);
123
124 table.add_column(Column::new("Property").style(self.theme.muted_style.clone()));
125 table.add_column(Column::new("Value"));
126
127 table.add_row_cells(["Name", client.name.as_str()]);
128 table.add_row_cells(["Version", client.version.as_str()]);
129
130 console.render(&table);
131 }
132
133 pub fn render_detail_with_caps(
135 &self,
136 client: &ClientInfo,
137 capabilities: &ClientCapabilities,
138 console: &FastMcpConsole,
139 ) {
140 if !self.should_use_rich(console) {
141 self.render_detail_with_caps_plain(client, capabilities, console);
142 return;
143 }
144
145 let mut table = Table::new()
146 .title("Connected Client")
147 .title_style(self.theme.header_style.clone())
148 .box_style(&ROUNDED)
149 .border_style(self.theme.border_style.clone())
150 .show_header(true);
151
152 table.add_column(Column::new("Property").style(self.theme.muted_style.clone()));
153 table.add_column(Column::new("Value"));
154
155 table.add_row_cells(["Name", client.name.as_str()]);
156 table.add_row_cells(["Version", client.version.as_str()]);
157
158 let caps = self.format_capabilities(capabilities);
159 let caps_display = if caps.is_empty() { "none" } else { &caps };
160 table.add_row_cells(["Capabilities", caps_display]);
161
162 console.render(&table);
163 }
164
165 fn format_capabilities(&self, caps: &ClientCapabilities) -> String {
167 let mut items = Vec::new();
168
169 if caps.sampling.is_some() {
170 items.push("sampling");
171 }
172 if let Some(roots) = &caps.roots {
173 if roots.list_changed {
174 items.push("roots (list_changed)");
175 } else {
176 items.push("roots");
177 }
178 }
179
180 items.join(", ")
181 }
182
183 fn should_use_rich(&self, console: &FastMcpConsole) -> bool {
184 self.context.is_human() && console.is_rich()
185 }
186
187 fn render_connected_plain(&self, client: &ClientInfo, console: &FastMcpConsole) {
188 console.print(&format!(
189 "Client Connected: {} v{}",
190 client.name, client.version
191 ));
192 }
193
194 fn render_connected_with_caps_plain(
195 &self,
196 client: &ClientInfo,
197 capabilities: &ClientCapabilities,
198 console: &FastMcpConsole,
199 ) {
200 console.print(&format!(
201 "Client Connected: {} v{}",
202 client.name, client.version
203 ));
204 let caps = self.format_capabilities(capabilities);
205 if !caps.is_empty() {
206 console.print(&format!(" Capabilities: {}", caps));
207 }
208 }
209
210 fn render_disconnected_plain(
211 &self,
212 client: &ClientInfo,
213 reason: Option<&str>,
214 console: &FastMcpConsole,
215 ) {
216 if let Some(r) = reason {
217 console.print(&format!("Client Disconnected: {} ({})", client.name, r));
218 } else {
219 console.print(&format!("Client Disconnected: {}", client.name));
220 }
221 }
222
223 fn render_detail_plain(&self, client: &ClientInfo, console: &FastMcpConsole) {
224 console.print("Connected Client:");
225 console.print(&format!(" Name: {}", client.name));
226 console.print(&format!(" Version: {}", client.version));
227 }
228
229 fn render_detail_with_caps_plain(
230 &self,
231 client: &ClientInfo,
232 capabilities: &ClientCapabilities,
233 console: &FastMcpConsole,
234 ) {
235 console.print("Connected Client:");
236 console.print(&format!(" Name: {}", client.name));
237 console.print(&format!(" Version: {}", client.version));
238 let caps = self.format_capabilities(capabilities);
239 let caps_display = if caps.is_empty() { "none" } else { &caps };
240 console.print(&format!(" Capabilities: {}", caps_display));
241 }
242}
243
244impl Default for ClientInfoRenderer {
245 fn default() -> Self {
246 Self::detect()
247 }
248}
249
250#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::console::FastMcpConsole;
258 use crate::testing::TestConsole;
259 use fastmcp_protocol::RootsCapability;
260
261 fn sample_client() -> ClientInfo {
262 ClientInfo {
263 name: "Claude Desktop".to_string(),
264 version: "1.2.3".to_string(),
265 }
266 }
267
268 fn sample_capabilities() -> ClientCapabilities {
269 ClientCapabilities {
270 sampling: Some(fastmcp_protocol::SamplingCapability {}),
271 elicitation: None,
272 roots: Some(RootsCapability { list_changed: true }),
273 }
274 }
275
276 #[test]
277 fn test_render_connected_plain() {
278 let client = sample_client();
279 let console = TestConsole::new();
280 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
281 renderer.render_connected(&client, console.console());
282 console.assert_contains("Client Connected: Claude Desktop v1.2.3");
283 }
284
285 #[test]
286 fn test_render_connected_with_caps_plain() {
287 let client = sample_client();
288 let caps = sample_capabilities();
289 let console = TestConsole::new();
290 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
291 renderer.render_connected_with_caps(&client, &caps, console.console());
292 console.assert_contains("Client Connected: Claude Desktop v1.2.3");
293 console.assert_contains("Capabilities: sampling, roots (list_changed)");
294 }
295
296 #[test]
297 fn test_render_disconnected_plain() {
298 let client = sample_client();
299 let console = TestConsole::new();
300 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
301 renderer.render_disconnected(&client, console.console());
302 console.assert_contains("Client Disconnected: Claude Desktop");
303 }
304
305 #[test]
306 fn test_render_disconnected_with_reason_plain() {
307 let client = sample_client();
308 let console = TestConsole::new();
309 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
310 renderer.render_disconnected_with_reason(&client, "timeout", console.console());
311 console.assert_contains("Client Disconnected: Claude Desktop (timeout)");
312 }
313
314 #[test]
315 fn test_render_detail_plain() {
316 let client = sample_client();
317 let console = TestConsole::new();
318 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
319 renderer.render_detail(&client, console.console());
320 console.assert_contains("Connected Client:");
321 console.assert_contains("Name: Claude Desktop");
322 console.assert_contains("Version: 1.2.3");
323 }
324
325 #[test]
326 fn test_render_detail_with_caps_plain() {
327 let client = sample_client();
328 let caps = sample_capabilities();
329 let console = TestConsole::new();
330 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
331 renderer.render_detail_with_caps(&client, &caps, console.console());
332 console.assert_contains("Connected Client:");
333 console.assert_contains("Name: Claude Desktop");
334 console.assert_contains("Capabilities: sampling, roots (list_changed)");
335 }
336
337 #[test]
338 fn test_format_capabilities_empty() {
339 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
340 let caps = ClientCapabilities::default();
341 assert_eq!(renderer.format_capabilities(&caps), "");
342 }
343
344 #[test]
345 fn test_format_capabilities_sampling_only() {
346 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
347 let caps = ClientCapabilities {
348 sampling: Some(fastmcp_protocol::SamplingCapability {}),
349 elicitation: None,
350 roots: None,
351 };
352 assert_eq!(renderer.format_capabilities(&caps), "sampling");
353 }
354
355 #[test]
356 fn test_format_capabilities_roots_only() {
357 let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
358 let caps = ClientCapabilities {
359 sampling: None,
360 elicitation: None,
361 roots: Some(RootsCapability {
362 list_changed: false,
363 }),
364 };
365 assert_eq!(renderer.format_capabilities(&caps), "roots");
366 }
367
368 #[test]
369 fn test_render_connected_rich() {
370 let client = sample_client();
371 let console = TestConsole::new_rich();
372 let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
373 renderer.render_connected(&client, console.console());
374 console.assert_contains("Client Connected");
375 console.assert_contains("Claude Desktop");
376 }
377
378 #[test]
379 fn test_render_connected_with_caps_rich_and_empty_caps() {
380 let client = sample_client();
381 let caps = sample_capabilities();
382 let console = TestConsole::new_rich();
383 let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
384
385 renderer.render_connected_with_caps(&client, &caps, console.console());
386 console.assert_contains("Capabilities: sampling, roots (list_changed)");
387
388 console.clear();
389 renderer.render_connected_with_caps(
390 &client,
391 &ClientCapabilities::default(),
392 console.console(),
393 );
394 console.assert_not_contains("Capabilities:");
395 }
396
397 #[test]
398 fn test_render_disconnected_rich_paths() {
399 let client = sample_client();
400 let console = TestConsole::new_rich();
401 let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
402
403 renderer.render_disconnected(&client, console.console());
404 console.assert_contains("Client Disconnected: Claude Desktop");
405
406 console.clear();
407 renderer.render_disconnected_with_reason(&client, "closed by peer", console.console());
408 console.assert_contains("Client Disconnected: Claude Desktop (closed by peer)");
409 }
410
411 #[test]
412 fn test_render_detail_rich_paths_and_should_use_rich_gate() {
413 let client = sample_client();
414 let caps = sample_capabilities();
415 let console = TestConsole::new_rich();
416 let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
417
418 renderer.render_detail(&client, console.console());
419 console.assert_contains("Connected Client");
420 console.assert_contains("Name");
421 console.assert_contains("Version");
422
423 console.clear();
424 renderer.render_detail_with_caps(&client, &caps, console.console());
425 console.assert_contains("Capabilities");
426 console.assert_contains("sampling, roots (list_changed)");
427
428 console.clear();
429 renderer.render_detail_with_caps(
430 &client,
431 &ClientCapabilities::default(),
432 console.console(),
433 );
434 console.assert_contains("Capabilities");
435 console.assert_contains("none");
436
437 let plain_console = FastMcpConsole::with_enabled(false);
438 assert!(!renderer.should_use_rich(&plain_console));
439 }
440}