A Swift 6 client library for the Model Context Protocol (MCP), enabling Swift applications to connect to MCP servers over HTTP/SSE, WebSocket, or stdio.
- Three transports — HTTP/SSE (production), WebSocket, and stdio (local dev)
- Full MCP spec coverage — tools, resources, prompts, sampling, roots, notifications, progress, autocompletion
- Swift 6 strict concurrency — actor-based client, all types
Sendable - Cross-platform — macOS, Linux (via AsyncHTTPClient + NIO)
- Testable —
MCPClientProtocolfor mocking in tests - Bi-directional — handles server-to-client requests (
roots/list,sampling/createMessage) - Auto-reconnect — HTTP/SSE transport reconnects with exponential backoff
- MCPExplorer — included SwiftUI macOS app for interactive server exploration
- Swift 6.0+
- macOS 14+ / iOS 17+ / tvOS 17+ / watchOS 10+
- Linux (Ubuntu 22.04+) — built and tested in CI
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/jpurnell/SwiftMCPClient.git", branch: "main")
]Then add MCPClient to your target:
.target(
name: "YourApp",
dependencies: [
.product(name: "MCPClient", package: "SwiftMCPClient")
]
)import MCPClient
let transport = HTTPSSETransport(url: URL(string: "https://mcp.example.com/sse")!)
let client = MCPClientConnection(transport: transport)
// Initialize the connection
let serverInfo = try await client.initialize(
clientName: "MyApp",
clientVersion: "1.0.0"
)
// Discover available tools
let tools = try await client.listTools()
for tool in tools {
print("\(tool.name): \(tool.description ?? "")")
}
// Call a tool
let result = try await client.callTool(
name: "analyze_content",
arguments: ["url": .string("https://example.com")]
)
for content in result.content {
if case .text(let text, _) = content {
print(text)
}
}
// Disconnect when done
try await client.disconnect()let transport = WebSocketTransport(url: URL(string: "wss://mcp.example.com/ws")!)
let client = MCPClientConnection(transport: transport)
let _ = try await client.initialize(clientName: "MyApp", clientVersion: "1.0.0")let transport = StdioTransport(
command: "/usr/local/bin/my-mcp-server",
arguments: ["--mode", "stdio"]
)
let client = MCPClientConnection(transport: transport)
let _ = try await client.initialize(clientName: "MyApp", clientVersion: "1.0.0")| Transport | Use Case | Protocol | Auto-Reconnect |
|---|---|---|---|
HTTPSSETransport |
Remote servers (production) | GET for SSE stream, POST for requests | Yes |
WebSocketTransport |
Remote servers (bidirectional) | WebSocket text frames | No |
StdioTransport |
Local development | Subprocess stdin/stdout | No |
let transport = HTTPSSETransport(
url: URL(string: "https://mcp.example.com/sse")!,
headers: ["Authorization": "Bearer token123"],
connectionTimeout: 30,
maxReconnectAttempts: 3,
reconnectBaseDelay: 1.0,
serverTrust: .system // the default; see below for self-signed and private-CA servers
)HTTPSSETransport and StreamableHTTPTransport follow a redirect only to the origin of the URL
they were configured with — the same scheme, host and port — and only if it repeats the request
as it was sent. A redirect anywhere else, including https → http and http → https, is not
followed; nor is a 301, 302 or 303 answering a POST, which would drop the JSON-RPC
message (307 and 308 are the statuses that redirect a POST); nor a loop, nor a sixth
redirect in a row. Nothing further is sent, the request fails with
MCPError.redirectRejected(destination:reason:), and it is not retried. WebSocketTransport
never follows a redirect of its upgrade request. If a server has moved, configure the URL it
moved to.
The OAuth requests MCPOAuthSession makes are held the same way: a metadata fetch follows a
redirect only within its own origin, and client registration and token requests follow none.
http:// and ws:// URLs are used as configured — they are how a local server is reached — and
a warning is logged when headers or an authorization: provider are configured for a plaintext
URL on another machine. WebSocketTransport accepts only ws:// and wss://.
Error and log text names a URL by origin and path, without userinfo, query or fragment, and with
any path segment that could be a credential (a UUID, hex, a JWT, a mixed letters-and-digits key)
replaced by -redacted-. A network failure is reported as
Could not reach <origin>: <kind of failure>, never as the networking library's own description.
Every network transport takes a serverTrust:. It defaults to .system, the platform's root
store. For a server whose certificate is self-signed, or issued by a private authority, supply
that certificate and it becomes a trust root:
// Trust this certificate and nothing else. For a self-signed server this is a pin.
let trust = try ServerTrust.onlyRoots([.pemFile("/etc/mcp/dev-server.pem")])
// Or: the system roots plus a private authority.
let trust = try ServerTrust.additionalRoots([.pem(corporateRootPEM)])
let transport = StreamableHTTPTransport(url: url, serverTrust: trust)Sources are .pem(String), .der([UInt8]), .pemFile(String) and .derFile(String). They are
read when the value is made, so a bad path throws there, not at connect time.
There is no option that turns verification off. The chain is validated and the certificate is
checked against the host in the URL in every case, so a development certificate must name its
host in a subject alternative name. Versions before 0.13.0 had a trustSelfSignedCertificates
flag that disabled verification outright; it is removed — see the
changelog for the migration.
let tools = try await client.listTools()
let result = try await client.callTool(name: "score_page", arguments: [
"url": .string("https://example.com"),
"depth": .integer(2)
])let resources = try await client.listResources()
let contents = try await client.readResource(uri: "file:///config.json")
// Subscribe to resource changes
try await client.subscribeResource(uri: "file:///data.json")
for await uri in client.resourceUpdates {
print("Resource updated: \(uri)")
}let prompts = try await client.listPrompts()
let result = try await client.getPrompt(
name: "analyze",
arguments: ["topic": "performance"]
)
for message in result.messages {
print("\(message.role): \(message.content)")
}let completions = try await client.complete(
ref: .prompt(name: "analyze"),
argumentName: "topic",
argumentValue: "perf"
)
// completions.values: ["performance", "permissions", ...]// All notifications
for await notification in client.notifications {
switch notification {
case .progress(let p): print("Progress: \(p.progress)/\(p.total ?? 0)")
case .logMessage(let m): print("Log [\(m.level)]: \(m.data)")
case .toolsListChanged: print("Tools changed")
default: break
}
}
// Filtered streams
for await progress in client.progressUpdates { ... }
for await log in client.logMessages { ... }// Handle roots/list requests from the server
client.setRootsHandler {
return [MCPRoot(uri: "file:///project", name: "My Project")]
}
// Handle sampling requests from the server
client.setSamplingHandler { request in
// Forward to your LLM, return the response
return MCPSamplingResponse(
model: "claude-3",
role: .assistant,
content: .text("Response text")
)
}Use MCPClientProtocol to mock the client in your tests:
final class MockMCPClient: MCPClientProtocol {
func callTool(name: String, arguments: [String: AnyCodableValue]?) async throws -> MCPToolResult {
return MCPToolResult(content: [.text("mocked result")])
}
// ... implement other protocol methods
}do {
let result = try await client.callTool(name: "analyze", arguments: [:])
} catch let error as MCPError {
switch error {
case .connectionFailed(let reason): print("Connection failed: \(reason)")
case .requestFailed(let code, let message, _): print("RPC error \(code): \(message)")
case .timeout: print("Request timed out")
case .invalidResponse: print("Invalid server response")
case .transportClosed: print("Connection closed unexpectedly")
case .processSpawnFailed(let reason): print("Subprocess failed: \(reason)")
case .endpointRejected(let endpoint, let reason): print("Refused endpoint on \(endpoint): \(reason)")
case .redirectRejected(let destination, let reason): print("Refused redirect to \(destination): \(reason)")
}
}The package includes MCPExplorer, a SwiftUI macOS app for interactively connecting to MCP servers, browsing tools/resources/prompts, and watching notifications in real time.
./Scripts/build-app.sh --install # builds, signs, and puts it in /ApplicationsThis produces MCPExplorer.app — a normal double-clickable Mac app with a Dock icon, a
version, and a Developer ID signature.
The signature is not decoration. MCPOAuthSession seals its credentials with a key from the
Keychain, and the Keychain decides what may read that key by the program's code identity. An
unsigned build has a new identity every time it is compiled, so every rebuild would lose the
saved OAuth tokens. A signed one keeps them.
The first Developer ID signing on a machine raises a system dialog asking to use the key, so
run that command from a terminal you are sitting at and choose Always Allow. It is
unattended after that. ADHOC=1 ./Scripts/build-app.sh skips signing when you only want
something that launches.
swift build --product MCPExplorer
.build/debug/MCPExplorerFine for development. The app detects that it has no bundle and claims a Dock icon for itself, but the Keychain will re-prompt after each rebuild.
MCPClient/
├── MCPClientConnection # Actor — manages connection lifecycle and JSON-RPC
├── MCPClientProtocol # Protocol — testable interface
├── MCPMessageDispatcher # Request/response correlation
├── Transport/
│ ├── MCPTransport # Protocol — pluggable transport layer
│ ├── HTTPSSETransport # HTTP POST + Server-Sent Events
│ ├── WebSocketTransport # WebSocket text frames
│ ├── StdioTransport # Subprocess stdin/stdout
│ ├── ServerTrust # Which certificate roots a TLS server may chain to
│ └── SSEParser # SSE event stream parser
├── AnyCodableValue # Type-safe JSON value wrapper
├── JSONRPCTypes # JSON-RPC 2.0 envelope types
├── MCPTypes # Capabilities, initialization
├── MCPError # Error enum
└── *Types.swift # Resources, prompts, sampling, roots, notifications, completion
MIT