From 57c525c974afe5baf38c379a5f355351744bfe3b Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 30 Jan 2026 05:22:44 -0800 Subject: [PATCH 1/3] Add Music service --- App/App.Debug.entitlements | 1 + App/App.entitlements | 1 + App/Controllers/ServerController.swift | 11 + App/Info.plist | 2 + App/Services/Music.swift | 524 +++++++++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 App/Services/Music.swift diff --git a/App/App.Debug.entitlements b/App/App.Debug.entitlements index 11b79bfc..3ba45c93 100644 --- a/App/App.Debug.entitlements +++ b/App/App.Debug.entitlements @@ -10,6 +10,7 @@ com.apple.security.temporary-exception.apple-events + com.apple.Music com.apple.Terminal com.apple.security.temporary-exception.files.absolute-path.read-write diff --git a/App/App.entitlements b/App/App.entitlements index 2bdd1230..a74a333f 100644 --- a/App/App.entitlements +++ b/App/App.entitlements @@ -10,6 +10,7 @@ com.apple.security.temporary-exception.apple-events + com.apple.Music com.apple.Terminal com.apple.security.temporary-exception.files.absolute-path.read-write diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 255c2bb6..c229375a 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -55,6 +55,7 @@ enum ServiceRegistry { ContactsService.shared, LocationService.shared, MapsService.shared, + MusicService.shared, MessageService.shared, RemindersService.shared, ShortcutsService.shared, @@ -72,6 +73,7 @@ enum ServiceRegistry { contactsEnabled: Binding, locationEnabled: Binding, mapsEnabled: Binding, + musicEnabled: Binding, messagesEnabled: Binding, remindersEnabled: Binding, shortcutsEnabled: Binding, @@ -114,6 +116,13 @@ enum ServiceRegistry { service: MapsService.shared, binding: mapsEnabled ), + ServiceConfig( + name: "Music", + iconName: "music.note", + color: .pink, + service: MusicService.shared, + binding: musicEnabled + ), ServiceConfig( name: "Messages", iconName: "message.fill", @@ -170,6 +179,7 @@ final class ServerController: ObservableObject { @AppStorage("contactsEnabled") private var contactsEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled + @AppStorage("musicEnabled") private var musicEnabled = false @AppStorage("messagesEnabled") private var messagesEnabled = false @AppStorage("remindersEnabled") private var remindersEnabled = false @AppStorage("shortcutsEnabled") private var shortcutsEnabled = false @@ -187,6 +197,7 @@ final class ServerController: ObservableObject { contactsEnabled: $contactsEnabled, locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, + musicEnabled: $musicEnabled, messagesEnabled: $messagesEnabled, remindersEnabled: $remindersEnabled, shortcutsEnabled: $shortcutsEnabled, diff --git a/App/Info.plist b/App/Info.plist index 57abec83..5bd40ef8 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -4,6 +4,8 @@ LSMultipleInstancesProhibited + NSAppleMusicUsageDescription + iMCP uses Apple Music access to search the catalog and report now playing information. NSBonjourServices _mcp._tcp diff --git a/App/Services/Music.swift b/App/Services/Music.swift new file mode 100644 index 00000000..2f59c0f2 --- /dev/null +++ b/App/Services/Music.swift @@ -0,0 +1,524 @@ +import Foundation +import MusicKit +import OSLog + +private let log = Logger.service("music") +private let defaultSearchLimit = 10 +private let artworkSize = 512 + +final class MusicService: Service { + static let shared = MusicService() + + var isActivated: Bool { + get async { + return MusicAuthorization.currentStatus == .authorized + } + } + + func activate() async throws { + let status = await MusicAuthorization.request() + guard status == .authorized else { + throw NSError( + domain: "MusicServiceError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Apple Music access not authorized"] + ) + } + } + + var tools: [Tool] { + Tool( + name: "music_now_playing", + description: "Get currently playing track info from Music app", + inputSchema: .object( + properties: [:], + additionalProperties: false + ), + annotations: .init( + title: "Get Now Playing", + readOnlyHint: true, + openWorldHint: false + ) + ) { _ in + try await self.activate() + return try self.fetchNowPlaying() + } + + Tool( + name: "music_control", + description: "Control Music app playback using a single action parameter", + inputSchema: .object( + properties: [ + "action": .string( + enum: ControlAction.allCases.map { .string($0.rawValue) } + ), + "position": .number( + description: "Playback position in seconds (for seek)" + ), + ], + required: ["action"], + additionalProperties: false + ), + annotations: .init( + title: "Control Playback", + readOnlyHint: false, + openWorldHint: false + ) + ) { arguments in + try await self.activate() + guard let actionRaw = arguments["action"]?.stringValue, + let action = ControlAction(rawValue: actionRaw) + else { + throw NSError( + domain: "MusicServiceError", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Invalid action"] + ) + } + + let position = arguments["position"]?.doubleValue + try self.performControlAction(action, position: position) + return true + } + + Tool( + name: "music_catalog_search", + description: "Search the Apple Music catalog", + inputSchema: .object( + properties: [ + "term": .string(description: "Search term"), + "types": .array( + description: "Catalog types to include", + items: .string( + enum: CatalogSearchType.allCases.map { .string($0.rawValue) } + ) + ), + "limit": .integer( + description: "Maximum results per type", + default: .int(defaultSearchLimit), + minimum: 1, + maximum: 50 + ), + "offset": .integer( + description: "Offset into the results", + minimum: 0 + ), + "includeTopResults": .boolean( + description: "Include top results in the response", + default: false + ), + ], + required: ["term"], + additionalProperties: false + ), + annotations: .init( + title: "Search Apple Music Catalog", + readOnlyHint: true, + openWorldHint: true + ) + ) { arguments in + try await self.activate() + return try await self.searchCatalog(with: arguments) + } + } +} + +private enum ControlAction: String, CaseIterable { + case play + case pause + case playpause + case next + case previous + case stop + case seek +} + +private enum CatalogSearchType: String, CaseIterable { + case songs + case albums + case artists + case playlists + case musicVideos + case stations + case topResults +} + +private struct MusicCatalogResult: Encodable { + let id: String + let type: String + let title: String + let artistName: String? + let url: URL? + let artworkURL: URL? +} + +extension MusicService { + private func executeAppleScript(_ source: String) throws -> NSAppleEventDescriptor { + var errorInfo: NSDictionary? + guard let script = NSAppleScript(source: source) else { + throw NSError( + domain: "MusicServiceError", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Failed to create AppleScript"] + ) + } + + let result = script.executeAndReturnError(&errorInfo) + if let errorInfo { + throw NSError( + domain: "MusicServiceError", + code: 4, + userInfo: [NSLocalizedDescriptionKey: String(describing: errorInfo)] + ) + } + + return result + } + + private func listItems(from descriptor: NSAppleEventDescriptor) -> [NSAppleEventDescriptor] { + guard descriptor.descriptorType == typeAEList else { + return [] + } + + return (1 ... descriptor.numberOfItems).compactMap { index in + descriptor.atIndex(index) + } + } + + private func fetchNowPlaying() throws -> [String: Value] { + let script = """ + tell application "Music" + if player state is stopped then + return {"stopped", "", "", "", 0, 0} + else + set trackName to name of current track + set trackArtist to artist of current track + set trackAlbum to album of current track + set trackDuration to duration of current track + set trackPosition to player position + return {player state as string, trackName, trackArtist, trackAlbum, trackDuration, trackPosition} + end if + end tell + """ + + let descriptor = try executeAppleScript(script) + let items = listItems(from: descriptor) + guard items.count >= 6 else { + throw NSError( + domain: "MusicServiceError", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "Unexpected Music app response"] + ) + } + + let state = items[0].stringValue ?? "stopped" + let title = items[1].stringValue ?? "" + let artist = items[2].stringValue ?? "" + let album = items[3].stringValue ?? "" + let duration = items[4].doubleValue + let position = items[5].doubleValue + + return [ + "state": .string(state), + "title": .string(title), + "artist": .string(artist), + "album": .string(album), + "duration": .double(duration), + "position": .double(position), + ] + } + + private func performControlAction(_ action: ControlAction, position: Double?) throws { + let script: String + switch action { + case .play: + script = "tell application \"Music\" to play" + case .pause: + script = "tell application \"Music\" to pause" + case .playpause: + script = "tell application \"Music\" to playpause" + case .next: + script = "tell application \"Music\" to next track" + case .previous: + script = "tell application \"Music\" to previous track" + case .stop: + script = "tell application \"Music\" to stop" + case .seek: + guard let position else { + throw NSError( + domain: "MusicServiceError", + code: 6, + userInfo: [NSLocalizedDescriptionKey: "Seek position required"] + ) + } + let clampedPosition = max(0, position) + script = "tell application \"Music\" to set player position to \(clampedPosition)" + } + + log.debug("Executing Music control action: \(action.rawValue)") + _ = try executeAppleScript(script) + } + + private func searchCatalog(with arguments: [String: Value]) async throws -> [MusicCatalogResult] { + guard let term = arguments["term"]?.stringValue, !term.isEmpty else { + throw NSError( + domain: "MusicServiceError", + code: 7, + userInfo: [NSLocalizedDescriptionKey: "Search term is required"] + ) + } + + let requestedTypes = + arguments["types"]?.arrayValue?.compactMap { $0.stringValue } ?? [] + let includeTopResults = + arguments["includeTopResults"]?.boolValue + ?? requestedTypes.contains(CatalogSearchType.topResults.rawValue) + + let (types, includeTopResultsResolved) = resolveSearchTypes(from: requestedTypes) + var request = MusicCatalogSearchRequest(term: term, types: types) + request.includeTopResults = includeTopResults || includeTopResultsResolved + + if let limit = arguments["limit"]?.intValue { + request.limit = min(50, max(1, limit)) + } else if let limit = arguments["limit"]?.doubleValue { + request.limit = min(50, max(1, Int(limit))) + } + + if let offset = arguments["offset"]?.intValue { + request.offset = max(0, offset) + } else if let offset = arguments["offset"]?.doubleValue { + request.offset = max(0, Int(offset)) + } + + let response = try await request.response() + var results: [MusicCatalogResult] = [] + + if request.includeTopResults { + for result in response.topResults { + results.append(contentsOf: mapTopResult(result)) + } + } + + results.append( + contentsOf: response.songs.map { + MusicCatalogResult( + id: $0.id.rawValue, + type: "song", + title: $0.title, + artistName: $0.artistName, + url: $0.url, + artworkURL: $0.artwork?.url(width: artworkSize, height: artworkSize) + ) + } + ) + + results.append( + contentsOf: response.albums.map { + MusicCatalogResult( + id: $0.id.rawValue, + type: "album", + title: $0.title, + artistName: $0.artistName, + url: $0.url, + artworkURL: $0.artwork?.url(width: artworkSize, height: artworkSize) + ) + } + ) + + results.append( + contentsOf: response.artists.map { + MusicCatalogResult( + id: $0.id.rawValue, + type: "artist", + title: $0.name, + artistName: nil, + url: $0.url, + artworkURL: $0.artwork?.url(width: artworkSize, height: artworkSize) + ) + } + ) + + results.append( + contentsOf: response.playlists.map { + MusicCatalogResult( + id: $0.id.rawValue, + type: "playlist", + title: $0.name, + artistName: $0.curatorName, + url: $0.url, + artworkURL: $0.artwork?.url(width: artworkSize, height: artworkSize) + ) + } + ) + + results.append( + contentsOf: response.musicVideos.map { + MusicCatalogResult( + id: $0.id.rawValue, + type: "musicVideo", + title: $0.title, + artistName: $0.artistName, + url: $0.url, + artworkURL: $0.artwork?.url(width: artworkSize, height: artworkSize) + ) + } + ) + + results.append( + contentsOf: response.stations.map { + MusicCatalogResult( + id: $0.id.rawValue, + type: "station", + title: $0.name, + artistName: nil, + url: $0.url, + artworkURL: $0.artwork?.url(width: artworkSize, height: artworkSize) + ) + } + ) + + return results + } + + private func resolveSearchTypes( + from rawTypes: [String] + ) -> ([any MusicCatalogSearchable.Type], Bool) { + var types: [any MusicCatalogSearchable.Type] = [] + var includeTopResults = false + + for rawType in rawTypes { + switch CatalogSearchType(rawValue: rawType) { + case .songs: + types.append(Song.self) + case .albums: + types.append(Album.self) + case .artists: + types.append(Artist.self) + case .playlists: + types.append(Playlist.self) + case .musicVideos: + types.append(MusicVideo.self) + case .stations: + types.append(Station.self) + case .topResults: + includeTopResults = true + case .none: + continue + } + } + + if types.isEmpty { + types = [Song.self, Album.self, Artist.self, Playlist.self] + } + + return (types, includeTopResults) + } + + private func mapTopResult( + _ result: MusicCatalogSearchResponse.TopResult + ) -> [MusicCatalogResult] { + switch result { + case .song(let song): + return [ + MusicCatalogResult( + id: song.id.rawValue, + type: "song", + title: song.title, + artistName: song.artistName, + url: song.url, + artworkURL: song.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .album(let album): + return [ + MusicCatalogResult( + id: album.id.rawValue, + type: "album", + title: album.title, + artistName: album.artistName, + url: album.url, + artworkURL: album.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .artist(let artist): + return [ + MusicCatalogResult( + id: artist.id.rawValue, + type: "artist", + title: artist.name, + artistName: nil, + url: artist.url, + artworkURL: artist.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .playlist(let playlist): + return [ + MusicCatalogResult( + id: playlist.id.rawValue, + type: "playlist", + title: playlist.name, + artistName: playlist.curatorName, + url: playlist.url, + artworkURL: playlist.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .musicVideo(let video): + return [ + MusicCatalogResult( + id: video.id.rawValue, + type: "musicVideo", + title: video.title, + artistName: video.artistName, + url: video.url, + artworkURL: video.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .station(let station): + return [ + MusicCatalogResult( + id: station.id.rawValue, + type: "station", + title: station.name, + artistName: nil, + url: station.url, + artworkURL: station.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .curator(let curator): + return [ + MusicCatalogResult( + id: curator.id.rawValue, + type: "curator", + title: curator.name, + artistName: nil, + url: curator.url, + artworkURL: curator.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .radioShow(let show): + return [ + MusicCatalogResult( + id: show.id.rawValue, + type: "radioShow", + title: show.name, + artistName: show.hostName, + url: show.url, + artworkURL: show.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + case .recordLabel(let label): + return [ + MusicCatalogResult( + id: label.id.rawValue, + type: "recordLabel", + title: label.name, + artistName: nil, + url: label.url, + artworkURL: label.artwork?.url(width: artworkSize, height: artworkSize) + ) + ] + @unknown default: + return [] + } + } +} From bb31580398afd4991c3e3a7f070937216a93c526 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 30 Jan 2026 05:57:22 -0800 Subject: [PATCH 2/3] Fix Apple events authorization --- App/Info.plist | 37 ++++++++++++++++++++----------------- App/Services/Music.swift | 12 ++++++++++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/App/Info.plist b/App/Info.plist index 5bd40ef8..faf807a2 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -1,20 +1,23 @@ - - LSMultipleInstancesProhibited - - NSAppleMusicUsageDescription - iMCP uses Apple Music access to search the catalog and report now playing information. - NSBonjourServices - - _mcp._tcp - - SUEnableInstallerLauncherService - - SUFeedURL - https://downloads.imcp.app/appcast.xml - SUPublicEDKey - +2ibtYfPSNKpUJsn1R9Ywc1GnjQA5vemVN95d0EzGxg= - - + + LSMultipleInstancesProhibited + + NSAppleMusicUsageDescription + iMCP uses Apple Music access to search the catalog and report now playing + information. + NSAppleEventsUsageDescription + iMCP uses Apple Events to control and read now playing data from Music. + NSBonjourServices + + _mcp._tcp + + SUEnableInstallerLauncherService + + SUFeedURL + https://downloads.imcp.app/appcast.xml + SUPublicEDKey + +2ibtYfPSNKpUJsn1R9Ywc1GnjQA5vemVN95d0EzGxg= + + \ No newline at end of file diff --git a/App/Services/Music.swift b/App/Services/Music.swift index 2f59c0f2..6d92cfd9 100644 --- a/App/Services/Music.swift +++ b/App/Services/Music.swift @@ -165,6 +165,18 @@ extension MusicService { let result = script.executeAndReturnError(&errorInfo) if let errorInfo { + if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? NSNumber, + errorNumber.intValue == -1743 + { + throw NSError( + domain: "MusicServiceError", + code: 4, + userInfo: [ + NSLocalizedDescriptionKey: + "Not authorized to send Apple events to Music. Enable iMCP in System Settings > Privacy & Security > Automation." + ] + ) + } throw NSError( domain: "MusicServiceError", code: 4, From f3893ce869417fc66aef6d3dd9f29c6051aa3ab8 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 30 Jan 2026 06:32:15 -0800 Subject: [PATCH 3/3] Update entitlements --- App/App.Debug.entitlements | 46 +++++++++++++++++++------------------- App/App.entitlements | 46 +++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/App/App.Debug.entitlements b/App/App.Debug.entitlements index 3ba45c93..cf693314 100644 --- a/App/App.Debug.entitlements +++ b/App/App.Debug.entitlements @@ -1,26 +1,26 @@ - - com.apple.security.files.user-selected.read-write - - com.apple.security.inherit - - com.apple.security.personal-information.health - - com.apple.security.temporary-exception.apple-events - - com.apple.Music - com.apple.Terminal - - com.apple.security.temporary-exception.files.absolute-path.read-write - - /Users/*/Library/Messages/ - - com.apple.security.temporary-exception.mach-lookup.global-name - - $(PRODUCT_BUNDLE_IDENTIFIER)-spks - $(PRODUCT_BUNDLE_IDENTIFIER)-spki - - - + + com.apple.security.files.user-selected.read-write + + com.apple.security.inherit + + com.apple.security.personal-information.health + + com.apple.security.temporary-exception.apple-events + + com.apple.Music + com.apple.Terminal + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /Users/*/Library/Messages/ + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + + + \ No newline at end of file diff --git a/App/App.entitlements b/App/App.entitlements index a74a333f..36aa39c8 100644 --- a/App/App.entitlements +++ b/App/App.entitlements @@ -1,26 +1,26 @@ - - com.apple.developer.weatherkit - - com.apple.security.inherit - - com.apple.security.personal-information.health - - com.apple.security.temporary-exception.apple-events - - com.apple.Music - com.apple.Terminal - - com.apple.security.temporary-exception.files.absolute-path.read-write - - /Users/*/Library/Messages/ - - com.apple.security.temporary-exception.mach-lookup.global-name - - $(PRODUCT_BUNDLE_IDENTIFIER)-spks - $(PRODUCT_BUNDLE_IDENTIFIER)-spki - - - + + com.apple.developer.weatherkit + + com.apple.security.inherit + + com.apple.security.personal-information.health + + com.apple.security.temporary-exception.apple-events + + com.apple.Music + com.apple.Terminal + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /Users/*/Library/Messages/ + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + + + \ No newline at end of file