mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat: Add MCP Servers management UI
Full MCP server lifecycle: add (stdio + HTTP), edit, remove, test, enable/disable. YAML config patching for args, env, headers, tool filters, timeouts. OAuth token detection + deletion. Preset picker for common MCP servers. Gateway restart banner after config changes. New sidebar section "MCP Servers" under Manage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,8 @@ struct ContentView: View {
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .mcpServers:
|
||||
MCPServersView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String>()
|
||||
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
|
||||
}
|
||||
|
||||
static func byCategory(_ category: String) -> [MCPServerPreset] {
|
||||
gallery.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
@@ -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..<blockStart]),
|
||||
block: Array(lines[blockStart..<blockEnd]),
|
||||
suffix: Array(lines[blockEnd..<lines.count])
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate func parseMCPServersBlock(yaml: String) -> [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[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
fields[key] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if indent >= 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[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
envMap[key] = Self.unquote(value)
|
||||
}
|
||||
case "headers":
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
headersMap[key] = Self.unquote(value)
|
||||
}
|
||||
case "tools":
|
||||
if trimmed == "include:" {
|
||||
subSection = "tools.include"
|
||||
} else if trimmed == "exclude:" {
|
||||
subSection = "tools.exclude"
|
||||
} else if trimmed.hasPrefix("resources:") {
|
||||
resources = trimmed.lowercased().hasSuffix("true")
|
||||
} else if trimmed.hasPrefix("prompts:") {
|
||||
prompts = trimmed.lowercased().hasSuffix("true")
|
||||
}
|
||||
case "tools.include":
|
||||
if trimmed.hasPrefix("- ") {
|
||||
includeList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||
}
|
||||
case "tools.exclude":
|
||||
if trimmed.hasPrefix("- ") {
|
||||
excludeList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush()
|
||||
return servers
|
||||
}
|
||||
|
||||
// MARK: - MCP YAML: surgical patcher
|
||||
|
||||
private func patchMCPServerField(name: String, mutate: (inout [String]) -> 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..<entryEnd])
|
||||
mutate(&entryLines)
|
||||
|
||||
block.replaceSubrange(entryStart..<entryEnd, with: entryLines)
|
||||
|
||||
var combined: [String] = []
|
||||
combined.append(contentsOf: location.prefix)
|
||||
combined.append(contentsOf: block)
|
||||
combined.append(contentsOf: location.suffix)
|
||||
let newYAML = combined.joined(separator: "\n")
|
||||
writeFile(HermesPaths.configYAML, content: newYAML)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - MCP YAML: mutators
|
||||
|
||||
private static func replaceOrInsertScalar(key: String, value: String, in lines: inout [String]) {
|
||||
// entry header is at lines[0] at indent 2. Scalars live at indent 4.
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
|
||||
lines[index] = " \(key): \(value)"
|
||||
return
|
||||
}
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Insert right after header.
|
||||
lines.insert(" \(key): \(value)", at: 1)
|
||||
}
|
||||
|
||||
private static func removeScalar(key: String, in lines: inout [String]) {
|
||||
var removeIndex: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
|
||||
removeIndex = index
|
||||
break
|
||||
}
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if let removeIndex {
|
||||
lines.remove(at: removeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private static func replaceOrInsertList(header: String, items: [String], in lines: inout [String]) {
|
||||
var headerIndex: Int?
|
||||
var removeEnd: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4 && trimmed == "\(header):" {
|
||||
headerIndex = index
|
||||
continue
|
||||
}
|
||||
if headerIndex != nil {
|
||||
// List items can appear at indent 4 (as " - item") OR indent 6 depending on style.
|
||||
if trimmed.hasPrefix("- ") && indent >= 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..<end)
|
||||
} else if let headerIndex {
|
||||
lines.removeSubrange(headerIndex..<lines.count)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var newLines: [String] = [" \(header):"]
|
||||
for item in items {
|
||||
newLines.append(" - \(yamlScalar(item))")
|
||||
}
|
||||
|
||||
if let headerIndex {
|
||||
let end = removeEnd ?? lines.count
|
||||
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||
} else {
|
||||
var insertAt = lines.count
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
lines.insert(contentsOf: newLines, at: insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func replaceOrInsertSubMap(header: String, map: [String: String], in lines: inout [String]) {
|
||||
var headerIndex: Int?
|
||||
var removeEnd: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4 && trimmed == "\(header):" {
|
||||
headerIndex = index
|
||||
continue
|
||||
}
|
||||
if headerIndex != nil {
|
||||
if indent >= 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..<end)
|
||||
} else if let headerIndex {
|
||||
lines.removeSubrange(headerIndex..<lines.count)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
newLines.append(" \(header):")
|
||||
for key in map.keys.sorted() {
|
||||
let value = map[key] ?? ""
|
||||
newLines.append(" \(key): \(yamlScalar(value))")
|
||||
}
|
||||
|
||||
if let headerIndex {
|
||||
let end = removeEnd ?? lines.count
|
||||
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||
} else {
|
||||
// Insert just before the first indent<=2 line we find after the header, else at end.
|
||||
var insertAt = lines.count
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
lines.insert(contentsOf: newLines, at: insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func replaceOrInsertToolsBlock(include: [String], exclude: [String], resources: Bool, prompts: Bool, in lines: inout [String]) {
|
||||
var headerIndex: Int?
|
||||
var removeEnd: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4 && trimmed == "tools:" {
|
||||
headerIndex = index
|
||||
continue
|
||||
}
|
||||
if headerIndex != nil {
|
||||
if indent >= 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..<end, with: newLines)
|
||||
} else {
|
||||
var insertAt = lines.count
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
lines.insert(contentsOf: newLines, at: insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func yamlScalar(_ value: String) -> 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)
|
||||
|
||||
@@ -19,7 +19,8 @@ final class HermesFileWatcher {
|
||||
HermesPaths.agentLog,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
HermesPaths.projectsRegistry,
|
||||
HermesPaths.mcpTokensDir
|
||||
]
|
||||
|
||||
for path in paths {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View>(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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View>(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))
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user