updates
This commit is contained in:
@@ -112,6 +112,18 @@ class MCPService {
|
||||
private let anytypeService = AnytypeMCPService.shared
|
||||
private let paperlessService = PaperlessService.shared
|
||||
|
||||
// MARK: - Bash Approval State
|
||||
|
||||
struct PendingBashCommand: Identifiable {
|
||||
let id = UUID()
|
||||
let command: String
|
||||
let workingDirectory: String
|
||||
}
|
||||
|
||||
private(set) var pendingBashCommand: PendingBashCommand? = nil
|
||||
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var bashSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Tool Schema Generation
|
||||
|
||||
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
||||
@@ -220,6 +232,21 @@ class MCPService {
|
||||
tools.append(contentsOf: paperlessService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add bash_execute tool when bash is enabled
|
||||
if settings.bashEnabled {
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
let timeout = settings.bashTimeout
|
||||
let approvalNote = settings.bashRequireApproval ? " User approval required before execution." : ""
|
||||
tools.append(makeTool(
|
||||
name: "bash_execute",
|
||||
description: "Execute a shell command via /bin/zsh and return stdout/stderr. Working directory: \(workDir). Timeout: \(timeout)s.\(approvalNote)",
|
||||
properties: [
|
||||
"command": prop("string", "The shell command to execute")
|
||||
],
|
||||
required: ["command"]
|
||||
))
|
||||
}
|
||||
|
||||
// Add web_search tool when online mode is active
|
||||
// (OpenRouter handles search natively via :online model suffix, so excluded here)
|
||||
if onlineMode {
|
||||
@@ -346,6 +373,20 @@ class MCPService {
|
||||
}
|
||||
return copyFile(source: source, destination: destination)
|
||||
|
||||
case "bash_execute":
|
||||
guard settings.bashEnabled else {
|
||||
return ["error": "Bash execution is disabled. Enable it in Settings > MCP."]
|
||||
}
|
||||
guard let command = args["command"] as? String, !command.isEmpty else {
|
||||
return ["error": "Missing required parameter: command"]
|
||||
}
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
if settings.bashRequireApproval {
|
||||
return await executeBashWithApproval(command: command, workingDirectory: workDir)
|
||||
} else {
|
||||
return await runBashCommand(command, workingDirectory: workDir)
|
||||
}
|
||||
|
||||
case "web_search":
|
||||
let query = args["query"] as? String ?? ""
|
||||
guard !query.isEmpty else {
|
||||
@@ -706,6 +747,113 @@ class MCPService {
|
||||
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
|
||||
}
|
||||
|
||||
// MARK: - Bash Execution
|
||||
|
||||
private func executeBashWithApproval(command: String, workingDirectory: String) async -> [String: Any] {
|
||||
// If the user already approved all commands for this session, skip the UI
|
||||
if bashSessionApproved {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
||||
self.pendingBashContinuation = continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func approvePendingBashCommand(forSession: Bool = false) {
|
||||
guard let pending = pendingBashCommand, let cont = pendingBashContinuation else { return }
|
||||
pendingBashCommand = nil
|
||||
pendingBashContinuation = nil
|
||||
if forSession {
|
||||
bashSessionApproved = true
|
||||
}
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let result = await self.runBashCommand(pending.command, workingDirectory: pending.workingDirectory)
|
||||
cont.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func denyPendingBashCommand() {
|
||||
guard pendingBashCommand != nil else { return }
|
||||
pendingBashCommand = nil
|
||||
pendingBashContinuation?.resume(returning: ["error": "User denied command execution"])
|
||||
pendingBashContinuation = nil
|
||||
}
|
||||
|
||||
func resetBashSessionApproval() {
|
||||
bashSessionApproved = false
|
||||
}
|
||||
|
||||
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
||||
let timeoutSeconds = settings.bashTimeout
|
||||
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
Log.mcp.info("bash_execute: \(command)")
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = ["-c", command]
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: workDir, isDirectory: &isDir), isDir.boolValue {
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: workDir)
|
||||
}
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
var timedOut = false
|
||||
let timeoutItem = DispatchWorkItem {
|
||||
if process.isRunning {
|
||||
timedOut = true
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: timeoutItem)
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
timeoutItem.cancel()
|
||||
Log.mcp.error("bash_execute failed to start: \(error.localizedDescription)")
|
||||
continuation.resume(returning: ["error": "Failed to run command: \(error.localizedDescription)"])
|
||||
return
|
||||
}
|
||||
|
||||
timeoutItem.cancel()
|
||||
|
||||
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let exitCode = Int(process.terminationStatus)
|
||||
|
||||
Log.mcp.info("bash_execute exit=\(exitCode) stdout=\(stdout.count)b stderr=\(stderr.count)b timedOut=\(timedOut)")
|
||||
|
||||
var result: [String: Any] = ["exit_code": exitCode]
|
||||
if !stdout.isEmpty {
|
||||
let maxOut = 20_000
|
||||
result["stdout"] = stdout.count > maxOut
|
||||
? String(stdout.prefix(maxOut)) + "\n... (output truncated)"
|
||||
: stdout
|
||||
}
|
||||
if !stderr.isEmpty {
|
||||
result["stderr"] = String(stderr.prefix(5_000))
|
||||
}
|
||||
if timedOut {
|
||||
result["timed_out"] = true
|
||||
result["note"] = "Command terminated after \(timeoutSeconds)s timeout"
|
||||
}
|
||||
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gitignore Support
|
||||
|
||||
/// Reload gitignore rules for all allowed folders
|
||||
|
||||
Reference in New Issue
Block a user