diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1156e..68408ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **MCP Client Config** (v0.5 MCP track, part 1 of 2). Pure-Swift + data layer for connecting macMLX to external MCP servers (mirror + of v0.4.0's MCP server role, but reversed: now we *are* the host + and chat models tool-call out to other people's MCP servers). + - `MCPClientConfig` Codable struct mirroring Claude Desktop's + `~/.claude_desktop_config.json` `mcpServers` shape — users + with existing MCP setups can copy the file across without + reformatting. + - `MCPClientConfigStore` actor reads / writes + `~/.mac-mlx/mcp.json`. Missing-file → empty config (no error + on first run). Malformed JSON → empty config; the bad bytes + stay on disk for the user to fix manually. + - 7 new unit tests cover Claude Desktop schema decode, JSON + round-trip, missing-file behaviour, and malformed-JSON fall- + through. Subprocess pool + chat-side tool-call routing follow + in v0.5 MCP track part 2. - **LoRA Parameters Inspector picker** (v0.5, part 4 of 4). User- facing surface for selecting and applying LoRA adapters. - `AppState.adapterStore` actor + `availableAdapters: [LocalAdapter]` diff --git a/MacMLXCore/Sources/MacMLXCore/MCP/MCPClientConfig.swift b/MacMLXCore/Sources/MacMLXCore/MCP/MCPClientConfig.swift new file mode 100644 index 0000000..c3a032f --- /dev/null +++ b/MacMLXCore/Sources/MacMLXCore/MCP/MCPClientConfig.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Codable mirror of Claude Desktop's `~/.claude_desktop_config.json` +/// `mcpServers` shape (and Cursor's identical schema). Persisted at +/// `~/.mac-mlx/mcp.json` so users with existing MCP setups can copy +/// the file across without translating. +/// +/// Wire format: +/// +/// ```json +/// { +/// "mcpServers": { +/// "everything": { +/// "command": "npx", +/// "args": ["-y", "@modelcontextprotocol/server-everything"], +/// "env": { "OPENAI_API_KEY": "…" } +/// } +/// } +/// } +/// ``` +public struct MCPClientConfig: Codable, Hashable, Sendable, Equatable { + + /// Map of human-readable server name → spawn instructions. + /// The map is unordered on the wire; `MCPClientPool.connectAll` + /// iterates `mcpServers.keys.sorted()` for stable ordering in + /// tool listings + UI. + public var mcpServers: [String: ServerEntry] + + public init(mcpServers: [String: ServerEntry] = [:]) { + self.mcpServers = mcpServers + } + + public struct ServerEntry: Codable, Hashable, Sendable, Equatable { + /// Executable to spawn (`npx`, `uvx`, an absolute path, …). + public let command: String + /// Arguments passed to the executable. Empty array is fine. + public let args: [String] + /// Optional environment variables merged into the + /// subprocess's environment. Nil means "inherit ours + /// unchanged"; an empty dict means "inherit + add nothing". + public let env: [String: String]? + + public init(command: String, args: [String] = [], env: [String: String]? = nil) { + self.command = command + self.args = args + self.env = env + } + } +} + +// MARK: - Persistence + +/// Filesystem-backed config loader / saver. +/// +/// On-disk default: `~/.mac-mlx/mcp.json`. Atomic writes; tolerates +/// missing files (returns `MCPClientConfig()` with no servers) so +/// first-run users see an empty list rather than an error. +public actor MCPClientConfigStore { + + private let url: URL + private let fileManager: FileManager + + /// Default URL: `~/.mac-mlx/mcp.json` (real home, dotfile data + /// root). + public init(url: URL? = nil, fileManager: FileManager = .default) { + self.url = url ?? DataRoot.macMLX("mcp.json") + self.fileManager = fileManager + } + + /// Read the config from disk. Missing file → empty config (no + /// servers). Malformed file → empty config + the bad bytes are + /// preserved on disk so the user can fix manually. + public func load() async -> MCPClientConfig { + guard let data = try? Data(contentsOf: url) else { + return MCPClientConfig() + } + return (try? JSONDecoder().decode(MCPClientConfig.self, from: data)) + ?? MCPClientConfig() + } + + /// Persist `config` atomically. Creates the parent directory if + /// it doesn't exist yet. + public func save(_ config: MCPClientConfig) async throws { + let dir = url.deletingLastPathComponent() + if !fileManager.fileExists(atPath: dir.path) { + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: url, options: .atomic) + } +} diff --git a/MacMLXCore/Tests/MacMLXCoreTests/MCP/MCPClientConfigTests.swift b/MacMLXCore/Tests/MacMLXCoreTests/MCP/MCPClientConfigTests.swift new file mode 100644 index 0000000..b7e07f5 --- /dev/null +++ b/MacMLXCore/Tests/MacMLXCoreTests/MCP/MCPClientConfigTests.swift @@ -0,0 +1,110 @@ +import Testing +import Foundation +@testable import MacMLXCore + +@Suite("MCPClientConfig") +struct MCPClientConfigTests { + + @Test + func decodesClaudeDesktopShape() throws { + let json = """ + { + "mcpServers": { + "everything": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everything"] + }, + "filesystem": { + "command": "uvx", + "args": ["mcp-server-filesystem", "/tmp"], + "env": { "FOO": "bar" } + } + } + } + """ + let config = try JSONDecoder().decode(MCPClientConfig.self, from: Data(json.utf8)) + #expect(config.mcpServers.count == 2) + + let everything = try #require(config.mcpServers["everything"]) + #expect(everything.command == "npx") + #expect(everything.args == ["-y", "@modelcontextprotocol/server-everything"]) + #expect(everything.env == nil) + + let fs = try #require(config.mcpServers["filesystem"]) + #expect(fs.env?["FOO"] == "bar") + } + + @Test + func roundTripsThroughJSON() throws { + let original = MCPClientConfig(mcpServers: [ + "x": .init(command: "uvx", args: ["mcp-server-x"], env: nil), + "y": .init(command: "node", args: ["server.js"], env: ["KEY": "val"]), + ]) + let data = try JSONEncoder().encode(original) + let back = try JSONDecoder().decode(MCPClientConfig.self, from: data) + #expect(back == original) + } + + @Test + func emptyConfigDecodesAndEncodes() throws { + let json = #"{"mcpServers": {}}"# + let cfg = try JSONDecoder().decode(MCPClientConfig.self, from: Data(json.utf8)) + #expect(cfg.mcpServers.isEmpty) + } +} + +@Suite("MCPClientConfigStore", .serialized) +struct MCPClientConfigStoreTests { + + @Test + func loadReturnsEmptyConfigWhenFileMissing() async throws { + let temp = try TempDir() + let store = MCPClientConfigStore(url: temp.url.appendingPathComponent("mcp.json")) + let cfg = await store.load() + #expect(cfg.mcpServers.isEmpty) + } + + @Test + func saveAndLoadRoundTrip() async throws { + let temp = try TempDir() + let store = MCPClientConfigStore(url: temp.url.appendingPathComponent("mcp.json")) + let original = MCPClientConfig(mcpServers: [ + "everything": .init(command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"]) + ]) + try await store.save(original) + let loaded = await store.load() + #expect(loaded == original) + } + + @Test + func saveCreatesParentDirectory() async throws { + let temp = try TempDir() + let nestedURL = temp.url + .appendingPathComponent("never-existed", isDirectory: true) + .appendingPathComponent("also-not", isDirectory: true) + .appendingPathComponent("mcp.json") + let store = MCPClientConfigStore(url: nestedURL) + try await store.save(MCPClientConfig()) + #expect(FileManager.default.fileExists(atPath: nestedURL.path)) + } + + @Test + func loadReturnsEmptyConfigOnMalformedJSON() async throws { + let temp = try TempDir() + let url = temp.url.appendingPathComponent("mcp.json") + try Data("{not json".utf8).write(to: url) + let store = MCPClientConfigStore(url: url) + let cfg = await store.load() + #expect(cfg.mcpServers.isEmpty, "malformed file must not crash; loader returns empty") + } +} + +private struct TempDir { + let url: URL + init() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("macmlx-mcp-config-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + self.url = base + } +}