Skip to content
Open
2 changes: 1 addition & 1 deletion App/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ struct App: SwiftUI.App {
isMenuPresented: $isMenuPresented
)
}
.menuBarExtraStyle(.window)
.menuBarExtraAccess(isPresented: $isMenuPresented)
.menuBarExtraStyle(.window)

Settings {
SettingsView(serverController: serverController)
Expand Down
27 changes: 19 additions & 8 deletions App/Controllers/ServerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -869,11 +869,19 @@ actor ServerNetworkManager {
{
for tool in service.tools {
log.debug("Adding tool: \(tool.name)")
let schemaValue: Value
do {
let data = try JSONEncoder().encode(tool.inputSchema)
schemaValue = try JSONDecoder().decode(Value.self, from: data)
} catch {
log.error("Failed to convert schema for tool \(tool.name): \(error)")
continue
}
tools.append(
.init(
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
inputSchema: schemaValue,
annotations: tool.annotations
)
)
Expand All @@ -889,7 +897,7 @@ actor ServerNetworkManager {
await server.withMethodHandler(CallTool.self) { [weak self] params in
guard let self = self else {
return CallTool.Result(
content: [.text("Server unavailable")],
content: [.text(text: "Server unavailable", annotations: nil, _meta: nil)],
isError: true
)
}
Expand All @@ -899,7 +907,7 @@ actor ServerNetworkManager {
guard await self.isEnabledState else {
log.notice("Tool call rejected: iMCP is disabled")
return CallTool.Result(
content: [.text("iMCP is currently disabled. Please enable it to use tools.")],
content: [.text(text: "iMCP is currently disabled. Please enable it to use tools.", annotations: nil, _meta: nil)],
isError: true
)
}
Expand Down Expand Up @@ -928,7 +936,9 @@ actor ServerNetworkManager {
content: [
.audio(
data: data.base64EncodedString(),
mimeType: mimeType
mimeType: mimeType,
annotations: nil,
_meta: nil
)
],
isError: false
Expand All @@ -939,7 +949,8 @@ actor ServerNetworkManager {
.image(
data: data.base64EncodedString(),
mimeType: mimeType,
metadata: nil
annotations: nil,
_meta: nil
)
],
isError: false
Expand All @@ -953,20 +964,20 @@ actor ServerNetworkManager {
let data = try encoder.encode(value)
let text = String(data: data, encoding: .utf8)!

return CallTool.Result(content: [.text(text)], isError: false)
return CallTool.Result(content: [.text(text: text, annotations: nil, _meta: nil)], isError: false)
}
} catch {
log.error(
"Error executing tool \(params.name): \(error.localizedDescription)"
)
return CallTool.Result(content: [.text("Error: \(error)")], isError: true)
return CallTool.Result(content: [.text(text: "Error: \(error)", annotations: nil, _meta: nil)], isError: true)
}
}
}

log.error("Tool not found or service not enabled: \(params.name)")
return CallTool.Result(
content: [.text("Tool not found or service not enabled: \(params.name)")],
content: [.text(text: "Tool not found or service not enabled: \(params.name)", annotations: nil, _meta: nil)],
isError: true
)
}
Expand Down
141 changes: 138 additions & 3 deletions App/Services/Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,120 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
}

var tools: [Tool] {
Tool(
name: "messages_open",
description:
"Open a conversation in the Messages app. For group chats, finds and selects the existing conversation rather than creating a compose window.",
inputSchema: .object(
properties: [
"participants": .array(
description:
"Participant handles (phone or email). Phone numbers should use E.164 format",
items: .string()
)
],
required: ["participants"],
additionalProperties: false
),
annotations: .init(
title: "Open Messages Conversation",
readOnlyHint: false,
openWorldHint: true
)
) { arguments in
log.debug("Starting messages_open with arguments: \(arguments)")

let participants =
arguments["participants"]?.arrayValue?.compactMap({ $0.stringValue }) ?? []
guard !participants.isEmpty else {
throw DatabaseAccessError.invalidParticipants
}

if participants.count == 1 {
let handle = participants[0]
.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: "imessage://\(handle)") {
NSWorkspace.shared.open(url)
}
return [
"status": "opened",
"method": "url",
]
}

// Group chat: query Messages DB to find the existing conversation
try await self.activate()
let db = try self.createDatabaseConnection()
let handles = try db.fetchParticipant(matching: participants)
let chats = try db.fetchChats(with: Set(handles))

guard chats.first(where: { Set($0.participants) == Set(handles) }) != nil else {
// No existing group chat — open compose window as fallback
let joined = participants.joined(separator: ",")
if let url = URL(string: "imessage://\(joined)") {
NSWorkspace.shared.open(url)
}
return [
"status": "opened_compose",
"method": "url-compose",
]
}

// Build search terms: email local part or full phone number
let searchTerms = participants.map { handle -> String in
if let atIndex = handle.firstIndex(of: "@") {
return String(handle[handle.startIndex..<atIndex])
}
return handle
}

// AppleScript: activate Messages and click the group chat in the sidebar.
// Group chats display as "Name1 & Name2..." so we look for text
// containing "&" and at least one participant search term.
let termsForScript = searchTerms
.map { "\"\($0)\"" }
.joined(separator: ", ")
let source = """
tell application "Messages" to activate
delay 0.8
tell application "System Events"
tell process "Messages"
set allElements to entire contents of window 1
repeat with anElement in allElements
try
if class of anElement is static text then
set elementValue to value of anElement
if elementValue contains "&" then
repeat with aTerm in {\(termsForScript)}
if elementValue contains aTerm then
click anElement
return "selected"
end if
end repeat
end if
end if
end try
end repeat
end tell
end tell
return "not_found"
"""

var scriptError: NSDictionary?
let scriptResult = NSAppleScript(source: source)?
.executeAndReturnError(&scriptError)
let selected = scriptResult?.stringValue == "selected"

if !selected {
log.warning("Sidebar click failed: \(String(describing: scriptError))")
}

return [
"status": selected ? "opened" : "fallback",
"method": selected ? "sidebar-click" : "activate-only",
]
}

Tool(
name: "messages_fetch",
description: "Fetch messages from the Messages app",
Expand All @@ -72,6 +186,10 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
"query": .string(
description: "Search term to filter messages by content"
),
"unread_only": .boolean(
description:
"If true, only return unread incoming messages (is_read = 0 and is_from_me = 0). Note: this is a best-effort approximation based on the Messages database and may not exactly match the Messages app badge count."
),
"limit": .integer(
description: "Maximum messages to return",
default: .int(defaultLimit)
Expand All @@ -88,6 +206,16 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
log.debug("Starting message fetch with arguments: \(arguments)")
try await self.activate()

// Activate Messages.app to nudge iCloud sync for recent messages
// from other devices. Without this, chat.db may be stale.
// Use bundle activation (not imessage:// which opens a compose window).
if let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.MobileSMS").first {
app.activate()
} else {
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Messages.app"))
}
try await Task.sleep(for: .seconds(2))

let participants =
arguments["participants"]?.arrayValue?.compactMap({
$0.stringValue
Expand Down Expand Up @@ -117,6 +245,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
}

let searchTerm = arguments["query"]?.stringValue
let unreadOnly = arguments["unread_only"]?.boolValue ?? false
let limit = arguments["limit"]?.intValue

let db = try self.createDatabaseConnection()
Expand All @@ -126,11 +255,12 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
let handles = try db.fetchParticipant(matching: participants)

log.debug(
"Fetching messages with date range: \(String(describing: dateRange)), limit: \(limit ?? -1)"
"Fetching messages with date range: \(String(describing: dateRange)), unreadOnly: \(unreadOnly), limit: \(limit ?? -1)"
)
for message in try db.fetchMessages(
with: Set(handles),
in: dateRange,
unreadOnly: unreadOnly,
limit: max(limit ?? defaultLimit, 1024)
) {
guard messages.count < (limit ?? defaultLimit) else { break }
Expand All @@ -151,14 +281,19 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate {
}
}

messages.append([
var entry: [String: Value] = [
"@id": .string(message.id.description),
"sender": [
"@id": .string(sender)
],
"text": .string(message.text),
"createdAt": .string(message.date.formatted(.iso8601)),
])
"isRead": .bool(message.isRead),
]
if let dateRead = message.dateRead {
entry["dateRead"] = .string(dateRead.formatted(.iso8601))
}
messages.append(entry)
}

log.debug("Successfully fetched \(messages.count) messages")
Expand Down
Loading