From 127c785638bed3c4f9644167e90de72bb29cef3d Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Sat, 3 Oct 2026 20:14:35 +0100 Subject: [PATCH] MCP: leave closed and filtered ports out of scan replies, send compact JSON scan_ports and inspect_host returned one object per scanned port. A 1,024-port scan of a LAN machine with two open ports was 136,834 bytes, almost all "closed". They now keep open, open|filtered and errored ports unless include_closed is true: the same scan is 178 bytes, 93,799 with include_closed. Tool results are serialized compactly rather than pretty-printed, which took a sweep of the local network from 15,399 bytes to 8,746. The MCP docs example is re-captured from a real session on this build. --- CHANGELOG.md | 8 ++++++ crates/netscli-mcp/README.md | 2 +- crates/netscli-mcp/src/server/operations.rs | 32 ++++++++++++++++++++- crates/netscli-mcp/src/server/schemas.rs | 3 ++ crates/netscli-mcp/src/server/tools.rs | 8 ++++-- site/src/content/docs/docs/mcp.md | 26 +++++++++++------ 6 files changed, 66 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2740e4..880b3912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,14 @@ its heading and collects entries; the date and the link go on with the tag. ### Changed +- **MCP scan replies leave out closed and filtered ports.** `scan_ports` and + `inspect_host` returned one entry for every port scanned, so a 1,024-port + scan of a machine with two open ports came to 136,834 bytes of mostly + "closed". They now return open, `open|filtered` and errored ports only, + which made the same scan 178 bytes. Pass `include_closed: true` for every + port. Replies are also sent as compact JSON instead of indented, which + took a sweep of the local network from 15,399 bytes to 8,746. + - **Discovery reads the Windows device table directly** instead of running `arp -a` and parsing its text. On an idle machine that makes no measurable difference (`arp -a` took 65 ms), but it removes a program start from every diff --git a/crates/netscli-mcp/README.md b/crates/netscli-mcp/README.md index b5f90c99..c898b878 100644 --- a/crates/netscli-mcp/README.md +++ b/crates/netscli-mcp/README.md @@ -12,7 +12,7 @@ same MCP surface inside a different host process. ## Exposed tools - `discover_network` — live hosts on a subnet. -- `scan_ports` — TCP port scan on a host, or UDP with `udp: true`. +- `scan_ports` — TCP port scan on a host, or UDP with `udp: true`. Open ports only unless `include_closed: true`. - `ping_host` — ping with packet-loss and RTT statistics. - `dns_lookup` — forward DNS, all record types. Reverse lookups are not exposed over MCP. diff --git a/crates/netscli-mcp/src/server/operations.rs b/crates/netscli-mcp/src/server/operations.rs index 6582a2c9..9ed83472 100644 --- a/crates/netscli-mcp/src/server/operations.rs +++ b/crates/netscli-mcp/src/server/operations.rs @@ -85,10 +85,27 @@ pub(super) async fn op_scan_ports( } else { ops.scan_ports(&ip.to_string(), ports).await }; - let (_ip, res) = scan.map_err(|e| RpcError::ToolError(e.to_string()))?; + let (_ip, mut res) = scan.map_err(|e| RpcError::ToolError(e.to_string()))?; + if !p.include_closed.unwrap_or(false) { + drop_unanswered_ports(&mut res); + } Ok(res) } +/// Keep the ports worth a model's attention: open, `open|filtered`, and any +/// that errored. Closed and filtered ports are what most of a scan is, and +/// they arrived one object each. A 1,024-port scan of a LAN machine with two +/// open ports was 136,834 bytes (measured 2026-10-03), nearly all of it +/// saying "closed". `include_closed: true` returns every port. +fn drop_unanswered_ports(ports: &mut Vec) { + ports.retain(|p| worth_reporting(p.status)); +} + +fn worth_reporting(status: netscli_core::PortStatus) -> bool { + use netscli_core::PortStatus; + !matches!(status, PortStatus::Closed | PortStatus::Filtered) +} + pub(super) async fn op_inspect_host( p: ScanParams, ) -> Result { @@ -110,6 +127,9 @@ pub(super) async fn op_inspect_host( // `host` is documented as the original target, so give the caller back // what it asked for. The address above is what was probed. result.host = p.host.trim().to_string(); + if !p.include_closed.unwrap_or(false) { + drop_unanswered_ports(&mut result.ports); + } Ok(result) } @@ -211,6 +231,16 @@ pub(super) async fn op_discover_mdns( mod tests { use super::*; + #[test] + fn closed_and_filtered_ports_are_dropped_and_the_rest_kept() { + use netscli_core::PortStatus; + assert!(worth_reporting(PortStatus::Open)); + assert!(worth_reporting(PortStatus::OpenFiltered)); + assert!(worth_reporting(PortStatus::Error)); + assert!(!worth_reporting(PortStatus::Closed)); + assert!(!worth_reporting(PortStatus::Filtered)); + } + #[test] fn a_publicly_addressed_interface_is_refused_rather_than_scanned() { // The case this whole change exists for. Before, the default was diff --git a/crates/netscli-mcp/src/server/schemas.rs b/crates/netscli-mcp/src/server/schemas.rs index 1f571556..62984544 100644 --- a/crates/netscli-mcp/src/server/schemas.rs +++ b/crates/netscli-mcp/src/server/schemas.rs @@ -114,6 +114,9 @@ pub(super) struct ScanParams { /// `scan_ports` only: probe UDP instead of TCP. `inspect_host` shares /// this struct and ignores it. pub(super) udp: Option, + /// Return closed and filtered ports too. Off by default: see + /// `drop_unanswered_ports`. + pub(super) include_closed: Option, } #[derive(Deserialize)] diff --git a/crates/netscli-mcp/src/server/tools.rs b/crates/netscli-mcp/src/server/tools.rs index 0ac2ca8c..e2e1494b 100644 --- a/crates/netscli-mcp/src/server/tools.rs +++ b/crates/netscli-mcp/src/server/tools.rs @@ -26,7 +26,7 @@ pub fn tools_list() -> serde_json::Value { }), json!({ "name": "scan_ports", - "description": "Scan TCP ports on a host, or UDP ports with udp: true. UDP without ports probes DNS, NTP, NetBIOS, SSDP and mDNS; a UDP port that neither replies nor refuses is open|filtered.", + "description": "Scan TCP ports on a host, or UDP ports with udp: true. UDP without ports probes DNS, NTP, NetBIOS, SSDP and mDNS; a UDP port that neither replies nor refuses is open|filtered. Returns only open, open|filtered and errored ports unless include_closed is true, so an empty list means every port scanned was closed or filtered.", "inputSchema": { "type": "object", "properties": { @@ -37,6 +37,7 @@ pub fn tools_list() -> serde_json::Value { "maxItems": 4096 }, "udp": { "type": "boolean", "default": false }, + "include_closed": { "type": "boolean", "default": false, "description": "Also return closed and filtered ports, one entry each." }, "timeout": { "type": "number", "default": 500, "minimum": 10, "maximum": 600000 }, "maxConcurrent": { "type": "number", "default": 256, "minimum": 1, "maximum": 1024 } }, @@ -102,7 +103,7 @@ pub fn tools_list() -> serde_json::Value { }), json!({ "name": "inspect_host", - "description": "Inspect a host: ping, port scan, reverse DNS, MAC vendor on the local segment, and an OS hint (family, detail and the evidence behind it, from SMB, SSH/HTTP banners, open ports, MAC vendor and ping TTL). The hint is a guess, not a fingerprint.", + "description": "Inspect a host: ping, port scan, reverse DNS, MAC vendor on the local segment, and an OS hint (family, detail and the evidence behind it, from SMB, SSH/HTTP banners, open ports, MAC vendor and ping TTL). The hint is a guess, not a fingerprint. ports lists only open, open|filtered and errored ports unless include_closed is true.", "inputSchema": { "type": "object", "properties": { @@ -113,6 +114,7 @@ pub fn tools_list() -> serde_json::Value { "maxItems": 4096 }, "timeout": { "type": "number", "default": 500, "minimum": 10, "maximum": 600000 }, + "include_closed": { "type": "boolean", "default": false, "description": "Also list closed and filtered ports in ports, one entry each." }, "maxConcurrent": { "type": "number", "default": 256, "minimum": 1, "maximum": 1024 } }, "required": ["host"] @@ -273,7 +275,7 @@ pub(super) fn mcp_tool_result_text(val: serde_json::Value) -> serde_json::Value "content": [ { "type": "text", - "text": serde_json::to_string_pretty(&val).unwrap_or_else(|_| "".to_string()) + "text": serde_json::to_string(&val).unwrap_or_else(|_| "".to_string()) } ] }) diff --git a/site/src/content/docs/docs/mcp.md b/site/src/content/docs/docs/mcp.md index d1d806de..6fab867e 100644 --- a/site/src/content/docs/docs/mcp.md +++ b/site/src/content/docs/docs/mcp.md @@ -79,7 +79,7 @@ there is nothing to look at until a client connects. | Tool | Purpose | | --- | --- | | `discover_network` | Discover reachable hosts on a subnet. | -| `scan_ports` | Scan TCP ports on a host, or UDP services with `udp: true`. | +| `scan_ports` | Scan TCP ports on a host, or UDP services with `udp: true`. Returns open ports only unless `include_closed` is true. | | `ping_host` | Check reachability and latency. | | `dns_lookup` | Query DNS records. | | `get_arp_table` | Read the local ARP neighbor cache. | @@ -98,20 +98,24 @@ Tool inputs stay stable. Structured output may gain additive fields as the share Captured from a real session against loopback. The client writes one JSON object per line to stdin, and the server answers on stdout. Most clients do this for you, and this is what they are exchanging. -Opening the connection: +Opening the connection, then telling the server the client is ready: ```json {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"docs","version":"1"}}} ``` ```json -{"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"netscli","version":"0.3.1"}},"error":null,"id":1} +{"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"netscli","version":"0.3.4"}},"error":null,"id":1} +``` + +```json +{"jsonrpc":"2.0","method":"notifications/initialized"} ``` Calling a tool: ```json -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"scan_ports","arguments":{"host":"127.0.0.1","ports":[22,80,443]}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"scan_ports","arguments":{"host":"127.0.0.1","ports":[22,135,445]}}} ``` The result arrives as MCP text content whose body is the same JSON the CLI @@ -123,8 +127,8 @@ would print, so a model reads one shape wherever it came from: "result": { "content": [ { - "type": "text", - "text": "[\n {\n \"open\": false,\n \"port\": 22,\n \"service\": \"ssh\",\n \"status\": \"filtered\"\n },\n {\n \"open\": false,\n \"port\": 80,\n \"service\": \"http\",\n \"status\": \"filtered\"\n },\n {\n \"open\": false,\n \"port\": 443,\n \"service\": \"https\",\n \"status\": \"filtered\"\n }\n]" + "text": "[{\"latency_ms\":0,\"open\":true,\"port\":135,\"protocol\":\"tcp\",\"service\":\"msrpc\",\"status\":\"open\"},{\"latency_ms\":0,\"open\":true,\"port\":445,\"protocol\":\"tcp\",\"service\":\"smb\",\"status\":\"open\"}]", + "type": "text" } ] }, @@ -133,8 +137,14 @@ would print, so a model reads one shape wherever it came from: } ``` -Every port reads `filtered` because nothing is listening on loopback for -those ports. Closing stdin cancels any operation still running and shuts the +Port 22 is missing because it is closed. `scan_ports` and `inspect_host` +leave out closed and filtered ports unless you pass `include_closed: true`, +since on a typical scan they are almost the whole reply. A scan of 1,024 +ports on a machine with two open came to 178 bytes this way, and to 93,799 +with `include_closed`. An empty list means every port scanned was closed or +filtered. + +Closing stdin cancels any operation still running and shuts the server down, which is why a client that exits mid-scan leaves nothing behind. ## Packet capture jobs