diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 93cac0a..0e7cfd9 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -32,6 +32,8 @@ struct ContentView: View { SkillsView() case .tools: ToolsView() + case .mcpServers: + MCPServersView() case .gateway: GatewayView() case .cron: diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift index 5b74ab4..f43a81c 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -22,6 +22,7 @@ enum HermesPaths: Sendable { nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes" nonisolated static let scarfDir: String = home + "/scarf" nonisolated static let projectsRegistry: String = scarfDir + "/projects.json" + nonisolated static let mcpTokensDir: String = home + "/mcp-tokens" } // MARK: - SQLite Constants diff --git a/scarf/scarf/Core/Models/HermesMCPServer.swift b/scarf/scarf/Core/Models/HermesMCPServer.swift new file mode 100644 index 0000000..4f43ac9 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesMCPServer.swift @@ -0,0 +1,54 @@ +import Foundation + +enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable { + case stdio + case http + + var id: String { rawValue } + + var displayName: String { + switch self { + case .stdio: return "Local (stdio)" + case .http: return "Remote (HTTP)" + } + } +} + +struct HermesMCPServer: Identifiable, Sendable, Equatable { + let name: String + let transport: MCPTransport + let command: String? + let args: [String] + let url: String? + let auth: String? + let env: [String: String] + let headers: [String: String] + let timeout: Int? + let connectTimeout: Int? + let enabled: Bool + let toolsInclude: [String] + let toolsExclude: [String] + let resourcesEnabled: Bool + let promptsEnabled: Bool + let hasOAuthToken: Bool + + var id: String { name } + + var summary: String { + switch transport { + case .stdio: + let argString = args.isEmpty ? "" : " " + args.joined(separator: " ") + return (command ?? "") + argString + case .http: + return url ?? "" + } + } +} + +struct MCPTestResult: Sendable, Equatable { + let serverName: String + let succeeded: Bool + let output: String + let tools: [String] + let elapsed: TimeInterval +} diff --git a/scarf/scarf/Core/Models/MCPServerPreset.swift b/scarf/scarf/Core/Models/MCPServerPreset.swift new file mode 100644 index 0000000..5e15b14 --- /dev/null +++ b/scarf/scarf/Core/Models/MCPServerPreset.swift @@ -0,0 +1,174 @@ +import Foundation + +struct MCPServerPreset: Identifiable, Sendable, Equatable { + let id: String + let displayName: String + let description: String + let category: String + let iconSystemName: String + let transport: MCPTransport + let command: String? + let args: [String] + let url: String? + let auth: String? + let requiredEnvKeys: [String] + let optionalEnvKeys: [String] + let pathArgPrompt: String? + let docsURL: String + + static let gallery: [MCPServerPreset] = [ + MCPServerPreset( + id: "filesystem", + displayName: "Filesystem", + description: "Read and write files under a root directory you choose.", + category: "Built-in", + iconSystemName: "folder", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + url: nil, + auth: nil, + requiredEnvKeys: [], + optionalEnvKeys: [], + pathArgPrompt: "Root directory (absolute path)", + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem" + ), + MCPServerPreset( + id: "github", + displayName: "GitHub", + description: "Issues, pull requests, code search, and file operations via GitHub API.", + category: "Dev", + iconSystemName: "chevron.left.forwardslash.chevron.right", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + url: nil, + auth: nil, + requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"], + optionalEnvKeys: [], + pathArgPrompt: nil, + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github" + ), + MCPServerPreset( + id: "postgres", + displayName: "Postgres", + description: "Read-only SQL access against a Postgres database.", + category: "Data", + iconSystemName: "cylinder.split.1x2", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-postgres"], + url: nil, + auth: nil, + requiredEnvKeys: [], + optionalEnvKeys: [], + pathArgPrompt: "Connection URL (postgres://user:pass@host/db)", + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres" + ), + MCPServerPreset( + id: "slack", + displayName: "Slack", + description: "Read channels, post messages, and search your Slack workspace.", + category: "Productivity", + iconSystemName: "bubble.left.and.bubble.right", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-slack"], + url: nil, + auth: nil, + requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"], + optionalEnvKeys: [], + pathArgPrompt: nil, + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack" + ), + MCPServerPreset( + id: "linear", + displayName: "Linear", + description: "Query and update Linear issues. Uses OAuth — no token needed.", + category: "Productivity", + iconSystemName: "list.bullet.rectangle", + transport: .http, + command: nil, + args: [], + url: "https://mcp.linear.app/sse", + auth: "oauth", + requiredEnvKeys: [], + optionalEnvKeys: [], + pathArgPrompt: nil, + docsURL: "https://linear.app/docs/mcp" + ), + MCPServerPreset( + id: "sentry", + displayName: "Sentry", + description: "Investigate errors and performance issues from Sentry.", + category: "Dev", + iconSystemName: "exclamationmark.triangle", + transport: .stdio, + command: "npx", + args: ["-y", "@sentry/mcp-server"], + url: nil, + auth: nil, + requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"], + optionalEnvKeys: [], + pathArgPrompt: nil, + docsURL: "https://docs.sentry.io/product/mcp/" + ), + MCPServerPreset( + id: "puppeteer", + displayName: "Puppeteer", + description: "Headless browser automation — navigate pages, click, screenshot.", + category: "Automation", + iconSystemName: "safari", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-puppeteer"], + url: nil, + auth: nil, + requiredEnvKeys: [], + optionalEnvKeys: [], + pathArgPrompt: nil, + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer" + ), + MCPServerPreset( + id: "memory", + displayName: "Memory (Knowledge Graph)", + description: "Persistent knowledge graph of entities and relations across sessions.", + category: "Built-in", + iconSystemName: "brain", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + url: nil, + auth: nil, + requiredEnvKeys: [], + optionalEnvKeys: ["MEMORY_FILE_PATH"], + pathArgPrompt: nil, + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory" + ), + MCPServerPreset( + id: "fetch", + displayName: "Fetch", + description: "Retrieve and convert web pages to markdown.", + category: "Built-in", + iconSystemName: "arrow.down.circle", + transport: .stdio, + command: "npx", + args: ["-y", "@modelcontextprotocol/server-fetch"], + url: nil, + auth: nil, + requiredEnvKeys: [], + optionalEnvKeys: [], + pathArgPrompt: nil, + docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch" + ) + ] + + static var categories: [String] { + var seen = Set() + return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil } + } + + static func byCategory(_ category: String) -> [MCPServerPreset] { + gallery.filter { $0.category == category } + } +} diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index d064259..baa1af2 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -246,6 +246,605 @@ struct HermesFileService: Sendable { return result } + // MARK: - MCP Servers + + func loadMCPServers() -> [HermesMCPServer] { + guard let yaml = readFile(HermesPaths.configYAML) else { return [] } + let parsed = parseMCPServersBlock(yaml: yaml) + let fm = FileManager.default + return parsed.map { server in + let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json" + let hasToken = fm.fileExists(atPath: tokenPath) + guard hasToken != server.hasOAuthToken else { return server } + return HermesMCPServer( + name: server.name, + transport: server.transport, + command: server.command, + args: server.args, + url: server.url, + auth: server.auth, + env: server.env, + headers: server.headers, + timeout: server.timeout, + connectTimeout: server.connectTimeout, + enabled: server.enabled, + toolsInclude: server.toolsInclude, + toolsExclude: server.toolsExclude, + resourcesEnabled: server.resourcesEnabled, + promptsEnabled: server.promptsEnabled, + hasOAuthToken: hasToken + ) + } + } + + /// Creates the server entry via `hermes mcp add` with only the command (no args). + /// Args are written separately via `setMCPServerArgs` to avoid argparse issues with `-`-prefixed args like `-y`. + /// Pipes `y\n` because the CLI prompts to save even when the initial connection check fails (which it will, since we intentionally add no args first). + @discardableResult + func addMCPServerStdio(name: String, command: String, args: [String]) -> (exitCode: Int32, output: String) { + let addResult = runHermesCLI( + args: ["mcp", "add", name, "--command", command], + timeout: 45, + stdinInput: "y\ny\ny\n" + ) + guard addResult.exitCode == 0 else { return addResult } + if !args.isEmpty { + _ = setMCPServerArgs(name: name, args: args) + } + return addResult + } + + @discardableResult + func addMCPServerHTTP(name: String, url: String, auth: String?) -> (exitCode: Int32, output: String) { + var cliArgs: [String] = ["mcp", "add", name, "--url", url] + if let auth, !auth.isEmpty { + cliArgs.append(contentsOf: ["--auth", auth]) + } + return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n") + } + + @discardableResult + func setMCPServerArgs(name: String, args: [String]) -> Bool { + patchMCPServerField(name: name) { entryLines in + Self.replaceOrInsertList(header: "args", items: args, in: &entryLines) + } + } + + @discardableResult + func removeMCPServer(name: String) -> (exitCode: Int32, output: String) { + runHermesCLI(args: ["mcp", "remove", name], timeout: 30) + } + + nonisolated func testMCPServer(name: String) async -> MCPTestResult { + let started = Date() + let service = self + let result = await Task.detached { () -> (Int32, String) in + service.runHermesCLI(args: ["mcp", "test", name], timeout: 30) + }.value + let elapsed = Date().timeIntervalSince(started) + let tools = Self.parseToolListFromTestOutput(result.1) + return MCPTestResult( + serverName: name, + succeeded: result.0 == 0, + output: result.1, + tools: tools, + elapsed: elapsed + ) + } + + private static func parseToolListFromTestOutput(_ output: String) -> [String] { + var tools: [String] = [] + for rawLine in output.components(separatedBy: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("- ") || line.hasPrefix("* ") else { continue } + let candidate = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces) + // Take only the identifier before any separator (":" or whitespace). + let token = candidate.split(whereSeparator: { ":(".contains($0) || $0.isWhitespace }).first.map(String.init) ?? candidate + if !token.isEmpty, token.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) { + tools.append(token) + } + } + return tools + } + + @discardableResult + func toggleMCPServerEnabled(name: String, enabled: Bool) -> Bool { + patchMCPServerField(name: name) { entryLines in + Self.replaceOrInsertScalar(key: "enabled", value: enabled ? "true" : "false", in: &entryLines) + } + } + + @discardableResult + func setMCPServerEnv(name: String, env: [String: String]) -> Bool { + patchMCPServerField(name: name) { entryLines in + Self.replaceOrInsertSubMap(header: "env", map: env, in: &entryLines) + } + } + + @discardableResult + func setMCPServerHeaders(name: String, headers: [String: String]) -> Bool { + patchMCPServerField(name: name) { entryLines in + Self.replaceOrInsertSubMap(header: "headers", map: headers, in: &entryLines) + } + } + + @discardableResult + func updateMCPToolFilters(name: String, include: [String], exclude: [String], resources: Bool, prompts: Bool) -> Bool { + patchMCPServerField(name: name) { entryLines in + Self.replaceOrInsertToolsBlock(include: include, exclude: exclude, resources: resources, prompts: prompts, in: &entryLines) + } + } + + @discardableResult + func setMCPServerTimeouts(name: String, timeout: Int?, connectTimeout: Int?) -> Bool { + patchMCPServerField(name: name) { entryLines in + if let timeout { + Self.replaceOrInsertScalar(key: "timeout", value: String(timeout), in: &entryLines) + } else { + Self.removeScalar(key: "timeout", in: &entryLines) + } + if let connectTimeout { + Self.replaceOrInsertScalar(key: "connect_timeout", value: String(connectTimeout), in: &entryLines) + } else { + Self.removeScalar(key: "connect_timeout", in: &entryLines) + } + } + } + + @discardableResult + func deleteMCPOAuthToken(name: String) -> Bool { + let path = HermesPaths.mcpTokensDir + "/" + name + ".json" + do { + try FileManager.default.removeItem(atPath: path) + return true + } catch { + return false + } + } + + @discardableResult + func restartGateway() -> (exitCode: Int32, output: String) { + runHermesCLI(args: ["gateway", "restart"], timeout: 30) + } + + // MARK: - MCP YAML: block extractor + parser + + private struct MCPBlockLocation { + let prefix: [String] + let block: [String] // includes the "mcp_servers:" header line + let suffix: [String] + } + + private func extractMCPBlock(yaml: String) -> MCPBlockLocation { + let lines = yaml.components(separatedBy: "\n") + var blockStart = -1 + var blockEnd = lines.count + for (index, line) in lines.enumerated() { + if blockStart < 0 { + if line.hasPrefix("mcp_servers:") { + blockStart = index + } + continue + } + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + let indent = line.prefix(while: { $0 == " " }).count + if indent == 0 && trimmed.contains(":") { + blockEnd = index + break + } + } + if blockStart < 0 { + return MCPBlockLocation(prefix: lines, block: [], suffix: []) + } + return MCPBlockLocation( + prefix: Array(lines[0.. [HermesMCPServer] { + let location = extractMCPBlock(yaml: yaml) + guard location.block.count > 1 else { return [] } + + var servers: [HermesMCPServer] = [] + + var currentName: String? + var fields: [String: String] = [:] + var argsList: [String] = [] + var envMap: [String: String] = [:] + var headersMap: [String: String] = [:] + var includeList: [String] = [] + var excludeList: [String] = [] + var resources = false + var prompts = false + var subSection: String? + + func flush() { + guard let name = currentName else { return } + let transport: MCPTransport = fields["url"] != nil ? .http : .stdio + let enabledStr = fields["enabled"]?.lowercased() + let enabled = enabledStr != "false" + let timeout = fields["timeout"].flatMap(Int.init) + let connectTimeout = fields["connect_timeout"].flatMap(Int.init) + let server = HermesMCPServer( + name: name, + transport: transport, + command: fields["command"].map { Self.unquote($0) }, + args: argsList, + url: fields["url"].map { Self.unquote($0) }, + auth: fields["auth"].map { Self.unquote($0) }, + env: envMap, + headers: headersMap, + timeout: timeout, + connectTimeout: connectTimeout, + enabled: enabled, + toolsInclude: includeList, + toolsExclude: excludeList, + resourcesEnabled: resources, + promptsEnabled: prompts, + hasOAuthToken: false + ) + servers.append(server) + + currentName = nil + fields = [:] + argsList = [] + envMap = [:] + headersMap = [:] + includeList = [] + excludeList = [] + resources = false + prompts = false + subSection = nil + } + + for rawLine in location.block.dropFirst() { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + let indent = rawLine.prefix(while: { $0 == " " }).count + + if indent == 2 && trimmed.hasSuffix(":") && !trimmed.contains(" ") { + flush() + currentName = String(trimmed.dropLast()) + subSection = nil + continue + } + + guard currentName != nil else { continue } + + if indent == 4 { + if trimmed.hasPrefix("- ") && subSection == "args" { + argsList.append(Self.unquote(String(trimmed.dropFirst(2)))) + continue + } + subSection = nil + if trimmed.hasSuffix(":") { + subSection = String(trimmed.dropLast()) + continue + } + if let colonIdx = trimmed.firstIndex(of: ":") { + let key = String(trimmed[..= 6 { + switch subSection { + case "args": + if trimmed.hasPrefix("- ") { + argsList.append(Self.unquote(String(trimmed.dropFirst(2)))) + } + case "env": + if let colonIdx = trimmed.firstIndex(of: ":") { + let key = String(trimmed[.. Void) -> Bool { + guard let yaml = readFile(HermesPaths.configYAML) else { return false } + let location = extractMCPBlock(yaml: yaml) + guard !location.block.isEmpty else { return false } + + var block = location.block + + var entryStart = -1 + var entryEnd = block.count + for (index, line) in block.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let indent = line.prefix(while: { $0 == " " }).count + if entryStart < 0 { + if indent == 2 && trimmed == "\(name):" { + entryStart = index + } + continue + } + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + if indent <= 2 { + entryEnd = index + break + } + } + guard entryStart >= 0 else { return false } + + var entryLines = Array(block[entryStart..= 4 { + continue + } else if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } else if indent >= 6 { + continue + } else { + removeEnd = index + break + } + } + } + + if items.isEmpty { + if let headerIndex, let end = removeEnd { + lines.removeSubrange(headerIndex..= 6 { + continue + } else if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } else { + removeEnd = index + break + } + } + } + + var newLines: [String] = [] + if map.isEmpty { + if let headerIndex, let end = removeEnd { + lines.removeSubrange(headerIndex..= 6 { + continue + } else if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } else { + removeEnd = index + break + } + } + } + + var newLines: [String] = [" tools:"] + newLines.append(" include:") + for tool in include { newLines.append(" - \(yamlScalar(tool))") } + newLines.append(" exclude:") + for tool in exclude { newLines.append(" - \(yamlScalar(tool))") } + newLines.append(" resources: \(resources ? "true" : "false")") + newLines.append(" prompts: \(prompts ? "true" : "false")") + + if let headerIndex { + let end = removeEnd ?? lines.count + lines.replaceSubrange(headerIndex.. String { + if value.isEmpty { return "\"\"" } + let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"") + || value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-") + || ["true", "false", "null", "yes", "no"].contains(value.lowercased()) + if needsQuoting { + let escaped = value.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + return value + } + + private static func unquote(_ value: String) -> String { + var v = value + if (v.hasPrefix("\"") && v.hasSuffix("\"") && v.count >= 2) || (v.hasPrefix("'") && v.hasSuffix("'") && v.count >= 2) { + v = String(v.dropFirst().dropLast()) + } + return v + } + // MARK: - Hermes Process func isHermesRunning() -> Bool { @@ -293,23 +892,31 @@ struct HermesFileService: Sendable { } @discardableResult - nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60) -> (exitCode: Int32, output: String) { + nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) { guard let binary = hermesBinaryPath() else { return (-1, "") } let stdoutPipe = Pipe() let stderrPipe = Pipe() + let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil let process = Process() process.executableURL = URL(fileURLWithPath: binary) process.arguments = args process.standardOutput = stdoutPipe process.standardError = stderrPipe + if let stdinPipe { process.standardInput = stdinPipe } defer { try? stdoutPipe.fileHandleForReading.close() try? stdoutPipe.fileHandleForWriting.close() try? stderrPipe.fileHandleForReading.close() try? stderrPipe.fileHandleForWriting.close() + try? stdinPipe?.fileHandleForReading.close() + try? stdinPipe?.fileHandleForWriting.close() } do { try process.run() + if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + try? stdinPipe.fileHandleForWriting.close() + } let deadline = Date().addingTimeInterval(timeout) while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index 313147c..e620330 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -19,7 +19,8 @@ final class HermesFileWatcher { HermesPaths.agentLog, HermesPaths.errorsLog, HermesPaths.gatewayLog, - HermesPaths.projectsRegistry + HermesPaths.projectsRegistry, + HermesPaths.mcpTokensDir ] for path in paths { diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift new file mode 100644 index 0000000..fe922d3 --- /dev/null +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift @@ -0,0 +1,111 @@ +import Foundation + +@Observable +final class MCPServerEditorViewModel { + struct KeyValueRow: Identifiable, Equatable { + let id = UUID() + var key: String + var value: String + } + + private let fileService = HermesFileService() + let server: HermesMCPServer + + var envDraft: [KeyValueRow] + var headersDraft: [KeyValueRow] + var includeDraft: String + var excludeDraft: String + var resourcesEnabled: Bool + var promptsEnabled: Bool + var timeoutDraft: String + var connectTimeoutDraft: String + var showSecrets: Bool = false + var isSaving: Bool = false + var saveError: String? + + init(server: HermesMCPServer) { + self.server = server + self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") } + self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") } + self.includeDraft = server.toolsInclude.joined(separator: ", ") + self.excludeDraft = server.toolsExclude.joined(separator: ", ") + self.resourcesEnabled = server.resourcesEnabled + self.promptsEnabled = server.promptsEnabled + self.timeoutDraft = server.timeout.map { String($0) } ?? "" + self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? "" + } + + func appendEnvRow() { + envDraft.append(KeyValueRow(key: "", value: "")) + } + + func removeEnvRow(id: UUID) { + envDraft.removeAll { $0.id == id } + } + + func appendHeaderRow() { + headersDraft.append(KeyValueRow(key: "", value: "")) + } + + func removeHeaderRow(id: UUID) { + headersDraft.removeAll { $0.id == id } + } + + func save(completion: @escaping (Bool) -> Void) { + isSaving = true + saveError = nil + + let envMap = Dictionary(uniqueKeysWithValues: envDraft + .filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty } + .map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) }) + let headerMap = Dictionary(uniqueKeysWithValues: headersDraft + .filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty } + .map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) }) + let include = includeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces)) + let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces)) + + let service = fileService + let transport = server.transport + let name = server.name + let resources = resourcesEnabled + let prompts = promptsEnabled + + Task.detached { + var success = true + switch transport { + case .stdio: + if !service.setMCPServerEnv(name: name, env: envMap) { success = false } + case .http: + if !service.setMCPServerHeaders(name: name, headers: headerMap) { success = false } + } + if !service.updateMCPToolFilters( + name: name, + include: include, + exclude: exclude, + resources: resources, + prompts: prompts + ) { success = false } + if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) { + success = false + } + await MainActor.run { + self.isSaving = false + if !success { + self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)." + } + completion(success) + } + } + } + + func clearOAuthToken(completion: @escaping (Bool) -> Void) { + let service = fileService + let name = server.name + Task.detached { + let ok = service.deleteMCPOAuthToken(name: name) + await MainActor.run { completion(ok) } + } + } +} diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift new file mode 100644 index 0000000..dc09c8b --- /dev/null +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift @@ -0,0 +1,223 @@ +import Foundation + +@Observable +final class MCPServersViewModel { + private let fileService = HermesFileService() + + var servers: [HermesMCPServer] = [] + var selectedServerName: String? + var searchText = "" + var isLoading = false + var statusMessage: String? + var showPresetPicker = false + var showAddCustom = false + var showRestartBanner = false + var testResults: [String: MCPTestResult] = [:] + var testingNames: Set = [] + var activeError: String? + var editingServer: HermesMCPServer? + + var filteredServers: [HermesMCPServer] { + guard !searchText.isEmpty else { return servers } + let query = searchText.lowercased() + return servers.filter { server in + server.name.lowercased().contains(query) || + server.summary.lowercased().contains(query) + } + } + + var stdioServers: [HermesMCPServer] { + filteredServers.filter { $0.transport == .stdio } + } + + var httpServers: [HermesMCPServer] { + filteredServers.filter { $0.transport == .http } + } + + var selectedServer: HermesMCPServer? { + guard let name = selectedServerName else { return nil } + return servers.first(where: { $0.name == name }) + } + + func load() { + isLoading = true + servers = fileService.loadMCPServers() + isLoading = false + if let name = selectedServerName, !servers.contains(where: { $0.name == name }) { + selectedServerName = nil + } + } + + func selectServer(name: String?) { + selectedServerName = name + } + + func beginEdit() { + editingServer = selectedServer + } + + func finishEdit(reload: Bool) { + editingServer = nil + if reload { + load() + showRestartBanner = true + } + } + + func deleteServer(name: String) { + let fileService = self.fileService + Task.detached { + let result = fileService.removeMCPServer(name: name) + await MainActor.run { + if result.exitCode == 0 { + self.flashStatus("Removed \(name)") + if self.selectedServerName == name { + self.selectedServerName = nil + } + self.testResults.removeValue(forKey: name) + self.load() + self.showRestartBanner = true + } else { + self.activeError = "Remove failed: \(result.output)" + } + } + } + } + + func toggleEnabled(name: String) { + guard let server = servers.first(where: { $0.name == name }) else { return } + let newValue = !server.enabled + let fileService = self.fileService + Task.detached { + let ok = fileService.toggleMCPServerEnabled(name: name, enabled: newValue) + await MainActor.run { + if ok { + self.flashStatus(newValue ? "Enabled \(name)" : "Disabled \(name)") + self.load() + self.showRestartBanner = true + } else { + self.activeError = "Could not update \(name)" + } + } + } + } + + func testServer(name: String) { + guard !testingNames.contains(name) else { return } + testingNames.insert(name) + let fileService = self.fileService + Task.detached { + let result = await fileService.testMCPServer(name: name) + await MainActor.run { + self.testingNames.remove(name) + self.testResults[name] = result + } + } + } + + func testAll() { + let targets = servers.map(\.name) + let fileService = self.fileService + Task.detached { + for name in targets { + let result = await fileService.testMCPServer(name: name) + await MainActor.run { + self.testResults[name] = result + } + } + } + } + + func addFromPreset(preset: MCPServerPreset, name: String, pathArg: String?, envValues: [String: String]) { + let fileService = self.fileService + let allArgs: [String] = { + var base = preset.args + if let pathArg, !pathArg.isEmpty { base.append(pathArg) } + return base + }() + Task.detached { + let addResult: (exitCode: Int32, output: String) + switch preset.transport { + case .stdio: + addResult = fileService.addMCPServerStdio( + name: name, + command: preset.command ?? "", + args: allArgs + ) + case .http: + addResult = fileService.addMCPServerHTTP( + name: name, + url: preset.url ?? "", + auth: preset.auth + ) + } + guard addResult.exitCode == 0 else { + await MainActor.run { + self.activeError = "Add failed: \(addResult.output)" + } + return + } + if !envValues.isEmpty { + _ = fileService.setMCPServerEnv(name: name, env: envValues) + } + await MainActor.run { + self.flashStatus("Added \(name)") + self.load() + self.selectedServerName = name + self.showRestartBanner = true + self.showPresetPicker = false + } + } + } + + func addCustom(name: String, transport: MCPTransport, command: String, args: [String], url: String, auth: String?) { + let fileService = self.fileService + Task.detached { + let result: (exitCode: Int32, output: String) + switch transport { + case .stdio: + result = fileService.addMCPServerStdio(name: name, command: command, args: args) + case .http: + result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth) + } + await MainActor.run { + if result.exitCode == 0 { + self.flashStatus("Added \(name)") + self.load() + self.selectedServerName = name + self.showRestartBanner = true + self.showAddCustom = false + } else { + self.activeError = "Add failed: \(result.output)" + } + } + } + } + + func restartGateway() { + let fileService = self.fileService + Task.detached { + let result = fileService.restartGateway() + await MainActor.run { + if result.exitCode == 0 { + self.flashStatus("Gateway restarted") + self.showRestartBanner = false + } else { + self.activeError = "Restart failed: \(result.output)" + } + } + } + } + + func flashStatus(_ message: String) { + statusMessage = message + Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + await MainActor.run { + if self.statusMessage == message { + self.statusMessage = nil + } + } + } + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift new file mode 100644 index 0000000..f8ae576 --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +struct MCPServerAddCustomView: View { + let viewModel: MCPServersViewModel + + @Environment(\.dismiss) private var dismiss + @State private var name: String = "" + @State private var transport: MCPTransport = .stdio + @State private var command: String = "npx" + @State private var argsText: String = "" + @State private var url: String = "" + @State private var auth: String = "none" + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Add Custom MCP Server") + .font(.headline) + Spacer() + Button("Cancel") { dismiss() } + Button("Add") { + submit() + } + .buttonStyle(.borderedProminent) + .disabled(!canSubmit) + } + .padding() + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + sectionBox(title: "Identity") { + VStack(alignment: .leading, spacing: 6) { + Text("Name").font(.caption.bold()) + TextField("my_server", text: $name) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + Text("Becomes the key under mcp_servers: in config.yaml.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + sectionBox(title: "Transport") { + Picker("", selection: $transport) { + ForEach(MCPTransport.allCases) { t in + Text(t.displayName).tag(t) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + if transport == .stdio { + stdioSection + } else { + httpSection + } + Text("Env vars, headers, and tool filters can be edited after the server is added.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } + } + .frame(minWidth: 560, minHeight: 500) + } + + private var stdioSection: some View { + sectionBox(title: "Command") { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("Command").font(.caption.bold()) + TextField("npx", text: $command) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + VStack(alignment: .leading, spacing: 4) { + Text("Args (one per line)").font(.caption.bold()) + TextEditor(text: $argsText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 80) + .padding(4) + .overlay( + RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25)) + ) + } + } + } + } + + private var httpSection: some View { + sectionBox(title: "Endpoint") { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("URL").font(.caption.bold()) + TextField("https://...", text: $url) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + VStack(alignment: .leading, spacing: 4) { + Text("Auth").font(.caption.bold()) + Picker("", selection: $auth) { + Text("None").tag("none") + Text("OAuth 2.1").tag("oauth") + Text("Header").tag("header") + } + .labelsHidden() + .pickerStyle(.segmented) + } + } + } + } + + private var canSubmit: Bool { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { return false } + switch transport { + case .stdio: + return !command.trimmingCharacters(in: .whitespaces).isEmpty + case .http: + return !url.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + private func submit() { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let args = argsText + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + let resolvedAuth: String? = (auth == "none") ? nil : auth + viewModel.addCustom( + name: trimmedName, + transport: transport, + command: command.trimmingCharacters(in: .whitespaces), + args: args, + url: url.trimmingCharacters(in: .whitespaces), + auth: resolvedAuth + ) + dismiss() + } + + @ViewBuilder + private func sectionBox(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.bold()) + content() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift new file mode 100644 index 0000000..d678c14 --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +struct MCPServerDetailView: View { + let server: HermesMCPServer + let testResult: MCPTestResult? + let isTesting: Bool + let onTest: () -> Void + let onToggleEnabled: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + + @State private var showDeleteConfirm = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + overview + if server.transport == .stdio { + envSection + } else { + headersSection + } + toolsSection + timeoutsSection + if let result = testResult { + MCPServerTestResultView(result: result) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .confirmationDialog( + "Remove \(server.name)?", + isPresented: $showDeleteConfirm, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { onDelete() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the server from config.yaml and deletes any OAuth token.") + } + } + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: server.transport == .http ? "network" : "terminal") + .foregroundStyle(.secondary) + Text(server.name) + .font(.title2.bold()) + if !server.enabled { + Text("Disabled") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2)) + .clipShape(Capsule()) + } + if server.hasOAuthToken { + Label("OAuth", systemImage: "key.fill") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.green.opacity(0.15)) + .clipShape(Capsule()) + } + } + Text(server.transport.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 8) { + Button { + onTest() + } label: { + if isTesting { + ProgressView().controlSize(.small) + } else { + Label("Test", systemImage: "bolt.horizontal") + } + } + .disabled(isTesting) + Button { + onToggleEnabled() + } label: { + Label(server.enabled ? "Disable" : "Enable", systemImage: server.enabled ? "pause.circle" : "play.circle") + } + Button { + onEdit() + } label: { + Label("Edit", systemImage: "pencil") + } + .buttonStyle(.borderedProminent) + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + + private var overview: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Connection") + .font(.caption.bold()) + .foregroundStyle(.secondary) + switch server.transport { + case .stdio: + summaryRow(label: "Command", value: server.command ?? "—") + if !server.args.isEmpty { + summaryRow(label: "Args", value: server.args.joined(separator: " ")) + } + case .http: + summaryRow(label: "URL", value: server.url ?? "—") + if let auth = server.auth, !auth.isEmpty { + summaryRow(label: "Auth", value: auth) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func summaryRow(label: String, value: String) -> some View { + HStack(alignment: .top) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .leading) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + private var envSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Environment Variables") + .font(.caption.bold()) + .foregroundStyle(.secondary) + if server.env.isEmpty { + Text("No env vars configured.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(server.env.keys.sorted(), id: \.self) { key in + HStack { + Text(key) + .font(.system(.caption, design: .monospaced)) + Spacer() + Text(String(repeating: "•", count: 10)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var headersSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Headers") + .font(.caption.bold()) + .foregroundStyle(.secondary) + if server.headers.isEmpty { + Text("No headers configured.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(server.headers.keys.sorted(), id: \.self) { key in + HStack { + Text(key) + .font(.system(.caption, design: .monospaced)) + Spacer() + Text(String(repeating: "•", count: 10)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var toolsSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Tool Filters") + .font(.caption.bold()) + .foregroundStyle(.secondary) + summaryRow(label: "Include", value: server.toolsInclude.isEmpty ? "(all)" : server.toolsInclude.joined(separator: ", ")) + summaryRow(label: "Exclude", value: server.toolsExclude.isEmpty ? "—" : server.toolsExclude.joined(separator: ", ")) + summaryRow(label: "Resources", value: server.resourcesEnabled ? "enabled" : "disabled") + summaryRow(label: "Prompts", value: server.promptsEnabled ? "enabled" : "disabled") + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var timeoutsSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Timeouts") + .font(.caption.bold()) + .foregroundStyle(.secondary) + summaryRow(label: "Connect", value: server.connectTimeout.map { "\($0)s" } ?? "default") + summaryRow(label: "Call", value: server.timeout.map { "\($0)s" } ?? "default") + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift new file mode 100644 index 0000000..b6c9f3f --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift @@ -0,0 +1,218 @@ +import SwiftUI + +struct MCPServerEditorView: View { + @State var viewModel: MCPServerEditorViewModel + let onSave: (Bool) -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Edit \(viewModel.server.name)") + .font(.headline) + Text(viewModel.server.transport.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Cancel") { onCancel() } + .keyboardShortcut(.cancelAction) + Button { + viewModel.save { changed in + if changed { onSave(true) } + } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(viewModel.isSaving) + } + .padding() + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if let error = viewModel.saveError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.red.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + if viewModel.server.transport == .stdio { + envSection + } else { + headersSection + } + toolsSection + timeoutsSection + if viewModel.server.hasOAuthToken { + oauthSection + } + } + .padding() + } + } + .frame(minWidth: 640, minHeight: 560) + } + + private var envSection: some View { + sectionBox(title: "Environment Variables") { + VStack(alignment: .leading, spacing: 8) { + if viewModel.envDraft.isEmpty { + Text("No env vars. Add one with the button below.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach($viewModel.envDraft) { $row in + HStack(spacing: 8) { + TextField("KEY", text: $row.key) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: 240) + if viewModel.showSecrets { + TextField("value", text: $row.value) + .textFieldStyle(.roundedBorder) + } else { + SecureField("value", text: $row.value) + .textFieldStyle(.roundedBorder) + } + Button(role: .destructive) { + viewModel.removeEnvRow(id: row.id) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + } + } + HStack { + Button { + viewModel.appendEnvRow() + } label: { + Label("Add", systemImage: "plus.circle") + } + Spacer() + Toggle("Show values", isOn: $viewModel.showSecrets) + .toggleStyle(.switch) + .controlSize(.small) + } + } + } + } + + private var headersSection: some View { + sectionBox(title: "Headers") { + VStack(alignment: .leading, spacing: 8) { + if viewModel.headersDraft.isEmpty { + Text("No headers. Add one with the button below.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach($viewModel.headersDraft) { $row in + HStack(spacing: 8) { + TextField("Header", text: $row.key) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 240) + TextField("value", text: $row.value) + .textFieldStyle(.roundedBorder) + Button(role: .destructive) { + viewModel.removeHeaderRow(id: row.id) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + } + } + Button { + viewModel.appendHeaderRow() + } label: { + Label("Add", systemImage: "plus.circle") + } + } + } + } + + private var toolsSection: some View { + sectionBox(title: "Tool Filters") { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Include (comma-separated — if set, only these are exposed)") + .font(.caption) + .foregroundStyle(.secondary) + TextField("tool_a, tool_b", text: $viewModel.includeDraft) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + VStack(alignment: .leading, spacing: 4) { + Text("Exclude") + .font(.caption) + .foregroundStyle(.secondary) + TextField("tool_c", text: $viewModel.excludeDraft) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + Toggle("Expose resources", isOn: $viewModel.resourcesEnabled) + Toggle("Expose prompts", isOn: $viewModel.promptsEnabled) + } + } + } + + private var timeoutsSection: some View { + sectionBox(title: "Timeouts (seconds)") { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Connect timeout") + .font(.caption) + .foregroundStyle(.secondary) + TextField("default", text: $viewModel.connectTimeoutDraft) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 140) + } + VStack(alignment: .leading, spacing: 4) { + Text("Call timeout") + .font(.caption) + .foregroundStyle(.secondary) + TextField("default", text: $viewModel.timeoutDraft) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 140) + } + Spacer() + } + } + } + + private var oauthSection: some View { + sectionBox(title: "OAuth Token") { + HStack { + Text("Token on disk. Clear to re-authenticate next time the gateway connects.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Clear Token", role: .destructive) { + viewModel.clearOAuthToken { _ in } + } + } + } + } + + @ViewBuilder + private func sectionBox(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.bold()) + content() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift new file mode 100644 index 0000000..b51a5bd --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift @@ -0,0 +1,240 @@ +import SwiftUI + +struct MCPServerPresetPickerView: View { + let viewModel: MCPServersViewModel + + @Environment(\.dismiss) private var dismiss + @State private var selectedPreset: MCPServerPreset? + @State private var nameOverride: String = "" + @State private var pathArg: String = "" + @State private var envValues: [String: String] = [:] + @State private var showSecrets: Bool = false + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if let preset = selectedPreset { + configureStep(preset: preset) + } else { + galleryStep + } + } + .frame(minWidth: 720, minHeight: 560) + } + + private var header: some View { + HStack { + if selectedPreset != nil { + Button { + selectedPreset = nil + } label: { + Label("Back", systemImage: "chevron.left") + } + } + VStack(alignment: .leading, spacing: 2) { + Text(selectedPreset?.displayName ?? "Add from Preset") + .font(.headline) + Text(selectedPreset?.description ?? "Pick an MCP server to add.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button("Close") { dismiss() } + } + .padding() + } + + private var galleryStep: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + ForEach(MCPServerPreset.categories, id: \.self) { category in + VStack(alignment: .leading, spacing: 8) { + Text(category) + .font(.subheadline.bold()) + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 200), spacing: 12)], + spacing: 12 + ) { + ForEach(MCPServerPreset.byCategory(category)) { preset in + presetCard(preset) + } + } + } + } + } + .padding() + } + } + + private func presetCard(_ preset: MCPServerPreset) -> some View { + Button { + selectedPreset = preset + nameOverride = preset.id + pathArg = "" + envValues = Dictionary(uniqueKeysWithValues: preset.requiredEnvKeys.map { ($0, "") }) + for key in preset.optionalEnvKeys { + envValues[key] = "" + } + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: preset.iconSystemName) + .font(.title3) + .foregroundStyle(Color.accentColor) + Text(preset.displayName) + .font(.body.bold()) + Spacer() + Image(systemName: preset.transport == .http ? "network" : "terminal") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(preset.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + if !preset.requiredEnvKeys.isEmpty { + Text("Requires: \(preset.requiredEnvKeys.joined(separator: ", "))") + .font(.caption2.monospaced()) + .foregroundStyle(.orange) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + + private func configureStep(preset: MCPServerPreset) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + nameField + if let prompt = preset.pathArgPrompt { + pathArgField(prompt: prompt) + } + if !preset.requiredEnvKeys.isEmpty || !preset.optionalEnvKeys.isEmpty { + envFields(preset: preset) + } + if !preset.docsURL.isEmpty { + Link(destination: URL(string: preset.docsURL) ?? URL(string: "https://modelcontextprotocol.io")!) { + Label("Docs", systemImage: "book") + .font(.caption) + } + } + HStack { + Spacer() + Button("Add Server") { + submit(preset: preset) + } + .buttonStyle(.borderedProminent) + .disabled(!canSubmit(preset: preset)) + } + } + .padding() + } + } + + private var nameField: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Server name") + .font(.caption.bold()) + TextField("e.g. github", text: $nameOverride) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + Text("Used as the YAML key. Lowercase, no spaces.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private func pathArgField(prompt: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(prompt) + .font(.caption.bold()) + TextField(prompt, text: $pathArg) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + } + + private func envFields(preset: MCPServerPreset) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Environment Variables") + .font(.caption.bold()) + Spacer() + Toggle("Show values", isOn: $showSecrets) + .toggleStyle(.switch) + .controlSize(.small) + } + ForEach(preset.requiredEnvKeys, id: \.self) { key in + envRow(key: key, required: true) + } + ForEach(preset.optionalEnvKeys, id: \.self) { key in + envRow(key: key, required: false) + } + } + } + + private func envRow(key: String, required: Bool) -> some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(key) + .font(.system(.caption, design: .monospaced)) + if required { + Text("required") + .font(.caption2) + .foregroundStyle(.orange) + } + } + .frame(width: 240, alignment: .leading) + if showSecrets { + TextField("value", text: bindingForEnv(key)) + .textFieldStyle(.roundedBorder) + } else { + SecureField("value", text: bindingForEnv(key)) + .textFieldStyle(.roundedBorder) + } + } + } + + private func bindingForEnv(_ key: String) -> Binding { + Binding( + get: { envValues[key] ?? "" }, + set: { envValues[key] = $0 } + ) + } + + private func canSubmit(preset: MCPServerPreset) -> Bool { + let trimmedName = nameOverride.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { return false } + if preset.pathArgPrompt != nil && pathArg.trimmingCharacters(in: .whitespaces).isEmpty { + return false + } + for key in preset.requiredEnvKeys { + if (envValues[key] ?? "").trimmingCharacters(in: .whitespaces).isEmpty { return false } + } + return true + } + + private func submit(preset: MCPServerPreset) { + let finalName = nameOverride.trimmingCharacters(in: .whitespaces) + let finalPath = pathArg.trimmingCharacters(in: .whitespaces) + let trimmedEnv = envValues.reduce(into: [String: String]()) { acc, pair in + let trimmedValue = pair.value.trimmingCharacters(in: .whitespaces) + if !trimmedValue.isEmpty { acc[pair.key] = pair.value } + } + viewModel.addFromPreset( + preset: preset, + name: finalName, + pathArg: preset.pathArgPrompt != nil ? finalPath : nil, + envValues: trimmedEnv + ) + dismiss() + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift new file mode 100644 index 0000000..55dee3f --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct MCPServerTestResultView: View { + let result: MCPTestResult + @State private var showOutput = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill") + .foregroundStyle(result.succeeded ? .green : .red) + VStack(alignment: .leading, spacing: 2) { + Text(result.succeeded ? "Test passed" : "Test failed") + .font(.subheadline.bold()) + Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + showOutput.toggle() + } label: { + Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down") + .font(.caption) + } + .buttonStyle(.borderless) + } + if !result.tools.isEmpty { + WrapChips(items: result.tools) + } + if showOutput { + ScrollView { + Text(result.output.isEmpty ? "(no output)" : result.output) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 220) + .background(Color.black.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background((result.succeeded ? Color.green : Color.red).opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +private struct WrapChips: View { + let items: [String] + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 6)], spacing: 6) { + ForEach(items, id: \.self) { item in + Text(item) + .font(.caption.monospaced()) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + } + } + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift new file mode 100644 index 0000000..ed3e60e --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +struct MCPServersView: View { + @State private var viewModel = MCPServersViewModel() + + var body: some View { + HSplitView { + serversList + .frame(minWidth: 260, idealWidth: 300) + serverDetail + .frame(minWidth: 500) + } + .navigationTitle("MCP Servers (\(viewModel.servers.count))") + .searchable(text: $viewModel.searchText, prompt: "Filter servers...") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + viewModel.showPresetPicker = true + } label: { + Label("Add from Preset", systemImage: "square.grid.2x2") + } + Button { + viewModel.showAddCustom = true + } label: { + Label("Add Custom", systemImage: "plus") + } + Button { + viewModel.testAll() + } label: { + Label("Test All", systemImage: "bolt.horizontal") + } + .disabled(viewModel.servers.isEmpty) + Button { + viewModel.load() + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + } + } + .onAppear { viewModel.load() } + .sheet(isPresented: $viewModel.showPresetPicker) { + MCPServerPresetPickerView(viewModel: viewModel) + } + .sheet(isPresented: $viewModel.showAddCustom) { + MCPServerAddCustomView(viewModel: viewModel) + } + .sheet(isPresented: Binding( + get: { viewModel.editingServer != nil }, + set: { if !$0 { viewModel.editingServer = nil } } + )) { + if let server = viewModel.editingServer { + MCPServerEditorView( + viewModel: MCPServerEditorViewModel(server: server), + onSave: { changed in viewModel.finishEdit(reload: changed) }, + onCancel: { viewModel.finishEdit(reload: false) } + ) + } + } + .alert("Error", isPresented: Binding( + get: { viewModel.activeError != nil }, + set: { if !$0 { viewModel.activeError = nil } } + )) { + Button("OK") { viewModel.activeError = nil } + } message: { + Text(viewModel.activeError ?? "") + } + } + + private var serversList: some View { + List(selection: Binding( + get: { viewModel.selectedServerName }, + set: { viewModel.selectServer(name: $0) } + )) { + if !viewModel.stdioServers.isEmpty { + Section("Local (stdio)") { + ForEach(viewModel.stdioServers) { server in + serverRow(server) + .tag(server.name as String?) + } + } + } + if !viewModel.httpServers.isEmpty { + Section("Remote (HTTP)") { + ForEach(viewModel.httpServers) { server in + serverRow(server) + .tag(server.name as String?) + } + } + } + if viewModel.servers.isEmpty && !viewModel.isLoading { + Section { + Text("No servers configured yet") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.sidebar) + } + + @ViewBuilder + private func serverRow(_ server: HermesMCPServer) -> some View { + HStack(spacing: 8) { + Image(systemName: server.transport == .http ? "network" : "terminal") + .foregroundStyle(server.enabled ? Color.accentColor : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(server.name) + .font(.body) + if !server.enabled { + Text("Disabled") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer() + if viewModel.testingNames.contains(server.name) { + ProgressView().controlSize(.small) + } else if let result = viewModel.testResults[server.name] { + Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(result.succeeded ? .green : .red) + .help(result.succeeded ? "\(result.tools.count) tools" : "Test failed") + } + } + } + + @ViewBuilder + private var serverDetail: some View { + VStack(spacing: 0) { + if viewModel.showRestartBanner { + RestartGatewayBanner( + onRestart: { viewModel.restartGateway() }, + onDismiss: { viewModel.showRestartBanner = false } + ) + } + if let status = viewModel.statusMessage { + Text(status) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.12)) + } + if let server = viewModel.selectedServer { + MCPServerDetailView( + server: server, + testResult: viewModel.testResults[server.name], + isTesting: viewModel.testingNames.contains(server.name), + onTest: { viewModel.testServer(name: server.name) }, + onToggleEnabled: { viewModel.toggleEnabled(name: server.name) }, + onEdit: { viewModel.beginEdit() }, + onDelete: { viewModel.deleteServer(name: server.name) } + ) + } else { + ContentUnavailableView( + "Select an MCP Server", + systemImage: "puzzlepiece.extension", + description: Text("Pick one from the list, or add a new server from the toolbar.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} diff --git a/scarf/scarf/Features/MCPServers/Views/RestartGatewayBanner.swift b/scarf/scarf/Features/MCPServers/Views/RestartGatewayBanner.swift new file mode 100644 index 0000000..b4762ad --- /dev/null +++ b/scarf/scarf/Features/MCPServers/Views/RestartGatewayBanner.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct RestartGatewayBanner: View { + let onRestart: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "arrow.triangle.2.circlepath.circle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 1) { + Text("Gateway restart required") + .font(.caption.bold()) + Text("Changes won't take effect until Hermes reloads the config.") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Button("Restart Now") { onRestart() } + .controlSize(.small) + .buttonStyle(.borderedProminent) + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.14)) + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 38379f4..294c204 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -10,6 +10,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case memory = "Memory" case skills = "Skills" case tools = "Tools" + case mcpServers = "MCP Servers" case gateway = "Gateway" case cron = "Cron" case health = "Health" @@ -29,6 +30,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .memory: return "brain" case .skills: return "lightbulb" case .tools: return "wrench.and.screwdriver" + case .mcpServers: return "puzzlepiece.extension" case .gateway: return "antenna.radiowaves.left.and.right" case .cron: return "clock.arrow.2.circlepath" case .health: return "stethoscope" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 1c6534c..a1eba9b 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -25,7 +25,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) }