Skip to main content
Glama
ServerController.swift36.8 kB
import AppKit import MCP import Network import OSLog import Ontology import SwiftUI import SystemPackage import UserNotifications import struct Foundation.Data import struct Foundation.Date import class Foundation.JSONDecoder import class Foundation.JSONEncoder private let serviceType = "_mcp._tcp" private let serviceDomain = "local." private let log = Logger.server struct ServiceConfig: Identifiable { let id: String let name: String let iconName: String let color: Color let service: any Service let binding: Binding<Bool> var isActivated: Bool { get async { await service.isActivated } } init( name: String, iconName: String, color: Color, service: any Service, binding: Binding<Bool> ) { self.id = String(describing: type(of: service)) self.name = name self.iconName = iconName self.color = color self.service = service self.binding = binding } } enum ServiceRegistry { static let services: [any Service] = [ CalendarService.shared, CaptureService.shared, ContactsService.shared, LocationService.shared, MapsService.shared, MessageService.shared, RemindersService.shared, UtilitiesService.shared, WeatherService.shared, ] static func configureServices( calendarEnabled: Binding<Bool>, captureEnabled: Binding<Bool>, contactsEnabled: Binding<Bool>, locationEnabled: Binding<Bool>, mapsEnabled: Binding<Bool>, messagesEnabled: Binding<Bool>, remindersEnabled: Binding<Bool>, utilitiesEnabled: Binding<Bool>, weatherEnabled: Binding<Bool> ) -> [ServiceConfig] { [ ServiceConfig( name: "Calendar", iconName: "calendar", color: .red, service: CalendarService.shared, binding: calendarEnabled ), ServiceConfig( name: "Capture", iconName: "camera.on.rectangle.fill", color: .gray.mix(with: .black, by: 0.7), service: CaptureService.shared, binding: captureEnabled ), ServiceConfig( name: "Contacts", iconName: "person.crop.square.filled.and.at.rectangle.fill", color: .brown, service: ContactsService.shared, binding: contactsEnabled ), ServiceConfig( name: "Location", iconName: "location.fill", color: .blue, service: LocationService.shared, binding: locationEnabled ), ServiceConfig( name: "Maps", iconName: "mappin.and.ellipse", color: .purple, service: MapsService.shared, binding: mapsEnabled ), ServiceConfig( name: "Messages", iconName: "message.fill", color: .green, service: MessageService.shared, binding: messagesEnabled ), ServiceConfig( name: "Reminders", iconName: "list.bullet", color: .orange, service: RemindersService.shared, binding: remindersEnabled ), ServiceConfig( name: "Weather", iconName: "cloud.sun.fill", color: .cyan, service: WeatherService.shared, binding: weatherEnabled ), ] } } @MainActor final class ServerController: ObservableObject { @Published var serverStatus: String = "Starting..." @Published var pendingConnectionID: String? @Published var pendingClientName: String = "" private var activeApprovalDialogs: Set<String> = [] private var pendingApprovals: [(String, () -> Void, () -> Void)] = [] private var currentApprovalHandlers: (approve: () -> Void, deny: () -> Void)? private let approvalWindowController = ConnectionApprovalWindowController() private let networkManager = ServerNetworkManager() // MARK: - AppStorage for Service Enablement States @AppStorage("calendarEnabled") private var calendarEnabled = false @AppStorage("captureEnabled") private var captureEnabled = false @AppStorage("contactsEnabled") private var contactsEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @AppStorage("mapsEnabled") private var mapsEnabled = true // Default for maps @AppStorage("messagesEnabled") private var messagesEnabled = false @AppStorage("remindersEnabled") private var remindersEnabled = false @AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default for utilities @AppStorage("weatherEnabled") private var weatherEnabled = false // MARK: - AppStorage for Trusted Clients @AppStorage("trustedClients") private var trustedClientsData = Data() // MARK: - Computed Properties for Service Configurations and Bindings var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( calendarEnabled: $calendarEnabled, captureEnabled: $captureEnabled, contactsEnabled: $contactsEnabled, locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, messagesEnabled: $messagesEnabled, remindersEnabled: $remindersEnabled, utilitiesEnabled: $utilitiesEnabled, weatherEnabled: $weatherEnabled ) } private var currentServiceBindings: [String: Binding<Bool>] { Dictionary( uniqueKeysWithValues: computedServiceConfigs.map { ($0.id, $0.binding) } ) } // MARK: - Trusted Clients Management private var trustedClients: Set<String> { get { (try? JSONDecoder().decode(Set<String>.self, from: trustedClientsData)) ?? [] } set { trustedClientsData = (try? JSONEncoder().encode(newValue)) ?? Data() } } private func isClientTrusted(_ clientName: String) -> Bool { trustedClients.contains(clientName) } private func addTrustedClient(_ clientName: String) { var clients = trustedClients clients.insert(clientName) trustedClients = clients } func removeTrustedClient(_ clientName: String) { var clients = trustedClients clients.remove(clientName) trustedClients = clients } func getTrustedClients() -> [String] { Array(trustedClients).sorted() } func resetTrustedClients() { trustedClients = Set<String>() } // MARK: - Connection Approval Methods private func cleanupApprovalState() { pendingClientName = "" currentApprovalHandlers = nil if let clientID = pendingConnectionID { activeApprovalDialogs.remove(clientID) pendingConnectionID = nil } } private func handlePendingApprovals(for clientID: String, approved: Bool) { while let pendingIndex = pendingApprovals.firstIndex(where: { $0.0 == clientID }) { let (_, pendingApprove, pendingDeny) = pendingApprovals.remove(at: pendingIndex) if approved { log.notice("Approving pending connection for client: \(clientID)") pendingApprove() } else { log.notice("Denying pending connection for client: \(clientID)") pendingDeny() } } } init() { Task { // Set initial bindings before starting the server, using own @AppStorage values await networkManager.updateServiceBindings(self.currentServiceBindings) await self.networkManager.start() self.updateServerStatus("Running") await networkManager.setConnectionApprovalHandler { [weak self] connectionID, clientInfo in guard let self = self else { return false } log.debug("ServerManager: Approval handler called for client \(clientInfo.name)") // Create a continuation to wait for the user's response return await withCheckedContinuation { continuation in Task { @MainActor in self.showConnectionApprovalAlert( clientID: clientInfo.name, approve: { continuation.resume(returning: true) }, deny: { continuation.resume(returning: false) } ) } } } } } func updateServiceBindings(_ bindings: [String: Binding<Bool>]) async { // This function is still called by ContentView's onChange when user toggles services. // It ensures ServerNetworkManager is updated and clients are notified. await networkManager.updateServiceBindings(bindings) } func startServer() async { await networkManager.start() updateServerStatus("Running") } func stopServer() async { await networkManager.stop() updateServerStatus("Stopped") } func setEnabled(_ enabled: Bool) async { await networkManager.setEnabled(enabled) updateServerStatus(enabled ? "Running" : "Disabled") } private func updateServerStatus(_ status: String) { log.info("Server status updated: \(status)") self.serverStatus = status } private func sendClientConnectionNotification(clientName: String) { let content = UNMutableNotificationContent() content.title = "Client Connected" content.body = "Client '\(clientName)' has connected to iMCP" content.threadIdentifier = "client-connection-\(clientName)" let request = UNNotificationRequest( identifier: "client-connection-\(clientName)-\(Date().timeIntervalSince1970)", content: content, trigger: nil ) UNUserNotificationCenter.current().add(request) { error in if let error = error { log.error("Failed to send notification: \(error.localizedDescription)") } else { log.info("Sent notification for client connection: \(clientName)") } } } private func showConnectionApprovalAlert( clientID: String, approve: @escaping () -> Void, deny: @escaping () -> Void ) { log.notice("Connection approval requested for client: \(clientID)") // Check if this client is already trusted if isClientTrusted(clientID) { log.notice("Client \(clientID) is already trusted, auto-approving") approve() // Send notification for trusted connections sendClientConnectionNotification(clientName: clientID) return } self.pendingConnectionID = clientID // Check if there's already an active dialog for this client guard !activeApprovalDialogs.contains(clientID) else { log.info("Adding to pending approvals for client: \(clientID)") pendingApprovals.append((clientID, approve, deny)) return } activeApprovalDialogs.insert(clientID) // Set up the SwiftUI approval dialog pendingClientName = clientID currentApprovalHandlers = (approve: approve, deny: deny) approvalWindowController.showApprovalWindow( clientName: clientID, onApprove: { alwaysTrust in if alwaysTrust { self.addTrustedClient(clientID) // Request notification permissions so that the user can be notified when a trusted client connects UNUserNotificationCenter.current().requestAuthorization(options: [ .alert, .sound, .badge, ]) { granted, error in if let error = error { log.error( "Failed to request notification permissions: \(error.localizedDescription)" ) } else { log.info("Notification permissions granted: \(granted)") } } } approve() self.cleanupApprovalState() self.handlePendingApprovals(for: clientID, approved: true) }, onDeny: { deny() self.cleanupApprovalState() self.handlePendingApprovals(for: clientID, approved: false) } ) NSApp.activate(ignoringOtherApps: true) // Handle any pending approvals for the same client after this one completes // We'll check for pending approvals when the dialog is dismissed } } // MARK: - Connection Management Components /// Manages a single MCP connection actor MCPConnectionManager { private let connectionID: UUID private let connection: NWConnection private let server: MCP.Server private var transport: NetworkTransport private let parentManager: ServerNetworkManager init(connectionID: UUID, connection: NWConnection, parentManager: ServerNetworkManager) { self.connectionID = connectionID self.connection = connection self.parentManager = parentManager self.transport = NetworkTransport( connection: connection, logger: nil, bufferConfig: .unlimited ) // Create the MCP server self.server = MCP.Server( name: Bundle.main.name ?? "iMCP", version: Bundle.main.shortVersionString ?? "unknown", capabilities: MCP.Server.Capabilities( tools: .init(listChanged: true) ) ) } func start(approvalHandler: @escaping (MCP.Client.Info) async -> Bool) async throws { do { log.notice("Starting MCP server for connection: \(self.connectionID)") try await server.start(transport: transport) { [weak self] clientInfo, capabilities in guard let self = self else { throw MCPError.connectionClosed } log.info("Received initialize request from client: \(clientInfo.name)") // Request user approval let approved = await approvalHandler(clientInfo) log.info( "Approval result for connection \(connectionID): \(approved ? "Approved" : "Denied")" ) if !approved { await self.parentManager.removeConnection(self.connectionID) throw MCPError.connectionClosed } } log.notice("MCP Server started successfully for connection: \(self.connectionID)") // Register handlers after successful approval await registerHandlers() // Monitor connection health await startHealthMonitoring() } catch { log.error("Failed to start MCP server: \(error.localizedDescription)") throw error } } private func registerHandlers() async { await parentManager.registerHandlers(for: server, connectionID: connectionID) } private func startHealthMonitoring() async { // Set up a connection health monitoring task Task { outer: while await parentManager.isRunning() { switch connection.state { case .ready, .setup, .preparing, .waiting: break case .cancelled: log.error("Connection \(self.connectionID) was cancelled, removing") await parentManager.removeConnection(connectionID) break outer case .failed(let error): log.error( "Connection \(self.connectionID) failed with error \(error), removing" ) await parentManager.removeConnection(connectionID) break outer @unknown default: log.debug("Connection \(self.connectionID) in unknown state, skipping") } // Check again after 30 seconds try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds } } } func notifyToolListChanged() async { do { log.info("Notifying client that tool list changed") try await server.notify(ToolListChangedNotification.message()) } catch { log.error("Failed to notify client of tool list change: \(error)") // If the error is related to connection issues, clean up the connection if let nwError = error as? NWError, nwError.errorCode == 57 || nwError.errorCode == 54 { log.debug("Connection appears to be closed") await parentManager.removeConnection(connectionID) } } } func stop() async { await server.stop() connection.cancel() } } /// Manages Bonjour service discovery and advertisement actor NetworkDiscoveryManager { private let serviceType: String private let serviceDomain: String var listener: NWListener private let browser: NWBrowser init(serviceType: String, serviceDomain: String) throws { self.serviceType = serviceType self.serviceDomain = serviceDomain // Set up network parameters let parameters = NWParameters.tcp parameters.acceptLocalOnly = true parameters.includePeerToPeer = false if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { tcpOptions.version = .v4 } // Create the listener with service discovery self.listener = try NWListener(using: parameters) self.listener.service = NWListener.Service(type: serviceType, domain: serviceDomain) // Set up browser for debugging/monitoring self.browser = NWBrowser( for: .bonjour(type: serviceType, domain: serviceDomain), using: parameters ) log.info("Network discovery manager initialized with Bonjour service type: \(serviceType)") } func start( stateHandler: @escaping @Sendable (NWListener.State) -> Void, connectionHandler: @escaping @Sendable (NWConnection) -> Void ) { // Set up state handler listener.stateUpdateHandler = stateHandler // Set up connection handler listener.newConnectionHandler = connectionHandler // Start the listener and browser listener.start(queue: .main) browser.start(queue: .main) log.info("Started network discovery and advertisement") } func stop() { listener.cancel() browser.cancel() log.info("Stopped network discovery and advertisement") } func restartWithRandomPort() async throws { // Cancel the current listener listener.cancel() // Create new parameters with a random port let parameters: NWParameters = NWParameters.tcp // Explicit type parameters.acceptLocalOnly = true parameters.includePeerToPeer = false if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { tcpOptions.version = .v4 } // Create a new listener with the updated parameters let newListener: NWListener = try NWListener(using: parameters) // Explicit type let service = NWListener.Service(type: self.serviceType, domain: self.serviceDomain) // Explicitly create service newListener.service = service // Update the state handler and connection handler if let currentStateHandler = listener.stateUpdateHandler { newListener.stateUpdateHandler = currentStateHandler } if let currentConnectionHandler = listener.newConnectionHandler { newListener.newConnectionHandler = currentConnectionHandler } // Start the new listener newListener.start(queue: .main) self.listener = newListener // Update the instance member log.notice("Restarted listener with a dynamic port") } } actor ServerNetworkManager { private var isRunningState: Bool = false private var isEnabledState: Bool = true private var discoveryManager: NetworkDiscoveryManager? private var connections: [UUID: MCPConnectionManager] = [:] private var connectionTasks: [UUID: Task<Void, Never>] = [:] private var pendingConnections: [UUID: String] = [:] typealias ConnectionApprovalHandler = @Sendable (UUID, MCP.Client.Info) async -> Bool private var connectionApprovalHandler: ConnectionApprovalHandler? // Use ServiceRegistry for services private let services = ServiceRegistry.services private var serviceBindings: [String: Binding<Bool>] = [:] init() { do { self.discoveryManager = try NetworkDiscoveryManager( serviceType: serviceType, serviceDomain: serviceDomain ) } catch { log.error("Failed to initialize network discovery manager: \(error)") } } func isRunning() -> Bool { isRunningState } func setConnectionApprovalHandler(_ handler: @escaping ConnectionApprovalHandler) { log.debug("Setting connection approval handler") self.connectionApprovalHandler = handler } func start() async { log.info("Starting network manager") isRunningState = true guard let discoveryManager = discoveryManager else { log.error("Cannot start network manager: discovery manager not initialized") return } // Configure listener state handler await discoveryManager.start( stateHandler: { [weak self] (state: NWListener.State) -> Void in guard let strongSelf = self else { return } Task { await strongSelf.handleListenerStateChange(state) } }, connectionHandler: { [weak self] (connection: NWConnection) -> Void in guard let strongSelf = self else { return } Task { await strongSelf.handleNewConnection(connection) } } ) // Start a monitoring task to check service health periodically Task { while self.isRunningState { // Explicit self. // Check if the listener is in a ready state if let currentDM = self.discoveryManager, // Explicit self. self.isRunningState // Ensure still running before proceeding { // Fetch the state of the listener explicitly. let listenerState: NWListener.State = await currentDM.listener.state if listenerState != .ready { log.warning( "Listener not in ready state, current state: \\(listenerState)" ) let shouldAttemptRestart: Bool switch listenerState { case .failed, .cancelled: shouldAttemptRestart = true default: shouldAttemptRestart = false } if shouldAttemptRestart { log.info( "Attempting to restart listener (state: \\(listenerState)) because it was failed or cancelled." ) try? await currentDM.restartWithRandomPort() } } } // Sleep for 10 seconds before checking again try? await Task.sleep(nanoseconds: 10_000_000_000) // 10s } } } private func handleListenerStateChange(_ state: NWListener.State) async { switch state { case .ready: log.info("Server ready and advertising via Bonjour as \(serviceType)") case .setup: log.debug("Server setting up...") case .waiting(let error): log.warning("Server waiting: \(error)") // If the port is already in use, try to restart with a different port if error.errorCode == 48 { log.error("Port already in use, will try to restart service") // Wait a bit and restart try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds // Try to restart with a different port if isRunningState { try? await discoveryManager?.restartWithRandomPort() } } case .failed(let error): log.error("Server failed: \(error)") // Attempt recovery if isRunningState { log.info("Attempting to recover from server failure") try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second // Try to restart the listener try? await discoveryManager?.restartWithRandomPort() } case .cancelled: log.info("Server cancelled") @unknown default: log.warning("Unknown server state") } } func stop() async { log.info("Stopping network manager") isRunningState = false // Stop all connections for (id, connectionManager) in connections { log.debug("Stopping connection: \(id)") await connectionManager.stop() connectionTasks[id]?.cancel() } connections.removeAll() connectionTasks.removeAll() pendingConnections.removeAll() // Stop discovery await discoveryManager?.stop() } func removeConnection(_ id: UUID) async { log.debug("Removing connection: \(id)") // Stop the connection manager if let connectionManager = connections[id] { await connectionManager.stop() } // Cancel any associated tasks if let task = connectionTasks[id] { task.cancel() } // Remove from all collections connections.removeValue(forKey: id) connectionTasks.removeValue(forKey: id) pendingConnections.removeValue(forKey: id) } // Handle new incoming connections private func handleNewConnection(_ connection: NWConnection) async { let connectionID = UUID() log.info("Handling new connection: \(connectionID)") // Create a connection manager let connectionManager = MCPConnectionManager( connectionID: connectionID, connection: connection, parentManager: self ) // Store the connection manager connections[connectionID] = connectionManager // Start a task to monitor connection state let task = Task { // Ensure this task is removed from the registry upon completion (success or handled failure) // so the timeout logic below doesn't act on an already completed task. defer { // This runs on ServerNetworkManager's actor context self.connectionTasks.removeValue(forKey: connectionID) } do { // Set up the connection approval handler guard let approvalHandler = self.connectionApprovalHandler else { log.error("No connection approval handler set, rejecting connection") await removeConnection(connectionID) return } // Start the MCP server with our approval handler try await connectionManager.start { clientInfo in await approvalHandler(connectionID, clientInfo) } log.notice("Connection \(connectionID) successfully established") } catch { log.error("Failed to establish connection \(connectionID): \(error)") await removeConnection(connectionID) } } // Store the task connectionTasks[connectionID] = task // Set up a timeout to ensure the connection becomes ready in a reasonable time Task { try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds // Check if the setup task is still in the registry. If so, it implies // it hasn't completed its defer block (e.g., it's stuck or genuinely timed out) // and wasn't cleaned up by an error path calling removeConnection. // Also, ensure the connection object itself still exists. if self.connectionTasks[connectionID] != nil, // Task entry still exists (meaning it hasn't completed defer) self.connections[connectionID] != nil { // Connection object still exists log.warning( "Connection \(connectionID) setup timed out (task still in registry), closing it" ) await removeConnection(connectionID) } } } func registerHandlers(for server: MCP.Server, connectionID: UUID) async { // Register prompts/list handler await server.withMethodHandler(ListPrompts.self) { _ in log.debug("Handling ListPrompts request for \(connectionID)") return ListPrompts.Result(prompts: []) } // Register the resources/list handler await server.withMethodHandler(ListResources.self) { _ in log.debug("Handling ListResources request for \(connectionID)") return ListResources.Result(resources: []) } // Register tools/list handler await server.withMethodHandler(ListTools.self) { [weak self] _ in guard let self = self else { return ListTools.Result(tools: []) } log.debug("Handling ListTools request for \(connectionID)") var tools: [MCP.Tool] = [] if await self.isEnabledState { for service in await self.services { let serviceId = String(describing: type(of: service)) // Get the binding value in an actor-safe way if let isServiceEnabled = await self.serviceBindings[serviceId]?.wrappedValue, isServiceEnabled { for tool in service.tools { log.debug("Adding tool: \(tool.name)") tools.append( .init( name: tool.name, description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations ) ) } } } } log.info("Returning \(tools.count) available tools for \(connectionID)") return ListTools.Result(tools: tools) } // Register tools/call handler await server.withMethodHandler(CallTool.self) { [weak self] params in guard let self = self else { return CallTool.Result( content: [.text("Server unavailable")], isError: true ) } log.notice("Tool call received from \(connectionID): \(params.name)") 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.")], isError: true ) } for service in await self.services { let serviceId = String(describing: type(of: service)) // Get the binding value in an actor-safe way if let isServiceEnabled = await self.serviceBindings[serviceId]?.wrappedValue, isServiceEnabled { do { guard let value = try await service.call( tool: params.name, with: params.arguments ?? [:] ) else { continue } log.notice("Tool \(params.name) executed successfully for \(connectionID)") switch value { case .data(let mimeType?, let data) where mimeType.hasPrefix("audio/"): return CallTool.Result( content: [ .audio( data: data.base64EncodedString(), mimeType: mimeType ) ], isError: false) case .data(let mimeType?, let data) where mimeType.hasPrefix("image/"): return CallTool.Result( content: [ .image( data: data.base64EncodedString(), mimeType: mimeType, metadata: nil ) ], isError: false) default: let encoder = JSONEncoder() encoder.userInfo[Ontology.DateTime.timeZoneOverrideKey] = TimeZone.current encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] let data = try encoder.encode(value) let text = String(data: data, encoding: .utf8)! return CallTool.Result(content: [.text(text)], isError: false) } } catch { log.error( "Error executing tool \(params.name): \(error.localizedDescription)") return CallTool.Result(content: [.text("Error: \(error)")], 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)")], isError: true ) } } // Update the enabled state and notify clients func setEnabled(_ enabled: Bool) async { // Only do something if the state actually changes guard isEnabledState != enabled else { return } isEnabledState = enabled log.info("iMCP enabled state changed to: \(enabled)") // Notify all connected clients that the tool list has changed for (_, connectionManager) in connections { Task { await connectionManager.notifyToolListChanged() } } } // Update service bindings func updateServiceBindings(_ newBindings: [String: Binding<Bool>]) async { self.serviceBindings = newBindings // Notify clients that tool availability may have changed Task { for (_, connectionManager) in connections { await connectionManager.notifyToolListChanged() } } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mattt/iMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server