Skip to content

MCP server: replies SDK clients accept, cancelling that works under load, safe captures - #554

Merged
fstubner merged 20 commits into
mainfrom
fix/mcp-server
Oct 7, 2026
Merged

fstubner merged 20 commits into
mainfrom
fix/mcp-server

Conversation

@fstubner

@fstubner fstubner commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Audit fixes for the MCP server and the core findings in areas/rust-core-mcp.md (local only). The DNS fallback is in #551.

The headline fix

Clients built on the official MCP TypeScript SDK can connect.

  • Every reply carried both result and error, one of them null. The SDK drops such replies, so its clients never received the initialize answer.
  • A reply now carries exactly one of the two. "id": null is still written where JSON-RPC requires it.
  • New Rust tests pin the shape. A new CI job (mcp-smoke) drives netscli serve with the real SDK, pinned at 1.32.1 in scripts/mcp-smoke. It fails at once on the 0.3.4 binary ("Unrecognized key: error") and passes here.

Also fixed

  • M1: with all 16 slots busy, the server still reads stdin, so a cancel or EOF gets through. Up to 32 requests can run or wait; past that a request is refused at once with -32000.
  • M2:
    • Cancel, timeout and EOF stop packet captures. The capture slot is held until the capture really ends.
    • A blocking capture_pcap with only maxPackets defaults to 10 s.
    • Measured with Npcap: after EOF the old build was still running at 20 s, and this one exits in about 0.25 s.
  • M3: the mDNS daemon is shut down on every exit path, and service types are checked before a daemon starts.
  • M4: a capture never overwrites an existing file or follows a symlink. Unnamed captures get a timestamped file name.
  • M5: a result over 1 MiB keeps its fields and trims only its largest list, marking truncated, returned and total. Job status and start report outputFile.
  • Lows:
    • over-long lines are discarded properly
    • ping is answered, and only real protocol versions are agreed
    • remote text in HTTP and mDNS results is capped
    • mDNS service types are deduplicated
    • Windows neighbour-table reads are sound
    • IPv6 Host headers are bracketed
    • ICMP-unreachable ports read as filtered
    • the working directory can no longer override the vendor list
    • ARP re-checks run 64 at a time
    • the core README example compiles
  • mcp.md samples were re-captured from a real session.

Checked (by the fixing agent, in its own target dir)

  • cargo fmt --all --check.
  • cargo clippy on netscli-core, netscli-mcp and netscli, with and without --features pcap, -D warnings.
  • cargo test:
    • netscli: 53 unit tests and 5 mcp_stdio tests
    • netscli-core: 141 unit tests and its integration tests
    • netscli-mcp: 50 unit tests
    • pcap test runs against a local Npcap
  • Every new test was checked by reverting just its fix and seeing it fail, except the Unix-only symlink test, which runs in CI.
  • The SDK smoke test passes against the built binary: 9 tools.

Decision for you

start_pcap_capture, the job-style tool meant for long captures, is still clamped to 120 s. The original design said the job tool is for the hour-long case. It is left as it was.

Not in this PR

  • Raising the open-file limit on macOS (an unconfirmed item) needs a direct libc dependency. It will be a small follow-up.
  • Making netscli serve exit promptly at EOF while a probe is still running is in the CLI PR.

Every JSON-RPC reply carried both members, one of them null. JSON-RPC 2.0
says the other member must not exist, and the official MCP TypeScript SDK
validates replies strictly, so it dropped every reply netscli sent. A
client built on the SDK never received the initialize answer and timed
out (seen with SDK 1.17.5 and 1.30.1).

result and error are now skipped when absent. id is not: a reply to a
request whose id was null or unreadable must still carry "id": null.
The tests fail against the old shape.
The Rust tests check the server's replies against what the server itself
expects, so a reply shape the official TypeScript SDK rejects passed all
of them for months. scripts/mcp-smoke connects with the SDK's
StdioClientTransport, lists the tools, calls list_network_interfaces
(read-only, no network) and checks that an error reply arrives too. It
fails with the SDK's own reason when it drops a message, and after 30 s
overall.

The SDK is pinned to 1.32.1 with a lockfile. A new CI job builds netscli
on ubuntu-latest and runs the script, and the CI Gate waits for it.

Against the 0.3.4 binary it fails at once with "Unrecognized key:
error". Against this branch it connects and lists 9 tools.
The read loop took a slot before reading the next line, so with sixteen
requests running nothing more was read: not notifications/cancelled,
which is how a client frees a slot, and not EOF. Cancelling failed
exactly when the server was busiest, and a client that closed stdin left
every request running.

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. The backpressure
that waiting gave is replaced by a bound: past 32 requests running or
waiting, a request is refused at once with -32000 instead of being held
in memory with its params. The 15-minute ceiling now starts when a
request gets its slot.

Two smaller transport fixes ride along:

- An over-long line's tail stayed in the buffer and was read as the next
  message. The rest of the line is now discarded.
- In-flight tracking was keyed by id alone, so a request finishing just
  as the client reused its id removed the new request's entry and left it
  impossible to cancel. An entry is now removed only by its own task.

serve() takes the transport and the handler as parameters so the tests
can hold every slot with requests that never finish. Line framing moves
to lines.rs, which server.rs needed to stay under the size guard.
ping is MCP's liveness check and may come at any time, before
initialize too. It was answered -32601 (or -32002 before initialize),
so a client that pings took a working server for a broken one. It now
gets an empty result.

initialize echoed whatever protocolVersion the client sent, claiming
support for versions this server has never seen. It now agrees the
client's version when it is one of 2025-06-18, 2025-03-26 or 2024-11-05,
and otherwise answers with the newest of those, as the MCP lifecycle
asks. A client that names no version still gets 2024-11-05.

The SDK smoke test pings too.
A capture runs on a blocking thread that dropping its future does not
reach. A cancel, the 15-minute request ceiling and EOF all drop the
future, and the capture carried on regardless, holding the interface and
writing its file for up to an hour. Its capture slot went with the
future, so cancelling and restarting got past the four-capture limit
with the old captures still running.

- Each capture gets a cancel token, cancelled when its future is
  dropped. libpcap's read times out every 250 ms, so the loop stops
  within that.
- The slot is held by a task that lives until the capture has returned.
- At EOF the server also stops background jobs, which belong to no
  request and ran on after the client had gone.
- A blocking capture_pcap with only maxPackets fell through to core's
  one-hour ceiling, past the two-minute clamp. It now gets the 10 s
  default the tool advertises.

The output file is now created with create_new before libpcap opens
it. libpcap truncates whatever is at the name, and capture_pcap always
wrote capture.pcap in the server's working directory, usually the
user's project. It replaced any file of that name, and a capture.pcap
symlink committed to a repository sent the capture through the link,
as root if the server ran elevated. create_new refuses anything at the
name, a dangling symlink included. An unnamed blocking capture now gets
a new timestamped name, and a capture that cannot start removes the
empty file it was given.

start_pcap_capture and the job status now report outputFile, so a
client knows where an unnamed job's capture goes before it ends.

The pcap job tests move to dispatch/pcap_tests.rs, because the
lifecycle test now waits for its job to end and dispatch/tests.rs is at
the size guard. The symlink test is Unix-only and runs in CI.
A result over 1 MiB that was an object, not a list, was replaced whole
by {error, limit_bytes, size_bytes}. A capture too large to return lost
its file path and packet count with its packets, and a capture job's
result lost its jobId and status, so for an unnamed capture the model
could not even say where the file was.

An object over the cap now keeps every field except its largest list,
which is cut to fit the way an oversized array already was, with
truncated, returned, total and a note naming the list beside them. For
capture_pcap that list is packets, for get_pcap_capture_result
result.packets, and for inspect_host ports.

The tests move to limits/tests.rs, because limits.rs passed the size
guard.
The remote-text cap reached banner, hex_preview and info only. The
Server header was cut to 256 characters in banner and returned whole
beside it in http.headers, and an HTTP status line, mDNS service and
host names and TXT properties were not capped at all.

Every string under http and properties is now capped like a banner, and
so are hostname and full_name. A real DNS name is at most 253
characters, so a valid one is never cut.
mdns-sd's 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. MdnsEngine::discover shut it down on its normal exit and when a
browse task failed, and nowhere else. A service type without its final
dot returned early through ?, and a dropped future (an MCP cancel or the
request ceiling) never reached the end. Both left the thread running,
re-sending every browse it had started, until the process exited.

