From b47afa25790e37628ae15a54c6e5b53f760a0891 Mon Sep 17 00:00:00 2001 From: Michael Adam Groberman <81723568+MichaelAdamGroberman@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:33:32 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(v0.2.1):=20spotlight=5Fsearch,=20notify?= =?UTF-8?q?,=20prompt=5Fuser=20=E2=80=94=20runloop-free=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tools were silently broken in v0.2.0 because their underlying APIs (NSMetadataQuery, UNUserNotificationCenter, NSAlert.runModal) require an active CFRunLoop, which never spins in this binary — `@main async` enters Swift Concurrency directly without calling NSApplicationMain(). Fixes: - spotlight_search: NSMetadataQuery → /usr/bin/mdfind (with 5s timeout) - notify: UNUserNotificationCenter → osascript 'display notification' - prompt_user: NSAlert.runModal → osascript 'display dialog' All three now use external processes that bring their own runloops. Functionally verified spotlight_search returns 5 paths in ~50ms post-fix. --- Resources/Info.plist | 4 +- Sources/MacMCP/Server.swift | 2 +- Sources/MacMCP/Tools/FinderTools.swift | 82 ++++++++----------- Sources/MacMCP/Tools/SystemTools.swift | 106 +++++++++++++++++-------- Sources/MacMCPCore/MacMCPCore.swift | 2 +- manifest.json | 2 +- 6 files changed, 115 insertions(+), 83 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index 4a3e473..58ff76a 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + 0.2.1 CFBundleVersion - 2 + 3 CFBundleInfoDictionaryVersion 6.0 LSMinimumSystemVersion diff --git a/Sources/MacMCP/Server.swift b/Sources/MacMCP/Server.swift index e432068..e96d230 100644 --- a/Sources/MacMCP/Server.swift +++ b/Sources/MacMCP/Server.swift @@ -5,7 +5,7 @@ enum MacMCPServer { static func make() async throws -> Server { let server = Server( name: "mac-mcp", - version: "0.2.0", + version: "0.2.1", capabilities: .init( tools: .init(listChanged: false) ) diff --git a/Sources/MacMCP/Tools/FinderTools.swift b/Sources/MacMCP/Tools/FinderTools.swift index e8cc170..b68d468 100644 --- a/Sources/MacMCP/Tools/FinderTools.swift +++ b/Sources/MacMCP/Tools/FinderTools.swift @@ -114,65 +114,53 @@ enum FinderTools { r.add( name: "spotlight_search", - description: "Run a Spotlight (NSMetadataQuery) search and return matching paths.", + description: "Run a Spotlight (mdfind) search and return matching paths. Accepts plain text or full kMDItem queries.", inputSchema: Schema.object(properties: [ "query": Schema.string("Spotlight query, e.g. 'kMDItemDisplayName == \"foo*\"' or plain words."), - "scope": Schema.string("Optional scope path (defaults to user home)."), + "scope": Schema.string("Optional scope path (defaults to whole index)."), "limit": Schema.int("Max results (default 50, hard cap 500).") ], required: ["query"]) ) { args in let q = try args.requiredString("query") let limit = min(args.int("limit") ?? 50, 500) - let scope = args.string("scope").map { URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) } - let pred = predicate(for: q) - let results = await runSpotlight(predicate: pred, scope: scope, limit: limit) - return jsonResult(.array(results)) - } - } - - @MainActor - private static func runSpotlight(predicate: NSPredicate, scope: URL?, limit: Int) async -> [Value] { - let mq = NSMetadataQuery() - mq.predicate = predicate - if let scope { mq.searchScopes = [scope] } + let scope = args.string("scope").map { ($0 as NSString).expandingTildeInPath } - await withCheckedContinuation { (cont: CheckedContinuation) in - var observer: NSObjectProtocol? - observer = NotificationCenter.default.addObserver( - forName: .NSMetadataQueryDidFinishGathering, - object: mq, - queue: .main - ) { _ in - if let observer { NotificationCenter.default.removeObserver(observer) } - cont.resume(returning: ()) - } - // Hard cap so Spotlight can't hang the tool call. - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - if let observer { NotificationCenter.default.removeObserver(observer) } - if mq.isStarted { mq.stop() } - cont.resume(returning: ()) + // Use mdfind: it doesn't require an active CFRunLoop the way + // NSMetadataQuery does, so it works inside an async-only Swift + // executable that never starts NSApplicationMain. + let task = Process() + task.launchPath = "/usr/bin/mdfind" + var argv: [String] = [] + if let scope, !scope.isEmpty { + argv += ["-onlyin", scope] } - mq.start() - } - mq.disableUpdates() - if mq.isStarted { mq.stop() } + argv.append(q) + task.arguments = argv - var results: [Value] = [] - for i in 0.. NSPredicate { - let trimmed = q.trimmingCharacters(in: .whitespaces) - if trimmed.contains("kMDItem") || trimmed.contains("==") || trimmed.contains("LIKE") { - return NSPredicate(fromMetadataQueryString: trimmed) - ?? NSPredicate(format: "kMDItemDisplayName CONTAINS[c] %@", trimmed) + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8) ?? "" + let paths = text + .split(separator: "\n", omittingEmptySubsequences: true) + .prefix(limit) + .map { Value.string(String($0)) } + return jsonResult(.array(Array(paths))) } - return NSPredicate(format: "kMDItemDisplayName CONTAINS[c] %@", trimmed) } } diff --git a/Sources/MacMCP/Tools/SystemTools.swift b/Sources/MacMCP/Tools/SystemTools.swift index 39fac27..ce9891b 100644 --- a/Sources/MacMCP/Tools/SystemTools.swift +++ b/Sources/MacMCP/Tools/SystemTools.swift @@ -84,23 +84,34 @@ enum SystemTools { let body = try args.requiredString("body") let subtitle = args.string("subtitle") ?? "" - let center = UNUserNotificationCenter.current() - // Best-effort permission request; ignore errors (we still try to deliver). - _ = try? await center.requestAuthorization(options: [.alert, .sound]) - - let content = UNMutableNotificationContent() - content.title = title - content.body = body - if !subtitle.isEmpty { content.subtitle = subtitle } - let req = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil - ) - do { - try await center.add(req) - } catch { - throw MacMCPError(code: "notify_failed", message: String(describing: error)) + // UNUserNotificationCenter requires an active CFRunLoop to deliver + // its delegate callbacks; this binary runs under Swift Concurrency + // without NSApplicationMain, so the runloop never spins. Shell out + // to osascript instead — it has its own runloop. + func esc(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + var script = "display notification \"\(esc(body))\" with title \"\(esc(title))\"" + if !subtitle.isEmpty { + script += " subtitle \"\(esc(subtitle))\"" + } + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", script] + let errPipe = Pipe() + task.standardError = errPipe + try task.run() + task.waitUntilExit() + if task.terminationStatus != 0 { + let err = String( + data: errPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + throw MacMCPError( + code: "notify_failed", + message: "osascript exit \(task.terminationStatus): \(err.trimmingCharacters(in: .whitespacesAndNewlines))" + ) } return jsonResult(.object(["delivered": .bool(true)])) } @@ -122,23 +133,56 @@ enum SystemTools { let okLabel = args.string("ok_label") ?? "OK" let cancelLabel = args.string("cancel_label") ?? "Cancel" - return await MainActor.run { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: okLabel) - alert.addButton(withTitle: cancelLabel) - let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) - field.stringValue = defaultValue - alert.accessoryView = field - NSApp.activate(ignoringOtherApps: true) - let response = alert.runModal() - let confirmed = (response == .alertFirstButtonReturn) + // NSAlert.runModal() requires the AppKit runloop to be running, + // which it isn't in a Swift-Concurrency-only binary. Shell out to + // osascript's `display dialog` — same UI, runs in its own runloop. + func esc(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + let script = """ + display dialog "\(esc(message))" \ + with title "\(esc(title))" \ + default answer "\(esc(defaultValue))" \ + buttons {"\(esc(cancelLabel))", "\(esc(okLabel))"} \ + default button "\(esc(okLabel))" + """ + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", script] + let outPipe = Pipe() + let errPipe = Pipe() + task.standardOutput = outPipe + task.standardError = errPipe + try task.run() + task.waitUntilExit() + let stdoutText = String( + data: outPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + // osascript returns: "button returned:OK, text returned:VALUE" + // (or non-zero exit + 'User canceled.' on stderr if Cancel pressed). + if task.terminationStatus != 0 { return jsonResult(.object([ - "confirmed": .bool(confirmed), - "value": .string(confirmed ? field.stringValue : "") + "confirmed": .bool(false), + "value": .string("") ])) } + var buttonReturned = "" + var textReturned = "" + for part in stdoutText.split(separator: ",") { + let trimmed = part.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("button returned:") { + buttonReturned = String(trimmed.dropFirst("button returned:".count)) + } else if trimmed.hasPrefix("text returned:") { + textReturned = String(trimmed.dropFirst("text returned:".count)) + } + } + let confirmed = (buttonReturned == okLabel) + return jsonResult(.object([ + "confirmed": .bool(confirmed), + "value": .string(confirmed ? textReturned : "") + ])) } r.add( diff --git a/Sources/MacMCPCore/MacMCPCore.swift b/Sources/MacMCPCore/MacMCPCore.swift index bcf1694..a2c6cf3 100644 --- a/Sources/MacMCPCore/MacMCPCore.swift +++ b/Sources/MacMCPCore/MacMCPCore.swift @@ -1,4 +1,4 @@ // Re-exports and version metadata for the MacMCPCore library. public enum MacMCPCoreInfo { - public static let version = "0.2.0" + public static let version = "0.2.1" } diff --git a/manifest.json b/manifest.json index 5425525..ff26fc1 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "mac-mcp", "display_name": "macOS Native Control", - "version": "0.2.0", + "version": "0.2.1", "description": "Native macOS control via Swift + Cocoa: windows, Finder, clipboard, screenshots, notifications, and typed app automation. Code-signed for stable TCC grants. No raw shell or AppleScript exec.", "long_description": "A Claude Desktop Extension that exposes a typed, allow-listed surface for controlling macOS — built directly on AppKit, the Accessibility API, NSPasteboard, UNUserNotificationCenter, CGWindowList, and pre-compiled OSAKit scripts. Unlike shell-based alternatives, every tool is an explicit named function with a JSON schema; there is no run_shell or run_applescript escape hatch. The signed Developer ID binary gives Claude Desktop a stable TCC identity, so Accessibility and Automation grants persist across rebuilds.", "author": { From f15f4b5131473352c2c1ad9a8641a0674c40f455 Mon Sep 17 00:00:00 2001 From: Michael Adam Groberman <81723568+MichaelAdamGroberman@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:45:54 -0400 Subject: [PATCH 2/3] chore(v0.2.2): rename display_name to 'MacOS-MCP' Applies to manifest.json, .claude-plugin/marketplace.json, Info.plist (CFBundleDisplayName), README title. Internal name 'mac-mcp' unchanged since it's the manifest id consumers key off. --- .claude-plugin/marketplace.json | 4 ++-- README.md | 2 +- Resources/Info.plist | 6 +++--- Sources/MacMCP/Server.swift | 2 +- Sources/MacMCPCore/MacMCPCore.swift | 2 +- manifest.json | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b60ee14..efe5b1a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Native macOS control for Claude Desktop and Claude Code. 56 typed allow-listed MCP tools (windows, Finder, filesystem, system, input, app automation, terminal, iPhone Mirroring) plus 5 workflow skills. Code-signed Swift binary for stable TCC grants. Distributed as a one-click .mcpb.", - "version": "0.2.0", + "version": "0.2.2", "homepage": "https://github.com/MichaelAdamGroberman/mac-mcp" }, "plugins": [ @@ -14,7 +14,7 @@ "name": "mac-mcp", "source": "./", "description": "Native macOS control: 56 typed tools across windows, Finder, filesystem (allow-listed roots, symlink-safe), clipboard, screenshots, mouse/keyboard, app automation (Mail/Calendar/Messages/Safari/Notes), iTerm/Terminal, and iPhone Mirroring. Plus 5 workflow skills.", - "version": "0.2.0", + "version": "0.2.2", "author": { "name": "Michael Adam Groberman", "url": "https://github.com/MichaelAdamGroberman", diff --git a/README.md b/README.md index ef5362f..16fc6da 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# mac-mcp +# MacOS-MCP (`mac-mcp`) [![release](https://img.shields.io/github/v/release/MichaelAdamGroberman/mac-mcp?display_name=tag&sort=semver)](https://github.com/MichaelAdamGroberman/mac-mcp/releases) [![macOS](https://img.shields.io/badge/macOS-13%2B-blue.svg)](https://github.com/MichaelAdamGroberman/mac-mcp) diff --git a/Resources/Info.plist b/Resources/Info.plist index 58ff76a..41af416 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -7,15 +7,15 @@ CFBundleName MacMCP CFBundleDisplayName - macOS Native Control (MCP) + MacOS-MCP CFBundleExecutable MacMCP CFBundlePackageType APPL CFBundleShortVersionString - 0.2.1 + 0.2.2 CFBundleVersion - 3 + 4 CFBundleInfoDictionaryVersion 6.0 LSMinimumSystemVersion diff --git a/Sources/MacMCP/Server.swift b/Sources/MacMCP/Server.swift index e96d230..aa75b57 100644 --- a/Sources/MacMCP/Server.swift +++ b/Sources/MacMCP/Server.swift @@ -5,7 +5,7 @@ enum MacMCPServer { static func make() async throws -> Server { let server = Server( name: "mac-mcp", - version: "0.2.1", + version: "0.2.2", capabilities: .init( tools: .init(listChanged: false) ) diff --git a/Sources/MacMCPCore/MacMCPCore.swift b/Sources/MacMCPCore/MacMCPCore.swift index a2c6cf3..6375824 100644 --- a/Sources/MacMCPCore/MacMCPCore.swift +++ b/Sources/MacMCPCore/MacMCPCore.swift @@ -1,4 +1,4 @@ // Re-exports and version metadata for the MacMCPCore library. public enum MacMCPCoreInfo { - public static let version = "0.2.1" + public static let version = "0.2.2" } diff --git a/manifest.json b/manifest.json index ff26fc1..d208b16 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": "0.3", "name": "mac-mcp", - "display_name": "macOS Native Control", - "version": "0.2.1", + "display_name": "MacOS-MCP", + "version": "0.2.2", "description": "Native macOS control via Swift + Cocoa: windows, Finder, clipboard, screenshots, notifications, and typed app automation. Code-signed for stable TCC grants. No raw shell or AppleScript exec.", "long_description": "A Claude Desktop Extension that exposes a typed, allow-listed surface for controlling macOS — built directly on AppKit, the Accessibility API, NSPasteboard, UNUserNotificationCenter, CGWindowList, and pre-compiled OSAKit scripts. Unlike shell-based alternatives, every tool is an explicit named function with a JSON schema; there is no run_shell or run_applescript escape hatch. The signed Developer ID binary gives Claude Desktop a stable TCC identity, so Accessibility and Automation grants persist across rebuilds.", "author": { From 686955af2f277092ff89faa0bde15e8a789f5323 Mon Sep 17 00:00:00 2001 From: Michael Groberman <> Date: Mon, 22 Jun 2026 02:51:31 -0400 Subject: [PATCH 3/3] deps: bump swift-sdk floor 0.11.0 -> 0.12.1 (resolved 0.12.0 -> 0.12.1, patch) Only outdated direct dependency. Patch bump of modelcontextprotocol/swift-sdk. swift build + swift test (8 tests) green on Swift 6.3.2. No majors available. Transitive pins (swift-nio/log/collections/atomics/system/eventsource) already latest. --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 955d3d4..98225ce 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( .library(name: "MacMCPCore", targets: ["MacMCPCore"]) ], dependencies: [ - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0") + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.1") ], targets: [ .executableTarget(