Skip to main content

fastmcp_console/
client.rs

1//! Client info rendering for connection status display.
2//!
3//! Provides beautiful rendering of connected client information,
4//! with plain-text fallback for agent contexts.
5
6pub 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/// Renders client connection information.
18///
19/// Displays client name, version, and capabilities in a beautiful format
20/// when a client connects or disconnects from the server.
21#[derive(Debug, Clone)]
22pub struct ClientInfoRenderer {
23    theme: &'static FastMcpTheme,
24    context: DisplayContext,
25}
26
27impl ClientInfoRenderer {
28    /// Create a new renderer with explicit display context.
29    #[must_use]
30    pub fn new(context: DisplayContext) -> Self {
31        Self {
32            theme: crate::theme::theme(),
33            context,
34        }
35    }
36
37    /// Create a renderer using auto-detected display context.
38    #[must_use]
39    pub fn detect() -> Self {
40        Self::new(DisplayContext::detect())
41    }
42
43    /// Render client connection notification.
44    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    /// Render client connection with capabilities.
57    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    /// Render client disconnection notification.
80    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    /// Render client disconnection with reason.
93    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    /// Render detailed client information panel.
111    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    /// Render detailed client information with capabilities.
134    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    /// Format capabilities as a comma-separated string.
166    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// ============================================================================
251// Tests
252// ============================================================================
253
254#[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}