Using Model Context Protocol in iOS apps

9 min read––– views

Using Model Context Protocol in iOS apps

In a previous post, we discussed how to create an MCP server in Swift. If you're new to MCP, I recommend reading it first.

There are plenty of MCP clients out there, but most of them are desktop apps. As an iOS developer, I wondered how to use MCP in an iOS app. In this post, we'll implement a simple chat with Claude LLM. As an example of MCP server, we'll get blood pressure data from HealthKit.

Prefer audio format? Listen to the blog post below:

Implementing MCP Server for HealthKit

Let's start with an abstract protocol for our MCP server:

import Foundation

protocol MCPServerProtocol {
    var tools: [Tool] { get }
    func call(_ tool: Tool) async throws -> String
} 

Based on Tool docs, we'll create a Tool struct to represent a function that can be called by the client:

struct Tool: Encodable {

    enum CodingKeys: String, CodingKey {
        case name, toolDescription = "description", input_schema
    }

    let name: String
    let toolDescription: String
    let input_schema: [String: String]
} 

Now, let's implement BloodPressureService that will be our MCP server:

final class BloodPressureService: MCPServerProtocol {

    var tools: [Tool] = [
        Tool(name: "blood_pressure",
             toolDescription: "Get the latest blood pressure (systolic and diastolic) from Apple Health.",
             input_schema: ["type": "object"])
    ]
}

Here we define a blood_pressure tool that will be used to get the latest blood pressure data from Apple Health. We'll not use any input parameters for this tool, but according to the docs input_schema is required.

Next, we'll implement the call method to handle the tool call:

func call(_ tool: Tool) async throws -> String {
    guard tool.name == "blood_pressure" else {
        // Custom error
        throw Error.toolNotSupported
    }
    let (systolic, diastolic) = try await fetchLatestBloodPressure()
    return "\(Int(systolic))/\(Int(diastolic))"
}

Now, let's implement the fetchLatestBloodPressure function:

private lazy var healthStore = HKHealthStore()

private let systolicType = HKQuantityType(.bloodPressureSystolic)
private let diastolicType = HKQuantityType(.bloodPressureDiastolic)
private let bloodPressureType = HKCorrelationType(.bloodPressure)

private func fetchLatestBloodPressure() async throws -> (systolic: Double, diastolic: Double) {
    // Request authorization
    try await healthStore.requestAuthorization(toShare: [], read: [systolicType, diastolicType])

    // Fetch the latest blood pressure data
    let descriptor = HKSampleQueryDescriptor(predicates: [.sample(type: bloodPressureType)], sortDescriptors: [])
    let samples = try await descriptor.result(for: healthStore)
    guard let sample = samples.first as? HKCorrelation else {
        // Custom error
        throw Error.missingBloodPressureData
    }
    guard let systolic = sample.objects(for: systolicType).first as? HKQuantitySample,
            let diastolic = sample.objects(for: diastolicType).first as? HKQuantitySample else {
        // Custom error
        throw Error.missingBloodPressureData
    }

    let systolicValue = systolic.quantity.doubleValue(for: .millimeterOfMercury())
    let diastolicValue = diastolic.quantity.doubleValue(for: .millimeterOfMercury())
    return (systolicValue, diastolicValue)
}
️ℹ️

You can test HealthKit in iOS Simulator. Open the Health app, select the Browse tab, search for "Blood Pressure" and add a sample to the simulator.

To add a HealthKit data access to your app, you need to add HealthKit capability and update Info.plist file with NSHealthShareUsageDescription key.

Now we can test the service:

let bloodPressureService = BloodPressureService()
Task {
    let result = try await bloodPressureService.call(bloodPressureService.tools[0])
    print(result) // Prints a sample output: 123/78
}

Using Anthropic API for messaging

To implement a chat with Claude LLM, we'll use Anthropic API for messaging. Let's create a AnthropicService that will be used in our MCP client:

import Foundation

final class AnthropicService {

