Skip to content

Desktop app: tell users the MCP server exists, and how to wire it up - #423

Merged
fstubner merged 3 commits into
mainfrom
feat/gui-mcp-settings
Sep 20, 2026
Merged

fstubner merged 3 commits into
mainfrom
feat/gui-mcp-settings

Conversation

@fstubner

@fstubner fstubner commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Closes #412.

The desktop app had no MCP integration at all. Someone who only ever opens the app had no way to learn that netscli ships an MCP server, let alone connect one to an agent.

What this deliberately does not do

#412 proposed install/uninstall buttons over netscli mcp-service. Three things in the code contradict that, so the panel does none of it:

Claim in the issue What the code does
A service to install mcp_service.rs has zero cfg(target_os) and writes a systemd unit. On Windows it creates C:\Users\<user>\.config\systemd\user\netscli-mcp.service and prints "✅ Service file created" — a false success on the desktop app's main platform.
"Both need elevation" They don't. systemctl --user is a user service.
Not a start-server button, because serve is stdio The unit it installs is one. It runs netscli serve with Restart=always, systemd hands it /dev/null on stdin, serve hits Ok(0) => break on its first read and exits 0 — then restarts 5 seconds later, forever.

None of that is touched here; it wants its own change.

What the panel does

The thing that actually solves discovery is the config block, and it needs a binary to point at — so it detects one first.

The desktop app does not ship the CLI. This crate links netscli-core directly, tauri.conf.json has no externalBin, and nothing in src-tauri spawns a netscli process. Someone who ran winget install netscli-gui or opened the MSI has no netscli binary at all. So both states are first-class:

  • Found → the client config block, with a copy button.
  • Not found → one platform-appropriate install command, plus a link to the docs page carrying the rest.

Absolute path, not the bare name. An MCP client launched from the desktop session doesn't necessarily inherit a shell's PATH, so "command": "netscli" fails for a client a terminal would have resolved fine. Detection checks PATH first, then the documented install locations per platform.

--version is run before a candidate is accepted. Finding a file named netscli isn't finding this program, and the cost of being wrong is paid far away — the block is pasted into another application and fails there, silently, later. Bounded at 3s so a hung candidate can't freeze the dialog.

JSON.stringify, not a template literal. A Windows path is full of backslashes and has to reach the client's config escaped. Verified by parsing the rendered block back and comparing to the input path.

Verified

  • Both states rendered and read in the running app (screenshots below).
  • Config parses as JSON and round-trips to the original Windows path.
  • Detection run for real on this machine: returned a path, 0.3.1, os=windows.
  • cargo fmt clean, clippy -D warnings clean, 3 Rust tests, 200 frontend tests, file-size guard clean.

One defect this caught: settings.css clips small to a single line with an ellipsis, which suits a one-line note beside a control and cut this section's explanation to …reopen Settings t… — losing exactly the half that says what to do. Found by looking at the rendered panel, not the diff.

Not proven locally

The Selenium render harness would not run to completion on this machine — it exits 0 straight after the build without reaching the driver, twice. The new assertMcpSection assertion in it is therefore unproven locally and will first execute on windows-latest in the GUI Render workflow.

settings-mcp.css is a new file rather than more of settings.css, which was at 257 lines and would have crossed the 300-line guard — same reason settings-controls.css and settings-number-field.css are separate.

The desktop app had no MCP integration at all -- no reference to it anywhere
under apps/netscli-gui. Someone who only ever opens the app had no way to
learn that netscli ships an MCP server, let alone connect one to an agent.

WHAT THIS IS NOT. Issue #412 proposed install/uninstall buttons over
`netscli mcp-service`. Three things in the code contradict that, and the
panel does none of it:

- mcp_service.rs has zero cfg(target_os) and writes a systemd user unit to
  ~/.config/systemd/user/. On Windows it creates
  C:\Users\<user>\.config\systemd\user\netscli-mcp.service and prints
  "Service file created" -- a false success on the desktop app's main
  platform.
