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/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(
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 4a3e473..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.0
+ 0.2.2
CFBundleVersion
- 2
+ 4
CFBundleInfoDictionaryVersion
6.0
LSMinimumSystemVersion
diff --git a/Sources/MacMCP/Server.swift b/Sources/MacMCP/Server.swift
index e432068..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.0",
+ version: "0.2.2",
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..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.0"
+ public static let version = "0.2.2"
}
diff --git a/manifest.json b/manifest.json
index 5425525..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.0",
+ "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": {