ExternalMCPManager.reconfigure() only started brand-new clients — a
server ID that already had a client (in ANY state, including .crashed)
was silently skipped even when its settings had just changed. Editing a
server in Settings and clicking Save persisted correctly but never
reached the live connection, which just kept running with its old
(often broken) config until the next app launch. Rune hit this directly
editing Obsidian's URL/token after the toggle-fields bug corrupted it.
Added ExternalMCPServer: Equatable so reconfigure can detect a changed
config for a still-enabled server and restart it fresh (extracted the
restart-attempt-reset logic already used by retryClient into a shared
restartFresh helper).
Root-caused two real issues Rune hit with Obsidian/Homepage external MCP
servers:
1. Toggling a server's enable switch silently wiped transportKind/env/
url/bearerToken/headers back to stdio defaults (only id/name/command/
args/isEnabled/timeout/createdAt were preserved) — almost certainly
how Obsidian's config got corrupted into an empty-command stdio entry
despite never being edited directly. Fixed via
ExternalMCPServer.withEnabledToggled(), which flips only isEnabled.
2. npx (installed via Homebrew) was invisible to Confab because GUI apps
only inherit launchd's minimal PATH, not the Terminal PATH. Tried
spawning the user's login shell to ask for its real PATH — this
caused two real hangs in one session (first an -ilc pipe deadlock,
then a waitUntilExit()/CFRunLoop reentrancy issue even after fixing
that) and was abandoned entirely in favor of LoginShellEnvironment:
deterministic, subprocess-free directory probing (Homebrew, MacPorts,
Volta, nvm's alias file) that can't hang by construction.
Also added:
- Edit capability for existing External MCP servers (previously only
Add/Toggle/Delete) — the second thing Rune explicitly asked for, and
the way to fix a corrupted entry like Obsidian's without deleting it.
- MCPClientError.commandNotFound: a stdio server's command is checked
against PATH up front in StdioMCPTransport.prepare() and fails
immediately with a clear reason instead of cycling through 3 rounds of
crash/restart backoff (5s/15s/30s) for a permanently-missing binary.
- A "Get Node.js" button appears when this happens, opening a sheet with
a copyable `brew install node`, a one-click install (via
NodeInstallHelper, using the terminationHandler/readabilityHandler
pattern already proven safe elsewhere in this file — deliberately not
waitUntilExit()), or a nodejs.org link if Homebrew isn't present.
- ExternalMCPManager.retryClient(id:) to manually retry after fixing the
underlying cause.
- Help book: new "Servers That Use npx" section, updated Server Status
section, updated Settings blurb.
37 new/changed tests covering the toggle fix, PATH probing, the
commandNotFound fast-fail path, and missing-command detection — full
suite (374 tests) passes clean.
External MCP Servers previously only spoke stdio (spawn a local
command + args). Adds:
- env vars for stdio servers (merged into the subprocess environment,
not embedded in the args string), with a masked key-value editor
- a native Streamable HTTP transport (URL + Bearer token + custom
headers), so HTTP-based MCP servers like Obsidian's Local REST API
plugin connect directly without needing npx/Node.js as a bridge
Introduces an MCPTransport abstraction (stdio/HTTP) so ExternalMCPClient
stays transport-agnostic — mirrors how Provider.swift already abstracts
AI backends in this codebase.
Also fixes a real crash found via live testing against Obsidian:
convertInputSchema force-unwrapped a tool parameter's `type`, which
isn't required by JSON Schema — Obsidian's plugin was the first real
server to send a parameter without one. Live-verified end to end
(vault search/read/write/edit) before this commit, per the project's
standing rule to hold external-service-dependent changes until they're
actually confirmed working, not just compiling and passing tests.