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.
139 lines
6.3 KiB
Swift
139 lines
6.3 KiB
Swift
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import SwiftUI
|
|
|
|
/// Shown when an External MCP server's stdio `command` (typically `npx`) couldn't be found on
|
|
/// PATH — the common cause is Node.js not being installed, or being installed somewhere Confab's
|
|
/// PATH probing doesn't check. Offers a copyable install command, a one-click install via Homebrew
|
|
/// (if present), and a link to the official Node.js installer as a fallback.
|
|
struct NodeInstallHelpSheet: View {
|
|
let missingCommand: String
|
|
let onRetry: () -> Void
|
|
let onDone: () -> Void
|
|
|
|
@State private var isInstalling = false
|
|
@State private var installResult: NodeInstallHelper.InstallResult?
|
|
@State private var copiedFeedback = false
|
|
|
|
private var homebrewPrefix: String? { LoginShellEnvironment.homebrewPrefix() }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "shippingbox")
|
|
.font(.title2)
|
|
.foregroundStyle(.orange)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Node.js Required")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
Text("'\(missingCommand)' couldn't be found on your Mac")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
Text("This MCP server is launched with '\(missingCommand)', which comes bundled with Node.js. Confab checked its usual install locations (Homebrew, MacPorts, Volta, nvm) and didn't find it there either.")
|
|
.font(.system(size: 13))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if let homebrewPrefix {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Homebrew is installed — this is the easiest way to install Node.js:")
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
|
HStack(spacing: 8) {
|
|
Text("brew install node")
|
|
.font(.system(size: 13, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.secondary.opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
Button(copiedFeedback ? "Copied" : "Copy") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString("brew install node", forType: .string)
|
|
copiedFeedback = true
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
if isInstalling {
|
|
HStack(spacing: 8) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Installing — this can take a minute…")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Button("Install Now") {
|
|
Task {
|
|
isInstalling = true
|
|
installResult = await NodeInstallHelper.installNode(homebrewPrefix: homebrewPrefix)
|
|
isInstalling = false
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
|
|
if let installResult {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(installResult.success ? "✓ Installed successfully" : "✗ Install failed")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(installResult.success ? .green : .red)
|
|
if !installResult.success && !installResult.output.isEmpty {
|
|
ScrollView {
|
|
Text(installResult.output)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 120)
|
|
.padding(8)
|
|
.background(Color.secondary.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color.secondary.opacity(0.05))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Homebrew wasn't found either. Install Node.js directly from its official installer:")
|
|
.font(.system(size: 13))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Button("Open nodejs.org") {
|
|
NSWorkspace.shared.open(URL(string: "https://nodejs.org")!)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(12)
|
|
.background(Color.secondary.opacity(0.05))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
Text("After installing, click Retry Connection below to try again.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack {
|
|
Button("Open nodejs.org") {
|
|
NSWorkspace.shared.open(URL(string: "https://nodejs.org")!)
|
|
}
|
|
Spacer()
|
|
Button("Done") { onDone() }
|
|
Button("Retry Connection") {
|
|
onRetry()
|
|
onDone()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(minWidth: 480, minHeight: 340)
|
|
}
|
|
}
|