The daemon is now owned by a guard that shuts it down when dropped, and
service types are checked before any daemon starts. browse_on takes the
daemon so the tests can keep a handle and see that it stops.
service_types had no length limit. Each entry is its own multicast
browse, repeated for the whole window (up to 30 s), so a long list was a
burst of mDNS queries on the LAN, and a repeated type was browsed twice.
Duplicates are now dropped and more than 32 types is refused with
-32602. The tool schema says maxItems 32.
The rows were read through (*table).Table.as_ptr(), which borrows the
one-row array the struct declares and then reads NumEntries rows through
it. Under Rust's aliasing model that borrow covers only the first row,
so reading the rest is undefined behaviour (Miri flags it), even though
it is not known to miscompile today. The pointer now comes from the table
pointer with addr_of!, for both GetIpNetTable2 and
GetUnicastIpAddressTable.

SendARP's buffer was a [u8; 8] where the API documents an array of
ULONG. It is now [u32; 2], so it also has a ULONG's alignment. A one-off
local run resolved three live neighbours with SendARP and got the same
MACs the table holds.
Discovery re-checks each neighbour-table-only host with a blocking
SendARP, and every check started at once, each timed from that moment.
With more candidates than tokio's blocking pool has threads, the later
checks spent their 2.5 s queued and real neighbours were dropped. The
checks now run 64 at a time, each timed from when it starts. The table's
size probably keeps this rare, which is unmeasured.
The probe sent the target's bare address as the Host header, so an IPv6
target got Host: fe80::1, which is malformed, and strict servers answered
400 instead of the headers the probe reads. probe_http now takes the
address and brackets an IPv6 one, as in a URL.
Only a refused connect (closed) and a timeout (filtered) were mapped. A
connect answered with an ICMP host, network or administratively
prohibited unreachable came back as error, so a host behind a firewall
that rejects rather than drops returned an error row for every port, and
the MCP server, which keeps error ports, returned all of them.

Those now read as filtered, as nmap reports them, and keep the error text
so the reason is still there. A timeout stays filtered with no text, as
before.
The OUI loader tried data/oui.min.json.gz and data/oui.json in the
working directory before the embedded copy, and any JSON that was not an
object parsed to an empty map that still won. A stray file in whatever
directory netscli ran from replaced vendor names, or removed them, with
nothing to say so.

The working-directory candidates are gone (NETSCLI_OUI_PATH and a copy
next to the executable still work, as the README says), and JSON that is
not an object is skipped.
When the preferred interface's network is wider than /16, the default
narrows to the /24 around this host. That host address came from
detect_default_ipv4_addr, which picks an interface by a different rule:
the first one up, unsorted, which may be a tunnel, and with no IPv4
address on it the default fell back to 192.168.1.0/24.

The address now comes from the same interface as the network. On
Windows it is the adapter's own address inside the chosen prefix.
detect_default_ipv4_addr is unchanged for its other caller.

No test: which interface wins depends on the machine's adapters, and
nothing here can stand in for them.
raw_icmp is compiled only off Windows (ping.rs gates it), so its
cfg(windows) setsockopt branch was unsafe code that no build ever
compiled or checked.
The example iterated the Result that scan_host returns and read .port on
the Vec inside it, so it did not compile. It now awaits with ?, and
compiles as written (checked as a throwaway example). The features table
also lists mdns and ts.
The two sample replies showed "error": null beside the result, which is
the shape the official SDK rejects. Both are re-captured from a real
session against this branch. The page also says what changed for a
client: replies over 1 MiB keep their other fields, captures stop on
cancel and EOF, cancelling works when every slot is busy, calls past 32
are refused, ping is answered, and capture files are never replaced or
followed through a symlink.
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Site preview: https://pr-554.netscli-site-preview.pages.dev

Built from 422219f with NETSCLI_PREVIEW=1 — noindex, and analytics disabled so it does not report into netscli.com's numbers.

Production is unaffected: netscli.com is served from GitHub Pages via pages.yml, which is manual-only.

@fstubner
fstubner merged commit beacf17 into main Oct 7, 2026
20 checks passed
@fstubner
fstubner deleted the fix/mcp-server branch October 7, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant