From c7361fe015c68a496bbeb32274ce244c82289531 Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 22:44:22 +0200 Subject: [PATCH 1/6] Add disabled-tools state and enforce it in the MCP server A JSON-encoded set of disabled tool names is persisted the same way as trusted clients and pushed into ServerNetworkManager the same way as service bindings. ListTools omits disabled tools and CallTool rejects them, so a client holding a stale list still cannot invoke one. Connected clients get tools/listChanged on every change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --- App/Controllers/ServerController.swift | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 12bc2a61..d6f67e1d 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -179,6 +179,9 @@ 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() + // MARK: - Computed Properties for Service Configurations and Bindings var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( @@ -237,6 +240,33 @@ 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() + let snapshot = newValue + Task { await networkManager.updateDisabledTools(snapshot) } + } + } + + 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 + } + // MARK: - Connection Approval Methods private func cleanupApprovalState() { pendingClientName = "" @@ -265,6 +295,7 @@ final class ServerController: ObservableObject { Task { // Initialize bindings from AppStorage before the server starts. await networkManager.updateServiceBindings(self.currentServiceBindings) + await networkManager.updateDisabledTools(self.disabledTools) await self.networkManager.start() self.updateServerStatus("Running") @@ -636,6 +667,7 @@ actor ServerNetworkManager { private let services = ServiceRegistry.services private var serviceBindings: [String: Binding] = [:] + private var disabledTools: Set = [] init() { do { @@ -880,6 +912,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 +958,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 +1078,17 @@ actor ServerNetworkManager { } } } + + // Update the disabled tool set. + func updateDisabledTools(_ newDisabledTools: Set) async { + guard disabledTools != newDisabledTools else { return } + self.disabledTools = newDisabledTools + + // Notify clients that tool availability may have changed. + Task { + for (_, connectionManager) in connections { + await connectionManager.notifyToolListChanged() + } + } + } } From b7c4e6d93bef8cabb43c47b28e7c329772d2e05d Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 22:50:10 +0200 Subject: [PATCH 2/6] Guard disabled-tools pushes against out-of-order delivery Each write to ServerController.disabledTools fired an unstructured Task carrying a snapshot to the actor. Two rapid writes could reach the actor out of order, and the actor's equality guard would accept the stale snapshot, leaving live ListTools/CallTool enforcement diverged from the persisted value until the next toggle. A monotonic generation counter is now attached to each push; the actor discards any delivery whose generation is not newer than the last one it applied. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --- App/Controllers/ServerController.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index d6f67e1d..98d06609 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -181,6 +181,7 @@ final class ServerController: ObservableObject { // 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] { @@ -248,8 +249,9 @@ final class ServerController: ObservableObject { set { objectWillChange.send() disabledToolsData = (try? JSONEncoder().encode(newValue)) ?? Data() - let snapshot = newValue - Task { await networkManager.updateDisabledTools(snapshot) } + disabledToolsGeneration += 1 + let generation = disabledToolsGeneration + Task { await networkManager.updateDisabledTools(newValue, generation: generation) } } } @@ -295,7 +297,7 @@ final class ServerController: ObservableObject { Task { // Initialize bindings from AppStorage before the server starts. await networkManager.updateServiceBindings(self.currentServiceBindings) - await networkManager.updateDisabledTools(self.disabledTools) + await networkManager.updateDisabledTools(self.disabledTools, generation: 0) await self.networkManager.start() self.updateServerStatus("Running") @@ -668,6 +670,7 @@ actor ServerNetworkManager { private let services = ServiceRegistry.services private var serviceBindings: [String: Binding] = [:] private var disabledTools: Set = [] + private var disabledToolsLastGeneration = -1 init() { do { @@ -1079,8 +1082,11 @@ actor ServerNetworkManager { } } - // Update the disabled tool set. - func updateDisabledTools(_ newDisabledTools: Set) async { + // 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 From ed8926ae34f628255cbeb3206d5a55337be364d3 Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 23:06:35 +0200 Subject: [PATCH 3/6] Add Services section to Settings with per-tool toggles Each service lists its tools with human-readable titles, descriptions, and a Read-only badge from the tool annotations, plus a switch per tool backed by the disabled-tools set. The service master toggle is the same binding the menu uses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --- App/Views/ServicesSettingsView.swift | 93 ++++++++++++++++++++++++++++ App/Views/SettingsView.swift | 5 ++ 2 files changed, 98 insertions(+) create mode 100644 App/Views/ServicesSettingsView.swift diff --git a/App/Views/ServicesSettingsView.swift b/App/Views/ServicesSettingsView.swift new file mode 100644 index 00000000..a2870e8f --- /dev/null +++ b/App/Views/ServicesSettingsView.swift @@ -0,0 +1,93 @@ +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("", isOn: serviceBinding(config)) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + } + } + } + .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("", isOn: toolBinding(tool.name)) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + } + .accessibilityElement(children: .combine) + } + + // Route writes through ServerController so views observing it refresh; + // @AppStorage-backed bindings don't publish through ObservableObject alone. + private func serviceBinding(_ config: ServiceConfig) -> Binding { + Binding( + get: { config.binding.wrappedValue }, + set: { newValue in + serverController.objectWillChange.send() + config.binding.wrappedValue = newValue + } + ) + } + + 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") From e11d6c2cbc047816d1e0303f64f89840316b1f2b Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 23:22:17 +0200 Subject: [PATCH 4/6] Fix accessibility and initial paint of service tool toggles Two Important findings from the Task 2 review, both in App/Views/ServicesSettingsView.swift: 1. `.accessibilityElement(children: .combine)` on each tool row merged the label and the interactive Toggle into a single non-actionable AX element, so VoiceOver users could not flip a tool's switch. Removed the combine modifier and gave both the per-tool and per-service Toggle real accessibility labels (tool title / service name) with `.labelsHidden()` to keep the visual layout unchanged while exposing a named, actionable control to assistive technology. 2. Freshly rendered/scrolled switch rows could transiently paint "off" while the persisted value was "on" (view identity was reused across rows with different underlying state). Per the controller's ruling, mitigated by keying each switch's view identity to its current value via `.id("\(name)-\(currentValue)")`, so a newly materialized row constructs its switch with the right state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --- App/Views/ServicesSettingsView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/App/Views/ServicesSettingsView.swift b/App/Views/ServicesSettingsView.swift index a2870e8f..c0862a6e 100644 --- a/App/Views/ServicesSettingsView.swift +++ b/App/Views/ServicesSettingsView.swift @@ -28,10 +28,11 @@ struct ServicesSettingsView: View { Spacer() - Toggle("", isOn: serviceBinding(config)) + Toggle(config.name, isOn: serviceBinding(config)) .toggleStyle(.switch) .controlSize(.small) .labelsHidden() + .id("\(config.id)-\(config.binding.wrappedValue)") } } } @@ -64,12 +65,12 @@ struct ServicesSettingsView: View { Spacer() - Toggle("", isOn: toolBinding(tool.name)) + Toggle(tool.annotations.title ?? tool.name, isOn: toolBinding(tool.name)) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() + .id("\(tool.name)-\(serverController.isToolEnabled(tool.name))") } - .accessibilityElement(children: .combine) } // Route writes through ServerController so views observing it refresh; From a309322f907f90cf0497f7908540c1b029926154 Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 23:26:51 +0200 Subject: [PATCH 5/6] List each service's tools in its menu row tooltip Hovering a service row now shows what enabling it exposes, using the tool annotation titles. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --- App/Views/ServiceToggleView.swift | 7 +++++++ 1 file changed, 7 insertions(+) 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: ", ") + } } From 060762541afcc7fb84778c0701ab36ea5ee7ca88 Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 23:51:25 +0200 Subject: [PATCH 6/6] Route Settings service toggles through activation and client notification The Settings service toggle (ServicesSettingsView.serviceBinding) wrote config.binding.wrappedValue directly, bypassing the activation flow that the menu path (ServiceToggleView) already performs. Enabling a service like Calendar from Settings never triggered the macOS permission prompt, and there was no revert-to-off if activation failed or was denied. It also never pushed the updated bindings to ServerNetworkManager, so connected MCP clients were not notified via tools/listChanged when a service was toggled from Settings (only the menu scene's ContentView .onChange did this). Add ServerController.setService(_:enabled:), which updates the binding, calls config.service.activate() when enabling an unactivated service (reverting on failure), and pushes currentServiceBindings to the network manager so notifyToolListChanged fires for connected clients. Route ServicesSettingsView.serviceBinding's setter through it, removing the now-redundant manual objectWillChange.send() from the view. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --- App/Controllers/ServerController.swift | 17 +++++++++++++++++ App/Views/ServicesSettingsView.swift | 9 +++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 98d06609..621c5692 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -269,6 +269,23 @@ final class ServerController: ObservableObject { 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 = "" diff --git a/App/Views/ServicesSettingsView.swift b/App/Views/ServicesSettingsView.swift index c0862a6e..2397b377 100644 --- a/App/Views/ServicesSettingsView.swift +++ b/App/Views/ServicesSettingsView.swift @@ -73,15 +73,12 @@ struct ServicesSettingsView: View { } } - // Route writes through ServerController so views observing it refresh; - // @AppStorage-backed bindings don't publish through ObservableObject alone. + // 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: { newValue in - serverController.objectWillChange.send() - config.binding.wrappedValue = newValue - } + set: { serverController.setService(config, enabled: $0) } ) }