Files
Termind/TFM/Features/Settings/MCPSettings.swift
T

126 lines
4.4 KiB
Swift

import AgentKit
import SwiftUI
/// The "MCP" section: servers whose tools the assistant may call.
///
/// HTTP only, and that is a property of the app rather than a shortcut — Termind is
/// sandboxed, so it cannot launch the local process a stdio server would need.
/// The footnote says so, because "why is there no stdio option" is otherwise a
/// reasonable thing to wonder for a while.
struct MCPSettings: View {
@Environment(AppModel.self) private var app
let edit: (MCPServer.ID, Bool) -> Void
let openServer: () -> Void
var body: some View {
Section {
if !app.aiConfiguration.conflictingMCPServerIDs.isEmpty {
Label(
"Some MCP servers have duplicate namespaces and are paused. Edit their namespace IDs to resolve the conflict.",
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.orange)
}
ForEach(app.aiConfiguration.mcpServers) { server in
MCPServerRow(server: server) { edit(server.id, false) }
}
FormNavigationAction(title: "Add Server") {
guard app.proAccess.require(.remoteMCP) else { return }
let server = MCPServer(name: "", namespaceID: "")
app.aiConfiguration.upsert(server)
edit(server.id, true)
}
Text("Termind connects directly from this Mac over HTTP.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let error = app.aiConfiguration.storageError {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
FormNavigationAction(title: "Server", action: openServer)
} header: {
Text("MCP")
}
}
}
private struct MCPServerRow: View {
let server: MCPServer
let edit: () -> Void
@Environment(AppModel.self) private var app
var body: some View {
HStack(spacing: Metrics.Space.m) {
StatusDot(status: app.aiConfiguration.mcpStatus(of: server.id))
.frame(width: Metrics.Sidebar.rowIcon)
VStack(alignment: .leading, spacing: 1) {
Text(server.name)
.lineLimit(1)
if !server.namespaceID.isEmpty {
Text(server.namespaceID)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(server.url.isEmpty ? String(localized: "No URL") : server.url)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: Metrics.Space.m)
CardChip(text: server.transport.displayName)
if server.tools.isEmpty {
CardChip(text: String(localized: "Not Connected"), symbol: "questionmark.circle")
} else {
CardChip(text: "\(server.tools.count)", symbol: "wrench.and.screwdriver")
}
Toggle("", isOn: enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.mini)
.accessibilityLabel(Text("Enable \(server.name)"))
Button {
guard app.proAccess.require(.remoteMCP) else { return }
edit()
} label: {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Edit \(server.name)"))
}
.contentShape(.rect)
.onTapGesture {
guard app.proAccess.require(.remoteMCP) else { return }
edit()
}
.help(Text(app.aiConfiguration.mcpStatus(of: server.id).label))
}
private var enabled: Binding<Bool> {
Binding(
get: { server.isEnabled },
set: {
guard app.proAccess.require(.remoteMCP) else { return }
app.aiConfiguration.setServerEnabled($0, for: server.id)
}
)
}
}