386 lines
15 KiB
Swift
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
|
|
}
|
|
}
|