c118996746
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge) - Agentic AI loop: LLM plans → tool calls → execution → feedback loop - 15 agent tools: file ops, terminal, git, xcode build, code intel - 7 persistent terminal tools with background session management - Chat sidebar with agent step rendering and auto-apply - NVIDIA NIM API integration (Llama 3.3 70B default) - OpenAI tool_calls format with prompt-based fallback - Code editor with syntax highlighting and multi-tab support - File tree, console view, terminal view - Git integration and workspace management
404 lines
19 KiB
Swift
404 lines
19 KiB
Swift
// 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
|
|
}
|
|
}
|