diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index e4b43a5..5cbbe87 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -31,17 +31,17 @@ jobs: - name: Run tests run: swift test -q --enable-code-coverage # Upload code coverage - - uses: michaelhenry/swifty-code-coverage@v1.0.0 - with: - build-path: .build - target: MCPPackageTests.xctest - is-spm: true - - name: Upload to Codecov - run: | - bash <(curl https://codecov.io/bash) -f "coverage/*.info" - shell: bash - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # - uses: michaelhenry/swifty-code-coverage@v1.0.0 + # with: + # build-path: .build + # target: MCPPackageTests.xctest + # is-spm: true + # - name: Upload to Codecov + # run: | + # bash <(curl https://codecov.io/bash) -f "coverage/*.info" + # shell: bash + # env: + # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} lint: runs-on: macos-15 diff --git a/ExampleMCPClient/Sources/main.swift b/ExampleMCPClient/Sources/main.swift new file mode 100644 index 0000000..daa71e7 --- /dev/null +++ b/ExampleMCPClient/Sources/main.swift @@ -0,0 +1,41 @@ +// swiftlint:disable no_direct_standard_out_logs +import Foundation +import MCPClient +import MCPInterface + +/// Read the path from the process args +let repoPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/path/to/repo" + +let client = try await MCPClient( + info: .init(name: "test", version: "1.0.0"), + transport: .stdioProcess( + "uvx", + args: ["mcp-server-git"], + verbose: true), + capabilities: .init()) + +let tools = await client.tools +let tool = try tools.value.get().first(where: { $0.name == "git_status" })! +print("git_status tool: \(tool)") + +// Those parameters can be passed to an LLM that support tool calling. +let description = tool.description +let name = tool.name +let schemaData = try JSONEncoder().encode(tool.inputSchema) +let schema = try JSONSerialization.jsonObject(with: schemaData) + +/// The LLM could call into the tool with unstructured JSON input: +let llmToolInput: [String: Any] = [ + "repo_path": repoPath, +] +let llmToolInputData = try JSONSerialization.data(withJSONObject: llmToolInput) +let toolInput = try JSONDecoder().decode(JSON.self, from: llmToolInputData) + +// Alternatively, you can call into the tool directly from Swift with structured input: +// let toolInput: JSON = ["repo_path": .string(repoPath)] + +let result = try await client.callTool(named: name, arguments: toolInput) +if result.isError != true { + let content = result.content.first?.text?.text + print("Git status: \(content ?? "")") +} diff --git a/MCPClient/Sources/DataChannel+StdioProcess.swift b/MCPClient/Sources/DataChannel+StdioProcess.swift index fb9fd4f..d9ef9d3 100644 --- a/MCPClient/Sources/DataChannel+StdioProcess.swift +++ b/MCPClient/Sources/DataChannel+StdioProcess.swift @@ -11,6 +11,7 @@ private let logger = Logger( public enum JSONRPCSetupError: Error { case missingStandardIO + case standardIOConnectionError(_ message: String) case couldNotLocateExecutable(executable: String, error: String?) } @@ -24,6 +25,8 @@ extension JSONRPCSetupError: LocalizedError { return "Missing standard IO" case .couldNotLocateExecutable(let executable, let error): return "Could not locate executable \(executable) \(error ?? "")".trimmingCharacters(in: .whitespaces) + case .standardIOConnectionError(let message): + return "Could not connect to stdio: \(message)".trimmingCharacters(in: .whitespaces) } } @@ -33,6 +36,8 @@ extension JSONRPCSetupError: LocalizedError { return "Make sure that the Process that is passed as an argument has stdin, stdout and stderr set as a Pipe." case .couldNotLocateExecutable: return "Check that the executable is findable given the PATH environment variable. If needed, pass the right environment to the process." + case .standardIOConnectionError: + return nil } } } @@ -55,20 +60,31 @@ extension DataChannel { } // Create the process - func path(for executable: String) throws -> String { + func path(for executable: String, env: [String: String]?) -> String? { guard !executable.contains("/") else { return executable } - let path = try locate(executable: executable, env: env) - return path.isEmpty ? executable : path + do { + let path = try locate(executable: executable, env: env) + return path.isEmpty ? nil : path + } catch { + // Most likely an error because we could not locate the executable + return nil + } } - // TODO: look at how to use /bin/zsh, at least on MacOS, to avoid needing to specify PATH to locate the executable let process = Process() - process.executableURL = URL(fileURLWithPath: try path(for: executable)) - process.arguments = args - if let env { - process.environment = env + // In MacOS, zsh is the default since macOS Catalina 10.15.7. We can safely assume it is available. + process.launchPath = "/bin/zsh" + if let executable = path(for: executable, env: env) { + let command = "\(executable) \(args.joined(separator: " "))" + process.arguments = ["-c"] + [command] + process.environment = env ?? ProcessInfo.processInfo.environment + } else { + // If we cannot locate the executable, try loading the default environment for zsh, as the current process might not have the correct PATH. + process.environment = try loadZshEnvironment() + let command = "\(executable) \(args.joined(separator: " "))" + process.arguments = ["-c"] + [command] } // Working directory @@ -179,11 +195,7 @@ extension DataChannel { /// Finds the full path to the executable using the `which` command. private static func locate(executable: String, env: [String: String]? = nil) throws -> String { - let stdout = Pipe() - let stderr = Pipe() let process = Process() - process.standardOutput = stdout - process.standardError = stderr process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [executable] @@ -191,6 +203,32 @@ extension DataChannel { process.environment = env } + guard let executablePath = try getProcessStdout(process: process), !executablePath.isEmpty + else { + throw JSONRPCSetupError.couldNotLocateExecutable(executable: executable, error: "") + } + return executablePath + } + + private static func loadZshEnvironment() throws -> [String: String] { + let process = Process() + process.launchPath = "/bin/zsh" + process.arguments = ["-c", "source ~/.zshrc && printenv"] + let env = try getProcessStdout(process: process) + + if let path = env?.split(separator: "\n").filter({ $0.starts(with: "PATH=") }).first { + return ["PATH": String(path.dropFirst("PATH=".count))] + } else { + return ProcessInfo.processInfo.environment + } + } + + private static func getProcessStdout(process: Process) throws -> String? { + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + let group = DispatchGroup() var stdoutData = Data() var stderrData = Data() @@ -208,20 +246,14 @@ extension DataChannel { stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() try process.finish() } catch { - throw JSONRPCSetupError.couldNotLocateExecutable( - executable: executable, - error: String(data: stderrData, encoding: .utf8)) + throw JSONRPCSetupError + .standardIOConnectionError( + "Error loading environment: \(error). Stderr: \(String(data: stderrData, encoding: .utf8) ?? "")") } group.wait() - guard - let executablePath = String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - !executablePath.isEmpty - else { - throw JSONRPCSetupError.couldNotLocateExecutable(executable: executable, error: String(data: stderrData, encoding: .utf8)) - } - return executablePath + return String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/Package.swift b/Package.swift index 3f41d1b..d8f0831 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,9 @@ let package = Package( .executable(name: "ExampleMCPServer", targets: [ "ExampleMCPServer", ]), + .executable(name: "ExampleMCPClient", targets: [ + "ExampleMCPClient", + ]), ], dependencies: [ .package(url: "https://github.com/ChimeHQ/JSONRPC", revision: "ef61a695bafa0e07080dadac65a0c59b37880548"), @@ -62,6 +65,12 @@ let package = Package( .target(name: "MCPServer"), ], path: "ExampleMCPServer/Sources"), + .executableTarget( + name: "ExampleMCPClient", + dependencies: [ + .target(name: "MCPClient"), + ], + path: "ExampleMCPClient/Sources"), // Tests libraries .target( diff --git a/default.profraw b/default.profraw new file mode 100644 index 0000000..e69de29