c118996746
- 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
579 lines
22 KiB
Swift
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))
|
|
}
|
|
}
|