Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/netscli-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 31 additions & 1 deletion crates/netscli-mcp/src/server/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<netscli_core::PortResult>) {
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<netscli_core::InspectResult, RpcError> {
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/netscli-mcp/src/server/schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
/// Return closed and filtered ports too. Off by default: see
/// `drop_unanswered_ports`.
pub(super) include_closed: Option<bool>,
}

#[derive(Deserialize)]
Expand Down
8 changes: 5 additions & 3 deletions crates/netscli-mcp/src/server/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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 }
},
Expand Down Expand Up @@ -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": {
Expand All @@ -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"]
Expand Down Expand Up @@ -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(|_| "<serialization error>".to_string())
"text": serde_json::to_string(&val).unwrap_or_else(|_| "<serialization error>".to_string())
}
]
})
Expand Down
26 changes: 18 additions & 8 deletions site/src/content/docs/docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand All @@ -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"
}
]
},
Expand All @@ -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
Expand Down
Loading