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`)
[](https://github.com/MichaelAdamGroberman/mac-mcp/releases)
[](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(