import AgentKit import Foundation import TermindDomain /// The single source of truth for model providers, their models, which model does /// what, and the MCP servers. /// /// Its own store rather than more fields on `HostLibraryModel`: that one is about /// machines you connect to, this one is about model endpoints. /// Provider and MCP configuration are projected from SwiftData in production /// while previews can still construct an in-memory store. @Observable public final class AIConfigurationModel { public private(set) var modelProviders: [ModelProvider] = [] /// What the user typed and picked for each provider's endpoint. /// /// Beside the providers rather than on them, because `ModelProvider` is /// AgentKit's type and this app does not get to add a field to it. Neither /// half survives a round trip through the composed result — a URL ending in /// `/messages` looks the same whether it was typed or appended, and /// `Automatic` looks the same as picking the format it resolved to — so /// there is nothing to derive it from and it has to be kept. /// /// A missing entry is not a broken one: it reads as `Automatic` over the /// provider.s stored `inferenceURL`, which resolves to the format already recorded /// there. The editor shows the same endpoint either way. private var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] public private(set) var mcpServers: [MCPServer] = [] /// Explicit role assignments. Absent means "work it out" — see `resolve`. public private(set) var roles: [AIRole: AIModelRef] = [:] /// The last model picked in a conversation's model menu. /// /// This is what makes the app usable with no configuration at all: choosing /// a model once, in the place you were already looking, quietly becomes the /// default for every conversation that has not chosen. It sits below an /// explicit Chat assignment and above first-available in `resolve`, so it /// never overrides a decision the user made in Settings. It is persisted on /// this device but deliberately excluded from Settings sync. public private(set) var lastSelectedModel: AIModelRef? /// The approval posture new and otherwise-unset conversations follow. /// Stored in local preferences rather than provider configuration so it /// survives relaunches without becoming part of the synced SwiftData model. public private(set) var lastSelectedPermissionMode: AgentPermissionMode /// Live MCP connection state, keyed by server. /// /// Here rather than on `MCPServer` for the same reason `HostLibraryModel.statuses` /// is not on `Host`: it is session state, not user data, and it must never /// be persisted or synced. public private(set) var mcpStatuses: [MCPServer.ID: ConnectionStatus] = [:] /// Session cache for API keys and service-account JSON. Production reads /// through to the Data Protection Keychain; previews keep their fake secret /// in memory so demo data never creates a real Keychain item. private var secrets: [String: String] = [:] private let aiSecrets: (any AISecretStoring)? private let webSearchPersistence: (any AIWebSearchPersisting)? public private(set) var webSearch = AIWebSearchPreferences() private let keychain: any SecretStoring private let persistsSecrets: Bool private let permissionDefaults: UserDefaults? public private(set) var secretStorageError: String? private var persistence: (any AIConfigurationPersisting)? public private(set) var storageError: String? public init( modelProviders: [ModelProvider] = [], endpoints: [ModelProvider.ID: ProviderEndpoint] = [:], mcpServers: [MCPServer] = [], roles: [AIRole: AIModelRef] = [:], lastSelectedModel: AIModelRef? = nil, lastSelectedPermissionMode: AgentPermissionMode = .askForApproval, persistence: (any AIConfigurationPersisting)? = nil, secrets secretStore: any SecretStoring = InMemorySecretStore(), persistsSecrets: Bool = true, permissionDefaults: UserDefaults? = nil, aiSecrets: (any AISecretStoring)? = nil, webSearchPersistence: (any AIWebSearchPersisting)? = nil ) { self.aiSecrets = aiSecrets self.webSearchPersistence = webSearchPersistence self.modelProviders = modelProviders self.endpoints = endpoints self.mcpServers = mcpServers self.roles = roles self.lastSelectedModel = lastSelectedModel self.lastSelectedPermissionMode = permissionDefaults .flatMap { $0.string(forKey: "assistant.permissionMode") } .flatMap(AgentPermissionMode.init(rawValue:)) ?? lastSelectedPermissionMode self.persistence = persistence self.keychain = secretStore self.persistsSecrets = persistsSecrets self.permissionDefaults = permissionDefaults if let webSearchPersistence { do { webSearch = try webSearchPersistence.loadWebSearch() } catch { storageError = error.localizedDescription } } guard let persistence else { return } do { let loaded = try persistence.load() self.modelProviders = loaded.providers self.endpoints = loaded.endpoints self.roles = loaded.roles self.lastSelectedModel = loaded.lastSelectedModel self.mcpServers = loaded.mcpServers } catch { storageError = error.localizedDescription } } // MARK: - Reads public func modelProvider(_ id: ModelProvider.ID) -> ModelProvider? { modelProviders.first { $0.id == id } } /// What the editor shows for a provider's endpoint. Falls back to reading /// the composed URL as `Automatic` — see `endpoints`. public func endpoint(for id: ModelProvider.ID) -> ProviderEndpoint { if let stored = endpoints[id] { return stored } return ProviderEndpoint(url: modelProvider(id)?.inferenceURL ?? "") } /// Providers whose models may be offered anywhere. A provider with no /// credential is included: it may have hand-added models, and hiding it /// would make "why is my model missing" unanswerable from the picker. public var enabledModelProviders: [ModelProvider] { modelProviders.filter(\.isEnabled) } /// Resolves a ref to the pair it names, or `nil` if either half has since /// been deleted, renamed, or disabled. /// /// Every step of `resolve` runs through here, which is what stops a stale /// assignment from silently pointing at nothing. /// /// A missing URL is deliberately *not* one of the disqualifying conditions, /// for the same reason a missing credential is not: the model is sitting /// right there in Settings, and a picker that quietly skips it makes "why is /// my model missing" unanswerable. Sending to a blank URL now fails with a /// bad-URL error naming the problem, which is the better of the two — there /// is no default endpoint left for it to reach instead. public func lookup(_ ref: AIModelRef) -> (provider: ModelProvider, model: AIModel)? { guard let provider = modelProvider(ref.modelProviderID), provider.isEnabled, let model = provider.model(ref.modelID) else { return nil } return (provider, model) } /// The fallback of last resort: the first chat model of the first enabled /// provider, in the order the lists are shown. public var firstAvailableModel: AIModelRef? { for provider in enabledModelProviders { if let model = provider.chatModels.first { return AIModelRef(modelProviderID: provider.id, modelID: model.id) } } return nil } /// Which model answers a given job. /// /// One chain, and no step of it is required: /// /// 1. an explicit assignment for this role /// 2. for Guardian, the highest-priority available review model /// 3. for other non-chat roles, whatever Chat resolves to /// 4. the last model picked in a conversation's menu /// 5. the first chat model of the first enabled provider /// /// Any step whose ref no longer resolves is skipped rather than returning /// nothing, so deleting a provider degrades the app instead of breaking it. /// The practical effect: add one provider, press Fetch, and the assistant /// works — the role pickers are for tuning, not for setup. public func resolve(_ role: AIRole) -> (provider: ModelProvider, model: AIModel)? { if let ref = roles[role], let hit = lookup(ref) { return hit } if role == .guardian { if let codex = firstGuardianModel(named: "codex-auto-review") { return codex } for family in Self.guardianModelFamilies { if let newest = newestGuardianModel( prefix: family.prefix, suffix: family.suffix ) { return newest } } return resolveAutomaticChat() } if role != .chat, let chat = resolveAutomaticChat() { return chat } return resolveAutomaticChat() } private func resolveAutomaticChat() -> (provider: ModelProvider, model: AIModel)? { if let ref = roles[.chat], let hit = lookup(ref) { return hit } if let ref = lastSelectedModel, let hit = lookup(ref) { return hit } if let ref = firstAvailableModel, let hit = lookup(ref) { return hit } return nil } private static let guardianModelFamilies = [ (prefix: "gpt-", suffix: "-luna"), (prefix: "claude-sonnet-", suffix: ""), (prefix: "gemini-", suffix: "-flash"), (prefix: "deepseek-v", suffix: "-flash"), (prefix: "glm-", suffix: "-flash"), ] private func firstGuardianModel(named id: String) -> (provider: ModelProvider, model: AIModel)? { for provider in enabledModelProviders { if let model = provider.chatModels.first(where: { let candidate = Self.normalizedModelID($0.id) return candidate == id }) { return (provider, model) } } return nil } private func newestGuardianModel( prefix: String, suffix: String ) -> (provider: ModelProvider, model: AIModel)? { var best: (provider: ModelProvider, model: AIModel, version: [Int])? for provider in enabledModelProviders { for model in provider.chatModels { let id = Self.normalizedModelID(model.id) guard let version = Self.guardianVersion(in: id, prefix: prefix, suffix: suffix) else { continue } if let best, !Self.version(version, isNewerThan: best.version) { continue } best = (provider, model, version) } } return best.map { ($0.provider, $0.model) } } private static func guardianVersion( in id: String, prefix: String, suffix: String ) -> [Int]? { guard id.hasPrefix(prefix), suffix.isEmpty || id.hasSuffix(suffix) else { return nil } let start = id.index(id.startIndex, offsetBy: prefix.count) let end = suffix.isEmpty ? id.endIndex : id.index(id.endIndex, offsetBy: -suffix.count) guard start < end else { return nil } let raw = id[start.. Bool { let count = max(lhs.count, rhs.count) for index in 0.. right } } return false } private static func normalizedModelID(_ id: String) -> String { id.lowercased().split(separator: "/").last.map(String.init) ?? id.lowercased() } /// The model a conversation will actually use. /// /// Its own choice if it made one, and whatever `.chat` resolves to if it did /// not. A conversation is left unset rather than assigned a model on /// creation, so this is the only place that difference has to be understood /// — the Assistant sidebar's model menu, the composer's caption, and the status bar /// all ask here instead of each reimplementing the fallback slightly /// differently. public func resolve(conversation model: AIModelRef?) -> (provider: ModelProvider, model: AIModel)? { if let model, let hit = lookup(model) { return hit } return resolve(.chat) } /// An explicit conversation choice wins; otherwise the app-wide latest /// choice is the default. The latter begins at the safest posture. public func resolve(conversation permissionMode: AgentPermissionMode?) -> AgentPermissionMode { permissionMode ?? lastSelectedPermissionMode } /// Whether a role is showing an explicit choice or following the chain. /// The picker needs this to know when to show its unset tag. public func assignment(for role: AIRole) -> AIModelRef? { guard let ref = roles[role], lookup(ref) != nil else { return nil } return ref } /// Which roles would lose their explicit model if a provider were deleted. /// /// The counterpart of `HostLibraryModel.identities(using:)`, and used for the same /// reason: a delete button should say what it takes with it. public func roles(using modelProviderID: ModelProvider.ID) -> [AIRole] { AIRole.allCases.filter { roles[$0]?.modelProviderID == modelProviderID } } public func mcpServer(_ id: MCPServer.ID) -> MCPServer? { mcpServers.first { $0.id == id } } public func mcpStatus(of id: MCPServer.ID) -> ConnectionStatus { mcpStatuses[id] ?? .disconnected } public func hasMCPNamespaceConflict(_ namespaceID: String, excluding id: MCPServer.ID) -> Bool { !namespaceID.isEmpty && mcpServers.contains { $0.id != id && $0.effectiveNamespaceID == namespaceID } } /// Every discovered tool on enabled servers. Denied tools stay visible to /// the model so an attempted call receives an explicit policy refusal. public var availableTools: [(server: MCPServer, tool: MCPTool)] { usableMCPServers.flatMap { server in server.tools.map { (server, $0) } } } public var conflictingMCPServerIDs: Set { var names = Set() var conflicts = Set() for server in mcpServers.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { if !server.effectiveNamespaceID.isEmpty, !names.insert(server.effectiveNamespaceID).inserted { conflicts.insert(server.id) } } return conflicts } public var usableMCPServers: [MCPServer] { let conflicts = conflictingMCPServerIDs return mcpServers.filter { $0.isEnabled && !conflicts.contains($0.id) } } public func setWebSearch(_ value: AIWebSearchPreferences) { do { try webSearchPersistence?.saveWebSearch(value) webSearch = value storageError = nil } catch { storageError = error.localizedDescription } } /// Applies an already committed replica. It never calls the local write path. public func applyRemoteSnapshot(_ snapshot: AIConfigurationSnapshot, webSearch: AIWebSearchPreferences) { let localSelection = lastSelectedModel let previousServers = Dictionary(uniqueKeysWithValues: mcpServers.map { ($0.id, $0) }) modelProviders = snapshot.providers endpoints = snapshot.endpoints roles = snapshot.roles lastSelectedModel = localSelection.flatMap { lookup($0) == nil ? nil : $0 } mcpServers = snapshot.mcpServers mcpStatuses = mcpStatuses.filter { id, _ in previousServers[id] == mcpServers.first { $0.id == id } } self.webSearch = webSearch secrets.removeAll() storageError = nil } // MARK: - Secrets /// An empty result explicitly means no authentication. A missing result means /// a configured credential is unavailable; callers must not send anonymously. /// Vertex always requires a service account, even without a stored reference. public func modelSecret(for provider: ModelProvider) -> String? { if provider.usesVertexEndpoint && !ModelProviderForm.hasValidAddress(provider) { return nil } let reference = provider.effectiveCredentialRef if reference.isEmpty { return provider.usesVertexEndpoint ? nil : "" } guard let value = secret(for: reference), !value.isEmpty else { return nil } return value } /// The secret behind a `credentialRef`, loaded from the Termind Data /// Protection Keychain and cached only for this process. public func secret(for ref: String) -> String? { guard !ref.isEmpty else { return nil } if let aiSecrets, persistsSecrets { do { let value = try aiSecrets.string(for: ref) secretStorageError = nil return value } catch { secretStorageError = error.localizedDescription return nil } } if let cached = secrets[ref] { return cached } guard persistsSecrets else { return nil } do { let value = try keychain.string(for: ref, storage: .thisDevice) if let value { secrets[ref] = value } secretStorageError = nil return value } catch { secretStorageError = error.localizedDescription return nil } } /// Stores or clears a secret. `nil` forgets it, which is what emptying the /// field in an editor has to mean. public func setSecret(_ secret: String?, for ref: String) { guard !ref.isEmpty else { return } do { if persistsSecrets { if let aiSecrets { try aiSecrets.setString(secret, for: ref) } else { try keychain.setString(secret, for: ref, storage: .thisDevice) } } secrets[ref] = secret secretStorageError = nil } catch { secretStorageError = error.localizedDescription } } /// Mints a reference for a new secret. The plaintext is handed over /// separately so the two are never in one value — the trick /// `NewIdentitySheet` already uses for passwords. public static func makeCredentialRef(_ kind: String = "key") -> String { "termind.ai.\(kind).\(UUID().uuidString)" } // MARK: - Model provider writes public func upsert(_ provider: ModelProvider) { var provider = provider let apiType = endpoints[provider.id]?.apiType ?? ModelAPIType(provider.apiFormat) provider.name = SavedName.resolve(provider.name, default: apiType.defaultProviderName) provider.models = provider.models.map(\.savingName) mutatePersistedState { if let index = modelProviders.firstIndex(where: { $0.id == provider.id }) { modelProviders[index] = provider } else { modelProviders.append(provider) } } } /// Commits a provider form and its endpoint together, without selecting a model. @discardableResult public func saveProvider(_ draft: ModelProvider, endpoint: ProviderEndpoint) -> Bool { mutatePersistedState { let (value, endpoint) = ModelProviderForm.normalized(draft, endpoint: endpoint) let removed = Set((modelProvider(value.id)?.models ?? []).map(\.id)).subtracting(value.models.map(\.id)) for role in AIRole.allCases { if let ref = roles[role], ref.modelProviderID == value.id, removed.contains(ref.modelID) { roles[role] = nil } } if let ref = lastSelectedModel, ref.modelProviderID == value.id, removed.contains(ref.modelID) { lastSelectedModel = nil } endpoints[value.id] = endpoint if let index = modelProviders.firstIndex(where: { $0.id == value.id }) { modelProviders[index] = value } else { modelProviders.append(value) } } } /// Derives and commits the request URL, base URL and format through the same form transaction. public func setEndpoint(_ endpoint: ProviderEndpoint, for id: ModelProvider.ID) { guard let provider = modelProvider(id) else { return } saveProvider(provider, endpoint: endpoint) } /// Removes a provider along with everything that only made sense while it /// existed: its secrets, the role assignments pointing at it, and the /// remembered selection if that was one of its models. /// /// The same discipline as `HostLibraryModel.delete(identityID:)`. Leaving any of /// these behind would give `resolve` a ref that looks valid and is not, and /// the failure would surface at send time rather than here. public func delete(modelProviderID: ModelProvider.ID) { guard let provider = modelProvider(modelProviderID) else { return } let deleted = mutatePersistedState { for role in roles(using: modelProviderID) { roles[role] = nil } if lastSelectedModel?.modelProviderID == modelProviderID { lastSelectedModel = nil } modelProviders.removeAll { $0.id == modelProviderID } endpoints[modelProviderID] = nil } guard deleted else { return } var failures: [String] = [] for reference in Set([provider.credentialRef, provider.vertex.credentialRef]) where !reference.isEmpty { setSecret(nil, for: reference) if let error = secretStorageError { failures.append(error) } } secretStorageError = failures.isEmpty ? nil : failures.joined(separator: "\n") } public func setModelProviderEnabled(_ enabled: Bool, for id: ModelProvider.ID) { mutatePersistedState { guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } modelProviders[index].isEnabled = enabled } } // MARK: - Model writes /// Replaces the whole model list. /// /// Hand-added models are kept and merged rather than wiped: someone who /// typed a model the endpoint does not advertise — a private deployment, a /// preview id — should not lose it. A fetched row wins on conflict, since /// the server is better informed than a guess. Saved names remain user-owned. /// /// Not what the Fetch button calls. A fetch offers a list and the user picks /// from it (see `ModelListEditor`), so the additions arrive one at a time /// through `addModel`. This exists for a caller that really does mean "this /// is now the list". public func replaceModels(_ fetched: [AIModel], for id: ModelProvider.ID) { mutatePersistedState { guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } let previous = Dictionary( uniqueKeysWithValues: modelProviders[index].models.map { ($0.id, $0) } ) let fetchedIDs = Set(fetched.map(\.id)) let merged = fetched.map { fresh in var fresh = fresh fresh.displayName = SavedName.resolve(previous[fresh.id]?.displayName ?? "", default: fresh.displayName) fresh.reasoningProfile = previous[fresh.id]?.reasoningProfile fresh.isShownInAssistant = previous[fresh.id]?.isShownInAssistant ?? fresh.isShownInAssistant return fresh.savingName } let kept = modelProviders[index].models.filter { $0.origin == .manual && !fetchedIDs.contains($0.id) } modelProviders[index].models = merged + kept.map(\.savingName) modelProviders[index].modelsFetchedAt = Date() } } /// Records that the endpoint was asked, whatever the user then chose to add. /// /// Separate from adding, because "when did I last check what this endpoint /// offers" is a different question from "what did I keep", and the answer to /// the first is useful even when the answer to the second is "nothing". public func markModelsFetched(for id: ModelProvider.ID) { mutatePersistedState { guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } modelProviders[index].modelsFetchedAt = Date() } } public func addModel(_ model: AIModel, to id: ModelProvider.ID) { mutatePersistedState { guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } guard !modelProviders[index].models.contains(where: { $0.id == model.id }) else { return } modelProviders[index].models.append(model.savingName) } } public func updateModel(_ model: AIModel, in id: ModelProvider.ID) { mutatePersistedState { guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } guard let slot = modelProviders[index].models.firstIndex(where: { $0.id == model.id }) else { return } modelProviders[index].models[slot] = model.savingName } } /// Removes models, and unhooks anything that pointed at them. /// /// Same reasoning as `delete(modelProviderID:)`: a role holding a ref to a model /// that no longer exists is a failure deferred to send time. public func removeModels(_ ids: Set, from modelProviderID: ModelProvider.ID) { mutatePersistedState { guard let index = modelProviders.firstIndex(where: { $0.id == modelProviderID }) else { return } modelProviders[index].models.removeAll { ids.contains($0.id) } for role in AIRole.allCases { guard let ref = roles[role], ref.modelProviderID == modelProviderID, ids.contains(ref.modelID) else { continue } roles[role] = nil } if let ref = lastSelectedModel, ref.modelProviderID == modelProviderID, ids.contains(ref.modelID) { lastSelectedModel = nil } } } // MARK: - Role writes public func assign(_ ref: AIModelRef?, to role: AIRole) { mutatePersistedState { roles[role] = ref } } /// Records a choice made in a conversation's model menu. /// /// Not an assignment: it feeds step 3 of `resolve`, so it changes the /// default without overwriting anything the user set in Settings. public func select(_ ref: AIModelRef) { mutatePersistedState { lastSelectedModel = ref } } /// Records a composer permission choice for subsequent unset conversations /// and future app launches. It does not alter command or MCP execution policy. public func select(_ permissionMode: AgentPermissionMode) { lastSelectedPermissionMode = permissionMode permissionDefaults?.set(permissionMode.rawValue, forKey: "assistant.permissionMode") } // MARK: - Provider persistence @discardableResult private func mutatePersistedState(_ mutation: () -> Void) -> Bool { let previous = persistedSnapshot mutation() guard let persistence else { return true } do { try persistence.save(persistedSnapshot) storageError = nil return true } catch { modelProviders = previous.providers endpoints = previous.endpoints roles = previous.roles lastSelectedModel = previous.lastSelectedModel mcpServers = previous.mcpServers storageError = error.localizedDescription return false } } private var persistedSnapshot: AIConfigurationSnapshot { AIConfigurationSnapshot( providers: modelProviders, endpoints: endpoints, roles: roles, lastSelectedModel: lastSelectedModel, mcpServers: mcpServers ) } // MARK: - MCP writes public func upsert(_ server: MCPServer) { mutatePersistedState { if let index = mcpServers.firstIndex(where: { $0.id == server.id }) { mcpServers[index] = server } else { mcpServers.append(server) } } } /// Locks a valid, unique draft namespace without changing the UUID identity. @discardableResult public func lockMCPNamespace(for id: MCPServer.ID) -> Bool { guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return false } if !mcpServers[index].namespaceID.isEmpty { return true } let namespaceID = mcpServers[index].effectiveNamespaceID guard !namespaceID.isEmpty, !hasMCPNamespaceConflict(namespaceID, excluding: id) else { return false } return mutatePersistedState { mcpServers[index].namespaceID = namespaceID } } public func delete(serverID: MCPServer.ID) { guard let server = mcpServer(serverID) else { return } let deleted = mutatePersistedState { mcpServers.removeAll { $0.id == serverID } } guard deleted else { return } setSecret(nil, for: server.credentialRef) mcpStatuses[serverID] = nil } public func setServerEnabled(_ enabled: Bool, for id: MCPServer.ID) { mutatePersistedState { guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } mcpServers[index].isEnabled = enabled } } /// What a successful `Connect` writes back. /// /// A tool keeps its access policy across rediscovery — otherwise every /// reconnect would silently undo the decision made about this server. A /// genuinely new tool uses its Termind default: a non-destructive tool with /// `readOnlyHint == true` is approved, while every other tool asks. public func setTools(_ discovered: [MCPTool], version: String, for id: MCPServer.ID) { mutatePersistedState { guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } let previous = Dictionary( uniqueKeysWithValues: mcpServers[index].tools.map { ($0.id, $0) } ) mcpServers[index].tools = discovered.map { tool in var merged = tool merged.accessPolicy = previous[tool.id]?.accessPolicy ?? merged.termindDefaultApprovalPolicy return merged } mcpServers[index].negotiatedVersion = version mcpServers[index].lastConnectedAt = Date() } } public func setToolAccessPolicy( _ policy: AgentToolDescriptor.ApprovalPolicy, tool toolID: String, on serverID: MCPServer.ID ) { mutate(toolID, on: serverID) { $0.accessPolicy = policy } } private func mutate( _ toolID: String, on serverID: MCPServer.ID, _ change: (inout MCPTool) -> Void ) { mutatePersistedState { guard let index = mcpServers.firstIndex(where: { $0.id == serverID }) else { return } guard let slot = mcpServers[index].tools.firstIndex(where: { $0.id == toolID }) else { return } change(&mcpServers[index].tools[slot]) } } public func setMCPStatus(_ status: ConnectionStatus, for id: MCPServer.ID) { mcpStatuses[id] = status } }