import AppKit
import OSLog
import SQLite3
import UniformTypeIdentifiers
import iMessage
private let log = Logger.service("messages")
private let messagesDatabasePath = "/Users/\(NSUserName())/Library/Messages/chat.db"
private let messagesDatabaseBookmarkKey: String = "me.mattt.iMCP.messagesDatabaseBookmark"
private let defaultLimit = 30
final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
static let shared = MessageService()
func activate() async throws {
log.debug("Starting message service activation")
if canAccessDatabaseAtDefaultPath {
log.debug("Successfully activated using default database path")
return
}
if canAccessDatabaseUsingBookmark {
log.debug("Successfully activated using stored bookmark")
return
}
log.debug("Opening file picker for manual database selection")
guard try await showDatabaseAccessAlert() else {
throw DatabaseAccessError.userDeclinedAccess
}
let selectedURL = try await showFilePicker()
guard FileManager.default.isReadableFile(atPath: selectedURL.path) else {
throw DatabaseAccessError.fileNotReadable
}
storeBookmark(for: selectedURL)
log.debug("Successfully activated message service")
}
var isActivated: Bool {
get async {
let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark
log.debug("Message service activation status: \(isActivated)")
return isActivated
}
}
var tools: [Tool] {
Tool(
name: "messages_fetch",
description: "Fetch messages from the Messages app",
inputSchema: .object(
properties: [
"participants": .array(
description:
"Participant handles (phone or email). Phone numbers should use E.164 format",
items: .string()
),
"start": .string(
description: "Start of the date range (inclusive)",
format: .dateTime
),
"end": .string(
description: "End of the date range (exclusive)",
format: .dateTime
),
"query": .string(
description: "Search term to filter messages by content"
),
"limit": .integer(
description: "Maximum messages to return",
default: .int(defaultLimit)
),
],
additionalProperties: false
),
annotations: .init(
title: "Fetch Messages",
readOnlyHint: true,
openWorldHint: false
)
) { arguments in
log.debug("Starting message fetch with arguments: \(arguments)")
try await self.activate()
let participants =
arguments["participants"]?.arrayValue?.compactMap({
$0.stringValue
}) ?? []
var dateRange: Range<Date>?
if let startDateStr = arguments["start"]?.stringValue,
let endDateStr = arguments["end"]?.stringValue,
let startDate = ISO8601DateFormatter.parseFlexibleISODate(startDateStr),
let endDate = ISO8601DateFormatter.parseFlexibleISODate(endDateStr)
{
dateRange = startDate..<endDate
}
let searchTerm = arguments["query"]?.stringValue
let limit = arguments["limit"]?.intValue
let db = try self.createDatabaseConnection()
var messages: [[String: Value]] = []
log.debug("Fetching handles for participants: \(participants)")
let handles = try db.fetchParticipant(matching: participants)
log.debug(
"Fetching messages with date range: \(String(describing: dateRange)), limit: \(limit ?? -1)"
)
for message in try db.fetchMessages(
with: Set(handles),
in: dateRange,
limit: max(limit ?? defaultLimit, 1024)
) {
guard messages.count < (limit ?? defaultLimit) else { break }
guard !message.text.isEmpty else { continue }
let sender: String
if message.isFromMe {
sender = "me"
} else if message.sender == nil {
sender = "unknown"
} else {
sender = message.sender!.rawValue
}
if let searchTerm {
guard message.text.localizedCaseInsensitiveContains(searchTerm) else {
continue
}
}
messages.append([
"@id": .string(message.id.description),
"sender": [
"@id": .string(sender)
],
"text": .string(message.text),
"createdAt": .string(message.date.formatted(.iso8601)),
])
}
log.debug("Successfully fetched \(messages.count) messages")
return [
"@context": "https://schema.org",
"@type": "Conversation",
"hasPart": Value.array(messages.map({ .object($0) })),
]
}
}
private var canAccessDatabaseAtDefaultPath: Bool {
return FileManager.default.isReadableFile(atPath: messagesDatabasePath)
}
private enum DatabaseAccessError: LocalizedError {
case noBookmarkFound
case securityScopeAccessFailed
case invalidParticipants
case userDeclinedAccess
case invalidFileSelected
case fileNotReadable
var errorDescription: String? {
switch self {
case .noBookmarkFound:
return "No stored bookmark found for database access"
case .securityScopeAccessFailed:
return "Failed to access security-scoped resource"
case .invalidParticipants:
return "Invalid participants provided"
case .userDeclinedAccess:
return "User declined to grant access to the messages database"
case .invalidFileSelected:
return "Messages database access denied or invalid file selected"
case .fileNotReadable:
return "Selected database file is not readable"
}
}
}
private func withSecurityScopedAccess<T>(_ url: URL, _ operation: (URL) throws -> T) throws -> T
{
guard url.startAccessingSecurityScopedResource() else {
log.error("Failed to start accessing security-scoped resource")
throw DatabaseAccessError.securityScopeAccessFailed
}
defer { url.stopAccessingSecurityScopedResource() }
return try operation(url)
}
private func resolveBookmarkURL() throws -> URL {
guard let bookmarkData = UserDefaults.standard.data(forKey: messagesDatabaseBookmarkKey)
else {
throw DatabaseAccessError.noBookmarkFound
}
var isStale = false
return try URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
}
private func createDatabaseConnection() throws -> iMessage.Database {
if canAccessDatabaseAtDefaultPath {
return try iMessage.Database()
}
let databaseURL = try resolveBookmarkURL()
return try withSecurityScopedAccess(databaseURL) { url in
try iMessage.Database(path: url.path)
}
}
private var canAccessDatabaseUsingBookmark: Bool {
do {
let url = try resolveBookmarkURL()
return try withSecurityScopedAccess(url) { url in
FileManager.default.isReadableFile(atPath: url.path)
}
} catch {
log.error("Error accessing database with bookmark: \(error.localizedDescription)")
return false
}
}
@MainActor
private func showDatabaseAccessAlert() async throws -> Bool {
let alert = NSAlert()
alert.messageText = "Messages Database Access Required"
alert.informativeText = """
To read your Messages history, we need to open your database file.
In the next screen, please select the file `chat.db` and click "Grant Access".
"""
alert.alertStyle = .informational
alert.addButton(withTitle: "Continue")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}
@MainActor
private func showFilePicker() async throws -> URL {
let openPanel = NSOpenPanel()
openPanel.delegate = self
openPanel.message = "Please select the Messages database file (chat.db)"
openPanel.prompt = "Grant Access"
openPanel.allowedContentTypes = [UTType.item]
openPanel.directoryURL = URL(fileURLWithPath: messagesDatabasePath)
.deletingLastPathComponent()
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = false
openPanel.canChooseFiles = true
openPanel.showsHiddenFiles = true
guard openPanel.runModal() == .OK,
let url = openPanel.url,
url.lastPathComponent == "chat.db"
else {
throw DatabaseAccessError.invalidFileSelected
}
return url
}
private func storeBookmark(for url: URL) {
do {
let bookmarkData = try url.bookmarkData(
options: .securityScopeAllowOnlyReadAccess,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(bookmarkData, forKey: messagesDatabaseBookmarkKey)
log.debug("Successfully created and stored bookmark")
} catch {
log.error("Failed to create bookmark: \(error.localizedDescription)")
}
}
// NSOpenSavePanelDelegate method to constrain file selection
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
let shouldEnable = url.lastPathComponent == "chat.db"
log.debug(
"File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)")
return shouldEnable
}
}