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) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 255c2bb6..28f4b1ac 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 ) ) @@ -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 ) } @@ -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 ) } @@ -928,7 +936,9 @@ actor ServerNetworkManager { content: [ .audio( data: data.base64EncodedString(), - mimeType: mimeType + mimeType: mimeType, + annotations: nil, + _meta: nil ) ], isError: false @@ -939,7 +949,8 @@ actor ServerNetworkManager { .image( data: data.base64EncodedString(), mimeType: mimeType, - metadata: nil + annotations: nil, + _meta: nil ) ], isError: false @@ -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 ) } diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 7a517bef..9fefbd01 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.. 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,236 @@ 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. 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" + ), + "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 .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 + ) + { + 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, + ] } } } diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index a7950147..8b5e0f9e 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -589,10 +589,10 @@ /* Begin XCRemoteSwiftPackageReference section */ F809556C2D7888920055B911 /* XCRemoteSwiftPackageReference "madrid" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/loopwork-ai/madrid"; + repositoryURL = "https://github.com/michaelfarrell76/Madrid"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.1.0; + kind = branch; + branch = "add-read-status"; }; }; F825BDBD2D9AD00E0063ADD7 /* XCRemoteSwiftPackageReference "swift-service-lifecycle" */ = { @@ -631,8 +631,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk"; requirement = { - kind = revision; - revision = 106167bad12cd8d004b0cbfcec8211c5408794d8; + kind = exactVersion; + 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 2b63aef7..c795c7c4 100644 --- a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "0604c8a8c049fb6e696fcd8af68cdc36f2f23f938eec962e31f24cc19d550d45", + "originHash" : "f2fe5516d576bf7c4ad91434e2fa12b3a51d38ec6d5141f0ef7336d9ed016262", "pins" : [ { "identity" : "eventsource", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/eventsource.git", + "location" : "https://github.com/mattt/eventsource.git", "state" : { - "revision" : "07957602bb99a5355c810187e66e6ce378a1057d", - "version" : "1.1.1" + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" } }, { @@ -15,17 +15,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/loopwork-ai/JSONSchema.git", "state" : { - "revision" : "e17c9e1fb6afbad656824d03f996cf8621f9db83", - "version" : "1.3.0" + "revision" : "4c6f2467d5bc72b062c7a9b7e4cd77a09635ea8b", + "version" : "1.3.1" } }, { "identity" : "madrid", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/madrid", + "location" : "https://github.com/michaelfarrell76/Madrid", "state" : { - "revision" : "9d9fdb20424483fb592e5a026d9d0816cd5c43eb", - "version" : "0.1.0" + "branch" : "add-read-status", + "revision" : "682d000b9de2dde754fc34e1aad792051f7c575a" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/orchetect/MenuBarExtraAccess", "state" : { - "revision" : "e911e6454f8cbfe34a52136fc48e1ceb989a60e7", - "version" : "1.2.1" + "revision" : "33bb0e4b1e407feac791e047dcaaf9c69b25fd26", + "version" : "1.3.0" } }, { @@ -51,8 +51,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", - "version" : "1.0.3" + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -60,17 +69,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { "identity" : "swift-log", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", - "version" : "1.6.2" + "revision" : "b31565862a8f39866af50bc6676160d8dda7de35", + "version" : "2.96.0" } }, { @@ -78,7 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/modelcontextprotocol/swift-sdk", "state" : { - "revision" : "106167bad12cd8d004b0cbfcec8211c5408794d8" + "revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129", + "version" : "0.12.0" } }, { @@ -86,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle", "state" : { - "revision" : "7ee57f99fbe0073c3700997186721e74d925b59b", - "version" : "2.7.0" + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" } }, { @@ -95,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", - "version" : "1.4.2" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } } ],