mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a29ab68d | |||
| 117a0ee9dd | |||
| 61d59ba0e4 | |||
| 0a584f6722 | |||
| 219bca264e |
@@ -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
|
- **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
|
- **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
|
- **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)
|
- **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
|
- **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
|
- **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
|
Memory/ Memory viewer and editor
|
||||||
Skills/ Skill browser by category
|
Skills/ Skill browser by category
|
||||||
Tools/ Toolset management per platform
|
Tools/ Toolset management per platform
|
||||||
|
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
|
||||||
Gateway/ Messaging gateway control and pairing
|
Gateway/ Messaging gateway control and pairing
|
||||||
Cron/ Scheduled job viewer
|
Cron/ Scheduled job viewer
|
||||||
Logs/ Real-time log 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 sessions` | CLI commands | Rename/Delete/Export |
|
||||||
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||||
| `hermes pairing` | CLI commands | Approve/Revoke |
|
| `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/dashboard.json` | JSON (per-project) | Read-only |
|
||||||
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -407,7 +407,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 7;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.5.5;
|
MARKETING_VERSION = 1.5.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 7;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.5.5;
|
MARKETING_VERSION = 1.5.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ struct ContentView: View {
|
|||||||
SkillsView()
|
SkillsView()
|
||||||
case .tools:
|
case .tools:
|
||||||
ToolsView()
|
ToolsView()
|
||||||
|
case .mcpServers:
|
||||||
|
MCPServersView()
|
||||||
case .gateway:
|
case .gateway:
|
||||||
GatewayView()
|
GatewayView()
|
||||||
case .cron:
|
case .cron:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ enum HermesPaths: Sendable {
|
|||||||
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||||
nonisolated static let scarfDir: String = home + "/scarf"
|
nonisolated static let scarfDir: String = home + "/scarf"
|
||||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||||
|
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SQLite Constants
|
// 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,8 +66,10 @@ actor ACPClient {
|
|||||||
proc.standardOutput = stdout
|
proc.standardOutput = stdout
|
||||||
proc.standardError = stderr
|
proc.standardError = stderr
|
||||||
|
|
||||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution
|
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution.
|
||||||
var env = ProcessInfo.processInfo.environment
|
// Use the enriched environment so any tools hermes spawns (MCP servers,
|
||||||
|
// shell commands) can find brew/nvm/asdf binaries on PATH.
|
||||||
|
var env = HermesFileService.enrichedEnvironment()
|
||||||
env.removeValue(forKey: "TERM")
|
env.removeValue(forKey: "TERM")
|
||||||
proc.environment = env
|
proc.environment = env
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,652 @@ struct HermesFileService: Sendable {
|
|||||||
return result
|
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)
|
||||||
|
// hermes mcp test exits 0 even when the inner connection fails — it
|
||||||
|
// reports the failure on stdout instead. Look for explicit failure
|
||||||
|
// markers so the UI doesn't show a green check on a broken server.
|
||||||
|
let output = result.1
|
||||||
|
let hasFailureMarker = output.contains("✗")
|
||||||
|
|| output.range(of: "Connection failed", options: .caseInsensitive) != nil
|
||||||
|
|| output.range(of: "No such file or directory", options: .caseInsensitive) != nil
|
||||||
|
|| output.range(of: "Error:", options: .caseInsensitive) != nil
|
||||||
|
return MCPTestResult(
|
||||||
|
serverName: name,
|
||||||
|
succeeded: result.0 == 0 && !hasFailureMarker,
|
||||||
|
output: output,
|
||||||
|
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: [])
|
||||||
|
}
|
||||||
|
// Trim trailing blank lines and comments from the block — they belong
|
||||||
|
// to the file footer, not the mcp_servers section. Without this, when
|
||||||
|
// mcp_servers is the last top-level key, the block would extend to EOF
|
||||||
|
// and any inserted content (args, env, headers, tools) would land
|
||||||
|
// after the trailing comments.
|
||||||
|
while blockEnd > blockStart + 1 {
|
||||||
|
let line = lines[blockEnd - 1]
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||||
|
blockEnd -= 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
|
||||||
|
// Trim trailing blank lines and comments off the entry so inserts land
|
||||||
|
// immediately after the entry's last real key, not after intervening
|
||||||
|
// comments that conceptually belong to the next entry (or the file
|
||||||
|
// footer when this is the last entry in the block).
|
||||||
|
while entryEnd > entryStart + 1 {
|
||||||
|
let line = block[entryEnd - 1]
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||||
|
entryEnd -= 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "\"\"" }
|
||||||
|
// YAML 1.2 reserved indicators that change meaning at the start of a
|
||||||
|
// scalar: @ * & ? | > ! % , [ ] { } < ` ' " — plus space (would be
|
||||||
|
// trimmed) and dash (looks like a sequence). Anything starting with
|
||||||
|
// one of these must be quoted or YAML treats the value as an alias,
|
||||||
|
// tag, flow collection, etc., and parsing breaks.
|
||||||
|
let reservedFirstChars: Set<Character> = [
|
||||||
|
"@", "*", "&", "?", "|", ">", "!", "%", ",",
|
||||||
|
"[", "]", "{", "}", "<", "`", "'", "\""
|
||||||
|
]
|
||||||
|
let firstCharNeedsQuoting = value.first.map { reservedFirstChars.contains($0) } ?? false
|
||||||
|
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|
||||||
|
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|
||||||
|
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
|
||||||
|
|| firstCharNeedsQuoting
|
||||||
|
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
|
// MARK: - Hermes Process
|
||||||
|
|
||||||
func isHermesRunning() -> Bool {
|
func isHermesRunning() -> Bool {
|
||||||
@@ -292,24 +938,97 @@ struct HermesFileService: Sendable {
|
|||||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PATH cobbled together from the user's login shell — needed because
|
||||||
|
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew,
|
||||||
|
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`,
|
||||||
|
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such
|
||||||
|
/// file or directory`. Computed once and cached.
|
||||||
|
private static let enrichedPath: String = {
|
||||||
|
let pipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc
|
||||||
|
// chain on macOS) so PATH manipulations made there are picked up.
|
||||||
|
// Skip -i to avoid hangs from interactive prompts.
|
||||||
|
process.arguments = ["-l", "-c", "echo $PATH"]
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
defer {
|
||||||
|
try? pipe.fileHandleForReading.close()
|
||||||
|
try? pipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForReading.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
let deadline = Date().addingTimeInterval(3)
|
||||||
|
while process.isRunning && Date() < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
}
|
||||||
|
if process.isRunning { process.terminate() }
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let path = (String(data: data, encoding: .utf8) ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if process.terminationStatus == 0 && !path.isEmpty {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to default below.
|
||||||
|
}
|
||||||
|
// Fallback when the login shell can't be queried (zsh missing,
|
||||||
|
// sandbox restriction, timeout). Covers Apple Silicon + Intel
|
||||||
|
// Homebrew plus the standard system paths.
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
return [
|
||||||
|
"\(home)/.local/bin",
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/bin",
|
||||||
|
"/usr/sbin",
|
||||||
|
"/sbin"
|
||||||
|
].joined(separator: ":")
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Environment to hand any subprocess that may itself spawn user-installed
|
||||||
|
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical
|
||||||
|
/// to ProcessInfo.processInfo.environment but with PATH replaced by the
|
||||||
|
/// login-shell PATH.
|
||||||
|
nonisolated static func enrichedEnvironment() -> [String: String] {
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PATH"] = enrichedPath
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@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, "") }
|
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
||||||
let stdoutPipe = Pipe()
|
let stdoutPipe = Pipe()
|
||||||
let stderrPipe = Pipe()
|
let stderrPipe = Pipe()
|
||||||
|
let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: binary)
|
process.executableURL = URL(fileURLWithPath: binary)
|
||||||
process.arguments = args
|
process.arguments = args
|
||||||
|
process.environment = Self.enrichedEnvironment()
|
||||||
process.standardOutput = stdoutPipe
|
process.standardOutput = stdoutPipe
|
||||||
process.standardError = stderrPipe
|
process.standardError = stderrPipe
|
||||||
|
if let stdinPipe { process.standardInput = stdinPipe }
|
||||||
defer {
|
defer {
|
||||||
try? stdoutPipe.fileHandleForReading.close()
|
try? stdoutPipe.fileHandleForReading.close()
|
||||||
try? stdoutPipe.fileHandleForWriting.close()
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
try? stderrPipe.fileHandleForReading.close()
|
try? stderrPipe.fileHandleForReading.close()
|
||||||
try? stderrPipe.fileHandleForWriting.close()
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
|
try? stdinPipe?.fileHandleForReading.close()
|
||||||
|
try? stdinPipe?.fileHandleForWriting.close()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try process.run()
|
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)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while process.isRunning && Date() < deadline {
|
while process.isRunning && Date() < deadline {
|
||||||
Thread.sleep(forTimeInterval: 0.05)
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ final class HermesFileWatcher {
|
|||||||
HermesPaths.agentLog,
|
HermesPaths.agentLog,
|
||||||
HermesPaths.errorsLog,
|
HermesPaths.errorsLog,
|
||||||
HermesPaths.gatewayLog,
|
HermesPaths.gatewayLog,
|
||||||
HermesPaths.projectsRegistry
|
HermesPaths.projectsRegistry,
|
||||||
|
HermesPaths.mcpTokensDir
|
||||||
]
|
]
|
||||||
|
|
||||||
for path in paths {
|
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 memory = "Memory"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
case tools = "Tools"
|
case tools = "Tools"
|
||||||
|
case mcpServers = "MCP Servers"
|
||||||
case gateway = "Gateway"
|
case gateway = "Gateway"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
case health = "Health"
|
case health = "Health"
|
||||||
@@ -29,6 +30,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
case .tools: return "wrench.and.screwdriver"
|
case .tools: return "wrench.and.screwdriver"
|
||||||
|
case .mcpServers: return "puzzlepiece.extension"
|
||||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||||
case .cron: return "clock.arrow.2.circlepath"
|
case .cron: return "clock.arrow.2.circlepath"
|
||||||
case .health: return "stethoscope"
|
case .health: return "stethoscope"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Manage") {
|
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)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user