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

403 lines
14 KiB
Swift

import AgentKit
import SwiftUI
import TermindApplication
import TermindPresentation
@Observable
final class MCPServerEditorSession {
enum ConnectionState: Equatable {
case idle
case running
case succeeded(String)
case failed(String)
}
var token = ""
var connection: ConnectionState = .idle
private var didBegin = false
func begin(with server: MCPServer, in store: AIConfigurationModel) {
guard !didBegin else { return }
didBegin = true
token = store.secret(for: server.credentialRef) ?? ""
}
}
/// One MCP server: how to reach it, and what it turned out to offer.
///
/// The tool list is the important half. It only exists after a successful
/// Connect, because it is what the server said rather than what the user typed —
/// and each row carries a local policy for whether the tool is denied, follows
/// the conversation's approval mode, or runs without another approval. New
/// non-destructive tools carrying `readOnlyHint == true` begin approved; every
/// other new tool begins by asking, and rediscovery preserves the user's choice.
///
/// Edits write straight through and the page returns to the MCP list in place.
struct MCPServerEditorPage: View {
@Environment(\.locale) private var locale
let serverID: MCPServer.ID
let deleted: () -> Void
@Environment(AppModel.self) private var app
@State private var session = MCPServerEditorSession()
private var server: MCPServer? { app.aiConfiguration.mcpServer(serverID) }
private var namespaceID: String { server?.effectiveNamespaceID ?? "" }
private var isNameEmpty: Bool {
server?.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
}
private var hasNamespaceConflict: Bool {
guard !namespaceID.isEmpty else { return false }
return app.aiConfiguration.hasMCPNamespaceConflict(namespaceID, excluding: serverID)
}
var body: some View {
Group {
if let server {
Form {
connectionSection(server)
credentialSection
headersSection(server)
toolsSection(server)
Section {
Button("Delete Server", role: .destructive) {
app.aiConfiguration.delete(serverID: serverID)
deleted()
}
}
}
.formStyle(.grouped)
.frame(maxWidth: .infinity)
} else {
ContentUnavailableView("Server Deleted", systemImage: "trash")
}
}
.onAppear {
guard let server else { return }
session.begin(with: server, in: app.aiConfiguration)
}
}
// MARK: - Sections
@ViewBuilder
private func connectionSection(_ server: MCPServer) -> some View {
Section {
TextField("Name", text: binding(\.name).singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
Toggle(
"Enabled",
isOn: Binding(
get: { app.aiConfiguration.mcpServer(serverID)?.isEnabled ?? false },
set: {
guard app.proAccess.require(.remoteMCP) else { return }
app.aiConfiguration.setServerEnabled($0, for: serverID)
}))
LabeledContent("ID") {
Text(namespaceID)
.font(.callout.monospaced())
.foregroundStyle(namespaceID.isEmpty ? .secondary : .primary)
.textSelection(.enabled)
}
if isNameEmpty {
Label("Name is required.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
} else if namespaceID.isEmpty {
Label(
"Name must produce an ID containing ASCII letters or numbers.",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else if hasNamespaceConflict {
Label("Another MCP server already uses this ID.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
Picker("Transport", selection: binding(\.transport)) {
ForEach(MCPServer.Transport.allCases) { Text($0.displayName).tag($0) }
}
TextField("URL", text: binding(\.url).singleLineText, prompt: Text("MCP endpoint URL"), axis: .horizontal)
.singleLinePaste()
.lineLimit(1)
connectRow(server)
} header: {
Text("Connection")
}
}
@ViewBuilder
private func connectRow(_ server: MCPServer) -> some View {
HStack(spacing: Metrics.Space.m) {
Button("Connect") { connect(server) }
.disabled(
!server.isReachable || namespaceID.isEmpty || hasNamespaceConflict
|| session.connection == .running
)
switch session.connection {
case .idle:
if let connected = server.lastConnectedAt {
Text("Last connected \(connected.formatted(.relative(presentation: .named).locale(locale)))")
.font(.caption)
.foregroundStyle(.tertiary)
}
case .running:
ProgressView().controlSize(.small)
case .succeeded(let message):
Label(message, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
.lineLimit(2)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var credentialSection: some View {
Section {
RevealableSecretField(
"Bearer Token",
text: Binding(get: { session.token }, set: { session.token = $0 }),
prompt: Text("Optional")
)
.onChange(of: session.token) { _, value in store(token: value) }
Text(
"Sent in the Authorization header. Custom headers below are stored as plain text."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} header: {
Text("Credential")
}
}
@ViewBuilder
private func headersSection(_ server: MCPServer) -> some View {
Section {
ForEach(server.headers) { header in
HStack(spacing: Metrics.Space.s) {
TextField(
"Name", text: headerBinding(header.id, \.name).singleLineText, prompt: Text("Header name"),
axis: .horizontal
).singleLinePaste().lineLimit(1)
TextField(
"Value", text: headerBinding(header.id, \.value).singleLineText, prompt: Text("Header value"),
axis: .horizontal
).singleLinePaste().lineLimit(1)
Button {
removeHeader(header.id)
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel(Text("Remove header"))
}
.labelsHidden()
}
FormNavigationAction(title: "Add Header", action: addHeader)
} header: {
Text("Custom Headers")
}
}
@ViewBuilder
private func toolsSection(_ server: MCPServer) -> some View {
Section {
if server.tools.isEmpty {
Text("Connect to see what this server offers.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(server.tools) { tool in
ToolRow(tool: tool, serverID: serverID)
}
}
} header: {
Text("Tools")
} footer: {
if !server.tools.isEmpty {
Text(
"Deny refuses the call. Ask uses the conversation's manual or automatic approval. Approve runs without approval."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Actions
private func connect(_ server: MCPServer) {
guard !namespaceID.isEmpty, !hasNamespaceConflict else { return }
guard app.aiConfiguration.lockMCPNamespace(for: serverID) else { return }
session.connection = .running
app.aiConfiguration.setMCPStatus(.connecting, for: serverID)
let bearer = session.token.isEmpty ? nil : session.token
Task {
do {
let discovery = try await MCPClient(clientInfo: TermindAgentPolicy.mcpClientInfo)
.discover(server, bearerToken: bearer)
app.aiConfiguration.setTools(discovery.tools, version: discovery.protocolVersion, for: serverID)
app.aiConfiguration.setMCPStatus(.connected, for: serverID)
session.connection = .succeeded(
String(localized: "\(discovery.serverName) — \(discovery.tools.count) tools")
)
} catch {
app.aiConfiguration.setMCPStatus(.failed(error.localizedDescription), for: serverID)
session.connection = .failed(error.localizedDescription)
}
}
}
private func store(token value: String) {
guard var server else { return }
if value.isEmpty {
app.aiConfiguration.setSecret(nil, for: server.credentialRef)
server.credentialRef = ""
} else {
if server.credentialRef.isEmpty {
server.credentialRef = AIConfigurationModel.makeCredentialRef("mcp")
}
app.aiConfiguration.setSecret(value, for: server.credentialRef)
}
app.aiConfiguration.upsert(server)
}
private func addHeader() {
guard var server else { return }
server.headers.append(MCPHeader())
app.aiConfiguration.upsert(server)
}
private func removeHeader(_ id: MCPHeader.ID) {
guard var server else { return }
server.headers.removeAll { $0.id == id }
app.aiConfiguration.upsert(server)
}
// MARK: - Bindings
private func binding<Value>(_ keyPath: WritableKeyPath<MCPServer, Value>) -> Binding<Value> {
Binding(
get: {
app.aiConfiguration.mcpServer(serverID)?[keyPath: keyPath] ?? MCPServer(name: "")[keyPath: keyPath]
},
set: { newValue in
guard var server = app.aiConfiguration.mcpServer(serverID) else { return }
server[keyPath: keyPath] = newValue
app.aiConfiguration.upsert(server)
}
)
}
private func headerBinding(
_ id: MCPHeader.ID, _ keyPath: WritableKeyPath<MCPHeader, String>
) -> Binding<String> {
Binding(
get: { app.aiConfiguration.mcpServer(serverID)?.headers.first { $0.id == id }?[keyPath: keyPath] ?? "" },
set: { newValue in
guard var server = app.aiConfiguration.mcpServer(serverID),
let index = server.headers.firstIndex(where: { $0.id == id })
else { return }
server.headers[index][keyPath: keyPath] = newValue
app.aiConfiguration.upsert(server)
}
)
}
}
// MARK: - Tool row
/// One discovered tool and the local policy applied when the model calls it.
private struct ToolRow: View {
let tool: MCPTool
let serverID: MCPServer.ID
@Environment(AppModel.self) private var app
private var qualifiedToolName: String {
guard let server = app.aiConfiguration.mcpServer(serverID) else { return tool.id }
let leaf =
MCPToolNaming.functionNames(for: server.tools)[tool.id]
?? MCPToolNaming.functionName(from: tool.id)
return MCPToolNaming.qualifiedName(
namespace: server.effectiveNamespaceID, function: leaf
)
}
var body: some View {
HStack(alignment: .center, spacing: Metrics.Space.m) {
VStack(alignment: .leading, spacing: 1) {
Text(qualifiedToolName)
.font(.callout.monospaced())
.lineLimit(1)
.truncationMode(.middle)
if !tool.summary.isEmpty {
Text(tool.summary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
Picker("Permission", selection: permission) {
ForEach(AgentToolDescriptor.ApprovalPolicy.allCases) { option in
Text(option.displayName)
.tag(option)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.accessibilityLabel(Text("Permission"))
}
.frame(maxWidth: .infinity)
.font(.caption)
.help(Text(tool.summary.isEmpty ? qualifiedToolName : tool.summary))
}
private var permission: Binding<AgentToolDescriptor.ApprovalPolicy> {
Binding(
get: { tool.accessPolicy },
set: { policy in
app.aiConfiguration.setToolAccessPolicy(policy, tool: tool.id, on: serverID)
}
)
}
}
extension AgentToolDescriptor.ApprovalPolicy {
fileprivate var displayName: LocalizedStringKey {
switch self {
case .deny: "Deny"
case .ask: "Ask"
case .approve: "Approve"
}
}
}