Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions App/Controllers/ServerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -237,6 +241,51 @@ final class ServerController: ObservableObject {
trustedClients = Set<String>()
}

// MARK: - Disabled Tools Management
var disabledTools: Set<String> {
get {
(try? JSONDecoder().decode(Set<String>.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 = ""
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -636,6 +686,8 @@ actor ServerNetworkManager {

private let services = ServiceRegistry.services
private var serviceBindings: [String: Binding<Bool>] = [:]
private var disabledTools: Set<String> = []
private var disabledToolsLastGeneration = -1

init() {
do {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -1028,4 +1098,20 @@ actor ServerNetworkManager {
}
}
}

// Update the disabled tool set, discarding out-of-order deliveries.
func updateDisabledTools(_ newDisabledTools: Set<String>, 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()
}
}
}
}
7 changes: 7 additions & 0 deletions App/Views/ServiceToggleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: ", ")
}
}
91 changes: 91 additions & 0 deletions App/Views/ServicesSettingsView.swift
Original file line number Diff line number Diff line change
@@ -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<Bool> {
Binding(
get: { config.binding.wrappedValue },
set: { serverController.setService(config, enabled: $0) }
)
}

private func toolBinding(_ name: String) -> Binding<Bool> {
Binding(
get: { serverController.isToolEnabled(name) },
set: { serverController.setTool(name, enabled: $0) }
)
}
}
5 changes: 5 additions & 0 deletions App/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down Expand Up @@ -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")
Expand Down