// 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 } }