Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down
93 changes: 93 additions & 0 deletions MacMLXCore/Sources/MacMLXCore/MCP/MCPClientConfig.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
110 changes: 110 additions & 0 deletions MacMLXCore/Tests/MacMLXCoreTests/MCP/MCPClientConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}