Compare commits

...

10 Commits

Author SHA1 Message Date
Alan Wizemann b2a29ab68d fix: MCP test failures hidden as success; brew/nvm binaries not on PATH
Two related bugs surfaced when testing MCP servers that spawn npx, node,
python, etc. from Homebrew/nvm/asdf/mise installs.

1. MCP test reported success even when the connection failed.
   `hermes mcp test <server>` exits 0 even when the inner connection
   fails — it prints the error to stdout instead. Scarf trusted the
   exit code and rendered a green checkmark while the output said
   "✗ Connection failed: [Errno 2] No such file or directory: 'npx'".
   Fix: also scan output for ✗, "Connection failed", "No such file or
   directory", and "Error:" markers.

2. .app launches start with a minimal PATH that excludes Homebrew.
   When Scarf is launched from Finder/Dock, ProcessInfo's PATH is
   `/usr/bin:/bin:/usr/sbin:/sbin` — no /opt/homebrew/bin, no
   /usr/local/bin, no nvm/asdf/mise shims. Hermes inherits this and
   can't find npx/node/python when spawning MCP server subprocesses.
   Fix: query the user's login shell PATH once via `/bin/zsh -lc 'echo
   $PATH'`, cache it on HermesFileService, and inject it into both
   `runHermesCLI` and the ACP subprocess. Falls back to a sane default
   covering both Apple Silicon and Intel Homebrew if zsh query fails.

Bumps version to 1.5.8 (build 10). Includes signed Universal + ARM64
binaries.
2026-04-16 07:51:32 -07:00
Alan Wizemann 117a0ee9dd fix: MCP Servers — preserve all entries when patching config.yaml
Fixes a bug where adding a second MCP server caused the first to disappear
from the list view, and any args containing YAML reserved characters (e.g.
"@modelcontextprotocol/server-fetch") corrupted the config file.

Three root causes in HermesFileService MCP YAML patching:

1. extractMCPBlock extended through trailing comments to EOF when
   mcp_servers was the last top-level key in config.yaml. Trailing
   comments became part of the "block", so subsequent inserts landed
   at end-of-file rather than inside the entry.

2. patchMCPServerField's entry boundary similarly absorbed trailing
   blanks/comments, making the entry "own" everything until the next
   sibling — or until EOF for the last entry.

