Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,83 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed
## [0.13.0] — 2026-10-03

### Read this first: `trustSelfSignedCertificates` is gone, and it never did what it said

**Breaking, and a security fix (CWE-295).** All three network transports took
`trustSelfSignedCertificates: Bool`. Passing `true` set NIOSSL's `certificateVerification` to
`.none`. That does not trust a self-signed certificate — it stops checking certificates, so the
connection accepts whatever is presented by whoever answers it. Anyone on the path between a
client and its "development" server could read and rewrite the session, bearer token included.

The parameter is removed rather than deprecated. A deprecated flag that quietly began verifying
would compile, warn, and then fail at run time in exactly the environments that set it; a
deprecated flag that kept working would keep the hole. Each transport instead carries an
`unavailable` initializer under the old label, so the old spelling is a compile error whose
message says what to write.

**Migration.** Where the argument was `false`, delete it. Where it was `true`, supply the
certificate the server presents — or the private authority that issued it — as a trust root:

```swift
// before
let transport = StreamableHTTPTransport(url: url, trustSelfSignedCertificates: true)

// after
let trust = try ServerTrust.onlyRoots([.pemFile("/etc/mcp/dev-server.pem")])
let transport = StreamableHTTPTransport(url: url, serverTrust: trust)
```

Two things that used to be skipped are now checked, and a development server has to pass both:

- **The name.** The certificate must carry the host in the URL as a subject alternative name —
a DNS name for `https://dev.internal`, an IP address for `https://127.0.0.1`. To export what a
server presents: `openssl s_client -connect host:443 -showcerts </dev/null | openssl x509 >
server.pem`. To make one that names its host: `openssl req -x509 -newkey ec -pkeyopt
ec_paramgen_curve:prime256v1 -nodes -keyout key.pem -out server.pem -days 365 -subj
"/CN=dev.internal" -addext "subjectAltName=DNS:dev.internal,IP:127.0.0.1" -addext
"extendedKeyUsage=serverAuth"`.
- **The chain.** It must end at a root you supplied (or, for `additionalRoots`, at one of those
or a system root).

There is no replacement for "verify nothing", by design.

### Added
- **`ServerTrust` — what a transport is prepared to believe about its server.** One type, used
by `StreamableHTTPTransport`, `HTTPSSETransport` and `WebSocketTransport` through a new
`serverTrust:` parameter that defaults to `.system`:
- `ServerTrust.system` — the platform root store. The default; behaviour is unchanged.
- `ServerTrust.additionalRoots(_:)` — the platform root store plus supplied certificates.
- `ServerTrust.onlyRoots(_:)` — the supplied certificates and nothing else. With a self-signed
server's own certificate this is a pin: a different self-signed certificate is refused.

Certificates come from `ServerTrust.CertificateSource`: `.pem(String)`, `.der([UInt8])`,
`.pemFile(String)`, `.derFile(String)`. They are read and parsed when the value is made, so a
bad path or a mangled certificate throws `ServerTrustError` where the configuration is
written. An empty list throws too; nothing falls back to the system roots.
`rootFingerprints` exposes the SHA-256 of each supplied root — the value `openssl x509
-fingerprint -sha256` prints — so an application can show what it trusts.
- **No hash pinning, deliberately.** Pinning by SHA-256 of a key or certificate needs NIOSSL's
verification callback, which neither `AsyncHTTPClient` nor `WebSocketKit` lets a caller
install, and which replaces chain validation rather than adding to it. A pin that only some
transports could enforce would be worse than none.

### Changed
- **A transport with supplied roots runs on NIO's event loops, on every platform.** On Apple
platforms `AsyncHTTPClient` normally runs on Network.framework and *translates* a NIOSSL
`TLSConfiguration` for it; that translation ignores `additionalTrustRoots` altogether. With
supplied roots the client is now built on `MultiThreadedEventLoopGroup.singleton`, so NIOSSL
enforces the configuration and macOS behaves as Linux does. `.system` keeps the platform
stack, so nothing changes for a caller who does not pass `serverTrust`.
- **`swift-nio-ssl` is a declared dependency.** The transports already imported it and received
it transitively. `swift-certificates` and `swift-asn1` are added for the test target only,
which mints its certificates at run time rather than committing a private key. All three were
already in the resolved graph; `Package.resolved` is unchanged.
- **MCPExplorer's "Trust self-signed certificates" toggle is now a "Trusted certificate file"
field.** It takes the path of a PEM file and trusts only what is in it; an unreadable path
stops the connection instead of connecting without it. It applies to WebSocket connections
too, which the toggle never reached.
- One `// SECURITY:` acknowledgement now says what was decided, ahead of the quality gate requiring a
reason of at least eight words on every one.

