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.
The app below speaks Anthropic's tool use format, and the "server" is a
local Swift type, not a process on the other end of a transport. For a real
client, the official
Swift SDK supports
iOS 16+ and ships HTTPClientTransport for remote servers. The Messages API
can also call remote MCP servers on its own via the MCP connector.
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")
request.setValue("application/json", forHTTPHeaderField: "content-type")
let body = Request(model: "claude-opus-5-5", messages: messages, max_tokens: 4096, 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.
Anyone can extract a key from the app bundle, and it's billed to you. That's fine for a sample project, but anything you ship needs a backend between the app and the API.
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 thinking(thinking: String, signature: String)
case redactedThinking(data: String)
case toolUse(id: String, name: String, input: [String: String])
case toolResult(toolUseId: String, content: String)
private enum CodingKeys: String, CodingKey {
case type, text, thinking, signature, data, 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 messagethinking— the model's reasoningredactedThinking— the same reasoning, safety-redactedtoolUse— a tool call by the assistanttoolResult— 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
.compactMap { content -> String? in
switch content {
case .text(let text):
text
case .thinking, .redactedThinking:
nil
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": 4096,
"model": "claude-opus-5-5",
"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-opus-5-5",
"content": [
{
"type": "text",
"text": "Let me check your latest reading from Apple Health."
},
{
"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
}
}Current models think by default, so the response also contains thinking
blocks. Their text is usually empty, since the summary is off unless you
ask for it. The signature field must come back unchanged with the next
request. Editing it throws away the model's reasoning, and accounts created
on or after August 31, 2026 get an error. Safety-redacted reasoning arrives
as redacted_thinking and follows the same rules.
When the assistant responds, the app will display a new message from the assistant:

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, .thinking, .redactedThinking, .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:

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.



