// FileOpsTools.swift // CxSwiftAgent — File Operations MCP Tools // // 10 tools: file_read, file_write, file_patch, file_search, file_list, // file_tree, file_info, file_delete, file_move, file_find import Foundation enum FileOpsTools { static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) { // ── file_read ───────────────────────────────────────────────── server.registerTool( "file_read", description: "Read file contents. Supports optional line range (start_line, end_line, 1-indexed).", inputSchema: [ "type": "object", "required": ["path"], "properties": [ "path": ["type": "string", "description": "File path within workspace."], "start_line": ["type": "integer", "description": "First line (1-indexed, default: 1)."], "end_line": ["type": "integer", "description": "Last line (1-indexed, default: EOF)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in guard let path = config.resolvePath(args["path"] as? String ?? "") else { return err("Path not allowed or outside workspace") } let fm = FileManager.default guard fm.fileExists(atPath: path) else { return err("File not found") } guard let attrs = try? fm.attributesOfItem(atPath: path), let size = attrs[.size] as? Int, size <= config.maxFileSize else { return err("File too large (max \(config.maxFileSize / 1_048_576)MB)") } guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return err("Cannot read file (binary or encoding error)") } let lines = content.components(separatedBy: "\n") let start = max(0, (args["start_line"] as? Int ?? 1) - 1) let end = min(lines.count - 1, (args["end_line"] as? Int ?? lines.count) - 1) var output = "" for i in start...max(start, end) { output += String(format: "%4d | %@\n", i + 1, lines[i]) } Task { await memory.recordFileAccess(path: path, action: "read") } return ok(output) } // ── file_write ──────────────────────────────────────────────── server.registerTool( "file_write", description: "Write content to a file. Creates parent directories if needed.", inputSchema: [ "type": "object", "required": ["path", "content"], "properties": [ "path": ["type": "string", "description": "File path within workspace."], "content": ["type": "string", "description": "Full file content to write."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(destructiveHint: true) ) { args in guard let path = config.resolvePath(args["path"] as? String ?? "") else { return err("Path not allowed or outside workspace") } guard let content = args["content"] as? String else { return err("Missing content parameter") } let dir = (path as NSString).deletingLastPathComponent try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) do { try content.write(toFile: path, atomically: true, encoding: .utf8) let lines = content.components(separatedBy: "\n").count Task { await memory.recordFileAccess(path: path, action: "write") } return ok("Written \(content.utf8.count) bytes (\(lines) lines) to \(args["path"] as? String ?? path)") } catch { return err("Write failed: \(error.localizedDescription)") } } // ── file_patch ──────────────────────────────────────────────── server.registerTool( "file_patch", description: "Apply search-and-replace patch to a file. Finds exact match of 'search' and replaces with 'replace'.", inputSchema: [ "type": "object", "required": ["path", "search", "replace"], "properties": [ "path": ["type": "string", "description": "File path."], "search": ["type": "string", "description": "Exact text to find."], "replace": ["type": "string", "description": "Replacement text."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(destructiveHint: true) ) { args in guard let path = config.resolvePath(args["path"] as? String ?? "") else { return err("Path not allowed or outside workspace") } guard let search = args["search"] as? String, !search.isEmpty else { return err("Missing search parameter") } let replace = args["replace"] as? String ?? "" guard var content = try? String(contentsOfFile: path, encoding: .utf8) else { return err("Cannot read file") } let occurrences = content.components(separatedBy: search).count - 1 guard occurrences > 0 else { return err("Search text not found in file") } content = content.replacingOccurrences(of: search, with: replace) do { try content.write(toFile: path, atomically: true, encoding: .utf8) Task { await memory.recordFileAccess(path: path, action: "patch") } return ok("Patched \(occurrences) occurrence(s) in \(args["path"] as? String ?? path)") } catch { return err("Write failed: \(error.localizedDescription)") } } // ── file_search ─────────────────────────────────────────────── server.registerTool( "file_search", description: "Search files by content (grep). Returns matching lines with file paths and line numbers.", inputSchema: [ "type": "object", "required": ["pattern"], "properties": [ "pattern": ["type": "string", "description": "Search pattern (substring match)."], "path": ["type": "string", "description": "Directory to search (default: workspace root)."], "include": ["type": "string", "description": "File extension filter (e.g. '.swift')."], "max_results": ["type": "integer", "description": "Maximum results (default: 50)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let pattern = args["pattern"] as? String ?? "" guard !pattern.isEmpty else { return err("Missing pattern") } let searchPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let includeExt = args["include"] as? String let maxResults = args["max_results"] as? Int ?? 50 var results: [String] = [] let fm = FileManager.default guard let enumerator = fm.enumerator(atPath: searchPath) else { return err("Cannot enumerate directory") } while let file = enumerator.nextObject() as? String { guard results.count < maxResults else { break } if config.isExcluded(file) { enumerator.skipDescendants(); continue } if let ext = includeExt, !file.hasSuffix(ext) { continue } let fullPath = (searchPath as NSString).appendingPathComponent(file) guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { continue } let lines = content.components(separatedBy: "\n") for (i, line) in lines.enumerated() { guard results.count < maxResults else { break } if line.localizedCaseInsensitiveContains(pattern) { results.append("\(file):\(i + 1): \(line.trimmingCharacters(in: .whitespaces))") } } } return ok(results.isEmpty ? "No matches found" : results.joined(separator: "\n")) } // ── file_list ───────────────────────────────────────────────── server.registerTool( "file_list", description: "List directory contents with file sizes and types.", inputSchema: [ "type": "object", "properties": [ "path": ["type": "string", "description": "Directory path (default: workspace root)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let dirPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let fm = FileManager.default guard let items = try? fm.contentsOfDirectory(atPath: dirPath) else { return err("Cannot list directory") } var output = "" for item in items.sorted() { if item.hasPrefix(".") && item != ".env" { continue } let fullPath = (dirPath as NSString).appendingPathComponent(item) var isDir: ObjCBool = false fm.fileExists(atPath: fullPath, isDirectory: &isDir) if isDir.boolValue { output += " \(item)/\n" } else { let size = (try? fm.attributesOfItem(atPath: fullPath)[.size] as? Int) ?? 0 output += " \(item) (\(formatSize(size)))\n" } } return ok(output.isEmpty ? "(empty directory)" : output) } // ── file_tree ───────────────────────────────────────────────── server.registerTool( "file_tree", description: "Recursive directory tree with depth limit.", inputSchema: [ "type": "object", "properties": [ "path": ["type": "string", "description": "Root directory (default: workspace root)."], "max_depth": ["type": "integer", "description": "Maximum depth (default: 3)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let rootPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let maxDepth = args["max_depth"] as? Int ?? 3 let tree = buildTree(rootPath, depth: 0, maxDepth: maxDepth, config: config) return ok(tree) } // ── file_info ───────────────────────────────────────────────── server.registerTool( "file_info", description: "File metadata: size, modification date, permissions, type.", inputSchema: [ "type": "object", "required": ["path"], "properties": [ "path": ["type": "string", "description": "File path."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in guard let path = config.resolvePath(args["path"] as? String ?? "") else { return err("Path not allowed") } let fm = FileManager.default guard let attrs = try? fm.attributesOfItem(atPath: path) else { return err("File not found") } var isDir: ObjCBool = false fm.fileExists(atPath: path, isDirectory: &isDir) let info: [String: Any] = [ "path": args["path"] as? String ?? path, "type": isDir.boolValue ? "directory" : "file", "size": attrs[.size] as? Int ?? 0, "modified": (attrs[.modificationDate] as? Date)?.description ?? "unknown", "permissions": String(format: "%o", attrs[.posixPermissions] as? Int ?? 0), ] return ok(JSON.serialize(info, pretty: true)) } // ── file_delete ─────────────────────────────────────────────── server.registerTool( "file_delete", description: "Delete a file. Requires sandbox mode to be disabled.", inputSchema: [ "type": "object", "required": ["path"], "properties": [ "path": ["type": "string", "description": "File path to delete."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(destructiveHint: true) ) { args in if config.sandboxMode { return err("Delete disabled in sandbox mode") } guard let path = config.resolvePath(args["path"] as? String ?? "") else { return err("Path not allowed") } do { try FileManager.default.removeItem(atPath: path) Task { await memory.recordFileAccess(path: path, action: "delete") } return ok("Deleted: \(args["path"] as? String ?? path)") } catch { return err("Delete failed: \(error.localizedDescription)") } } // ── file_move ───────────────────────────────────────────────── server.registerTool( "file_move", description: "Move or rename a file.", inputSchema: [ "type": "object", "required": ["source", "destination"], "properties": [ "source": ["type": "string", "description": "Source file path."], "destination": ["type": "string", "description": "Destination file path."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(destructiveHint: true) ) { args in guard let src = config.resolvePath(args["source"] as? String ?? ""), let dst = config.resolvePath(args["destination"] as? String ?? "") else { return err("Path not allowed") } let dstDir = (dst as NSString).deletingLastPathComponent try? FileManager.default.createDirectory(atPath: dstDir, withIntermediateDirectories: true) do { try FileManager.default.moveItem(atPath: src, toPath: dst) Task { await memory.recordFileAccess(path: dst, action: "move") } return ok("Moved to \(args["destination"] as? String ?? dst)") } catch { return err("Move failed: \(error.localizedDescription)") } } // ── file_find ───────────────────────────────────────────────── server.registerTool( "file_find", description: "Find files by name pattern (substring match).", inputSchema: [ "type": "object", "required": ["pattern"], "properties": [ "pattern": ["type": "string", "description": "Filename pattern (substring)."], "path": ["type": "string", "description": "Search directory (default: workspace root)."], "max_results": ["type": "integer", "description": "Maximum results (default: 50)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let pattern = args["pattern"] as? String ?? "" let searchPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot let maxResults = args["max_results"] as? Int ?? 50 var results: [String] = [] let fm = FileManager.default guard let enumerator = fm.enumerator(atPath: searchPath) else { return err("Cannot enumerate directory") } while let file = enumerator.nextObject() as? String { guard results.count < maxResults else { break } if config.isExcluded(file) { enumerator.skipDescendants(); continue } if file.localizedCaseInsensitiveContains(pattern) { results.append(file) } } return ok(results.isEmpty ? "No files found" : results.joined(separator: "\n")) } } // MARK: - Helpers private static func ok(_ text: String) -> [[String: Any]] { [["type": "text", "text": text]] } private static func err(_ message: String) -> [[String: Any]] { [["type": "text", "text": "Error: \(message)"]] } private static func formatSize(_ bytes: Int) -> String { if bytes < 1024 { return "\(bytes) B" } if bytes < 1_048_576 { return "\(bytes / 1024) KB" } return String(format: "%.1f MB", Double(bytes) / 1_048_576) } private static func buildTree(_ path: String, depth: Int, maxDepth: Int, config: AgentConfig, prefix: String = "") -> String { guard depth < maxDepth else { return "" } let fm = FileManager.default guard let items = try? fm.contentsOfDirectory(atPath: path).sorted() else { return "" } var output = "" for (i, item) in items.enumerated() { if item.hasPrefix(".") { continue } if config.isExcluded(item) { continue } let isLast = i == items.count - 1 let connector = isLast ? "└── " : "├── " let fullPath = (path as NSString).appendingPathComponent(item) var isDir: ObjCBool = false fm.fileExists(atPath: fullPath, isDirectory: &isDir) if isDir.boolValue { output += "\(prefix)\(connector)\(item)/\n" let childPrefix = prefix + (isLast ? " " : "│ ") output += buildTree(fullPath, depth: depth + 1, maxDepth: maxDepth, config: config, prefix: childPrefix) } else { output += "\(prefix)\(connector)\(item)\n" } } return output } }