Files
scarf/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift
T
Alan Wizemann c81a8a56e8 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>
2026-05-09 18:34:27 +02:00

219 lines
8.2 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfDesign
struct MCPServersView: View {
@State private var viewModel: MCPServersViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: MCPServersViewModel(context: context))
}
var body: some View {
VStack(spacing: 0) {
pageHeader
HSplitView {
serversList
.frame(minWidth: 260, idealWidth: 320)
serverDetail
.frame(minWidth: 500)
}
}
.background(ScarfColor.backgroundPrimary)
.navigationTitle("MCP Servers")
.loadingOverlay(
viewModel.isLoading,
label: "Loading MCP servers…",
isEmpty: viewModel.servers.isEmpty
)
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
.toolbar {
ToolbarItem(placement: .primaryAction) {
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 pageHeader: some View {
HStack(alignment: .top, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 2) {
Text("MCP Servers")
.scarfStyle(.title2)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Model Context Protocol endpoints — \(viewModel.servers.count) configured.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer()
HStack(spacing: ScarfSpace.s2) {
Button {
viewModel.testAll()
} label: {
Label("Test all", systemImage: "bolt.horizontal")
}
.buttonStyle(ScarfGhostButton())
.disabled(viewModel.servers.isEmpty)
Button {
viewModel.showPresetPicker = true
} label: {
Label("From preset", systemImage: "square.grid.2x2")
}
.buttonStyle(ScarfSecondaryButton())
Button {
viewModel.showAddCustom = true
} label: {
Label("Add server", systemImage: "plus")
}
.buttonStyle(ScarfPrimaryButton())
}
.fixedSize(horizontal: true, vertical: false)
}
.padding(.horizontal, ScarfSpace.s6)
.padding(.top, ScarfSpace.s5)
.padding(.bottom, ScarfSpace.s4)
.overlay(
Rectangle().fill(ScarfColor.border).frame(height: 1),
alignment: .bottom
)
}
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.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")
.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 ? ScarfColor.accent : ScarfColor.foregroundMuted)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
if !server.enabled {
Text("Disabled")
.font(ScarfFont.caption2)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
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 ? ScarfColor.success : ScarfColor.danger)
.help(result.succeeded ? Text("\(result.tools.count) tools") : Text("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)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.accentActive)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.accentTint)
}
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)
}
}
}
}