feat(mcp): add SSE transport support gated on hasMCPSSETransport

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
  <u> --transport sse [--sse-read-timeout <t>]`.
- 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-09 18:34:27 +02:00
parent 3e470c7155
commit c81a8a56e8
8 changed files with 197 additions and 15 deletions
@@ -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 ?? ""
}
}
}
@@ -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 <int>` and persisted as `sse_read_timeout: <int>`
/// 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)
@@ -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,
@@ -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 {
@@ -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()
}
@@ -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)
@@ -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()
}
}
@@ -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")