diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7184a33..fa8636cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,6 +380,41 @@ jobs: run: npm run build working-directory: apps/netscli-gui + # Drive `netscli serve` with the official MCP TypeScript SDK client. + # + # The Rust tests check the server's replies against what the server itself + # expects. Every reply carried both `result` and `error` for months with all + # of them green, while the SDK most MCP clients are built on dropped those + # replies and timed out waiting for `initialize`. This asks the SDK instead. + mcp-smoke: + name: MCP SDK smoke test + needs: [changes, lint] + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Rust + # See the pin note on the lint job above. + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: "1.96.0" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: mcp-smoke + - name: cargo build -p netscli + run: cargo build -p netscli + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + - name: npm ci + run: npm ci --ignore-scripts + working-directory: scripts/mcp-smoke + - name: Connect, list tools and call one + run: node scripts/mcp-smoke/smoke.mjs target/debug/netscli + # Release-config binary build. This catches regressions that only show # up under optimizations / lto that debug builds miss, and the artifact # upload makes it possible to grab a "tip of main" binary straight from @@ -499,6 +534,7 @@ jobs: - lint - test - gui-build + - mcp-smoke - release-build - publish-dry-run runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6291a214..0137413c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,8 +64,80 @@ its heading and collects entries. The date and the link go on with the tag. `--version` and gives up after 3 seconds. A program still running at that point was left behind. It is now stopped. +- **MCP clients built on the official SDK can connect.** Every reply the MCP + server sent carried both a result and an error, one of them empty, which + the protocol doesn't allow. The official TypeScript SDK drops a reply like + that, so a client built on it waited for the answer to its first message + and timed out. Replies now carry one or the other, and CI connects to the + server with the SDK whenever the code changes. + +- **Cancelling an MCP call works when the server is busy.** When a 17th call + arrived with 16 running, the server stopped reading what the client sent + until one finished. A cancel, which is how a client frees a slot, went + unread, and so did the client closing the connection. The server now + keeps reading. Up to 16 more calls wait for a slot, and any past that are + refused at once with an error. + +- **Cancelling or disconnecting stops an MCP packet capture.** A capture + ran on after its call was cancelled, timed out or lost its client, for up + to an hour, and gave its slot back straight away, so cancelling and + restarting got past the limit of four captures. It now stops, and gives + the slot back once it has. Background captures stop when the client goes + too. In a test on Windows, a server whose client left mid-capture exited + within 0.3 seconds, where before it was still running after 20. A + `capture_pcap` call with only `maxPackets` could also run for an hour. It + now stops after the 10 seconds the tool advertises. + +- **mDNS discovery no longer leaves its listener running.** A service type + without its final dot, such as `_http._tcp.local`, or a cancelled MCP + call left the listener running in the background, its network sockets + open and any searches it had started still repeating, until the program + exited. It now always stops. + +- **An MCP result too large to send keeps its file path and counts.** A + result over 1 MiB that wasn't a plain list was replaced by an error, so a + large packet capture lost its file name, packet count and job status along + with its packets. Now only its longest list is cut to fit. A background + capture also reports its file name as soon as it starts. + +- **Smaller MCP server fixes.** + - The server answers `ping`, which clients use to check that a server is + alive. It answered with an error. + - It agrees a protocol version it supports, instead of whatever version + the client asked for. + - Text from scanned hosts in HTTP headers and mDNS records is cut to 256 + characters, as banners already were. + - `discover_mdns` takes at most 32 service types, and browses each once. + - An over-long request line no longer has its tail read as the next + message. + - A request that reuses the id of one just answered can always be + cancelled. A race could leave it impossible to cancel. + +- **Smaller scan fixes.** + - A port behind a firewall that rejects connections with an ICMP message + reads as filtered, as nmap reports it, instead of as an error. + - The HTTP probe sends IPv6 targets a valid `Host` header, with the + address in brackets. The old one was malformed, and a strict server can + refuse it. + - A stray `data/oui.json` in the folder netscli ran from no longer + replaces or empties the vendor list. + - On a network wider than /16, the default /24 is the one around this + machine's address on that network. It could come from another interface + or fall back to 192.168.1.0/24. + - Windows discovery re-checks at most 64 devices at once. Checked all at + once, a very long device table could drop devices that were there. + - The example in the `netscli-core` README compiles. + ### Security +- **MCP packet captures never overwrite a file or follow a symlink.** + `capture_pcap` wrote `capture.pcap` in the server's working directory, + often your project, and replaced any file of that name. On Linux and + macOS, a `capture.pcap` symlink in a repository could send the capture + through to wherever it pointed, as root if the server ran as root. + Captures now create a new file and refuse a name that is taken, symlinks + included, and without `outputFile` each capture gets a new name. + - **DNS lookups no longer go to Cloudflare behind your back.** When your own DNS servers could not answer a lookup, including when a name simply had no records of the type asked for, NetsCLI asked Cloudflare's public resolver diff --git a/crates/netscli-core/README.md b/crates/netscli-core/README.md index 0328b585..5bba018c 100644 --- a/crates/netscli-core/README.md +++ b/crates/netscli-core/README.md @@ -39,7 +39,7 @@ async fn main() -> anyhow::Result<()> { let scanner = PortScanner::new(100); // max concurrent connections let target = "192.168.1.1".parse()?; - let results = scanner.scan_host(target, ports, 500).await; // timeout_ms + let results = scanner.scan_host(target, ports, 500).await?; // timeout_ms for result in results { // `PortResult` carries the port, not the host — the host is the @@ -56,6 +56,8 @@ async fn main() -> anyhow::Result<()> { |---------|---------|---------| | `db` | off | Enables the `Database` type and SQLite persistence via sqlx + chrono. Pulls ~90 extra transitive crates, so library consumers who don't need scan history should leave this off. | | `pcap` | off | Enables `PcapEngine` packet capture. Needs libpcap/Npcap at runtime. | +| `mdns` | off | Enables `MdnsEngine` for mDNS/DNS-SD (Bonjour) discovery. Pure Rust, no system dependency. | +| `ts` | off | Generates TypeScript types for the desktop app. Build tooling only. | The `netscli` binary crate enables `db` by default (it uses `Database` for scan history). Library consumers who only want the scan / DNS / diff --git a/crates/netscli-core/src/arp.rs b/crates/netscli-core/src/arp.rs index c7e25a63..5be66ec8 100644 --- a/crates/netscli-core/src/arp.rs +++ b/crates/netscli-core/src/arp.rs @@ -48,13 +48,25 @@ impl NetworkManager { pub(crate) async fn still_present(candidates: &[Ipv4Addr], wait: Duration) -> HashSet { #[cfg(target_os = "windows")] { - let checks = candidates.iter().map(|&ip| async move { + use futures::stream::StreamExt; + + // Lookups in flight at once. All of them used to start together, and + // each `wait` was counted from then, so with more candidates than + // tokio's blocking pool has threads, lookups spent their time queued + // and real neighbours were dropped. Now `wait` starts when a lookup + // does. + const AT_ONCE: usize = 64; + // By value: a closure over `&Ipv4Addr` makes the stream's future not + // `Send` for every lifetime, which `tokio::spawn` in discovery needs. + let checks = candidates.iter().copied().map(|ip| async move { // A lookup that outlives `wait` keeps its blocking thread until // Windows gives up on it, about 4 s; its answer is just ignored. let asked = tokio::task::spawn_blocking(move || platform::answers_arp(ip)); matches!(tokio::time::timeout(wait, asked).await, Ok(Ok(true))).then_some(ip) }); - futures::future::join_all(checks) + futures::stream::iter(checks) + .buffer_unordered(AT_ONCE) + .collect::>() .await .into_iter() .flatten() diff --git a/crates/netscli-core/src/arp/platform/windows_resolve.rs b/crates/netscli-core/src/arp/platform/windows_resolve.rs index 5dea5034..fbb6e49f 100644 --- a/crates/netscli-core/src/arp/platform/windows_resolve.rs +++ b/crates/netscli-core/src/arp/platform/windows_resolve.rs @@ -17,8 +17,10 @@ use windows_sys::Win32::NetworkManagement::IpHelper::SendARP; /// The MAC `ip` answers ARP with, or `None` if nothing answered. Blocking. pub(super) fn resolve(ip: Ipv4Addr) -> Option<[u8; 6]> { - let mut mac = [0u8; 8]; - let mut len = mac.len() as u32; + // Two ULONGs, as SendARP documents, which also gives the buffer a ULONG's + // alignment. A byte array has none. + let mut mac = [0u32; 2]; + let mut len = std::mem::size_of_val(&mac) as u32; // SendARP takes the address in network byte order as a u32. let dest = u32::from_ne_bytes(ip.octets()); // SAFETY: an 8-byte buffer and its length; SendARP writes at most `len`. @@ -26,7 +28,7 @@ pub(super) fn resolve(ip: Ipv4Addr) -> Option<[u8; 6]> { if status != 0 || len != 6 { return None; } - let mut out = [0u8; 6]; - out.copy_from_slice(&mac[..6]); - Some(out) + // The bytes as SendARP wrote them to memory. + let [low, high] = mac.map(u32::to_ne_bytes); + Some([low[0], low[1], low[2], low[3], high[0], high[1]]) } diff --git a/crates/netscli-core/src/arp/platform/windows_table.rs b/crates/netscli-core/src/arp/platform/windows_table.rs index fcd9ca22..0954d2ca 100644 --- a/crates/netscli-core/src/arp/platform/windows_table.rs +++ b/crates/netscli-core/src/arp/platform/windows_table.rs @@ -17,8 +17,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use mac_address::MacAddress; use windows_sys::Win32::Foundation::NO_ERROR; use windows_sys::Win32::NetworkManagement::IpHelper::{ - FreeMibTable, GetIpNetTable2, GetUnicastIpAddressTable, MIB_IPNET_TABLE2, - MIB_UNICASTIPADDRESS_TABLE, + FreeMibTable, GetIpNetTable2, GetUnicastIpAddressTable, MIB_IPNET_ROW2, MIB_IPNET_TABLE2, + MIB_UNICASTIPADDRESS_ROW, MIB_UNICASTIPADDRESS_TABLE, }; use windows_sys::Win32::Networking::WinSock::{ NlnsDelay, NlnsPermanent, NlnsProbe, NlnsReachable, NlnsStale, AF_INET, AF_INET6, @@ -39,8 +39,12 @@ pub(super) fn read() -> Result> { return Err(Error::Network(format!("GetIpNetTable2 failed: {status}"))); } // SAFETY: on success `table` points at NumEntries rows; freed below. + // `Table` is declared as one row and the rest follow it, so the pointer + // is taken from `table` itself. `.Table.as_ptr()` borrowed the + // one-row array first, which covers only the first row. let rows = unsafe { - std::slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize) + let first = std::ptr::addr_of!((*table).Table).cast::(); + std::slice::from_raw_parts(first, (*table).NumEntries as usize) }; let mut entries = Vec::new(); for row in rows { @@ -97,8 +101,10 @@ fn interface_ipv4_by_index() -> HashMap { return map; } // SAFETY: on success `table` points at NumEntries rows; freed below. + // The pointer comes from `table`, as in `read` above. let rows = unsafe { - std::slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize) + let first = std::ptr::addr_of!((*table).Table).cast::(); + std::slice::from_raw_parts(first, (*table).NumEntries as usize) }; for row in rows { if let Some(IpAddr::V4(ip)) = sockaddr_ip(&row.Address) { diff --git a/crates/netscli-core/src/common/network.rs b/crates/netscli-core/src/common/network.rs index e8ad1337..d0dac597 100644 --- a/crates/netscli-core/src/common/network.rs +++ b/crates/netscli-core/src/common/network.rs @@ -4,8 +4,15 @@ use ipnetwork::IpNetwork; use super::constants::DEFAULT_SUBNET; -#[cfg(not(windows))] pub fn detect_default_ipv4_subnet() -> Option { + // `.trunc()` drops the host bits, so this is the network address. + default_interface_ipv4().map(|net| net.trunc()) +} + +/// This host's IPv4 address on the preferred interface, with that +/// network's prefix length. +#[cfg(not(windows))] +fn default_interface_ipv4() -> Option { // Prefer non-tunnel interfaces (vpn/wireguard adapters often sort first // on laptops, and scanning the VPN subnet is almost never what the user // wants). We iterate ALL qualifying interfaces and return the first one @@ -21,9 +28,7 @@ pub fn detect_default_ipv4_subnet() -> Option { for ip in iface.ips { if let IpNetwork::V4(v4) = ip { if let Ok(net) = Ipv4Net::new(v4.ip(), v4.prefix()) { - // `.trunc()` drops any host bits so we return the network - // address regardless of whether the interface IP is a host. - return Some(net.trunc()); + return Some(net); } } } @@ -46,9 +51,12 @@ fn interface_preference_rank(name: &str) -> u8 { } } +/// This host's IPv4 address on the first adapter that is up with a +/// network-shaped prefix, with that network's prefix length. #[cfg(windows)] -pub fn detect_default_ipv4_subnet() -> Option { +fn default_interface_ipv4() -> Option { use ipconfig::{IfType, OperStatus}; + use std::net::IpAddr; let adapters = ipconfig::get_adapters().ok()?; for adapter in adapters { @@ -59,7 +67,17 @@ pub fn detect_default_ipv4_subnet() -> Option { continue; } if let Some(net) = pick_ipv4_subnet_from_prefixes(adapter.prefixes()) { - return Some(net); + // The prefix list gives the network. This host's own address on + // it is among the adapter's addresses. + let host = adapter + .ip_addresses() + .iter() + .find_map(|ip| match ip { + IpAddr::V4(v4) if net.contains(v4) => Some(*v4), + _ => None, + }) + .unwrap_or(net.network()); + return Ipv4Net::new(host, net.prefix_len()).ok(); } } @@ -146,9 +164,10 @@ pub fn detect_default_ipv4_addr() -> Option { const NARROWED_DEFAULT_PREFIX: u8 = 24; pub fn default_ipv4_subnet_string() -> String { - let Some(net) = detect_default_ipv4_subnet() else { + let Some(interface) = default_interface_ipv4() else { return DEFAULT_SUBNET.to_string(); }; + let net = interface.trunc(); // An interface legitimately carrying a /8 -- a 10.0.0.0/8 corporate // network, or Docker -- produced a default that the /16 cap then @@ -158,11 +177,13 @@ pub fn default_ipv4_subnet_string() -> String { if net.prefix_len() >= 16 { return format!("{}/{}", net.network(), net.prefix_len()); } - match detect_default_ipv4_addr() - .and_then(|addr| Ipv4Net::new(addr, NARROWED_DEFAULT_PREFIX).ok()) - { - Some(narrowed) => format!("{}/{}", narrowed.network(), narrowed.prefix_len()), - None => DEFAULT_SUBNET.to_string(), + // Around this host's address on that same interface. The address used to + // come from `detect_default_ipv4_addr`, which picks an interface by a + // different rule: the first one up, which may be a tunnel, or have no + // IPv4 address and fall back to 192.168.1.0/24. + match Ipv4Net::new(interface.addr(), NARROWED_DEFAULT_PREFIX) { + Ok(narrowed) => format!("{}/{}", narrowed.network(), narrowed.prefix_len()), + Err(_) => DEFAULT_SUBNET.to_string(), } } diff --git a/crates/netscli-core/src/mdns.rs b/crates/netscli-core/src/mdns.rs index 54001bd4..28709f6d 100644 --- a/crates/netscli-core/src/mdns.rs +++ b/crates/netscli-core/src/mdns.rs @@ -71,6 +71,30 @@ pub const COMMON_SERVICE_TYPES: &[&str] = &[ pub struct MdnsEngine; +/// Owns the mdns-sd daemon and shuts it down when dropped. +/// +/// The daemon is a thread with multicast sockets on every interface, and it +/// never stops by itself: it holds a sender to its own command channel, so +/// dropping every handle is not enough. `discover` shut it down on its +/// normal exit and when a browse task failed, and nowhere else. An invalid +/// service type returned early through `?`, and a dropped future (an MCP +/// cancel or timeout) never reached the end, so both left the thread running, +/// re-sending every browse it had started, until the process exited. +struct Daemon(ServiceDaemon); + +impl Drop for Daemon { + fn drop(&mut self) { + // Best effort. If the daemon has already gone there is nothing to stop. + let _ = self.0.shutdown(); + } +} + +/// The suffix mdns-sd requires of a service type, checked before a daemon +/// exists rather than by `browse` after one has started. +fn is_service_type(service_type: &str) -> bool { + service_type.ends_with("._tcp.local.") || service_type.ends_with("._udp.local.") +} + impl MdnsEngine { /// Browse every `service_type` in parallel for up to `timeout`, then /// return every service the daemon fully resolved during that window. @@ -84,9 +108,25 @@ impl MdnsEngine { if service_types.is_empty() { return Ok(Vec::new()); } + if let Some(bad) = service_types.iter().find(|t| !is_service_type(t)) { + return Err(Error::invalid_input(format!( + "mDNS service type {bad} must end in ._tcp.local. or ._udp.local., final dot included" + ))); + } let daemon = ServiceDaemon::new() .map_err(|e| Error::Other(format!("mDNS daemon init failed: {e}")))?; + Self::browse_on(daemon, service_types, timeout).await + } + + /// The body of `discover`, given the daemon. Split so a test can keep a + /// handle to the daemon and see that it is shut down. + async fn browse_on( + daemon: ServiceDaemon, + service_types: &[&str], + timeout: Duration, + ) -> Result> { + let daemon = Daemon(daemon); // Browse each service type concurrently. `daemon.browse(..)` returns // a flume receiver per type; we drain each one until the deadline. @@ -96,6 +136,7 @@ impl MdnsEngine { for stype in service_types { let stype = (*stype).to_string(); let receiver = daemon + .0 .browse(&stype) .map_err(|e| Error::Other(format!("mDNS browse({stype}) failed: {e}")))?; tasks.spawn(async move { @@ -140,27 +181,20 @@ impl MdnsEngine { }); } - // Collect every task's results. Errors propagate; the daemon is - // always shut down after the JoinSet drains. + // Collect every task's results. Errors propagate; `Daemon` shuts the + // daemon down on every way out of this function. let mut services: Vec = Vec::new(); while let Some(res) = tasks.join_next().await { match res { Ok(Ok(mut v)) => services.append(&mut v), - Ok(Err(e)) => { - let _ = daemon.shutdown(); - return Err(e); - } + Ok(Err(e)) => return Err(e), Err(join_err) => { - let _ = daemon.shutdown(); return Err(Error::Other(format!( "mDNS browse task panicked: {join_err}" ))); } } } - - // Best-effort shutdown — if the daemon is already gone we don't care. - let _ = daemon.shutdown(); Ok(services) } @@ -193,4 +227,46 @@ mod tests { // Must return immediately without waiting for the timeout. assert!(t0.elapsed() < Duration::from_secs(1)); } + + /// Whether the daemon behind `handle` stops within a few seconds. + fn stops(handle: &ServiceDaemon) -> bool { + for _ in 0..30 { + match handle.status() { + // Its thread has exited and dropped the channel. + Err(_) => return true, + Ok(status) => match status.recv_timeout(Duration::from_millis(100)) { + Ok(mdns_sd::DaemonStatus::Shutdown) => return true, + _ => std::thread::sleep(Duration::from_millis(100)), + }, + } + } + false + } + + #[tokio::test] + async fn the_daemon_stops_when_a_service_type_is_refused() { + let daemon = ServiceDaemon::new().unwrap(); + let handle = daemon.clone(); + // No final dot: the typo that left a daemon running per call. + let res = + MdnsEngine::browse_on(daemon, &["_http._tcp.local"], Duration::from_secs(10)).await; + assert!(res.is_err()); + assert!(stops(&handle), "the daemon was left running"); + } + + #[tokio::test] + async fn the_daemon_stops_when_the_browse_is_dropped() { + let daemon = ServiceDaemon::new().unwrap(); + let handle = daemon.clone(); + let browse = MdnsEngine::browse_on( + daemon, + &["_netscli-test._tcp.local."], + Duration::from_secs(30), + ); + // What an MCP cancel or timeout does to the future. + assert!(tokio::time::timeout(Duration::from_millis(200), browse) + .await + .is_err()); + assert!(stops(&handle), "the daemon was left running"); + } } diff --git a/crates/netscli-core/src/oui.rs b/crates/netscli-core/src/oui.rs index d8fb7529..6d9236b2 100644 --- a/crates/netscli-core/src/oui.rs +++ b/crates/netscli-core/src/oui.rs @@ -10,7 +10,7 @@ static OUI_MAP: OnceCell> = OnceCell::new(); // Embedded copy of the vendor DB that ships inside the crate, so // `cargo install netscli` and release binaries without a data/ dir // next to them still get working vendor lookups. Overridable via -// NETSCLI_OUI_PATH or any of the on-disk candidates below. +// NETSCLI_OUI_PATH or a copy next to the executable. const EMBEDDED_OUI: &[u8] = include_bytes!("../data/oui.min.json.gz"); pub fn lookup_vendor(mac: &str) -> Option { @@ -56,9 +56,9 @@ fn load_oui() -> HashMap { candidates.push(dir.join("data").join("oui.json")); } } - - candidates.push(PathBuf::from("data/oui.min.json.gz")); - candidates.push(PathBuf::from("data/oui.json")); + // Not the working directory. A `data/oui.json` that happened to be in + // whatever directory netscli ran from replaced the vendor list, or + // emptied it, with nothing to say so. for cand in &candidates { if let Some(map) = read_map(cand) { @@ -109,18 +109,20 @@ fn parse_gz(bytes: &[u8]) -> Option> { parse_json(&buf) } +/// The vendor map in `data`, or `None` if it is not one. JSON that is not an +/// object used to parse as an empty map, which still won over every other +/// candidate and the embedded copy, so vendor names silently disappeared. fn parse_json(data: &str) -> Option> { let json: Value = serde_json::from_str(data).ok()?; + let obj = json.as_object()?; let mut map = HashMap::new(); - if let Some(obj) = json.as_object() { - for (k, v) in obj { - if let Some(s) = v.as_str() { - // Same char-vs-byte hazard as `lookup_vendor`: keys come - // from a JSON file that may be user-supplied via - // NETSCLI_OUI_PATH, so a multi-byte key must not panic. - if let Some(prefix) = oui_prefix(k) { - map.insert(prefix, s.to_string()); - } + for (k, v) in obj { + if let Some(s) = v.as_str() { + // Same char-vs-byte hazard as `lookup_vendor`: keys come + // from a JSON file that may be user-supplied via + // NETSCLI_OUI_PATH, so a multi-byte key must not panic. + if let Some(prefix) = oui_prefix(k) { + map.insert(prefix, s.to_string()); } } } @@ -129,7 +131,16 @@ fn parse_json(data: &str) -> Option> { #[cfg(test)] mod tests { - use super::{lookup_vendor, oui_prefix}; + use super::{lookup_vendor, oui_prefix, parse_json}; + + #[test] + fn json_that_is_not_a_vendor_map_is_not_taken_for_an_empty_one() { + for data in ["[]", "null", "42", "\"aa:bb:cc\""] { + assert!(parse_json(data).is_none(), "{data}"); + } + let map = parse_json(r#"{"AA:BB:CC": "Example"}"#).unwrap(); + assert_eq!(map.get("AABBCC").map(String::as_str), Some("Example")); + } #[test] fn normalizes_common_mac_separators() { diff --git a/crates/netscli-core/src/ping/raw_icmp.rs b/crates/netscli-core/src/ping/raw_icmp.rs index 2b1bcb7e..bcc26cee 100644 --- a/crates/netscli-core/src/ping/raw_icmp.rs +++ b/crates/netscli-core/src/ping/raw_icmp.rs @@ -86,35 +86,13 @@ pub(super) fn send_icmp_echo_v4( } fn configure_read_timeout(rx: &TransportReceiver, timeout: Duration) -> anyhow::Result<()> { + // No Windows branch: this module is not built there. One used to sit + // here, unsafe code that no build ever compiled. #[cfg(unix)] { pnet_sys::set_socket_receive_timeout(rx.socket.fd, timeout) .map_err(|e| anyhow::anyhow!("failed to set read timeout: {e}"))?; } - #[cfg(windows)] - { - use windows_sys::Win32::Networking::WinSock::{ - setsockopt, SOCKET_ERROR, SOL_SOCKET, SO_RCVTIMEO, - }; - - let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; - let result = unsafe { - setsockopt( - rx.socket.fd, - SOL_SOCKET, - SO_RCVTIMEO, - (&timeout_ms as *const i32).cast(), - std::mem::size_of::() as i32, - ) - }; - if result == SOCKET_ERROR { - return Err(anyhow::anyhow!( - "failed to set read timeout: {}", - std::io::Error::last_os_error() - )); - } - } - Ok(()) } diff --git a/crates/netscli-core/src/scan/probes/http.rs b/crates/netscli-core/src/scan/probes/http.rs index 1a59c828..dfbbcdfe 100644 --- a/crates/netscli-core/src/scan/probes/http.rs +++ b/crates/netscli-core/src/scan/probes/http.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::time::timeout; @@ -9,12 +11,18 @@ const USER_AGENT: &str = "netscli-scan"; pub(in crate::scan) async fn probe_http( stream: &mut S, - host: &str, + target: IpAddr, timeout_ms: u64, ) -> Option<(HttpProbe, Option, Option)> where S: AsyncRead + AsyncWrite + Unpin, { + // An IPv6 address goes in brackets, as in a URL. `Host: fe80::1` is + // malformed, and strict servers answered it with 400. + let host = match target { + IpAddr::V4(v4) => v4.to_string(), + IpAddr::V6(v6) => format!("[{v6}]"), + }; let req = format!( "{}\r\nHost: {}\r\nUser-Agent: {}\r\nConnection: close\r\n\r\n", String::from_utf8_lossy(HTTP_METHOD), diff --git a/crates/netscli-core/src/scan/probes/tls.rs b/crates/netscli-core/src/scan/probes/tls.rs index 02f01d5d..714888b1 100644 --- a/crates/netscli-core/src/scan/probes/tls.rs +++ b/crates/netscli-core/src/scan/probes/tls.rs @@ -110,8 +110,7 @@ pub(in crate::scan) async fn probe_tls( }; if is_https_port(port, service) && alpn.as_deref() != Some("h2") { - if let Some((http, mut banner, raw)) = - probe_http(&mut tls_stream, &target.to_string(), timeout_ms).await + if let Some((http, mut banner, raw)) = probe_http(&mut tls_stream, target, timeout_ms).await { if let Some(proto) = tls.protocol.as_deref() { banner = match banner { diff --git a/crates/netscli-core/src/scan/services.rs b/crates/netscli-core/src/scan/services.rs index 6c693fef..09acea77 100644 --- a/crates/netscli-core/src/scan/services.rs +++ b/crates/netscli-core/src/scan/services.rs @@ -91,7 +91,13 @@ pub(super) fn guess_service(port: u16) -> Option { pub(super) fn classify_connect_error(kind: ErrorKind) -> PortStatus { match kind { ErrorKind::ConnectionRefused => PortStatus::Closed, - ErrorKind::TimedOut => PortStatus::Filtered, + // An ICMP unreachable (host, network or administratively + // prohibited) is a firewall's REJECT as often as a missing route, + // and nmap calls it filtered too. As `error`, a host behind one + // returned an error row for every port it was asked about. + ErrorKind::TimedOut | ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => { + PortStatus::Filtered + } _ => PortStatus::Error, } } diff --git a/crates/netscli-core/src/scan/tcp.rs b/crates/netscli-core/src/scan/tcp.rs index 818f465c..c4af6c1a 100644 --- a/crates/netscli-core/src/scan/tcp.rs +++ b/crates/netscli-core/src/scan/tcp.rs @@ -145,7 +145,14 @@ impl PortScanner { Err(e) => match classify_connect_error(e.kind()) { PortStatus::Closed => PortResult::new(port, PortStatus::Closed, service) .with_latency(started.elapsed().as_millis() as u64), - PortStatus::Filtered => PortResult::new(port, PortStatus::Filtered, service), + // A timeout says nothing more than `filtered` does. An + // unreachable says what answered, so it keeps the text. + PortStatus::Filtered if e.kind() == std::io::ErrorKind::TimedOut => { + PortResult::new(port, PortStatus::Filtered, service) + } + PortStatus::Filtered => { + PortResult::new(port, PortStatus::Filtered, service).with_error(e.to_string()) + } PortStatus::Error | PortStatus::Open | PortStatus::OpenFiltered => { PortResult::new(port, PortStatus::Error, service) .with_latency(started.elapsed().as_millis() as u64) @@ -178,9 +185,7 @@ impl PortScanner { if is_http_port(port, service) { let mut stream = stream; - if let Some((http, banner, raw)) = - probe_http(&mut stream, &target.to_string(), timeout_ms).await - { + if let Some((http, banner, raw)) = probe_http(&mut stream, target, timeout_ms).await { result.http = Some(http); result.banner = banner; result.raw = raw; diff --git a/crates/netscli-core/src/scan/tests.rs b/crates/netscli-core/src/scan/tests.rs index 59911560..4e2d8576 100644 --- a/crates/netscli-core/src/scan/tests.rs +++ b/crates/netscli-core/src/scan/tests.rs @@ -39,6 +39,19 @@ fn test_timeout_error_classifies_as_filtered() { ); } +#[test] +fn an_icmp_unreachable_classifies_as_filtered() { + // A host behind a firewall that rejects with ICMP returned an error row + // for every port it was asked about. + for kind in [ErrorKind::HostUnreachable, ErrorKind::NetworkUnreachable] { + assert_eq!( + classify_connect_error(kind), + PortStatus::Filtered, + "{kind:?}" + ); + } +} + #[tokio::test] async fn test_scan_localhost_common_ports() { let scanner = PortScanner::new(10); @@ -133,7 +146,7 @@ async fn test_http_probe_captures_banner_and_headers() { }); let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - let (http, banner, raw) = probe_http(&mut stream, "127.0.0.1", 1000) + let (http, banner, raw) = probe_http(&mut stream, IpAddr::from([127, 0, 0, 1]), 1000) .await .expect("HTTP metadata should be captured"); @@ -146,6 +159,22 @@ async fn test_http_probe_captures_banner_and_headers() { assert!(raw.as_deref().unwrap_or_default().contains("HTTP/1.1")); } +#[tokio::test] +async fn the_http_probe_brackets_an_ipv6_host() { + // `Host: fe80::1` is malformed, and strict servers answered it with 400. + let (mut client, mut server) = tokio::io::duplex(4096); + let target: IpAddr = "fe80::1".parse().unwrap(); + let probe = tokio::spawn(async move { probe_http(&mut client, target, 1000).await }); + + let mut request = vec![0_u8; 1024]; + let read = server.read(&mut request).await.unwrap(); + server.write_all(b"HTTP/1.1 200 OK\r\n\r\n").await.unwrap(); + probe.await.unwrap().expect("a reply to parse"); + + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.contains("\r\nHost: [fe80::1]\r\n"), "{request}"); +} + #[tokio::test] async fn test_scan_captures_tls_metadata() { let CertifiedKey { cert, signing_key } = diff --git a/crates/netscli-mcp/src/server.rs b/crates/netscli-mcp/src/server.rs index 165d1256..075416fd 100644 --- a/crates/netscli-mcp/src/server.rs +++ b/crates/netscli-mcp/src/server.rs @@ -4,6 +4,7 @@ mod inflight; #[cfg(feature = "pcap")] mod jobs; mod limits; +mod lines; mod operations; mod progress; mod protocol; @@ -11,13 +12,15 @@ mod schemas; mod targets; mod tools; +use std::future::Future; use std::sync::{Arc, Mutex}; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; +use tokio::io::{self, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, BufWriter}; use tokio::sync::{mpsc, Semaphore}; use tokio::task::JoinSet; -use dispatch::{handle_request, ServerState}; +use dispatch::{handle_request, ServerState, SharedState}; +use lines::{read_bounded_line, LineError}; use protocol::{JsonRpcRequest, JsonRpcResponse}; /// Ceiling on requests executing at once. @@ -25,10 +28,22 @@ use protocol::{JsonRpcRequest, JsonRpcResponse}; /// Each in-flight request can itself fan out to `Ops`-level concurrency /// (which clamps to 1024 sockets), so this bounds how many of those fans a /// misbehaving or enthusiastic client can stack up. Requests beyond the -/// limit queue rather than being rejected — ordering of *responses* is not -/// guaranteed by JSON-RPC, but every request still gets answered. +/// limit wait for a slot rather than being rejected, up to +/// `MAX_HELD_REQUESTS`. const MAX_CONCURRENT_REQUESTS: usize = 16; +/// Most requests held at once, running or waiting for a slot. +/// +/// The read loop used to wait for a free slot itself, so with all sixteen +/// busy nothing more was read from stdin: not `notifications/cancelled`, +/// which is how a client frees a slot, and not EOF. Cancelling failed exactly +/// when the server was busiest. A request that finds every slot taken now +/// waits in its own task, where a cancel can reach it, and the loop goes back +/// to reading. This bound replaces the backpressure that waiting gave: past +/// it, a request is answered with an error at once rather than kept in memory +/// with its params. +const MAX_HELD_REQUESTS: usize = 2 * MAX_CONCURRENT_REQUESTS; + /// Longest any single request may run before it is abandoned. /// /// The per-probe timeouts are bounded, but nothing bounded probes multiplied @@ -37,12 +52,6 @@ const MAX_CONCURRENT_REQUESTS: usize = 16; /// all of it. Sixteen such calls wedged the server with no way back. const MAX_REQUEST_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60); -/// Longest line accepted on stdin. -/// -/// `next_line` grows a `String` without limit, so a client that sends -/// megabytes and no newline was an unbounded allocation. -const MAX_REQUEST_LINE_BYTES: usize = 4 * 1024 * 1024; - pub use tools::tools_list; /// Initialize a tracing subscriber that writes JSON to stderr. @@ -75,14 +84,27 @@ pub async fn run_server() -> anyhow::Result<()> { pcap = cfg!(feature = "pcap"), "netscli MCP server starting" ); + serve(io::stdin(), io::stdout(), handle_request).await +} - let stdin = io::stdin(); - let stdout = io::stdout(); - // `.take()` bounds a single line; `lines()` on its own grows without - // limit. The reader is rebuilt per line below so the cap applies to each. - let mut reader = BufReader::new(stdin); +/// The JSON-RPC loop over any transport. +/// +/// `handle` answers one request. It is a parameter so the tests can drive +/// the loop itself, with requests that never finish, to pin what happens +/// when every slot is busy. `run_server` passes `handle_request`. +async fn serve(input: R, output: W, handle: H) -> anyhow::Result<()> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin + Send + 'static, + H: Fn(SharedState, JsonRpcRequest) -> F, + F: Future + Send + 'static, +{ + // `read_bounded_line` caps each line; `lines()` on its own grows without + // limit. + let mut reader = BufReader::new(input); let state = Arc::new(Mutex::new(ServerState::default())); - let limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS)); + let slots = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS)); + let held = Arc::new(Semaphore::new(MAX_HELD_REQUESTS)); let inflight = Arc::new(inflight::InFlight::default()); // Responses are funnelled through one channel to a single writer task. @@ -91,7 +113,7 @@ pub async fn run_server() -> anyhow::Result<()> { // writer between tasks. let (tx, mut rx) = mpsc::unbounded_channel::(); let writer_task = tokio::spawn(async move { - let mut writer = BufWriter::new(stdout); + let mut writer = BufWriter::new(output); let mut written: u64 = 0; while let Some(line) = rx.recv().await { if writer.write_all(line.as_bytes()).await.is_err() @@ -175,33 +197,35 @@ pub async fn run_server() -> anyhow::Result<()> { continue; } - // The permit is taken *here*, before spawning, so the semaphore - // bounds admission and not merely execution. Acquiring it inside the - // task meant a client could stack up unbounded tasks, each holding a - // full `params` value, faster than they completed; backpressure now - // reaches stdin, which is where it can actually slow the client down. - // - // Reading the next line still does not wait on the handler, so a slow - // `sweep_network` does not block requests queued behind it. - let Ok(permit) = Arc::clone(&limiter).acquire_owned().await else { - break; - }; - - let state = Arc::clone(&state); - let tx = tx.clone(); // `Option>` distinguishes absent from explicit null; a // notification never reaches here, so the outer layer is always Some. let id = request.id.clone().flatten(); + + // Never wait here. See `MAX_HELD_REQUESTS`. + let Ok(admitted) = Arc::clone(&held).try_acquire_owned() else { + tracing::warn!(held = MAX_HELD_REQUESTS, "request refused, server busy"); + send(&tx, &JsonRpcResponse::server_busy(id, MAX_HELD_REQUESTS)); + continue; + }; + + let tx = tx.clone(); let key = id.as_ref().map(inflight::key); let progress = progress::Progress::for_request(request.params.as_ref(), &tx); let inflight_for_task = Arc::clone(&inflight); let key_for_task = key.clone(); + let slots = Arc::clone(&slots); + let call = handle(Arc::clone(&state), request); let task = async move { - let _permit = permit; - // A hard ceiling on the whole call. Per-probe timeouts are - // bounded but their product was not, and a request that never - // returns holds its permit forever. - let call = progress::scope(progress, handle_request(state, request)); + let _admitted = admitted; + // Waiting for a slot happens in the task, so the read loop keeps + // reading and a cancel can reach a request that is still queued. + let Ok(_slot) = slots.acquire_owned().await else { + return; + }; + // A hard ceiling on the whole call, counted from when it starts + // running. Per-probe timeouts are bounded but their product was + // not, and a request that never returns holds its slot forever. + let call = progress::scope(progress, call); let response = match tokio::time::timeout(MAX_REQUEST_DURATION, call).await { Ok(response) => response, Err(_) => { @@ -236,6 +260,13 @@ pub async fn run_server() -> anyhow::Result<()> { // hour-long capture meant an hour. let aborted = handlers.len(); handlers.shutdown().await; + // Background captures belong to no request, so the line above does not + // reach them, and they ran to the end of their duration after the client + // had gone. + #[cfg(feature = "pcap")] + if let Ok(state) = state.lock() { + state.stop_pcap_jobs(); + } drop(tx); let requests_handled = writer_task.await.unwrap_or(0); @@ -247,39 +278,5 @@ pub async fn run_server() -> anyhow::Result<()> { Ok(()) } -/// Why a line could not be read. -enum LineError { - /// The bytes were not a usable line, but the transport is still fine. - Invalid(&'static str), - /// The transport itself failed. - Fatal(std::io::Error), -} - -/// Read one line, bounded, treating undecodable bytes as a bad message -/// rather than the end of the server. -async fn read_bounded_line(reader: &mut R, out: &mut String) -> Result -where - R: tokio::io::AsyncBufRead + Unpin, -{ - let mut raw = Vec::new(); - // One byte past the limit, so an over-long line is detected rather than - // silently truncated into something that might still parse as JSON. - let mut limited = tokio::io::AsyncReadExt::take(reader, MAX_REQUEST_LINE_BYTES as u64 + 1); - let read = limited - .read_until(b'\n', &mut raw) - .await - .map_err(LineError::Fatal)?; - if read == 0 { - return Ok(0); - } - if raw.len() > MAX_REQUEST_LINE_BYTES { - return Err(LineError::Invalid("line exceeds the maximum request size")); - } - match String::from_utf8(raw) { - Ok(text) => { - out.push_str(&text); - Ok(read) - } - Err(_) => Err(LineError::Invalid("line is not valid UTF-8")), - } -} +#[cfg(test)] +mod tests; diff --git a/crates/netscli-mcp/src/server/dispatch.rs b/crates/netscli-mcp/src/server/dispatch.rs index 893cf342..fb793606 100644 --- a/crates/netscli-mcp/src/server/dispatch.rs +++ b/crates/netscli-mcp/src/server/dispatch.rs @@ -21,6 +21,24 @@ use super::operations::op_discover_mdns; #[cfg(feature = "mdns")] use super::schemas::MdnsParams; +/// MCP protocol versions this server implements, newest first. +const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +/// The version to answer `initialize` with: the client's, when this server +/// implements it, and otherwise the newest this server does, as the MCP +/// lifecycle asks. The client's version used to be echoed whatever it was, +/// which claimed support for versions this server has never seen. +fn negotiate_protocol_version(requested: Option<&str>) -> &'static str { + let Some(requested) = requested else { + // What a client that names no version always got. + return "2024-11-05"; + }; + SUPPORTED_PROTOCOL_VERSIONS + .iter() + .find(|supported| **supported == requested) + .unwrap_or(&SUPPORTED_PROTOCOL_VERSIONS[0]) +} + #[derive(Debug, Default)] pub(super) struct ServerState { pub(super) initialized: bool, @@ -113,8 +131,11 @@ async fn handle_request_inner( } // Enforce MCP init lifecycle for requests. Read and release — the guard - // must not survive into the tool dispatch below. - if !lock(state)?.initialized && req.method != "initialize" && req.method != "tools/list" { + // must not survive into the tool dispatch below. `ping` is allowed at + // any time. + if !lock(state)?.initialized + && !matches!(req.method.as_str(), "initialize" | "tools/list" | "ping") + { return Err(RpcError::NotInitialized); } @@ -131,9 +152,7 @@ async fn handle_request_inner( // MCP lifecycle "initialize" => { let p: InitializeParams = parse_params(params)?; - let protocol = p - .protocol_version - .unwrap_or_else(|| "2024-11-05".to_string()); + let protocol = negotiate_protocol_version(p.protocol_version.as_deref()); lock(state)?.initialized = true; Ok(json!({ "protocolVersion": protocol, @@ -141,6 +160,9 @@ async fn handle_request_inner( "serverInfo": { "name": "netscli", "version": env!("CARGO_PKG_VERSION") } })) } + // MCP's liveness check. It answered -32601, so a client that pings + // took a working server for a broken one. + "ping" => Ok(json!({})), "tools/list" => Ok(tools_list()), "tools/call" => handle_tools_call(state, params).await, @@ -245,6 +267,10 @@ async fn dispatch_tool_inner(name: &str, params: Value) -> Result) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params, + id: Some(Some(json!(1))), + } +} + +fn new_state() -> SharedState { + Arc::new(Mutex::new(ServerState::default())) +} + +async fn negotiated(requested: Value) -> Value { + let params = json!({ "protocolVersion": requested, "capabilities": {} }); + let resp = handle_request(new_state(), request("initialize", Some(params))).await; + resp.result.expect("initialize must succeed")["protocolVersion"].clone() +} + +#[tokio::test] +async fn ping_is_answered_with_an_empty_result_even_before_initialize() { + let resp = handle_request(new_state(), request("ping", None)).await; + assert!(resp.error.is_none(), "ping failed: {:?}", resp.error); + assert_eq!(resp.result, Some(json!({}))); +} + +#[tokio::test] +async fn only_a_version_this_server_implements_is_agreed_as_asked() { + for version in SUPPORTED_PROTOCOL_VERSIONS { + assert_eq!(negotiated(json!(version)).await, json!(version)); + } + // The client's version was echoed whatever it was, so this server + // claimed to speak versions it has never seen. + for version in ["2099-01-01", "1.0", ""] { + assert_eq!( + negotiated(json!(version)).await, + json!(SUPPORTED_PROTOCOL_VERSIONS[0]), + "asked for {version:?}" + ); + } +} diff --git a/crates/netscli-mcp/src/server/dispatch/pcap_tests.rs b/crates/netscli-mcp/src/server/dispatch/pcap_tests.rs new file mode 100644 index 00000000..eef7b1b8 --- /dev/null +++ b/crates/netscli-mcp/src/server/dispatch/pcap_tests.rs @@ -0,0 +1,91 @@ +//! The packet-capture job tools over `tools/call`. +//! +//! Moved out of `tests.rs`, which is at the size guard, when the lifecycle +//! test had to wait for its job to end. + +use std::time::Duration; + +use serde_json::json; + +use super::*; +use crate::server::protocol::JsonRpcRequest; + +fn request(method: &str, params: Option, id: i64) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params, + id: Some(Some(json!(id))), + } +} + +async fn initialized_state() -> SharedState { + let state: SharedState = Arc::new(Mutex::new(ServerState::default())); + let resp = handle_request(state.clone(), request("initialize", Some(json!({})), 0)).await; + assert!(resp.error.is_none(), "initialize failed: {:?}", resp.error); + state +} + +async fn call(state: &SharedState, tool: &str, arguments: Value) -> JsonRpcResponse { + let params = json!({ "name": tool, "arguments": arguments }); + handle_request(state.clone(), request("tools/call", Some(params), 1)).await +} + +/// The JSON a tool returned as its text content. +fn tool_output(resp: &JsonRpcResponse) -> Option { + let result = resp.result.as_ref()?; + let text = result.get("content")?.get(0)?.get("text")?.as_str()?; + serde_json::from_str(text).ok() +} + +#[tokio::test] +async fn pcap_job_lifecycle_start_status_result() { + let state = initialized_state().await; + + let start = call( + &state, + "start_pcap_capture", + json!({"interface": "netscli-test0"}), + ) + .await; + assert!(start.error.is_none(), "start failed: {:?}", start.error); + let started = tool_output(&start).expect("start returns the job's status"); + let job_id = started["jobId"].as_str().expect("a jobId").to_string(); + // Named, and created, before the capture runs, so the client knows where + // the capture goes without waiting for the result. + let output_file = started["outputFile"].as_str().expect("an outputFile"); + + // There is no interface netscli-test0, so the job fails at once. Wait for + // that, so the file made for it is gone before the test ends. + let mut status = Value::Null; + for _ in 0..100 { + let resp = call(&state, "get_pcap_capture_status", json!({"jobId": job_id})).await; + assert!(resp.error.is_none(), "status failed: {:?}", resp.error); + status = tool_output(&resp).expect("a status"); + if status["running"] == false { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert_eq!(status["status"], "failed", "{status}"); + assert!( + !std::path::Path::new(output_file).exists(), + "the empty file made for the job must go" + ); + + let result = call(&state, "get_pcap_capture_result", json!({"jobId": job_id})).await; + assert!(result.error.is_none(), "result failed: {:?}", result.error); +} + +#[tokio::test] +async fn pcap_job_status_rejects_unknown_job_id() { + let state = initialized_state().await; + let resp = call( + &state, + "get_pcap_capture_status", + json!({"jobId": "pcap-does-not-exist"}), + ) + .await; + let err = resp.error.expect("expected unknown-jobId error"); + assert_eq!(err.code, -32602); +} diff --git a/crates/netscli-mcp/src/server/dispatch/tests.rs b/crates/netscli-mcp/src/server/dispatch/tests.rs index 7d1fccdb..57d57ba7 100644 --- a/crates/netscli-mcp/src/server/dispatch/tests.rs +++ b/crates/netscli-mcp/src/server/dispatch/tests.rs @@ -221,73 +221,3 @@ async fn pcap_job_tools_are_unknown_without_pcap_feature() { assert_eq!(err.code, -32602); } } - -#[cfg(feature = "pcap")] -#[tokio::test] -async fn pcap_job_lifecycle_start_status_result() { - let state = initialized_state().await; - - let start = handle_request( - state.clone(), - request( - "tools/call", - Some( - json!({"name": "start_pcap_capture", "arguments": {"interface": "netscli-test0"}}), - ), - 9, - ), - ) - .await; - assert!(start.error.is_none(), "start failed: {:?}", start.error); - let job_id = extract_job_id(&start).expect("start response should include a jobId"); - - let status = handle_request( - state.clone(), - request( - "tools/call", - Some(json!({"name": "get_pcap_capture_status", "arguments": {"jobId": job_id}})), - 10, - ), - ) - .await; - assert!(status.error.is_none(), "status failed: {:?}", status.error); - - let result = handle_request( - state.clone(), - request( - "tools/call", - Some(json!({"name": "get_pcap_capture_result", "arguments": {"jobId": job_id}})), - 11, - ), - ) - .await; - assert!(result.error.is_none(), "result failed: {:?}", result.error); -} - -#[cfg(feature = "pcap")] -#[tokio::test] -async fn pcap_job_status_rejects_unknown_job_id() { - let state = initialized_state().await; - let resp = handle_request( - state.clone(), - request( - "tools/call", - Some(json!({"name": "get_pcap_capture_status", "arguments": {"jobId": "pcap-does-not-exist"}})), - 12, - ), - ) - .await; - let err = resp.error.expect("expected unknown-jobId error"); - assert_eq!(err.code, -32602); -} - -#[cfg(feature = "pcap")] -fn extract_job_id(resp: &JsonRpcResponse) -> Option { - let result = resp.result.as_ref()?; - let text = result.get("content")?.get(0)?.get("text")?.as_str()?; - let parsed: Value = serde_json::from_str(text).ok()?; - parsed - .get("jobId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} diff --git a/crates/netscli-mcp/src/server/inflight.rs b/crates/netscli-mcp/src/server/inflight.rs index 36654118..3c778f9a 100644 --- a/crates/netscli-mcp/src/server/inflight.rs +++ b/crates/netscli-mcp/src/server/inflight.rs @@ -38,9 +38,22 @@ impl InFlight { map.insert(key, handle); } - /// The request answered; stop tracking it. + /// The request answered; stop tracking it. Called from the request's own + /// task, and removes the entry only if it is still that task's. + /// + /// The key alone is not enough. A client may reuse an id as soon as it + /// has the answer, and a second request with an id already in flight + /// replaces the first one's entry. Either way the entry under this key + /// could belong to a newer request, and removing it left that request + /// impossible to cancel. pub(super) fn finish(&self, key: &str) { - self.map().remove(key); + let mut map = self.map(); + let own = map + .get(key) + .is_some_and(|handle| Some(handle.id()) == tokio::task::try_id()); + if own { + map.remove(key); + } } /// Handle `notifications/cancelled`. Returns whether a running request @@ -79,6 +92,34 @@ mod tests { assert!(outcome.unwrap_err().is_cancelled()); } + #[tokio::test] + async fn a_finished_request_does_not_untrack_a_newer_one_with_its_id() { + let inflight = std::sync::Arc::new(InFlight::default()); + let mut set = tokio::task::JoinSet::new(); + let (finish_old, finished) = tokio::sync::oneshot::channel::<()>(); + let tracker = std::sync::Arc::clone(&inflight); + inflight.track(key(&json!(1)), || { + set.spawn(async move { + let _ = finished.await; + tracker.finish(&key(&json!(1))); + }) + }); + // The client reuses the id while the first request is still + // finishing, which replaces the entry. + inflight.track(key(&json!(1)), || { + set.spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }) + }); + + finish_old.send(()).unwrap(); + let first = tokio::time::timeout(Duration::from_secs(1), set.join_next()).await; + assert!(first.expect("the first request finishes").unwrap().is_ok()); + + assert!( + inflight.cancel(Some(&json!({"requestId": 1}))), + "the newer request must still be cancellable" + ); + } + #[test] fn unknown_or_finished_ids_are_ignored() { let inflight = InFlight::default(); diff --git a/crates/netscli-mcp/src/server/jobs.rs b/crates/netscli-mcp/src/server/jobs.rs index 3500dc88..b4f660f5 100644 --- a/crates/netscli-mcp/src/server/jobs.rs +++ b/crates/netscli-mcp/src/server/jobs.rs @@ -22,6 +22,11 @@ pub(super) struct PcapCaptureJob { started_at: Instant, finished_at: Option, outcome: Option>, + /// Reported from the start. With no `outputFile` the name is made up + /// here, and a client had no way to learn it before the result. + output_file: String, + /// Stops the capture when the server shuts down. + cancel: netscli_core::PcapCancelToken, } #[derive(Debug, Serialize)] @@ -32,6 +37,7 @@ pub(super) struct PcapJobStatus { pub(super) running: bool, pub(super) elapsed_ms: u64, pub(super) result_available: bool, + pub(super) output_file: String, #[serde(skip_serializing_if = "Option::is_none")] pub(super) error: Option, } @@ -44,6 +50,7 @@ pub(super) struct PcapJobResult { pub(super) running: bool, pub(super) elapsed_ms: u64, pub(super) result_available: bool, + pub(super) output_file: String, #[serde(skip_serializing_if = "Option::is_none")] pub(super) result: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -51,14 +58,20 @@ pub(super) struct PcapJobResult { } impl PcapCaptureJob { - pub(super) fn new() -> Self { + pub(super) fn new(output_file: String) -> Self { Self { started_at: Instant::now(), finished_at: None, outcome: None, + output_file, + cancel: netscli_core::PcapCancelToken::new(), } } + pub(super) fn cancel_token(&self) -> netscli_core::PcapCancelToken { + self.cancel.clone() + } + pub(super) fn complete(&mut self, result: netscli_core::PcapResult) { self.finished_at = Some(Instant::now()); self.outcome = Some(Ok(result)); @@ -90,6 +103,7 @@ impl PcapCaptureJob { running, elapsed_ms: self.elapsed_ms(), result_available, + output_file: self.output_file.clone(), error, } } @@ -107,6 +121,7 @@ impl PcapCaptureJob { running, elapsed_ms: self.elapsed_ms(), result_available, + output_file: self.output_file.clone(), result, error, } @@ -131,6 +146,16 @@ impl ServerState { .ok_or_else(|| RpcError::InvalidParams(format!("unknown pcap jobId: {job_id}"))) } + /// Stop every running capture, for shutdown. A job outlives the request + /// that started it, so nothing else stops it when the client goes. + pub(super) fn stop_pcap_jobs(&self) { + for job in self.pcap_jobs.values() { + if let Ok(job) = job.lock() { + job.cancel.cancel(); + } + } + } + pub(super) fn running_pcap_jobs(&self) -> usize { self.pcap_jobs .values() @@ -179,19 +204,22 @@ pub(super) fn start_pcap_capture_job( ))); } let job_id = state.allocate_pcap_job_id(); - // Timestamped, because the job counter restarts at 1 with the process - // and libpcap's savefile truncates: two runs of the server both wrote - // `netscli-pcap-1.pcap`, and the second silently replaced the first. + // Timestamped, because the job counter restarts at 1 with the process: + // two runs of the server both chose `netscli-pcap-1.pcap`. The file is + // created here, so a name that is taken fails this call rather than the + // job later. let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - request.ensure_default_output_file(format!("netscli-{job_id}-{stamp}.pcap")); - let job = Arc::new(Mutex::new(PcapCaptureJob::new())); + let output_file = request.claim_output_file(format!("netscli-{job_id}-{stamp}.pcap"))?; + let job = PcapCaptureJob::new(output_file); + let cancel = job.cancel_token(); + let job = Arc::new(Mutex::new(job)); state.pcap_jobs.insert(job_id.clone(), job.clone()); tokio::spawn(async move { - let outcome = run_pcap_capture(request).await; + let outcome = run_pcap_capture(request, cancel).await; let Ok(mut guard) = job.lock() else { return; }; diff --git a/crates/netscli-mcp/src/server/jobs/tests.rs b/crates/netscli-mcp/src/server/jobs/tests.rs index 82004997..263312e5 100644 --- a/crates/netscli-mcp/src/server/jobs/tests.rs +++ b/crates/netscli-mcp/src/server/jobs/tests.rs @@ -35,7 +35,7 @@ fn packet(index: usize) -> PcapPacketSummary { fn completed_job(packets: usize) -> ServerState { let mut state = ServerState::default(); - let job = Arc::new(Mutex::new(PcapCaptureJob::new())); + let job = Arc::new(Mutex::new(PcapCaptureJob::new("capture.pcap".to_string()))); job.lock().unwrap().complete(PcapResult { packets_captured: packets, duration: Duration::from_secs(1), diff --git a/crates/netscli-mcp/src/server/limits.rs b/crates/netscli-mcp/src/server/limits.rs index 83998b92..ded1c8b2 100644 --- a/crates/netscli-mcp/src/server/limits.rs +++ b/crates/netscli-mcp/src/server/limits.rs @@ -13,13 +13,14 @@ //! //! - `raw` is dropped. It is the full probe response, it exists for the //! GUI's detail pane, and no model needs 4 KB of it. -//! - `banner` is truncated to something identifying rather than expansive. +//! - `banner` and other remote text are truncated to something identifying +//! rather than expansive. //! - The whole response is capped, and says so when it truncates. //! //! Without these, `scan_ports` against a host that answers on every port //! could return 4,096 × 4 KB of remote-chosen text in one response. -use serde_json::Value; +use serde_json::{json, Value}; /// Longest `banner` returned to a client. /// @@ -32,7 +33,16 @@ const MAX_BANNER_CHARS: usize = 256; const MAX_RESULT_BYTES: usize = 1024 * 1024; /// Keys whose values are remote bytes rather than netscli's own findings. -const REMOTE_TEXT_KEYS: &[&str] = &["banner", "hex_preview", "info"]; +const REMOTE_TEXT_KEYS: &[&str] = &["banner", "hex_preview", "info", "hostname", "full_name"]; + +/// Keys whose whole value is remote text, every string under them capped +/// like a banner: an HTTP reply's status line and headers, and an mDNS +/// service's TXT properties. The `Server` header was cut short in `banner` +/// and returned whole beside it in `http.headers`. +const REMOTE_TEXT_TREES: &[&str] = &["http", "properties"]; + +/// Room kept for the fields that say an object was cut. +const TRUNCATION_NOTE_BYTES: usize = 512; /// Both caps, in the order they have to run: strip and truncate the remote /// text first, then bound what is left. @@ -59,6 +69,8 @@ pub(super) fn cap_remote_text(value: &mut Value) { if let Value::String(text) = entry { truncate_chars(text, MAX_BANNER_CHARS); } + } else if REMOTE_TEXT_TREES.contains(&key.as_str()) { + cap_every_string(entry); } else { cap_remote_text(entry); } @@ -68,6 +80,15 @@ pub(super) fn cap_remote_text(value: &mut Value) { } } +fn cap_every_string(value: &mut Value) { + match value { + Value::String(text) => truncate_chars(text, MAX_BANNER_CHARS), + Value::Array(items) => items.iter_mut().for_each(cap_every_string), + Value::Object(map) => map.values_mut().for_each(cap_every_string), + _ => {} + } +} + fn truncate_chars(text: &mut String, max: usize) { if text.chars().count() <= max { return; @@ -77,43 +98,36 @@ fn truncate_chars(text: &mut String, max: usize) { *text = format!("{kept}… (truncated)"); } +fn encoded_len(value: &Value) -> usize { + serde_json::to_string(value).map(|s| s.len()).unwrap_or(0) +} + /// Cap the whole response, reporting what was dropped. /// -/// Truncating the array rather than erroring keeps a large scan useful: the +/// Truncating a list rather than erroring keeps a large scan useful: the /// results that fit are still real, and the count says how many are missing /// so the client can narrow its request instead of guessing. pub(super) fn cap_result_size(value: Value) -> Value { - let encoded = serde_json::to_string(&value).map(|s| s.len()).unwrap_or(0); + let encoded = encoded_len(&value); if encoded <= MAX_RESULT_BYTES { return value; } - let Value::Array(items) = value else { - // Not an array, so there is no natural place to cut. Say so rather - // than returning something that looks complete. - return serde_json::json!({ - "error": "result too large to return", - "limit_bytes": MAX_RESULT_BYTES, - "size_bytes": encoded, - }); - }; + match value { + Value::Array(items) => cap_array(items), + Value::Object(_) => cap_object(value, encoded), + _ => too_large(encoded), + } +} +fn cap_array(items: Vec) -> Value { let total = items.len(); - let mut kept: Vec = Vec::new(); - let mut used = 0usize; - for item in items { - let size = serde_json::to_string(&item).map(|s| s.len()).unwrap_or(0) + 1; - if used + size > MAX_RESULT_BYTES { - break; - } - used += size; - kept.push(item); - } + let kept = keep_fitting(items, MAX_RESULT_BYTES); // `kept.len()`, not the byte counter. `returned` was `used.min(total)`, // and `used` counts bytes -- so a 40,000-item result that kept 11,518 // reported `returned: 40000, total: 40000` beside `truncated: true`, // telling the model it had everything while three quarters was cut. let returned = kept.len(); - serde_json::json!({ + json!({ "results": kept, "truncated": true, "returned": returned, @@ -124,116 +138,93 @@ pub(super) fn cap_result_size(value: Value) -> Value { }) } -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn raw_is_dropped_and_banner_truncated() { - let mut value = json!([{ - "port": 80, - "banner": "A".repeat(5000), - "raw": "B".repeat(4096), - "service": "http", - }]); - cap_remote_text(&mut value); - - let entry = &value[0]; - assert!(entry.get("raw").is_none(), "raw must not reach the model"); - let banner = entry["banner"].as_str().unwrap(); - assert!(banner.ends_with("… (truncated)")); - assert_eq!( - banner.chars().count(), - MAX_BANNER_CHARS + "… (truncated)".chars().count() - ); - // Untouched fields survive. - assert_eq!(entry["service"], "http"); - assert_eq!(entry["port"], 80); - } - - #[test] - fn a_short_banner_is_left_exactly_as_it_was() { - let mut value = json!([{ "banner": "SSH-2.0-OpenSSH_9.6p1" }]); - cap_remote_text(&mut value); - assert_eq!(value[0]["banner"], "SSH-2.0-OpenSSH_9.6p1"); +/// An object over the cap keeps every field except its largest list, which +/// is cut to fit as an oversized array is. +/// +/// It used to be replaced whole by an error, so a capture too large to +/// return lost its file path and packet count along with its packets, and a +/// job result lost its id and status. The model learned nothing it could act +/// on, and for a capture with a made-up name, not even where the file was. +fn cap_object(mut value: Value, encoded: usize) -> Value { + let Some((pointer, _)) = largest_list(&value, String::new()) else { + return too_large(encoded); + }; + let Some(Value::Array(items)) = value.pointer_mut(&pointer).map(Value::take) else { + return too_large(encoded); + }; + let total = items.len(); + let room = MAX_RESULT_BYTES.saturating_sub(encoded_len(&value) + TRUNCATION_NOTE_BYTES); + if room == 0 { + // Too large even without the list. Nothing sensible is left to cut. + return too_large(encoded); } - - #[test] - fn multibyte_banners_are_not_split_mid_character() { - let mut value = json!([{ "banner": "\u{1f600}".repeat(1000) }]); - cap_remote_text(&mut value); - // Round-trips as valid UTF-8, which a byte-wise cut would not. - assert!(value[0]["banner"] - .as_str() - .unwrap() - .starts_with('\u{1f600}')); + let kept = keep_fitting(items, room); + let returned = kept.len(); + if let Some(slot) = value.pointer_mut(&pointer) { + *slot = Value::Array(kept); } - - #[test] - fn an_oversized_array_is_truncated_and_says_so() { - let items: Vec = (0..40_000) - .map(|i| json!({ "port": i, "service": "x".repeat(64) })) - .collect(); - let capped = cap_result_size(Value::Array(items)); - assert_eq!(capped["truncated"], true); - assert_eq!(capped["total"], 40_000); - assert!(serde_json::to_string(&capped).unwrap().len() <= MAX_RESULT_BYTES * 2); - - // The count the client reads has to be the count it received. This - // assertion is the whole reason the bug survived: everything else - // here was already checked. - let actually_returned = capped["results"].as_array().unwrap().len(); - assert_eq!(capped["returned"], actually_returned); - assert!( - actually_returned < 40_000, - "the fixture must actually truncate for this to mean anything" + if let Value::Object(map) = &mut value { + let field = pointer.trim_start_matches('/').replace('/', "."); + map.insert("truncated".to_string(), json!(true)); + map.insert("returned".to_string(), json!(returned)); + map.insert("total".to_string(), json!(total)); + map.insert( + "note".to_string(), + json!(format!( + "Result exceeded {MAX_RESULT_BYTES} bytes, so {field} holds the first {returned} of {total}. Ask for less to see the rest." + )), ); } + value +} - #[test] - fn a_small_result_passes_through_untouched() { - let value = json!([{ "port": 22, "open": true }]); - assert_eq!(cap_result_size(value.clone()), value); +/// The JSON pointer to the largest list in an object, and its size. Lists +/// inside lists are not searched: the outer list is always the larger. +fn largest_list(value: &Value, at: String) -> Option<(String, usize)> { + let Value::Object(map) = value else { + return None; + }; + let mut largest: Option<(String, usize)> = None; + for (key, entry) in map { + let path = format!("{at}/{}", key.replace('~', "~0").replace('/', "~1")); + let candidate = match entry { + Value::Array(_) => Some((path, encoded_len(entry))), + Value::Object(_) => largest_list(entry, path), + _ => None, + }; + if let Some(candidate) = candidate { + if largest.as_ref().is_none_or(|(_, size)| candidate.1 > *size) { + largest = Some(candidate); + } + } } + largest +} - #[test] - fn a_pcap_job_result_is_capped_through_its_nesting() { - // `get_pcap_capture_result` returns packets under `result.packets` - // rather than as a bare array, so this pins that both caps still - // reach them. Built as JSON rather than from `PcapResult` so it runs - // without the `pcap` feature -- the fields are the ones - // `PcapPacketSummary` serializes. - let packets: Vec = (0..20_000) - .map(|i| { - json!({ - "index": i, - "protocol": "TCP", - "info": "X".repeat(400), - "hex_preview": "de ad be ef ".repeat(40), - }) - }) - .collect(); - let job_result = json!({ - "jobId": "pcap-1", - "status": "completed", - "result": { "packets_captured": 20_000, "packets": packets }, - }); - - let capped = cap_tool_result(job_result); - let encoded = serde_json::to_string(&capped).unwrap(); - - // Not an array at the top level, so `cap_result_size` cannot trim it - // item by item and says so instead of returning something that looks - // complete. Either way the wall of remote text does not arrive. - assert!( - encoded.len() <= MAX_RESULT_BYTES, - "a job result must not exceed the cap; got {} bytes", - encoded.len() - ); - assert!( - !encoded.contains(&"X".repeat(MAX_BANNER_CHARS + 1)), - "no untruncated remote `info` may survive" - ); +/// The leading items that fit in `budget` bytes. +fn keep_fitting(items: Vec, budget: usize) -> Vec { + let mut kept = Vec::new(); + let mut used = 0usize; + for item in items { + let size = encoded_len(&item) + 1; + if used + size > budget { + break; + } + used += size; + kept.push(item); } + kept +} + +fn too_large(encoded: usize) -> Value { + // Nowhere sensible to cut. Say so rather than returning something that + // looks complete. + json!({ + "error": "result too large to return", + "limit_bytes": MAX_RESULT_BYTES, + "size_bytes": encoded, + }) } + +#[cfg(test)] +mod tests; diff --git a/crates/netscli-mcp/src/server/limits/tests.rs b/crates/netscli-mcp/src/server/limits/tests.rs new file mode 100644 index 00000000..019095de --- /dev/null +++ b/crates/netscli-mcp/src/server/limits/tests.rs @@ -0,0 +1,158 @@ +//! Tests for the result caps. +//! +//! In their own file because `limits.rs` passed the size guard when objects +//! over the cap started keeping their fields. + +use super::*; +use serde_json::json; + +#[test] +fn raw_is_dropped_and_banner_truncated() { + let mut value = json!([{ + "port": 80, + "banner": "A".repeat(5000), + "raw": "B".repeat(4096), + "service": "http", + }]); + cap_remote_text(&mut value); + + let entry = &value[0]; + assert!(entry.get("raw").is_none(), "raw must not reach the model"); + let banner = entry["banner"].as_str().unwrap(); + assert!(banner.ends_with("… (truncated)")); + assert_eq!( + banner.chars().count(), + MAX_BANNER_CHARS + "… (truncated)".chars().count() + ); + // Untouched fields survive. + assert_eq!(entry["service"], "http"); + assert_eq!(entry["port"], 80); +} + +#[test] +fn a_short_banner_is_left_exactly_as_it_was() { + let mut value = json!([{ "banner": "SSH-2.0-OpenSSH_9.6p1" }]); + cap_remote_text(&mut value); + assert_eq!(value[0]["banner"], "SSH-2.0-OpenSSH_9.6p1"); +} + +#[test] +fn multibyte_banners_are_not_split_mid_character() { + let mut value = json!([{ "banner": "\u{1f600}".repeat(1000) }]); + cap_remote_text(&mut value); + // Round-trips as valid UTF-8, which a byte-wise cut would not. + assert!(value[0]["banner"] + .as_str() + .unwrap() + .starts_with('\u{1f600}')); +} + +#[test] +fn remote_text_in_http_replies_and_mdns_records_is_capped_too() { + // The `Server` header was cut to 256 characters in `banner` and returned + // whole beside it in `http.headers`. + let long = "X".repeat(5000); + let mut value = json!({ + "ports": [{ + "banner": long, + "http": { + "status_line": format!("HTTP/1.1 200 {long}"), + "headers": [{ "name": "Server", "value": long }], + }, + }], + "services": [{ + "full_name": format!("{long}._http._tcp.local."), + "hostname": format!("{long}.local."), + "properties": { "note": long }, + }], + }); + cap_remote_text(&mut value); + + let encoded = serde_json::to_string(&value).unwrap(); + assert!( + !encoded.contains(&"X".repeat(MAX_BANNER_CHARS + 1)), + "remote text survived uncapped: {encoded}" + ); + assert_eq!(value["ports"][0]["http"]["headers"][0]["name"], "Server"); +} + +#[test] +fn an_oversized_array_is_truncated_and_says_so() { + let items: Vec = (0..40_000) + .map(|i| json!({ "port": i, "service": "x".repeat(64) })) + .collect(); + let capped = cap_result_size(Value::Array(items)); + assert_eq!(capped["truncated"], true); + assert_eq!(capped["total"], 40_000); + assert!(serde_json::to_string(&capped).unwrap().len() <= MAX_RESULT_BYTES * 2); + + // The count the client reads has to be the count it received. This + // assertion is the whole reason the bug survived: everything else + // here was already checked. + let actually_returned = capped["results"].as_array().unwrap().len(); + assert_eq!(capped["returned"], actually_returned); + assert!( + actually_returned < 40_000, + "the fixture must actually truncate for this to mean anything" + ); +} + +#[test] +fn a_small_result_passes_through_untouched() { + let value = json!([{ "port": 22, "open": true }]); + assert_eq!(cap_result_size(value.clone()), value); +} + +#[test] +fn a_pcap_job_result_over_the_cap_keeps_its_fields_and_cuts_its_packets() { + // `get_pcap_capture_result` returns packets under `result.packets` + // rather than as a bare array, so this pins that both caps still reach + // them. Built as JSON rather than from `PcapResult` so it runs without + // the `pcap` feature -- the fields are the ones `PcapPacketSummary` + // serializes. + let packets: Vec = (0..20_000) + .map(|i| { + json!({ + "index": i, + "protocol": "TCP", + "info": "X".repeat(400), + "hex_preview": "de ad be ef ".repeat(40), + }) + }) + .collect(); + let job_result = json!({ + "jobId": "pcap-1", + "status": "completed", + "outputFile": "netscli-pcap-1-1.pcap", + "result": { + "file_path": "netscli-pcap-1-1.pcap", + "packets_captured": 20_000, + "packets": packets, + }, + }); + + let capped = cap_tool_result(job_result); + let encoded = serde_json::to_string(&capped).unwrap(); + + assert!( + encoded.len() <= MAX_RESULT_BYTES, + "a job result must not exceed the cap; got {} bytes", + encoded.len() + ); + assert!( + !encoded.contains(&"X".repeat(MAX_BANNER_CHARS + 1)), + "no untruncated remote `info` may survive" + ); + + // An object over the cap used to be replaced whole by an error, so the + // job id, its status, the file and the count went with the packets. + assert_eq!(capped["jobId"], "pcap-1"); + assert_eq!(capped["status"], "completed"); + assert_eq!(capped["result"]["file_path"], "netscli-pcap-1-1.pcap"); + assert_eq!(capped["result"]["packets_captured"], 20_000); + let kept = capped["result"]["packets"].as_array().unwrap().len(); + assert!(kept > 0 && kept < 20_000, "kept {kept} packets"); + assert_eq!(capped["truncated"], true); + assert_eq!(capped["returned"], kept); + assert_eq!(capped["total"], 20_000); +} diff --git a/crates/netscli-mcp/src/server/lines.rs b/crates/netscli-mcp/src/server/lines.rs new file mode 100644 index 00000000..a145ff05 --- /dev/null +++ b/crates/netscli-mcp/src/server/lines.rs @@ -0,0 +1,109 @@ +//! Reading one bounded line from the transport. +//! +//! Split out of `server.rs` when the read loop's fix for saturation pushed +//! that file past the size guard. Framing is a seam of its own: nothing here +//! knows about JSON-RPC, only about where one message ends. + +use tokio::io::{AsyncBufRead, AsyncBufReadExt}; + +/// Longest line accepted on stdin. +/// +/// `next_line` grows a `String` without limit, so a client that sends +/// megabytes and no newline was an unbounded allocation. +pub(super) const MAX_REQUEST_LINE_BYTES: usize = 4 * 1024 * 1024; + +/// Why a line could not be read. +pub(super) enum LineError { + /// The bytes were not a usable line, but the transport is still fine. + Invalid(&'static str), + /// The transport itself failed. + Fatal(std::io::Error), +} + +/// Read one line, bounded, treating undecodable bytes as a bad message +/// rather than the end of the server. +pub(super) async fn read_bounded_line( + reader: &mut R, + out: &mut String, +) -> Result +where + R: AsyncBufRead + Unpin, +{ + let mut raw = Vec::new(); + // One byte past the limit, so an over-long line is detected rather than + // silently truncated into something that might still parse as JSON. + let mut limited = + tokio::io::AsyncReadExt::take(&mut *reader, MAX_REQUEST_LINE_BYTES as u64 + 1); + let read = limited + .read_until(b'\n', &mut raw) + .await + .map_err(LineError::Fatal)?; + if read == 0 { + return Ok(0); + } + if raw.len() > MAX_REQUEST_LINE_BYTES { + // The rest of the line is still waiting to be read. Left there, it + // was taken for the next message: another parse error at best, and a + // request placed after 4 MiB of padding was run. + if raw.last() != Some(&b'\n') { + skip_past_newline(reader).await.map_err(LineError::Fatal)?; + } + return Err(LineError::Invalid("line exceeds the maximum request size")); + } + match String::from_utf8(raw) { + Ok(text) => { + out.push_str(&text); + Ok(read) + } + Err(_) => Err(LineError::Invalid("line is not valid UTF-8")), + } +} + +/// Discard input up to and including the next newline, or to EOF, without +/// holding any of it. +async fn skip_past_newline(reader: &mut R) -> std::io::Result<()> +where + R: AsyncBufRead + Unpin, +{ + loop { + let buf = reader.fill_buf().await?; + if buf.is_empty() { + return Ok(()); + } + match buf.iter().position(|&b| b == b'\n') { + Some(at) => { + reader.consume(at + 1); + return Ok(()); + } + None => { + let len = buf.len(); + reader.consume(len); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn the_rest_of_an_over_long_line_is_not_read_as_the_next_message() { + let mut input = "x".repeat(MAX_REQUEST_LINE_BYTES + 100).into_bytes(); + input.extend_from_slice(b"\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n"); + let mut reader = input.as_slice(); + + let mut line = String::new(); + assert!(matches!( + read_bounded_line(&mut reader, &mut line).await, + Err(LineError::Invalid(_)) + )); + + let mut line = String::new(); + assert!(read_bounded_line(&mut reader, &mut line).await.is_ok()); + assert_eq!( + line.trim_end(), + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"# + ); + } +} diff --git a/crates/netscli-mcp/src/server/operations.rs b/crates/netscli-mcp/src/server/operations.rs index 1b2c3155..0b6f3ac1 100644 --- a/crates/netscli-mcp/src/server/operations.rs +++ b/crates/netscli-mcp/src/server/operations.rs @@ -262,12 +262,32 @@ pub(super) async fn op_discover_mdns( .timeout_ms .unwrap_or(3000) .clamp(100, netscli_core::MAX_MDNS_TIMEOUT_MS); - let service_types = p.service_types.unwrap_or_default(); + let service_types = service_types_to_browse(p.service_types)?; let ops = netscli_core::Ops::default(); ops.discover_mdns(&service_types, std::time::Duration::from_millis(timeout_ms)) .await .map_err(|e| RpcError::ToolError(e.to_string())) } +/// Most service types one `discover_mdns` call browses. Each is its own +/// multicast query, repeated for the whole window, so an unbounded list was +/// a burst of queries on the LAN, and a repeated type was browsed twice. +#[cfg(feature = "mdns")] +const MAX_MDNS_SERVICE_TYPES: usize = 32; + +#[cfg(feature = "mdns")] +fn service_types_to_browse(requested: Option>) -> Result, RpcError> { + let mut types = requested.unwrap_or_default(); + types.sort_unstable(); + types.dedup(); + if types.len() > MAX_MDNS_SERVICE_TYPES { + return Err(RpcError::InvalidParams(format!( + "too many service_types: {} (max {MAX_MDNS_SERVICE_TYPES})", + types.len() + ))); + } + Ok(types) +} + #[cfg(test)] mod tests; diff --git a/crates/netscli-mcp/src/server/operations/pcap.rs b/crates/netscli-mcp/src/server/operations/pcap.rs index 5f0bc8ff..69c15235 100644 --- a/crates/netscli-mcp/src/server/operations/pcap.rs +++ b/crates/netscli-mcp/src/server/operations/pcap.rs @@ -20,13 +20,48 @@ pub(in crate::server) struct PcapCaptureRequest { #[cfg(feature = "pcap")] impl PcapCaptureRequest { - pub(in crate::server) fn ensure_default_output_file(&mut self, output_file: String) { - if self.output_file.is_none() { - self.output_file = Some(output_file); + /// Create the output file now, named `default_name` if the client chose + /// no name, and return the name. + /// + /// libpcap opens its file by name and truncates whatever is there. + /// `capture_pcap` always wrote `capture.pcap` in the server's working + /// directory, usually the user's project, so it replaced any file of that + /// name, and a `capture.pcap` symlink committed to a repository sent the + /// capture through the link to wherever it pointed, as root if the server + /// ran elevated. `create_new` refuses anything already at the name, a + /// symlink included, even one that points nowhere. + pub(in crate::server) fn claim_output_file( + &mut self, + default_name: String, + ) -> Result { + let name = self.output_file.get_or_insert(default_name).clone(); + let created = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&name); + match created { + Ok(_) => Ok(name), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + Err(RpcError::ToolError(format!( + "{name} already exists, and a capture never replaces a file. Choose another outputFile." + ))) + } + Err(e) => Err(RpcError::ToolError(format!("cannot create {name}: {e}"))), } } } +/// A name for a blocking capture the client did not name. Every one used to +/// be `capture.pcap`, so each replaced the last. +#[cfg(feature = "pcap")] +fn unnamed_capture() -> String { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("netscli-capture-{millis}.pcap") +} + /// Longest a *blocking* `capture_pcap` call may run. /// /// Core allows an hour, which is right for a capture written to a file and @@ -91,37 +126,107 @@ static PCAP_CAPTURE_SLOTS: tokio::sync::Semaphore = #[cfg(feature = "pcap")] pub(in crate::server) const MAX_CONCURRENT_PCAP_CAPTURES: usize = 4; +/// Run a capture until it ends or `cancel` is cancelled. +/// +/// The capture itself runs on a blocking thread that dropping a future does +/// not reach. A cancel, the request ceiling and the client going away all +/// drop the future, and the capture used to carry on regardless, holding the +/// interface and writing its file for up to an hour. #[cfg(feature = "pcap")] pub(in crate::server) async fn run_pcap_capture( request: PcapCaptureRequest, + cancel: netscli_core::PcapCancelToken, +) -> Result { + let output_file = request.output_file.clone(); + let outcome = capture_in_slot(request, cancel).await; + if outcome.is_err() { + if let Some(name) = output_file { + remove_if_empty(&name); + } + } + outcome +} + +#[cfg(feature = "pcap")] +async fn capture_in_slot( + request: PcapCaptureRequest, + cancel: netscli_core::PcapCancelToken, ) -> Result { // `try_acquire`, not `acquire`: waiting would hold one of the server's // request permits for however long the running captures take, which is // the wedge this limit exists to prevent. Refusing immediately tells the // client something actionable instead. - let _slot = PCAP_CAPTURE_SLOTS.try_acquire().map_err(|_| { + let slot = PCAP_CAPTURE_SLOTS.try_acquire().map_err(|_| { RpcError::ToolError(format!( "too many packet captures are already running (max {MAX_CONCURRENT_PCAP_CAPTURES})" )) })?; - let ops = netscli_core::Ops::default(); - ops.capture_pcap_async( - request.interface, - request.filter, - request.duration, - request.output_file, - request.max_packets, - ) - .await - .map_err(|e| RpcError::ToolError(e.to_string())) + let _stop = StopOnDrop(cancel.clone()); + // The slot belongs to a task that lives until the capture has really + // returned, at most one read timeout (250 ms) after a cancel. Held by + // this future it came back the moment the future was dropped, so + // cancelling and restarting got past the limit with the old captures + // still running. + let capture = tokio::spawn(async move { + let _slot = slot; + netscli_core::Ops::default() + .capture_pcap_async_with_cancel( + request.interface, + request.filter, + request.duration, + request.output_file, + request.max_packets, + Some(cancel), + ) + .await + }); + match capture.await { + Ok(outcome) => outcome.map_err(|e| RpcError::ToolError(e.to_string())), + Err(e) => Err(RpcError::Internal(format!("pcap capture task failed: {e}"))), + } +} + +/// Cancels a capture when the future running it is dropped. +#[cfg(feature = "pcap")] +struct StopOnDrop(netscli_core::PcapCancelToken); + +#[cfg(feature = "pcap")] +impl Drop for StopOnDrop { + fn drop(&mut self) { + self.0.cancel(); + } +} + +/// Remove an output file nothing was written to, so a capture that could +/// not start (no capture rights, no such interface) leaves nothing behind, +/// as it did before the file was created up front. +#[cfg(feature = "pcap")] +fn remove_if_empty(name: &str) { + let empty = std::fs::symlink_metadata(name).is_ok_and(|m| m.is_file() && m.len() == 0); + if empty { + let _ = std::fs::remove_file(name); + } } #[cfg(feature = "pcap")] pub(in crate::server) async fn op_capture_pcap( p: PcapParams, ) -> Result { - let request = validate_pcap_capture_params(p)?; - run_pcap_capture(request).await + let mut request = interactive_request(p)?; + request.claim_output_file(unnamed_capture())?; + run_pcap_capture(request, netscli_core::PcapCancelToken::new()).await +} + +/// The blocking tool's request: a duration always set, and never past the +/// interactive clamp. With only `maxPackets` it used to fall through to +/// core's one-hour ceiling. It now gets the default the tool advertises. +#[cfg(feature = "pcap")] +fn interactive_request(p: PcapParams) -> Result { + let mut request = validate_pcap_capture_params(p)?; + request + .duration + .get_or_insert(netscli_core::DEFAULT_PCAP_CAPTURE_SECONDS); + Ok(request) } #[cfg(feature = "pcap")] @@ -152,3 +257,6 @@ pub(in crate::server) async fn op_capture_pcap( "pcap support disabled at compile time".to_string(), )) } + +#[cfg(all(test, feature = "pcap"))] +mod tests; diff --git a/crates/netscli-mcp/src/server/operations/pcap/tests.rs b/crates/netscli-mcp/src/server/operations/pcap/tests.rs new file mode 100644 index 00000000..e772a3c1 --- /dev/null +++ b/crates/netscli-mcp/src/server/operations/pcap/tests.rs @@ -0,0 +1,99 @@ +//! How the capture tools set up a capture: its duration and its file. +//! +//! In their own file because `pcap.rs` is near the size guard. + +use std::path::{Path, PathBuf}; + +use super::*; + +fn params(duration: Option, max_packets: Option) -> PcapParams { + PcapParams { + interface: "netscli-test0".to_string(), + filter: None, + duration, + output_file: None, + max_packets, + } +} + +/// An empty directory of this test's own. +fn scratch_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("netscli-mcp-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A request writing to `path`. The tools only take a bare filename, in the +/// server's working directory, and a test must not write there. +fn request_for(path: &Path) -> PcapCaptureRequest { + PcapCaptureRequest { + interface: "netscli-test0".to_string(), + filter: None, + duration: Some(1), + output_file: Some(path.to_string_lossy().into_owned()), + max_packets: None, + } +} + +#[test] +fn a_blocking_capture_always_has_a_duration_within_the_clamp() { + // `maxPackets` alone fell through to core's one-hour ceiling. + let request = interactive_request(params(None, Some(5))).unwrap(); + assert_eq!( + request.duration, + Some(netscli_core::DEFAULT_PCAP_CAPTURE_SECONDS) + ); + + let request = interactive_request(params(Some(3_600), None)).unwrap(); + assert_eq!(request.duration, Some(MAX_INTERACTIVE_CAPTURE_SECONDS)); +} + +#[test] +fn an_existing_file_is_never_replaced() { + let dir = scratch_dir("existing"); + let path = dir.join("capture.pcap"); + std::fs::write(&path, b"keep me").unwrap(); + + let err = request_for(&path) + .claim_output_file(String::new()) + .unwrap_err(); + + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"keep me"); +} + +#[cfg(unix)] +#[test] +fn a_symlink_at_the_name_is_not_followed() { + let dir = scratch_dir("symlink"); + let target = dir.join("elsewhere.txt"); + std::fs::write(&target, b"keep me").unwrap(); + let link = dir.join("capture.pcap"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert!(request_for(&link).claim_output_file(String::new()).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"keep me"); + + // Nor one that points nowhere, which would create the file it names. + let dangling = dir.join("dangling.pcap"); + std::os::unix::fs::symlink(dir.join("nowhere"), &dangling).unwrap(); + assert!(request_for(&dangling) + .claim_output_file(String::new()) + .is_err()); + assert!(!dir.join("nowhere").exists()); +} + +#[tokio::test] +async fn a_capture_that_cannot_start_leaves_no_file_behind() { + let dir = scratch_dir("cannot-start"); + let path = dir.join("capture.pcap"); + let mut request = request_for(&path); + request.claim_output_file(String::new()).unwrap(); + assert!(path.exists()); + + let outcome = run_pcap_capture(request, netscli_core::PcapCancelToken::new()).await; + + assert!(outcome.is_err(), "there is no interface netscli-test0"); + assert!(!path.exists(), "the empty file made for it must go"); +} diff --git a/crates/netscli-mcp/src/server/operations/tests.rs b/crates/netscli-mcp/src/server/operations/tests.rs index 46a8b0cc..4e3ac0e3 100644 --- a/crates/netscli-mcp/src/server/operations/tests.rs +++ b/crates/netscli-mcp/src/server/operations/tests.rs @@ -52,6 +52,24 @@ fn an_ordinary_private_default_is_returned_unchanged() { assert_eq!(subnet, "192.168.1.0/24"); } +#[cfg(feature = "mdns")] +#[test] +fn mdns_service_types_are_deduplicated_and_capped() { + let repeated = vec!["_http._tcp.local.".to_string(); 40]; + assert_eq!( + service_types_to_browse(Some(repeated)).unwrap(), + ["_http._tcp.local."] + ); + + let many: Vec = (0..=MAX_MDNS_SERVICE_TYPES) + .map(|i| format!("_s{i}._tcp.local.")) + .collect(); + assert!(matches!( + service_types_to_browse(Some(many)), + Err(RpcError::InvalidParams(_)) + )); +} + #[test] fn an_explicit_subnet_wins_over_the_default() { // The default must not be consulted at all when one was supplied, diff --git a/crates/netscli-mcp/src/server/protocol.rs b/crates/netscli-mcp/src/server/protocol.rs index 7b9fadc9..d37827fe 100644 --- a/crates/netscli-mcp/src/server/protocol.rs +++ b/crates/netscli-mcp/src/server/protocol.rs @@ -31,8 +31,17 @@ pub(super) struct JsonRpcRequest { #[derive(Serialize, Deserialize, Debug)] pub(super) struct JsonRpcResponse { pub(super) jsonrpc: String, + // Exactly one of `result` and `error` goes on the wire. JSON-RPC 2.0 says + // the other member MUST NOT exist, and the official MCP TypeScript SDK + // holds replies to that: one carrying `"error": null` beside its result + // was dropped as invalid, so a client built on the SDK never received the + // `initialize` answer and timed out. + #[serde(skip_serializing_if = "Option::is_none")] pub(super) result: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(super) error: Option, + // Never skipped. A reply to a request whose id was null, or could not be + // read, must still say `"id": null`. pub(super) id: Option, } @@ -67,6 +76,22 @@ impl JsonRpcResponse { id, } } + + /// A request that arrived with `held` requests already running or + /// waiting for a slot. + pub(super) fn server_busy(id: Option, held: usize) -> Self { + Self { + jsonrpc: "2.0".to_string(), + result: None, + error: Some(JsonRpcError { + code: -32000, + message: format!( + "server busy: {held} requests are already running or waiting. Retry when one has been answered." + ), + }), + id, + } + } } #[derive(Serialize, Deserialize, Debug)] @@ -74,3 +99,51 @@ pub(super) struct JsonRpcError { pub(super) code: i32, pub(super) message: String, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + // JSON-RPC 2.0 allows exactly one of `result` and `error` in a reply. + // Every reply used to carry both, one of them null, and the official MCP + // TypeScript SDK drops such a reply as invalid: clients built on it never + // received the `initialize` answer and timed out. + #[test] + fn a_reply_carries_result_or_error_never_both() { + let ok = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + result: Some(json!({})), + error: None, + id: Some(json!(1)), + }; + let ok = serde_json::to_value(ok).unwrap(); + assert_eq!(ok, json!({"jsonrpc": "2.0", "result": {}, "id": 1})); + + let failed = JsonRpcResponse::request_timeout(Some(json!(2)), 900); + let failed = serde_json::to_value(failed).unwrap(); + assert!(failed.get("result").is_none(), "{failed}"); + assert_eq!(failed["error"]["code"], -32000); + } + + // The id is the one member that stays when it is null. A reply to a + // request whose id could not be read, or was itself null, must still say + // `"id": null`. + #[test] + fn an_unknown_id_is_still_written_as_null() { + let reply = serde_json::to_value(JsonRpcResponse::parse_error()).unwrap(); + assert_eq!( + reply, + json!({"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}) + ); + + let reply = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + result: Some(json!({})), + error: None, + id: None, + }; + let reply = serde_json::to_value(reply).unwrap(); + assert_eq!(reply.get("id"), Some(&Value::Null), "{reply}"); + } +} diff --git a/crates/netscli-mcp/src/server/tests.rs b/crates/netscli-mcp/src/server/tests.rs new file mode 100644 index 00000000..69e564a4 --- /dev/null +++ b/crates/netscli-mcp/src/server/tests.rs @@ -0,0 +1,106 @@ +//! The read loop in `serve`, driven over in-memory pipes. +//! +//! The handler stands in for `handle_request` so a request can be made to +//! never finish, which is the only way to hold every slot without a network. + +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines}; +use tokio::task::JoinHandle; + +use super::*; + +/// `hang` never answers. Anything else is answered at once. +async fn handler(_state: SharedState, request: JsonRpcRequest) -> JsonRpcResponse { + if request.method == "hang" { + std::future::pending::<()>().await; + } + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + result: Some(json!({ "method": request.method })), + error: None, + id: request.id.flatten(), + } +} + +type Replies = Lines>; + +fn start() -> (DuplexStream, Replies, JoinHandle>) { + let (to_server, server_input) = tokio::io::duplex(1 << 16); + let (server_output, from_server) = tokio::io::duplex(1 << 16); + let server = tokio::spawn(serve(server_input, server_output, handler)); + (to_server, BufReader::new(from_server).lines(), server) +} + +async fn send(input: &mut DuplexStream, message: Value) { + input + .write_all(format!("{message}\n").as_bytes()) + .await + .unwrap(); +} + +async fn request(input: &mut DuplexStream, id: Value, method: &str) { + send(input, json!({"jsonrpc": "2.0", "id": id, "method": method})).await; +} + +async fn next_reply(replies: &mut Replies) -> Value { + let line = tokio::time::timeout(Duration::from_secs(5), replies.next_line()) + .await + .expect("a reply within 5 s") + .unwrap() + .expect("the server's output is still open"); + serde_json::from_str(&line).unwrap() +} + +// With every slot busy the loop used to wait for one before reading another +// line, so this cancel was never read, and cancelling is how a client frees +// a slot. +#[tokio::test] +async fn a_cancel_is_read_while_every_slot_is_busy() { + let (mut input, mut replies, _server) = start(); + for id in 1..=MAX_CONCURRENT_REQUESTS { + request(&mut input, json!(id), "hang").await; + } + request(&mut input, json!("queued"), "echo").await; + send( + &mut input, + json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}), + ) + .await; + + let reply = next_reply(&mut replies).await; + assert_eq!(reply["id"], "queued", "{reply}"); + assert_eq!(reply["result"]["method"], "echo"); +} + +// EOF went unread the same way, so a client that closed stdin left the +// server running with every request still holding its slot. +#[tokio::test] +async fn eof_is_read_while_every_slot_is_busy() { + let (mut input, _replies, server) = start(); + for id in 1..=MAX_CONCURRENT_REQUESTS + 1 { + request(&mut input, json!(id), "hang").await; + } + drop(input); + + let ended = tokio::time::timeout(Duration::from_secs(5), server).await; + ended + .expect("serve must return once stdin closes") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn a_request_past_the_held_limit_is_refused_at_once() { + let (mut input, mut replies, _server) = start(); + for id in 1..=MAX_HELD_REQUESTS { + request(&mut input, json!(id), "hang").await; + } + request(&mut input, json!("one too many"), "echo").await; + + let reply = next_reply(&mut replies).await; + assert_eq!(reply["id"], "one too many", "{reply}"); + assert_eq!(reply["error"]["code"], -32000); + assert!(reply.get("result").is_none(), "{reply}"); +} diff --git a/crates/netscli-mcp/src/server/tools.rs b/crates/netscli-mcp/src/server/tools.rs index 8a929020..bb5b0466 100644 --- a/crates/netscli-mcp/src/server/tools.rs +++ b/crates/netscli-mcp/src/server/tools.rs @@ -170,7 +170,7 @@ pub fn tools_list() -> serde_json::Value { "interface": { "type": "string" }, "filter": { "type": "string" }, "duration": { "type": "number", "default": 10, "minimum": 1, "maximum": 120 }, - "outputFile": { "type": "string", "default": "capture.pcap" }, + "outputFile": { "type": "string", "description": "A filename ending in .pcap, written in the server's working directory. Omit for a new name. An existing file is never replaced." }, "maxPackets": { "type": "number" } }, "required": ["interface"] @@ -190,7 +190,7 @@ pub fn tools_list() -> serde_json::Value { "interface": { "type": "string" }, "filter": { "type": "string" }, "duration": { "type": "number", "default": 10 }, - "outputFile": { "type": "string", "description": "Where to write the capture. Omit for a file named after the job." }, + "outputFile": { "type": "string", "description": "A filename ending in .pcap, written in the server's working directory. Omit for a file named after the job. An existing file is never replaced." }, "maxPackets": { "type": "number" } }, "required": ["interface"] @@ -252,7 +252,7 @@ pub fn tools_list() -> serde_json::Value { }, "service_types": { "type": "array", - "items": { "type": "string" }, + "items": { "type": "string" }, "maxItems": 32, "description": "Explicit service types to browse (e.g. [\"_http._tcp.local.\", \"_airplay._tcp.local.\"]). Omit to use a curated default set." } } diff --git a/scripts/mcp-smoke/package-lock.json b/scripts/mcp-smoke/package-lock.json new file mode 100644 index 00000000..de794a88 --- /dev/null +++ b/scripts/mcp-smoke/package-lock.json @@ -0,0 +1,1200 @@ +{ + "name": "netscli-mcp-smoke", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "netscli-mcp-smoke", + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.1" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/scripts/mcp-smoke/package.json b/scripts/mcp-smoke/package.json new file mode 100644 index 00000000..e1494cd2 --- /dev/null +++ b/scripts/mcp-smoke/package.json @@ -0,0 +1,8 @@ +{ + "name": "netscli-mcp-smoke", + "description": "Drives netscli serve with the official MCP TypeScript SDK client", + "private": true, + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.1" + } +} diff --git a/scripts/mcp-smoke/smoke.mjs b/scripts/mcp-smoke/smoke.mjs new file mode 100644 index 00000000..c88bc358 --- /dev/null +++ b/scripts/mcp-smoke/smoke.mjs @@ -0,0 +1,98 @@ +// Drive `netscli serve` with the official MCP TypeScript SDK client. +// +// The Rust tests check the server's replies against what the server itself +// expects. That is how every reply carried both `result` and `error` for +// months with all of them green, while the SDK most MCP clients are built on +// dropped those replies and timed out waiting for `initialize`. This asks the +// SDK instead. +// +// Usage: node smoke.mjs +// Exits 0 when every check passes, 1 on any failure, 2 on bad usage. +// +// Checks, in order: initialize, ping, tools/list, one tool call that +// succeeds, and one that is refused with an error reply. + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const OVERALL_TIMEOUT_MS = 30_000; + +const binary = process.argv[2]; +if (!binary) { + console.error('usage: node smoke.mjs '); + process.exit(2); +} + +function fail(message) { + console.error(`mcp-smoke: FAIL: ${message}`); + process.exit(1); +} + +// One deadline for the whole run, so a server that never answers fails here +// rather than on the SDK's own 60 s request timeout. +const deadline = setTimeout( + () => fail(`no complete exchange with the server within ${OVERALL_TIMEOUT_MS / 1000} s`), + OVERALL_TIMEOUT_MS, +); + +const transport = new StdioClientTransport({ command: binary, args: ['serve'] }); +const client = new Client({ name: 'netscli-mcp-smoke', version: '1.0.0' }); +// The SDK drops a message it cannot validate and reports it only here. The +// request it answered then waits forever, so this is the useful error. +client.onerror = (error) => fail(`the SDK rejected a message from the server: ${error?.message ?? error}`); + +let summary; +try { + await client.connect(transport); + const server = client.getServerVersion(); + if (server?.name !== 'netscli') { + fail(`initialize named the server ${JSON.stringify(server)}, expected netscli`); + } + await client.ping(); + + const { tools } = await client.listTools(); + const names = tools.map((tool) => tool.name); + for (const expected of ['list_network_interfaces', 'scan_ports']) { + if (!names.includes(expected)) { + fail(`tools/list has no ${expected}. It listed: ${names.join(', ')}`); + } + } + + // Read-only, and sends nothing on the network. + const listed = await client.callTool({ name: 'list_network_interfaces', arguments: {} }); + if (listed.isError) { + fail(`list_network_interfaces failed: ${JSON.stringify(listed.content)}`); + } + const text = listed.content?.[0]?.text; + let interfaces; + try { + interfaces = JSON.parse(text); + } catch { + fail(`list_network_interfaces returned text that is not JSON: ${text}`); + } + if (!Array.isArray(interfaces)) { + fail(`list_network_interfaces returned ${typeof interfaces}, expected an array`); + } + + // An error reply has to arrive too. scan_ports with no host is refused + // before anything is sent. + let refused; + try { + await client.callTool({ name: 'scan_ports', arguments: {} }); + } catch (error) { + refused = error; + } + if (refused?.code !== -32602) { + const got = refused ? `${refused.code}: ${refused.message}` : 'a result'; + fail(`scan_ports with no host should fail with -32602, got ${got}`); + } + + summary = `${server.name} ${server.version}, ${tools.length} tools, ${interfaces.length} interfaces listed, error reply delivered`; +} catch (error) { + fail(error?.message ?? String(error)); +} + +clearTimeout(deadline); +await client.close(); +console.log(`mcp-smoke: ok: ${summary}`); +process.exit(0); diff --git a/site/src/content/docs/docs/mcp.md b/site/src/content/docs/docs/mcp.md index 97923f35..43fd0ab9 100644 --- a/site/src/content/docs/docs/mcp.md +++ b/site/src/content/docs/docs/mcp.md @@ -105,7 +105,7 @@ Opening the connection, then telling the server the client is ready: ``` ```json -{"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"netscli","version":"0.3.4"}},"error":null,"id":1} +{"jsonrpc":"2.0","result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"netscli","version":"0.3.5"}},"id":1} ``` ```json @@ -132,7 +132,6 @@ would print, so a model reads one shape wherever it came from: } ] }, - "error": null, "id": 2 } ``` @@ -144,8 +143,13 @@ 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. +A tool result is capped at 1 MiB. Past that, its longest list is cut short +and everything else in it is kept. The result then has `truncated` set to +true, and `returned` and `total` say how many items of that list arrived. + +Closing stdin cancels any operation still running, packet captures included, +and shuts the server down, which is why a client that exits mid-scan leaves +nothing behind. ## Progress and cancelling @@ -159,6 +163,13 @@ A client can stop any call by sending `notifications/cancelled` with the call's `requestId`. The scan stops, frees its slot for the next request, and, as the protocol asks, sends no response for the cancelled call. +The server runs up to 16 calls at once, and up to 16 more wait for a slot. A +cancel still gets through when every slot is busy, and so does closing stdin. +A call beyond those 32 is refused straight away with an error, so a client can +retry it once an earlier call has been answered. + +The server also answers `ping` at any time, with an empty result. + ## Packet capture jobs Packet-capture tools appear only in MCP builds that include packet-capture support. Captures also need Npcap on Windows or libpcap on Linux/macOS. Supported builds expose two packet-capture styles. @@ -170,7 +181,11 @@ Use the job-style flow by default. Start the capture, poll status, then fetch th | `start_pcap_capture` -> `get_pcap_capture_status` -> `get_pcap_capture_result` | Recommended for packet capture, especially when duration, traffic volume, or client timeout behavior is uncertain. | | `capture_pcap` | Compatibility path for very short captures where the MCP client can safely wait for one blocking response. | -The start call returns a `jobId`. Poll with that ID until `resultAvailable` is true, then fetch the result. +The start call returns a `jobId` and the `outputFile` the capture goes to. Poll with that ID until `resultAvailable` is true, then fetch the result. + +Captures are written to the server's working directory. Give `outputFile` as a filename ending in `.pcap`, or leave it out and the server picks a new name. `capture_pcap` names it after the time, and `start_pcap_capture` after the job. A capture never replaces a file that is already there, and never follows a symlink at that name. Choose another name if one is taken. + +Without a `duration`, `capture_pcap` stops after 10 seconds, even when you give `maxPackets`. A longer `duration` is cut to 2 minutes. ## Safety model