diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 2ce546c24..0946b57f1 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -540,6 +540,40 @@ actor APIClient { return data } + // MARK: - Configuration (Spec 060) + + /// Fetch the full server configuration as a JSON dictionary. + /// GET /api/v1/config → { success, data: { config: {...} } }. + func getConfig() async throws -> [String: Any] { + let (data, response) = try await performRequest(path: "/api/v1/config", method: "GET") + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw APIClientError.httpError(statusCode: response.statusCode, message: "Malformed config response") + } + if let inner = root["data"] as? [String: Any], let cfg = inner["config"] as? [String: Any] { + return cfg + } + // Some builds may return the config object directly. + if let cfg = root["config"] as? [String: Any] { return cfg } + throw APIClientError.httpError(statusCode: response.statusCode, message: "Config not found in response") + } + + /// Apply a partial config update (only the changed fields) via the + /// deep-merge PATCH endpoint, so unrelated settings and redacted secrets are + /// never clobbered. Returns the apply-result dictionary (success, + /// applied_immediately, requires_restart, restart_reason, changed_fields, + /// validation_errors). + @discardableResult + func patchConfig(_ partial: [String: Any]) async throws -> [String: Any] { + let bodyData = try JSONSerialization.data(withJSONObject: partial) + let (data, response) = try await performRequest(path: "/api/v1/config", method: "PATCH", body: bodyData) + let root = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + if let success = root["success"] as? Bool, !success { + let msg = (root["error"] as? String) ?? "Failed to apply configuration" + throw APIClientError.httpError(statusCode: response.statusCode, message: msg) + } + return (root["data"] as? [String: Any]) ?? [:] + } + // MARK: - Private Helpers /// Fetch a resource wrapped in the standard `APIResponse` envelope. diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index eac784270..14013aed0 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -20,6 +20,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS private var statusItem: NSStatusItem! private var mainWindow: NSWindow? + private var settingsWindow: NSWindow? private var cancellables = Set() private var keyMonitor: Any? @@ -56,6 +57,12 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS NSLog("[MCPProxy] Cmd+N: show add server") self?.showAddServer() return nil + case ",": + // Intercept ⌘, before SwiftUI's Settings scene sees it, so it + // opens our config window instead of the (unreliable) scene. + NSLog("[MCPProxy] Cmd+,: show settings") + self?.showSettingsWindow() + return nil default: return event } @@ -64,6 +71,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS // Set up the app's main menu bar with View > Text Size commands setupMainMenu() + // The SwiftUI Settings scene window is owned by SwiftUI, not us, so it + // never hits our NSWindowDelegate. Observe all window closes so we can + // drop back to a menu-bar-only app when the Settings window is dismissed. + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: nil, queue: .main + ) { [weak self] _ in + DispatchQueue.main.async { self?.restoreAccessoryIfNoVisibleWindows() } + } + // Create the status bar item with the MCPProxy monochrome icon statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = statusItem.button { @@ -231,6 +247,55 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS showMainWindow() } + // Our single config window. Both the tray "Settings…" item and the app + // menu's "Settings…" / ⌘, route here (the latter via the key monitor + + // menu-item repoint in setupMainMenu) — never the SwiftUI Settings scene, + // whose programmatic opening proved unreliable from a menu-bar app. + @objc private func showSettingsWindow() { + // Reuse the existing window if it's already open. + if let window = settingsWindow, window.isVisible { + NSApp.setActivationPolicy(.regular) + setupMainMenu() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + // A menu-bar (.accessory) app can't make a window key without first + // becoming a regular app — same dance as showMainWindow(). + NSApp.setActivationPolicy(.regular) + + let hostingView = NSHostingView(rootView: SettingsView(appState: appState)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 580, height: 660), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "MCPProxy Settings" + window.contentView = hostingView + window.setFrameAutosaveName("MCPProxySettingsWindow") + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + setupMainMenu() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + settingsWindow = window + } + + /// Called from the SwiftUI Settings scene bridge: open the real config + /// window, then close the empty SwiftUI scene window SwiftUI just created. + func openSettingsFromScene() { + showSettingsWindow() + DispatchQueue.main.async { + NSApp.windows + .first { $0.identifier?.rawValue == "com_apple_SwiftUI_Settings_window" }? + .close() + } + } + @objc private func showAddServer() { showMainWindow() // First switch to the Servers tab so ServersView is mounted and @@ -253,10 +318,22 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS } } - // NSWindowDelegate — hide from Dock when window closes + // NSWindowDelegate — hide from Dock when the last managed window closes. func windowWillClose(_ notification: Notification) { - // Return to accessory (menu bar only) when main window closes - NSApp.setActivationPolicy(.accessory) + // Defer so the closing window has already left the visible set. + DispatchQueue.main.async { [weak self] in self?.restoreAccessoryIfNoVisibleWindows() } + } + + // Drop back to a menu-bar-only (.accessory) app once no real window remains. + // Covers both the AppKit main window (delegate) and the SwiftUI Settings + // scene window (which we don't own — handled via a global close observer). + private func restoreAccessoryIfNoVisibleWindows() { + let anyVisible = NSApp.windows.contains { win in + win.isVisible && win.styleMask.contains(.titled) && !(win is NSPanel) + } + if !anyVisible { + NSApp.setActivationPolicy(.accessory) + } } // MARK: - Main Menu Bar (View > Text Size) @@ -264,6 +341,18 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS private func setupMainMenu() { guard let mainMenu = NSApp.mainMenu else { return } + // Route a CLICK on the app-menu "Settings…" item to our config window + // (the ⌘, keyboard shortcut is intercepted separately in the key + // monitor). Both bypass the empty SwiftUI `Settings {}` scene. + if let appMenu = mainMenu.item(at: 0)?.submenu { + for item in appMenu.items where item.title.hasPrefix("Settings") || item.title.hasPrefix("Preferences") { + item.target = self + item.action = #selector(showSettingsWindow) + item.keyEquivalent = "," + item.keyEquivalentModifierMask = .command + } + } + // Find or create View menu and add text size items let viewMenu: NSMenu if let existingViewItem = mainMenu.item(withTitle: "View"), @@ -607,10 +696,14 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS addServer.target = self menu.addItem(addServer) - let openApp = NSMenuItem(title: "Open MCPProxy...", action: #selector(openMainWindow), keyEquivalent: ",") + let openApp = NSMenuItem(title: "Open MCPProxy...", action: #selector(openMainWindow), keyEquivalent: "") openApp.target = self menu.addItem(openApp) + let settingsItem = NSMenuItem(title: "Settings...", action: #selector(showSettingsWindow), keyEquivalent: ",") + settingsItem.target = self + menu.addItem(settingsItem) + let webUI = NSMenuItem(title: "Open Web UI", action: #selector(openWebUI), keyEquivalent: "") webUI.target = self menu.addItem(webUI) @@ -917,14 +1010,30 @@ struct MCPProxyApp: App { @NSApplicationDelegateAdaptor(AppController.self) var controller var body: some Scene { - // No SwiftUI scenes — the tray menu is pure AppKit (NSStatusItem + NSMenu). - // This avoids the MenuBarExtra .menu style bug where ForEach duplicates items. - // Settings scene intentionally hidden — Cmd+, is handled by tray menu "Open MCPProxy..." item. + // The tray menu is pure AppKit (NSStatusItem + NSMenu) — this avoids the + // MenuBarExtra .menu ForEach-duplication bug. There is ONE config window, + // the AppKit NSWindow in showSettingsWindow(). The tray "Settings…", the + // app-menu "Settings…" click, and ⌘, all route there. This SwiftUI + // Settings scene exists only to own the system "Settings…" slot; it is + // bridged away so it never actually shows. Settings { - Text("Use the MCPProxy tray menu to access settings.") - .frame(width: 300, height: 100) - .font(.body) - .foregroundColor(.secondary) + // Safety net only: ⌘, is intercepted by the key monitor and the + // app-menu "Settings…" click is repointed (both in AppController), + // so this scene normally never opens. If some path we didn't catch + // does open it, redirect to the real config window and dismiss this + // empty scene window — the user must never see a stub. + SettingsSceneBridge(controller: controller) } } } + +/// Empty stand-in for the SwiftUI Settings scene that immediately hands off to +/// the AppController's AppKit config window. See `body` above for why. +private struct SettingsSceneBridge: View { + let controller: AppController + var body: some View { + Color.clear + .frame(width: 1, height: 1) + .onAppear { controller.openSettingsFromScene() } + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift b/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift new file mode 100644 index 000000000..1a95ff124 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Settings/ConfigSettingsView.swift @@ -0,0 +1,474 @@ +// ConfigSettingsView.swift +// MCPProxy +// +// Native, full-fidelity config editor for the tray app — mirrors the web UI +// Configuration page (Security & Access / General / Advanced). The tray is a +// pure REST client: it loads via GET /api/v1/config and saves only the changed +// fields via PATCH /api/v1/config (deep-merge), so unrelated settings and +// redacted secrets are never clobbered and the JSON file is never touched +// directly (Constitution III). + +import SwiftUI +import AppKit + +// MARK: - Store + +@MainActor +final class ConfigStore: ObservableObject { + @Published var working: [String: Any] = [:] + @Published var loaded = false + @Published var loading = false + @Published var loadError: String? + /// Bumped on every mutation so SwiftUI re-evaluates dirty state. + @Published var revision = 0 + + private var original: [String: Any] = [:] + private let appState: AppState + + init(appState: AppState) { self.appState = appState } + + func load() async { + guard let api = appState.apiClient else { + loadError = "Not connected to the MCPProxy core." + return + } + loading = true + loadError = nil + do { + let cfg = try await api.getConfig() + working = cfg + original = cfg + loaded = true + revision += 1 + } catch { + loadError = (error as? APIClientError)?.errorDescription ?? error.localizedDescription + } + loading = false + } + + /// The core's current (saved) configuration, pretty-printed for the + /// read-only Raw tab. Reflects server truth — not unsaved form edits. + var prettyJSON: String { + guard JSONSerialization.isValidJSONObject(original), + let data = try? JSONSerialization.data( + withJSONObject: original, options: [.prettyPrinted, .sortedKeys]), + let str = String(data: data, encoding: .utf8) + else { return "{}" } + return str + } + + // MARK: value access + + func value(_ key: String) -> Any? { configGet(working, key) } + + func setValue(_ key: String, _ value: Any?) { + configSet(&working, key, value) + revision += 1 + } + + func isDirty(_ key: String) -> Bool { + !valuesEqual(configGet(working, key), configGet(original, key)) + } + + func dirtyKeys(in fields: [ConfigField]) -> [String] { + fields.map(\.key).filter { isDirty($0) } + } + + func revert(_ keys: [String]) { + for k in keys { configSet(&working, k, configGet(original, k)) } + revision += 1 + } + + /// Apply the given keys via PATCH. Returns (requiresRestart, restartReason) + /// on success; throws on failure (incl. validation errors). + func save(_ keys: [String]) async throws -> (requiresRestart: Bool, reason: String?) { + guard let api = appState.apiClient else { + throw APIClientError.httpError(statusCode: 0, message: "Not connected to the core.") + } + let partial = buildPartial(working, keys) + let result = try await api.patchConfig(partial) + if let errs = result["validation_errors"] as? [[String: Any]], !errs.isEmpty { + let msg = errs.compactMap { e -> String? in + let field = (e["field"] as? String) ?? "" + let m = (e["message"] as? String) ?? "" + return field.isEmpty ? m : "\(field): \(m)" + }.joined(separator: "; ") + throw APIClientError.httpError(statusCode: 422, message: msg.isEmpty ? "Validation failed" : msg) + } + // Commit saved keys into the snapshot so they're no longer dirty. + for k in keys { configSet(&original, k, configGet(working, k)) } + revision += 1 + let requiresRestart = (result["requires_restart"] as? Bool) ?? false + return (requiresRestart, result["restart_reason"] as? String) + } + + // MARK: typed bindings + + func boolBinding(_ key: String) -> Binding { + Binding( + get: { (self.value(key) as? NSNumber)?.boolValue ?? (self.value(key) as? Bool) ?? false }, + set: { self.setValue(key, $0) } + ) + } + + func stringBinding(_ key: String) -> Binding { + Binding( + get: { coerceString(self.value(key)) }, + set: { self.setValue(key, $0) } + ) + } + + func doubleBinding(_ key: String) -> Binding { + Binding( + get: { coerceDouble(self.value(key)) ?? 0 }, + set: { self.setValue(key, $0) } + ) + } + + func stringArrayContains(_ key: String, _ option: String) -> Bool { + (value(key) as? [Any])?.compactMap { $0 as? String }.contains(option) ?? false + } + + func toggleStringArray(_ key: String, _ option: String, _ on: Bool) { + var arr = (value(key) as? [Any])?.compactMap { $0 as? String } ?? [] + if on, !arr.contains(option) { arr.append(option) } + if !on { arr.removeAll { $0 == option } } + setValue(key, arr) + } +} + +// helpers usable from the view layer +func coerceString(_ v: Any?) -> String { + switch v { + case let s as String: return s + case let n as NSNumber: return n.stringValue + default: return "" + } +} +func coerceDouble(_ v: Any?) -> Double? { + switch v { + case let n as NSNumber: return n.doubleValue + case let d as Double: return d + case let i as Int: return Double(i) + case let s as String: return Double(s) + default: return nil + } +} +func valuesEqual(_ a: Any?, _ b: Any?) -> Bool { + if a == nil && b == nil { return true } + guard let a = a as? NSObject, let b = b as? NSObject else { return false } + return a.isEqual(b) +} + +// MARK: - Section view (one Save button per section) + +struct ConfigSectionView: View { + @ObservedObject var store: ConfigStore + let sectionId: String + let fields: [ConfigField] + + @State private var saving = false + @State private var savedNote: String? + @State private var errorNote: String? + @State private var showConfirm = false + @State private var confirmMessages: [String] = [] + @State private var confirmInfoOnly = false + + private var dirty: [String] { store.dirtyKeys(in: fields) } + private var invalid: Bool { + dirty.contains { key in + guard let f = fields.first(where: { $0.key == key }) else { return false } + return validateConfigField(f, store.value(key)) != nil + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(fields) { field in + ConfigFieldRow(store: store, field: field) + Divider() + } + + HStack { + Group { + if let savedNote { Text(savedNote).foregroundColor(.green) } + else if let errorNote { Text(errorNote).foregroundColor(.red) } + else if !dirty.isEmpty { Text("\(dirty.count) unsaved change\(dirty.count > 1 ? "s" : "")").foregroundColor(.secondary) } + } + .font(.callout) + Spacer() + if !dirty.isEmpty { + Button("Discard") { store.revert(dirty); savedNote = nil; errorNote = nil } + .buttonStyle(.borderless) + } + Button { + attemptSave() + } label: { + if saving { ProgressView().controlSize(.small) } else { Text("Save changes") } + } + .buttonStyle(.borderedProminent) + .disabled(dirty.isEmpty || saving || invalid) + } + .padding(.top, 10) + } + .alert(confirmInfoOnly ? "Are you sure?" : "Confirm sensitive change", + isPresented: $showConfirm) { + Button(confirmInfoOnly ? "Keep it on" : "Cancel", role: .cancel) {} + Button(confirmInfoOnly ? "Turn off anyway" : "Apply anyway", + role: confirmInfoOnly ? nil : .destructive) { Task { await doSave() } } + } message: { + Text(confirmMessages.joined(separator: "\n\n")) + } + } + + private func attemptSave() { + var msgs: [String] = [] + var infoOnly = true + for key in dirty { + guard let f = fields.first(where: { $0.key == key }), let dm = f.dangerMessage else { continue } + let val = store.value(key) + let triggers: Bool + if let cv = f.dangerConfirmValue { + triggers = ((val as? NSNumber)?.boolValue ?? (val as? Bool) ?? false) == cv + } else if f.key == "listen" { + triggers = !isLoopbackAddress(coerceString(val)) + } else { + triggers = true + } + if triggers { + msgs.append(dm) + if !f.dangerInfoTone { infoOnly = false } + } + } + if msgs.isEmpty { + Task { await doSave() } + } else { + confirmMessages = msgs + confirmInfoOnly = infoOnly + showConfirm = true + } + } + + private func doSave() async { + saving = true + savedNote = nil + errorNote = nil + let keys = dirty + do { + let r = try await store.save(keys) + savedNote = r.requiresRestart + ? "Saved — restart required\(r.reason.map { ": \($0)" } ?? "")" + : "Saved" + } catch { + errorNote = (error as? APIClientError)?.errorDescription ?? error.localizedDescription + } + saving = false + } +} + +private func isLoopbackAddress(_ s: String) -> Bool { + let host = s.split(separator: "@").last.map(String.init) ?? s + return host.hasPrefix("127.") || host.hasPrefix("localhost") || host.hasPrefix("[::1]") || host.hasPrefix("::1") +} + +// MARK: - Field row + +struct ConfigFieldRow: View { + @ObservedObject var store: ConfigStore + let field: ConfigField + @State private var revealSecret = false + + private var dirty: Bool { store.isDirty(field.key) } + private var validationError: String? { dirty ? validateConfigField(field, store.value(field.key)) : nil } + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(field.label).fontWeight(.medium) + if dirty { Circle().fill(Color.orange).frame(width: 6, height: 6) } + if field.restart { + Text("restart").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1) + .background(Color.orange.opacity(0.25)).cornerRadius(4) + } + if let docs = field.docs, let url = URL(string: SettingsCatalog.docsBase + docs) { + Link("docs ↗", destination: url).font(.caption) + } + } + if let help = field.help { + Text(help).font(.caption).foregroundColor(.secondary).fixedSize(horizontal: false, vertical: true) + } + if let err = validationError { + Text(err).font(.caption).foregroundColor(.red) + } + } + Spacer(minLength: 16) + control.frame(maxWidth: 240, alignment: .trailing) + } + .padding(.vertical, 8) + } + + @ViewBuilder private var control: some View { + switch field.control { + case .toggle: + Toggle("", isOn: store.boolBinding(field.key)).labelsHidden() + case .select: + Picker("", selection: store.stringBinding(field.key)) { + ForEach(field.options) { opt in Text(opt.label).tag(opt.value) } + } + .labelsHidden().frame(maxWidth: 220) + case .number: + TextField("", value: store.doubleBinding(field.key), format: .number) + .multilineTextAlignment(.trailing).frame(width: 100) + .textFieldStyle(.roundedBorder) + case .text, .duration: + TextField(field.control == .duration ? "2m" : "", text: store.stringBinding(field.key)) + .frame(width: 200).textFieldStyle(.roundedBorder).font(.system(.body, design: .monospaced)) + case .secret: + HStack(spacing: 4) { + if revealSecret { + TextField("", text: store.stringBinding(field.key)) + .frame(width: 170).textFieldStyle(.roundedBorder).font(.system(.body, design: .monospaced)) + } else { + SecureField("", text: store.stringBinding(field.key)) + .frame(width: 170).textFieldStyle(.roundedBorder) + } + Button { revealSecret.toggle() } label: { Image(systemName: revealSecret ? "eye.slash" : "eye") } + .buttonStyle(.borderless) + } + case .multiselect: + VStack(alignment: .trailing, spacing: 2) { + ForEach(field.options) { opt in + Toggle(opt.label, isOn: Binding( + get: { store.stringArrayContains(field.key, opt.value) }, + set: { store.toggleStringArray(field.key, opt.value, $0) } + )) + .toggleStyle(.checkbox).font(.caption) + } + } + } + } +} + +// MARK: - Tab containers (load once via the shared store) + +struct ConfigTabContainer: View { + @ObservedObject var store: ConfigStore + @ViewBuilder let content: () -> Content + + var body: some View { + Group { + if let err = store.loadError { + VStack(spacing: 8) { + Text("Couldn’t load configuration").font(.headline) + Text(err).font(.callout).foregroundColor(.secondary) + Button("Retry") { Task { await store.load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity).padding() + } else if !store.loaded { + ProgressView("Loading configuration…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { content().padding(20) } + } + } + .task { if !store.loaded { await store.load() } } + } +} + +struct SecuritySettingsTab: View { + @ObservedObject var store: ConfigStore + var body: some View { + ConfigTabContainer(store: store) { + ConfigSectionView(store: store, sectionId: "security", fields: SettingsCatalog.security) + } + } +} + +struct GeneralConfigTab: View { + @ObservedObject var store: ConfigStore + var body: some View { + ConfigTabContainer(store: store) { + ConfigSectionView(store: store, sectionId: "general", fields: SettingsCatalog.general) + } + } +} + +struct AdvancedSettingsTab: View { + @ObservedObject var store: ConfigStore + @State private var expanded: Set = [] + + var body: some View { + ConfigTabContainer(store: store) { + VStack(alignment: .leading, spacing: 8) { + ForEach(SettingsCatalog.advanced) { section in + let isOpen = expanded.contains(section.id) + VStack(alignment: .leading, spacing: 0) { + // Whole header row toggles the section (not just the chevron). + Button { + if isOpen { expanded.remove(section.id) } else { expanded.insert(section.id) } + } label: { + HStack(spacing: 8) { + Image(systemName: isOpen ? "chevron.down" : "chevron.right") + .font(.caption.weight(.bold)) + .foregroundColor(.secondary) + Text(section.title).fontWeight(.semibold) + Spacer() + } + .contentShape(Rectangle()) // entire row is the hit target + } + .buttonStyle(.plain) + + if isOpen { + ConfigSectionView(store: store, sectionId: section.id, fields: section.fields) + .padding(.top, 6) + } + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + } + } + } + } +} + +/// Read-only view of the effective configuration the core is running, sourced +/// over REST (GET /api/v1/config via ConfigStore). Editing happens only in the +/// other tabs — this tab never writes, mirroring the tray's REST-only contract. +struct RawConfigTab: View { + @ObservedObject var store: ConfigStore + @State private var copied = false + + var body: some View { + ConfigTabContainer(store: store) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Text("Read-only — the effective configuration the core is running. To change values, use the App, Security, General and Advanced tabs.") + .font(.callout) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(store.prettyJSON, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } + } label: { + Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") + } + .help("Copy the full configuration JSON") + } + + Text(store.prettyJSON) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(8) + } + } + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift b/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift new file mode 100644 index 000000000..ddffc38f5 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift @@ -0,0 +1,465 @@ +// Spec 061 — declarative settings field catalogue (Swift port of the Web UI's +// frontend/src/views/settings/fields.ts from Spec 060). +// +// Each entry maps a dot-path into the mcpproxy config to a labelled control. +// Keeping this declarative makes the tray Settings sheet auditable ("every +// non-deprecated option reachable") and mirrors the Web UI 1:1. + +import Foundation + +// MARK: - Pinned types (view layer depends on these — do not rename) + +enum ConfigControl { case toggle, select, number, text, secret, duration, multiselect } +enum ConfigValueKind { case hostport, bytesize, cpu, hostname, url, secretkey } + +struct ConfigOption: Identifiable { let value: String; let label: String; var id: String { value } } + +struct ConfigField: Identifiable { + let key: String // dot-path, e.g. "docker_isolation.enabled" + let label: String + var help: String? = nil + let control: ConfigControl + var options: [ConfigOption] = [] + var min: Double? = nil + var max: Double? = nil + var restart: Bool = false + var docs: String? = nil // doc path on docs.mcpproxy.app + var valueKind: ConfigValueKind? = nil + var optional: Bool = false + // Danger confirm: present a confirm dialog before saving. For toggles, + // only when the new value == dangerConfirmValue (nil = always confirm). + var dangerMessage: String? = nil + var dangerConfirmValue: Bool? = nil + var dangerInfoTone: Bool = false // gentle "are you sure" (telemetry), not red + var id: String { key } +} + +struct ConfigSection: Identifiable { + let id: String + let title: String + var help: String? = nil + var docs: String? = nil + let fields: [ConfigField] +} + +// MARK: - Catalogue + +enum SettingsCatalog { + static let docsBase = "https://docs.mcpproxy.app" + + /// Absolute docs URL for a field/section `docs` path, or nil. + static func docsURL(_ path: String?) -> String? { + guard let path = path, !path.isEmpty else { return nil } + return path.hasPrefix("http") ? path : docsBase + path + } + + // ---- Section 1: Security & Access (prioritised, security-first) ---- + static let security: [ConfigField] = [ + ConfigField( + key: "api_key", + label: "API key", + help: "Secret that authenticates clients to the REST API and Web UI (sent as the \"X-API-Key\" header or \"?apikey=\" in the URL). Leave blank to keep the current key; ↻ generates a new one. Changing it requires a restart and re-connecting clients.", + control: .secret, + restart: true, + valueKind: .secretkey, + optional: true + ), + ConfigField( + key: "require_mcp_auth", + label: "Require API key for MCP clients", + help: "When on, AI clients connecting to the /mcp endpoint must send the API key above (as an \"Authorization: Bearer \" header). Use \u{201C}Connect a client\u{201D} to configure this automatically. When off, any local client can connect without a key.", + control: .toggle + ), + ConfigField( + key: "quarantine_enabled", + label: "Quarantine new servers & changed tools", + help: "Holds newly added servers and tools whose description/schema changed for your approval before agents can call them — protects against Tool Poisoning Attacks. Recommended ON.", + control: .toggle, + docs: "/features/security-quarantine", + dangerMessage: "Disabling quarantine removes Tool Poisoning Attack protection — new servers and changed tools will run without your approval. Continue?", + dangerConfirmValue: false + ), + ConfigField( + key: "docker_isolation.enabled", + label: "Run stdio servers in Docker", + help: "Runs command-based (stdio) MCP servers inside isolated Docker containers instead of directly on your machine. Requires Docker to be installed and running.", + control: .toggle, + docs: "/features/docker-isolation" + ), + ConfigField( + key: "enable_code_execution", + label: "Enable code execution tool", + help: "Adds a sandboxed JavaScript tool agents can use to orchestrate several tool calls in one request. Off by default.", + control: .toggle, + docs: "/features/code-execution" + ), + ConfigField( + key: "read_only_mode", + label: "Read-only mode", + help: "Blocks every write and destructive tool call across all servers — agents can only read. Useful for safe exploration.", + control: .toggle + ), + ConfigField( + key: "sensitive_data_detection.enabled", + label: "Scan for secrets in tool traffic", + help: "Inspects tool arguments and responses for credentials (API keys, tokens, private keys, …) and flags them in the Activity log.", + control: .toggle, + docs: "/features/sensitive-data-detection" + ), + ConfigField( + key: "reveal_secret_headers", + label: "Show secret headers (debug)", + help: "Normally mcpproxy redacts Authorization / API-Key header values in responses and logs. Turning this on shows them in clear text — debugging only.", + control: .toggle, + dangerMessage: "Revealing secret headers will expose credentials in tool responses, logs and the Activity view. Only enable temporarily for debugging. Continue?", + dangerConfirmValue: true + ), + ConfigField( + key: "listen", + label: "Listen address", + help: "Where the server accepts connections. Keep 127.0.0.1:8080 for local-only use. To reach mcpproxy from other machines use 0.0.0.0:8080 — turn on \u{201C}Require API key for MCP clients\u{201D} first. Takes effect after restart.", + control: .text, + restart: true, + valueKind: .hostport, + dangerMessage: "Binding to a non-loopback address (e.g. 0.0.0.0) exposes mcpproxy to your network. Make sure \u{201C}Require API key for MCP clients\u{201D} is enabled. Continue?" + ), + ] + + // ---- Section 2: General ---- + static let general: [ConfigField] = [ + ConfigField( + key: "routing_mode", + label: "How agents find tools", + help: "Retrieve = agents search for tools first (best when you have many servers); Direct = every tool is listed to the agent; Code execution = agents call tools from JavaScript.", + control: .select, + options: [ + ConfigOption(value: "retrieve_tools", label: "Retrieve — search tools first (recommended)"), + ConfigOption(value: "direct", label: "Direct — list all tools"), + ConfigOption(value: "code_execution", label: "Code execution"), + ], + docs: "/features/routing-modes" + ), + ConfigField( + key: "tools_limit", + label: "Search results limit", + help: "How many tools a single tool-search returns to the agent.", + control: .number, + min: 1, + max: 1000 + ), + ConfigField( + key: "tool_response_limit", + label: "Max tool response size (characters)", + help: "Responses larger than this are truncated and cached so the agent can page through them. 0 = never truncate.", + control: .number, + min: 0 + ), + ConfigField( + key: "call_tool_timeout", + label: "Tool call timeout", + help: "How long to wait for a single tool call before giving up. e.g. 2m, 90s, 30s.", + control: .duration + ), + ConfigField( + key: "logging.level", + label: "Log verbosity", + help: "How much detail mcpproxy writes to its logs. Use debug/trace when troubleshooting.", + control: .select, + options: ["trace", "debug", "info", "warn", "error"].map { ConfigOption(value: $0, label: $0) } + ), + ConfigField( + key: "telemetry.enabled", + label: "Anonymous usage telemetry", + help: "Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time.", + control: .toggle, + docs: "/features/telemetry", + dangerMessage: "Anonymous telemetry is how we see which features matter and catch problems — it never includes your tool arguments, content, or any identifying info. Turning it off removes that signal. Turn it off anyway?", + dangerConfirmValue: false, + dangerInfoTone: true + ), + ConfigField( + key: "enable_prompts", + label: "Expose MCP prompts to clients", + help: "Advertises mcpproxy\u{2019}s built-in guided prompts to connected AI clients: \u{201C}setup-new-mcp-server\u{201D} (add a server) and \u{201C}troubleshoot-mcp-server\u{201D} (diagnose connection issues).", + control: .toggle + ), + ] + + // ---- Section 3: Advanced (subsystem accordions) ---- + static let advanced: [ConfigSection] = [ + ConfigSection( + id: "code-execution", + title: "Code execution", + help: "Limits for the sandboxed JavaScript tool (enable it in Security & Access).", + docs: "/features/code-execution", + fields: [ + ConfigField(key: "code_execution_timeout_ms", label: "Max run time per execution (ms)", control: .number, min: 1, max: 600000), + ConfigField(key: "code_execution_max_tool_calls", label: "Max tool calls per execution", help: "0 = unlimited.", control: .number, min: 0), + ConfigField(key: "code_execution_pool_size", label: "JavaScript runtime pool size", help: "How many sandboxes run concurrently.", control: .number, min: 1, max: 100), + ] + ), + ConfigSection( + id: "docker-isolation", + title: "Docker isolation", + help: "Defaults for containerised stdio servers (turn isolation on in Security & Access). The per-runtime image map and extra docker args are edited in the Raw JSON tab.", + docs: "/features/docker-isolation", + fields: [ + ConfigField(key: "docker_isolation.network_mode", label: "Container network", help: "none = no network (most secure), bridge = NAT, host = share host network.", control: .select, options: ["bridge", "none", "host"].map { ConfigOption(value: $0, label: $0) }), + ConfigField(key: "docker_isolation.memory_limit", label: "Memory limit per container", control: .text, valueKind: .bytesize, optional: true), + ConfigField(key: "docker_isolation.cpu_limit", label: "CPU limit per container", control: .text, valueKind: .cpu, optional: true), + ConfigField(key: "docker_isolation.registry", label: "Container image registry", control: .text, valueKind: .hostname, optional: true), + ConfigField(key: "docker_isolation.enable_cache_volume", label: "Share a package cache volume", help: "Speeds up repeated npm/uvx installs by caching across containers.", control: .toggle), + ] + ), + ConfigSection( + id: "sensitive-data", + title: "Secret detection", + help: "Tuning for the secret scanner (turn it on in Security & Access). Per-category toggles and custom patterns are edited in the Raw JSON tab.", + docs: "/features/sensitive-data-detection", + fields: [ + ConfigField(key: "sensitive_data_detection.scan_requests", label: "Scan tool arguments", control: .toggle), + ConfigField(key: "sensitive_data_detection.scan_responses", label: "Scan tool responses", control: .toggle), + ConfigField(key: "sensitive_data_detection.max_payload_size_kb", label: "Max payload scanned (KB)", help: "Larger payloads are scanned only up to this size.", control: .number, min: 1), + ConfigField(key: "sensitive_data_detection.entropy_threshold", label: "Randomness threshold", help: "Higher = fewer false positives when flagging random-looking strings (default 4.5).", control: .number, min: 0, max: 8), + ] + ), + ConfigSection( + id: "output-validation", + title: "Output-schema validation", + help: "Checks a tool\u{2019}s structured response against its declared schema before it reaches the agent (Spec 054 Track A).", + docs: "/features/output-schema-validation", + fields: [ + ConfigField(key: "output_validation.mode", label: "Validation mode", help: "off = no checks; warn = log mismatches; strict = block non-conforming responses.", control: .select, options: ["off", "warn", "strict"].map { ConfigOption(value: $0, label: $0) }), + ConfigField(key: "output_validation.missing_structured_content", label: "When structured content is missing", help: "Whether to allow or block a response that declares a schema but returns none.", control: .select, options: ["allow", "block"].map { ConfigOption(value: $0, label: $0) }), + ] + ), + ConfigSection( + id: "output-sanitisation", + title: "Output sanitisation", + help: "Contains untrusted tool output before it reaches the agent (Spec 054 Track B). All opt-in.", + docs: "/features/output-sanitisation", + fields: [ + ConfigField(key: "output_sanitisation.spotlight_untrusted", label: "Wrap untrusted output in markers", help: "Surrounds output from open-world tools with «untrusted» delimiters so the agent can tell data from instructions.", control: .toggle), + ConfigField(key: "output_sanitisation.response_action", label: "On detected secrets", help: "spotlight = only wrap; redact = mask secrets; block = drop the whole response on a critical detection.", control: .select, options: ["spotlight", "redact", "block"].map { ConfigOption(value: $0, label: $0) }), + ConfigField(key: "output_sanitisation.strip_control_chars", label: "Strip control characters", help: "Remove ANSI/zero-width/bidi sequences that can hide instructions in untrusted text.", control: .toggle), + ConfigField(key: "output_sanitisation.strip_classes", label: "Which characters to strip", control: .multiselect, options: ["ansi", "c0c1", "bidi", "zero_width"].map { ConfigOption(value: $0, label: $0) }), + ConfigField(key: "output_sanitisation.max_redactions", label: "Max redactions per response", control: .number, min: 0), + ] + ), + ConfigSection( + id: "activity", + title: "Activity log retention", + help: "How long the audit log of tool calls is kept.", + docs: "/features/activity-log", + fields: [ + ConfigField(key: "activity_retention_days", label: "Keep records for (days)", help: "0 = keep until the record cap is hit.", control: .number, min: 0), + ConfigField(key: "activity_max_records", label: "Maximum records kept", control: .number, min: 0), + ConfigField(key: "activity_cleanup_interval_min", label: "Cleanup runs every (minutes)", control: .number, min: 1), + ] + ), + ConfigSection( + id: "logging", + title: "Logging", + fields: [ + ConfigField(key: "logging.enable_file", label: "Write logs to a file", control: .toggle), + ConfigField(key: "logging.enable_console", label: "Write logs to the console", control: .toggle), + ConfigField(key: "logging.json_format", label: "Structured (JSON) log format", control: .toggle), + ConfigField(key: "logging.max_size", label: "Rotate log after (MB)", control: .number, min: 1), + ConfigField(key: "logging.max_backups", label: "Rotated files to keep", control: .number, min: 0), + ConfigField(key: "logging.max_age", label: "Delete rotated logs after (days)", control: .number, min: 0), + ] + ), + ConfigSection( + id: "tls", + title: "TLS / HTTPS", + help: "Serve the API and Web UI over HTTPS. Changes take effect after a restart.", + fields: [ + ConfigField(key: "tls.enabled", label: "Serve over HTTPS", control: .toggle, restart: true), + ConfigField(key: "tls.require_client_cert", label: "Require client certificate (mTLS)", control: .toggle, restart: true), + ConfigField(key: "tls.hsts", label: "Send HSTS header", help: "Tells browsers to always use HTTPS for this host.", control: .toggle, restart: true), + ] + ), + ConfigSection( + id: "misc", + title: "Other", + fields: [ + ConfigField(key: "max_result_size_chars", label: "Max inline response to the client (characters)", help: "Hard ceiling on a single response sent inline. 0 = no ceiling.", control: .number, min: 0), + ConfigField(key: "oauth_expiry_warning_hours", label: "Warn before OAuth token expires (hours)", help: "How early a server is shown as \u{201C}degraded\u{201D} before its OAuth token expires.", control: .number, min: 0), + ConfigField(key: "disable_management", label: "Block agents from managing servers", help: "Prevents agents from using the upstream_servers management tool.", control: .toggle, dangerMessage: "This prevents agents from adding or removing servers via the management tool. Continue?", dangerConfirmValue: true), + ConfigField(key: "allow_server_add", label: "Let agents add servers", control: .toggle), + ConfigField(key: "allow_server_remove", label: "Let agents remove servers", control: .toggle), + ConfigField(key: "debug_search", label: "Verbose tool-search debugging", control: .toggle), + ] + ), + ] +} + +// MARK: - Path helpers (operate on a JSON-decoded config dictionary) + +/// Dot-path lookup. Returns the nested value or nil if any segment is missing. +func configGet(_ obj: [String: Any], _ path: String) -> Any? { + let keys = path.split(separator: ".").map(String.init) + guard !keys.isEmpty else { return nil } + var current: Any = obj + for key in keys { + guard let dict = current as? [String: Any], let next = dict[key] else { return nil } + current = next + } + return current +} + +/// Dot-path set, creating intermediate dictionaries as needed. Empty path +/// components are skipped defensively. A nil value writes NSNull (explicit null) +/// — callers that want to drop a key should remove it instead. +func configSet(_ obj: inout [String: Any], _ path: String, _ value: Any?) { + let keys = path.split(separator: ".").map(String.init).filter { !$0.isEmpty } + guard !keys.isEmpty else { return } + + func assign(_ dict: inout [String: Any], _ keys: ArraySlice) { + let key = keys[keys.startIndex] + if keys.count == 1 { + dict[key] = value ?? NSNull() + return + } + var child = (dict[key] as? [String: Any]) ?? [:] + assign(&child, keys.dropFirst()) + dict[key] = child + } + assign(&obj, keys[...]) +} + +/// Assembles a nested object containing ONLY the given dot-path keys, read from +/// `source`. This is the partial payload sent to PATCH /config. +func buildPartial(_ source: [String: Any], _ keys: [String]) -> [String: Any] { + var out: [String: Any] = [:] + for key in keys { + if let value = configGet(source, key) { + configSet(&out, key, value) + } + } + return out +} + +// MARK: - Validation + +private let durationRegex = try! NSRegularExpression( + pattern: "^(\\d+(\\.\\d+)?(ns|us|µs|ms|s|m|h))+$" +) +private let portDigitsRegex = try! NSRegularExpression(pattern: "^\\d+$") +private let hostCharsRegex = try! NSRegularExpression(pattern: "^[A-Za-z0-9.\\-:]+$") +private let byteSizeRegex = try! NSRegularExpression(pattern: "^\\d+(\\.\\d+)?\\s*([bkmgtBKMGT]i?b?)?$") +private let cpuRegex = try! NSRegularExpression(pattern: "^\\d+(\\.\\d+)?$") +private let hostnameRegex = try! NSRegularExpression(pattern: "^[A-Za-z0-9.\\-]+(:\\d+)?(/[^\\s]*)?$") +private let urlRegex = try! NSRegularExpression(pattern: "^https?://[^\\s]+$") + +private func matches(_ regex: NSRegularExpression, _ s: String) -> Bool { + let range = NSRange(s.startIndex.. String? { + let host: String + let portStr: String + if s.hasPrefix("[") { + guard let closeIdx = s.firstIndex(of: "]") else { return "Unclosed \u{201C}[\u{201D} in IPv6 address" } + host = String(s[s.index(after: s.startIndex).. 65535 { return "Port must be between 1 and 65535" } + if !host.isEmpty && !matches(hostCharsRegex, host) { return "Invalid host in address" } + return nil +} + +/// Coerce a JSON-decoded value into a Double if numeric (Int, Double, NSNumber), +/// or by parsing a String. Returns nil when not numeric / empty. +private func numericValue(_ value: Any?) -> Double? { + guard let value = value else { return nil } + if let n = value as? NSNumber { return n.doubleValue } + if let d = value as? Double { return d } + if let i = value as? Int { return Double(i) } + if let s = value as? String { + let trimmed = s.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return nil } + return Double(trimmed) + } + return nil +} + +/// Render a JSON-decoded value as the trimmed string the validators expect. +private func stringValue(_ value: Any?) -> String { + guard let value = value else { return "" } + if let s = value as? String { return s.trimmingCharacters(in: .whitespaces) } + if value is NSNull { return "" } + if let n = value as? NSNumber { return n.stringValue.trimmingCharacters(in: .whitespaces) } + return String(describing: value).trimmingCharacters(in: .whitespaces) +} + +/// validateConfigField returns a human-readable error string for an invalid +/// value, or nil when the value is acceptable. Mirrors validateField in fields.ts. +func validateConfigField(_ field: ConfigField, _ value: Any?) -> String? { + if field.control == .number { + // Empty / null → "Enter a number" + if value == nil || value is NSNull { return "Enter a number" } + if let s = value as? String, s.trimmingCharacters(in: .whitespaces).isEmpty { + return "Enter a number" + } + guard let n = numericValue(value) else { return "Must be a number" } + if n.isNaN { return "Must be a number" } + if let lo = field.min, n < lo { return "Must be ≥ \(formatBound(lo))" } + if let hi = field.max, n > hi { return "Must be ≤ \(formatBound(hi))" } + return nil + } + + if field.control == .duration { + let s = stringValue(value) + if s.isEmpty { return "Enter a duration, e.g. 2m" } + if !matches(durationRegex, s) { return "Use a duration like 2m, 90s, or 1h30m" } + return nil + } + + if let kind = field.valueKind, field.control == .text || field.control == .secret { + let s = stringValue(value) + if s.isEmpty { return field.optional ? nil : "This field is required" } + switch kind { + case .hostport: + return validateHostPort(s) + case .bytesize: + // Docker-style size: 512m, 1g, 256k, 1073741824 (bytes), optional unit + return matches(byteSizeRegex, s) ? nil : "Use a size like 512m, 1g, or 256k" + case .cpu: + if matches(cpuRegex, s), let n = Double(s), n > 0 { return nil } + return "Use a positive number of CPUs, e.g. 1.0 or 0.5" + case .hostname: + return matches(hostnameRegex, s) ? nil : "Use a registry host like docker.io or ghcr.io" + case .url: + return matches(urlRegex, s) ? nil : "Use a URL starting with http:// or https://" + case .secretkey: + // A non-empty key must be reasonably strong. Empty is handled above + // (api_key uses optional:true → blank keeps the current key). + return s.count >= 16 ? nil : "API key must be at least 16 characters" + } + } + + // toggles / selects / multiselect have no value validation here. + return nil +} + +/// Format a numeric bound without a trailing ".0" for integer values, matching +/// how the TS template literal renders `${field.min}` / `${field.max}`. +private func formatBound(_ d: Double) -> String { + if d == d.rounded() && abs(d) < 1e15 { + return String(Int(d)) + } + return String(d) +} diff --git a/native/macos/MCPProxy/MCPProxy/Views/ConfigView.swift b/native/macos/MCPProxy/MCPProxy/Views/ConfigView.swift deleted file mode 100644 index e497fd2ab..000000000 --- a/native/macos/MCPProxy/MCPProxy/Views/ConfigView.swift +++ /dev/null @@ -1,267 +0,0 @@ -// ConfigView.swift -// MCPProxy -// -// Displays and allows editing the MCPProxy configuration file -// (~/.mcpproxy/mcp_config.json). Shows the raw JSON with basic -// syntax highlighting and provides save/revert functionality. -// -// Reads apiClient from appState instead of taking it as a parameter. - -import SwiftUI - -// MARK: - Config View - -struct ConfigView: View { - @ObservedObject var appState: AppState - @Environment(\.fontScale) var fontScale - - private var apiClient: APIClient? { appState.apiClient } - - @State private var configText = "" - @State private var originalText = "" - @State private var isLoading = false - @State private var isSaving = false - @State private var errorMessage: String? - @State private var successMessage: String? - @State private var isEditing = false - - private let configPath: URL = { - let home = FileManager.default.homeDirectoryForCurrentUser - return home.appendingPathComponent(".mcpproxy/mcp_config.json") - }() - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header - configHeader - - Divider() - - if let error = errorMessage { - errorBanner(error) - } - - if let success = successMessage { - successBanner(success) - } - - // Config content - if isLoading { - ProgressView("Loading configuration...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - configEditor - } - } - .task { loadConfig() } - } - - // MARK: - Header - - @ViewBuilder - private var configHeader: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Configuration") - .font(.scaled(.title2, scale: fontScale).bold()) - Text(configPath.path) - .font(.scaled(.caption, scale: fontScale)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Spacer() - - if hasChanges { - Text("Modified") - .font(.scaled(.caption, scale: fontScale)) - .foregroundStyle(.orange) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(.orange.opacity(0.1)) - .cornerRadius(4) - } - - Button { - loadConfig() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Reload from disk") - - Button("Open in Editor") { - NSWorkspace.shared.open(configPath) - } - .buttonStyle(.bordered) - .controlSize(.small) - - if isEditing { - Button("Revert") { - configText = originalText - clearMessages() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!hasChanges) - - Button("Save") { - saveConfig() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(!hasChanges || isSaving) - } else { - Button("Edit") { - isEditing = true - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - .padding() - } - - // MARK: - Editor - - @ViewBuilder - private var configEditor: some View { - if isEditing { - // Editable text editor - TextEditor(text: $configText) - .font(.scaledMonospaced(.body, scale: fontScale)) - .padding(8) - .accessibilityIdentifier("config-editor") - .onChange(of: configText) { _ in - clearMessages() - } - } else { - // Read-only scrollable view with line numbers - ScrollView([.horizontal, .vertical]) { - HStack(alignment: .top, spacing: 0) { - // Line numbers - let lines = configText.components(separatedBy: "\n") - VStack(alignment: .trailing, spacing: 0) { - ForEach(Array(lines.enumerated()), id: \.offset) { index, _ in - Text("\(index + 1)") - .font(.scaledMonospaced(.caption, scale: fontScale)) - .foregroundStyle(.tertiary) - .frame(minWidth: 30, alignment: .trailing) - } - } - .padding(.trailing, 8) - .padding(.leading, 8) - - Divider() - - // Config text - Text(configText) - .font(.scaledMonospaced(.body, scale: fontScale)) - .textSelection(.enabled) - .padding(.leading, 8) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical, 8) - } - .background(Color(nsColor: .textBackgroundColor)) - .accessibilityIdentifier("config-editor") - } - } - - // MARK: - Banners - - @ViewBuilder - private func errorBanner(_ message: String) -> some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text(message) - .font(.scaled(.caption, scale: fontScale)) - .foregroundStyle(.secondary) - Spacer() - Button("Dismiss") { errorMessage = nil } - .buttonStyle(.borderless) - .font(.scaled(.caption, scale: fontScale)) - } - .padding(.horizontal) - .padding(.vertical, 6) - .background(Color.red.opacity(0.1)) - } - - @ViewBuilder - private func successBanner(_ message: String) -> some View { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(message) - .font(.scaled(.caption, scale: fontScale)) - .foregroundStyle(.secondary) - Spacer() - Button("Dismiss") { successMessage = nil } - .buttonStyle(.borderless) - .font(.scaled(.caption, scale: fontScale)) - } - .padding(.horizontal) - .padding(.vertical, 6) - .background(Color.green.opacity(0.1)) - } - - // MARK: - Computed - - private var hasChanges: Bool { - configText != originalText - } - - // MARK: - File I/O - - private func loadConfig() { - isLoading = true - clearMessages() - defer { isLoading = false } - - do { - let data = try Data(contentsOf: configPath) - let text = String(data: data, encoding: .utf8) ?? "" - configText = text - originalText = text - } catch { - errorMessage = "Failed to load config: \(error.localizedDescription)" - configText = "" - originalText = "" - } - } - - private func saveConfig() { - clearMessages() - - // Validate JSON before saving - guard let data = configText.data(using: .utf8) else { - errorMessage = "Failed to encode text as UTF-8" - return - } - - do { - _ = try JSONSerialization.jsonObject(with: data) - } catch { - errorMessage = "Invalid JSON: \(error.localizedDescription)" - return - } - - isSaving = true - defer { isSaving = false } - - do { - try data.write(to: configPath, options: .atomic) - originalText = configText - successMessage = "Configuration saved. MCPProxy will auto-reload." - isEditing = false - } catch { - errorMessage = "Failed to save: \(error.localizedDescription)" - } - } - - private func clearMessages() { - errorMessage = nil - successMessage = nil - } -} diff --git a/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift b/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift index 80db7039f..6f0652d9d 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift @@ -8,7 +8,6 @@ enum SidebarItem: String, CaseIterable, Identifiable { case servers = "Servers" case activity = "Activity Log" case secrets = "Secrets" - case config = "Configuration" var id: String { rawValue } @@ -18,7 +17,6 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .servers: return "server.rack" case .activity: return "clock.arrow.circlepath" case .secrets: return "key.fill" - case .config: return "gearshape" } } @@ -65,8 +63,6 @@ struct MainWindow: View { ActivityView(appState: appState) case .secrets: SecretsView(appState: appState) - case .config: - ConfigView(appState: appState) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/native/macos/MCPProxy/MCPProxy/Views/SettingsView.swift b/native/macos/MCPProxy/MCPProxy/Views/SettingsView.swift new file mode 100644 index 000000000..89e35a5cf --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Views/SettingsView.swift @@ -0,0 +1,108 @@ +// SettingsView.swift +// MCPProxy +// +// Native Settings window. The tray is a full alternative client to the core: +// every backend setting is edited here over REST (GET/PATCH /api/v1/config), +// mirroring the web UI Configuration page — the config JSON file is never read +// or written directly. The "App" tab holds the few OS-level prefs that are +// genuinely the app's own concern (launch-at-login, interface size). + +import SwiftUI +import ServiceManagement + +struct SettingsView: View { + @ObservedObject var appState: AppState + @StateObject private var store: ConfigStore + @State private var tab = 0 + + init(appState: AppState) { + self.appState = appState + _store = StateObject(wrappedValue: ConfigStore(appState: appState)) + } + + var body: some View { + TabView(selection: $tab) { + AppPrefsTab(appState: appState) + .tabItem { Label("App", systemImage: "macwindow") }.tag(0) + + SecuritySettingsTab(store: store) + .tabItem { Label("Security", systemImage: "lock.shield") }.tag(1) + + GeneralConfigTab(store: store) + .tabItem { Label("General", systemImage: "gearshape") }.tag(2) + + AdvancedSettingsTab(store: store) + .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }.tag(3) + + RawConfigTab(store: store) + .tabItem { Label("Raw", systemImage: "curlybraces") }.tag(4) + } + .frame(minWidth: 540, minHeight: 560) + // ⌘1–⌘5 switch tabs (handy, and lets UI tests navigate). + .background { + ForEach(0..<5, id: \.self) { i in + Button("") { tab = i } + .keyboardShortcut(KeyEquivalent(Character(String(i + 1))), modifiers: .command) + .opacity(0) + } + } + } +} + +// MARK: - App preferences (OS-level, app-owned) + +private struct AppPrefsTab: View { + @ObservedObject var appState: AppState + @State private var launchAtLogin = AutoStartService.isEnabled + @State private var launchError: String? + + var body: some View { + Form { + Section { + Toggle("Launch MCPProxy at login", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { applyLaunchAtLogin($0) } + if let launchError { + Text(launchError).font(.callout).foregroundColor(.red) + } + } header: { Text("Startup") } + + Section { + HStack { + Text("Interface text size") + Spacer() + Stepper(value: $appState.fontScale, in: 0.8...1.6, step: 0.1) { + Text("\(Int(appState.fontScale * 100))%").monospacedDigit().frame(width: 48, alignment: .trailing) + } + } + } header: { Text("Appearance") } + + Section { + LabeledContent("Version", value: appState.version) + LabeledContent("Core") { + HStack(spacing: 6) { + Circle().fill(appState.isConnected ? .green : .secondary).frame(width: 8, height: 8) + Text(appState.isConnected ? "Connected" : "Not connected") + } + } + } header: { Text("About") } footer: { + Text("All server configuration is managed in the Security, General and Advanced tabs.") + .font(.callout).foregroundColor(.secondary) + } + } + .formStyle(.grouped) + .padding(.vertical, 8) + .onAppear { launchAtLogin = AutoStartService.isEnabled } + } + + private func applyLaunchAtLogin(_ enabled: Bool) { + do { + if enabled { try AutoStartService.enable() } else { try AutoStartService.disable() } + launchError = nil + appState.autoStartEnabled = AutoStartService.isEnabled + AutostartSidecarService.refresh() + } catch { + launchAtLogin = AutoStartService.isEnabled + launchError = error.localizedDescription + } + } +} diff --git a/native/macos/MCPProxyUITest/.gitignore b/native/macos/MCPProxyUITest/.gitignore index 577404093..927b01294 100644 --- a/native/macos/MCPProxyUITest/.gitignore +++ b/native/macos/MCPProxyUITest/.gitignore @@ -1,4 +1,5 @@ .build/ +.bin/ *.swiftmodule *.pcm modules.timestamp diff --git a/native/macos/MCPProxyUITest/run.sh b/native/macos/MCPProxyUITest/run.sh new file mode 100755 index 000000000..6e46a0900 --- /dev/null +++ b/native/macos/MCPProxyUITest/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Launcher for the mcpproxy-ui-test MCP server. +# Builds the binary from source (kept in this project dir) if it's missing or +# stale, then execs it — so the MCP server self-heals after a clean checkout or +# a /tmp wipe instead of failing with ENOENT. Build output goes to stderr so it +# never corrupts the stdout JSON-RPC stream. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$DIR/Sources/main.swift" +BIN="$DIR/.bin/mcpproxy-ui-test" + +mkdir -p "$DIR/.bin" +if [[ ! -x "$BIN" || "$SRC" -nt "$BIN" ]]; then + echo "[run.sh] building mcpproxy-ui-test from source…" >&2 + SDK="$(xcrun --sdk macosx --show-sdk-path)" + # Keep swiftc's module cache under .build/ (gitignored) so it never litters + # the source dir with *.swiftmodule / hash-named cache directories. + swiftc -target arm64-apple-macosx13.0 -sdk "$SDK" -O \ + -module-cache-path "$DIR/.build/ModuleCache" \ + -o "$BIN" "$SRC" >&2 +fi + +exec "$BIN" "$@"