Files
Termind/TFI/Features/Settings/MobileMCPSettings.swift
T

418 lines
16 KiB
Swift

import AgentKit
import SwiftUI
import TermindApplication
import TermindInfrastructure
import TermindPresentation
struct MobileMCPSettings: View {
let ai: AIConfigurationModel
let pro: ProAccessController
@State private var adding: MCPServer?
var body: some View {
List {
Section {
if !ai.conflictingMCPServerIDs.isEmpty {
Text(
"Some MCP servers have duplicate namespaces and are paused. Edit their namespace IDs to resolve the conflict."
)
.foregroundStyle(.orange)
}
if ai.mcpServers.isEmpty {
ContentUnavailableView(
"No MCP Servers", systemImage: "network",
description: Text("Add an MCP server to connect remote tools.")
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
ForEach(ai.mcpServers) { server in
serverLink(server)
.contextMenu {
Button("Delete", systemImage: "trash", role: .destructive) {
ai.delete(serverID: server.id)
}
}
.swipeActions(edge: .trailing) {
Button("Delete", systemImage: "trash", role: .destructive) {
ai.delete(serverID: server.id)
}
}
}
} footer: {
Text("Connect to remote MCP servers over HTTP.")
}
}
.navigationTitle("MCP")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Server", systemImage: "plus") {
if pro.require(.remoteMCP) { adding = MCPServer(name: "", namespaceID: "") }
}
.labelStyle(.iconOnly)
}
}
.navigationDestination(item: $adding) { MobileMCPEditor(ai: ai, pro: pro, server: $0) }
}
@ViewBuilder
private func serverLink(_ server: MCPServer) -> some View {
if pro.canUse(.remoteMCP) {
NavigationLink {
MobileMCPEditor(ai: ai, pro: pro, server: server)
} label: {
MobileMCPServerRow(
server: server,
hasNamespaceConflict: ai.conflictingMCPServerIDs.contains(server.id))
}
} else {
Button {
pro.require(.remoteMCP)
} label: {
HStack {
MobileMCPServerRow(
server: server,
hasNamespaceConflict: ai.conflictingMCPServerIDs.contains(server.id))
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
}
private struct MobileMCPServerRow: View {
let server: MCPServer
let hasNamespaceConflict: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: "network")
.foregroundStyle(.secondary)
.frame(width: 28, height: 28)
.background(.quaternary, in: .rect(cornerRadius: 7))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(server.name).foregroundStyle(.primary).lineLimit(1)
Text(server.url.isEmpty ? String(localized: "No URL") : server.url)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
MobileMCPStatusBadge(server: server, hasNamespaceConflict: hasNamespaceConflict)
}
.contentShape(.rect)
}
}
private struct MobileMCPStatusBadge: View {
let status: MobileMCPServerStatus
init(server: MCPServer, hasNamespaceConflict: Bool) {
status = MobileMCPServerStatus(server: server, hasNamespaceConflict: hasNamespaceConflict)
}
var body: some View {
label
.font(.caption2.weight(.medium))
.foregroundStyle(tint)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(tint.opacity(0.12), in: .capsule)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
@ViewBuilder private var label: some View {
switch status {
case .conflict:
Text("Conflict")
case .disabled:
Text("Disabled")
case .notConnected:
Text("Not Connected")
case .tools(let count):
Text("\(count) tools")
}
}
private var tint: Color {
switch status {
case .conflict: .orange
case .disabled, .notConnected: .secondary
case .tools: .accentColor
}
}
}
nonisolated enum MobileMCPServerStatus: Equatable {
case conflict
case disabled
case notConnected
case tools(count: Int)
init(server: MCPServer, hasNamespaceConflict: Bool) {
if hasNamespaceConflict {
self = .conflict
} else if !server.isEnabled {
self = .disabled
} else if server.lastConnectedAt == nil {
self = .notConnected
} else {
self = .tools(count: server.tools.count)
}
}
}
struct MobileMCPEditor: View {
let ai: AIConfigurationModel
let pro: ProAccessController
@State private var model: MobileMCPEditorModel
@State private var editingHeader: MCPHeader?
@State private var alert: EditorAlert?
@Environment(\.dismiss) private var dismiss
private struct EditorAlert {
let title: LocalizedStringKey
let message: String
}
init(ai: AIConfigurationModel, pro: ProAccessController, server: MCPServer) {
self.ai = ai
self.pro = pro
_model = State(initialValue: MobileMCPEditorModel(ai: ai, server: server))
}
var body: some View {
Form {
Section("Connection") {
LabeledContent("Name") {
TextField("Name", text: $model.draft.name.singleLineText, axis: .horizontal).singleLinePaste()
.lineLimit(1)
.multilineTextAlignment(.trailing)
}
Toggle("Enabled", isOn: $model.draft.isEnabled)
LabeledContent("ID") {
Text(model.draft.effectiveNamespaceID).font(.caption.monospaced()).textSelection(.enabled)
}
if model.draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Name is required.").foregroundStyle(.orange)
} else if model.draft.effectiveNamespaceID.isEmpty {
Text("Name must produce an ID containing ASCII letters or numbers.").foregroundStyle(.orange)
} else if ai.hasMCPNamespaceConflict(model.draft.effectiveNamespaceID, excluding: model.draft.id) {
Text("Another MCP server already uses this ID.").foregroundStyle(.orange)
}
Picker("Transport", selection: $model.draft.transport) {
ForEach(MCPServer.Transport.allCases) { Text($0.displayName).tag($0) }
}
LabeledContent("URL") {
TextField("MCP endpoint URL", text: $model.draft.url.singleLineText, axis: .horizontal)
.singleLinePaste().lineLimit(1)
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
.multilineTextAlignment(.trailing)
}
if !model.draft.url.isEmpty && !model.hasValidURL {
Text("Enter an HTTP or HTTPS MCP URL.").font(.caption).foregroundStyle(.orange)
}
if model.running { ProgressView() }
if let connection = model.connection { Text(connection).font(.caption).textSelection(.enabled) }
}
MobileSecretFields(key: model.key, isToken: true)
Section {
ForEach(model.draft.headers) { header in
NavigationLink {
MobileMCPHeaderEditor(header: header) { model.upsertHeader($0) }
} label: {
MobileMCPHeaderRow(header: header)
}
.contextMenu {
Button("Delete", systemImage: "trash", role: .destructive) {
removeHeader(header.id)
}
}
}
.onDelete(perform: model.removeHeaders)
Button("Add Header", systemImage: "plus") { editingHeader = MCPHeader() }
} header: {
Text("Custom Headers")
}
Section("Tools") {
if model.draft.tools.isEmpty { Text("Connect to see what this server offers.") }
ForEach($model.draft.tools) { $tool in
VStack(alignment: .leading) {
Text(qualified(tool)).font(.caption.monospaced()).textSelection(.enabled)
if !tool.summary.isEmpty {
Text(tool.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Picker("Permission", selection: $tool.accessPolicy) {
Text("Deny").tag(AgentToolDescriptor.ApprovalPolicy.deny)
Text("Ask").tag(AgentToolDescriptor.ApprovalPolicy.ask)
Text("Approve")
.tag(AgentToolDescriptor.ApprovalPolicy.approve)
}
}
}
Text(
"Deny refuses the call. Ask uses the conversation's manual or automatic approval. Approve runs without approval."
)
.font(.caption).foregroundStyle(.secondary)
}
}
.navigationTitle("MCP Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
MobileToolbarOverflow {
Button("Connect", systemImage: "bolt") {
if pro.require(.remoteMCP) { model.connect() }
}
.disabled(!model.canSave || model.running)
if ai.mcpServer(model.original.id) != nil {
Divider()
Button("Delete", systemImage: "trash", role: .destructive, action: delete)
}
}
ToolbarItem(placement: .confirmationAction) {
Button {
save()
} label: {
Label("Save", systemImage: "checkmark")
}
.labelStyle(.iconOnly)
.accessibilityLabel(Text("Save"))
.disabled(!model.canSave || !pro.canUse(.remoteMCP))
}
.mobileProminentAction()
}
.navigationDestination(item: $editingHeader) { header in
MobileMCPHeaderEditor(header: header, focusesName: true) { model.upsertHeader($0) }
}
.onChange(of: model.draft.name) { _, _ in model.cancel() }
.onChange(of: model.draft.url) { _, _ in model.invalidateConnection() }
.onChange(of: model.draft.transport) { _, _ in model.invalidateConnection() }
.onChange(of: model.key.text) { _, _ in model.invalidateConnection() }
.onChange(of: model.key.action) { _, _ in model.invalidateConnection() }
.onChange(of: ai.mcpServer(model.original.id)) { _, _ in model.cancel() }
.onChange(of: pro.hasProAccess) { _, granted in if !granted { model.cancel() } }
.onDisappear { model.cancel() }
.alert(
alert?.title ?? "Unable to Save",
isPresented: Binding(get: { alert != nil }, set: { if !$0 { alert = nil } })
) {
Button("OK", role: .cancel) { alert = nil }
} message: {
Text(alert?.message ?? "")
}
}
private func delete() {
ai.delete(serverID: model.original.id)
if let error = ai.storageError ?? ai.secretStorageError {
alert = EditorAlert(title: "Unable to Delete", message: error)
} else {
dismiss()
}
}
private func save() {
do {
try model.save()
dismiss()
} catch {
alert = EditorAlert(title: "Unable to Save", message: error.localizedDescription)
}
}
private func removeHeader(_ id: MCPHeader.ID) {
guard let index = model.draft.headers.firstIndex(where: { $0.id == id }) else { return }
model.removeHeaders(atOffsets: IndexSet(integer: index))
}
private func qualified(_ tool: MCPTool) -> String {
let leaf =
MCPToolNaming.functionNames(for: model.draft.tools)[tool.id] ?? MCPToolNaming.functionName(from: tool.id)
return MCPToolNaming.qualifiedName(namespace: model.draft.effectiveNamespaceID, function: leaf)
}
}
private struct MobileMCPHeaderRow: View {
let header: MCPHeader
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(header.isComplete ? header.name : String(localized: "Header name"))
.foregroundStyle(header.isComplete ? .primary : .secondary)
.lineLimit(1)
if !header.value.isEmpty {
Text(header.value)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
}
private struct MobileMCPHeaderEditor: View {
let save: (MCPHeader) -> Void
let focusesName: Bool
@State private var draft: MCPHeader
@FocusState private var focusedField: Field?
@Environment(\.dismiss) private var dismiss
private enum Field {
case name
case value
}
init(header: MCPHeader, focusesName: Bool = false, save: @escaping (MCPHeader) -> Void) {
self.save = save
self.focusesName = focusesName
_draft = State(initialValue: header)
}
var body: some View {
Form {
Section {
LabeledContent("Name") {
TextField("Header name", text: $draft.name.singleLineText, axis: .horizontal).singleLinePaste()
.lineLimit(1)
.focused($focusedField, equals: .name)
.multilineTextAlignment(.trailing)
}
LabeledContent("Value") {
TextField("Header value", text: $draft.value.singleLineText, axis: .horizontal).singleLinePaste()
.lineLimit(1)
.focused($focusedField, equals: .value)
.multilineTextAlignment(.trailing)
}
} footer: {
Text("Custom headers are stored as plain text. For secrets, use the bearer token field above.")
}
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.navigationTitle("Custom Header")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done", systemImage: "checkmark") {
save(draft)
dismiss()
}
.labelStyle(.iconOnly)
.disabled(!draft.isComplete)
}
}
.onAppear {
if focusesName { focusedField = .name }
}
}
}