diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 12bc2a61..621c5692 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -179,6 +179,10 @@ final class ServerController: ObservableObject { // MARK: - AppStorage for Trusted Clients @AppStorage("trustedClients") private var trustedClientsData = Data() + // MARK: - AppStorage for Disabled Tools + @AppStorage("disabledTools") private var disabledToolsData = Data() + private var disabledToolsGeneration = 0 + // MARK: - Computed Properties for Service Configurations and Bindings var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( @@ -237,6 +241,51 @@ final class ServerController: ObservableObject { trustedClients = Set() } + // MARK: - Disabled Tools Management + var disabledTools: Set { + get { + (try? JSONDecoder().decode(Set.self, from: disabledToolsData)) ?? [] + } + set { + objectWillChange.send() + disabledToolsData = (try? JSONEncoder().encode(newValue)) ?? Data() + disabledToolsGeneration += 1 + let generation = disabledToolsGeneration + Task { await networkManager.updateDisabledTools(newValue, generation: generation) } + } + } + + func isToolEnabled(_ name: String) -> Bool { + !disabledTools.contains(name) + } + + func setTool(_ name: String, enabled: Bool) { + var tools = disabledTools + if enabled { + tools.remove(name) + } else { + tools.insert(name) + } + disabledTools = tools + } + + func setService(_ config: ServiceConfig, enabled: Bool) { + objectWillChange.send() + config.binding.wrappedValue = enabled + + Task { + if enabled, await !config.isActivated { + do { + try await config.service.activate() + } catch { + self.objectWillChange.send() + config.binding.wrappedValue = false + } + } + await networkManager.updateServiceBindings(self.currentServiceBindings) + } + } + // MARK: - Connection Approval Methods private func cleanupApprovalState() { pendingClientName = "" @@ -265,6 +314,7 @@ final class ServerController: ObservableObject { Task { // Initialize bindings from AppStorage before the server starts. await networkManager.updateServiceBindings(self.currentServiceBindings) + await networkManager.updateDisabledTools(self.disabledTools, generation: 0) await self.networkManager.start() self.updateServerStatus("Running") @@ -636,6 +686,8 @@ actor ServerNetworkManager { private let services = ServiceRegistry.services private var serviceBindings: [String: Binding] = [:] + private var disabledTools: Set = [] + private var disabledToolsLastGeneration = -1 init() { do { @@ -880,6 +932,10 @@ actor ServerNetworkManager { isServiceEnabled { for tool in service.tools { + if await self.disabledTools.contains(tool.name) { + log.debug("Skipping disabled tool: \(tool.name)") + continue + } log.debug("Adding tool: \(tool.name)") tools.append( .init( @@ -922,6 +978,20 @@ actor ServerNetworkManager { ) } + if await self.disabledTools.contains(params.name) { + log.notice("Tool call rejected: \(params.name) is disabled") + return CallTool.Result( + content: [ + .text( + text: "Tool \(params.name) is currently disabled in iMCP settings.", + annotations: nil, + _meta: nil + ) + ], + isError: true + ) + } + for service in await self.services { let serviceId = String(describing: type(of: service)) @@ -1028,4 +1098,20 @@ actor ServerNetworkManager { } } } + + // Update the disabled tool set, discarding out-of-order deliveries. + func updateDisabledTools(_ newDisabledTools: Set, generation: Int) async { + guard generation > disabledToolsLastGeneration else { return } + disabledToolsLastGeneration = generation + + guard disabledTools != newDisabledTools else { return } + self.disabledTools = newDisabledTools + + // Notify clients that tool availability may have changed. + Task { + for (_, connectionManager) in connections { + await connectionManager.notifyToolListChanged() + } + } + } } diff --git a/App/Views/ServiceToggleView.swift b/App/Views/ServiceToggleView.swift index a0ca7429..2ba591d1 100644 --- a/App/Views/ServiceToggleView.swift +++ b/App/Views/ServiceToggleView.swift @@ -41,6 +41,7 @@ struct ServiceToggleView: View { .buttonStyle(PlainButtonStyle()) .disabled(!isEnabled) .frame(width: buttonSize, height: buttonSize) + .help("\(config.name) tools: \(toolSummary)") Text(config.name) .frame(maxWidth: .infinity, alignment: .leading) @@ -69,4 +70,10 @@ struct ServiceToggleView: View { return .primary.opacity(isEnabled ? 0.7 : 0.4) } } + + private var toolSummary: String { + config.service.tools + .map { $0.annotations.title ?? $0.name } + .joined(separator: ", ") + } } diff --git a/App/Views/ServicesSettingsView.swift b/App/Views/ServicesSettingsView.swift new file mode 100644 index 00000000..2397b377 --- /dev/null +++ b/App/Views/ServicesSettingsView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct ServicesSettingsView: View { + @ObservedObject var serverController: ServerController + + var body: some View { + Form { + ForEach(serverController.computedServiceConfigs) { config in + Section { + ForEach(config.service.tools, id: \.name) { tool in + toolRow(tool) + } + .disabled(!config.binding.wrappedValue) + } header: { + HStack(spacing: 8) { + Circle() + .fill(config.color) + .overlay( + Image(systemName: config.iconName) + .resizable() + .scaledToFit() + .foregroundColor(.white) + .padding(4) + ) + .frame(width: 20, height: 20) + + Text(config.name) + + Spacer() + + Toggle(config.name, isOn: serviceBinding(config)) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + .id("\(config.id)-\(config.binding.wrappedValue)") + } + } + } + } + .formStyle(.grouped) + } + + @ViewBuilder + private func toolRow(_ tool: Tool) -> some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(tool.annotations.title ?? tool.name) + + if tool.annotations.readOnlyHint == true { + Text("Read-only") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + .foregroundStyle(.secondary) + } + } + + Text(tool.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + Toggle(tool.annotations.title ?? tool.name, isOn: toolBinding(tool.name)) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + .id("\(tool.name)-\(serverController.isToolEnabled(tool.name))") + } + } + + // Delegate to ServerController so toggling activates the service (permission + // prompt, revert on failure) and pushes updated bindings to connected clients. + private func serviceBinding(_ config: ServiceConfig) -> Binding { + Binding( + get: { config.binding.wrappedValue }, + set: { serverController.setService(config, enabled: $0) } + ) + } + + private func toolBinding(_ name: String) -> Binding { + Binding( + get: { serverController.isToolEnabled(name) }, + set: { serverController.setTool(name, enabled: $0) } + ) + } +} diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index c4703894..86acbad4 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -6,12 +6,14 @@ struct SettingsView: View { enum SettingsSection: String, CaseIterable, Identifiable { case general = "General" + case services = "Services" var id: String { self.rawValue } var icon: String { switch self { case .general: return "gear" + case .services: return "square.grid.2x2" } } } @@ -40,6 +42,9 @@ struct SettingsView: View { GeneralSettingsView(serverController: serverController) .navigationTitle("General") .formStyle(.grouped) + case .services: + ServicesSettingsView(serverController: serverController) + .navigationTitle("Services") } } else { Text("Select a category")