Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
},
"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": [
{
"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",
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
],
Comment on lines 13 to 15
targets: [
.executableTarget(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 3 additions & 3 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
<key>CFBundleName</key>
<string>MacMCP</string>
<key>CFBundleDisplayName</key>
<string>macOS Native Control (MCP)</string>
<string>MacOS-MCP</string>
<key>CFBundleExecutable</key>
<string>MacMCP</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<string>0.2.2</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>4</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>LSMinimumSystemVersion</key>
Expand Down
2 changes: 1 addition & 1 deletion Sources/MacMCP/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
82 changes: 35 additions & 47 deletions Sources/MacMCP/Tools/FinderTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>) 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)
Comment on lines +133 to +137
task.arguments = argv

var results: [Value] = []
for i in 0..<min(mq.resultCount, limit) {
if let item = mq.result(at: i) as? NSMetadataItem,
let path = item.value(forAttribute: NSMetadataItemPathKey) as? String {
results.append(.string(path))
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
task.standardOutput = stdoutPipe
task.standardError = stderrPipe
try task.run()

// Cap at 5 s so a slow Spotlight can't hang the MCP loop.
let deadline = Date().addingTimeInterval(5)
while task.isRunning && Date() < deadline {
try await Task.sleep(nanoseconds: 25_000_000)
}
Comment on lines +146 to +150
if task.isRunning {
task.terminate()
_ = try? await Task.sleep(nanoseconds: 250_000_000)
if task.isRunning { kill(task.processIdentifier, SIGKILL) }
}
}
return results
}

private static func predicate(for q: String) -> 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)
}
}
106 changes: 75 additions & 31 deletions Sources/MacMCP/Tools/SystemTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)]))
}
Expand All @@ -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))"
"""
Comment on lines +143 to +149
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("")
]))
Comment on lines +165 to 169
}
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(
Expand Down
2 changes: 1 addition & 1 deletion Sources/MacMCPCore/MacMCPCore.swift
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down