// // MCPService.swift // oAI // // MCP (Model Context Protocol) service for filesystem tool execution // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . 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 = [ ".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 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 // MARK: - Tool Schema Generation func getToolSchemas() -> [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()) } 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 func executeTool(name: String, arguments: String) 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) default: // Route anytype_* tools to AnytypeMCPService if name.hasPrefix("anytype_") { return await anytypeService.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: - 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: []) } }