Files
oai-swift/oAI/Services/MCPService.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

1342 lines
56 KiB
Swift

//
// MCPService.swift
// oAI
//
// MCP (Model Context Protocol) service for filesystem tool execution
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import os
@Observable
class MCPService {
static let shared = MCPService()
private(set) var allowedFolders: [String] = []
private let settings = SettingsService.shared
private let fm = FileManager.default
private let maxFileSize = 10 * 1024 * 1024 // 10 MB
private let maxTextDisplay = 50 * 1024 // 50 KB before truncation
private let maxDirItems = 1000
private let maxSearchResults = 100
private let skipPatterns: Set<String> = [
".git", "node_modules", ".DS_Store", "__pycache__",
".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100"
]
/// Cached gitignore rules per allowed folder
private var gitignoreRules: [String: GitignoreParser] = [:]
private init() {
allowedFolders = settings.mcpAllowedFolders
if settings.mcpRespectGitignore {
loadGitignores()
}
}
// MARK: - Folder Management
func addFolder(_ rawPath: String) -> String? {
let expanded = (rawPath as NSString).expandingTildeInPath
let resolved = (expanded as NSString).standardizingPath
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return "Path is not a directory: \(rawPath)"
}
if allowedFolders.contains(resolved) {
return "Folder already added: \(resolved)"
}
allowedFolders.append(resolved)
settings.mcpAllowedFolders = allowedFolders
if settings.mcpRespectGitignore {
loadGitignoreForFolder(resolved)
}
return nil
}
func removeFolder(at index: Int) -> Bool {
guard index >= 0 && index < allowedFolders.count else { return false }
let removed = allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: removed)
return true
}
func removeFolder(path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
if let index = allowedFolders.firstIndex(of: resolved) {
allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: resolved)
return true
}
return false
}
func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
// Always allow the system temp directory — external MCP servers and tools write
// intermediate data there (e.g. Safari MCP page-content files, generated images).
// Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
// codebase's own temp files actually use, e.g. ChatViewModel's /tmp/oai_generated_*
// and ExternalMCPClient's /tmp/oai_mcp_* — they resolve to different directories.
let tmpCandidates = [
(NSTemporaryDirectory() as NSString).standardizingPath,
"/tmp",
"/private/tmp"
]
if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
return allowedFolders.contains { resolved.hasPrefix($0) }
}
// MARK: - Permission Helpers
var canWriteFiles: Bool { settings.mcpCanWriteFiles }
var canDeleteFiles: Bool { settings.mcpCanDeleteFiles }
var canCreateDirectories: Bool { settings.mcpCanCreateDirectories }
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
var respectGitignore: Bool { settings.mcpRespectGitignore }
private let anytypeService = AnytypeMCPService.shared
private let paperlessService = PaperlessService.shared
private let eventKitService = EventKitService.shared
private let contactsService = ContactsService.shared
private let locationMapsService = LocationMapsService.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: - Personal Data (Calendar/Reminders) Approval State
struct PendingPersonalDataAction: Identifiable {
let id = UUID()
let toolName: String
let argumentsJSON: String
let summary: String
}
private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var personalDataSessionApproved: Bool = false
// MARK: - Tool Schema Generation
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
var tools: [Tool] = [
makeTool(
name: "read_file",
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
properties: [
"file_path": prop("string", "The absolute path to the file to read")
],
required: ["file_path"]
),
makeTool(
name: "list_directory",
description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to list"),
"recursive": prop("boolean", "Whether to list recursively (default: false)")
],
required: ["dir_path"]
),
makeTool(
name: "search_files",
description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.",
properties: [
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
"content_search": prop("string", "Optional text to search for inside files")
],
required: ["pattern"]
)
]
if canWriteFiles {
tools.append(makeTool(
name: "write_file",
description: "Create or overwrite a file with the given content. Parent directories are created automatically. WARNING: For existing files larger than ~200 lines, use edit_file instead — writing very large files in a single call may exceed output token limits and fail.",
properties: [
"file_path": prop("string", "The absolute path to the file to write"),
"content": prop("string", "The text content to write to the file")
],
required: ["file_path", "content"]
))
tools.append(makeTool(
name: "edit_file",
description: "Find and replace text in a file. The old_text must appear exactly once in the file.",
properties: [
"file_path": prop("string", "The absolute path to the file to edit"),
"old_text": prop("string", "The exact text to find (must be a unique match)"),
"new_text": prop("string", "The replacement text")
],
required: ["file_path", "old_text", "new_text"]
))
}
if canDeleteFiles {
tools.append(makeTool(
name: "delete_file",
description: "Delete a file at the given path.",
properties: [
"file_path": prop("string", "The absolute path to the file to delete")
],
required: ["file_path"]
))
}
if canCreateDirectories {
tools.append(makeTool(
name: "create_directory",
description: "Create a directory (and any intermediate directories) at the given path.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to create")
],
required: ["dir_path"]
))
}
if canMoveFiles {
tools.append(makeTool(
name: "move_file",
description: "Move or rename a file or directory.",
properties: [
"source": prop("string", "The absolute path of the file/directory to move"),
"destination": prop("string", "The absolute destination path")
],
required: ["source", "destination"]
))
tools.append(makeTool(
name: "copy_file",
description: "Copy a file or directory to a new location.",
properties: [
"source": prop("string", "The absolute path of the file/directory to copy"),
"destination": prop("string", "The absolute destination path for the copy")
],
required: ["source", "destination"]
))
}
// Add Anytype tools if enabled and configured
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
tools.append(contentsOf: anytypeService.getToolSchemas())
}
// Add Paperless-NGX tools if enabled and configured
if settings.paperlessEnabled && settings.paperlessConfigured {
tools.append(contentsOf: paperlessService.getToolSchemas())
}
// Add Calendar/Reminders tools if enabled
if settings.calendarEnabled || settings.remindersEnabled {
tools.append(contentsOf: eventKitService.getToolSchemas(
calendarEnabled: settings.calendarEnabled,
remindersEnabled: settings.remindersEnabled
))
}
// Add Contacts tools if enabled
if settings.contactsEnabled {
tools.append(contentsOf: contactsService.getToolSchemas())
}
// Add Location/Maps tools if enabled
if settings.locationMapsEnabled {
tools.append(contentsOf: locationMapsService.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 {
tools.append(makeTool(
name: "web_search",
description: "Search the web for current information using DuckDuckGo. Use this when you need up-to-date information, news, or facts not in your training data. Formulate a concise, focused search query.",
properties: [
"query": prop("string", "The search query to look up")
],
required: ["query"]
))
}
// Add spawn_research_agents when Research Agents are enabled
if settings.agentsEnabled {
tools.append(makeTool(
name: "spawn_research_agents",
description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
properties: [
"tasks": Tool.Function.Parameters.Property(
type: "array",
description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
items: .init(type: "string")
)
],
required: ["tasks"]
))
}
// Add tools from external MCP servers (stdio JSON-RPC protocol)
tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())
return tools
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
// MARK: - Tool Execution
/// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
/// its own model calls — every other tool ignores them. Passed in by the caller's active
/// chat session rather than stored on MCPService, since this service has no provider state.
func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
Log.mcp.error("Invalid arguments JSON for tool \(name)")
return ["error": "Invalid arguments JSON"]
}
switch name {
case "read_file":
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return readFile(filePath: filePath)
case "list_directory":
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
let recursive = args["recursive"] as? Bool ?? false
return listDirectory(dirPath: dirPath, recursive: recursive)
case "search_files":
guard let pattern = args["pattern"] as? String else {
return ["error": "Missing required parameter: pattern"]
}
let searchPath = args["search_path"] as? String
let contentSearch = args["content_search"] as? String
return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch)
case "write_file":
guard canWriteFiles else {
return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let content = args["content"] as? String else {
return ["error": "Missing required parameters: file_path, content"]
}
return writeFile(filePath: filePath, content: content)
case "edit_file":
guard canWriteFiles else {
return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let oldText = args["old_text"] as? String,
let newText = args["new_text"] as? String else {
return ["error": "Missing required parameters: file_path, old_text, new_text"]
}
return editFile(filePath: filePath, oldText: oldText, newText: newText)
case "delete_file":
guard canDeleteFiles else {
return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return deleteFile(filePath: filePath)
case "create_directory":
guard canCreateDirectories else {
return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."]
}
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
return createDirectory(dirPath: dirPath)
case "move_file":
guard canMoveFiles else {
return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return moveFile(source: source, destination: destination)
case "copy_file":
guard canMoveFiles else {
return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
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 {
return ["error": "Missing required parameter: query"]
}
let results = await WebSearchService.shared.search(query: query)
if results.isEmpty {
return ["results": [], "message": "No results found for: \(query)"]
}
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
return ["results": mapped]
case "spawn_research_agents":
guard settings.agentsEnabled else {
return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
}
guard let agentProvider, let agentModelId else {
return ["error": "Internal error: missing model context for spawn_research_agents"]
}
guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
}
return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)
case "calendar_create_event", "reminders_create", "reminders_complete":
guard settings.calendarEnabled || settings.remindersEnabled else {
return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
}
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
default:
// Route to external MCP servers (stdio JSON-RPC)
if ExternalMCPManager.shared.isExternalTool(name) {
return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
}
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments)
}
// Route paperless_* tools to PaperlessService
if name.hasPrefix("paperless_") {
return await paperlessService.executeTool(name: name, arguments: arguments)
}
// Route calendar_*/reminders_* read tools to EventKitService
if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
return await eventKitService.executeTool(name: name, arguments: arguments)
}
// Route contacts_* tools to ContactsService
if name.hasPrefix("contacts_") {
return await contactsService.executeTool(name: name, arguments: arguments)
}
// Route location_*/maps_* tools to LocationMapsService
if name.hasPrefix("location_") || name.hasPrefix("maps_") {
return await locationMapsService.executeTool(name: name, arguments: arguments)
}
return ["error": "Unknown tool: \(name)"]
}
}
// MARK: - Read Tool Implementations
private func readFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let attrs = try? fm.attributesOfItem(atPath: resolved),
let fileSize = attrs[.size] as? Int else {
return ["error": "Cannot read file attributes: \(filePath)"]
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
var finalContent = content
if content.utf8.count > maxTextDisplay {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
return ["content": finalContent, "path": resolved, "size": fileSize]
}
private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return ["error": "Directory not found: \(dirPath)"]
}
var items: [String] = []
if recursive {
guard let enumerator = fm.enumerator(atPath: resolved) else {
return ["error": "Cannot enumerate directory"]
}
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (resolved as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
// Skip gitignored directories entirely
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
items.append(item)
if items.count >= maxDirItems { break }
}
} else {
guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else {
return ["error": "Cannot list directory"]
}
items = contents.filter { !skipPatterns.contains($0) }
.filter { entry in
if respectGitignore {
let fullPath = (resolved as NSString).appendingPathComponent(entry)
return !isGitignored(fullPath)
}
return true
}
.prefix(maxDirItems).map { entry in
var entryIsDir: ObjCBool = false
let fullPath = (resolved as NSString).appendingPathComponent(entry)
fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir)
return entryIsDir.boolValue ? "\(entry)/" : entry
}
}
let truncated = items.count >= maxDirItems
return ["items": items, "count": items.count, "truncated": truncated, "path": resolved]
}
private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] {
let basePath: String
if let sp = searchPath {
basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath
} else if let first = allowedFolders.first {
basePath = first
} else {
return ["error": "No search path specified and no allowed folders configured"]
}
guard isPathAllowed(basePath) else {
return ["error": "Access denied: search path is outside allowed folders"]
}
guard let enumerator = fm.enumerator(atPath: basePath) else {
return ["error": "Cannot enumerate directory: \(basePath)"]
}
var results: [String] = []
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (basePath as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
let filename = (item as NSString).lastPathComponent
// Filename pattern match
if fnmatch(pattern, filename, 0) != 0 {
continue
}
// Content search if requested
if let searchText = contentSearch, !searchText.isEmpty {
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
continue
}
if !content.localizedCaseInsensitiveContains(searchText) {
continue
}
}
results.append(item)
if results.count >= maxSearchResults { break }
}
let truncated = results.count >= maxSearchResults
return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath]
}
// MARK: - Write Tool Implementations
private func writeFile(filePath: String, content: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
// Create parent directories if needed
let parentDir = (resolved as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create parent directories: \(error.localizedDescription)"]
}
}
do {
try content.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)")
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)")
return ["success": true, "path": resolved, "bytes_written": content.utf8.count]
}
private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
// Count occurrences
let occurrences = content.components(separatedBy: oldText).count - 1
if occurrences == 0 {
return ["error": "old_text not found in file"]
}
if occurrences > 1 {
return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."]
}
let newContent = content.replacingOccurrences(of: oldText, with: newText)
do {
try newContent.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func deleteFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
do {
try fm.removeItem(atPath: resolved)
} catch {
Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)")
return ["error": "Cannot delete file: \(error.localizedDescription)"]
}
Log.mcp.info("Deleted \(resolved)")
return ["success": true, "path": resolved]
}
private func createDirectory(dirPath: String) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
do {
try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create directory: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func moveFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot move file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
private func copyFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot copy file: \(error.localizedDescription)"]
}
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)
}
// 2nd Brain calls its helper script via bash_execute — let the user mark that
// specific traffic as always-trusted instead of approving it every time.
if isTrustedSecondBrainCommand(command) {
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 isTrustedSecondBrainCommand(_ command: String) -> Bool {
guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
}
// MARK: - Research Agents (read-only, parallel)
/// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
/// read-only tool loop, and returns their findings concatenated for the orchestrator.
private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
// Hard cap on total sub-agents regardless of concurrency setting, so a model
// requesting an unreasonably long task list can't run away with cost.
let cappedTasks = Array(tasks.prefix(8))
var results: [(Int, String)] = []
await withTaskGroup(of: (Int, String).self) { group in
var nextIndex = 0
func launchNext() {
guard nextIndex < cappedTasks.count else { return }
let idx = nextIndex
let task = cappedTasks[idx]
nextIndex += 1
group.addTask {
let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
return (idx, answer)
}
}
for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
for await result in group {
results.append(result)
launchNext()
}
}
let sorted = results.sorted { $0.0 < $1.0 }
let formatted = sorted.map { idx, answer in
"### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
}.joined(separator: "\n\n")
var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
if tasks.count > cappedTasks.count {
response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
}
return response
}
/// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
/// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
let readOnlyTools: [Tool] = [
makeTool(
name: "read_file",
description: "Read the contents of a file. Maximum file size is 10MB.",
properties: ["file_path": prop("string", "The absolute path to the file to read")],
required: ["file_path"]
),
makeTool(
name: "list_directory",
description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to list"),
"recursive": prop("boolean", "Whether to list recursively (default: false)")
],
required: ["dir_path"]
),
makeTool(
name: "search_files",
description: "Search for files by name pattern or content.",
properties: [
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
"content_search": prop("string", "Optional text to search for inside files")
],
required: ["pattern"]
),
makeTool(
name: "web_search",
description: "Search the web for current information using DuckDuckGo.",
properties: ["query": prop("string", "The search query to look up")],
required: ["query"]
)
]
let allowedNames = Set(readOnlyTools.map { $0.function.name })
var apiMessages: [[String: Any]] = [
["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
["role": "user", "content": task]
]
let maxIterations = 6
for iteration in 0..<maxIterations {
if Task.isCancelled { return "(cancelled)" }
guard let response = try? await provider.chatWithToolMessages(
model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
) else {
return "(error: sub-agent request failed)"
}
let toolCalls = response.toolCalls ?? []
guard !toolCalls.isEmpty else {
return response.content.isEmpty ? "(no findings)" : response.content
}
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty { assistantMsg["content"] = response.content }
assistantMsg["tool_calls"] = toolCalls.map { tc in
["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
}
apiMessages.append(assistantMsg)
for tc in toolCalls {
let resultJSON: String
if allowedNames.contains(tc.functionName) {
let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
resultJSON = serializeToolResult(result)
} else {
resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
}
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
}
if iteration == maxIterations - 1 {
return "(research incomplete: sub-agent reached its iteration limit)"
}
}
return "(no findings)"
}
private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) else {
return "{\"error\": \"Failed to serialize result\"}"
}
guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
return str
}
return truncated + "\n... (result truncated)"
}
// MARK: - Personal Data (Calendar/Reminders) Approval
private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
self.pendingPersonalDataContinuation = continuation
}
}
}
func approvePendingPersonalDataAction(forSession: Bool = false) {
guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
pendingPersonalDataAction = nil
pendingPersonalDataContinuation = nil
if forSession {
personalDataSessionApproved = true
}
Task.detached(priority: .userInitiated) {
let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
cont.resume(returning: result)
}
}
func denyPendingPersonalDataAction() {
guard pendingPersonalDataAction != nil else { return }
pendingPersonalDataAction = nil
pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
pendingPersonalDataContinuation = nil
}
func resetPersonalDataSessionApproval() {
personalDataSessionApproved = 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
func reloadGitignores() {
gitignoreRules.removeAll()
if settings.mcpRespectGitignore {
loadGitignores()
}
}
private func loadGitignores() {
for folder in allowedFolders {
loadGitignoreForFolder(folder)
}
}
private func loadGitignoreForFolder(_ folder: String) {
var parser = GitignoreParser(rootPath: folder)
parser.loadRules(fileManager: fm)
gitignoreRules[folder] = parser
}
/// Check if an absolute path is gitignored by any loaded gitignore rules
func isGitignored(_ absolutePath: String) -> Bool {
guard settings.mcpRespectGitignore else { return false }
for (folder, parser) in gitignoreRules {
if absolutePath.hasPrefix(folder) {
let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" }))
if !relativePath.isEmpty && parser.isIgnored(relativePath) {
return true
}
}
}
return false
}
}
// MARK: - GitignoreParser
/// Parses .gitignore files and checks paths against the patterns.
/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#).
struct GitignoreParser {
let rootPath: String
private var rules: [GitignoreRule] = []
struct GitignoreRule {
let pattern: String
let isNegation: Bool
let isDirectoryOnly: Bool
/// Regex compiled from the gitignore glob pattern
let regex: NSRegularExpression?
}
init(rootPath: String) {
self.rootPath = rootPath
}
/// Load .gitignore from the root path (non-recursive — only the root .gitignore)
mutating func loadRules(fileManager fm: FileManager) {
let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore")
guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else {
return
}
parseContent(content)
}
mutating func parseContent(_ content: String) {
let lines = content.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines and comments
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
var pattern = trimmed
let isNegation = pattern.hasPrefix("!")
if isNegation {
pattern = String(pattern.dropFirst())
}
// Remove trailing spaces (unless escaped)
while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") {
pattern = String(pattern.dropLast())
}
let isDirectoryOnly = pattern.hasSuffix("/")
if isDirectoryOnly {
pattern = String(pattern.dropLast())
}
// Remove leading slash (anchors to root, but we match relative paths)
if pattern.hasPrefix("/") {
pattern = String(pattern.dropFirst())
}
guard !pattern.isEmpty else { continue }
let regex = gitignorePatternToRegex(pattern)
rules.append(GitignoreRule(
pattern: pattern,
isNegation: isNegation,
isDirectoryOnly: isDirectoryOnly,
regex: regex
))
}
}
/// Check if a relative path (from rootPath) is ignored
func isIgnored(_ relativePath: String) -> Bool {
var ignored = false
for rule in rules {
let matches: Bool
if let regex = rule.regex {
let range = NSRange(relativePath.startIndex..., in: relativePath)
matches = regex.firstMatch(in: relativePath, range: range) != nil
} else {
// Fallback: simple contains check for the pattern basename
let basename = (relativePath as NSString).lastPathComponent
matches = basename == rule.pattern
}
if matches {
if rule.isNegation {
ignored = false
} else {
ignored = true
}
}
}
return ignored
}
/// Convert a gitignore glob pattern to a regex
private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? {
var regex = ""
let chars = Array(pattern)
var i = 0
// If the pattern contains no slash, it matches against the filename only
let matchesPath = pattern.contains("/")
if !matchesPath {
// Match against any path component — the pattern can appear as the last component
regex += "(?:^|/)"
} else {
regex += "^"
}
while i < chars.count {
let c = chars[i]
switch c {
case "*":
if i + 1 < chars.count && chars[i + 1] == "*" {
// **
if i + 2 < chars.count && chars[i + 2] == "/" {
// **/ — matches zero or more directories
regex += "(?:.+/)?"
i += 3
continue
} else {
// ** at end — matches everything
regex += ".*"
i += 2
continue
}
} else {
// Single * — matches anything except /
regex += "[^/]*"
}
case "?":
regex += "[^/]"
case ".":
regex += "\\."
case "[":
// Character class — pass through
regex += "["
case "]":
regex += "]"
case "\\":
// Escape next character
if i + 1 < chars.count {
i += 1
regex += NSRegularExpression.escapedPattern(for: String(chars[i]))
}
default:
regex += NSRegularExpression.escapedPattern(for: String(c))
}
i += 1
}
// Allow matching as a prefix (directory) or exact match
regex += "(?:/.*)?$"
return try? NSRegularExpression(pattern: regex, options: [])
}
}