bdf25d1274
- XcodeTools: swift_package commands now search workspace root and immediate subdirs for Package.swift instead of blindly running from workspaceRoot (fixes 'Could not find Package.swift' error) - VS Code: set git.autoRepositoryDetection=openEditors and git.scanRepositories=[] to prevent path-doubling on CxSwiftAgent
325 lines
15 KiB
Swift
325 lines
15 KiB
Swift
// XcodeTools.swift
|
|
// CxSwiftAgent — Xcode Integration MCP Tools
|
|
//
|
|
// 10 tools: xcode_build, xcode_test, xcode_analyze, xcode_schemes,
|
|
// xcode_devices, xcode_swift_check, xcode_clean, xcode_archive,
|
|
// xcode_provisioning, xcode_swift_format
|
|
|
|
import Foundation
|
|
|
|
enum XcodeTools {
|
|
static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) {
|
|
|
|
// ── xcode_build ───────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_build",
|
|
description: "Build an Xcode project or Swift package.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [
|
|
"scheme": ["type": "string", "description": "Xcode scheme name."],
|
|
"project": ["type": "string", "description": "Project/workspace path."],
|
|
"configuration": ["type": "string", "description": "Build configuration (Debug/Release, default: Debug)."],
|
|
"destination": ["type": "string", "description": "Build destination."],
|
|
"swift_package": ["type": "boolean", "description": "Build as Swift Package (default: false)."],
|
|
] as [String: Any],
|
|
] as [String: Any]
|
|
) { args in
|
|
if args["swift_package"] as? Bool == true {
|
|
guard let spmDir = findPackageSwift(from: config.workspaceRoot) else {
|
|
return err("Could not find Package.swift in workspace. Specify a project path or use xcode_build without swift_package.")
|
|
}
|
|
return runCommand("swift", ["build"], in: spmDir)
|
|
}
|
|
let xcArgs = buildXcodebuildCmd(args, action: "build", config: config)
|
|
return runCommand(config.xcodePath, xcArgs, in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_test ───────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_test",
|
|
description: "Run tests for an Xcode project or Swift package.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [
|
|
"scheme": ["type": "string", "description": "Xcode scheme name."],
|
|
"project": ["type": "string", "description": "Project/workspace path."],
|
|
"test_plan": ["type": "string", "description": "Test plan name."],
|
|
"destination": ["type": "string", "description": "Test destination."],
|
|
"swift_package": ["type": "boolean", "description": "Test as Swift Package (default: false)."],
|
|
] as [String: Any],
|
|
] as [String: Any]
|
|
) { args in
|
|
if args["swift_package"] as? Bool == true {
|
|
guard let spmDir = findPackageSwift(from: config.workspaceRoot) else {
|
|
return err("Could not find Package.swift in workspace. Specify a project path or use xcode_test without swift_package.")
|
|
}
|
|
return runCommand("swift", ["test"], in: spmDir)
|
|
}
|
|
var xcArgs = buildXcodebuildCmd(args, action: "test", config: config)
|
|
if let plan = args["test_plan"] as? String {
|
|
xcArgs += ["-testPlan", plan]
|
|
}
|
|
return runCommand(config.xcodePath, xcArgs, in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_analyze ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_analyze",
|
|
description: "Run Xcode static analyzer.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [
|
|
"scheme": ["type": "string", "description": "Xcode scheme name."],
|
|
"project": ["type": "string", "description": "Project/workspace path."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { args in
|
|
let xcArgs = buildXcodebuildCmd(args, action: "analyze", config: config)
|
|
return runCommand(config.xcodePath, xcArgs, in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_schemes ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_schemes",
|
|
description: "List available Xcode schemes.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [
|
|
"project": ["type": "string", "description": "Project/workspace path."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { args in
|
|
var xcArgs = ["-list"]
|
|
if let project = args["project"] as? String {
|
|
if project.hasSuffix(".xcworkspace") {
|
|
xcArgs += ["-workspace", project]
|
|
} else {
|
|
xcArgs += ["-project", project]
|
|
}
|
|
}
|
|
return runCommand(config.xcodePath, xcArgs, in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_devices ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_devices",
|
|
description: "List available simulators and devices.",
|
|
inputSchema: ["type": "object", "properties": [:] as [String: Any]] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { _ in
|
|
return runCommand("xcrun", ["simctl", "list", "devices", "--json"], in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_swift_check ─────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_swift_check",
|
|
description: "Type-check Swift source file without building.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["path"],
|
|
"properties": [
|
|
"path": ["type": "string", "description": "Swift source 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")
|
|
}
|
|
return runCommand("swiftc", ["-typecheck", path], in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_clean ───────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_clean",
|
|
description: "Clean Xcode build products.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [
|
|
"scheme": ["type": "string", "description": "Xcode scheme name."],
|
|
"project": ["type": "string", "description": "Project/workspace path."],
|
|
"swift_package": ["type": "boolean", "description": "Clean Swift Package (default: false)."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(destructiveHint: true)
|
|
) { args in
|
|
if args["swift_package"] as? Bool == true {
|
|
guard let spmDir = findPackageSwift(from: config.workspaceRoot) else {
|
|
return err("Could not find Package.swift in workspace.")
|
|
}
|
|
return runCommand("swift", ["package", "clean"], in: spmDir)
|
|
}
|
|
let xcArgs = buildXcodebuildCmd(args, action: "clean", config: config)
|
|
return runCommand(config.xcodePath, xcArgs, in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_archive ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_archive",
|
|
description: "Create an Xcode archive for distribution.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["scheme"],
|
|
"properties": [
|
|
"scheme": ["type": "string", "description": "Xcode scheme name."],
|
|
"project": ["type": "string", "description": "Project/workspace path."],
|
|
"archive_path": ["type": "string", "description": "Output archive path."],
|
|
] as [String: Any],
|
|
] as [String: Any]
|
|
) { args in
|
|
if config.sandboxMode { return err("Archive disabled in sandbox mode") }
|
|
var xcArgs = buildXcodebuildCmd(args, action: "archive", config: config)
|
|
if let archivePath = args["archive_path"] as? String {
|
|
xcArgs += ["-archivePath", archivePath]
|
|
}
|
|
return runCommand(config.xcodePath, xcArgs, in: config.workspaceRoot)
|
|
}
|
|
|
|
// ── xcode_provisioning ────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_provisioning",
|
|
description: "List installed provisioning profiles.",
|
|
inputSchema: ["type": "object", "properties": [:] as [String: Any]] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { _ in
|
|
let profileDir = NSHomeDirectory() + "/Library/MobileDevice/Provisioning Profiles"
|
|
let fm = FileManager.default
|
|
guard let files = try? fm.contentsOfDirectory(atPath: profileDir) else {
|
|
return ok("No provisioning profiles directory found")
|
|
}
|
|
|
|
var output = "Provisioning Profiles:\n"
|
|
for file in files.sorted() where file.hasSuffix(".mobileprovision") {
|
|
output += " \(file)\n"
|
|
}
|
|
return ok(output)
|
|
}
|
|
|
|
// ── xcode_swift_format ────────────────────────────────────────
|
|
server.registerTool(
|
|
"xcode_swift_format",
|
|
description: "Format Swift source file using swift-format.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["path"],
|
|
"properties": [
|
|
"path": ["type": "string", "description": "Swift source file path."],
|
|
"in_place": ["type": "boolean", "description": "Format in place (default: false)."],
|
|
] as [String: Any],
|
|
] as [String: Any]
|
|
) { args in
|
|
guard let path = config.resolvePath(args["path"] as? String ?? "") else {
|
|
return err("Path not allowed")
|
|
}
|
|
var formatArgs = [path]
|
|
if args["in_place"] as? Bool == true {
|
|
formatArgs.insert("-i", at: 0)
|
|
}
|
|
return runCommand("swift-format", formatArgs, in: config.workspaceRoot)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Search for Package.swift in the root and one level of subdirectories.
|
|
private static func findPackageSwift(from root: String) -> String? {
|
|
let fm = FileManager.default
|
|
// Check root first
|
|
if fm.fileExists(atPath: (root as NSString).appendingPathComponent("Package.swift")) {
|
|
return root
|
|
}
|
|
// Check immediate subdirectories
|
|
if let children = try? fm.contentsOfDirectory(atPath: root) {
|
|
for child in children {
|
|
let childPath = (root as NSString).appendingPathComponent(child)
|
|
var isDir: ObjCBool = false
|
|
if fm.fileExists(atPath: childPath, isDirectory: &isDir), isDir.boolValue {
|
|
let pkg = (childPath as NSString).appendingPathComponent("Package.swift")
|
|
if fm.fileExists(atPath: pkg) {
|
|
return childPath
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func buildXcodebuildCmd(_ args: [String: Any], action: String, config: AgentConfig) -> [String] {
|
|
var xcArgs = [action]
|
|
|
|
if let scheme = args["scheme"] as? String { xcArgs += ["-scheme", scheme] }
|
|
|
|
if let project = args["project"] as? String {
|
|
if project.hasSuffix(".xcworkspace") {
|
|
xcArgs += ["-workspace", project]
|
|
} else {
|
|
xcArgs += ["-project", project]
|
|
}
|
|
} else {
|
|
// Auto-detect .xcodeproj or .xcworkspace in workspace root
|
|
let fm = FileManager.default
|
|
let root = config.workspaceRoot
|
|
if let contents = try? fm.contentsOfDirectory(atPath: root) {
|
|
if let ws = contents.first(where: { $0.hasSuffix(".xcworkspace") }) {
|
|
xcArgs += ["-workspace", (root as NSString).appendingPathComponent(ws)]
|
|
} else if let proj = contents.first(where: { $0.hasSuffix(".xcodeproj") }) {
|
|
xcArgs += ["-project", (root as NSString).appendingPathComponent(proj)]
|
|
}
|
|
}
|
|
}
|
|
|
|
if let configuration = args["configuration"] as? String {
|
|
xcArgs += ["-configuration", configuration]
|
|
}
|
|
|
|
if let destination = args["destination"] as? String {
|
|
xcArgs += ["-destination", destination]
|
|
}
|
|
|
|
return xcArgs
|
|
}
|
|
|
|
private static func runCommand(_ executable: String, _ args: [String], in directory: String) -> [[String: Any]] {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: executable.hasPrefix("/") ? executable : "/usr/bin/\(executable)")
|
|
process.arguments = args
|
|
process.currentDirectoryURL = URL(fileURLWithPath: directory)
|
|
process.environment = ProcessInfo.processInfo.environment
|
|
|
|
let pipe = Pipe()
|
|
let errPipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = errPipe
|
|
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
let outData = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let output = String(data: outData, encoding: .utf8) ?? ""
|
|
let errOutput = String(data: errData, encoding: .utf8) ?? ""
|
|
|
|
let combined = output + (errOutput.isEmpty ? "" : "\n--- stderr ---\n\(errOutput)")
|
|
|
|
if process.terminationStatus != 0 {
|
|
return err("Exit code \(process.terminationStatus):\n\(combined)")
|
|
}
|
|
return ok(combined.isEmpty ? "(success, no output)" : combined)
|
|
} catch {
|
|
return err("Failed to run \(executable): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
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)"]]
|
|
}
|
|
}
|