netscli-mcp 0.3.1

Model Context Protocol (MCP) server exposing netscli-core tools to LLM agents
Documentation
use ipnet::Ipv4Net;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::Value;

use super::errors::RpcError;

const MAX_SUBNET_ADDRESSES: u64 = 1 << 16; // /16

/// Clamp to the same ceiling `Ops` enforces.
///
/// This used to clamp to 4096 while `Ops::new` re-clamped to 1024, so any
/// value in 1025..=4096 was accepted here and then silently reduced, and a
/// comment in `ops/config.rs` claimed the two bounds matched (C-10).
/// Deferring to the core constant makes that true by construction.
pub(super) fn clamp_concurrency(max_concurrent: Option<usize>, default: usize) -> usize {
    let c = max_concurrent.unwrap_or(default);
    c.clamp(1, netscli_core::MAX_CONCURRENCY)
}

pub(super) fn clamp_timeout_ms(timeout_ms: Option<u64>, default: u64) -> u64 {
    // Enforce a sane lower/upper bound to prevent hangs or instant timeouts.
    let t = timeout_ms.unwrap_or(default);
    t.clamp(10, 10 * 60 * 1000)
}

pub(super) fn parse_params<T: DeserializeOwned>(val: Value) -> Result<T, RpcError> {
    serde_json::from_value(val).map_err(|e| RpcError::InvalidParams(e.to_string()))
}

/// Check a client-supplied subnet for both size and scope.
///
/// The two live together so a caller cannot apply one and forget the other.
/// Size alone was never sufficient: a /16 of someone else's address space is
/// still a /16.
pub(super) fn validate_subnet(subnet: &str) -> Result<(), RpcError> {
    let net: Ipv4Net = subnet
        .parse()
        .map_err(|e| RpcError::InvalidParams(format!("invalid subnet '{subnet}': {e}")))?;
    let prefix = net.prefix_len() as u32;
    let total = 1u64
        .checked_shl(32u32.saturating_sub(prefix))
        .unwrap_or(u64::MAX);
    if total > MAX_SUBNET_ADDRESSES {
        return Err(RpcError::InvalidParams(format!(
            "subnet too large: {subnet} (max /16)"
        )));
    }
    super::targets::ensure_subnet_allowed(&net, subnet)
}

/// Normalize an MCP-supplied port list into what `Ops` expects.
///
/// The *rules* (port 0, the 4,096 cap) are not restated here — they live in
/// `netscli_core::validate_ports` so every interface answers identically.
/// This used to carry its own copy, which is how the CLI ended up accepting
/// `-p 0` while this surface rejected it. Validating early still matters: it
/// turns a core error into a proper `-32602` before the request reaches a
/// tool, rather than surfacing as a generic tool failure.
pub(super) fn normalize_ports(mut ports: Option<Vec<u16>>) -> Result<Option<Vec<u16>>, RpcError> {
    let Some(mut ps) = ports.take() else {
        return Ok(None);
    };
    if ps.is_empty() {
        return Ok(None);
    }
    ps.sort_unstable();
    ps.dedup();
    netscli_core::validate_ports(&ps).map_err(|e| RpcError::InvalidParams(e.to_string()))?;
    Ok(Some(ps))
}

#[derive(Deserialize)]
pub(super) struct InitializeParams {
    #[serde(rename = "protocolVersion")]
    pub(super) protocol_version: Option<String>,
    #[allow(dead_code)]
    pub(super) capabilities: Option<serde_json::Value>,
    #[allow(dead_code)]
    #[serde(rename = "clientInfo")]
    pub(super) client_info: Option<serde_json::Value>,
}

#[derive(Deserialize)]
pub(super) struct DiscoverParams {
    pub(super) subnet: Option<String>,
    #[serde(rename = "resolveHostnames")]
    pub(super) resolve_hostnames: Option<bool>,
    pub(super) timeout: Option<u64>,
    #[serde(rename = "maxConcurrent")]
    pub(super) max_concurrent: Option<usize>,
}

#[derive(Deserialize)]
pub(super) struct PingHostParams {
    pub(super) host: String,
    /// Number of ICMP/TCP probes to send. Defaults to 1 (single-shot); the
    /// CLI's `netscli ping` defaults to 4 but MCP clients typically want a
    /// single summary for a scripted workflow.
    #[serde(default)]
    pub(super) count: Option<u32>,
    pub(super) timeout: Option<u64>,
    #[serde(rename = "maxConcurrent")]
    pub(super) max_concurrent: Option<usize>,
}

#[derive(Deserialize)]
pub(super) struct ScanParams {
    pub(super) host: String,
    pub(super) ports: Option<Vec<u16>>,
    pub(super) timeout: Option<u64>,
    #[serde(rename = "maxConcurrent")]
    pub(super) max_concurrent: Option<usize>,
}

#[derive(Deserialize)]
pub(super) struct DnsParams {
    pub(super) host: String,
    #[serde(rename = "type")]
    pub(super) record_type: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct SweepParams {
    pub(super) subnet: Option<String>,
    pub(super) ports: Option<Vec<u16>>,
    #[serde(rename = "resolveHostnames")]
    pub(super) resolve_hostnames: Option<bool>,
    pub(super) timeout: Option<u64>,
    #[serde(rename = "maxConcurrent")]
    pub(super) max_concurrent: Option<usize>,
}

#[derive(Deserialize)]
#[cfg_attr(not(feature = "pcap"), allow(dead_code))]
pub(super) struct PcapParams {
    pub(super) interface: String,
    pub(super) filter: Option<String>,
    pub(super) duration: Option<u64>,
    #[serde(rename = "outputFile")]
    pub(super) output_file: Option<String>,
    #[serde(rename = "maxPackets")]
    pub(super) max_packets: Option<u64>,
}

#[derive(Deserialize)]
#[cfg_attr(not(feature = "pcap"), allow(dead_code))]
pub(super) struct PcapJobParams {
    #[serde(rename = "jobId")]
    pub(super) job_id: String,
}

#[cfg(feature = "mdns")]
#[derive(Deserialize, Default)]
pub(super) struct MdnsParams {
    #[serde(default)]
    pub(super) timeout_ms: Option<u64>,
    #[serde(default)]
    pub(super) service_types: Option<Vec<String>>,
}