Expand Down Expand Up @@ -471,3 +546,13 @@ and Linux.
- Phase 2 spec compliance: notifications, ping, pagination, protocol version negotiation
- TransportGuide DocC article
- Initial MCPClient package with HTTP/SSE transport

[Unreleased]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.12.0...HEAD
[0.13.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.12.0...v0.13.0
[0.12.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.4.0...v0.9.0
[0.4.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/jpurnell/SwiftMCPClient/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/jpurnell/SwiftMCPClient/releases/tag/v0.2.0
19 changes: 19 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ var targets: [Target] = [
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
// The transports import NIOSSL to say what a TLS connection trusts. It arrived
// transitively for as long as nothing here named a type of its own; `ServerTrust`
// does, so the dependency is declared rather than borrowed.
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "WebSocketKit", package: "websocket-kit"),
],
// Declared rather than left implicit. SwiftPM does not claim this catalog on a
Expand All @@ -37,6 +41,13 @@ var targets: [Target] = [
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
// What a transport trusts is also wire behaviour: whether a handshake with a
// given certificate completes. The tests stand up a TLS server on loopback and
// mint its certificates at run time, so no private key is ever committed.
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "SwiftASN1", package: "swift-asn1"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
]
Expand Down Expand Up @@ -118,6 +129,14 @@ let package = Package(
// listener uses it directly.
.package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
// Already present transitively via async-http-client and websocket-kit; declared
// because `ServerTrust` builds a `TLSConfiguration` directly.
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
// Test-only: mints the self-signed and private-CA certificates the trust tests
// handshake against. Both were already in the graph through async-http-client's
// dependencies; neither reaches the library target.
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.18.0"),
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.6.0"),
.package(url: "https://github.com/vapor/websocket-kit.git", from: "2.15.0"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.3"),
],
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A Swift 6 client library for the [Model Context Protocol (MCP)](https://modelcon

- Swift 6.0+
- macOS 14+ / iOS 17+ / tvOS 17+ / watchOS 10+
- Linux (Ubuntu 22.04+) — builds; CI verification is still outstanding, so treat Linux as unproven
- Linux (Ubuntu 22.04+) — built and tested in CI

## Installation

Expand Down Expand Up @@ -114,10 +114,35 @@ let transport = HTTPSSETransport(
connectionTimeout: 30,
maxReconnectAttempts: 3,
reconnectBaseDelay: 1.0,
trustSelfSignedCertificates: false // true only for dev/testing
serverTrust: .system // the default; see below for self-signed and private-CA servers
)
```

### 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:

```swift
// 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](CHANGELOG.md) for the migration.

## MCP Operations

### Tools
Expand Down Expand Up @@ -279,6 +304,7 @@ MCPClient/
│ ├── 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
Expand Down
3 changes: 3 additions & 0 deletions Sources/MCPClient/MCPClient.docc/MCPClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ Swift project that needs MCP integration.

- <doc:TransportGuide>
- ``MCPTransport``
- ``StreamableHTTPTransport``
- ``HTTPSSETransport``
- ``WebSocketTransport``
- ``StdioTransport``
- ``ServerTrust``
- ``ServerTrustError``

### JSON-RPC Protocol

Expand Down
25 changes: 25 additions & 0 deletions Sources/MCPClient/MCPClient.docc/MigrationGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,28 @@ func connectOverWebSocket() throws {
_ = webSocketTransport
}
```

## Migrating to 0.13.0: `trustSelfSignedCertificates` is removed

The network transports no longer accept `trustSelfSignedCertificates`. Passing
`true` disabled certificate verification entirely — it did not trust a
self-signed certificate, it trusted whoever answered. The old spelling is now a
compile error.

Where the argument was `false`, delete it. Where it was `true`, supply the
certificate the server presents, or the authority that issued it, through
``ServerTrust``:

```swift
func connectWithSuppliedCertificate() throws {
guard let url = URL(string: "https://dev.internal:8443/mcp") else { return }

// Before: StreamableHTTPTransport(url: url, trustSelfSignedCertificates: true)
let trust = try ServerTrust.onlyRoots([.pemFile("/etc/mcp/dev-server.pem")])
_ = StreamableHTTPTransport(url: url, serverTrust: trust)
}
```

The certificate must name the host in the URL as a subject alternative name;
that check was skipped before and is not skippable now. See
<doc:TransportGuide> for the details.
78 changes: 78 additions & 0 deletions Sources/MCPClient/MCPClient.docc/TransportGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,84 @@ an exponential backoff — three attempts by default, configurable through
- Servers that offer only the legacy HTTP+SSE endpoints
- Cross-platform (macOS, iOS, tvOS, watchOS, Linux)

## Self-Signed and Private-CA Servers

The three network transports — ``StreamableHTTPTransport``, ``HTTPSSETransport``
and ``WebSocketTransport`` — each take a ``ServerTrust``, which says which
certificate roots the server's chain may end at. It defaults to
``ServerTrust/system``: the platform's root store, which is what any publicly
certified server needs.

A development server with a self-signed certificate, or a server behind a
private certificate authority, is trusted by *supplying the certificate*:

```swift
// Shown inside functions this guide never calls: one reads a file that is not
// there when the guide runs, and both would open a connection.
func connectToSelfSignedServer() async throws {
guard let url = URL(string: "https://dev.internal:8443/mcp") else { return }

// The server's own certificate, and nothing else. A server presenting any
// other certificate — including another self-signed one — is refused.
let trust = try ServerTrust.onlyRoots([.pemFile("/etc/mcp/dev-server.pem")])

let transport = StreamableHTTPTransport(url: url, serverTrust: trust)
let client = MCPClientConnection(transport: transport)
_ = try await client.initialize(clientName: "my-app", clientVersion: "1.0.0")
}

func connectBehindPrivateAuthority(rootPEM: String) throws {
guard let url = URL(string: "wss://mcp.corp.example/ws") else { return }

// The system roots as well as the private one, for a client that reaches
// public servers too.
let trust = try ServerTrust.additionalRoots([.pem(rootPEM)])

// What was just trusted, as `openssl x509 -fingerprint -sha256` prints it.
print(trust.rootFingerprints)

_ = WebSocketTransport(url: url, serverTrust: trust)
}
```

### What is still checked

Everything. A supplied root changes who is allowed to have signed the server's
certificate; it does not change whether that is checked, and there is no option
that does. In particular:

- **The hostname.** The certificate must carry the host in the URL as a subject
alternative name — a DNS name, or an IP address if the URL uses one. A trusted
certificate issued for another name is refused.
- **The chain.** It must end at a supplied root, or for
``ServerTrust/additionalRoots(_:)`` at a supplied root or a system one.
- **The dates.** An expired certificate is refused even if it is the one you
supplied.

On Apple platforms ``ServerTrust/additionalRoots(_:)`` is evaluated by the
system verifier, which applies Apple's own requirements for TLS server
certificates — among them an extended key usage of `serverAuth`.
``ServerTrust/onlyRoots(_:)`` is evaluated by BoringSSL on every platform.

### When it fails

A ``ServerTrust`` that cannot be built — a missing file, text holding no
certificate, an empty list — throws ``ServerTrustError`` where it is
constructed, before any transport exists. Nothing falls back to the system
roots.

A server that is refused surfaces as ``MCPError/connectionFailed(reason:)``
from the first request. The HTTP transports retry a failed connection until
`connectionTimeout` elapses, so a refusal is reported after that long rather
than at once.

### Why there is no "trust anything" switch

Versions before 0.13.0 had `trustSelfSignedCertificates: Bool`. It did not
trust a self-signed certificate; it turned verification off, which accepts any
certificate from anyone able to answer the connection. It has been removed —
the old spelling is a compile error that points here.

## Stdio — Local Subprocess

``StdioTransport`` launches an MCP server as a local child process and
Expand Down
Loading
Loading