    private let apiKey: String
    private let tools: [Tool]
    
    init(apiKey: String, tools: [Tool]) {
        self.apiKey = apiKey
        self.tools = tools
    }
    
    func send(messages: [Request.Message]) async throws -> Response {
        var request = URLRequest(url: URL(string: "https://api.anthropic.com/v1/messages")!)
        request.httpMethod = "POST"
        request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
        request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
        
        let body = Request(model: "claude-3-opus-20240229", messages: messages, max_tokens: 1024, tools: tools)
        let encoder = JSONEncoder()
        request.httpBody = try encoder.encode(body)
        
        let (data, _) = try await URLSession.shared.data(for: request)
        
        return try JSONDecoder().decode(Response.self, from: data)
    }
}

For initializing the AnthropicService, we need to provide an API key and a list of tools. You can get an API key from Anthropic Dashboard. The tools array will be used to register the tools available to the assistant during the chat.

Here is the Request struct that will be used to send messages to the Anthropic API:

import Foundation

struct Request: Encodable {
    let model: String
    let messages: [Message]
    let max_tokens: Int
    let tools: [Tool]?

    struct Message: Encodable {
        enum Role: String, Encodable {
            case user
            case assistant
        }
        
        let role: Role
        let content: [Content]
    }
}

Here is the Response struct:

import Foundation

struct Response: Decodable {
    let content: [Content]
}

You may notice a Content object in both Request and Response structs. It represents a message in the chat and has the same structure:

enum Content: Codable {
    case text(text: String)
    case toolUse(id: String, name: String, input: [String: String])
    case toolResult(toolUseId: String, content: String)

    private enum CodingKeys: String, CodingKey {
        case type, text, id, name, input, tool_use_id, content
    }

    init(from decoder: Decoder) throws {
        // Custom decoding
    }

    func encode(to encoder: Encoder) throws {
        // Custom encoding
    }
}

To simplify usage, we'll use an enum to represent the content:

  • text — a regular text message
  • toolUse — a tool call by the assistant
  • toolResult — a tool result by the MCP client

Implementing MCP Client

Now, let's implement the MCP client. It will show a chat with the assistant and a text field to send messages to the assistant.

import SwiftUI

struct ContentView: View {
    @State private var viewModel = ContentViewModel()

    var body: some View {
        // Chat messages
    }
}

Here is the ContentViewModel that will handle the chat logic:

import SwiftUI
import Observation

@Observable
final class ContentViewModel {
    var messages: [ChatMessage] = []
    var inputText: String = ""
    var isLoading: Bool = false

    private let mcpServerService: MCPServerProtocol
    private let anthropicService: AnthropicService

    init() {
        self.mcpServerService = BloodPressureService()
        self.anthropicService = AnthropicService(
            apiKey: "YOUR_API_KEY",
            tools: mcpServerService.tools
        )
    }
}

Here we initialize the BloodPressureService and AnthropicService. To update the chat, we'll use messages array. The ChatMessage struct simplifies usage of the Request.Message struct:

struct ChatMessage: Identifiable {

    let message: Request.Message

    var id: UUID = .init()

    var content: String {
        message.content
            .map { content in
                switch content {
                case .text(let text):
                    text
                case .toolUse(_, let name, _):
                    "Called MCP Tool: \(name)"
                case .toolResult(_, let content):
                    "Result: \(content)"
                }
            }
            .joined(separator: "\n")
    }
}

In ContentView, we'll use a ScrollView with a LazyVGrid to show the chat messages and a TextField to send messages to the assistant:

var body: some View {
    ScrollView {
        LazyVGrid(columns: [GridItem(.flexible())], spacing: 8) {
            ForEach(viewModel.messages) { message in
                let isUser = message.message.role == .user
                VStack {
                    Text(message.content)
                        .padding(8)
                        .background(isUser ? Color.blue.opacity(0.2) : nil)
                        .cornerRadius(8)
                }
                .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
            }
        }
        .padding()
    }
    .safeAreaInset(edge: .bottom) {
        HStack {
            TextField("Type a message...", text: $viewModel.inputText, axis: .vertical)
                .textFieldStyle(.roundedBorder)
                .disabled(viewModel.isLoading)
            Button("Send") {
                viewModel.sendMessage()
            }
            .disabled(viewModel.inputText.isEmpty || viewModel.isLoading)
        }
        .padding()
        .background(.white)
    }
}

