import SwiftUI import Persist import GitHubCopilotService import Client import Logger import Foundation /// Section for a single server's tools struct MCPServerToolsSection: View { let serverTools: MCPServerToolsCollection @Binding var isServerEnabled: Bool var forceExpand: Bool = false @State private var toolEnabledStates: [String: Bool] = [:] @State private var isExpanded: Bool = true private var originalServerName: String { serverTools.name } private var serverToggleLabel: some View { HStack(spacing: 8) { Text("MCP Server: \(serverTools.name)") .fontWeight(.medium) .foregroundStyle( serverTools.status == .running ? .primary : .tertiary ) if serverTools.status == .error || serverTools.status == .blocked { let message = extractErrorMessage(serverTools.error?.description ?? "") if serverTools.status == .error { Badge( attributedText: createErrorMessage(message), level: .danger, icon: "xmark.circle.fill" ) .environment((\.openURL), OpenURLAction { url in if url.absoluteString == "mcp://open-config" { openMCPConfigFile() return .handled } return .systemAction }) } else if serverTools.status == .blocked { Badge(text: serverTools.registryInfo ?? "Blocked", level: .warning, icon: "exclamationmark.triangle.fill") } } else if let registryInfo = serverTools.registryInfo { Text(registryInfo) .foregroundStyle(.secondary) .font(.system(size: 11)) } Spacer() } } private func openMCPConfigFile() { let url = URL(fileURLWithPath: mcpConfigFilePath) NSWorkspace.shared.open(url) } private func createErrorMessage(_ baseMessage: String) -> AttributedString { if hasServerConfigPlaceholders() { var attributedString = AttributedString(baseMessage) attributedString.append(AttributedString(". You may need to update placeholders in ")) var mcpLink = AttributedString("mcp.json") mcpLink.link = URL(string: "mcp://open-config") mcpLink.underlineStyle = .single attributedString.append(mcpLink) attributedString.append(AttributedString(".")) return attributedString } else { return AttributedString(baseMessage) } } private var serverToggle: some View { Toggle(isOn: Binding( get: { isServerEnabled }, set: { updateAllToolsStatus(enabled: $0) } )) { serverToggleLabel } .toggleStyle(.checkbox) .padding(.leading, 4) .disabled(serverTools.status == .error || serverTools.status == .blocked) } private var divider: some View { Divider() .padding(.leading, 36) .padding(.top, 2) .padding(.bottom, 4) } private var toolsList: some View { VStack(spacing: 0) { divider ForEach(serverTools.tools, id: \.name) { tool in ToolRow( toolName: tool.name, toolDescription: tool.description, toolStatus: tool._status, isServerEnabled: isServerEnabled, isToolEnabled: toolBindingFor(tool), onToolToggleChanged: { handleToolToggleChange(tool: tool, isEnabled: $0) } ) .padding(.leading, 36) } } .onChange(of: serverTools) { newValue in initializeToolStates(server: newValue) } } var body: some View { VStack(alignment: .leading, spacing: 0) { // Conditional view rendering based on error state if serverTools.status == .error || serverTools.status == .blocked { // No disclosure group for error state VStack(spacing: 0) { serverToggle.padding(.leading, 12) divider.padding(.top, 4) } } else { // Regular DisclosureGroup for non-error state DisclosureGroup(isExpanded: $isExpanded) { toolsList } label: { serverToggle } .onAppear { initializeToolStates(server: serverTools) if forceExpand { isExpanded = true } } .onChange(of: forceExpand) { newForceExpand in if newForceExpand { isExpanded = true } } if !isExpanded { divider } } } } private func extractErrorMessage(_ description: String) -> String { guard let messageRange = description.range(of: "message:"), let stackRange = description.range(of: "stack:") else { return description } let start = description.index(messageRange.upperBound, offsetBy: 0) let end = description.index(stackRange.lowerBound, offsetBy: 0) return description[start.. Bool { let configFileURL = URL(fileURLWithPath: mcpConfigFilePath) guard FileManager.default.fileExists(atPath: mcpConfigFilePath), let data = try? Data(contentsOf: configFileURL), let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let servers = jsonObject["servers"] as? [String: Any], let serverConfig = servers[serverTools.name] else { return false } // Convert server config to JSON string guard let serverData = try? JSONSerialization.data(withJSONObject: serverConfig, options: []), let serverConfigString = String(data: serverData, encoding: .utf8) else { return false } // Check for placeholder patterns ending with }" // Matches: "{PLACEHOLDER}", "${PLACEHOLDER}", "key={PLACEHOLDER}", "key=${PLACEHOLDER}", "${prefix:PLACEHOLDER}" let placeholderPattern = "\"([a-zA-Z0-9_]+=)?\\$?\\{[a-zA-Z0-9_:\\-\\.]+\\}\"" guard let regex = try? NSRegularExpression(pattern: placeholderPattern, options: []) else { return false } let range = NSRange(serverConfigString.startIndex.. Binding { Binding( get: { toolEnabledStates[tool.name] ?? (tool._status == .enabled) }, set: { toolEnabledStates[tool.name] = $0 } ) } private func handleToolToggleChange(tool: MCPTool, isEnabled: Bool) { toolEnabledStates[tool.name] = isEnabled // Update server state based on tool states updateServerState() // Update only this specific tool status updateToolStatus(tool: tool, isEnabled: isEnabled) } private func updateServerState() { // If any tool is enabled, server should be enabled // If all tools are disabled, server should be disabled let allToolsDisabled = serverTools.tools.allSatisfy { tool in !(toolEnabledStates[tool.name] ?? (tool._status == .enabled)) } isServerEnabled = !allToolsDisabled } private func updateToolStatus(tool: MCPTool, isEnabled: Bool) { let serverUpdate = UpdateMCPToolsStatusServerCollection( name: serverTools.name, tools: [UpdatedMCPToolsStatus(name: tool.name, status: isEnabled ? .enabled : .disabled)] ) updateMCPStatus([serverUpdate]) } private func updateAllToolsStatus(enabled: Bool) { isServerEnabled = enabled // Get all tools for this server from the original collection let allServerTools = CopilotMCPToolManagerObservable.shared.availableMCPServerTools .first(where: { $0.name == originalServerName })?.tools ?? serverTools.tools // Update all tool states - includes both visible and filtered-out tools for tool in allServerTools { toolEnabledStates[tool.name] = enabled } // Create status update for all tools let serverUpdate = UpdateMCPToolsStatusServerCollection( name: serverTools.name, tools: allServerTools.map { UpdatedMCPToolsStatus(name: $0.name, status: enabled ? .enabled : .disabled) } ) updateMCPStatus([serverUpdate]) } private func updateMCPStatus(_ serverUpdates: [UpdateMCPToolsStatusServerCollection]) { // Update status in AppState and CopilotMCPToolManager AppState.shared.updateMCPToolsStatus(serverUpdates) Task { do { let service = try getService() try await service.updateMCPServerToolsStatus(serverUpdates) } catch { Logger.client.error("Failed to update MCP status: \(error.localizedDescription)") } } } }