3. yamlScalar did not quote values starting with YAML reserved
   indicators (@ * & ? | > ! % , [ ] { } < ` ' "). Args like
   "@modelcontextprotocol/server-fetch" were written bare, producing
   invalid YAML that broke subsequent reads/writes.

Fix: trim trailing blanks/comments off both the block and the entry
in the locator/extractor; quote any scalar starting with a reserved
first character.

Bumps version to 1.5.7 (build 9). Includes signed Universal + ARM64
binaries.

Note: users with an already-corrupted ~/.hermes/config.yaml from the
1.5.6 bug should manually clean up their mcp_servers block (delete the
orphan args at end of file) before upgrading. New writes will be clean.
2026-04-16 07:38:49 -07:00
Alan Wizemann 61d59ba0e4 chore: Re-sign 1.5.6 release binaries
The first 1.5.6 zips contained `linker-signed` bundles with no Sealed
Resources, plus a stray nested scarf.app from a case-insensitive cp.
macOS Gatekeeper rejected the ARM64 download as "damaged"; the
Universal one ran only because the user had already trusted it.

Now both bundles are properly ad-hoc-signed (`Sealed Resources
version=2`) with the hardened runtime preserved. Sizes dropped
significantly (Universal 33MB→16MB, ARM64 27MB→13MB) because the
nested junk is gone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:13:55 -07:00
Alan Wizemann 0a584f6722 chore: Bump version to 1.5.6 and add release binaries
Includes the MCP Servers management UI shipped in 219bca2:
- Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, …)
  or fully custom (stdio command + args, or HTTP URL with bearer auth)
- Per-server detail view: enable/disable, env + headers editor,
  tool include/exclude filters, resources/prompts toggles, request
  and connect timeouts, OAuth token detection + clearing
- One-click "Test Connection" runs `hermes mcp test` and surfaces
  the discovered tool list
- Gateway-restart banner after config changes that need a reload

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:45:19 -07:00
Alan Wizemann 219bca264e feat: Add MCP Servers management UI
Full MCP server lifecycle: add (stdio + HTTP), edit, remove, test,
enable/disable. YAML config patching for args, env, headers, tool
filters, timeouts. OAuth token detection + deletion. Preset picker
for common MCP servers. Gateway restart banner after config changes.

New sidebar section "MCP Servers" under Manage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:53:55 -07:00
Alan Wizemann c7e6a809ed chore: Bump version to 1.5.5 and add release binaries
Ship Hermes v0.9.0 compatibility plus new features (log component
filter, session pill, Fast Mode, Backup/Restore, iMessage, /compress,
Discord threads). README lists both universal and ARM64 downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:32:16 -07:00
Alan Wizemann c5d6116f99 feat: Add Hermes v0.9.0 compatibility and new feature surfaces
- Log parser: session-ID tag in v0.9.0 log format is now an optional
  capture group; session pill renders inline and tap-filters the view.
- Logs: component filter (Gateway/Agent/Tools/CLI/Cron) and bounded
  logger column with middle truncation.
- Gateway stop: uses `hermes gateway stop` CLI (v0.9.0's launchctl
  bootout fix) with SIGTERM as fallback.
- HermesConfig: new keys for Fast Mode (service_tier), gateway notify
  interval, force IPv4, context engine, interim assistant messages,
  and Honcho eager init (camelCase per PR #6995).
- Settings: new Performance, Network, Advanced, and Backup & Restore
  sections that call `hermes backup` / `hermes import` off the main
  actor; robust zip-path extraction via regex.
- Platforms: iMessage (BlueBubbles) added to KnownPlatforms and
  icon map.
- Cron: Discord thread delivery (`discord:chat:thread`) renders as
  "Discord thread X in Y".
- Chat: `/compress <focus>` button appears when ACP advertises the
  command; optional focus sheet sends through existing prompt path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:59:46 -07:00
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:16:52 -04:00
49 changed files with 2938 additions and 81 deletions
+4
View File
@@ -38,3 +38,7 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
```
## Hermes Version
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
+17 -10
View File
@@ -23,15 +23,16 @@
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, and permission request dialogs; **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, 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)
- **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 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
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Fast Mode service tier, interim assistant messages, gateway notify interval, force IPv4, context engine, Honcho eager init, Docker environment, command allowlist, credential management, and one-click **Backup & Restore** via `hermes backup` / `hermes import`
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
- **Menu Bar** — Status icon showing Hermes running state with quick actions
@@ -39,7 +40,7 @@
- macOS 14.6+ (Sonoma)
- Xcode 16.0+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support)
### Compatibility
@@ -49,7 +50,8 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08, latest) | Verified |
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13, latest) | Verified |
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
@@ -57,11 +59,13 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
1. Unzip and drag **Scarf.app** to Applications
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
### Build from Source
@@ -96,6 +100,7 @@ scarf/
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
@@ -122,6 +127,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.8;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.8;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+2
View File
@@ -32,6 +32,8 @@ struct ContentView: View {
SkillsView()
case .tools:
ToolsView()
case .mcpServers:
MCPServersView()
case .gateway:
GatewayView()
case .cron:
+13 -1
View File
@@ -23,6 +23,12 @@ struct HermesConfig: Sendable {
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
var serviceTier: String
var gatewayNotifyInterval: Int
var forceIPv4: Bool
var contextEngine: String
var interimAssistantMessages: Bool
var honchoInitOnSessionStart: Bool
static let empty = HermesConfig(
model: "unknown",
@@ -46,7 +52,13 @@ struct HermesConfig: Sendable {
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: ""
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false
)
}
@@ -22,6 +22,7 @@ enum HermesPaths: Sendable {
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf"
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
}
// MARK: - SQLite Constants
@@ -41,6 +41,21 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
default: return "questionmark.circle"
}
}
var deliveryDisplay: String? {
guard let deliver, !deliver.isEmpty else { return nil }
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
if deliver.hasPrefix("discord:") {
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2 {
return "Discord thread \(parts[1]) in \(parts[0])"
}
if parts.count == 1 {
return "Discord \(parts[0])"
}
}
return deliver
}
}
struct CronSchedule: Sendable, Codable {
@@ -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
}
+2
View File
@@ -30,6 +30,7 @@ enum KnownPlatforms {
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
]
static func icon(for platform: String) -> String {
@@ -46,6 +47,7 @@ enum KnownPlatforms {
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
default: return "bubble.left"
}
}
@@ -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 }
}
}
+4 -2
View File
@@ -66,8 +66,10 @@ actor ACPClient {
proc.standardOutput = stdout
proc.standardError = stderr
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution
var env = ProcessInfo.processInfo.environment
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution.
// 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")
proc.environment = env
@@ -87,7 +87,13 @@ struct HermesFileService: Sendable {
memoryProvider: values["memory.provider"] ?? "",
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: values["memory.profile"] ?? ""
memoryProfile: values["memory.profile"] ?? "",
serviceTier: values["agent.service_tier"] ?? "normal",
gatewayNotifyInterval: Int(values["agent.gateway_notify_interval"] ?? "") ?? 600,
forceIPv4: values["network.force_ipv4"] == "true",
contextEngine: values["context.engine"] ?? "compressor",
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
honchoInitOnSessionStart: values["honcho.initOnSessionStart"] == "true"
)
}
@@ -240,6 +246,652 @@ struct HermesFileService: Sendable {
return result
}
// MARK: - MCP Servers
func loadMCPServers() -> [HermesMCPServer] {
guard let yaml = readFile(HermesPaths.configYAML) else { return [] }
let parsed = parseMCPServersBlock(yaml: yaml)
let fm = FileManager.default
return parsed.map { server in
let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json"
let hasToken = fm.fileExists(atPath: tokenPath)
guard hasToken != server.hasOAuthToken else { return server }
return HermesMCPServer(
name: server.name,
transport: server.transport,
command: server.command,
args: server.args,
url: server.url,
auth: server.auth,
env: server.env,
headers: server.headers,
timeout: server.timeout,
connectTimeout: server.connectTimeout,
enabled: server.enabled,
toolsInclude: server.toolsInclude,
toolsExclude: server.toolsExclude,
resourcesEnabled: server.resourcesEnabled,
promptsEnabled: server.promptsEnabled,
hasOAuthToken: hasToken
)
}
}
/// Creates the server entry via `hermes mcp add` with only the command (no args).
/// Args are written separately via `setMCPServerArgs` to avoid argparse issues with `-`-prefixed args like `-y`.
/// Pipes `y\n` because the CLI prompts to save even when the initial connection check fails (which it will, since we intentionally add no args first).
@discardableResult
func addMCPServerStdio(name: String, command: String, args: [String]) -> (exitCode: Int32, output: String) {
let addResult = runHermesCLI(
args: ["mcp", "add", name, "--command", command],
timeout: 45,
stdinInput: "y\ny\ny\n"
)
guard addResult.exitCode == 0 else { return addResult }
if !args.isEmpty {
_ = setMCPServerArgs(name: name, args: args)
}
return addResult
}
@discardableResult
func addMCPServerHTTP(name: String, url: String, auth: String?) -> (exitCode: Int32, output: String) {
var cliArgs: [String] = ["mcp", "add", name, "--url", url]
if let auth, !auth.isEmpty {
cliArgs.append(contentsOf: ["--auth", auth])
}
return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n")
}
@discardableResult
func setMCPServerArgs(name: String, args: [String]) -> Bool {
patchMCPServerField(name: name) { entryLines in
Self.replaceOrInsertList(header: "args", items: args, in: &entryLines)
}
}
@discardableResult
func removeMCPServer(name: String) -> (exitCode: Int32, output: String) {
runHermesCLI(args: ["mcp", "remove", name], timeout: 30)
}
nonisolated func testMCPServer(name: String) async -> MCPTestResult {
let started = Date()
let service = self
let result = await Task.detached { () -> (Int32, String) in
service.runHermesCLI(args: ["mcp", "test", name], timeout: 30)
}.value
let elapsed = Date().timeIntervalSince(started)
let tools = Self.parseToolListFromTestOutput(result.1)
// 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
func isHermesRunning() -> Bool {
@@ -268,10 +920,130 @@ struct HermesFileService: Sendable {
@discardableResult
func stopHermes() -> Bool {
// v0.9.0 fixed `hermes gateway stop` so it issues `launchctl bootout` and
// waits for exit. Use the CLI to avoid racing launchd's KeepAlive respawn.
if runHermesCLI(args: ["gateway", "stop"]).exitCode == 0 {
return true
}
guard let pid = hermesPID() else { return false }
return kill(pid, SIGTERM) == 0
}
nonisolated func hermesBinaryPath() -> String? {
let candidates = [
("\(NSHomeDirectory())/.local/bin/hermes"),
"/opt/homebrew/bin/hermes",
"/usr/local/bin/hermes"
]
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
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
guard let binary = hermesBinaryPath() else { return (-1, "") }
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = args
process.environment = Self.enrichedEnvironment()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let stdinPipe { process.standardInput = stdinPipe }
defer {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
try? stdinPipe?.fileHandleForReading.close()
try? stdinPipe?.fileHandleForWriting.close()
}
do {
try process.run()
if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
try? stdinPipe.fileHandleForWriting.close()
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning { process.terminate() }
process.waitUntilExit()
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "")
return (process.terminationStatus, combined)
} catch {
return (-1, error.localizedDescription)
}
}
// MARK: - File I/O
private func readFile(_ path: String) -> String? {
@@ -19,7 +19,8 @@ final class HermesFileWatcher {
HermesPaths.agentLog,
HermesPaths.errorsLog,
HermesPaths.gatewayLog,
HermesPaths.projectsRegistry
HermesPaths.projectsRegistry,
HermesPaths.mcpTokensDir
]
for path in paths {
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
let id: Int
let timestamp: String
let level: LogLevel
let sessionId: String?
let logger: String
let message: String
let raw: String
@@ -72,23 +73,30 @@ actor HermesLogService {
private func parseLine(_ line: String) -> LogEntry {
entryCounter += 1
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
// Session tag is optional earlier Hermes releases and out-of-session lines omit it.
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
let logger = String(line[Range(match.range(at: 3), in: line)!])
let message = String(line[Range(match.range(at: 4), in: line)!])
let sessionId: String? = {
let range = match.range(at: 3)
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
return String(line[r])
}()
let logger = String(line[Range(match.range(at: 4), in: line)!])
let message = String(line[Range(match.range(at: 5), in: line)!])
return LogEntry(
id: entryCounter,
timestamp: timestamp,
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
sessionId: sessionId,
logger: logger,
message: message,
raw: line
)
}
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
}
}
@@ -187,6 +187,8 @@ final class ChatViewModel {
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
} catch is CancellationError {
acpStatus = "Cancelled"
} catch {
@@ -32,9 +32,26 @@ final class RichChatViewModel {
var messageGroups: [MessageGroup] = []
var isAgentWorking = false
var pendingPermission: PendingPermission?
/// Mutated to trigger a scroll-to-bottom in the message list.
var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var availableCommandNames: Set<String> = []
var supportsCompress: Bool { availableCommandNames.contains("compress") }
var hasMessages: Bool { !messages.isEmpty }
func requestScrollToBottom() {
scrollTrigger = UUID()
}
private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages.
@@ -77,6 +94,11 @@ final class RichChatViewModel {
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
availableCommandNames = []
pendingPermission = nil
}
@@ -91,6 +113,17 @@ final class RichChatViewModel {
await dataService.close()
}
/// Re-fetch session metadata from DB to pick up cost/token updates.
func refreshSessionFromDB() async {
guard let sessionId else { return }
let opened = await dataService.open()
guard opened else { return }
if let session = await dataService.fetchSession(id: sessionId) {
currentSession = session
}
await dataService.close()
}
// MARK: - ACP Event Handling
/// Add a user message immediately (before DB write) for instant UI feedback.
@@ -136,11 +169,20 @@ final class RichChatViewModel {
kind: request.toolCallKind,
options: request.options
)
case .promptComplete:
handlePromptComplete()
case .promptComplete(_, let response):
handlePromptComplete(response: response)
case .connectionLost(let reason):
handleConnectionLost(reason: reason)
case .availableCommands, .unknown:
case .availableCommands(_, let commands):
var names: Set<String> = []
for entry in commands {
if let name = entry["name"] as? String {
// Hermes sends names either as "compress" or "/compress"
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
}
}
availableCommandNames = names
case .unknown:
break
}
}
@@ -188,9 +230,13 @@ final class RichChatViewModel {
buildMessageGroups()
}
private func handlePromptComplete() {
// Finalize any remaining streaming content
private func handlePromptComplete(response: ACPPromptResult) {
finalizeStreamingMessage()
// Accumulate token usage from this prompt
acpInputTokens += response.inputTokens
acpOutputTokens += response.outputTokens
acpThoughtTokens += response.thoughtTokens
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false
buildMessageGroups()
}
@@ -91,9 +91,8 @@ struct ChatView: View {
Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") {
// Already active just ensure we're showing it
viewModel.richChatViewModel.requestScrollToBottom()
}
.disabled(true)
Divider()
}
Button("New Session") {
@@ -105,6 +104,8 @@ struct ChatView: View {
if !viewModel.recentSessions.isEmpty {
Divider()
Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in
Button {
viewModel.resumeSession(session.id)
@@ -120,6 +121,7 @@ struct ChatView: View {
}
}
}
.disabled(session.id == activeSessionId || session.id == originSessionId)
}
}
} label: {
@@ -3,12 +3,29 @@ import SwiftUI
struct RichChatInputBar: View {
let onSend: (String) -> Void
let isEnabled: Bool
var supportsCompress: Bool = false
@State private var text = ""
@State private var showCompressSheet = false
@State private var compressFocus = ""
@FocusState private var isFocused: Bool
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if supportsCompress {
Button {
compressFocus = ""
showCompressSheet = true
} label: {
Image(systemName: "rectangle.compress.vertical")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.help("Compress conversation (/compress)")
}
TextEditor(text: $text)
.font(.body)
.scrollContentBackground(.hidden)
@@ -50,6 +67,34 @@ struct RichChatInputBar: View {
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.sheet(isPresented: $showCompressSheet) {
compressSheet
}
}
private var compressSheet: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Compress Conversation")
.font(.headline)
Text("Optionally focus the summary on a specific topic. Leave blank to compress evenly.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Focus topic (optional)", text: $compressFocus)
.textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("Cancel") { showCompressSheet = false }
Button("Compress") {
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
onSend(command)
showCompressSheet = false
}
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 360)
}
private var canSend: Bool {
@@ -3,6 +3,8 @@ import SwiftUI
struct RichChatMessageList: View {
let groups: [MessageGroup]
let isWorking: Bool
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID()
/// Track the last group's assistant content length to detect streaming updates.
private var scrollAnchor: String {
@@ -30,6 +32,14 @@ struct RichChatMessageList: View {
.padding()
}
.defaultScrollAnchor(.bottom)
// Scroll to bottom when view first appears with content
.onAppear {
if !groups.isEmpty {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: false)
}
}
}
// Scroll on new groups
.onChange(of: groups.count) {
scrollToBottom(proxy: proxy)
@@ -50,6 +60,10 @@ struct RichChatMessageList: View {
.onChange(of: groups.last?.toolCallCount ?? 0) {
scrollToBottom(proxy: proxy)
}
// Scroll on external trigger (e.g., "Return to Active Session" button)
.onChange(of: scrollTrigger) {
scrollToBottom(proxy: proxy)
}
}
}
@@ -14,7 +14,10 @@ struct RichChatView: View {
VStack(spacing: 0) {
SessionInfoBar(
session: richChat.currentSession,
isWorking: richChat.isAgentWorking
isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens
)
Divider()
@@ -28,7 +31,8 @@ struct RichChatView: View {
} else {
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking
isWorking: richChat.isAgentWorking,
scrollTrigger: richChat.scrollTrigger
)
}
@@ -37,7 +41,8 @@ struct RichChatView: View {
onSend: { text in
onSend(text)
},
isEnabled: isEnabled
isEnabled: isEnabled,
supportsCompress: richChat.supportsCompress
)
}
// DB polling fallback for terminal mode only never overwrite ACP messages
@@ -3,6 +3,10 @@ import SwiftUI
struct SessionInfoBar: View {
let session: HermesSession?
let isWorking: Bool
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0
var body: some View {
HStack(spacing: 16) {
@@ -30,11 +34,14 @@ struct SessionInfoBar: View {
Label(model, systemImage: "cpu")
}
Label("\(formatTokens(session.inputTokens)) in / \(formatTokens(session.outputTokens)) out", systemImage: "number")
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
.contentTransition(.numericText())
if session.reasoningTokens > 0 {
Label("\(formatTokens(session.reasoningTokens)) reasoning", systemImage: "brain")
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
if reasonToks > 0 {
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
@@ -72,7 +72,7 @@ struct CronView: View {
Label(job.state, systemImage: job.stateIcon)
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
if let deliver = job.deliver {
if let deliver = job.deliveryDisplay {
Label("Deliver: \(deliver)", systemImage: "paperplane")
}
}
@@ -164,6 +164,9 @@ struct SessionRow: View {
HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
}
.font(.caption)
.foregroundStyle(.secondary)
@@ -7,6 +7,7 @@ final class LogsViewModel {
var entries: [LogEntry] = []
var selectedLogFile: LogFile = .agent
var filterLevel: LogEntry.LogLevel?
var selectedComponent: LogComponent = .all
var searchText = ""
private var pollTimer: Timer?
@@ -26,11 +27,37 @@ final class LogsViewModel {
}
}
enum LogComponent: String, CaseIterable, Identifiable {
case all = "All"
case gateway = "Gateway"
case agent = "Agent"
case tools = "Tools"
case cli = "CLI"
case cron = "Cron"
var id: String { rawValue }
var loggerPrefix: String? {
switch self {
case .all: return nil
case .gateway: return "gateway"
case .agent: return "agent"
case .tools: return "tools"
case .cli: return "cli"
case .cron: return "cron"
}
}
}
var filteredEntries: [LogEntry] {
entries.filter { entry in
let levelOk = filterLevel == nil || entry.level == filterLevel
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
return levelOk && searchOk
let componentOk: Bool = {
guard let prefix = selectedComponent.loggerPrefix else { return true }
return entry.logger.hasPrefix(prefix)
}()
return levelOk && searchOk && componentOk
}
}
@@ -28,6 +28,13 @@ struct LogsView: View {
.pickerStyle(.segmented)
.frame(maxWidth: 300)
Picker("Component", selection: $viewModel.selectedComponent) {
ForEach(LogsViewModel.LogComponent.allCases) { component in
Text(component.rawValue).tag(component)
}
}
.frame(maxWidth: 140)
Spacer()
Picker("Level", selection: $viewModel.filterLevel) {
@@ -58,6 +65,27 @@ struct LogsView: View {
.font(.caption.monospaced().bold())
.foregroundStyle(colorForLevel(entry.level))
.frame(width: 60, alignment: .leading)
if let sessionId = entry.sessionId {
Button {
viewModel.searchText = sessionId
} label: {
Text(sessionId)
.font(.system(.caption2, design: .monospaced))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 3))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Filter to session \(sessionId)")
}
Text(entry.logger)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 140, alignment: .leading)
Text(entry.message)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
@@ -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))
}
}
@@ -1,5 +1,6 @@
import Foundation
import AppKit
import UniformTypeIdentifiers
@Observable
final class SettingsViewModel {
@@ -58,6 +59,79 @@ final class SettingsViewModel {
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
// Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes).
func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") }
// MARK: - Backup & Restore (v0.9.0)
var backupInProgress = false
func runBackup() {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["backup"], timeout: 300)
let zipPath = Self.extractZipPath(from: result.output)
await MainActor.run {
self.backupInProgress = false
if result.exitCode == 0 {
if let zipPath {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)])
self.saveMessage = "Backup saved"
} else {
self.saveMessage = "Backup complete"
}
} else {
self.saveMessage = "Backup failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
}
}
func runRestore(from url: URL) {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
await MainActor.run {
self.backupInProgress = false
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
if result.exitCode == 0 {
self.load()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.saveMessage = nil
}
}
}
}
/// Pull the first absolute `.zip` path out of `hermes backup` stdout.
/// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)".
nonisolated static func extractZipPath(from output: String) -> String? {
let pattern = #"(/[^\s]+\.zip)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(output.startIndex..., in: output)
guard let match = regex.firstMatch(in: output, range: range),
let r = Range(match.range(at: 1), in: output) else { return nil }
return String(output[r])
}
func presentRestorePicker() -> URL? {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = "Choose a Hermes backup archive to restore"
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
func removeAuth() {
let result = runHermes(["auth", "remove"])
@@ -19,6 +19,10 @@ struct SettingsView: View {
}
voiceSection
memorySection
performanceSection
networkSection
advancedSection
backupSection
pathsSection
rawConfigSection
}
@@ -85,6 +89,7 @@ struct SettingsView: View {
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
}
}
@@ -139,6 +144,87 @@ struct SettingsView: View {
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
if viewModel.config.memoryProvider == "honcho" {
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
}
}
}
// MARK: - Performance (v0.9.0)
private var performanceSection: some View {
SettingsSection(title: "Performance", icon: "bolt") {
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
viewModel.setServiceTier(on ? "fast" : "normal")
}
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600) { viewModel.setGatewayNotifyInterval($0) }
}
}
// MARK: - Network (v0.9.0)
private var networkSection: some View {
SettingsSection(title: "Network", icon: "network") {
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
}
}
// MARK: - Advanced (v0.9.0)
private var advancedSection: some View {
SettingsSection(title: "Advanced", icon: "slider.horizontal.3") {
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
}
}
// MARK: - Backup & Restore (v0.9.0)
@State private var showRestoreConfirm = false
@State private var pendingRestoreURL: URL?
private var backupSection: some View {
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
HStack {
Text("Archive")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Button {
viewModel.runBackup()
} label: {
Label("Backup Now", systemImage: "arrow.down.doc")
}
.controlSize(.small)
.disabled(viewModel.backupInProgress)
Button {
if let url = viewModel.presentRestorePicker() {
pendingRestoreURL = url
showRestoreConfirm = true
}
} label: {
Label("Restore…", systemImage: "arrow.up.doc")
}
.controlSize(.small)
.disabled(viewModel.backupInProgress)
if viewModel.backupInProgress {
ProgressView().controlSize(.small)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
Button("Restore", role: .destructive) {
if let url = pendingRestoreURL {
viewModel.runRestore(from: url)
}
pendingRestoreURL = nil
}
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
} message: {
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
}
}
@@ -1,40 +1,56 @@
import Foundation
import os
@Observable
final class ToolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = []
var mcpStatus: String = ""
var isLoading = false
var availablePlatforms: [HermesToolPlatform] = []
func load() {
loadPlatforms()
loadTools(for: selectedPlatform)
loadMCPStatus()
@MainActor
func load() async {
isLoading = true
await loadPlatforms()
await loadTools(for: selectedPlatform)
await loadMCPStatus()
isLoading = false
}
func switchPlatform(_ platform: HermesToolPlatform) {
@MainActor
func switchPlatform(_ platform: HermesToolPlatform) async {
selectedPlatform = platform
loadTools(for: platform)
await loadTools(for: platform)
}
func toggleTool(_ tool: HermesToolset) {
let action = tool.enabled ? "disable" : "enable"
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode == 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
@MainActor
func toggleTool(_ tool: HermesToolset) async {
guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
toolsets[idx].enabled.toggle()
let newEnabled = toolsets[idx].enabled
let action = newEnabled ? "enable" : "disable"
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode != 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled = !newEnabled
}
}
}
private func loadPlatforms() {
@MainActor
private func loadPlatforms() async {
let config: String
do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
config = try await Task.detached {
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
}.value
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
config = ""
}
var platforms: [HermesToolPlatform] = []
@@ -67,15 +83,15 @@ final class ToolsViewModel {
}
}
private func loadTools(for platform: HermesToolPlatform) {
isLoading = true
let result = runHermes(["tools", "list", "--platform", platform.name])
@MainActor
private func loadTools(for platform: HermesToolPlatform) async {
let result = await runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output)
isLoading = false
}
private func loadMCPStatus() {
let result = runHermes(["mcp", "list"])
@MainActor
private func loadMCPStatus() async {
let result = await runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -121,21 +137,32 @@ final class ToolsViewModel {
return "🔧"
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
await Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return (output, process.terminationStatus)
} catch {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return ("", -1)
}
}.value
}
}
@@ -14,7 +14,7 @@ struct ToolsView: View {
}
}
.navigationTitle("Tools")
.onAppear { viewModel.load() }
.task { await viewModel.load() }
}
private var platformPicker: some View {
@@ -23,7 +23,7 @@ struct ToolsView: View {
get: { viewModel.selectedPlatform.name },
set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
viewModel.switchPlatform(platform)
Task { await viewModel.switchPlatform(platform) }
}
}
)) {
@@ -46,7 +46,7 @@ struct ToolsView: View {
LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) {
viewModel.toggleTool(tool)
await viewModel.toggleTool(tool)
}
}
}
@@ -78,7 +78,7 @@ struct ToolsView: View {
struct ToolRow: View {
let tool: HermesToolset
let onToggle: () -> Void
let onToggle: () async -> Void
var body: some View {
HStack(spacing: 12) {
@@ -95,7 +95,7 @@ struct ToolRow: View {
Spacer()
Toggle("", isOn: Binding(
get: { tool.enabled },
set: { _ in onToggle() }
set: { _ in Task { await onToggle() } }
))
.toggleStyle(.switch)
.labelsHidden()
@@ -10,6 +10,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case memory = "Memory"
case skills = "Skills"
case tools = "Tools"
case mcpServers = "MCP Servers"
case gateway = "Gateway"
case cron = "Cron"
case health = "Health"
@@ -29,6 +30,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .memory: return "brain"
case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .mcpServers: return "puzzlepiece.extension"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath"
case .health: return "stethoscope"
+1 -1
View File
@@ -25,7 +25,7 @@ struct SidebarView: View {
}
}
Section("Manage") {
ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}