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

Peter LiddlePeter Liddle
10 min read

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:

  1. Starting the MCP server process
  2. Setting up pipes for communication
  3. Creating a transport layer for the MCP client
  4. Connecting the client to the server
  5. 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:

  1. Build the MCP server from our previous tutorial (or any compatible MCP server)
  2. Note the path to the built executable
  3. 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:

  1. Open the project in Xcode by opening the Package.swift file
  2. Select the SwiftMCPClientExample scheme
  3. Go to Product > Scheme > Edit Scheme (or press ⌘<)
  4. Select the 'Run' action on the left panel
  5. Go to the 'Arguments' tab
  6. Under 'Arguments Passed On Launch', click the '+' button
  7. Add -MCP_SERVER_BUILD_PATH=/path/to/server/build/directory (replace with the actual path to your MCP server's build directory)
  8. Click 'Close' and run the project (⌘R)

Xcode Scheme Arguments

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:

  1. The tool information for the two available tools on the server ("echo" and "selectRandom")
  2. The schema information for each tool
  3. 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 server
  • executablePath: The path to the server executable
  • arguments: Command-line arguments to pass to the server
  • environment: 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:

  1. Define a structure that matches the tool's input schema
  2. Create an instance of that structure with the appropriate values
  3. Convert the structure to a dictionary of arguments
  4. 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:

  1. Create your own custom tools to extend AI capabilities
  2. Integrate MCP clients into your Swift applications
  3. 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

0
Subscribe to my newsletter

Read articles from Peter Liddle directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Peter Liddle
Peter Liddle