Skip to content

About

Swift 6 client library for the Model Context Protocol (MCP) — HTTP/SSE, WebSocket, and stdio transports

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

SwiftMCPClient

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.

Features

  • 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 — MCPClientProtocol for 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

Requirements

  • Swift 6.0+
  • macOS 14+ / iOS 17+ / tvOS 17+ / watchOS 10+
  • Linux (Ubuntu 22.04+) — built and tested in CI

Installation

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")
    ]
)

Quick Start

Connect to a remote MCP server via HTTP/SSE

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()

Connect via WebSocket

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")

Connect to a local server via stdio

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")

Transports

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

HTTPSSETransport Options

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
)

Redirects

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.

Plaintext, and what errors say

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.

Self-signed and private-CA servers

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.

MCP Operations

Tools

let tools = try await client.listTools()
let result = try await client.callTool(name: "score_page", arguments: [
    "url": .string("https://example.com"),
    "depth": .integer(2)
])

Resources

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)")
}

Prompts

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)")
}

Autocompletion

let completions = try await client.complete(
    ref: .prompt(name: "analyze"),
    argumentName: "topic",
    argumentValue: "perf"
)
// completions.values: ["performance", "permissions", ...]

Notifications

// 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 { ... }

Server-to-Client (Bi-directional)

// 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")
    )
}

Testing

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
}

Error Handling

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)")
    }
}

MCPExplorer

The package includes MCPExplorer, a SwiftUI macOS app for interactively connecting to MCP servers, browsing tools/resources/prompts, and watching notifications in real time.

Install it as an application

./Scripts/build-app.sh --install     # builds, signs, and puts it in /Applications

This 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.

Or run it from the build directory

swift build --product MCPExplorer
.build/debug/MCPExplorer

Fine 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.

Architecture

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

License

MIT

About

Swift 6 client library for the Model Context Protocol (MCP) — HTTP/SSE, WebSocket, and stdio transports

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages