turul-mcp-client 0.4.2

Comprehensive MCP client library with multi-transport support
Documentation
//! Configuration types for MCP client

use serde::{Deserialize, Serialize};
use std::time::Duration;

/// Main client configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientConfig {
    /// Client identification information
    pub client_info: ClientInfo,

    /// Timeout configurations
    pub timeouts: TimeoutConfig,

    /// Retry configurations
    pub retry: RetryConfig,

    /// Connection configurations
    pub connection: ConnectionConfig,

    /// Logging configuration
    pub logging: LoggingConfig,

    /// Explicit wire-spec hint. When set, `connect()` skips auto-detection and
    /// drives the handshake for this version directly. `None` (default) =
    /// try `server/discover` then fall back to `initialize` on JSON-RPC -32601.
    pub mcp_protocol_version: Option<crate::version::McpVersion>,

    /// When `true`, broadens the version-downgrade fallback to also accept HTTP
    /// 404/405 from `server/discover` (for gateways that return those for unknown
    /// methods). Off by default; enabling it weakens protocol-downgrade resistance.
    pub allow_legacy_gateway_fallback: bool,

    /// Capabilities this client declares to servers — in the 2025 `initialize`
    /// handshake and in every 2026 request's `_meta` `clientCapabilities`.
    /// All off by default: declare only what the application actually handles,
    /// since servers may send MRTR input requests for declared capabilities.
    pub declared_capabilities: DeclaredCapabilities,
}

/// Client-side capability declarations (see [`ClientConfig::declared_capabilities`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeclaredCapabilities {
    /// Can answer `elicitation/create` input requests (MRTR, SEP-2322).
    /// Declares form-mode support ("an empty capabilities object is
    /// equivalent to declaring support for form mode only").
    pub elicitation: bool,
    /// Also supports URL-mode elicitation (`elicitation.url`). Servers MUST
    /// NOT send URL-mode requests unless this is declared. Implies
    /// `elicitation` when set.
    pub elicitation_url: bool,
    /// Can answer `sampling/createMessage` input requests (deprecated per SEP-2577).
    pub sampling: bool,
    /// Also supports tool-enabled sampling (`sampling.tools`). Servers MUST
    /// NOT send `tools`/`toolChoice` unless this is declared. Implies
    /// `sampling` when set.
    pub sampling_tools: bool,
    /// Also supports `includeContext: "thisServer"/"allServers"` in sampling
    /// (`sampling.context`). Implies `sampling` when set.
    pub sampling_context: bool,
    /// Can answer `roots/list` input requests (deprecated per SEP-2577).
    pub roots: bool,
    /// Declares the Tasks extension (`io.modelcontextprotocol/tasks`,
    /// SEP-2663) in every 2026 request's `_meta` `clientCapabilities.extensions`
    /// — servers may then answer task-electing calls with a `CreateTaskResult`
    /// instead of the normal result. Use the `call_tool_or_task`/`task_*`
    /// client APIs (requires the `ext-tasks` cargo feature).
    pub ext_tasks: bool,
}

/// Client identification information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientInfo {
    /// Client name
    pub name: String,

    /// Client version
    pub version: String,

    /// Client description
    pub description: Option<String>,

    /// Vendor information
    pub vendor: Option<String>,

    /// Additional metadata
    pub metadata: Option<serde_json::Value>,
}

/// Timeout configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeoutConfig {
    /// Connection timeout
    #[serde(with = "duration_serde")]
    pub connect: Duration,

    /// Request timeout for individual operations
    #[serde(with = "duration_serde")]
    pub request: Duration,

    /// Long operation timeout (for streaming, etc.)
    #[serde(with = "duration_serde")]
    pub long_operation: Duration,

    /// Session initialization timeout
    #[serde(with = "duration_serde")]
    pub initialization: Duration,

    /// Heartbeat interval for keep-alive
    #[serde(with = "duration_serde")]
    pub heartbeat: Duration,
}

/// Retry configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
    /// Maximum number of retry attempts
    pub max_attempts: u32,

    /// Initial retry delay
    #[serde(with = "duration_serde")]
    pub initial_delay: Duration,

    /// Maximum retry delay
    #[serde(with = "duration_serde")]
    pub max_delay: Duration,

    /// Exponential backoff multiplier
    pub backoff_multiplier: f64,

    /// Jitter factor (0.0 to 1.0)
    pub jitter: f64,

    /// Whether to enable exponential backoff
    pub exponential_backoff: bool,
}

/// Connection configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
    /// User agent string
    pub user_agent: Option<String>,

    /// Custom headers to include in requests
    pub headers: Option<std::collections::HashMap<String, String>>,

    /// Whether to follow redirects
    pub follow_redirects: bool,

    /// Maximum number of redirects to follow (only applies when `follow_redirects = true`)
    pub max_redirects: u32,

    /// Keep-alive settings.
    ///
    /// No reqwest equivalent — reqwest exposes `tcp_keepalive(Option<Duration>)`, not a
    /// boolean. This field is scheduled for removal in 0.4.
    #[deprecated(
        since = "0.3.35",
        note = "no reqwest equivalent; scheduled for removal in 0.4"
    )]
    pub keep_alive: bool,

    /// Connection pool settings
    pub pool_settings: PoolConfig,
}

/// Connection pool configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
    /// Maximum number of idle connections per host kept in the pool.
    ///
    /// Wired to `reqwest::ClientBuilder::pool_max_idle_per_host`. The default was raised
    /// from 5 to 32 in 0.3.35 so that callers relying on the previous default (which was
    /// silently ignored and deferred to reqwest's internal default) are not regressed.
    pub max_idle_per_host: u32,

    /// Idle connection timeout.
    ///
    /// Wired to `reqwest::ClientBuilder::pool_idle_timeout`.
    #[serde(with = "duration_serde")]
    pub idle_timeout: Duration,

    /// Maximum connection lifetime.
    ///
    /// No reqwest equivalent — reqwest exposes `pool_idle_timeout` but no per-connection
    /// max lifetime. This field is scheduled for removal in 0.4.
    #[deprecated(
        since = "0.3.35",
        note = "no reqwest equivalent; scheduled for removal in 0.4"
    )]
    #[serde(with = "duration_serde")]
    pub max_lifetime: Duration,
}

/// Logging configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
    /// Log level
    pub level: String,

    /// Whether to log requests
    pub log_requests: bool,

    /// Whether to log responses
    pub log_responses: bool,

    /// Whether to log transport events
    pub log_transport: bool,

    /// Whether to redact sensitive information
    pub redact_sensitive: bool,
}

impl Default for ClientInfo {
    fn default() -> Self {
        Self {
            name: "mcp-client".to_string(),
            version: env!("CARGO_PKG_VERSION").to_string(),
            description: Some("Rust MCP Client Library".to_string()),
            vendor: Some("MCP Framework".to_string()),
            metadata: None,
        }
    }
}

impl Default for TimeoutConfig {
    fn default() -> Self {
        Self {
            connect: Duration::from_secs(10),
            request: Duration::from_secs(30),
            long_operation: Duration::from_secs(300), // 5 minutes
            initialization: Duration::from_secs(15),
            heartbeat: Duration::from_secs(30),
        }
    }
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            initial_delay: Duration::from_millis(100),
            max_delay: Duration::from_secs(10),
            backoff_multiplier: 2.0,
            jitter: 0.1,
            exponential_backoff: true,
        }
    }
}

impl Default for ConnectionConfig {
    #[allow(deprecated)] // keep_alive is deprecated but must remain in Default until 0.4
    fn default() -> Self {
        Self {
            user_agent: Some(format!("mcp-client/{}", env!("CARGO_PKG_VERSION"))),
            headers: None,
            follow_redirects: true,
            max_redirects: 5,
            keep_alive: true,
            pool_settings: PoolConfig::default(),
        }
    }
}

impl Default for PoolConfig {
    #[allow(deprecated)] // max_lifetime is deprecated but must remain in Default until 0.4
    fn default() -> Self {
        Self {
            // 32 matches typical HTTP client pool sizing. Previously 5, silently ignored
            // (reqwest default was `usize::MAX`). 0.3.35 honors the value, so raising the
            // default here avoids regressing callers who took the previous default.
            max_idle_per_host: 32,
            idle_timeout: Duration::from_secs(90),
            max_lifetime: Duration::from_secs(300),
        }
    }
}

impl Default for LoggingConfig {
    fn default() -> Self {
        Self {
            level: "info".to_string(),
            log_requests: true,
            log_responses: true,
            log_transport: false,
            redact_sensitive: true,
        }
    }
}

impl RetryConfig {
    /// Calculate the delay for a given attempt number
    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
        if attempt == 0 {
            return Duration::from_millis(0);
        }

        let mut delay = self.initial_delay;

        if self.exponential_backoff && attempt > 1 {
            // Apply exponential backoff
            let multiplier = self.backoff_multiplier.powi((attempt - 1) as i32);
            delay = Duration::from_millis((delay.as_millis() as f64 * multiplier) as u64);
        }

        // Cap at max delay
        if delay > self.max_delay {
            delay = self.max_delay;
        }

        // Apply jitter
        if self.jitter > 0.0 {
            let jitter_ms = (delay.as_millis() as f64 * self.jitter) as u64;
            let random_offset = rand::random::<f64>() * jitter_ms as f64;
            delay = Duration::from_millis(delay.as_millis() as u64 + random_offset as u64);
        }

        // Ensure final delay never exceeds max_delay (even after jitter)
        if delay > self.max_delay {
            delay = self.max_delay;
        }

        delay
    }

    /// Check if an attempt should be retried
    pub fn should_retry(&self, attempt: u32) -> bool {
        attempt < self.max_attempts
    }
}

// Helper module for Duration serialization
mod duration_serde {
    use serde::{Deserialize, Deserializer, Serializer};
    use std::time::Duration;

    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_u64(duration.as_millis() as u64)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
    where
        D: Deserializer<'de>,
    {
        let millis = u64::deserialize(deserializer)?;
        Ok(Duration::from_millis(millis))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_retry_delay_calculation() {
        let config = RetryConfig::default();

        // First attempt should have no delay
        assert_eq!(config.delay_for_attempt(0), Duration::from_millis(0));

        // Second attempt should have initial delay
        let delay1 = config.delay_for_attempt(1);
        assert!(delay1 >= config.initial_delay);

        // Third attempt should be longer with exponential backoff
        let delay2 = config.delay_for_attempt(2);
        assert!(delay2 > delay1);

        // Should not exceed max delay
        let large_delay = config.delay_for_attempt(20);
        assert!(large_delay <= config.max_delay);
    }

    #[test]
    fn test_retry_attempts() {
        let config = RetryConfig::default();

        assert!(config.should_retry(0));
        assert!(config.should_retry(1));
        assert!(config.should_retry(2));
        assert!(!config.should_retry(3)); // Default max is 3
    }

    #[test]
    fn test_config_serialization() {
        let config = ClientConfig::default();
        let json = serde_json::to_string(&config).unwrap();
        let _deserialized: ClientConfig = serde_json::from_str(&json).unwrap();
    }
}