CxLLM-IOS/CxLLMStudio/Views/MCP/MCPView.swift
2026-05-16 10:52:04 -05:00

386 lines
15 KiB
Swift

// CxLLM Studio iOS Application
// Views/MCP/MCPView.swift MCP protocol inspector for iOS
import SwiftUI
import CxCode
struct MCPView: View {
@Environment(MCPService.self) private var mcp
enum Tab: String, CaseIterable {
case tools, resources, history
var label: String { rawValue.capitalized }
var icon: String {
switch self {
case .tools: return "wrench.and.screwdriver"
case .resources: return "folder"
case .history: return "clock.arrow.circlepath"
}
}
}
@State private var tab: Tab = .tools
@State private var selectedToolName: String?
@State private var toolArgs = "{}"
@State private var toolResult = ""
@State private var selectedResource: McpResource?
@State private var resourceContent = ""
@State private var toolSearch = ""
private let mcpTeal = Color(red: 0.0, green: 0.72, blue: 0.72)
var body: some View {
VStack(spacing: 0) {
connectionBar
Picker("Tab", selection: $tab) {
ForEach(Tab.allCases, id: \.self) { t in
Label(t.label, systemImage: t.icon).tag(t)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
switch tab {
case .tools: toolsTab
case .resources: resourcesTab
case .history: historyTab
}
}
.navigationTitle("MCP")
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button { Task { let _ = await mcp.ping() } } label: {
Image(systemName: "antenna.radiowaves.left.and.right")
}
Button { Task { await mcp.reload() } } label: {
Image(systemName: "arrow.clockwise")
}
}
}
.task { await mcp.initialize() }
}
// MARK: - Connection Bar
private var connectionBar: some View {
HStack(spacing: 8) {
Circle()
.fill(mcp.isInitialized ? (mcp.pingOk ? Color.green : .yellow) : Color.red.opacity(0.5))
.frame(width: 8, height: 8)
Text(mcp.statusMessage)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer()
HStack(spacing: 12) {
statLabel("Tools", mcp.tools.count)
statLabel("Resources", mcp.resources.count)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.03))
}
private func statLabel(_ label: String, _ count: Int) -> some View {
HStack(spacing: 2) {
Text("\(count)").font(.caption.weight(.bold).monospacedDigit())
Text(label).font(.caption2).foregroundStyle(.secondary)
}
}
// MARK: - Tools Tab
private var toolsTab: some View {
VStack(spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass").foregroundStyle(.tertiary)
TextField("Search tools...", text: $toolSearch)
.textFieldStyle(.plain)
if !toolSearch.isEmpty {
Button { toolSearch = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
}
}
}
.font(.subheadline)
.padding(8)
.background(Color.primary.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
.padding(.bottom, 4)
List(filteredTools, id: \.name, selection: $selectedToolName) { tool in
NavigationLink(value: tool.name) {
VStack(alignment: .leading, spacing: 2) {
Text(tool.name).font(.subheadline.weight(.medium))
if let desc = tool.description {
Text(desc).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
}
.listStyle(.plain)
.navigationDestination(for: String.self) { name in
if let tool = mcp.tool(named: name) {
toolDetailView(tool)
}
}
}
}
private var filteredTools: [McpToolDef] {
mcp.filteredTools(query: toolSearch)
}
private func toolDetailView(_ tool: McpToolDef) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(tool.name)
.font(.title3.weight(.bold).monospaced())
if let desc = tool.description {
Text(desc).font(.subheadline).foregroundStyle(.secondary)
}
if let schema = tool.inputSchema?.dictValue {
Divider()
Text("Input Schema").font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
schemaView(schema)
}
Divider()
Text("Arguments (JSON)").font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
TextEditor(text: $toolArgs)
.font(.system(.caption, design: .monospaced))
.scrollContentBackground(.hidden)
.frame(minHeight: 80)
.padding(8)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
Button { callTool(tool.name) } label: {
HStack(spacing: 4) {
if mcp.isExecutingTool {
ProgressView().controlSize(.small)
} else {
Image(systemName: "play.fill")
}
Text("Execute")
}
.font(.subheadline.weight(.medium))
}
.buttonStyle(.borderedProminent).tint(mcpTeal)
.disabled(mcp.isExecutingTool)
if !toolResult.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Result").font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
Spacer()
Button { UIPasteboard.general.string = toolResult } label: {
Image(systemName: "doc.on.doc").font(.caption2)
}
}
Text(toolResult)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding()
}
.navigationTitle(tool.name)
.navigationBarTitleDisplayMode(.inline)
}
private func schemaView(_ dict: [String: Any]) -> some View {
VStack(alignment: .leading, spacing: 4) {
if let props = dict["properties"] as? [String: Any] {
let required = dict["required"] as? [String] ?? []
ForEach(Array(props.keys.sorted()), id: \.self) { key in
let info = props[key] as? [String: Any] ?? [:]
let typ = info["type"] as? String ?? "any"
let desc = info["description"] as? String
let isReq = required.contains(key)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(key).font(.caption.weight(.medium).monospaced())
if isReq {
Text("*").font(.caption.weight(.bold)).foregroundStyle(.red)
}
Spacer()
Text(typ).font(.caption2.monospaced()).foregroundStyle(.tertiary)
}
if let d = desc {
Text(d).font(.caption2).foregroundStyle(.quaternary)
}
}
}
}
}
.padding(8)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// MARK: - Resources Tab
private var resourcesTab: some View {
List(mcp.resources, id: \.uri) { resource in
NavigationLink {
resourceDetailView(resource)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(resource.name).font(.subheadline.weight(.medium))
Text(resource.uri).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
if let mime = resource.mimeType {
Text(mime).font(.caption2).foregroundStyle(.tertiary)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.primary.opacity(0.04))
.clipShape(Capsule())
}
}
}
}
.listStyle(.plain)
.overlay {
if mcp.resources.isEmpty {
ContentUnavailableView("No Resources", systemImage: "folder", description: Text("Connect to the MCP server to discover resources."))
}
}
}
private func resourceDetailView(_ resource: McpResource) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(resource.name).font(.title3.weight(.bold))
Text(resource.uri).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
if let mime = resource.mimeType {
Text(mime).font(.caption2).foregroundStyle(.tertiary)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.primary.opacity(0.04))
.clipShape(Capsule())
}
if let desc = resource.description {
Text(desc).font(.subheadline).foregroundStyle(.secondary)
}
Button { readResource(resource) } label: {
HStack(spacing: 4) {
if mcp.isReadingResource {
ProgressView().controlSize(.small)
} else {
Image(systemName: "doc.text.magnifyingglass")
}
Text("Read Content")
}
.font(.subheadline.weight(.medium))
}
.buttonStyle(.borderedProminent).tint(.blue)
.disabled(mcp.isReadingResource)
if !resourceContent.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Content").font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
Spacer()
Button { UIPasteboard.general.string = resourceContent } label: {
Image(systemName: "doc.on.doc").font(.caption2)
}
}
Text(resourceContent)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding()
}
.navigationTitle(resource.name)
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - History Tab
private var historyTab: some View {
Group {
if mcp.history.isEmpty {
ContentUnavailableView("No History",
systemImage: "clock.arrow.circlepath",
description: Text("Execute tools or read resources to see history."))
} else {
List {
Section {
ForEach(mcp.history) { entry in
HStack(spacing: 10) {
Image(systemName: entry.type == .tool ? "wrench" : "doc")
.foregroundStyle(entry.type == .tool ? .green : .blue)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(entry.name).font(.subheadline.weight(.medium))
Text(String(entry.result.prefix(80)))
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(entry.time, format: .dateTime.hour().minute().second())
.font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
Text(entry.success ? "OK" : "ERR")
.font(.caption2.weight(.bold))
.foregroundStyle(entry.success ? .green : .red)
}
}
}
} header: {
HStack {
Text("\(mcp.history.count) entries")
Spacer()
Button("Clear") { mcp.clearHistory() }
.font(.caption)
}
}
}
.listStyle(.insetGrouped)
}
}
}
// MARK: - Actions
private func callTool(_ name: String) {
toolResult = ""
Task {
let result = await mcp.callTool(name: name, arguments: parseJSON(toolArgs))
toolResult = result.content
}
}
private func readResource(_ res: McpResource) {
resourceContent = ""
Task {
resourceContent = await mcp.readResource(uri: res.uri, name: res.name)
}
}
private func parseJSON(_ s: String) -> [String: Any] {
guard let d = s.data(using: .utf8),
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any]
else { return [:] }
return o
}
}