From c81a8a56e8dd535af5bf77a238326dd571e191f8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:34:27 +0200 Subject: [PATCH] feat(mcp): add SSE transport support gated on hasMCPSSETransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends MCPTransport with a third .sse case (alongside stdio + http), plumbed through the YAML parser, add-server form, list view, detail view, and editor. The add-server form filters .sse out of the segmented picker on pre-v0.13 hosts (capability-gated on hasMCPSSETransport) so Hermes never sees a transport flag it can't parse. The editor renders a third numeric "SSE read timeout" field only for .sse servers. YAML layer: - HermesMCPServer.sseReadTimeout: Int? — defaulted in init, decoded from `sse_read_timeout` scalar. - parseMCPServersBlock: 3-way transport discriminator — `transport: sse` scalar wins, then url-bearing entries default to .http (v0.12 shape), command-bearing to .stdio. Pre-v0.13 entries are byte-for-byte unaffected. - HermesFileService.addMCPServerSSE writes via `hermes mcp add --url --transport sse [--sse-read-timeout ]`. - HermesFileService.setMCPServerSSETimeout patches the scalar via the same surgical patcher used by setMCPServerTimeouts. TODO markers (WS-7-Q1/Q2/Q3) flag the wire-format unknowns the plan called out — verify against a v0.13 Hermes install during integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesMCPServer.swift | 17 ++++- .../Core/Services/HermesFileService.swift | 51 ++++++++++++- .../ViewModels/MCPServerEditorViewModel.swift | 11 +++ .../ViewModels/MCPServersViewModel.swift | 36 +++++++++ .../Views/MCPServerAddCustomView.swift | 74 ++++++++++++++++--- .../Views/MCPServerDetailView.swift | 5 ++ .../Views/MCPServerEditorView.swift | 10 +++ .../MCPServers/Views/MCPServersView.swift | 8 ++ 8 files changed, 197 insertions(+), 15 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift index 2578a6e..0be0484 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift @@ -3,6 +3,10 @@ import Foundation public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable { case stdio case http + /// Server-Sent Events transport. Hermes v0.13+ only. + // TODO(WS-7-Q1): Verify Hermes uses the literal `sse` transport name + // (vs. `streamable-http`/`http-sse`/etc.) once a v0.13 host is on hand. + case sse public var id: String { rawValue } @@ -11,6 +15,7 @@ public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiabl switch self { case .stdio: return "Local (stdio)" case .http: return "Remote (HTTP)" + case .sse: return "Remote (SSE)" } } #endif @@ -33,6 +38,12 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { public let resourcesEnabled: Bool public let promptsEnabled: Bool public let hasOAuthToken: Bool + /// Hermes-side keepalive interval (seconds) for SSE transport. `nil` + /// when the YAML doesn't specify `sse_read_timeout` (Hermes default + /// applies). Pre-v0.13 hosts always have this as `nil`. + // TODO(WS-7-Q2): Default is assumed to be 300s per WS-7 plan; placeholder + // copy uses that. Verify against `~/.hermes/hermes-agent/hermes_cli/mcp.py`. + public let sseReadTimeout: Int? public init( @@ -51,7 +62,8 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { toolsExclude: [String], resourcesEnabled: Bool, promptsEnabled: Bool, - hasOAuthToken: Bool + hasOAuthToken: Bool, + sseReadTimeout: Int? = nil ) { self.name = name self.transport = transport @@ -69,6 +81,7 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { self.resourcesEnabled = resourcesEnabled self.promptsEnabled = promptsEnabled self.hasOAuthToken = hasOAuthToken + self.sseReadTimeout = sseReadTimeout } public var id: String { name } @@ -79,6 +92,8 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { return (command ?? "") + argString case .http: return url ?? "" + case .sse: + return url ?? "" } } } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 3938d09..7bcedc3 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -599,7 +599,8 @@ struct HermesFileService: Sendable { toolsExclude: server.toolsExclude, resourcesEnabled: server.resourcesEnabled, promptsEnabled: server.promptsEnabled, - hasOAuthToken: hasToken + hasOAuthToken: hasToken, + sseReadTimeout: server.sseReadTimeout ) } } @@ -630,6 +631,37 @@ struct HermesFileService: Sendable { return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n") } + /// Adds an SSE-transport MCP server. v0.13+ only — caller is responsible + /// for capability-gating; pre-v0.13 hosts will reject the `--transport` + /// flag at argparse time. The optional `sseReadTimeout` is passed via + /// `--sse-read-timeout ` and persisted as `sse_read_timeout: ` + /// in the YAML entry. + // TODO(WS-7-Q3): Verify exact CLI flag spelling against `hermes mcp add --help` + // on a v0.13 install. Plan assumes `--transport sse` + `--sse-read-timeout`; + // alternatives could be `--sse` (boolean) + `--read-timeout`. + @discardableResult + nonisolated func addMCPServerSSE(name: String, url: String, sseReadTimeout: Int?) -> (exitCode: Int32, output: String) { + var cliArgs: [String] = ["mcp", "add", name, "--url", url, "--transport", "sse"] + if let timeout = sseReadTimeout { + cliArgs.append(contentsOf: ["--sse-read-timeout", String(timeout)]) + } + return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n") + } + + /// Updates the `sse_read_timeout` scalar in-place via the same surgical + /// patcher used by `setMCPServerTimeouts`. Pass `nil` to remove the + /// scalar entirely (Hermes default applies). + @discardableResult + nonisolated func setMCPServerSSETimeout(name: String, sseReadTimeout: Int?) -> Bool { + patchMCPServerField(name: name) { entryLines in + if let timeout = sseReadTimeout { + Self.replaceOrInsertScalar(key: "sse_read_timeout", value: String(timeout), in: &entryLines) + } else { + Self.removeScalar(key: "sse_read_timeout", in: &entryLines) + } + } + } + @discardableResult nonisolated func setMCPServerArgs(name: String, args: [String]) -> Bool { patchMCPServerField(name: name) { entryLines in @@ -812,11 +844,23 @@ struct HermesFileService: Sendable { func flush() { guard let name = currentName else { return } - let transport: MCPTransport = fields["url"] != nil ? .http : .stdio + // 3-way transport discriminator: an explicit `transport: sse` scalar + // wins (Hermes v0.13+ emits it for SSE servers); otherwise URL-bearing + // entries fall back to .http (v0.12 shape) and command-bearing entries + // to .stdio. This preserves byte-for-byte round-trip on existing files + // — pre-v0.13 entries have no `transport:` key so they parse identically. + // TODO(WS-7-Q1): Verify Hermes v0.13 actually emits `transport: sse` + // (vs. inferring from the schema/url shape) once a v0.13 host is on hand. + let transport: MCPTransport = { + if fields["transport"]?.lowercased() == "sse" { return .sse } + if fields["url"] != nil { return .http } + return .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 sseReadTimeout = fields["sse_read_timeout"].flatMap(Int.init) let server = HermesMCPServer( name: name, transport: transport, @@ -833,7 +877,8 @@ struct HermesFileService: Sendable { toolsExclude: excludeList, resourcesEnabled: resources, promptsEnabled: prompts, - hasOAuthToken: false + hasOAuthToken: false, + sseReadTimeout: sseReadTimeout ) servers.append(server) diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift index 6f85fa2..636f3a6 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift @@ -21,6 +21,9 @@ final class MCPServerEditorViewModel { var promptsEnabled: Bool var timeoutDraft: String var connectTimeoutDraft: String + /// SSE-only — renders as a third numeric on `.sse` servers. Empty string + /// means "use Hermes default" (writer drops the scalar). + var sseReadTimeoutDraft: String var showSecrets: Bool = false var isSaving: Bool = false var saveError: String? @@ -37,6 +40,7 @@ final class MCPServerEditorViewModel { self.promptsEnabled = server.promptsEnabled self.timeoutDraft = server.timeout.map { String($0) } ?? "" self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? "" + self.sseReadTimeoutDraft = server.sseReadTimeout.map { String($0) } ?? "" } func appendEnvRow() { @@ -69,6 +73,8 @@ final class MCPServerEditorViewModel { 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 trimmedSSE = sseReadTimeoutDraft.trimmingCharacters(in: .whitespaces) + let sseTimeoutValue: Int? = trimmedSSE.isEmpty ? nil : Int(trimmedSSE) let service = fileService let transport = server.transport @@ -87,6 +93,11 @@ final class MCPServerEditorViewModel { if !service.setMCPServerEnv(name: name, env: envMap) { ok = false } case .http: if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false } + case .sse: + // SSE servers carry headers like .http does, plus an + // optional sse_read_timeout written below. + if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false } + if !service.setMCPServerSSETimeout(name: name, sseReadTimeout: sseTimeoutValue) { ok = false } } if !service.updateMCPToolFilters( name: name, diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift index 71deaa7..ceef6e1 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift @@ -42,6 +42,10 @@ final class MCPServersViewModel { filteredServers.filter { $0.transport == .http } } + var sseServers: [HermesMCPServer] { + filteredServers.filter { $0.transport == .sse } + } + var selectedServer: HermesMCPServer? { guard let name = selectedServerName else { return nil } return servers.first(where: { $0.name == name }) @@ -167,6 +171,11 @@ final class MCPServersViewModel { url: preset.url ?? "", auth: preset.auth ) + case .sse: + // No SSE-transport presets ship today; the preset picker + // only surfaces stdio/http servers. Treat as a no-op + // failure if a preset somehow declares .sse. + addResult = (exitCode: 1, output: "SSE-transport presets are not supported.") } guard addResult.exitCode == 0 else { await MainActor.run { @@ -196,6 +205,11 @@ final class MCPServersViewModel { result = fileService.addMCPServerStdio(name: name, command: command, args: args) case .http: result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth) + case .sse: + // Routed through addCustomSSE; this branch is unreachable from + // the add-server form (which dispatches per-transport in submit()) + // but kept so the switch is exhaustive without `@unknown default`. + result = (exitCode: 1, output: "SSE servers must be added via addCustomSSE.") } await MainActor.run { if result.exitCode == 0 { @@ -211,6 +225,28 @@ final class MCPServersViewModel { } } + /// v0.13+ SSE-transport server creation. Caller is responsible for + /// capability-gating; the form filters `.sse` out of `availableTransports` + /// when `hasMCPSSETransport` is false, so this method is unreachable + /// from the UI on pre-v0.13 hosts. + func addCustomSSE(name: String, url: String, sseReadTimeout: Int?) { + let fileService = self.fileService + Task.detached { + let result = fileService.addMCPServerSSE(name: name, url: url, sseReadTimeout: sseReadTimeout) + 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 { diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift index 88da9cc..431c529 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift @@ -6,12 +6,26 @@ struct MCPServerAddCustomView: View { let viewModel: MCPServersViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.hermesCapabilities) private var capabilitiesStore @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" + @State private var sseReadTimeout: String = "" + + /// `.sse` is a v0.13+ surface; pre-v0.13 hosts only see stdio + http. + /// Iterating `MCPTransport.allCases` directly would render the SSE + /// segment unconditionally and Hermes would reject the resulting CLI + /// invocation at argparse time. + private var availableTransports: [MCPTransport] { + var t: [MCPTransport] = [.stdio, .http] + if capabilitiesStore?.capabilities.hasMCPSSETransport ?? false { + t.append(.sse) + } + return t + } var body: some View { VStack(spacing: 0) { @@ -44,17 +58,20 @@ struct MCPServerAddCustomView: View { } sectionBox(title: "Transport") { Picker("", selection: $transport) { - ForEach(MCPTransport.allCases) { t in + ForEach(availableTransports) { t in Text(t.displayName).tag(t) } } .pickerStyle(.segmented) .labelsHidden() } - if transport == .stdio { + switch transport { + case .stdio: stdioSection - } else { + case .http: httpSection + case .sse: + sseSection } Text("Env vars, headers, and tool filters can be edited after the server is added.") .font(.caption) @@ -112,6 +129,28 @@ struct MCPServerAddCustomView: View { } } + private var sseSection: some View { + sectionBox(title: "Endpoint (SSE)") { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("URL").font(.caption.bold()) + TextField("https://.../sse", text: $url) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + VStack(alignment: .leading, spacing: 4) { + Text("SSE Read Timeout (seconds)").font(.caption.bold()) + TextField("default 300", text: $sseReadTimeout) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 140) + Text("Hermes-side keepalive interval. Leave blank to use the default.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + private var canSubmit: Bool { let trimmedName = name.trimmingCharacters(in: .whitespaces) guard !trimmedName.isEmpty else { return false } @@ -120,6 +159,8 @@ struct MCPServerAddCustomView: View { return !command.trimmingCharacters(in: .whitespaces).isEmpty case .http: return !url.trimmingCharacters(in: .whitespaces).isEmpty + case .sse: + return !url.trimmingCharacters(in: .whitespaces).isEmpty } } @@ -130,14 +171,25 @@ struct MCPServerAddCustomView: View { .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 - ) + switch transport { + case .stdio, .http: + viewModel.addCustom( + name: trimmedName, + transport: transport, + command: command.trimmingCharacters(in: .whitespaces), + args: args, + url: url.trimmingCharacters(in: .whitespaces), + auth: resolvedAuth + ) + case .sse: + let trimmedTimeout = sseReadTimeout.trimmingCharacters(in: .whitespaces) + let parsedTimeout: Int? = trimmedTimeout.isEmpty ? nil : Int(trimmedTimeout) + viewModel.addCustomSSE( + name: trimmedName, + url: url.trimmingCharacters(in: .whitespaces), + sseReadTimeout: parsedTimeout + ) + } dismiss() } diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift index 1fc8634..5db184a 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift @@ -127,6 +127,11 @@ struct MCPServerDetailView: View { if let auth = server.auth, !auth.isEmpty { summaryRow(label: "Auth", value: auth) } + case .sse: + summaryRow(label: "URL", value: server.url ?? "—") + if let timeout = server.sseReadTimeout { + summaryRow(label: "Read TO", value: "\(timeout)s") + } } } .padding(ScarfSpace.s3) diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift index 4062c9f..8bc8a75 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift @@ -186,6 +186,16 @@ struct MCPServerEditorView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: 140) } + if viewModel.server.transport == .sse { + VStack(alignment: .leading, spacing: 4) { + Text("SSE read timeout") + .font(.caption) + .foregroundStyle(.secondary) + TextField("default 300", text: $viewModel.sseReadTimeoutDraft) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 140) + } + } Spacer() } } diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift index 12210a0..4d86e28 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift @@ -132,6 +132,14 @@ struct MCPServersView: View { } } } + if !viewModel.sseServers.isEmpty { + Section("Remote (SSE)") { + ForEach(viewModel.sseServers) { server in + serverRow(server) + .tag(server.name as String?) + } + } + } if viewModel.servers.isEmpty && !viewModel.isLoading { Section { Text("No servers configured yet")