Skip to content
This repository was archived by the owner on May 29, 2025. It is now read-only.

Commit 8979940

Browse files
gsabranGui Sabran
andauthored
Automatically find PATH to execute stdio process (#10)
* Automatically find PATH to execute stdio process * lint and disable code coverage * update executable to work in command line mode as well --------- Co-authored-by: Gui Sabran <gsabran@www.com>
1 parent a436420 commit 8979940

File tree

5 files changed

+115
-33
lines changed

5 files changed

+115
-33
lines changed

.github/workflows/swift.yml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@ jobs:
3131
- name: Run tests
3232
run: swift test -q --enable-code-coverage
3333
# Upload code coverage
34-
- uses: michaelhenry/[email protected]
35-
with:
36-
build-path: .build
37-
target: MCPPackageTests.xctest
38-
is-spm: true
39-
- name: Upload to Codecov
40-
run: |
41-
bash <(curl https://codecov.io/bash) -f "coverage/*.info"
42-
shell: bash
43-
env:
44-
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
34+
# - uses: michaelhenry/[email protected]
35+
# with:
36+
# build-path: .build
37+
# target: MCPPackageTests.xctest
38+
# is-spm: true
39+
# - name: Upload to Codecov
40+
# run: |
41+
# bash <(curl https://codecov.io/bash) -f "coverage/*.info"
42+
# shell: bash
43+
# env:
44+
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
4545

4646
lint:
4747
runs-on: macos-15
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// swiftlint:disable no_direct_standard_out_logs
2+
import Foundation
3+
import MCPClient
4+
import MCPInterface
5+
6+
/// Read the path from the process args
7+
let repoPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/path/to/repo"
8+
9+
let client = try await MCPClient(
10+
info: .init(name: "test", version: "1.0.0"),
11+
transport: .stdioProcess(
12+
"uvx",
13+
args: ["mcp-server-git"],
14+
verbose: true),
15+
capabilities: .init())
16+
17+
let tools = await client.tools
18+
let tool = try tools.value.get().first(where: { $0.name == "git_status" })!
19+
print("git_status tool: \(tool)")
20+
21+
// Those parameters can be passed to an LLM that support tool calling.
22+
let description = tool.description
23+
let name = tool.name
24+
let schemaData = try JSONEncoder().encode(tool.inputSchema)
25+
let schema = try JSONSerialization.jsonObject(with: schemaData)
26+
27+
/// The LLM could call into the tool with unstructured JSON input:
28+
let llmToolInput: [String: Any] = [
29+
"repo_path": repoPath,
30+
]
31+
let llmToolInputData = try JSONSerialization.data(withJSONObject: llmToolInput)
32+
let toolInput = try JSONDecoder().decode(JSON.self, from: llmToolInputData)
33+
34+
// Alternatively, you can call into the tool directly from Swift with structured input:
35+
// let toolInput: JSON = ["repo_path": .string(repoPath)]
36+
37+
let result = try await client.callTool(named: name, arguments: toolInput)
38+
if result.isError != true {
39+
let content = result.content.first?.text?.text
40+
print("Git status: \(content ?? "")")
41+
}

MCPClient/Sources/DataChannel+StdioProcess.swift

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ private let logger = Logger(
1111

1212
public enum JSONRPCSetupError: Error {
1313
case missingStandardIO
14+
case standardIOConnectionError(_ message: String)
1415
case couldNotLocateExecutable(executable: String, error: String?)
1516
}
1617

@@ -24,6 +25,8 @@ extension JSONRPCSetupError: LocalizedError {
2425
return "Missing standard IO"
2526
case .couldNotLocateExecutable(let executable, let error):
2627
return "Could not locate executable \(executable) \(error ?? "")".trimmingCharacters(in: .whitespaces)
28+
case .standardIOConnectionError(let message):
29+
return "Could not connect to stdio: \(message)".trimmingCharacters(in: .whitespaces)
2730
}
2831
}
2932

@@ -33,6 +36,8 @@ extension JSONRPCSetupError: LocalizedError {
3336
return "Make sure that the Process that is passed as an argument has stdin, stdout and stderr set as a Pipe."
3437
case .couldNotLocateExecutable:
3538
return "Check that the executable is findable given the PATH environment variable. If needed, pass the right environment to the process."
39+
case .standardIOConnectionError:
40+
return nil
3641
}
3742
}
3843
}
@@ -55,20 +60,31 @@ extension DataChannel {
5560
}
5661