Now, let's implement the sendMessage method:

func sendMessage() {
    let requestMessage = Request.Message(role: .user, content: [.text(text: inputText)])
    messages.append(.init(message: requestMessage))
    inputText = ""
    isLoading = true

    let requestMessages = messages.map(\.message)

    Task {
        do {
            let response = try await anthropicService.send(messages: requestMessages)
            let message = ChatMessage(message: .init(role: .assistant, content: response.content))
            self.messages.append(message)
        } catch {
            print("Error: \(error)")
        }
        self.isLoading = false
    }
}

Pay attention to the role of the messages. In case of wrong roles you'll get an error from the API.

Now we're ready to send a message to the assistant. Let's run the app and send a message like "Whats my last blood pressure?". In the request body, we'll see our messages and the tools that can be used:

{
  "messages": [
    {
      "content": [
        {
          "text": "Whats my last blood pressure?",
          "type": "text"
        }
      ],
      "role": "user"
    }
  ],
  "max_tokens": 1024,
  "model": "claude-3-opus-20240229",
  "tools": [
    {
      "description": "Get the latest blood pressure (systolic and diastolic) from Apple Health.",
      "input_schema": {
        "type": "object"
      },
      "name": "blood_pressure"
    }
  ]
}

Let's see the response from the assistant. It may look like this:

{
  "id": "msg_01Jf3zsSaK4nGVASwx8Qqk34",
  "type": "message",
  "role": "assistant",
  "model": "claude-3-opus-20240229",
  "content": [
    {
      "type": "text",
      "text": "<thinking>\nThe `blood_pressure` function from the Apple Health API is directly relevant to answering the user's request for their last blood pressure reading. This function does not require any parameters, so we have all the necessary information to make the API call.\n</thinking>"
    },
    {
      "type": "tool_use",
      "id": "toolu_01QmsfxcBXhf6Vrwa4Gb6H22",
      "name": "blood_pressure",
      "input": {}
    }
  ],
  "stop_reason": "tool_use",
  "stop_sequence": null,
  "usage": {
    "input_tokens": 593,
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 0,
    "output_tokens": 92
  }
}

When the assistant responds, the app will display a new message from the assistant:

First response

After getting the response, we need to handle the tool use:

// in sendMessage()
for content in response.content {
    switch content {
    case .toolUse(let id, let name, _):
        try await useTool(withID: id, name: name)
    case .text, .toolResult:
        continue
    }
}

Now, let's implement the useTool method:

private func useTool(withID id: String, name: String) async throws {
    // 1. Find the tool by name
    guard let tool = mcpServerService.tools.first(where: { $0.name == name }) else {
        print("Tool with name \(name) not found.")
        return
    }
    // 2. Create the tool result content
    let content = try await mcpServerService.call(tool)
    let toolResultMessage = Request.Message(
        role: .user,
        content: [.toolResult(toolUseId: id, content: content)]
    )

    self.messages.append(.init(message: toolResultMessage))

    // 3. Send the tool result message
    let requestMessages = self.messages.map(\.message)
    let response = try await anthropicService.send(messages: requestMessages)
    let message = ChatMessage(message: .init(role: .assistant, content: response.content))
    self.messages.append(message)
}

Every tool result must have a tool_use_id that matches the id of the tool use. To save a chat context, we need to update the messages and send them again to the assistant. Here is the final response:

Final response

Conclusion

Model Context Protocol is a powerful way to integrate LLM into your app. In context of iOS development, it also may be useful for accessing local and remote data and enriching the user experience.

The full code of the app is available on GitHub.

References