Building a MCP Client in Swift: A Step-by-Step Guide (Part 2)

Table of contents
This is the second article in our series on the Model Context Protocol (MCP) in Swift. If you haven't read the first part about building an MCP server in Swift, we recommend checking it out first.
In our previous article, we explored how to create a Model Context Protocol (MCP) server in Swift. Now, let's look at the other side of the equation: building a client application that connects to an MCP server. This guide will walk you through creating a Swift client that can communicate with any MCP-compatible server, including the one we built in our previous tutorial.
What is a MCP Client?
A Model Context Protocol (MCP) client is an application that connects to an MCP server to access its tools and capabilities. The client sends requests to the server, which processes them and returns responses. This architecture allows AI systems to extend their capabilities through external tools without needing to implement those capabilities directly.
Prerequisites
Before we begin, make sure you have:
- macOS 13.3 or later
- Swift 6.0 or later
- Xcode 15.0 or later (recommended)
- A compatible MCP server (like the one from our previous tutorial)
Step 1: Set Up Your Project
First, let's create a new Swift package for our MCP client:
mkdir SwiftMCPClientExample
cd SwiftMCPClientExample
swift package init --type executable
Next, update your Package.swift
file to include the necessary dependencies:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SwiftMCPClientExample",
platforms: [
.macOS("13.3")
],
dependencies: [
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"),
.package(url: "https://github.com/glaciotech/mcp-swift-sdk-helpers.git", from: "0.2.0"),
],
targets: [
.executableTarget(
name: "SwiftMCPClientExample",
dependencies: [
.product(name: "MCP", package: "swift-sdk"),
.product(name: "MCPHelpers", package: "mcp-swift-sdk-helpers"),
]),
]
)
Step 2: Create a Configuration Structure
We'll start by creating a configuration structure to hold information about the MCP server we want to connect to. Create a new file called LocalMCProcess.swift
in your Sources directory:
import MCP
import Foundation
import Logging
import System
public struct LocalMCPServerConfig: Codable {
public var name: String
public var executablePath: String
public var arguments: [String]
public var environment: [String: String]
public init(name: String, executablePath: String, arguments: [String] = [], environment: [String: String] = [:]) {
self.name = name
self.executablePath = executablePath
self.arguments = arguments
self.environment = environment
}
}
This structure defines the configuration needed to start and connect to a local MCP server process.
Step 3: Implement the LocalMCProcess Class
Next, we'll implement a class that manages the lifecycle of a local MCP server process. Add the following to your LocalMCProcess.swift
file:
public class LocalMCProcess {
private let config: LocalMCPServerConfig
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
public init(config: LocalMCPServerConfig) {
self.config = config
}
public func start() async throws -> Client {
// Create pipes for stdin, stdout, and stderr
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
// Create and configure the process
let process = Process()
process.executableURL = URL(fileURLWithPath: config.executablePath)
process.arguments = config.arguments
// Set environment variables if provided
if !config.environment.isEmpty {
var environment = ProcessInfo.processInfo.environment
for (key, value) in config.environment {
environment[key] = value
}
process.environment = environment
}
// Connect pipes to the process
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
// Store references to prevent deallocation
self.process = process
self.stdinPipe = stdinPipe
self.stdoutPipe = stdoutPipe
self.stderrPipe = stderrPipe
// Launch the process
try process.run()
// Convert FileHandles to FileDescriptors for StdioTransport
let inputFD = FileDescriptor(rawValue: stdoutPipe.fileHandleForReading.fileDescriptor)
let outputFD = FileDescriptor(rawValue: stdinPipe.fileHandleForWriting.fileDescriptor)
// Create StdioTransport
let logger = Logger(label: "mcp.transport.process.\(config.name)")
let transport = StdioTransport(
input: inputFD,
output: outputFD,
logger: logger
)
// Connect the client
let client = Client(name: self.config.name, version: "1.0.0", configuration: .default)
_ = try await client.connect(transport: transport)
return client
}
public func stop() {
// Terminate the process
process?.terminate()
// Close all file handles
try? stdinPipe?.fileHandleForWriting.close()
try? stdoutPipe?.fileHandleForReading.close()
try? stderrPipe?.fileHandleForReading.close()
// Clear references
process = nil
stdinPipe = nil
stdoutPipe = nil
stderrPipe = nil
}
}
This class handles:
- Starting the MCP server process
- Setting up pipes for communication
- Creating a transport layer for the MCP client
- Connecting the client to the server
- Cleaning up resources when done
Step 4: Create the Main Application
Now, let's create our main application that uses the LocalMCProcess
class to connect to an MCP server and call its tools. Update your main.swift
file:
import Foundation
import MCP
import MCPHelpers
import SwiftyJsonSchema
// MARK: - Helpers
let jsonEncoder = JSONEncoder()
let jsonDecoder = JSONDecoder()
func printToolInfo(_ tool: Tool) throws {
print("--- TOOL INFO ---")
print("Name: \(tool.name)")
print("Annotations: \(tool.annotations)")
do {
guard let rawInputSchema = tool.inputSchema else {
print("ERROR: No input schema in Tool info")
return
}
let jsonSchema = try JSONSchema(fromValue: rawInputSchema)
print("Schema: \(jsonSchema)")
}
catch {
print("!!! Problem reading schema !!!: \(error)")
}
print("------------")
}
// MARK: - Test MCP
// Get the path to the MCP server executable
// You can pass this as a command-line argument or hardcode it
guard let pathToMCPExampleServerBuildDirectory = UserDefaults.standard.string(forKey: "MCP_SERVER_BUILD_PATH") else {
fatalError("!!! YOU NEED TO DEFINE THE PATH TO YOUR MCP SERVER !!!")
}
// Create a configuration for the MCP server
let jsonConfig = """
{
"name": "swift-mcp-server-example",
"executablePath": "\(pathToMCPExampleServerBuildDirectory)/Products/Debug/SwiftMCPServerExample",
"arguments": [],
"environment": {}
}
"""
// Load the configuration
let config = try jsonDecoder.decode(LocalMCPServerConfig.self, from: jsonConfig.data(using: .ascii)!)
// Start the server process and connect a client
let mcp = LocalMCProcess(config: config)
let client = try await mcp.start()
// List available tools
let toolListResponse = try await client.listTools()
for tool in toolListResponse.tools {
try printToolInfo(tool)
}
// Define a structure for the tool input
struct EchoToolInput: Codable {
var echoText: String = ""
}
// Call the "echo" tool
let toolRequest = EchoToolInput(echoText: "Hello World from MCP Client")
let toolArgs = try Value(toolRequest).objectValue
let result = try await client.callTool(name: "echo", arguments: toolArgs)
print("RESULT FROM TOOL CALL: \(result.content.first!)")
// Clean up
mcp.stop()
Step 5: Running the Client
To run the client, you need to:
- Build the MCP server from our previous tutorial (or any compatible MCP server)
- Note the path to the built executable
- Run the client with the server path as an argument
Running from Command Line
swift run SwiftMCPClientExample -MCP_SERVER_BUILD_PATH=/path/to/server/build/directory
Replace /path/to/server/build/directory
with the actual path to your MCP server's build directory.
Running from Xcode
To run the project in Xcode:
- Open the project in Xcode by opening the Package.swift file
- Select the SwiftMCPClientExample scheme
- Go to Product > Scheme > Edit Scheme (or press ⌘<)
- Select the 'Run' action on the left panel
- Go to the 'Arguments' tab
- Under 'Arguments Passed On Launch', click the '+' button
- Add
-MCP_SERVER_BUILD_PATH=/path/to/server/build/directory
(replace with the actual path to your MCP server's build directory) - Click 'Close' and run the project (⌘R)
Tip: You can find the build directory path in Xcode by building the MCP server project, then selecting Product > Show Build Folder in Finder.
Expected Output
When you run the client successfully, you should see output similar to the following:
--- TOOL INFO ---
Name: echo
Annotations: Annotations(title: nil, destructiveHint: nil, idempotentHint: nil, openWorldHint: nil, readOnlyHint: nil)
Schema: {
"additionalProperties" : false,
"properties" : {
"echoText" : {
"items" : {
},
"description" : "Text to send to the service that will be echoed back",
"type" : "string",
"additionalProperties" : false
}
},
"items" : {
},
"type" : "object",
"required" : [
"echoText"
],
"$schema" : "http:\/\/json-schema.org\/draft-07\/schema#"
}
------------
--- TOOL INFO ---
Name: selectRandom
Annotations: Annotations(title: nil, destructiveHint: nil, idempotentHint: nil, openWorldHint: nil, readOnlyHint: nil)
Schema: {
"$schema" : "http:\/\/json-schema.org\/draft-07\/schema#",
"properties" : {
"ints" : {
"items" : {
"items" : {
"properties" : {
},
"items" : {
},
"type" : "object",
"required" : [
],
"additionalProperties" : false
}
},
"description" : "An array of integers to select one at random from",
"type" : "array",
"additionalProperties" : false
}
},
"items" : {
},
"type" : "object",
"required" : [
"ints"
],
"additionalProperties" : false
}
------------
RESULT FROM TOOL CALL: text("You sent: Hello World from MCP Client")
This output shows:
- The tool information for the two available tools on the server ("echo" and "selectRandom")
- The schema information for each tool
- The result of calling the "echo" tool, which returns the text we sent
Understanding the Code
Let's break down what's happening in our client application:
1. Server Configuration
We define a LocalMCPServerConfig
structure that contains all the information needed to start and connect to a local MCP server:
name
: A unique identifier for the serverexecutablePath
: The path to the server executablearguments
: Command-line arguments to pass to the serverenvironment
: Environment variables to set for the server process
2. Process Management
The LocalMCProcess
class handles the lifecycle of the MCP server process:
- It creates pipes for stdin, stdout, and stderr communication
- It launches the server process with the specified configuration
- It sets up a transport layer for the MCP client to communicate with the server
- It provides a method to stop the server and clean up resources when done
3. Client Connection
The client connects to the server using the MCP SDK's Client
class:
- It creates a client instance with a name and version
- It connects to the server using the transport layer
- Once connected, it can list available tools and call them with parameters
4. Tool Discovery and Invocation
The client can discover the tools available on the server:
- It calls
client.listTools()
to get a list of available tools - It can inspect each tool's name, annotations, and input schema
- It can call a tool by name with the appropriate arguments
5. Tool Calling
To call a tool, the client:
- Creates a structure representing the tool's input parameters
- Converts the structure to a dictionary of arguments
- Calls the tool with
client.callTool(name:, arguments:)
- Processes the result returned by the tool
Advanced Usage
Handling Different Tool Types
The example above demonstrates calling a simple "echo" tool, but you can call any tool provided by the MCP server. For each tool, you'll need to:
- Define a structure that matches the tool's input schema
- Create an instance of that structure with the appropriate values
- Convert the structure to a dictionary of arguments
- Call the tool and process the result
Error Handling
In a production application, you should add proper error handling:
do {
let result = try await client.callTool(name: "echo", arguments: toolArgs)
print("Success: \(result.content.first!)")
} catch let error as MCPError {
print("MCP Error: \(error)")
} catch {
print("Unexpected error: \(error)")
}
Asynchronous Tool Calls
The MCP SDK uses Swift's async/await pattern for asynchronous operations. Make sure your application is set up to handle asynchronous code:
Task {
do {
let client = try await mcp.start()
// Use the client...
} catch {
print("Error: \(error)")
}
}
Conclusion
In this tutorial, we've built a Swift client that can connect to an MCP server and call its tools. This client can work with any MCP-compatible server, including the one we built in our previous tutorial.
The Model Context Protocol provides a standardized way for AI systems to extend their capabilities through external tools. By implementing both the server and client sides of the protocol, you can create powerful, modular AI applications that can be easily extended with new capabilities.
Next Steps
Now that you understand how to build both MCP servers and clients in Swift, you can:
- Create your own custom tools to extend AI capabilities
- Integrate MCP clients into your Swift applications
- Connect your applications to AI systems that support the MCP protocol
Finding the Code on GitHub
The complete code for this tutorial is available on GitHub at github.com/glaciotech/SwiftMCPClientExample. Feel free to clone it, modify it, and use it as a starting point for your own MCP client projects.
Resources
Subscribe to my newsletter
Read articles from Peter Liddle directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
