mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user