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/// Initialize a tracing subscriber that writes JSON to stderr.
196///
197/// Uses `RUST_LOG` if set, otherwise defaults to `info`. Uses `try_init`
198/// so it's a no-op when the caller already installed a subscriber (e.g.
199/// a host binary that wants its own format).
200///
201/// **Why stderr, not stdout?** stdout is the JSON-RPC transport — any
202/// byte written there that isn't a valid response will break the client.
203fn init_tracing() {
204    use tracing_subscriber::{fmt, EnvFilter};
205
206    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
207
208    let _ = fmt()
209        .with_writer(std::io::stderr)
210        .with_env_filter(filter)
211        .with_target(false)
212        .json()
213        .with_current_span(false)
214        .with_span_list(false)
215        .try_init();
216}
217
218pub async fn run_server() -> anyhow::Result<()> {
219    init_tracing();
220    tracing::info!(
221        version = env!("CARGO_PKG_VERSION"),
222        pcap = cfg!(feature = "pcap"),
223        "netscli MCP server starting"
224    );
225
226    let stdin = io::stdin();
227    let stdout = io::stdout();
228    let mut reader = BufReader::new(stdin).lines();
229    let mut writer = BufWriter::new(stdout);
230    let mut state = ServerState::default();
231    let mut requests_handled: u64 = 0;
232
233    while let Some(line) = reader.next_line().await? {
234        if line.trim().is_empty() {
235            continue;
236        }
237
238        let request = match serde_json::from_str::<JsonRpcRequest>(&line) {
239            Ok(req) => req,
240            Err(e) => {
241                tracing::warn!(error = %e, "json parse error");
242                // Parse error. JSON-RPC expects id=null.
243                let response = JsonRpcResponse {
244                    jsonrpc: "2.0".to_string(),
245                    result: None,
246                    error: Some(JsonRpcError {
247                        code: -32700,
248                        message: "Parse error".to_string(),
249                    }),
250                    id: Some(serde_json::Value::Null),
251                };
252                let response_str = serde_json::to_string(&response)?;
253                writer.write_all(response_str.as_bytes()).await?;
254                writer.write_all(b"\n").await?;
255                writer.flush().await?;
256                continue;
257            }
258        };
259
260        // Notifications (no id) must not get a response.
261        if request.id.is_none() {
262            tracing::debug!(method = %request.method, "notification");
263            if request.method == "notifications/initialized" {
264                state.initialized = true;
265            }
266            continue;
267        }
268
269        let response = handle_request(&mut state, request).await;
270        let response_str = serde_json::to_string(&response)?;
271        writer.write_all(response_str.as_bytes()).await?;
272        writer.write_all(b"\n").await?;
273        writer.flush().await?;
274        requests_handled += 1;
275    }
276
277    tracing::info!(requests_handled, "netscli MCP server shutting down");
278    Ok(())
279}
280
281async fn handle_request(state: &mut ServerState, req: JsonRpcRequest) -> JsonRpcResponse {
282    let id = req.id.clone();
283    let method = req.method.clone();
284    // For tools/call the caller-visible operation is the tool name; surface it
285    // so log consumers can grep a single tool without regex-parsing params.
286    let tool_name: Option<String> = if method == "tools/call" {
287        req.params
288            .as_ref()
289            .and_then(|p| p.get("name"))
290            .and_then(|n| n.as_str())
291            .map(|s| s.to_string())
292    } else {
293        None
294    };
295
296    let start = std::time::Instant::now();
297    let mut response = JsonRpcResponse {
298        jsonrpc: "2.0".to_string(),
299        result: None,
300        error: None,
301        id,
302    };
303
304    match handle_request_inner(state, &req).await {
305        Ok(val) => {
306            response.result = Some(val);
307        }
308        Err(e) => {
309            response.error = Some(JsonRpcError {
310                code: e.code(),
311                message: e.to_string(),
312            });
313        }
314    }
315
316    let duration_ms = start.elapsed().as_millis() as u64;
317    match response.error.as_ref() {
318        None => tracing::info!(
319            method = %method,
320            tool = tool_name.as_deref(),
321            duration_ms,
322            "ok"
323        ),
324        Some(err) => tracing::warn!(
325            method = %method,
326            tool = tool_name.as_deref(),
327            duration_ms,
328            code = err.code,
329            message = %err.message,
330            "error"
331        ),
332    }
333
334    response
335}
336
337pub fn tools_list() -> serde_json::Value {
338    let tools = vec![
339        json!({
340            "name": "discover_network",
341            "description": "Discover live hosts on a network subnet",
342            "inputSchema": {
343                "type": "object",
344                "properties": {
345                    "subnet": { "type": "string", "default": "192.168.1.0/24" },
346                    "resolveHostnames": { "type": "boolean", "default": false },
347                    "timeout": { "type": "number", "default": 1000 },
348                    "maxConcurrent": { "type": "number", "default": 256 }
349                }
350            }
351        }),
352        json!({
353            "name": "scan_ports",
354            "description": "Scan TCP ports on a host",
355            "inputSchema": {
356                "type": "object",
357                "properties": {
358                    "host": { "type": "string" },
359                    "ports": { "type": "array", "items": { "type": "number" } },
360                    "timeout": { "type": "number", "default": 500 },
361                    "maxConcurrent": { "type": "number", "default": 256 }
362                },
363                "required": ["host"]
364            }
365        }),
366        json!({
367            "name": "ping_host",
368            "description": "Ping a host (ICMP with TCP-connect fallback). Returns a PingSummary with aggregate loss and min/avg/max RTT when count > 1.",
369            "inputSchema": {
370                "type": "object",
371                "properties": {
372                    "host": { "type": "string" },
373                    "count": { "type": "number", "default": 1, "minimum": 1, "maximum": 256 },
374                    "timeout": { "type": "number", "default": 1000 },
375                    "maxConcurrent": { "type": "number", "default": 64 }
376                },
377                "required": ["host"]
378            }
379        }),
380        json!({
381            "name": "dns_lookup",
382            "description": "DNS lookup (A, AAAA, CNAME, MX, NS, TXT, SRV, PTR, SOA, CAA, or ALL/ANY for every record type)",
383            "inputSchema": {
384                "type": "object",
385                "properties": {
386                    "host": { "type": "string" },
387                    "type": {
388                        "type": "string",
389                        "default": "A",
390                        "enum": ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SRV", "PTR", "SOA", "CAA", "ALL", "ANY"]
391                    }
392                },
393                "required": ["host"]
394            }
395        }),
396        json!({
397            "name": "get_arp_table",
398            "description": "Get ARP/neighbor table with vendor information",
399            "inputSchema": {
400                "type": "object",
401                "properties": {}
402            }
403        }),
404        json!({
405            "name": "inspect_host",
406            "description": "Inspect a host (ping + port scan + optional DNS resolution)",
407            "inputSchema": {
408                "type": "object",
409                "properties": {
410                    "host": { "type": "string" },
411                    "ports": { "type": "array", "items": { "type": "number" } }
412                },
413                "required": ["host"]
414            }
415        }),
416        json!({
417            "name": "sweep_network",
418            "description": "Sweep a network (discover hosts then scan ports)",
419            "inputSchema": {
420                "type": "object",
421                "properties": {
422                    "subnet": { "type": "string", "default": "192.168.1.0/24" },
423                    "ports": { "type": "array", "items": { "type": "number" } },
424                    "resolveHostnames": { "type": "boolean", "default": false },
425                    "timeout": { "type": "number", "default": 500 },
426                    "maxConcurrent": { "type": "number", "default": 256 }
427                }
428            }
429        }),
430        json!({
431            "name": "list_network_interfaces",
432            "description": "List network interfaces with details",
433            "inputSchema": {
434                "type": "object",
435                "properties": {}
436            }
437        }),
438    ];
439
440    #[cfg(feature = "pcap")]
441    let tools = {
442        let mut tools = tools;
443        tools.push(json!({
444            "name": "capture_pcap",
445            "description": "Capture network packets to a PCAP file (may require root/admin)",
446            "inputSchema": {
447                "type": "object",
448                "properties": {
449                    "interface": { "type": "string" },
450                    "filter": { "type": "string" },
451                    "duration": { "type": "number", "default": 10 },
452                    "outputFile": { "type": "string", "default": "capture.pcap" },
453                    "maxPackets": { "type": "number" }
454                },
455                "required": ["interface"]
456            }
457        }));
458        tools
459    };
460
461    json!({ "tools": tools })
462}
463
464fn mcp_tool_result_text(val: serde_json::Value) -> serde_json::Value {
465    json!({
466        "content": [
467            {
468                "type": "text",
469                "text": serde_json::to_string_pretty(&val).unwrap_or_else(|_| "<serialization error>".to_string())
470            }
471        ]
472    })
473}
474
475// NOTE: Hostname/IP resolution is shared in netscli-core (`netscli_core::resolve_host_ip`).
476
477async fn op_discover(p: DiscoverParams) -> Result<Vec<netscli_core::Host>, RpcError> {
478    if let Some(ref subnet) = p.subnet {
479        validate_subnet(subnet)?;
480    }
481    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
482    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_PING_TIMEOUT_MS);
483    let cfg = netscli_core::OpsConfig {
484        concurrency,
485        ping_timeout_ms: timeout_ms,
486        dns_timeout_ms: timeout_ms,
487        ..Default::default()
488    };
489    let ops = netscli_core::Ops::new(cfg);
490    let (_subnet, hosts) = ops
491        .discover_ipv4(p.subnet, p.resolve_hostnames.unwrap_or(false))
492        .await
493        .map_err(|e| RpcError::ToolError(e.to_string()))?;
494    Ok(hosts)
495}
496
497async fn op_scan_ports(p: ScanParams) -> Result<Vec<netscli_core::PortResult>, RpcError> {
498    let ports = normalize_ports(p.ports)?;
499    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
500    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_SCAN_TIMEOUT_MS);
501    let cfg = netscli_core::OpsConfig {
502        concurrency,
503        scan_timeout_ms: timeout_ms,
504        ..Default::default()
505    };
506    let ops = netscli_core::Ops::new(cfg);
507    let (_ip, res) = ops
508        .scan_ports(&p.host, ports)
509        .await
510        .map_err(|e| RpcError::ToolError(e.to_string()))?;
511    Ok(res)
512}
513
514async fn op_inspect_host(p: ScanParams) -> Result<netscli_core::InspectResult, RpcError> {
515    let ports = normalize_ports(p.ports)?;
516    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
517    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_SCAN_TIMEOUT_MS);
518    let cfg = netscli_core::OpsConfig {
519        concurrency,
520        scan_timeout_ms: timeout_ms,
521        ping_timeout_ms: timeout_ms,
522        dns_timeout_ms: timeout_ms,
523    };
524    let ops = netscli_core::Ops::new(cfg);
525    ops.inspect_host(p.host, ports)
526        .await
527        .map_err(|e| RpcError::ToolError(e.to_string()))
528}
529
530async fn op_sweep(p: SweepParams) -> Result<Vec<netscli_core::SweepEntry>, RpcError> {
531    if let Some(ref subnet) = p.subnet {
532        validate_subnet(subnet)?;
533    }
534    let ports = normalize_ports(p.ports)?;
535    let concurrency = clamp_concurrency(p.max_concurrent, netscli_core::DEFAULT_CONCURRENCY);
536    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_SCAN_TIMEOUT_MS);
537    let cfg = netscli_core::OpsConfig {
538        concurrency,
539        scan_timeout_ms: timeout_ms,
540        ping_timeout_ms: timeout_ms,
541        dns_timeout_ms: timeout_ms,
542    };
543    let ops = netscli_core::Ops::new(cfg);
544    let (_subnet, res) = ops
545        .sweep_ipv4(p.subnet, ports, p.resolve_hostnames.unwrap_or(false))
546        .await
547        .map_err(|e| RpcError::ToolError(e.to_string()))?;
548    Ok(res)
549}
550
551async fn op_ping_host(p: PingHostParams) -> Result<netscli_core::PingSummary, RpcError> {
552    if p.host.trim().is_empty() {
553        return Err(RpcError::InvalidParams("host is required".to_string()));
554    }
555    let _ = p.max_concurrent; // retained for forward-compat with older clients
556    let count = p.count.unwrap_or(1).clamp(1, 256);
557    let timeout_ms = clamp_timeout_ms(p.timeout, netscli_core::DEFAULT_PING_TIMEOUT_MS);
558
559    // Use the Ops facade so the summary (loss %, min/avg/max RTT) matches
560    // what `netscli ping` emits from the CLI.
561    let cfg = netscli_core::OpsConfig {
562        ping_timeout_ms: timeout_ms,
563        dns_timeout_ms: timeout_ms,
564        ..Default::default()
565    };
566    let ops = netscli_core::Ops::new(cfg);
567    ops.ping_host_summary(&p.host, count)
568        .await
569        .map_err(|e| RpcError::ToolError(e.to_string()))
570}
571
572async fn op_dns_lookup(p: DnsParams) -> Result<Vec<netscli_core::dns::DnsRecord>, RpcError> {
573    if let Some(ref t) = p.record_type {
574        let upper = t.to_uppercase();
575        if upper != "ALL"
576            && upper != "ANY"
577            && netscli_core::dns::parse_record_type(&upper).is_none()
578        {
579            return Err(RpcError::InvalidParams(format!(
580                "unsupported record type: {t}"
581            )));
582        }
583    }
584    let ops = netscli_core::Ops::default();
585    ops.dns_lookup(&p.host, p.record_type)
586        .await
587        .map_err(|e| RpcError::ToolError(e.to_string()))
588}
589
590fn op_get_arp_table() -> Result<Vec<netscli_core::ArpEntry>, RpcError> {
591    let ops = netscli_core::Ops::default();
592    ops.get_arp_table()
593        .map_err(|e| RpcError::ToolError(e.to_string()))
594}
595
596fn op_list_interfaces() -> Vec<netscli_core::InterfaceInfo> {
597    let ops = netscli_core::Ops::default();
598    ops.list_interfaces()
599}
600
601#[cfg(feature = "pcap")]
602async fn op_capture_pcap(p: PcapParams) -> Result<netscli_core::PcapResult, RpcError> {
603    if p.interface.trim().is_empty() {
604        return Err(RpcError::InvalidParams("interface is required".to_string()));
605    }
606    let duration = p.duration.map(|s| s.clamp(1, 60 * 60));
607    let max_packets = match p.max_packets {
608        Some(0) => None,
609        Some(n) => {
610            if n > (usize::MAX as u64) {
611                return Err(RpcError::InvalidParams("maxPackets too large".to_string()));
612            }
613            Some(n as usize)
614        }
615        None => None,
616    };
617    let ops = netscli_core::Ops::default();
618    ops.capture_pcap_async(p.interface, p.filter, duration, p.output_file, max_packets)
619        .await
620        .map_err(|e| RpcError::ToolError(e.to_string()))
621}
622
623#[cfg(not(feature = "pcap"))]
624async fn op_capture_pcap(_p: PcapParams) -> Result<netscli_core::PcapResult, RpcError> {
625    Err(RpcError::ToolError(
626        "pcap support disabled at compile time".to_string(),
627    ))
628}
629
630async fn handle_request_inner(
631    state: &mut ServerState,
632    req: &JsonRpcRequest,
633) -> Result<serde_json::Value, RpcError> {
634    if req.jsonrpc != "2.0" {
635        return Err(RpcError::InvalidRequest(format!(
636            "expected jsonrpc='2.0', got '{}'",
637            req.jsonrpc
638        )));
639    }
640
641    // Enforce MCP init lifecycle for requests.
642    if !state.initialized && req.method != "initialize" && req.method != "tools/list" {
643        return Err(RpcError::NotInitialized);
644    }
645
646    let params = req.params.clone().unwrap_or(Value::Null);
647
648    match req.method.as_str() {
649        // MCP lifecycle
650        "initialize" => {
651            let p: InitializeParams = parse_params(params)?;
652            let protocol = p
653                .protocol_version
654                .unwrap_or_else(|| "2024-11-05".to_string());
655            state.initialized = true;
656            Ok(json!({
657                "protocolVersion": protocol,
658                "capabilities": { "tools": {} },
659                "serverInfo": { "name": "netscli", "version": env!("CARGO_PKG_VERSION") }
660            }))
661        }
662        "tools/list" => Ok(tools_list()),
663        "tools/call" => {
664            #[derive(Deserialize)]
665            struct ToolCallParams {
666                name: String,
667                #[serde(default, rename = "arguments")]
668                args: serde_json::Value,
669            }
670
671            let p: ToolCallParams = parse_params(params)?;
672            let args = if p.args.is_null() {
673                Value::Object(serde_json::Map::new())
674            } else {
675                p.args
676            };
677
678            let output = match p.name.as_str() {
679                "discover_network" => {
680                    let p: DiscoverParams = parse_params(args)?;
681                    let hosts = op_discover(p).await?;
682                    json!(hosts)
683                }
684                "scan_ports" => {
685                    let p: ScanParams = parse_params(args)?;
686                    let res = op_scan_ports(p).await?;
687                    let open: Vec<_> = res.into_iter().filter(|r| r.open).collect();
688                    json!(open)
689                }
690                "ping_host" => {
691                    let p: PingHostParams = parse_params(args)?;
692                    let res = op_ping_host(p).await?;
693                    json!(res)
694                }
695                "dns_lookup" => {
696                    let p: DnsParams = parse_params(args)?;
697                    let res = op_dns_lookup(p).await?;
698                    json!(res)
699                }
700                "get_arp_table" => {
701                    json!(op_get_arp_table()?)
702                }
703                "inspect_host" => {
704                    let p: ScanParams = parse_params(args)?;
705                    let res = op_inspect_host(p).await?;
706                    json!(res)
707                }
708                "sweep_network" => {
709                    let p: SweepParams = parse_params(args)?;
710                    let res = op_sweep(p).await?;
711                    json!(res)
712                }
713                "list_network_interfaces" => {
714                    json!(op_list_interfaces())
715                }
716                "capture_pcap" => {
717                    let p: PcapParams = parse_params(args)?;
718                    let res = op_capture_pcap(p).await?;
719                    json!(res)
720                }
721                other => {
722                    return Err(RpcError::InvalidParams(format!("Unknown tool: {other}")));
723                }
724            };
725
726            Ok(mcp_tool_result_text(output))
727        }
728
729        // Backwards-compatible direct JSON-RPC methods
730        "discover_network" => {
731            let p: DiscoverParams = parse_params(params)?;
732            let hosts = op_discover(p).await?;
733            serde_json::to_value(hosts).map_err(|e| RpcError::Internal(e.to_string()))
734        }
735        "scan_ports" => {
736            let p: ScanParams = parse_params(params)?;
737            let res = op_scan_ports(p).await?;
738            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
739        }
740        "ping_host" => {
741            let p: PingHostParams = parse_params(params)?;
742            let res = op_ping_host(p).await?;
743            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
744        }
745        "dns_lookup" => {
746            let p: DnsParams = parse_params(params)?;
747            let res = op_dns_lookup(p).await?;
748            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
749        }
750        "get_arp_table" => {
751            serde_json::to_value(op_get_arp_table()?).map_err(|e| RpcError::Internal(e.to_string()))
752        }
753        "inspect_host" => {
754            let p: ScanParams = parse_params(params)?;
755            let res = op_inspect_host(p).await?;
756            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
757        }
758        "sweep_network" => {
759            let p: SweepParams = parse_params(params)?;
760            let res = op_sweep(p).await?;
761            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
762        }
763        "list_network_interfaces" => serde_json::to_value(op_list_interfaces())
764            .map_err(|e| RpcError::Internal(e.to_string())),
765        "capture_pcap" => {
766            let p: PcapParams = parse_params(params)?;
767            let res = op_capture_pcap(p).await?;
768            serde_json::to_value(res).map_err(|e| RpcError::Internal(e.to_string()))
769        }
770        _ => Err(RpcError::MethodNotFound),
771    }
772}