852 lines
33 KiB
Swift
852 lines
33 KiB
Swift
//
|
|
// MCPService.swift
|
|
// oAI
|
|
//
|
|
// MCP (Model Context Protocol) service for filesystem tool execution
|
|
//
|
|
|
|
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
|
|
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.",
|
|
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: [])
|
|
}
|
|
}
|