Skip to content
This repository was archived by the owner on May 29, 2025. It is now read-only.
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
22 changes: 11 additions & 11 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions ExampleMCPClient/Sources/main.swift
Original file line number Diff line number Diff line change
@@ -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 ?? "")")
}
76 changes: 54 additions & 22 deletions MCPClient/Sources/DataChannel+StdioProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ private let logger = Logger(

public enum JSONRPCSetupError: Error {
case missingStandardIO
case standardIOConnectionError(_ message: String)
case couldNotLocateExecutable(executable: String, error: String?)
}

Expand All @@ -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)
}
}

Expand All @@ -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
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -179,18 +195,40 @@ 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]

if let env {
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()
Expand All @@ -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)
}

}
Expand Down
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down
Empty file added default.profraw
Empty file.