fix: MCP test failures hidden as success; brew/nvm binaries not on PATH

Two related bugs surfaced when testing MCP servers that spawn npx, node,
python, etc. from Homebrew/nvm/asdf/mise installs.

1. MCP test reported success even when the connection failed.
   `hermes mcp test <server>` exits 0 even when the inner connection
   fails — it prints the error to stdout instead. Scarf trusted the
   exit code and rendered a green checkmark while the output said
   "✗ Connection failed: [Errno 2] No such file or directory: 'npx'".
   Fix: also scan output for ✗, "Connection failed", "No such file or
   directory", and "Error:" markers.

2. .app launches start with a minimal PATH that excludes Homebrew.
   When Scarf is launched from Finder/Dock, ProcessInfo's PATH is
   `/usr/bin:/bin:/usr/sbin:/sbin` — no /opt/homebrew/bin, no
   /usr/local/bin, no nvm/asdf/mise shims. Hermes inherits this and
   can't find npx/node/python when spawning MCP server subprocesses.
   Fix: query the user's login shell PATH once via `/bin/zsh -lc 'echo
   $PATH'`, cache it on HermesFileService, and inject it into both
   `runHermesCLI` and the ACP subprocess. Falls back to a sane default
   covering both Apple Silicon and Intel Homebrew if zsh query fails.

Bumps version to 1.5.8 (build 10). Includes signed Universal + ARM64
binaries.
This commit is contained in:
Alan Wizemann
2026-04-16 07:51:32 -07:00
parent 117a0ee9dd
commit b2a29ab68d
5 changed files with 83 additions and 8 deletions
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.7; MARKETING_VERSION = 1.5.8;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.7; MARKETING_VERSION = 1.5.8;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
+4 -2
View File
@@ -66,8 +66,10 @@ actor ACPClient {
proc.standardOutput = stdout proc.standardOutput = stdout
proc.standardError = stderr proc.standardError = stderr
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution // ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution.
var env = ProcessInfo.processInfo.environment // Use the enriched environment so any tools hermes spawns (MCP servers,
// shell commands) can find brew/nvm/asdf binaries on PATH.
var env = HermesFileService.enrichedEnvironment()
env.removeValue(forKey: "TERM") env.removeValue(forKey: "TERM")
proc.environment = env proc.environment = env
@@ -323,10 +323,18 @@ struct HermesFileService: Sendable {
}.value }.value
let elapsed = Date().timeIntervalSince(started) let elapsed = Date().timeIntervalSince(started)
let tools = Self.parseToolListFromTestOutput(result.1) let tools = Self.parseToolListFromTestOutput(result.1)
// hermes mcp test exits 0 even when the inner connection fails it
// reports the failure on stdout instead. Look for explicit failure
// markers so the UI doesn't show a green check on a broken server.
let output = result.1
let hasFailureMarker = output.contains("")
|| output.range(of: "Connection failed", options: .caseInsensitive) != nil
|| output.range(of: "No such file or directory", options: .caseInsensitive) != nil
|| output.range(of: "Error:", options: .caseInsensitive) != nil
return MCPTestResult( return MCPTestResult(
serverName: name, serverName: name,
succeeded: result.0 == 0, succeeded: result.0 == 0 && !hasFailureMarker,
output: result.1, output: output,
tools: tools, tools: tools,
elapsed: elapsed elapsed: elapsed
) )
@@ -930,6 +938,70 @@ struct HermesFileService: Sendable {
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
} }
/// PATH cobbled together from the user's login shell needed because
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew,
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`,
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such
/// file or directory`. Computed once and cached.
private static let enrichedPath: String = {
let pipe = Pipe()
let errPipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc
// chain on macOS) so PATH manipulations made there are picked up.
// Skip -i to avoid hangs from interactive prompts.
process.arguments = ["-l", "-c", "echo $PATH"]
process.standardOutput = pipe
process.standardError = errPipe
defer {
try? pipe.fileHandleForReading.close()
try? pipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
let deadline = Date().addingTimeInterval(3)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning { process.terminate() }
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let path = (String(data: data, encoding: .utf8) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if process.terminationStatus == 0 && !path.isEmpty {
return path
}
} catch {
// Fall through to default below.
}
// Fallback when the login shell can't be queried (zsh missing,
// sandbox restriction, timeout). Covers Apple Silicon + Intel
// Homebrew plus the standard system paths.
let home = NSHomeDirectory()
return [
"\(home)/.local/bin",
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin"
].joined(separator: ":")
}()
/// Environment to hand any subprocess that may itself spawn user-installed
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical
/// to ProcessInfo.processInfo.environment but with PATH replaced by the
/// login-shell PATH.
nonisolated static func enrichedEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["PATH"] = enrichedPath
return env
}
@discardableResult @discardableResult
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) { nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
guard let binary = hermesBinaryPath() else { return (-1, "") } guard let binary = hermesBinaryPath() else { return (-1, "") }
@@ -939,6 +1011,7 @@ struct HermesFileService: Sendable {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: binary) process.executableURL = URL(fileURLWithPath: binary)
process.arguments = args process.arguments = args
process.environment = Self.enrichedEnvironment()
process.standardOutput = stdoutPipe process.standardOutput = stdoutPipe
process.standardError = stderrPipe process.standardError = stderrPipe
if let stdinPipe { process.standardInput = stdinPipe } if let stdinPipe { process.standardInput = stdinPipe }