pub mod traffic;
pub use traffic::RequestResponseRenderer;
use fastmcp_protocol::{ClientCapabilities, ClientInfo};
use rich_rust::r#box::ROUNDED;
use rich_rust::prelude::*;
use crate::console::{
FastMcpConsole, bounded_redacted_rich_fragment, bounded_redacted_terminal_text,
};
use crate::detection::DisplayContext;
use crate::theme::FastMcpTheme;
const CLIENT_NAME_MAX_CHARS: usize = 128;
const CLIENT_VERSION_MAX_CHARS: usize = 64;
const DISCONNECT_REASON_MAX_CHARS: usize = 256;
#[derive(Debug, Clone)]
pub struct ClientInfoRenderer {
theme: &'static FastMcpTheme,
context: DisplayContext,
}
impl ClientInfoRenderer {
#[must_use]
pub fn new(context: DisplayContext) -> Self {
Self {
theme: crate::theme::theme(),
context,
}
}
#[must_use]
pub fn detect() -> Self {
Self::new(DisplayContext::detect())
}
pub fn render_connected(&self, client: &ClientInfo, console: &FastMcpConsole) {
if !self.should_use_rich(console) {
self.render_connected_plain(client, console);
return;
}
let name = bounded_redacted_rich_fragment(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_rich_fragment(&client.version, CLIENT_VERSION_MAX_CHARS);
console.print(&format!(
"\n[green bold]Client Connected[/]: [cyan]{}[/] [dim]v{}[/]",
name, version
));
}
pub fn render_connected_with_caps(
&self,
client: &ClientInfo,
capabilities: &ClientCapabilities,
console: &FastMcpConsole,
) {
if !self.should_use_rich(console) {
self.render_connected_with_caps_plain(client, capabilities, console);
return;
}
let name = bounded_redacted_rich_fragment(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_rich_fragment(&client.version, CLIENT_VERSION_MAX_CHARS);
console.print(&format!(
"\n[green bold]Client Connected[/]: [cyan]{}[/] [dim]v{}[/]",
name, version
));
let caps = self.format_capabilities(capabilities);
if !caps.is_empty() {
console.print(&format!(" [dim]Capabilities:[/] {}", caps));
}
}
pub fn render_disconnected(&self, client: &ClientInfo, console: &FastMcpConsole) {
if !self.should_use_rich(console) {
self.render_disconnected_plain(client, None, console);
return;
}
let name = bounded_redacted_rich_fragment(&client.name, CLIENT_NAME_MAX_CHARS);
console.print(&format!(
"[yellow]Client Disconnected[/]: [cyan]{}[/]",
name
));
}
pub fn render_disconnected_with_reason(
&self,
client: &ClientInfo,
reason: &str,
console: &FastMcpConsole,
) {
if !self.should_use_rich(console) {
self.render_disconnected_plain(client, Some(reason), console);
return;
}
let name = bounded_redacted_rich_fragment(&client.name, CLIENT_NAME_MAX_CHARS);
let reason = bounded_redacted_rich_fragment(reason, DISCONNECT_REASON_MAX_CHARS);
console.print(&format!(
"[yellow]Client Disconnected[/]: [cyan]{}[/] [dim]({})[/]",
name, reason
));
}
pub fn render_detail(&self, client: &ClientInfo, console: &FastMcpConsole) {
if !self.should_use_rich(console) {
self.render_detail_plain(client, console);
return;
}
let mut table = Table::new()
.title("Connected Client")
.title_style(self.theme.header_style.clone())
.box_style(&ROUNDED)
.border_style(self.theme.border_style.clone())
.show_header(true);
table.add_column(Column::new("Property").style(self.theme.muted_style.clone()));
table.add_column(Column::new("Value"));
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_terminal_text(&client.version, CLIENT_VERSION_MAX_CHARS);
table.add_row_cells(["Name", name.as_str()]);
table.add_row_cells(["Version", version.as_str()]);
console.render(&table);
}
pub fn render_detail_with_caps(
&self,
client: &ClientInfo,
capabilities: &ClientCapabilities,
console: &FastMcpConsole,
) {
if !self.should_use_rich(console) {
self.render_detail_with_caps_plain(client, capabilities, console);
return;
}
let mut table = Table::new()
.title("Connected Client")
.title_style(self.theme.header_style.clone())
.box_style(&ROUNDED)
.border_style(self.theme.border_style.clone())
.show_header(true);
table.add_column(Column::new("Property").style(self.theme.muted_style.clone()));
table.add_column(Column::new("Value"));
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_terminal_text(&client.version, CLIENT_VERSION_MAX_CHARS);
table.add_row_cells(["Name", name.as_str()]);
table.add_row_cells(["Version", version.as_str()]);
let caps = self.format_capabilities(capabilities);
let caps_display = if caps.is_empty() { "none" } else { &caps };
table.add_row_cells(["Capabilities", caps_display]);
console.render(&table);
}
fn format_capabilities(&self, caps: &ClientCapabilities) -> String {
let mut items = Vec::new();
if caps.sampling.is_some() {
items.push("sampling");
}
if let Some(elicitation) = &caps.elicitation {
match (elicitation.supports_form(), elicitation.supports_url()) {
(true, true) => items.push("elicitation (form, url)"),
(true, false) => items.push("elicitation (form)"),
(false, true) => items.push("elicitation (url)"),
(false, false) => items.push("elicitation (no modes)"),
}
}
if let Some(roots) = &caps.roots {
if roots.list_changed {
items.push("roots (list_changed)");
} else {
items.push("roots");
}
}
items.join(", ")
}
fn should_use_rich(&self, console: &FastMcpConsole) -> bool {
self.context.is_human() && console.is_rich()
}
fn render_connected_plain(&self, client: &ClientInfo, console: &FastMcpConsole) {
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_terminal_text(&client.version, CLIENT_VERSION_MAX_CHARS);
console.print_plain(&format!("Client Connected: {} v{}", name, version));
}
fn render_connected_with_caps_plain(
&self,
client: &ClientInfo,
capabilities: &ClientCapabilities,
console: &FastMcpConsole,
) {
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_terminal_text(&client.version, CLIENT_VERSION_MAX_CHARS);
console.print_plain(&format!("Client Connected: {} v{}", name, version));
let caps = self.format_capabilities(capabilities);
if !caps.is_empty() {
console.print_plain(&format!(" Capabilities: {}", caps));
}
}
fn render_disconnected_plain(
&self,
client: &ClientInfo,
reason: Option<&str>,
console: &FastMcpConsole,
) {
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
if let Some(reason) = reason {
let reason = bounded_redacted_terminal_text(reason, DISCONNECT_REASON_MAX_CHARS);
console.print_plain(&format!("Client Disconnected: {name} ({reason})"));
} else {
console.print_plain(&format!("Client Disconnected: {name}"));
}
}
fn render_detail_plain(&self, client: &ClientInfo, console: &FastMcpConsole) {
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_terminal_text(&client.version, CLIENT_VERSION_MAX_CHARS);
console.print_plain("Connected Client:");
console.print_plain(&format!(" Name: {name}"));
console.print_plain(&format!(" Version: {version}"));
}
fn render_detail_with_caps_plain(
&self,
client: &ClientInfo,
capabilities: &ClientCapabilities,
console: &FastMcpConsole,
) {
let name = bounded_redacted_terminal_text(&client.name, CLIENT_NAME_MAX_CHARS);
let version = bounded_redacted_terminal_text(&client.version, CLIENT_VERSION_MAX_CHARS);
console.print_plain("Connected Client:");
console.print_plain(&format!(" Name: {name}"));
console.print_plain(&format!(" Version: {version}"));
let caps = self.format_capabilities(capabilities);
let caps_display = if caps.is_empty() { "none" } else { &caps };
console.print_plain(&format!(" Capabilities: {}", caps_display));
}
}
impl Default for ClientInfoRenderer {
fn default() -> Self {
Self::detect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::console::FastMcpConsole;
use crate::testing::TestConsole;
use fastmcp_protocol::{ElicitationCapability, RootsCapability};
use std::io::Write;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, input: &[u8]) -> std::io::Result<usize> {
if let Ok(mut output) = self.0.lock() {
output.extend_from_slice(input);
}
Ok(input.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn sample_client() -> ClientInfo {
ClientInfo {
name: "Claude Desktop".to_string(),
version: "1.2.3".to_string(),
}
}
fn sample_capabilities() -> ClientCapabilities {
ClientCapabilities {
sampling: Some(fastmcp_protocol::SamplingCapability {}),
elicitation: None,
roots: Some(RootsCapability { list_changed: true }),
}
}
#[test]
fn test_render_connected_plain() {
let client = sample_client();
let console = TestConsole::new();
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
renderer.render_connected(&client, console.console());
console.assert_contains("Client Connected: Claude Desktop v1.2.3");
}
#[test]
fn test_render_connected_with_caps_plain() {
let client = sample_client();
let caps = sample_capabilities();
let console = TestConsole::new();
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
renderer.render_connected_with_caps(&client, &caps, console.console());
console.assert_contains("Client Connected: Claude Desktop v1.2.3");
console.assert_contains("Capabilities: sampling, roots (list_changed)");
}
#[test]
fn test_render_disconnected_plain() {
let client = sample_client();
let console = TestConsole::new();
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
renderer.render_disconnected(&client, console.console());
console.assert_contains("Client Disconnected: Claude Desktop");
}
#[test]
fn test_render_disconnected_with_reason_plain() {
let client = sample_client();
let console = TestConsole::new();
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
renderer.render_disconnected_with_reason(&client, "timeout", console.console());
console.assert_contains("Client Disconnected: Claude Desktop (timeout)");
}
#[test]
fn test_render_detail_plain() {
let client = sample_client();
let console = TestConsole::new();
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
renderer.render_detail(&client, console.console());
console.assert_contains("Connected Client:");
console.assert_contains("Name: Claude Desktop");
console.assert_contains("Version: 1.2.3");
}
#[test]
fn test_render_detail_with_caps_plain() {
let client = sample_client();
let caps = sample_capabilities();
let console = TestConsole::new();
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
renderer.render_detail_with_caps(&client, &caps, console.console());
console.assert_contains("Connected Client:");
console.assert_contains("Name: Claude Desktop");
console.assert_contains("Capabilities: sampling, roots (list_changed)");
}
#[test]
fn test_format_capabilities_empty() {
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
let caps = ClientCapabilities::default();
assert_eq!(renderer.format_capabilities(&caps), "");
}
#[test]
fn test_format_capabilities_sampling_only() {
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
let caps = ClientCapabilities {
sampling: Some(fastmcp_protocol::SamplingCapability {}),
elicitation: None,
roots: None,
};
assert_eq!(renderer.format_capabilities(&caps), "sampling");
}
#[test]
fn test_format_capabilities_roots_only() {
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
let caps = ClientCapabilities {
sampling: None,
elicitation: None,
roots: Some(RootsCapability {
list_changed: false,
}),
};
assert_eq!(renderer.format_capabilities(&caps), "roots");
}
#[test]
fn test_format_capabilities_elicitation_modes() {
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
for (elicitation, expected) in [
(ElicitationCapability::form(), "elicitation (form)"),
(ElicitationCapability::url(), "elicitation (url)"),
(ElicitationCapability::both(), "elicitation (form, url)"),
(ElicitationCapability::default(), "elicitation (no modes)"),
] {
let caps = ClientCapabilities {
sampling: None,
elicitation: Some(elicitation),
roots: None,
};
assert_eq!(renderer.format_capabilities(&caps), expected);
}
}
#[test]
fn test_format_capabilities_includes_all_supported_families() {
let renderer = ClientInfoRenderer::new(DisplayContext::new_agent());
let caps = ClientCapabilities {
sampling: Some(fastmcp_protocol::SamplingCapability {}),
elicitation: Some(ElicitationCapability::both()),
roots: Some(RootsCapability { list_changed: true }),
};
assert_eq!(
renderer.format_capabilities(&caps),
"sampling, elicitation (form, url), roots (list_changed)"
);
}
#[test]
fn test_render_connected_rich() {
let client = sample_client();
let console = TestConsole::new_rich();
let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
renderer.render_connected(&client, console.console());
console.assert_contains("Client Connected");
console.assert_contains("Claude Desktop");
}
#[test]
fn test_render_connected_with_caps_rich_and_empty_caps() {
let client = sample_client();
let caps = sample_capabilities();
let console = TestConsole::new_rich();
let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
renderer.render_connected_with_caps(&client, &caps, console.console());
console.assert_contains("Capabilities: sampling, roots (list_changed)");
console.clear();
renderer.render_connected_with_caps(
&client,
&ClientCapabilities::default(),
console.console(),
);
console.assert_not_contains("Capabilities:");
}
#[test]
fn test_render_disconnected_rich_paths() {
let client = sample_client();
let console = TestConsole::new_rich();
let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
renderer.render_disconnected(&client, console.console());
console.assert_contains("Client Disconnected: Claude Desktop");
console.clear();
renderer.render_disconnected_with_reason(&client, "closed by peer", console.console());
console.assert_contains("Client Disconnected: Claude Desktop (closed by peer)");
}
#[test]
fn test_render_detail_rich_paths_and_should_use_rich_gate() {
let client = sample_client();
let caps = sample_capabilities();
let console = TestConsole::new_rich();
let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
renderer.render_detail(&client, console.console());
console.assert_contains("Connected Client");
console.assert_contains("Name");
console.assert_contains("Version");
console.clear();
renderer.render_detail_with_caps(&client, &caps, console.console());
console.assert_contains("Capabilities");
console.assert_contains("sampling, roots (list_changed)");
console.clear();
renderer.render_detail_with_caps(
&client,
&ClientCapabilities::default(),
console.console(),
);
console.assert_contains("Capabilities");
console.assert_contains("none");
let plain_console = FastMcpConsole::with_enabled(false);
assert!(!renderer.should_use_rich(&plain_console));
}
#[test]
fn hostile_client_metadata_is_bounded_redacted_and_terminal_safe() {
let client = ClientInfo {
name:
"client[prod] [bold]literal[/]\n\u{001b}\u{202e} Authorization: Bearer name-canary"
.to_owned(),
version: format!("v[peer] password=version-canary {}", "x".repeat(1_000)),
};
for (context, console) in [
(DisplayContext::new_agent(), TestConsole::new()),
(DisplayContext::new_human(), TestConsole::new_rich()),
] {
let renderer = ClientInfoRenderer::new(context);
renderer.render_connected(&client, console.console());
renderer.render_disconnected_with_reason(
&client,
"[red]reason[/]\r password=reason-canary",
console.console(),
);
renderer.render_detail(&client, console.console());
let output = console.output_string();
assert!(
output.contains("client[prod]"),
"brackets were corrupted: {output}"
);
assert!(
output.contains("[bold]literal[/]"),
"markup was executed: {output}"
);
assert!(output.contains("\\n"));
assert!(output.contains("\\u{1b}"));
assert!(output.contains("\\u{202e}"));
for escaped_markup in [r"\[prod]", r"\[bold]literal\[/]", r"\[peer]"] {
assert!(
!output.contains(escaped_markup),
"table cell exposed rich escape syntax {escaped_markup:?}: {output}"
);
}
for canary in ["name-canary", "version-canary", "reason-canary"] {
assert!(!output.contains(canary), "leaked {canary}: {output}");
}
assert!(output.chars().count() < 2_500, "unbounded output: {output}");
}
}
#[test]
fn hostile_client_metadata_is_safe_on_a_true_plain_console() {
let output = Arc::new(Mutex::new(Vec::new()));
let console = FastMcpConsole::with_writer(SharedWriter(Arc::clone(&output)), false);
let renderer = ClientInfoRenderer::new(DisplayContext::new_human());
let client = ClientInfo {
name: "plain[client]\nAuthorization: Bearer plain-canary".to_string(),
version: "v[1]".to_string(),
};
renderer.render_connected(&client, &console);
renderer.render_detail(&client, &console);
let output = String::from_utf8(
output
.lock()
.expect("plain client output lock poisoned")
.clone(),
)
.expect("plain client output must be UTF-8");
assert!(output.contains("plain[client]"), "{output}");
assert!(output.contains(r"\n"), "{output}");
assert!(!output.contains(r"\[client]"), "{output}");
assert!(!output.contains("plain-canary"), "{output}");
}
}