5762
// Create the process
58-
func path(for executable: String) throws -> String {
63+
func path(for executable: String, env: [String: String]?) -> String? {
5964
guard !executable.contains("/") else {
6065
return executable
6166
}
62-
let path = try locate(executable: executable, env: env)
63-
return path.isEmpty ? executable : path
67+
do {
68+
let path = try locate(executable: executable, env: env)
69+
return path.isEmpty ? nil : path
70+
} catch {
71+
// Most likely an error because we could not locate the executable
72+
return nil
73+
}
6474
}
6575

66-
// TODO: look at how to use /bin/zsh, at least on MacOS, to avoid needing to specify PATH to locate the executable
6776
let process = Process()
68-
process.executableURL = URL(fileURLWithPath: try path(for: executable))
69-
process.arguments = args
70-
if let env {
71-
process.environment = env
77+
// In MacOS, zsh is the default since macOS Catalina 10.15.7. We can safely assume it is available.
78+
process.launchPath = "/bin/zsh"
79+
if let executable = path(for: executable, env: env) {
80+
let command = "\(executable) \(args.joined(separator: " "))"
81+
process.arguments = ["-c"] + [command]
82+
process.environment = env ?? ProcessInfo.processInfo.environment
83+
} else {
84+
// If we cannot locate the executable, try loading the default environment for zsh, as the current process might not have the correct PATH.
85+
process.environment = try loadZshEnvironment()
86+
let command = "\(executable) \(args.joined(separator: " "))"
87+
process.arguments = ["-c"] + [command]
7288
}
7389

7490
// Working directory
@@ -179,18 +195,40 @@ extension DataChannel {
179195

180196
/// Finds the full path to the executable using the `which` command.
181197
private static func locate(executable: String, env: [String: String]? = nil) throws -> String {
182-
let stdout = Pipe()
183-
let stderr = Pipe()
184198
let process = Process()
185-
process.standardOutput = stdout
186-
process.standardError = stderr
187199
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
188200
process.arguments = [executable]
189201

190202
if let env {
191203
process.environment = env
192204
}
193205

206+
guard let executablePath = try getProcessStdout(process: process), !executablePath.isEmpty
207+
else {
208+
throw JSONRPCSetupError.couldNotLocateExecutable(executable: executable, error: "")
209+
}
210+
return executablePath
211+
}
212+
213+
private static func loadZshEnvironment() throws -> [String: String] {
214+
let process = Process()
215+
process.launchPath = "/bin/zsh"
216+
process.arguments = ["-c", "source ~/.zshrc && printenv"]
217+
let env = try getProcessStdout(process: process)
218+
219+
if let path = env?.split(separator: "\n").filter({ $0.starts(with: "PATH=") }).first {
220+
return ["PATH": String(path.dropFirst("PATH=".count))]
221+
} else {
222+
return ProcessInfo.processInfo.environment
223+
}
224+
}
225+
226+
private static func getProcessStdout(process: Process) throws -> String? {
227+
let stdout = Pipe()
228+
let stderr = Pipe()
229+
process.standardOutput = stdout
230+
process.standardError = stderr
231+
194232
let group = DispatchGroup()
195233
var stdoutData = Data()
196234
var stderrData = Data()
@@ -208,20 +246,14 @@ extension DataChannel {
208246
stdoutData = stdout.fileHandleForReading.readDataToEndOfFile()
209247
try process.finish()
210248
} catch {
211-
throw JSONRPCSetupError.couldNotLocateExecutable(
212-
executable: executable,
213-
error: String(data: stderrData, encoding: .utf8))
249+
throw JSONRPCSetupError
250+
.standardIOConnectionError(
251+
"Error loading environment: \(error). Stderr: \(String(data: stderrData, encoding: .utf8) ?? "")")
214252
}
215253

216254
group.wait()
217255

218-
guard
219-
let executablePath = String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
220-
!executablePath.isEmpty
221-
else {
222-
throw JSONRPCSetupError.couldNotLocateExecutable(executable: executable, error: String(data: stderrData, encoding: .utf8))
223-
}
224-
return executablePath
256+
return String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
225257
}
226258

227259
}

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ let package = Package(
2222
.executable(name: "ExampleMCPServer", targets: [
2323
"ExampleMCPServer",
2424
]),
25+
.executable(name: "ExampleMCPClient", targets: [
26+
"ExampleMCPClient",
27+
]),
2528
],
2629
dependencies: [
2730
.package(url: "https://github.com/ChimeHQ/JSONRPC", revision: "ef61a695bafa0e07080dadac65a0c59b37880548"),
@@ -62,6 +65,12 @@ let package = Package(
6265
.target(name: "MCPServer"),
6366
],
6467
path: "ExampleMCPServer/Sources"),
68+
.executableTarget(
69+
name: "ExampleMCPClient",
70+
dependencies: [
71+
.target(name: "MCPClient"),
72+
],
73+
path: "ExampleMCPClient/Sources"),
6574

6675
// Tests libraries
6776
.target(

default.profraw

Whitespace-only changes.

0 commit comments

Comments
 (0)