215 lines
6.2 KiB
Swift
215 lines
6.2 KiB
Swift
// CxLLM Studio — iOS Application
|
|
// Services/MCPService.swift — Model Context Protocol service
|
|
|
|
import Foundation
|
|
import CxCode
|
|
|
|
@Observable
|
|
final class MCPService {
|
|
|
|
// MARK: - State
|
|
|
|
private(set) var isInitialized = false
|
|
private(set) var pingOk = false
|
|
private(set) var tools: [McpToolDef] = []
|
|
private(set) var resources: [McpResource] = []
|
|
private(set) var error: String?
|
|
private(set) var statusMessage: String = "Not initialized"
|
|
private(set) var isExecutingTool = false
|
|
private(set) var isReadingResource = false
|
|
private(set) var history: [MCPHistoryEntry] = []
|
|
private(set) var totalToolCalls: Int = 0
|
|
private(set) var totalResourceReads: Int = 0
|
|
private(set) var lastInitialized: Date?
|
|
|
|
private let gateway: CxGateway
|
|
|
|
init(gateway: CxGateway) {
|
|
self.gateway = gateway
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func initialize() async {
|
|
do {
|
|
_ = try await gateway.mcp.initialize()
|
|
isInitialized = true
|
|
error = nil
|
|
lastInitialized = Date()
|
|
|
|
if let _ = try? await gateway.mcp.ping() {
|
|
pingOk = true
|
|
}
|
|
|
|
tools = try await gateway.mcp.listTools()
|
|
resources = try await gateway.mcp.listResources()
|
|
statusMessage = "Ready — \(tools.count) tools, \(resources.count) resources"
|
|
} catch {
|
|
isInitialized = false
|
|
self.error = error.localizedDescription
|
|
statusMessage = "Error: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
func ping() async -> Bool {
|
|
do {
|
|
let _ = try await gateway.mcp.ping()
|
|
pingOk = true
|
|
statusMessage = "Pong OK"
|
|
error = nil
|
|
return true
|
|
} catch {
|
|
pingOk = false
|
|
statusMessage = "Ping failed"
|
|
self.error = error.localizedDescription
|
|
return false
|
|
}
|
|
}
|
|
|
|
func reload() async {
|
|
do {
|
|
tools = try await gateway.mcp.listTools()
|
|
resources = try await gateway.mcp.listResources()
|
|
statusMessage = "Reloaded — \(tools.count) tools, \(resources.count) resources"
|
|
error = nil
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
statusMessage = "Reload failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool Execution
|
|
|
|
struct ToolCallResult {
|
|
let content: String
|
|
let isError: Bool
|
|
}
|
|
|
|
func callTool(name: String, arguments: [String: Any]) async -> ToolCallResult {
|
|
isExecutingTool = true
|
|
|
|
do {
|
|
let result = try await gateway.mcp.callTool(name: name, arguments: arguments)
|
|
let content = result.content?.compactMap(\.text).joined(separator: "\n") ?? "No content"
|
|
let isErr = result.isError == true
|
|
let displayContent = isErr ? "[ERROR] \(content)" : content
|
|
|
|
totalToolCalls += 1
|
|
history.insert(
|
|
MCPHistoryEntry(type: .tool, name: name, result: displayContent, time: Date(), success: !isErr),
|
|
at: 0
|
|
)
|
|
isExecutingTool = false
|
|
error = nil
|
|
return ToolCallResult(content: displayContent, isError: isErr)
|
|
} catch {
|
|
let errMsg = "Error: \(error.localizedDescription)"
|
|
history.insert(
|
|
MCPHistoryEntry(type: .tool, name: name, result: errMsg, time: Date(), success: false),
|
|
at: 0
|
|
)
|
|
self.error = error.localizedDescription
|
|
isExecutingTool = false
|
|
return ToolCallResult(content: errMsg, isError: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Resource Reading
|
|
|
|
func readResource(uri: String, name: String) async -> String {
|
|
isReadingResource = true
|
|
|
|
do {
|
|
let content = try await gateway.mcp.readResource(uri: uri)
|
|
totalResourceReads += 1
|
|
history.insert(
|
|
MCPHistoryEntry(type: .resource, name: name, result: String(content.prefix(100)), time: Date(), success: true),
|
|
at: 0
|
|
)
|
|
isReadingResource = false
|
|
error = nil
|
|
return content
|
|
} catch {
|
|
let errMsg = "Error: \(error.localizedDescription)"
|
|
history.insert(
|
|
MCPHistoryEntry(type: .resource, name: name, result: errMsg, time: Date(), success: false),
|
|
at: 0
|
|
)
|
|
self.error = error.localizedDescription
|
|
isReadingResource = false
|
|
return errMsg
|
|
}
|
|
}
|
|
|
|
// MARK: - History Management
|
|
|
|
func clearHistory() {
|
|
history.removeAll()
|
|
}
|
|
|
|
// MARK: - Computed
|
|
|
|
var connectionState: ConnectionState {
|
|
if !isInitialized { return .disconnected }
|
|
if pingOk { return .live }
|
|
return .initialized
|
|
}
|
|
|
|
var successRate: Double {
|
|
guard !history.isEmpty else { return 0 }
|
|
let successes = history.filter(\.success).count
|
|
return Double(successes) / Double(history.count)
|
|
}
|
|
|
|
func tool(named name: String) -> McpToolDef? {
|
|
tools.first { $0.name == name }
|
|
}
|
|
|
|
func filteredTools(query: String) -> [McpToolDef] {
|
|
guard !query.isEmpty else { return tools }
|
|
let q = query.lowercased()
|
|
return tools.filter {
|
|
$0.name.lowercased().contains(q) || ($0.description ?? "").lowercased().contains(q)
|
|
}
|
|
}
|
|
|
|
func filteredResources(query: String) -> [McpResource] {
|
|
guard !query.isEmpty else { return resources }
|
|
let q = query.lowercased()
|
|
return resources.filter {
|
|
$0.name.lowercased().contains(q) || $0.uri.lowercased().contains(q)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting Types
|
|
|
|
enum ConnectionState {
|
|
case disconnected, initialized, live
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .disconnected: return "Off"
|
|
case .initialized: return "Init"
|
|
case .live: return "Live"
|
|
}
|
|
}
|
|
|
|
var isConnected: Bool {
|
|
self != .disconnected
|
|
}
|
|
}
|
|
|
|
struct MCPHistoryEntry: Identifiable {
|
|
let id = UUID()
|
|
let type: EntryType
|
|
let name: String
|
|
let result: String
|
|
let time: Date
|
|
let success: Bool
|
|
|
|
enum EntryType: String {
|
|
case tool, resource
|
|
}
|
|
}
|