Skip to main content

netscli_mcp/
server.rs

1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
5
6use ipnet::Ipv4Net;
7use serde_json::json;
8use serde_json::Value;
9
10#[derive(Serialize, Deserialize, Debug)]
11struct JsonRpcRequest {
12    jsonrpc: String,
13    method: String,
14    params: Option<serde_json::Value>,
15    id: Option<serde_json::Value>,
16}
17
18#[derive(Serialize, Deserialize, Debug)]
19struct JsonRpcResponse {
20    jsonrpc: String,
21    result: Option<serde_json::Value>,
22    error: Option<JsonRpcError>,
23    id: Option<serde_json::Value>,
24}
25
26#[derive(Serialize, Deserialize, Debug)]
27struct JsonRpcError {
28    code: i32,
29    message: String,
30}
31
32#[derive(Debug, Error)]
33enum RpcError {
34    #[error("Invalid Request: {0}")]
35    InvalidRequest(String),
36    #[error("Not initialized")]
37    NotInitialized,
38    #[error("Method not found")]
39    MethodNotFound,
40    #[error("Invalid params: {0}")]
41    InvalidParams(String),
42    #[error("{0}")]
43    ToolError(String),
44    #[error("{0}")]
45    Internal(String),
46}
47
48impl RpcError {
49    fn code(&self) -> i32 {
50        match self {
51            RpcError::InvalidRequest(_) => -32600,
52            RpcError::NotInitialized => -32002,
53            RpcError::MethodNotFound => -32601,
54            RpcError::InvalidParams(_) => -32602,
55            RpcError::ToolError(_) => -32000,
56            RpcError::Internal(_) => -32603,
57        }
58    }
59}
60
61#[derive(Debug, Default)]
62struct ServerState {
63    initialized: bool,
64}
65
66fn clamp_concurrency(max_concurrent: Option<usize>, default: usize) -> usize {
67    let c = max_concurrent.unwrap_or(default);
68    c.clamp(1, 4096)
69}
70
71fn clamp_timeout_ms(timeout_ms: Option<u64>, default: u64) -> u64 {
72    // Enforce a sane lower/upper bound to prevent hangs or instant timeouts.
73    let t = timeout_ms.unwrap_or(default);
74    t.clamp(10, 10 * 60 * 1000)
75}
76
77fn parse_params<T: DeserializeOwned>(val: Value) -> Result<T, RpcError> {
78    serde_json::from_value(val).map_err(|e| RpcError::InvalidParams(e.to_string()))
79}
80
81const MAX_SUBNET_ADDRESSES: u64 = 1 << 16; // /16
82const MAX_PORTS_PER_REQUEST: usize = 4096;
83
84fn validate_subnet(subnet: &str) -> Result<(), RpcError> {
85    let net: Ipv4Net = subnet
86        .parse()
87        .map_err(|e| RpcError::InvalidParams(format!("invalid subnet '{subnet}': {e}")))?;
88    let prefix = net.prefix_len() as u32;
89    let total = 1u64
90        .checked_shl(32u32.saturating_sub(prefix))
91        .unwrap_or(u64::MAX);
92    if total > MAX_SUBNET_ADDRESSES {
93        return Err(RpcError::InvalidParams(format!(
94            "subnet too large: {subnet} (max /16)"
95        )));
96    }
97    Ok(())
98}
99
100fn normalize_ports(mut ports: Option<Vec<u16>>) -> Result<Option<Vec<u16>>, RpcError> {
101    let Some(mut ps) = ports.take() else {
102        return Ok(None);
103    };
104    if ps.is_empty() {
105        return Ok(None);
106    }
107    if ps.len() > MAX_PORTS_PER_REQUEST {
108        return Err(RpcError::InvalidParams(format!(
109            "too many ports requested ({} > {})",
110            ps.len(),
111            MAX_PORTS_PER_REQUEST
112        )));
113    }
114    if ps.contains(&0) {
115        return Err(RpcError::InvalidParams("port 0 is invalid".to_string()));
116    }
117    ps.sort_unstable();
118    ps.dedup();
119    Ok(Some(ps))
120}
121
122#[derive(Deserialize)]
123struct InitializeParams {
124    #[serde(rename = "protocolVersion")]
125    protocol_version: Option<String>,
126    #[allow(dead_code)]
127    capabilities: Option<serde_json::Value>,
128    #[allow(dead_code)]
129    #[serde(rename = "clientInfo")]
130    client_info: Option<serde_json::Value>,
131}
132
133#[derive(Deserialize)]
134struct DiscoverParams {
135    subnet: Option<String>,
136    #[serde(rename = "resolveHostnames")]
137    resolve_hostnames: Option<bool>,
138    timeout: Option<u64>,
139    #[serde(rename = "maxConcurrent")]
140    max_concurrent: Option<usize>,
141}
142
143#[derive(Deserialize)]
144struct PingHostParams {
145    host: String,
146    /// Number of ICMP/TCP probes to send. Defaults to 1 (single-shot); the
147    /// CLI's `netscli ping` defaults to 4 but MCP clients typically want a
148    /// single summary for a scripted workflow.
149    #[serde(default)]
150    count: Option<u32>,
151    timeout: Option<u64>,
152    #[serde(rename = "maxConcurrent")]
153    max_concurrent: Option<usize>,
154}
155
156#[derive(Deserialize)]
157struct ScanParams {
158    host: String,
159    ports: Option<Vec<u16>>,
160    timeout: Option<u64>,
161    #[serde(rename = "maxConcurrent")]
162    max_concurrent: Option<usize>,
163}
164
165#[derive(Deserialize)]
166struct DnsParams {
167    host: String,
168    #[serde(rename = "type")]
169    record_type: Option<String>,
170}
171
172#[derive(Deserialize)]
173struct SweepParams {
174    subnet: Option<String>,
175    ports: Option<Vec<u16>>,
176    #[serde(rename = "resolveHostnames")]
177    resolve_hostnames: Option<bool>,
178    timeout: Option<u64>,
179    #[serde(rename = "maxConcurrent")]
180    max_concurrent: Option<usize>,
181}
182
183#[derive(Deserialize)]
184#[cfg_attr(not(feature = "pcap"), allow(dead_code))]
185struct PcapParams {
186    interface: String,
187    filter: Option<String>,
188    duration: Option<u64>,
189    #[serde(rename = "outputFile")]
190    output_file: Option<String>,
191    #[serde(rename = "maxPackets")]
192    max_packets: Option<u64>,
193}
194
195#[cfg(feature = "mdns")]
196#[derive(Deserialize, Default)]
197struct MdnsParams {
198    #[serde(default)]
199    timeout_ms: Option<u64>,
200    #[serde(default)]
201    service_types: Option<Vec<String>>,
202}
203
204/// Initialize a tracing subscriber that writes JSON to stderr.
205///
206/// Uses `RUST_LOG` if set, otherwise defaults to `info`. Uses `try_init`
207/// so it's a no-op when the caller already installed a subscriber (e.g.
208/// a host binary that wants its own format).
209///
210/// **Why stderr, not stdout?** stdout is the JSON-RPC transport — any
211/// byte written there that isn't a valid response will break the client.
212fn init_tracing() {
213    use tracing_subscriber::{fmt, EnvFilter};
214
215    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
216
217    let _ = fmt()
218        .with_writer(std::io::stderr)
219        .with_env_filter(filter)
220        .with_target(false)
221        .json()
222        .with_current_span(false)
223        .with_span_list(false)
224        .try_init();
225}
226
227pub async fn run_server() -> anyhow::Result<()> {
228    init_tracing();
229    tracing::info!(
230        version = env!("CARGO_PKG_VERSION"),
231        pcap = cfg!(feature = "pcap"),
232        "netscli MCP server starting"
233    );
234
235    let stdin = io::stdin();
236    let stdout = io::stdout();
237    let mut reader = BufReader::new(stdin).lines();
238    let mut writer = BufWriter::new(stdout);
239    let mut state = ServerState::default();
240    let mut requests_handled: u64 = 0;
241
242    while let Some(line) = reader.next_line().await? {
243        if line.trim().is_empty() {
244            continue;
245        }
246
247        let request = match serde_json::from_str::<JsonRpcRequest>(&line) {
248            Ok(req) => req,
249            Err(e) => {
250                tracing::warn!(error = %e, "json parse error");
251                // Parse error. JSON-RPC expects id=null.
252                let response = JsonRpcResponse {
253                    jsonrpc: "2.0".to_string(),
254                    result: None,
255                    error: Some(JsonRpcError {
256                        code: -32700,
257                        message: "Parse error".to_string(),
258                    }),
259                    id: Some(serde_json::Value::Null),
260                };
261                let response_str = serde_json::to_string(&response)?;
262                writer.write_all(response_str.as_bytes()).await?;
263                writer.write_all(b"\n").await?;
264                writer.flush().await?;
265                continue;
266            }
267        };
268
269        // Notifications (no id) must not get a response.
270        if request.id.is_none() {
271            tracing::debug!(method = %request.method, "notification");
272            if request.method == "notifications/initialized" {
273                state.initialized = true;
274            }
275            continue;
276        }
277
278        let response = handle_request(&mut state, request).await;
279        let response_str = serde_json::to_string(&response)?;
280        writer.write_all(response_str.as_bytes()).await?;
281        writer.write_all(b"\n").await?;
282        writer.flush().await?;
283        requests_handled += 1;
284    }
285
286    tracing::info!(requests_handled, "netscli MCP server shutting down");
287    Ok(())
288}
289
290async fn handle_request(state: &mut ServerState, req: JsonRpcRequest) -> JsonRpcResponse {
291    let id = req.id.clone();
292    let method = req.method.clone();
293    // For tools/call the caller-visible operation is the tool name; surface it
294    // so log consumers can grep a single tool without regex-parsing params.
295    let tool_name: Option<String> = if method == "tools/call" {
296        req.params
297            .as_ref()
298            .and_then(|p| p.get("name"))
299            .and_then(|n| n.as_str())
300            .map(|s| s.to_string())
301    } else {
302        None
303    };
304
305    let start = std::time::Instant::now();
306    let mut response = JsonRpcResponse {
307        jsonrpc: "2.0".to_string(),
308        result: None,
309        error: None,
310        id,
311    };
312
313    match handle_request_inner(state, &req).await {
314        Ok(val) => {
315            response.result = Some(val);
316        }
317        Err(e) => {
318            response.error = Some(JsonRpcError {
319                code: e.code(),
320                message: e.to_string(),
321            });
322        }
323    }
324
325    let duration_ms = start.elapsed().as_millis() as u64;
326    match response.error.as_ref() {
327        None => tracing::info!(
328            method = %method,
329            tool = tool_name.as_deref(),
330            duration_ms,
331            "ok"
332        ),
333        Some(err) => tracing::warn!(
334            method = %method,
335            tool = tool_name.as_deref(),
336            duration_ms,
337            code = err.code,
338            message = %err.message,
339            "error"
340        ),
341    }
342
343    response
344}
345
346pub fn tools_list() -> serde_json::Value {
347    let tools = vec![
348        json!({
349            "name": "discover_network",
350            "description": "Discover live hosts on a network subnet",
351            "inputSchema": {
352                "type": "object",
353                "properties": {
354                    "subnet": { "type": "string", "default": "192.168.1.0/24" },
355                    "resolveHostnames": { "type": "boolean", "default": false },
356                    "timeout": { "type": "number", "default": 1000 },
357                    "maxConcurrent": { "type": "number", "default": 256 }
358                }
359            }
360        }),
361        json!({
362            "name": "scan_ports",
363            "description": "Scan TCP ports on a host",
364            "inputSchema": {
365                "type": "object",
366                "properties": {
367                    "host": { "type": "string" },
368                    "ports": { "type": "array", "items": { "type": "number" } },
369                    "timeout": { "type": "number", "default": 500 },
370                    "maxConcurrent": { "type": "number", "default": 256 }
371                },
372                "required": ["host"]
373            }
374        }),
375        json!({
376            "name": "ping_host",
377            "description": "Ping a host (ICMP with TCP-connect fallback). Returns a PingSummary with aggregate loss and min/avg/max RTT when count > 1.",
378            "inputSchema": {
379                "type": "object",
380                "properties": {
381                    "host": { "type": "string" },
382                    "count": { "type": "number", "default": 1, "minimum": 1, "maximum": 256 },
383                    "timeout": { "type": "number", "default": 1000 },
384                    "maxConcurrent": { "type": "number", "default": 64 }
385                },
386                "required": ["host"]
387            }
388        }),
389        json!({
390            "name": "dns_lookup",
391            "description": "DNS lookup (A, AAAA, CNAME, MX, NS, TXT, SRV, PTR, SOA, CAA, or ALL/ANY for every record type)",
392            "inputSchema": {
393                "type": "object",
394                "properties": {
395                    "host": { "type": "string" },
396                    "type": {
397                        "type": "string",
398                        "default": "A",
399                        "enum": ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SRV", "PTR", "SOA", "CAA", "ALL", "ANY"]
400                    }
401                },
402                "required": ["host"]
403            }
404        }),
405        json!({
406            "name": "get_arp_table",
407            "description": "Get ARP/neighbor table with vendor information",
408            "inputSchema": {
409                "type": "object",
410                "properties": {}
411            }
412        }),
413        json!({
414            "name": "inspect_host",
415            "description": "Inspect a host (ping + port scan + optional DNS resolution)",
416            "inputSchema": {
417                "type": "object",
418                "properties": {
419                    "host": { "type": "string" },
420                    "ports": { "type": "array", "items": { "type": "number" } }
421                },
422                "required": ["host"]
423            }
424        }),
425        json!({
426            "name": "sweep_network",
427            "description": "Sweep a network (discover hosts then scan ports)",
428            "inputSchema": {
429                "type": "object",
430                "properties": {
431                    "subnet": { "type": "string", "default": "192.168.1.0/24" },
432                    "ports": { "type": "array", "items": { "type": "number" } },
433                    "resolveHostnames": { "type": "boolean", "default": false },
434                    "timeout": { "type": "number", "default": 500 },
435                    "maxConcurrent": { "type": "number", "default": 256 }
436                }
437            }
438        }),
439        json!({
440            "name": "list_network_interfaces",
441            "description": "List network interfaces with details",
442            "inputSchema": {
443                "type": "object",
444                "properties": {}
445            }
446        }),
447    ];
448
449    #[cfg(feature = "pcap")]
450    let tools = {
451        let mut tools = tools;
452        tools.push(json!({
453            "name": "capture_pcap",
454            "description": "Capture network packets to a PCAP file (may require root/admin)",
455            "inputSchema": {
456                "type": "object",
457                "properties": {
458                    "interface": { "type": "string" },
459                    "filter": { "type": "string" },
460                    "duration": { "type": "number", "default": 10 },
461                    "outputFile": { "type": "string", "default": "capture.pcap" },
462                    "maxPackets": { "type": "number" }
463                },
464                "required": ["interface"]
465            }
466        }));
467        tools
468    };
469
470    #[cfg(feature = "mdns")]
471    let tools = {
472        let mut tools = tools;
473        tools.push(json!({
474            "name": "discover_mdns",
475            "description": "Discover devices on the local network via mDNS/DNS-SD (Bonjour). Returns services with their hostnames, resolved IPs, ports, and TXT properties. Much friendlier than IP-based discovery for named devices like printers, Chromecasts, or Homebridge accessories.",
476            "inputSchema": {
477                "type": "object",
478                "properties": {
479                    "timeout_ms": {
480                        "type": "number",
481                        "default": 3000,
482                        "description": "How long to browse for responses. 3000-5000ms is typical; many devices re-announce on a multi-second cadence."
483                    },
484                    "service_types": {
485                        "type": "array",
486                        "items": { "type": "string" },
487                        "description": "Explicit service types to browse (e.g. [\"_http._tcp.local.\", \"_airplay._tcp.local.\"]). Omit to use a curated default set."
488                    }
489                }
490            }
491        }));
492        tools
493    };
494
495    json!({ "tools": tools })
496}
497
498fn mcp_tool_result_text(val: serde_json::Value) -> serde_json::Value {
499    json!({
500        "content": [
501            {
502                "type": "text",
503                "text": serde_json::to_string_pretty(&val).unwrap_or_else(|_| "<serialization error>".to_string())
504            }
505        ]
506    })
507}
508
509// NOTE: Hostname/IP resolution is shared in netscli-core (`netscli_core::resolve_host_ip`).
510
511async fn op_discover(p: DiscoverParams) -> Result<Vec<netscli_core::Host>, RpcError> {
512    if let Some(ref subnet) = p.subnet {
513        validate_subnet(subnet)?;
514    }
515    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
516    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_PING_TIMEOUT_MS);
517    let cfg = netscli_core::OpsConfig {
518        concurrency,
519        ping_timeout_ms: timeout_ms,
520        dns_timeout_ms: timeout_ms,
521        ..Default::default()
522    };
523    let ops = netscli_core::Ops::new(cfg);
524    let (_subnet, hosts) = ops
525        .discover_ipv4(p.subnet, p.resolve_hostnames.unwrap_or(false))
526        .await
527        .map_err(|e| RpcError::ToolError(e.to_string()))?;
528    Ok(hosts)
529}
530
531async fn op_scan_ports(p: ScanParams) -> Result<Vec<netscli_core::PortResult>, RpcError> {
532    let ports = normalize_ports(p.ports)?;
533    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
534    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_SCAN_TIMEOUT_MS);
535    let cfg = netscli_core::OpsConfig {
536        concurrency,
537        scan_timeout_ms: timeout_ms,
538        ..Default::default()
539    };
540    let ops = netscli_core::Ops::new(cfg);
541    let (_ip, res) = ops
542        .scan_ports(&p.host, ports)
543        .await
544        .map_err(|e| RpcError::ToolError(e.to_string()))?;
545    Ok(res)
546}
547
548async fn op_inspect_host(p: ScanParams) -> Result<netscli_core::InspectResult, RpcError> {
549    let ports = normalize_ports(p.ports)?;
550    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
551    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_SCAN_TIMEOUT_MS);
552    let cfg = netscli_core::OpsConfig {
553        concurrency,
554        scan_timeout_ms: timeout_ms,
555        ping_timeout_ms: timeout_ms,
556        dns_timeout_ms: timeout_ms,
557    };
558    let ops = netscli_core::Ops::new(cfg);
559    ops.inspect_host(p.host, ports)
560        .await
561        .map_err(|e| RpcError::ToolError(e.to_string()))
562}
563
564async fn op_sweep(p: SweepParams) -> Result<Vec<netscli_core::SweepEntry>, RpcError> {
565    if let Some(ref subnet) = p.subnet {
566        validate_subnet(subnet)?;
567    }
568    let ports = normalize_ports(p.ports)?;
569    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
570    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_SCAN_TIMEOUT_MS);
571    let cfg = netscli_core::OpsConfig {
572        concurrency,
573        scan_timeout_ms: timeout_ms,
574        ping_timeout_ms: timeout_ms,
575        dns_timeout_ms: timeout_ms,
576    };
577    let ops = netscli_core::Ops::new(cfg);
578    let (_subnet, res) = ops
579        .sweep_ipv4(p.subnet, ports, p.resolve_hostnames.unwrap_or(false))
580        .await
581        .map_err(|e| RpcError::ToolError(e.to_string()))?;
582    Ok(res)
583}
584
585async fn op_ping_host(p: PingHostParams) -> Result<netscli_core::PingSummary, RpcError> {
586    if p.host.trim().is_empty() {
587        return Err(RpcError::InvalidParams("host is required".to_string()));
588    }
589    let _ = p.max_concurrent; // retained for forward-compat with older clients
590    let count = p.count.unwrap_or(1).clamp(1, 256);
591    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_PING_TIMEOUT_MS);
592
593    // Use the Ops facade so the summary (loss %, min/avg/max RTT) matches
594    // what `netscli ping` emits from the CLI.
595    let cfg = netscli_core::OpsConfig {
596        ping_timeout_ms: timeout_ms,
597        dns_timeout_ms: timeout_ms,
598        ..Default::default()
599    };
600    let ops = netscli_core::Ops::new(cfg);
601    ops.ping_host_summary(&p.host, count)
602        .await
603        .map_err(|e| RpcError::ToolError(e.to_string()))
604}
605
606async fn op_dns_lookup(p: DnsParams) -> Result<Vec<netscli_core::dns::DnsRecord>, RpcError> {
607    if let Some(ref t) = p.record_type {
608        let upper = t.to_uppercase();
609        if upper != "ALL"
610            && upper != "ANY"
611            && netscli_core::dns::parse_record_type(&upper).is_none()
612        {
613            return Err(RpcError::InvalidParams(format!(
614                "unsupported record type: {t}"
615            )));
616        }
617    }
618    let ops = netscli_core::Ops::default();
619    ops.dns_lookup(&p.host, p.record_type)
620        .await
621        .map_err(|e| RpcError::ToolError(e.to_string()))
622}
623
624fn op_get_arp_table() -> Result<Vec<netscli_core::ArpEntry>, RpcError> {
625    let ops = netscli_core::Ops::default();
626    ops.get_arp_table()
627        .map_err(|e| RpcError::ToolError(e.to_string()))
628}
629
630fn op_list_interfaces() -> Vec<netscli_core::InterfaceInfo> {
631    let ops = netscli_core::Ops::default();
632    ops.list_interfaces()
633}
634
635#[cfg(feature = "pcap")]
636async fn op_capture_pcap(p: PcapParams) -> Result<netscli_core::PcapResult, RpcError> {
637    if p.interface.trim().is_empty() {
638        return Err(RpcError::InvalidParams("interface is required".to_string()));
639    }
640    let duration = p.duration.map(|s| s.clamp(1, 60 * 60));
641    let max_packets = match p.max_packets {
642        Some(0) => None,
643        Some(n) => {
644            if n > (usize::MAX as u64) {
645                return Err(RpcError::InvalidParams("maxPackets too large".to_string()));
646            }
647            Some(n as usize)
648        }
649        None => None,
650    };
651    let ops = netscli_core::Ops::default();
652    ops.capture_pcap_async(p.interface, p.filter, duration, p.output_file, max_packets)
653        .await
654        .map_err(|e| RpcError::ToolError(e.to_string()))
655}
656
657#[cfg(feature = "mdns")]
658async fn op_discover_mdns(p: MdnsParams) -> Result<Vec<netscli_core::MdnsService>, RpcError> {
659    // Clamp timeout: no point waiting more than 30s for an interactive-like
660    // tool call, and 0/None means use the 3s default.
661    let timeout_ms = p.timeout_ms.unwrap_or(3000).clamp(100, 30_000);
662    let service_types = p.service_types.unwrap_or_default();
663    let ops = netscli_core::Ops::default();
664    ops.discover_mdns(&service_types, std::time::Duration::from_millis(timeout_ms))
665        .await
666        .map_err(|e| RpcError::ToolError(e.to_string()))
667}
668
669#[cfg(not(feature = "pcap"))]
670async fn op_capture_pcap(_p: PcapParams) -> Result<netscli_core::PcapResult, RpcError> {
671    Err(RpcError::ToolError(
672        "pcap support disabled at compile time".to_string(),
673    ))
674}
675
676async fn handle_request_inner(
677    state: &mut ServerState,
678    req: &JsonRpcRequest,
679) -> Result<serde_json::Value, RpcError> {
680    if req.jsonrpc != "2.0" {
681        return Err(RpcError::InvalidRequest(format!(
682            "expected jsonrpc='2.0', got '{}'",
683            req.jsonrpc
684        )));
685    }
686
687    // Enforce MCP init lifecycle for requests.
688    if !state.initialized && req.method != "initialize" && req.method != "tools/list" {
689        return Err(RpcError::NotInitialized);
690    }
691
692    let params = req.params.clone().unwrap_or(Value::Null);
693
694    match req.method.as_str() {
695        // MCP lifecycle
696        "initialize" => {
697            let p: InitializeParams = parse_params(params)?;
698            let protocol = p
699                .protocol_version
700                .unwrap_or_else(|| "2024-11-05".to_string());
701            state.initialized = true;
702            Ok(json!({
703                "protocolVersion": protocol,
704                "capabilities": { "tools": {} },
705                "serverInfo": { "name": "netscli", "version": env!("CARGO_PKG_VERSION") }
706            }))
707        }
708        "tools/list" => Ok(tools_list()),
709        "tools/call" => {
710            #[derive(Deserialize)]
711            struct ToolCallParams {
712                name: String,
713                #[serde(default, rename = "arguments")]
714                args: serde_json::Value,
715            }
716
717            let p: ToolCallParams = parse_params(params)?;
718            let args = if p.args.is_null() {
719                Value::Object(serde_json::Map::new())
720            } else {
721                p.args
722            };
723
724            let output = match p.name.as_str() {
725                "discover_network" => {
726                    let p: DiscoverParams = parse_params(args)?;
727                    let hosts = op_discover(p).await?;
728                    json!(hosts)
729                }
730                "scan_ports" => {
731                    let p: ScanParams = parse_params(args)?;
732                    let res = op_scan_ports(p).await?;
733                    let open: Vec<_> = res.into_iter().filter(|r| r.open).collect();
734                    json!(open)
735                }
736                "ping_host" => {
737                    let p: PingHostParams = parse_params(args)?;
738                    let res = op_ping_host(p).await?;
739                    json!(res)
740                }
741                "dns_lookup" => {
742                    let p: DnsParams = parse_params(args)?;
743                    let res = op_dns_lookup(p).await?;
744                    json!(res)
745                }
746                "get_arp_table" => {
747                    json!(op_get_arp_table()?)
748                }
749                "inspect_host" => {
750                    let p: ScanParams = parse_params(args)?;
751                    let res = op_inspect_host(p).await?;
752                    json!(res)
753                }
754                "sweep_network" => {
755                    let p: SweepParams = parse_params(args)?;
756                    let res = op_sweep(p).await?;
757                    json!(res)
758                }
759                "list_network_interfaces" => {
760                    json!(op_list_interfaces())
761                }
762                "capture_pcap" => {
763                    let p: PcapParams = parse_params(args)?;
764                    let res = op_capture_pcap(p).await?;
765                    json!(res)
766                }
767                #[cfg(feature = "mdns")]
768                "discover_mdns" => {
769                    let p: MdnsParams = parse_params(args)?;
770                    let res = op_discover_mdns(p).await?;
771                    json!(res)
772                }
773                other => {
774                    return Err(RpcError::InvalidParams(format!("Unknown tool: {other}")));
775                }
776            };
777
778            Ok(mcp_tool_result_text(output))
779        }
780
781        // Backwards-compatible direct JSON-RPC methods
782        "discover_network" => {
783            let p: DiscoverParams = parse_params(params)?;
784            let hosts = op_discover(p).await?;
785            serde_json::to_value(hosts).map_err(|e| RpcError::Internal(e.to_string()))
786        }
787        "scan_ports" => {
788            let p: ScanParams = parse_params(params)?;
789            let res = op_scan_ports(p).await?;
790            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
791        }
792        "ping_host" => {
793            let p: PingHostParams = parse_params(params)?;
794            let res = op_ping_host(p).await?;
795            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
796        }
797        "dns_lookup" => {
798            let p: DnsParams = parse_params(params)?;
799            let res = op_dns_lookup(p).await?;
800            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
801        }
802        "get_arp_table" => {
803            serde_json::to_value(op_get_arp_table()?).map_err(|e| RpcError::Internal(e.to_string()))
804        }
805        "inspect_host" => {
806            let p: ScanParams = parse_params(params)?;
807            let res = op_inspect_host(p).await?;
808            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
809        }
810        "sweep_network" => {
811            let p: SweepParams = parse_params(params)?;
812            let res = op_sweep(p).await?;
813            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
814        }
815        "list_network_interfaces" => serde_json::to_value(op_list_interfaces())
816            .map_err(|e| RpcError::Internal(e.to_string())),
817        "capture_pcap" => {
818            let p: PcapParams = parse_params(params)?;
819            let res = op_capture_pcap(p).await?;
820            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
821        }
822        #[cfg(feature = "mdns")]
823        "discover_mdns" => {
824            let p: MdnsParams = parse_params(params)?;
825            let res = op_discover_mdns(p).await?;
826            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
827        }
828        _ => Err(RpcError::MethodNotFound),
829    }
830}