Compare commits

...

2 Commits

Author SHA1 Message Date
Alan Wizemann 0a584f6722 chore: Bump version to 1.5.6 and add release binaries
Includes the MCP Servers management UI shipped in 219bca2:
- Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, …)
  or fully custom (stdio command + args, or HTTP URL with bearer auth)
- Per-server detail view: enable/disable, env + headers editor,
  tool include/exclude filters, resources/prompts toggles, request
  and connect timeouts, OAuth token detection + clearing
- One-click "Test Connection" runs `hermes mcp test` and surfaces
  the discovered tool list
- Gateway-restart banner after config changes that need a reload

README updated with the MCP Servers section, the new MCPServers/
feature module entry, and the `hermes mcp` + `mcp-tokens/` entries
in the Data Sources table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:45:19 -07:00
Alan Wizemann 219bca264e 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>
2026-04-15 19:53:55 -07:00
21 changed files with 2287 additions and 7 deletions
+4
View File
@@ -27,6 +27,7 @@
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, iMessage, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
@@ -99,6 +100,7 @@ scarf/
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
@@ -125,6 +127,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.5;
MARKETING_VERSION = 1.5.6;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.5;
MARKETING_VERSION = 1.5.6;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+2
View File
@@ -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"
+1 -1
View File
@@ -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)
}