- The issue says both need elevation. They do not; `systemctl --user` is a
  user service.
- The unit runs `netscli serve` with Restart=always. serve is stdio-only, and
  systemd's default StandardInput=null hands it /dev/null, so it reads EOF on
  its first read and exits 0 (server.rs: `Ok(0) => break`). Restart brings it
  back five seconds later to do the same thing, forever. The issue argues
  against a start-server button for exactly this reason and then proposes
  wiring up a service with the same flaw.

Those are left alone here and want their own change.

WHAT THE PANEL DOES. The thing that actually solves discovery is the config
block, and it needs a binary to point at -- so the panel detects one first.

The detection is the part worth reading. This crate links netscli-core
directly, tauri.conf.json has no externalBin, and nothing here spawns a
netscli process, so the desktop app does NOT ship the CLI. Someone who ran
`winget install netscli-gui` or opened the MSI has no netscli binary at all,
and for them the honest answer is "install the CLI", not a config naming a
file that does not exist. Both states are therefore first-class: found
renders the config, missing renders one install command and a link to the
docs page carrying the rest.

The absolute path, not the bare name, because an MCP client launched from the
desktop session does not necessarily inherit a shell's PATH -- `"command":
"netscli"` fails for a client that a terminal would have resolved fine. That
is also why detection checks PATH first and then the documented install
locations per platform.

`--version` is run on a candidate before accepting it. Finding a file named
netscli is not finding this program, and the cost of being wrong is paid far
from here: the block is pasted into another application and fails there,
silently, later. The probe is bounded at 3s so a hung candidate cannot freeze
the dialog.

The block is built with JSON.stringify rather than a template literal. A
Windows path is full of backslashes and has to reach the client's config file
escaped; hand-writing that is how it gets missed on the one platform most
desktop users are on. Verified by parsing the rendered block back and
comparing to the input path.

settings-mcp.css is a new file rather than more of settings.css, which was at
257 lines and would have gone over the 300-line guard. Same reason
settings-controls.css and settings-number-field.css are separate.

It also overrides one inherited rule: settings.css clips `small` to a single
line with an ellipsis, which suits a one-line note beside a control and cut
this section's explanation to "... reopen Settings t…" -- losing exactly the
half that says what to do. Caught by looking at the rendered panel, not the
diff.

Verified: both states rendered and read in the running app; the config parses
as JSON and round-trips to the original Windows path; detection run for real
on this machine returned a path, version 0.3.1 and os=windows. astro-side
untouched. cargo fmt clean, clippy -D warnings clean, 3 Rust tests, 200
frontend tests, file-size guard clean.

The Selenium render harness would not run to completion on this machine --
it exits 0 straight after the build without reaching the driver, twice --
so the new assertion in it is unproven locally and will first execute on
windows-latest in the GUI Render workflow.
The check resolved a netscli.com link by looking for `<slug>.astro` or
`<slug>/index.astro` under site/src/pages. That was true when it was written
and is not now: src/pages also holds endpoint routes that return a file
rather than a page -- install.sh.ts, install.ps1.ts, llms.txt.ts and
llms-full.txt.ts.

So it reported the site's OWN canonical install one-liner,
https://netscli.com/install.sh, as a link the site does not build. The route
exists (site/src/pages/install.sh.ts), the site serves it, and the site's
install-urls.ts generates that exact URL for the hero and the install panel.

Found by the MCP settings panel, which is the first thing in the app to link
to it. The link was correct; the gate could not see the route.

`.ts` is added alongside `.astro` for both the bare and index forms, so the
other three endpoint routes are covered too rather than only the one that
happened to fail.
The GUI Render workflow last succeeded on 2026-07-14. Every run since is a
failure or a cancellation -- 74 and 17 of them -- so the gate has reported
nothing for two months. Three separate bugs, each of which alone was enough
to stop it.

PORT RACE (why the driver never started). main() called getFreePort() at the
top, then handed that number to msedgedriver minutes later, after the Rust
build and after the app launched. getFreePort reserves nothing: it binds an
ephemeral port, reads the number and closes, so the result is only a fact
about the instant it was taken. WebView2 starts a swarm of processes that
take ephemeral ports, and one of them took that one. msedgedriver then
exited 1 after printing only its banner:

    [SEVERE]: bind() returned an error: Only one usage of each socket
    address (protocol/network address/port) is normally permitted. (0x2740)
    IPv6 port not available. Exiting...

Reproduced by holding the port open and spawning the driver on it, which
gives that exact output and exit code. The port is now chosen inside
startNativeDriver at the moment it spawns, with two retries on a bind
collision. An explicit TAURI_DRIVER_PORT is honoured and never retried: if a
port was named, failing to get it is the answer.

THE 21-MINUTE HANG (why CI burned its whole 30-minute budget). On failure
launchApplication let the rejection escape with the app still running, and
the app is spawned with piped stdio, so those pipes kept node's event loop
alive. The caller cannot clean that up -- `appProcess` is only assigned from
this function's return value, so on the throw path it is still undefined and
stopProcess(appProcess) is a no-op. CI printed "Timed out waiting for port
64892" at 09:21 and sat there until 09:42. It now kills its own child before
rethrowing, so the failure is immediate and legible.

TOOLTIP RACE (what failed once the first two were fixed). The assertion ran
dispatch, then waitForText, then a separate executeScript to measure.
AppTooltip hides 40ms after a pointerout, and every WebDriver round trip
costs far more than that, so the wait passed and the measurement then found
nothing. It reported "Global tooltip should render", which reads as a broken
tooltip rather than a test that looked too late.

Hovering and measuring now happen in one in-page script, with the hover
re-asserted every animation frame so the hide timer is continually cancelled
whatever triggers it, and the measurement taken in the same tick as the
match. A real failure still fails, and now reports what the tooltip held
instead of only that it was absent. dispatchTooltipPointerOver has no callers
left and is deleted rather than kept warm.

PROGRESS OUTPUT. The harness printed nothing between the build and the first
scenario, so a stall in setup was indistinguishable from a clean exit: the
local symptom was "builds, then exits 0" and diagnosing it needed
instrumentation added by hand, twice. Each setup stage now says what it is
doing. That is what turned this from unreproducible into three named bugs.

Verified: four consecutive local runs exit 0 and write every scenario
screenshot -- scan, dns, inspect, interfaces, sweep, menus/toolbar, dark,
light, narrow. Run four times specifically because two of these were races
and one green run proves little. eslint clean, file-size guard clean.
@fstubner
fstubner merged commit 6962a4d into main Sep 20, 2026
15 of 16 checks passed
@fstubner
fstubner deleted the feat/gui-mcp-settings branch September 20, 2026 11:24
fstubner added a commit that referenced this pull request Sep 21, 2026
Twenty-eight non-dependency commits have landed since 0.3.1 and four of them
were in [Unreleased]. Cutting a release on that would have shipped notes
describing a fraction of it.

Added: discover naming hosts from mDNS (#418), and the desktop app's MCP
panel (#423). Fixed: result tables being unselectable (#417), and the
changelog page losing its paragraph breaks (#426). Changed: one bullet for
the site and docs pass, following the shape 0.3.1 used rather than listing
twelve site PRs a reader does not care about individually. Security: the
three advisories cleared together in #428.

The release pipeline fixes, the CI changes and the site deploy fix are
deliberately absent. #411 dropped the internal-changes section on purpose and
this follows it.

The rustls advisory is described as having shipped, because it did: 0.23.40
is in v0.3.1's Cargo.lock. The other two are the website's build
dependencies and reach no release artifact, which the entry now says rather
than lumping all three together.

Also repairs the header entry, which carried a duplicated paragraph from an
earlier edit.
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.

Desktop app: make the MCP server discoverable from Settings

1 participant