259 lines
9.9 KiB
Swift
259 lines
9.9 KiB
Swift
import SwiftUI
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct MCPServerSettings: View {
|
|
@Environment(AppModel.self) private var app
|
|
@State private var showsToken = false
|
|
@State private var historyQuery = ""
|
|
@State private var confirmsHistoryClear = false
|
|
|
|
private var server: MCPServerController { app.mcpServer }
|
|
|
|
var body: some View {
|
|
Section("Server") {
|
|
Toggle(
|
|
"Enable",
|
|
isOn: Binding(
|
|
get: { server.isEnabled && app.proAccess.canUse(.mcpServer) },
|
|
set: { server.setEnabled($0) }
|
|
))
|
|
|
|
LabeledContent("Status") {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
Circle()
|
|
.fill(statusColor)
|
|
.frame(width: 8, height: 8)
|
|
Text(server.status.label).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
LabeledContent("Connected clients", value: "\(server.connectedClients)")
|
|
LabeledContent("Port") {
|
|
TextField(
|
|
"Port",
|
|
value: Binding(
|
|
get: { server.port },
|
|
set: { server.setPort($0) }
|
|
), format: .number.grouping(.never)
|
|
).lineLimit(1)
|
|
.frame(width: 100)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
Text(
|
|
"The server listens only on 127.0.0.1 and ::1. It is off by default; when enabled, external calls execute without per-call approval."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Section("Bearer Token") {
|
|
LabeledContent("Token") {
|
|
HStack {
|
|
Text(showsToken ? displayToken : String(repeating: "•", count: 24))
|
|
.font(.system(.body, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
.lineLimit(1)
|
|
Button {
|
|
showsToken.toggle()
|
|
} label: {
|
|
Image(systemName: showsToken ? "eye.slash" : "eye")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(showsToken ? "Hide Secret" : "Show Secret")
|
|
Button("Copy") { copy(displayToken) }
|
|
}
|
|
}
|
|
HStack {
|
|
Button(displayToken.isEmpty ? "Generate Token" : "Rotate Token") {
|
|
server.rotateToken()
|
|
showsToken = true
|
|
}
|
|
if !displayToken.isEmpty {
|
|
Text("Rotating disconnects every active MCP session.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let error = server.tokenStorageError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
clientSetupSection
|
|
|
|
Section("Exposed Tools") {
|
|
ForEach(MCPServerTools.exportedNames.sorted(), id: \.self) { name in
|
|
Toggle(
|
|
name,
|
|
isOn: Binding(
|
|
get: { !server.disabledTools.contains(name) },
|
|
set: { server.setToolEnabled($0, name: name) }
|
|
)
|
|
)
|
|
.font(.system(.body, design: .monospaced))
|
|
}
|
|
Text(
|
|
"Tool changes are enforced immediately. Existing clients see the new list when they reconnect or list tools again."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Section("Call History") {
|
|
TextField(
|
|
"Filter by client, tool, host or argument", text: $historyQuery.singleLineText,
|
|
prompt: Text("Filter by client, tool, host or argument"), axis: .horizontal
|
|
)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.labelsHidden()
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
let rows = app.toolAudit.entries(matching: historyQuery)
|
|
if rows.isEmpty {
|
|
ContentUnavailableView(
|
|
"No external calls", systemImage: "clock.arrow.circlepath",
|
|
description: Text("Calls appear here after an MCP client invokes a tool.")
|
|
)
|
|
.frame(maxWidth: .infinity, minHeight: 160, alignment: .center)
|
|
} else {
|
|
ForEach(rows.prefix(200)) { row in ExternalToolCallRow(row: row) }
|
|
}
|
|
HStack {
|
|
Button("Clear History", role: .destructive) { confirmsHistoryClear = true }
|
|
Spacer()
|
|
Text("Stores commands, paths and byte counts; never file bodies, stdin, stdout or stderr.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Clear Call History?", isPresented: $confirmsHistoryClear,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Clear History", role: .destructive) { app.toolAudit.clear() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This permanently removes all saved MCP call records.")
|
|
}
|
|
}
|
|
|
|
private var displayToken: String { server.token }
|
|
|
|
private var clientSetupSection: some View {
|
|
let setup = MCPClientSetupCommand(
|
|
port: server.port, token: displayToken,
|
|
bridgePath: Bundle.main.url(forAuxiliaryExecutable: "termind-mcp")?.path
|
|
)
|
|
let preview = MCPClientSetupCommand(
|
|
port: setup.port, token: showsToken ? setup.token : "••••••••",
|
|
bridgePath: setup.bridgePath
|
|
)
|
|
return Section("Connect Clients") {
|
|
ForEach(MCPClientSetupCommand.Client.allCases) { client in
|
|
let command = setup.command(for: client)
|
|
HStack(spacing: Metrics.Space.m) {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
|
|
Text(verbatim: client.rawValue)
|
|
if command != nil, let detail = preview.command(for: client) {
|
|
Text(verbatim: detail)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
CopyButton(text: command ?? "", title: "Copy Add Command")
|
|
.disabled(command == nil)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
|
if displayToken.isEmpty {
|
|
Text("Generate a token to copy client setup commands.")
|
|
}
|
|
if setup.bridgePath == nil {
|
|
Text("The bundled MCP bridge is missing. Codex and Hermes setup commands are unavailable.")
|
|
}
|
|
Text("Run the copied command in a terminal on this Mac. Enable the server before connecting.")
|
|
Text("Hermes needs the server running during setup and may ask you to select tools.")
|
|
Text(
|
|
"Update client configurations after changing the port or token, or after moving Termind for Codex and Hermes."
|
|
)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch server.status {
|
|
case .ready: .green
|
|
case .starting: .orange
|
|
case .failed: .red
|
|
case .stopped: .secondary
|
|
}
|
|
}
|
|
|
|
private func copy(_ value: String) {
|
|
#if canImport(AppKit)
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(value, forType: .string)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct ExternalToolCallRow: View {
|
|
let row: ExternalToolCall
|
|
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.m) {
|
|
Image(systemName: statusSymbol).foregroundStyle(statusColor)
|
|
.frame(width: Metrics.Sidebar.rowIcon)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Text(row.toolName).font(.system(.body, design: .monospaced))
|
|
if let host = row.hostDisplayName { Text(host).foregroundStyle(.secondary) }
|
|
Spacer()
|
|
Text(row.risk.label).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Text(row.summary.isEmpty ? row.clientName : row.summary)
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
HStack {
|
|
Text(row.clientName)
|
|
Text(row.startedAt, style: .relative)
|
|
if let duration = row.duration {
|
|
Text(duration, format: .number.precision(.fractionLength(2))) + Text("s")
|
|
}
|
|
}
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var statusSymbol: String {
|
|
switch row.status {
|
|
case .running: "circle.dotted"
|
|
case .ok: "checkmark.circle.fill"
|
|
case .error: "xmark.circle.fill"
|
|
case .denied: "hand.raised.circle.fill"
|
|
}
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch row.status {
|
|
case .running: .orange
|
|
case .ok: .green
|
|
case .error: .red
|
|
case .denied: .secondary
|
|
}
|
|
}
|
|
}
|