Files
CxIDE/Agent/MCPServer.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge)
- Agentic AI loop: LLM plans → tool calls → execution → feedback loop
- 15 agent tools: file ops, terminal, git, xcode build, code intel
- 7 persistent terminal tools with background session management
- Chat sidebar with agent step rendering and auto-apply
- NVIDIA NIM API integration (Llama 3.3 70B default)
- OpenAI tool_calls format with prompt-based fallback
- Code editor with syntax highlighting and multi-tab support
- File tree, console view, terminal view
- Git integration and workspace management
2026-04-21 16:05:52 -05:00

579 lines
22 KiB
Swift

// MCPServer.swift
// CxSwiftAgent MCP Server (JSON-RPC 2.0 over HTTP + stdio)
//
// Full MCP 2025-03-26 protocol implementation:
// POST /mcp JSON-RPC dispatcher
// GET /health Liveness probe
// GET /ready Readiness probe
// GET /status Runtime info
// GET /mcp/tools Tool discovery (REST)
// GET /metrics Prometheus metrics
// DELETE /mcp Session termination
// OPTIONS /* CORS preflight
//
// Zero external dependencies NWListener for HTTP, stdin/stdout for stdio.
import Foundation
#if canImport(Network)
import Network
#endif
final class MCPServer: @unchecked Sendable {
let name = "CxSwiftAgent MCP Server"
let version = "1.0.0"
let protocolVersion = "2025-03-26"
private let config: AgentConfig
private let memory: AgentMemory
private var tools: [String: MCPToolDefinition] = [:]
private var resources: [String: MCPResourceDefinition] = [:]
private var prompts: [String: MCPPromptDefinition] = [:]
private var roots: [[String: String]] = []
private var initialized = false
private let sessionId: String
private var logLevel: String
private let startTime = Date()
// Metrics
private var requestsTotal = 0
private var mcpCalls = 0
private var toolCalls = 0
private var toolErrors = 0
private var errors = 0
private var authFailures = 0
private var toolMetrics: [String: [String: Any]] = [:]
init(config: AgentConfig, memory: AgentMemory) {
self.config = config
self.memory = memory
self.sessionId = UUID().uuidString.lowercased()
self.logLevel = config.logLevel
}
// MARK: - Registration
func registerTool(
_ name: String,
description: String,
inputSchema: [String: Any],
annotations: ToolAnnotations = ToolAnnotations(),
handler: @escaping @Sendable ([String: Any]) async -> [[String: Any]]
) {
tools[name] = MCPToolDefinition(
name: name, description: description,
inputSchema: inputSchema, annotations: annotations,
handler: handler
)
}
func registerResource(_ uri: String, name: String, mimeType: String,
handler: @escaping @Sendable () async -> String) {
resources[uri] = MCPResourceDefinition(uri: uri, name: name, mimeType: mimeType, handler: handler)
}
func registerPrompt(_ name: String, description: String,
arguments: [[String: String]] = [],
handler: @escaping @Sendable ([String: String]) -> [[String: Any]]) {
prompts[name] = MCPPromptDefinition(name: name, description: description,
arguments: arguments, handler: handler)
}
func registerRoot(uri: String, name: String = "") {
roots.append(["uri": uri, "name": name])
}
var toolNames: [String] { tools.keys.sorted() }
var toolCount: Int { tools.count }
// MARK: - Serve
func serve() async {
if config.transport == "stdio" {
log("info", "Starting stdio transport")
await serveStdio()
} else {
log("info", "Starting HTTP server on \(config.host):\(config.port)")
await serveHTTP()
}
}
// MARK: - Stdio Transport
private func serveStdio() async {
log("info", "MCP server ready (stdio mode, \(tools.count) tools)")
let stdout = FileHandle.standardOutput
while true {
guard let data = readLine(strippingNewline: false)?.data(using: .utf8) else {
break
}
let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if line.isEmpty { continue }
let response = await handleJsonRpc(line)
if let response {
let responseJson = JSON.serialize(response)
stdout.write(Data((responseJson + "\n").utf8))
}
}
}
// MARK: - HTTP Transport
private func serveHTTP() async {
let serverFd = socket(AF_INET6, SOCK_STREAM, 0)
guard serverFd >= 0 else {
log("error", "Failed to create socket")
return
}
var yes: Int32 = 1
setsockopt(serverFd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in6()
addr.sin6_family = sa_family_t(AF_INET6)
addr.sin6_port = UInt16(config.port).bigEndian
addr.sin6_addr = in6addr_any
let bindResult = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(serverFd, $0, socklen_t(MemoryLayout<sockaddr_in6>.size))
}
}
guard bindResult == 0 else {
log("error", "Failed to bind to port \(config.port): \(String(cString: strerror(errno)))")
close(serverFd)
return
}
listen(serverFd, 128)
let banner = """
╔══════════════════════════════════════════════════════════╗
║ CxSwiftAgent MCP Server v\(version)
║ Protocol: MCP \(protocolVersion)
║ Transport: HTTP on \(config.host):\(config.port)
║ Tools: \(tools.count) registered ║
║ Workspace: \(config.workspaceRoot.prefix(42))
║ Profile: \(config.profile)
╚══════════════════════════════════════════════════════════╝
"""
FileHandle.standardError.write(Data(banner.utf8))
while true {
var clientAddr = sockaddr_in6()
var clientLen = socklen_t(MemoryLayout<sockaddr_in6>.size)
let clientFd = withUnsafeMutablePointer(to: &clientAddr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
accept(serverFd, $0, &clientLen)
}
}
guard clientFd >= 0 else { continue }
Task { [weak self] in
await self?.handleHTTPConnection(clientFd)
}
}
}
private func handleHTTPConnection(_ fd: Int32) async {
defer { close(fd) }
var buffer = [UInt8](repeating: 0, count: config.maxRequestSize)
let bytesRead = read(fd, &buffer, buffer.count)
guard bytesRead > 0 else { return }
let rawRequest = String(bytes: buffer[..<bytesRead], encoding: .utf8) ?? ""
requestsTotal += 1
let (method, path, headers, body) = parseHTTPRequest(rawRequest)
// Auth check
if config.hasAuth {
let authHeader = headers["authorization"] ?? ""
if !authHeader.hasSuffix(config.authToken) {
authFailures += 1
sendHTTPResponse(fd, status: 401, body: #"{"error":"Unauthorized"}"#)
return
}
}
// CORS
let origin = headers["origin"]
let corsHeaders = buildCORSHeaders(origin: origin)
if method == "OPTIONS" {
sendHTTPResponse(fd, status: 204, body: "", extraHeaders: corsHeaders)
return
}
let responseBody: String
let statusCode: Int
switch (method, path) {
case ("GET", "/health"):
statusCode = 200
responseBody = JSON.serialize([
"status": "ok",
"service": name,
"version": version,
"protocol": protocolVersion,
"tools": tools.count,
"uptime_seconds": Int(Date().timeIntervalSince(startTime)),
] as [String: Any])
case ("GET", "/ready"):
statusCode = tools.isEmpty ? 503 : 200
responseBody = JSON.serialize([
"ready": !tools.isEmpty,
"tools": tools.count,
] as [String: Any])
case ("GET", "/status"):
statusCode = 200
responseBody = JSON.serialize([
"server": name,
"version": version,
"protocol": protocolVersion,
"tools": tools.count,
"resources": resources.count,
"prompts": prompts.count,
"session_id": sessionId,
"uptime_seconds": Int(Date().timeIntervalSince(startTime)),
"config": config.toDict(),
] as [String: Any])
case ("GET", "/mcp/tools"):
statusCode = 200
let toolList = tools.values.sorted { $0.name < $1.name }.map { tool -> [String: Any] in
[
"name": tool.name,
"description": tool.description,
"annotations": [
"readOnlyHint": tool.annotations.readOnlyHint,
"destructiveHint": tool.annotations.destructiveHint,
"idempotentHint": tool.annotations.idempotentHint,
] as [String: Any],
]
}
responseBody = JSON.serialize(["tools": toolList] as [String: Any])
case ("GET", "/metrics"):
statusCode = 200
responseBody = generatePrometheusMetrics()
case ("POST", "/mcp"):
mcpCalls += 1
let result = await handleJsonRpc(body)
statusCode = 200
if let result {
responseBody = JSON.serialize(result)
} else {
responseBody = ""
sendHTTPResponse(fd, status: 204, body: "", extraHeaders: corsHeaders)
return
}
case ("DELETE", "/mcp"):
statusCode = 200
responseBody = JSON.serialize(["terminated": true, "session_id": sessionId] as [String: Any])
default:
statusCode = 404
responseBody = JSON.serialize(["error": "Not Found", "path": path] as [String: Any])
}
sendHTTPResponse(fd, status: statusCode, body: responseBody, extraHeaders: corsHeaders)
}
// MARK: - JSON-RPC Dispatch
func handleJsonRpc(_ input: String) async -> [String: Any]? {
guard let parsed = JSON.parse(input) else {
return errorResponse(id: nil, code: -32700, message: "Parse error")
}
// Batch support
if let batch = parsed as? [[String: Any]] {
var results: [[String: Any]] = []
for item in batch {
if let result = await dispatchMethod(item) {
results.append(result)
}
}
return ["_batch": results]
}
if let single = parsed as? [String: Any] {
return await dispatchMethod(single)
}
return errorResponse(id: nil, code: -32600, message: "Invalid Request")
}
private func dispatchMethod(_ request: [String: Any]) async -> [String: Any]? {
let id = request["id"]
let method = request["method"] as? String ?? ""
let params = request["params"] as? [String: Any] ?? [:]
// Notifications (no id) don't return a response
if request["id"] == nil {
switch method {
case "notifications/initialized":
initialized = true
log("info", "Client initialized")
case "notifications/cancelled":
log("debug", "Request cancelled")
default:
break
}
return nil
}
let anyId = id
switch method {
case "initialize":
initialized = true
return successResponse(id: anyId, result: [
"protocolVersion": protocolVersion,
"serverInfo": [
"name": name,
"version": version,
] as [String: Any],
"capabilities": [
"tools": ["listChanged": true],
"resources": ["subscribe": true, "listChanged": true],
"prompts": ["listChanged": true],
"logging": [:] as [String: Any],
] as [String: Any],
"instructions": "CxSwiftAgent — Swift MCP coding agent with \(tools.count) tools. "
+ "Supports file operations, code intelligence, Git, Xcode, project management, "
+ "and terminal execution.",
] as [String: Any])
case "tools/list":
let toolList = tools.values.sorted { $0.name < $1.name }.map { tool -> [String: Any] in
var entry: [String: Any] = [
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema,
]
let ann = tool.annotations
if ann.readOnlyHint || ann.destructiveHint || ann.idempotentHint {
entry["annotations"] = [
"readOnlyHint": ann.readOnlyHint,
"destructiveHint": ann.destructiveHint,
"idempotentHint": ann.idempotentHint,
] as [String: Any]
}
return entry
}
return successResponse(id: anyId, result: ["tools": toolList] as [String: Any])
case "tools/call":
return await handleToolCall(id: anyId, params: params)
case "resources/list":
let resList = resources.values.sorted { $0.uri < $1.uri }.map { res -> [String: Any] in
["uri": res.uri, "name": res.name, "mimeType": res.mimeType]
}
return successResponse(id: anyId, result: ["resources": resList] as [String: Any])
case "resources/read":
let uri = params["uri"] as? String ?? ""
guard let resource = resources[uri] else {
return errorResponse(id: anyId, code: -32602, message: "Unknown resource: \(uri)")
}
let content = await resource.handler()
return successResponse(id: anyId, result: [
"contents": [["uri": uri, "mimeType": resource.mimeType, "text": content]] as [[String: Any]]
] as [String: Any])
case "prompts/list":
let promptList = prompts.values.sorted { $0.name < $1.name }.map { p -> [String: Any] in
["name": p.name, "description": p.description, "arguments": p.arguments]
}
return successResponse(id: anyId, result: ["prompts": promptList] as [String: Any])
case "prompts/get":
let promptName = params["name"] as? String ?? ""
guard let prompt = prompts[promptName] else {
return errorResponse(id: anyId, code: -32602, message: "Unknown prompt: \(promptName)")
}
let args = (params["arguments"] as? [String: String]) ?? [:]
let messages = prompt.handler(args)
return successResponse(id: anyId, result: [
"description": prompt.description,
"messages": messages,
] as [String: Any])
case "roots/list":
return successResponse(id: anyId, result: ["roots": roots] as [String: Any])
case "logging/setLevel":
let level = (params["level"] as? String)?.lowercased() ?? "info"
logLevel = level
return successResponse(id: anyId, result: ["level": level] as [String: Any])
case "ping":
return successResponse(id: anyId, result: [:] as [String: Any])
default:
return errorResponse(id: anyId, code: -32601, message: "Method not found: \(method)")
}
}
// MARK: - Tool Execution
private func handleToolCall(id: Any?, params: [String: Any]) async -> [String: Any] {
let toolName = params["name"] as? String ?? ""
let arguments = params["arguments"] as? [String: Any] ?? [:]
guard let tool = tools[toolName] else {
return errorResponse(id: id, code: -32602, message: "Unknown tool: \(toolName)")
}
toolCalls += 1
let startTime = Date()
log("debug", "Tool call: \(toolName)")
let result = await tool.handler(arguments)
let durationMs = Date().timeIntervalSince(startTime) * 1000
// Track metrics
await memory.recordToolCall(name: toolName, durationMs: durationMs, success: true)
updateToolMetrics(toolName, durationMs: durationMs, success: true)
return successResponse(id: id, result: [
"content": result,
"isError": false,
] as [String: Any])
}
// MARK: - HTTP Helpers
private func parseHTTPRequest(_ raw: String) -> (method: String, path: String, headers: [String: String], body: String) {
let parts = raw.split(separator: "\r\n\r\n", maxSplits: 1)
let headerSection = String(parts[0])
let body = parts.count > 1 ? String(parts[1]) : ""
let lines = headerSection.components(separatedBy: "\r\n")
let requestLine = lines.first?.components(separatedBy: " ") ?? []
let method = requestLine.first ?? "GET"
let fullPath = requestLine.count > 1 ? requestLine[1] : "/"
let path = fullPath.components(separatedBy: "?").first ?? "/"
var headers: [String: String] = [:]
for line in lines.dropFirst() {
let headerParts = line.split(separator: ":", maxSplits: 1)
if headerParts.count == 2 {
headers[String(headerParts[0]).lowercased().trimmingCharacters(in: .whitespaces)]
= String(headerParts[1]).trimmingCharacters(in: .whitespaces)
}
}
return (method, path, headers, body)
}
private func sendHTTPResponse(_ fd: Int32, status: Int, body: String,
contentType: String = "application/json",
extraHeaders: [String: String] = [:]) {
let statusText: String
switch status {
case 200: statusText = "OK"
case 204: statusText = "No Content"
case 401: statusText = "Unauthorized"
case 404: statusText = "Not Found"
case 429: statusText = "Too Many Requests"
case 500: statusText = "Internal Server Error"
case 503: statusText = "Service Unavailable"
default: statusText = "OK"
}
var headerLines = [
"HTTP/1.1 \(status) \(statusText)",
"Content-Type: \(contentType); charset=utf-8",
"Content-Length: \(body.utf8.count)",
"Connection: close",
"X-Server: CxSwiftAgent/\(version)",
"Mcp-Session-Id: \(sessionId)",
]
for (key, val) in extraHeaders {
headerLines.append("\(key): \(val)")
}
let response = headerLines.joined(separator: "\r\n") + "\r\n\r\n" + body
let data = Data(response.utf8)
data.withUnsafeBytes { ptr in
_ = write(fd, ptr.baseAddress, data.count)
}
}
private func buildCORSHeaders(origin: String?) -> [String: String] {
let allowedOrigin = origin.flatMap { config.isOriginAllowed($0) ? $0 : nil } ?? "*"
return [
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, X-Request-ID",
"Access-Control-Max-Age": "86400",
]
}
// MARK: - Response Helpers
private func successResponse(id: Any?, result: [String: Any]) -> [String: Any] {
["jsonrpc": "2.0", "result": result, "id": id ?? NSNull()]
}
private func errorResponse(id: Any?, code: Int, message: String) -> [String: Any] {
errors += 1
return ["jsonrpc": "2.0", "error": ["code": code, "message": message] as [String: Any], "id": id ?? NSNull()]
}
// MARK: - Metrics
private func updateToolMetrics(_ name: String, durationMs: Double, success: Bool) {
var m = (toolMetrics[name] as [String: Any]?) ?? ["calls": 0, "errors": 0, "total_ms": 0.0]
m["calls"] = (m["calls"] as? Int ?? 0) + 1
if !success { m["errors"] = (m["errors"] as? Int ?? 0) + 1 }
m["total_ms"] = (m["total_ms"] as? Double ?? 0) + durationMs
toolMetrics[name] = m
}
private func generatePrometheusMetrics() -> String {
let uptime = Int(Date().timeIntervalSince(startTime))
return """
# HELP cxswiftagent_requests_total Total HTTP requests
# TYPE cxswiftagent_requests_total counter
cxswiftagent_requests_total \(requestsTotal)
# HELP cxswiftagent_mcp_calls_total Total MCP JSON-RPC calls
# TYPE cxswiftagent_mcp_calls_total counter
cxswiftagent_mcp_calls_total \(mcpCalls)
# HELP cxswiftagent_tool_calls_total Total tool executions
# TYPE cxswiftagent_tool_calls_total counter
cxswiftagent_tool_calls_total \(toolCalls)
# HELP cxswiftagent_tool_errors_total Total tool errors
# TYPE cxswiftagent_tool_errors_total counter
cxswiftagent_tool_errors_total \(toolErrors)
# HELP cxswiftagent_tools_registered Number of registered tools
# TYPE cxswiftagent_tools_registered gauge
cxswiftagent_tools_registered \(tools.count)
# HELP cxswiftagent_uptime_seconds Server uptime
# TYPE cxswiftagent_uptime_seconds gauge
cxswiftagent_uptime_seconds \(uptime)
"""
}
// MARK: - Logging
func log(_ level: String, _ message: String) {
let levels = ["debug": 0, "info": 1, "warn": 2, "error": 3]
guard (levels[level] ?? 1) >= (levels[logLevel] ?? 1) else { return }
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] [\(level.uppercased())] \(message)\n"
FileHandle.standardError.write(Data(line.utf8))
}
}