From cf1bbe31762ae939d0ed08297ef97046987efcdd Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Sat, 21 Mar 2026 17:22:38 -0700 Subject: [PATCH 1/8] feat: Add unread_only filter and isRead output to messages_fetch Add an `unread_only` boolean parameter to the `messages_fetch` tool. When true, only incoming unread messages are returned (is_read = 0, is_from_me = 0). Also include `isRead` and `dateRead` fields in the message output for all fetched messages. Depends on loopwork-ai/Madrid adding isRead/dateRead to the Message model (see companion PR). Made-with: Cursor --- App/Services/Messages.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 7a517bef..8f937522 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -72,6 +72,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) @@ -117,6 +121,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() @@ -126,11 +131,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 } @@ -151,14 +157,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") From fb065bf8c79cb50bacc52c51d20cf7198bc94c16 Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Sat, 21 Mar 2026 17:57:54 -0700 Subject: [PATCH 2/8] fix: Reorder menuBarExtraAccess modifier to be first MenuBarExtraAccess 1.3.0 requires `.menuBarExtraAccess(...)` to be the first modifier applied to a MenuBarExtra. Swap the order of `.menuBarExtraStyle(.window)` and `.menuBarExtraAccess(isPresented:)` to fix the build with the updated dependency. Made-with: Cursor --- App/App.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/App.swift b/App/App.swift index e395e546..fe58eff4 100644 --- a/App/App.swift +++ b/App/App.swift @@ -15,8 +15,8 @@ struct App: SwiftUI.App { isMenuPresented: $isMenuPresented ) } - .menuBarExtraStyle(.window) .menuBarExtraAccess(isPresented: $isMenuPresented) + .menuBarExtraStyle(.window) Settings { SettingsView(serverController: serverController) From 893fcfdab469a67d7aa9793575a105afcb6fd06f Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Sat, 21 Mar 2026 21:05:41 -0700 Subject: [PATCH 3/8] fix: Update MCP Swift SDK to 0.11.0 to fix NetworkTransport crash The app was crashing with a CheckedContinuation double-resume in NetworkTransport.handleReconnection when multiple MCP clients connected simultaneously (e.g. Cursor IDE + Mastra voice agent). This was a known upstream bug (modelcontextprotocol/swift-sdk#137) fixed in PR #147. Also adapts ServerController to the new SDK API where Tool.inputSchema changed from JSONSchema to Value type. Made-with: Cursor --- App/Controllers/ServerController.swift | 10 +- App/Services/Messages.swift | 114 ++++++++++++++++++ iMCP.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/swiftpm/Package.resolved | 65 ++++++---- 4 files changed, 170 insertions(+), 29 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 255c2bb6..922d7964 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -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 ) ) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 8f937522..9135073c 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -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.. Date: Sat, 21 Mar 2026 23:07:23 -0700 Subject: [PATCH 4/8] feat: Add reminders_update, reminders_complete, reminders_delete tools - reminders_update: modify title, due date, notes, priority, alarms, or list - reminders_complete: mark a reminder as completed - reminders_delete: permanently remove a reminder - Fix PlanAction to include calendarItemIdentifier so fetch/create results expose the @id needed for update/complete/delete operations Made-with: Cursor --- App/Services/Reminders.swift | 232 ++++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 2 deletions(-) diff --git a/App/Services/Reminders.swift b/App/Services/Reminders.swift index 43f957d7..cde75f46 100644 --- a/App/Services/Reminders.swift +++ b/App/Services/Reminders.swift @@ -5,6 +5,12 @@ import Ontology private let log = Logger.service("reminders") +private func planAction(from reminder: EKReminder) -> PlanAction { + var action = PlanAction(reminder) + action.identifier = reminder.calendarItemIdentifier + return action +} + final class RemindersService: Service { private let eventStore = EKEventStore() @@ -190,7 +196,7 @@ final class RemindersService: Service { } } - return filteredReminders.map { PlanAction($0) } + return filteredReminders.map { planAction(from: $0) } } Tool( @@ -296,7 +302,229 @@ final class RemindersService: Service { // Save the reminder try self.eventStore.save(reminder, commit: true) - return PlanAction(reminder) + return planAction(from: reminder) + } + + Tool( + name: "reminders_update", + description: + "Update an existing reminder's properties. Only provide values for properties that need to be changed; omit any properties that should remain unchanged.", + inputSchema: .object( + properties: [ + "identifier": .string( + description: "Unique identifier of the reminder to update (from @id in fetch/create results)" + ), + "title": .string(description: "New title for the reminder"), + "due": .string( + description: + "New due date/time. If timezone is omitted, local time is assumed. Date-only uses local midnight.", + format: .dateTime + ), + "list": .string( + description: "Move to a different reminder list by name" + ), + "notes": .string(description: "New notes for the reminder"), + "priority": .string( + default: .string(EKReminderPriority.none.stringValue), + enum: EKReminderPriority.allCases.map { .string($0.stringValue) } + ), + "alarms": .array( + description: "Minutes before due date to set alarms (replaces existing alarms)", + items: .integer() + ), + ], + required: ["identifier"], + additionalProperties: false + ), + annotations: .init( + title: "Update Reminder", + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false + ) + ) { arguments in + try await self.activate() + + guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { + log.error("Reminders access not authorized") + throw NSError( + domain: "RemindersError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"] + ) + } + + guard case .string(let identifier) = arguments["identifier"], !identifier.isEmpty else { + throw NSError( + domain: "RemindersError", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Valid reminder identifier is required"] + ) + } + + guard let reminder = self.eventStore.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw NSError( + domain: "RemindersError", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Reminder not found with identifier: \(identifier)"] + ) + } + + if case .string(let title) = arguments["title"] { + reminder.title = title + } + + if case .string(let dueDateStr) = arguments["due"], + let parsedDueDate = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: dueDateStr + ) + { + let calendar = Calendar.current + let dueDate = calendar.normalizedStartDate( + from: parsedDueDate.date, + isDateOnly: parsedDueDate.isDateOnly + ) + reminder.dueDateComponents = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: dueDate + ) + } + + if case .string(let listName) = arguments["list"] { + if let matchingCalendar = self.eventStore.calendars(for: .reminder) + .first(where: { $0.title.lowercased() == listName.lowercased() }) + { + reminder.calendar = matchingCalendar + } + } + + if case .string(let notes) = arguments["notes"] { + reminder.notes = notes + } + + if case .string(let priorityStr) = arguments["priority"] { + reminder.priority = Int(EKReminderPriority.from(string: priorityStr).rawValue) + } + + if case .array(let alarmMinutes) = arguments["alarms"] { + reminder.alarms = alarmMinutes.compactMap { + guard case .int(let minutes) = $0 else { return nil } + return EKAlarm(relativeOffset: TimeInterval(-minutes * 60)) + } + } + + try self.eventStore.save(reminder, commit: true) + + return planAction(from: reminder) + } + + Tool( + name: "reminders_complete", + description: "Mark an existing reminder as completed", + inputSchema: .object( + properties: [ + "identifier": .string( + description: "Unique identifier of the reminder to complete (from @id in fetch/create results)" + ), + ], + required: ["identifier"], + additionalProperties: false + ), + annotations: .init( + title: "Complete Reminder", + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false + ) + ) { arguments in + try await self.activate() + + guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { + log.error("Reminders access not authorized") + throw NSError( + domain: "RemindersError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"] + ) + } + + guard case .string(let identifier) = arguments["identifier"], !identifier.isEmpty else { + throw NSError( + domain: "RemindersError", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Valid reminder identifier is required"] + ) + } + + guard let reminder = self.eventStore.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw NSError( + domain: "RemindersError", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Reminder not found with identifier: \(identifier)"] + ) + } + + reminder.isCompleted = true + reminder.completionDate = Date() + + try self.eventStore.save(reminder, commit: true) + + return planAction(from: reminder) + } + + Tool( + name: "reminders_delete", + description: "Delete an existing reminder permanently", + inputSchema: .object( + properties: [ + "identifier": .string( + description: "Unique identifier of the reminder to delete (from @id in fetch/create results)" + ), + ], + required: ["identifier"], + additionalProperties: false + ), + annotations: .init( + title: "Delete Reminder", + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false + ) + ) { arguments in + try await self.activate() + + guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { + log.error("Reminders access not authorized") + throw NSError( + domain: "RemindersError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"] + ) + } + + guard case .string(let identifier) = arguments["identifier"], !identifier.isEmpty else { + throw NSError( + domain: "RemindersError", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Valid reminder identifier is required"] + ) + } + + guard let reminder = self.eventStore.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw NSError( + domain: "RemindersError", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Reminder not found with identifier: \(identifier)"] + ) + } + + let title = reminder.title ?? "Untitled" + try self.eventStore.remove(reminder, commit: true) + + return [ + "deleted": title, + "identifier": identifier, + ] } } } From 78d948de9e42e851cbe7f9ab612c79e0aa2f0d98 Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Sun, 22 Mar 2026 01:00:41 -0700 Subject: [PATCH 5/8] feat: Add clear_due parameter to reminders_update Setting clear_due: true removes the due date and all alarms from a reminder entirely, rather than requiring a dummy far-future date. Made-with: Cursor --- App/Services/Reminders.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/App/Services/Reminders.swift b/App/Services/Reminders.swift index cde75f46..5e3998d3 100644 --- a/App/Services/Reminders.swift +++ b/App/Services/Reminders.swift @@ -317,9 +317,13 @@ final class RemindersService: Service { "title": .string(description: "New title for the reminder"), "due": .string( description: - "New due date/time. If timezone is omitted, local time is assumed. Date-only uses local midnight.", + "New due date/time. If timezone is omitted, local time is assumed. Date-only uses local midnight. Ignored if clear_due is true.", format: .dateTime ), + "clear_due": .boolean( + description: + "Set to true to remove the due date entirely. Also removes all alarms." + ), "list": .string( description: "Move to a different reminder list by name" ), @@ -374,7 +378,10 @@ final class RemindersService: Service { reminder.title = title } - if case .string(let dueDateStr) = arguments["due"], + if case .bool(let clearDue) = arguments["clear_due"], clearDue { + reminder.dueDateComponents = nil + reminder.alarms = nil + } else if case .string(let dueDateStr) = arguments["due"], let parsedDueDate = ISO8601DateFormatter.parsedLenientISO8601Date( fromISO8601String: dueDateStr ) From 66c14041e6463081cf7e550f58e13eb9d7f8bf24 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Thu, 9 Apr 2026 23:00:17 -0700 Subject: [PATCH 6/8] =?UTF-8?q?chore:=20Bump=20MCP=20Swift=20SDK=200.11.0?= =?UTF-8?q?=20=E2=86=92=200.12.0=20for=20Xcode=2026.4=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.11.0's sendContinuationResumed/receiveContinuationResumed flags trigger strict-concurrency data-race errors in Swift 6.2. 0.12.0 fixes those. Also migrates deprecated Content factory methods: .text(_:metadata:) → .text(text:annotations:_meta:) .image(data:mimeType:metadata:) → .image(data:mimeType:annotations:_meta:) .audio(data:mimeType:) → .audio(data:mimeType:annotations:_meta:) Made-with: Cursor --- App/Controllers/ServerController.swift | 17 ++++++++++------- iMCP.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 922d7964..28f4b1ac 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -897,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 ) } @@ -907,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 ) } @@ -936,7 +936,9 @@ actor ServerNetworkManager { content: [ .audio( data: data.base64EncodedString(), - mimeType: mimeType + mimeType: mimeType, + annotations: nil, + _meta: nil ) ], isError: false @@ -947,7 +949,8 @@ actor ServerNetworkManager { .image( data: data.base64EncodedString(), mimeType: mimeType, - metadata: nil + annotations: nil, + _meta: nil ) ], isError: false @@ -961,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 ) } diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index be494a7c..8b5e0f9e 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -632,7 +632,7 @@ repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk"; requirement = { kind = exactVersion; - version = 0.11.0; + version = 0.12.0; }; }; F8D8C48C2DCE0E6800369E5C /* XCRemoteSwiftPackageReference "JSONSchema" */ = { diff --git a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 022fff40..c795c7c4 100644 --- a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/modelcontextprotocol/swift-sdk", "state" : { - "revision" : "6112a3995a992d159ad0e82c2d62a008ce932666", - "version" : "0.11.0" + "revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129", + "version" : "0.12.0" } }, { From 9b11fcc96ecfb5c2b2dcff6bc2f40edc04674730 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Fri, 10 Apr 2026 00:15:41 -0700 Subject: [PATCH 7/8] feat: Activate Messages.app before fetch to trigger iCloud sync chat.db can be stale when messages arrive on iPhone but haven't synced to the Mac yet. Activating Messages.app nudges iCloud to pull recent messages. Adds a 2-second wait after activation. Made-with: Cursor --- App/Services/Messages.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 9135073c..c0c56ea5 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -206,6 +206,11 @@ 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. + NSWorkspace.shared.open(URL(string: "imessage://")!) + try await Task.sleep(for: .seconds(2)) + let participants = arguments["participants"]?.arrayValue?.compactMap({ $0.stringValue From 968f6452a6117a4cdc2c2d9e7770e48c527694c2 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Fri, 10 Apr 2026 00:39:30 -0700 Subject: [PATCH 8/8] fix: Use bundle activation instead of imessage:// URL imessage:// opens a new compose window on every fetch, which is disruptive. Use NSRunningApplication.activate() instead to just bring Messages to the foreground without opening any windows. Made-with: Cursor --- App/Services/Messages.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index c0c56ea5..9fefbd01 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -208,7 +208,12 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { // Activate Messages.app to nudge iCloud sync for recent messages // from other devices. Without this, chat.db may be stale. - NSWorkspace.shared.open(URL(string: "imessage://")!) + // 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 =