diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f586c9c..6e1fab0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,12 +12,11 @@ permissions:
jobs:
test:
- timeout-minutes: 5
+ timeout-minutes: 10
strategy:
matrix:
os: [macos-latest, ubuntu-latest]
swift-version:
- - 6.0.3
- 6.1.0
runs-on: ${{ matrix.os }}
@@ -26,6 +25,12 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Prepare Linux apt dependencies
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ sudo -n apt-get update
+ sudo -n apt-get install -y --no-install-recommends libcurl4-openssl-dev
+
- name: Setup Swift on Linux
if: matrix.os == 'ubuntu-latest'
uses: vapor/swiftly-action@v0.2
@@ -43,6 +48,67 @@ jobs:
- name: Run tests
run: swift test -v
+ conformance:
+ timeout-minutes: 10
+ runs-on: macos-latest
+ name: MCP Conformance Tests
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Swift
+ uses: swift-actions/setup-swift@v2
+ with:
+ swift-version: 6.1.0
+
+ - name: Build Swift executables
+ run: |
+ swift build --product mcp-everything-client
+ swift build --product mcp-everything-server
+
+ - name: Run client conformance tests
+ uses: modelcontextprotocol/conformance@v0.1.15
+ with:
+ mode: client
+ command: '.build/debug/mcp-everything-client'
+ suite: 'core'
+ expected-failures: './conformance-baseline.yml'
+ node-version: '22'
+
+ - name: Start server for testing
+ run: |
+ .build/debug/mcp-everything-server &
+ echo "SERVER_PID=$!" >> $GITHUB_ENV
+ sleep 3
+
+ - name: Run server conformance tests
+ uses: modelcontextprotocol/conformance@v0.1.15
+ with:
+ mode: server
+ url: 'http://localhost:3001/mcp'
+ suite: 'core'
+ expected-failures: './conformance-baseline.yml'
+ node-version: '22'
+
+ - name: Cleanup server
+ if: always()
+ run: |
+ if [ -n "$SERVER_PID" ]; then
+ kill $SERVER_PID 2>/dev/null || true
+ fi
+
+ documentation:
+ name: Documentation
+ runs-on: macos-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: swift-actions/setup-swift@v2
+ with:
+ swift-version: "6.1.0"
+ - name: Build Documentation
+ run: swift package generate-documentation --target MCP --warnings-as-errors
+
static-linux-sdk-build:
name: Linux Static SDK Build (${{ matrix.swift-version }} - ${{ matrix.os }})
strategy:
diff --git a/.spi.yml b/.spi.yml
new file mode 100644
index 0000000..f846581
--- /dev/null
+++ b/.spi.yml
@@ -0,0 +1,4 @@
+version: 1
+builder:
+ configs:
+ - documentation_targets: [MCP]
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index e4ad6b1..fddc342 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,5 +7,9 @@
},
"[github-actions-workflow]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[swift]": {
+ "editor.insertSpaces": true,
+ "editor.tabSize": 4
}
}
diff --git a/LICENSE b/LICENSE
index 9cf1062..4a93985 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,194 @@
+The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
+
+Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
+
+No rights beyond those granted by the applicable original license are conveyed for such contributions.
+
+---
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to the Licensor for inclusion in the Work by the copyright
+ owner or by an individual or Legal Entity authorized to submit on behalf
+ of the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+---
+
MIT License
+Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
+
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
@@ -17,3 +206,11 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+---
+
+Creative Commons Attribution 4.0 International (CC-BY-4.0)
+
+Documentation in this project (excluding specifications) is licensed under
+CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
+the full license text.
diff --git a/Package.resolved b/Package.resolved
index 5e9023c..d21ebac 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,15 +1,51 @@
{
- "originHash" : "08de61941b7919a65e36c0e34f8c1c41995469b86a39122158b75b4a68c4527d",
+ "originHash" : "67fa9b6a0d1f334fd836cabb7af8c36110a6b88c5b49fa5a42a50125b18a056b",
"pins" : [
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/loopwork-ai/eventsource.git",
+ "location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "e83f076811f32757305b8bf69ac92d05626ffdd7",
"version" : "1.1.0"
}
},
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-docc-plugin",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-plugin",
+ "state" : {
+ "branch" : "main",
+ "revision" : "e977f65879f82b375a044c8837597f690c067da6"
+ }
+ },
+ {
+ "identity" : "swift-docc-symbolkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
+ "state" : {
+ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
+ "version" : "1.0.0"
+ }
+ },
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
@@ -19,6 +55,15 @@
"version" : "1.6.2"
}
},
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "5e72fc102906ebe75a3487595a653e6f43725552",
+ "version" : "2.94.0"
+ }
+ },
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
diff --git a/Package.swift b/Package.swift
index 9ced03c..273715a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,22 +1,8 @@
-// swift-tools-version:6.0
+// swift-tools-version:6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
-// Base dependencies needed on all platforms
-var dependencies: [Package.Dependency] = [
- .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
- .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
- .package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0")
-]
-
-// Target dependencies needed on all platforms
-var targetDependencies: [Target.Dependency] = [
- .product(name: "SystemPackage", package: "swift-system"),
- .product(name: "Logging", package: "swift-log"),
- .product(name: "EventSource", package: "eventsource", condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS]))
-]
-
let package = Package(
name: "mcp-swift-sdk",
platforms: [
@@ -31,17 +17,64 @@ let package = Package(
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "MCP",
- targets: ["MCP"])
+ targets: ["MCP"]),
+ .executable(
+ name: "mcp-everything-server",
+ targets: ["MCPConformanceServer"]),
+ .executable(
+ name: "mcp-everything-client",
+ targets: ["MCPConformanceClient"])
+ ],
+ dependencies: [
+ .package(url: "https://github.com/swiftlang/swift-docc-plugin", branch: "main"),
+ .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
+ .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
+ .package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0"),
+ .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
],
- dependencies: dependencies,
targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
- // Targets can depend on other targets in this package and products from dependencies.
.target(
name: "MCP",
- dependencies: targetDependencies),
+ dependencies: [
+ .product(name: "SystemPackage", package: "swift-system"),
+ .product(name: "Logging", package: "swift-log"),
+ .product(
+ name: "EventSource", package: "eventsource",
+ condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS, .macCatalyst])),
+ ],
+ swiftSettings: [
+ .enableUpcomingFeature("StrictConcurrency")
+ ]
+ ),
.testTarget(
name: "MCPTests",
- dependencies: ["MCP"] + targetDependencies),
+ dependencies: [
+ "MCP",
+ .product(name: "SystemPackage", package: "swift-system"),
+ .product(name: "Logging", package: "swift-log"),
+ .product(
+ name: "EventSource", package: "eventsource",
+ condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS, .macCatalyst])),
+ ]
+ ),
+ .executableTarget(
+ name: "MCPConformanceServer",
+ dependencies: [
+ "MCP",
+ .product(name: "Logging", package: "swift-log"),
+ .product(name: "NIOCore", package: "swift-nio"),
+ .product(name: "NIOPosix", package: "swift-nio"),
+ .product(name: "NIOHTTP1", package: "swift-nio"),
+ ],
+ path: "Sources/MCPConformance/Server"
+ ),
+ .executableTarget(
+ name: "MCPConformanceClient",
+ dependencies: [
+ "MCP",
+ .product(name: "Logging", package: "swift-log"),
+ ],
+ path: "Sources/MCPConformance/Client"
+ )
]
)
diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift
new file mode 100644
index 0000000..5fdf2e4
--- /dev/null
+++ b/Package@swift-6.0.swift
@@ -0,0 +1,49 @@
+// swift-tools-version:6.0
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+// Base dependencies needed on all platforms
+var dependencies: [Package.Dependency] = [
+ .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
+ .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
+ .package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0"),
+]
+
+// Target dependencies needed on all platforms
+var targetDependencies: [Target.Dependency] = [
+ .product(name: "SystemPackage", package: "swift-system"),
+ .product(name: "Logging", package: "swift-log"),
+ .product(
+ name: "EventSource", package: "eventsource",
+ condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS])),
+]
+
+let package = Package(
+ name: "mcp-swift-sdk",
+ platforms: [
+ .macOS("13.0"),
+ .macCatalyst("16.0"),
+ .iOS("16.0"),
+ .watchOS("9.0"),
+ .tvOS("16.0"),
+ .visionOS("1.0"),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, making them visible to other packages.
+ .library(
+ name: "MCP",
+ targets: ["MCP"])
+ ],
+ dependencies: dependencies,
+ targets: [
+ // Targets are the basic building blocks of a package, defining a module or a test suite.
+ // Targets can depend on other targets in this package and products from dependencies.
+ .target(
+ name: "MCP",
+ dependencies: targetDependencies),
+ .testTarget(
+ name: "MCPTests",
+ dependencies: ["MCP"] + targetDependencies),
+ ]
+)
diff --git a/README.md b/README.md
index ac23a4c..1bf1670 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,58 @@ Official Swift SDK for the [Model Context Protocol][mcp] (MCP).
The Model Context Protocol (MCP) defines a standardized way
for applications to communicate with AI and ML models.
This Swift SDK implements both client and server components
-according to the [2025-03-26][mcp-spec-2025-03-26] (latest) version
+according to the [2025-11-25][mcp-spec-2025-11-25] (latest) version
of the MCP specification.
+## Table of contents
+
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Client Usage](#client-usage)
+ - [Basic Client Setup](#basic-client-setup)
+ - [Transport Options for Clients](#transport-options-for-clients)
+ - [Tools](#tools)
+ - [Resources](#resources)
+ - [Prompts](#prompts)
+ - [Completions](#completions)
+ - [Sampling](#sampling)
+ - [Elicitation](#elicitation)
+ - [Roots](#roots)
+ - [Logging](#logging)
+ - [Error Handling](#error-handling)
+ - [Cancellation](#cancellation)
+ - [Progress Tracking](#progress-tracking)
+ - [Advanced Client Features](#advanced-client-features)
+- [Server Usage](#server-usage)
+ - [Basic Server Setup](#basic-server-setup)
+ - [Tools](#tools-1)
+ - [Resources](#resources-1)
+ - [Prompts](#prompts-1)
+ - [Completions](#completions-1)
+ - [Sampling](#sampling-1)
+ - [Elicitations](#elicitations)
+ - [Roots](#roots-1)
+ - [Logging](#logging-1)
+ - [Progress Tracking](#progress-tracking-1)
+ - [Initialize Hook](#initialize-hook)
+ - [HTTP Request Context in Handlers](#http-request-context-in-handlers)
+ - [Graceful Shutdown](#graceful-shutdown)
+- [Transports](#transports)
+- [Authentication](#authentication)
+ - [Client: Client Credentials Flow](#client-client-credentials-flow)
+ - [Client: Authorization Code Flow](#client-authorization-code-flow)
+ - [Client: Custom Token Provider](#client-custom-token-provider)
+ - [Client: Custom Token Storage](#client-custom-token-storage)
+ - [Client: private\_key\_jwt Authentication](#client-private_key_jwt-authentication)
+ - [Client: Endpoint Overrides](#client-endpoint-overrides)
+ - [Server: Serving Protected Resource Metadata](#server-serving-protected-resource-metadata)
+ - [Server: Validating Bearer Tokens](#server-validating-bearer-tokens)
+- [Platform Availability](#platform-availability)
+- [Debugging and Logging](#debugging-and-logging)
+- [Additional Resources](#additional-resources)
+- [Changelog](#changelog)
+- [License](#license)
+
## Requirements
- Swift 6.0+ (Xcode 16+)
@@ -25,7 +74,7 @@ Add the following to your `Package.swift` file:
```swift
dependencies: [
- .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.10.0")
+ .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0")
]
```
@@ -101,7 +150,7 @@ Tools represent functions that can be called by the client:
let (tools, cursor) = try await client.listTools()
print("Available tools: \(tools.map { $0.name }.joined(separator: ", "))")
-// Call a tool with arguments
+// Call a tool with arguments and get the result
let (content, isError) = try await client.callTool(
name: "image-generator",
arguments: [
@@ -125,11 +174,10 @@ for item in content {
}
case .audio(let data, let mimeType):
print("Received audio data of type \(mimeType)")
- case .resource(let uri, let mimeType, let text):
- print("Received resource from \(uri) of type \(mimeType)")
- if let text = text {
- print("Resource text: \(text)")
- }
+ case .resource(let resource, _, _):
+ print("Received embedded resource: \(resource)")
+ case .resourceLink(let uri, let name, _, _, let mimeType, _):
+ print("Resource link: \(name) at \(uri), type: \(mimeType ?? "unknown")")
}
}
```
@@ -148,7 +196,7 @@ let contents = try await client.readResource(uri: "resource://example")
print("Resource content: \(contents)")
// Subscribe to resource updates if supported
-if result.capabilities.resources.subscribe {
+if result.capabilities.resources?.subscribe == true {
try await client.subscribeToResource(uri: "resource://example")
// Register notification handler
@@ -191,6 +239,61 @@ for message in messages {
}
```
+### Completions
+
+Completions allow servers to provide autocompletion suggestions for prompt and resource template arguments as users type:
+
+```swift
+// Request completions for a prompt argument
+let completion = try await client.complete(
+ promptName: "code_review",
+ argumentName: "language",
+ argumentValue: "py"
+)
+
+// Display suggestions to the user
+for value in completion.values {
+ print("Suggestion: \(value)")
+}
+
+if completion.hasMore == true {
+ print("More suggestions available (total: \(completion.total ?? 0))")
+}
+```
+
+You can also provide context with already-resolved arguments:
+
+```swift
+// First, user selects a language
+let languageCompletion = try await client.complete(
+ promptName: "code_review",
+ argumentName: "language",
+ argumentValue: "py"
+)
+// User selects "python"
+
+// Then get framework suggestions based on the selected language
+let frameworkCompletion = try await client.complete(
+ promptName: "code_review",
+ argumentName: "framework",
+ argumentValue: "fla",
+ context: ["language": .string("python")]
+)
+// Returns: ["flask"]
+```
+
+Completions work for resource templates as well:
+
+```swift
+// Get path completions for a resource URI template
+let pathCompletion = try await client.complete(
+ resourceURI: "file:///{path}",
+ argumentName: "path",
+ argumentValue: "/usr/"
+)
+// Returns: ["/usr/bin", "/usr/lib", "/usr/local"]
+```
+
### Sampling
Sampling allows servers to request LLM completions through the client,
@@ -236,43 +339,129 @@ await client.withSamplingHandler { parameters in
}
```
-The sampling flow follows these steps:
+### Elicitation
-```mermaid
-sequenceDiagram
- participant S as MCP Server
- participant C as MCP Client
- participant U as User/Human
- participant L as LLM Service
+Elicitation allows servers to request structured information directly from users through the client.
+This is useful when servers need user input that wasn't provided in the original request,
+such as credentials, configuration choices, or approval for sensitive operations.
- Note over S,L: Server-initiated sampling request
- S->>C: sampling/createMessage request
- Note right of S: Server needs AI assistance
for decision or content
+> [!TIP]
+> Elicitation requests flow from **server to client**,
+> similar to sampling.
+> Clients must register a handler to respond to elicitation requests from servers.
- Note over C,U: Human-in-the-loop review #1
- C->>U: Show sampling request
- U->>U: Review & optionally modify
messages, system prompt
- U->>C: Approve request
+#### Client-Side: Handling Elicitation Requests
- Note over C,L: Client handles LLM interaction
- C->>L: Send messages to LLM
- L->>C: Return completion
+Register an elicitation handler to respond to server requests:
- Note over C,U: Human-in-the-loop review #2
- C->>U: Show LLM completion
- U->>U: Review & optionally modify
or reject completion
- U->>C: Approve completion
+```swift
+// Register an elicitation handler in the client
+await client.withElicitationHandler { parameters in
+ switch parameters {
+ case .form(let form):
+ // Display the request to the user
+ print("Server requests: \(form.message)")
- Note over C,S: Return result to server
- C->>S: sampling/createMessage response
- Note left of C: Contains model used,
stop reason, final content
+ // If a schema was provided, inspect it
+ if let schema = form.requestedSchema {
+ print("Required fields: \(schema.required ?? [])")
+ print("Schema: \(schema.properties)")
+ }
- Note over S: Server continues with
AI-assisted result
+ // Present UI to collect user input
+ let userResponse = presentElicitationUI(form)
+
+ // Return the user's response
+ if userResponse.accepted {
+ return CreateElicitation.Result(
+ action: .accept,
+ content: userResponse.data
+ )
+ } else if userResponse.canceled {
+ return CreateElicitation.Result(action: .cancel)
+ } else {
+ return CreateElicitation.Result(action: .decline)
+ }
+
+ case .url(let url):
+ // Direct the user to an external URL (e.g., for OAuth)
+ openURL(url.url)
+ return CreateElicitation.Result(action: .accept)
+ }
+}
```
-This human-in-the-loop design ensures that users
-maintain control over what the LLM sees and generates,
-even when servers initiate the requests.
+Common use cases for elicitation:
+
+- **Authentication**: Request credentials when needed rather than upfront
+- **Confirmation**: Ask for user approval before sensitive operations
+- **Configuration**: Collect preferences or settings during operation
+- **Missing information**: Request additional details not provided initially
+
+### Roots
+
+Roots define the filesystem boundaries that a client exposes to servers. Servers discover roots by sending a `roots/list` request to the client; clients notify servers when the list changes.
+
+> [!TIP]
+> To use roots, declare the `roots` capability when creating the client.
+
+```swift
+let client = Client(
+ name: "MyApp",
+ version: "1.0.0",
+ capabilities: .init(
+ roots: .init(listChanged: true)
+ )
+)
+
+// Register a handler for roots/list requests from servers
+await client.withRootsHandler {
+ return [
+ Root(uri: "file:///Users/user/projects", name: "Projects"),
+ Root(uri: "file:///Users/user/documents", name: "Documents")
+ ]
+}
+
+// Notify connected servers whenever roots change
+try await client.notifyRootsChanged()
+```
+
+### Logging
+
+Clients can control server logging levels and receive structured log messages:
+
+```swift
+// Set the minimum logging level
+try await client.setLoggingLevel(.warning)
+
+// Register a handler for log messages from the server
+await client.onNotification(LogMessageNotification.self) { message in
+ let level = message.params.level // LogLevel (debug, info, warning, etc.)
+ let logger = message.params.logger // Optional logger name
+ let data = message.params.data // Arbitrary JSON data
+
+ // Display log message based on level
+ switch level {
+ case .error, .critical, .alert, .emergency:
+ print("❌ [\(logger ?? "server")] \(data)")
+ case .warning:
+ print("⚠️ [\(logger ?? "server")] \(data)")
+ default:
+ print("ℹ️ [\(logger ?? "server")] \(data)")
+ }
+}
+```
+
+Log levels follow the standard syslog severity levels (RFC 5424):
+
+- **debug**: Detailed debugging information
+- **info**: General informational messages
+- **notice**: Normal but significant events
+- **warning**: Warning conditions
+- **error**: Error conditions
+- **critical**: Critical conditions
+- **alert**: Action must be taken immediately
+- **emergency**: System is unusable
### Error Handling
@@ -289,6 +478,84 @@ do {
}
```
+### Cancellation
+
+Either side can cancel an in-progress request and handle incoming cancellations gracefully:
+
+#### Option 1: Convenience Methods with RequestContext Overload
+
+For common operations like tool calls, use the overloaded method that returns `RequestContext`:
+
+```swift
+// Call a tool and get a context for cancellation
+let context = try client.callTool(
+ name: "long-running-analysis",
+ arguments: ["data": largeDataset]
+)
+
+// You can cancel the request at any time
+try await client.cancelRequest(context.requestID, reason: "User cancelled")
+
+// Await the result (will throw CancellationError if cancelled)
+do {
+ let result = try await context.value
+ print("Result: \(result.content)")
+} catch is CancellationError {
+ print("Request was cancelled")
+}
+```
+
+#### Option 2: Direct send() for Maximum Flexibility
+
+For full control or custom requests, use `send()` directly:
+
+```swift
+// Create any request type
+let request = CallTool.request(.init(
+ name: "long-running-analysis",
+ arguments: ["data": largeDataset]
+))
+
+// Send and get a context for cancellation tracking
+let context: RequestContext = try client.send(request)
+
+// Cancel when needed
+try await client.cancelRequest(context.requestID, reason: "Timeout")
+
+// Get the result
+let result = try await context.value
+let content = result.content
+let isError = result.isError
+```
+
+### Progress tracking
+
+Clients can attach a progress token to a request and receive incremental progress updates for long-running operations:
+
+```swift
+// Call a tool with progress tracking
+let progressToken = ProgressToken.unique()
+
+// Register a notification handler to receive progress updates
+await client.onNotification(ProgressNotification.self) { message in
+ let params = message.params
+ // Filter by your progress token
+ if params.progressToken == progressToken {
+ print("Progress: \(params.progress)/\(params.total ?? 0)")
+ if let message = params.message {
+ print("Status: \(message)")
+ }
+ }
+}
+
+// Make the request with the progress token
+let (content, isError) = try await client.callTool(
+ name: "long-running-tool",
+ arguments: ["input": "value"],
+ meta: Metadata(progressToken: progressToken)
+)
+```
+
### Advanced Client Features
#### Strict vs Non-Strict Configuration
@@ -409,6 +676,8 @@ let server = Server(
name: "MyModelServer",
version: "1.0.0",
capabilities: .init(
+ completions: .init(),
+ logging: .init(),
prompts: .init(listChanged: true),
resources: .init(subscribe: true, listChanged: true),
tools: .init(listChanged: true)
@@ -577,9 +846,9 @@ await server.withMethodHandler(GetPrompt.self) { params in
let description = "Job interview for \(position) position at \(company)"
let messages: [Prompt.Message] = [
- .user("You are an interviewer for the \(position) position at \(company)."),
- .user("Hello, I'm \(interviewee) and I'm here for the \(position) interview."),
- .assistant("Hi \(interviewee), welcome to \(company)! I'd like to start by asking about your background and experience.")
+ .user(.text(text: "You are an interviewer for the \(position) position at \(company).")),
+ .user(.text(text: "Hello, I'm \(interviewee) and I'm here for the \(position) interview.")),
+ .assistant(.text(text: "Hi \(interviewee), welcome to \(company)! I'd like to start by asking about your background and experience."))
]
return .init(description: description, messages: messages)
@@ -593,13 +862,93 @@ await server.withMethodHandler(GetPrompt.self) { params in
}
```
+### Completions
+
+Servers can provide autocompletion suggestions for prompt and resource template arguments:
+
+```swift
+// Enable completions capability
+let server = Server(
+ name: "MyServer",
+ version: "1.0.0",
+ capabilities: .init(
+ completions: .init(),
+ prompts: .init(listChanged: true)
+ )
+)
+
+// Register a completion handler
+await server.withMethodHandler(Complete.self) { params in
+ // Get the argument being completed
+ let argumentName = params.argument.name
+ let currentValue = params.argument.value
+
+ // Check which prompt or resource is being completed
+ switch params.ref {
+ case .prompt(let promptRef):
+ // Provide completions for a prompt argument
+ if promptRef.name == "code_review" && argumentName == "language" {
+ // Simple prefix matching
+ let allLanguages = ["python", "perl", "php", "javascript", "java", "swift"]
+ let matches = allLanguages.filter { $0.hasPrefix(currentValue.lowercased()) }
+
+ return .init(
+ completion: .init(
+ values: Array(matches.prefix(100)), // Max 100 items
+ total: matches.count,
+ hasMore: matches.count > 100
+ )
+ )
+ }
+
+ case .resource(let resourceRef):
+ // Provide completions for a resource template argument
+ if resourceRef.uri == "file:///{path}" && argumentName == "path" {
+ // Return directory suggestions
+ let suggestions = try getDirectoryCompletions(for: currentValue)
+ return .init(
+ completion: .init(
+ values: suggestions,
+ total: suggestions.count,
+ hasMore: false
+ )
+ )
+ }
+ }
+
+ // No completions available
+ return .init(completion: .init(values: [], total: 0, hasMore: false))
+}
+```
+
+You can also use context from already-resolved arguments:
+
+```swift
+await server.withMethodHandler(Complete.self) { params in
+ // Access context from previous argument completions
+ if let context = params.context,
+ let language = context.arguments["language"]?.stringValue {
+
+ // Provide framework suggestions based on selected language
+ if language == "python" {
+ let frameworks = ["flask", "django", "fastapi", "tornado"]
+ let matches = frameworks.filter {
+ $0.hasPrefix(params.argument.value.lowercased())
+ }
+ return .init(
+ completion: .init(values: matches, total: matches.count, hasMore: false)
+ )
+ }
+ }
+
+ return .init(completion: .init(values: [], total: 0, hasMore: false))
+}
+```
+
### Sampling
Servers can request LLM completions from clients through sampling. This enables agentic behaviors where servers can ask for AI assistance while maintaining human oversight.
-> [!NOTE]
-> The current implementation provides the correct API design for sampling, but requires bidirectional communication support in the transport layer. This feature will be fully functional when bidirectional transport support is added.
-
```swift
// Enable sampling capability in server
let server = Server(
@@ -611,7 +960,7 @@ let server = Server(
)
)
-// Request sampling from the client (conceptual - requires bidirectional transport)
+// Request sampling from the connected client
do {
let result = try await server.requestSampling(
messages: [
@@ -631,11 +980,187 @@ do {
```
Sampling enables powerful agentic workflows:
+
- **Decision-making**: Ask the LLM to choose between options
- **Content generation**: Request drafts for user approval
- **Data analysis**: Get AI insights on complex data
- **Multi-step reasoning**: Chain AI completions with tool calls
+### Elicitations
+
+Servers can request information from users through elicitation:
+
+```swift
+// Ask the user to provide some additional information
+let schema = Elicitation.RequestSchema(
+ title: "Additional Information Required",
+ description: "Please provide the following details to continue",
+ properties: [
+ "name": .object([
+ "type": .string("string"),
+ "description": .string("Your full name")
+ ]),
+ "confirmed": .object([
+ "type": .string("boolean"),
+ "description": .string("Do you confirm the provided information?")
+ ])
+ ],
+ required: ["name", "confirmed"]
+)
+
+let result = try await server.requestElicitation(
+ message: "Some details are needed before proceeding",
+ requestedSchema: schema
+)
+
+switch result.action {
+case .accept:
+ if let content = result.content {
+ let name = content["name"]?.stringValue
+ let confirmed = content["confirmed"]?.boolValue
+ // Use the collected data...
+ }
+case .decline:
+ throw MCPError.invalidRequest("User declined to provide information")
+case .cancel:
+ throw MCPError.invalidRequest("Operation canceled by user")
+}
+```
+
+For URL-based elicitation (e.g., OAuth flows), use the URL overload:
+
+```swift
+let result = try await server.requestElicitation(
+ message: "Please sign in to continue",
+ url: "https://example.com/oauth/authorize?client_id=...",
+ elicitationId: UUID().uuidString
+)
+```
+
+### Roots
+
+Servers can request the list of filesystem roots that the client has exposed:
+
+```swift
+// Request roots from the connected client
+// (requires the client to declare the roots capability)
+let roots = try await server.listRoots()
+for root in roots {
+ print("Root: \(root.name ?? root.uri) at \(root.uri)")
+}
+
+// React to root list changes
+await server.onNotification(RootsListChangedNotification.self) { _ in
+ let updatedRoots = try await server.listRoots()
+ print("Roots updated: \(updatedRoots.map { $0.uri })")
+}
+```
+
+### Logging
+
+Servers can send structured log messages to clients:
+
+```swift
+// Enable logging capability
+let server = Server(
+ name: "MyServer",
+ version: "1.0.0",
+ capabilities: .init(
+ logging: .init(),
+ tools: .init(listChanged: true)
+ )
+)
+
+// Send log messages at different severity levels
+try await server.log(
+ level: .info,
+ logger: "database",
+ data: Value.object([
+ "message": .string("Database connected successfully"),
+ "host": .string("localhost"),
+ "port": .int(5432)
+ ])
+)
+
+try await server.log(
+ level: .error,
+ logger: "api",
+ data: Value.object([
+ "message": .string("Request failed"),
+ "statusCode": .int(500),
+ "error": .string("Internal server error")
+ ])
+)
+
+// You can also use codable types directly
+struct ErrorLog: Codable {
+ let message: String
+ let code: Int
+ let timestamp: String
+}
+
+let errorLog = ErrorLog(
+ message: "Operation failed",
+ code: 500,
+ timestamp: ISO8601DateFormatter().string(from: Date())
+)
+
+try await server.log(level: .error, logger: "operations", data: errorLog)
+```
+
+Clients can control which log levels they receive:
+
+```swift
+// Register a handler for client's logging level preferences
+await server.withMethodHandler(SetLoggingLevel.self) { params in
+ let minimumLevel = params.level
+
+ // Store the client's preference and filter log messages accordingly
+ // (Implementation depends on your server architecture)
+ storeLogLevel(minimumLevel)
+
+ return Empty()
+}
+```
+
+### Progress Tracking
+
+Servers can send incremental progress notifications during long-running tool calls by reading the `progressToken` from the request metadata and sending `ProgressNotification` messages:
+
+```swift
+await server.withMethodHandler(CallTool.self) { params in
+ // Read the progress token from request metadata
+ guard let token = params._meta?.progressToken else {
+ // No progress token provided — run without reporting progress
+ return .init(content: [.text("Done")], isError: false)
+ }
+
+ // Report initial progress
+ let started = ProgressNotification.message(
+ .init(progressToken: token, progress: 0, total: 100)
+ )
+ try await server.notify(started)
+
+ // ... do work ...
+
+ // Report intermediate progress
+ let halfway = ProgressNotification.message(
+ .init(progressToken: token, progress: 50, total: 100, message: "Halfway there")
+ )
+ try await server.notify(halfway)
+
+ // ... do more work ...
+
+ // Report completion
+ let done = ProgressNotification.message(
+ .init(progressToken: token, progress: 100, total: 100, message: "Complete")
+ )
+ try await server.notify(done)
+
+ return .init(content: [.text("Done")], isError: false)
+}
+```
+
#### Initialize Hook
Control client connections with an initialize hook:
@@ -660,6 +1185,35 @@ try await server.start(transport: transport) { clientInfo, clientCapabilities in
}
```
+### HTTP Request Context in Handlers
+
+When a server is connected over `StatefulHTTPServerTransport` or `StatelessHTTPServerTransport`,
+method handlers can observe the originating HTTP request (headers, body, path, method) via
+`Server.currentHandlerContext` — a task-local set automatically before each handler runs:
+
+```swift
+await server.withMethodHandler(CallTool.self) { params in
+ let httpRequest = Server.currentHandlerContext?.httpContext
+ let authHeader = httpRequest?.headers["Authorization"]
+ // …use the header to scope the response, audit, etc.
+ return .init(content: [.text("ok")], isError: false)
+}
+```
+
+`httpContext` is `nil` for transports that don't carry HTTP context (e.g. `StdioTransport`,
+`InMemoryTransport`) and for handlers reached off the dispatch path.
+
+Task-locals are not inherited by `Task.detached`. If you spawn a detached task from a handler,
+capture the context up front:
+
+```swift
+let ctx = Server.currentHandlerContext
+Task.detached { await doWork(with: ctx?.httpContext) }
+```
+
+Custom HTTP transports can opt in by conforming to `HTTPContextProviding` and returning the
+`HTTPRequest` for a given JSON-RPC id while it is in flight.
+
### Graceful Shutdown
We recommend using
@@ -757,28 +1311,32 @@ try await serviceGroup.run()
This approach has several benefits:
- **Signal handling**:
- Automatically traps SIGINT, SIGTERM and triggers graceful shutdown
+Automatically traps SIGINT, SIGTERM and triggers graceful shutdown
- **Graceful shutdown**:
- Properly shuts down your MCP server and other services
+Properly shuts down your MCP server and other services
- **Timeout-based shutdown**:
- Configurable shutdown timeouts to prevent hanging processes
+Configurable shutdown timeouts to prevent hanging processes
- **Advanced service management**:
- [`ServiceLifecycle`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle)
- also supports service dependencies, conditional services,
- and other useful features.
+`[ServiceLifecycle](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle)`
+also supports service dependencies, conditional services,
+and other useful features.
## Transports
MCP's transport layer handles communication between clients and servers.
The Swift SDK provides multiple built-in transports:
+
| Transport | Description | Platforms | Best for |
-|-----------|-------------|-----------|----------|
-| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools |
-| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications |
+| --------- | ----------- | --------- | -------- |
+| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools |
+| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications |
+| [`StatelessHTTPServerTransport`](/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift) | HTTP server transport with simple request-response semantics; no session management or SSE streaming | All platforms with Foundation | Simple HTTP servers, serverless/edge functions |
+| [`StatefulHTTPServerTransport`](/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift) | HTTP server transport with full session management and SSE streaming for server-initiated messages | All platforms with Foundation | Full-featured HTTP servers, streaming notifications |
| [`InMemoryTransport`](/Sources/MCP/Base/Transports/InMemoryTransport.swift) | Custom in-memory transport for direct communication within the same process | All platforms | Testing, debugging, same-process client-server communication |
| [`NetworkTransport`](/Sources/MCP/Base/Transports/NetworkTransport.swift) | Custom transport using Apple's Network framework for TCP/UDP connections | Apple platforms only | Low-level networking, custom protocols |
+
### Custom Transport Implementation
You can implement a custom transport by conforming to the `Transport` protocol:
@@ -822,24 +1380,212 @@ public actor MyCustomTransport: Transport {
}
```
+## Authentication
+
+`HTTPClientTransport` supports OAuth 2.1 Bearer token authorization per the
+[MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
+When a server returns `401 Unauthorized` or `403 Forbidden`, the transport automatically:
+
+1. Discovers Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource`
+2. Discovers Authorization Server Metadata (RFC 8414 / OIDC Discovery 1.0)
+3. Registers the client dynamically (RFC 7591) if needed
+4. Acquires a Bearer token using the configured grant flow (PKCE enforced)
+5. Retries the original request with the token attached
+
+Authorization is opt-in and disabled by default.
+Pass an `OAuthAuthorizer` to `HTTPClientTransport(authorizer:)` to enable it.
+
+### Client: Client Credentials Flow
+
+Machine-to-machine authentication using a pre-shared client secret:
+
+```swift
+let config = OAuthConfiguration(
+ grantType: .clientCredentials,
+ authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
+)
+let authorizer = OAuthAuthorizer(configuration: config)
+let transport = HTTPClientTransport(
+ endpoint: URL(string: "https://api.example.com/mcp")!,
+ authorizer: authorizer
+)
+let client = Client(name: "MyClient", version: "1.0.0")
+try await client.connect(transport: transport)
+```
+
+### Client: Authorization Code Flow
+
+Interactive, browser-based authentication with PKCE.
+Implement `OAuthAuthorizationDelegate` to open the authorization URL and capture the redirect:
+
+```swift
+struct MyAuthDelegate: OAuthAuthorizationDelegate {
+ func presentAuthorizationURL(_ url: URL) async throws -> URL {
+ // Open the URL in a browser/webview and wait for the callback redirect URI.
+ // The returned URL must include the authorization code and state parameters.
+ return try await openBrowserAndWaitForCallback(url)
+ }
+}
+
+let config = OAuthConfiguration(
+ grantType: .authorizationCode,
+ authentication: .none(clientID: "my-app"),
+ authorizationDelegate: MyAuthDelegate()
+)
+let authorizer = OAuthAuthorizer(configuration: config)
+let transport = HTTPClientTransport(
+ endpoint: URL(string: "https://api.example.com/mcp")!,
+ authorizer: authorizer
+)
+```
+
+### Client: Custom Token Provider
+
+Supply an externally acquired token (e.g., from a system credential store) via `accessTokenProvider`.
+The SDK calls this closure after discovery completes. Return `nil` to fall back to the configured grant flow:
+
+```swift
+let config = OAuthConfiguration(
+ grantType: .clientCredentials,
+ authentication: .none(clientID: "my-app"),
+ accessTokenProvider: { context, session in
+ // context contains the discovered resource URI, token endpoint, scopes, etc.
+ return try await KeychainTokenStore.shared.loadToken(for: context.resource)
+ }
+)
+```
+
+### Client: Custom Token Storage
+
+By default, tokens are stored in memory and lost when the process exits.
+To persist tokens across sessions, implement `TokenStorage` and pass it to `OAuthAuthorizer`:
+
+```swift
+final class KeychainTokenStorage: TokenStorage {
+ func save(_ token: OAuthAccessToken) {
+ // Encode and store token.value in the system Keychain
+ }
+
+ func load() -> OAuthAccessToken? {
+ // Load and decode token from the Keychain
+ return nil
+ }
+
+ func clear() {
+ // Delete from the Keychain
+ }
+}
+
+let authorizer = OAuthAuthorizer(
+ configuration: config,
+ tokenStorage: KeychainTokenStorage()
+)
+```
+
+### Client: `private_key_jwt` Authentication
+
+Authenticate to the token endpoint using an asymmetric key (RFC 7523).
+The SDK provides a built-in ES256 helper for P-256 keys:
+
+```swift
+let config = OAuthConfiguration(
+ grantType: .clientCredentials,
+ authentication: .privateKeyJWT(
+ clientID: "my-app",
+ assertionFactory: { tokenEndpoint, clientID in
+ try OAuthConfiguration.makePrivateKeyJWTAssertion(
+ clientID: clientID,
+ tokenEndpoint: tokenEndpoint,
+ privateKeyPEM: myEC256PrivateKeyPEM // PEM-encoded P-256 private key
+ )
+ }
+ )
+)
+```
+
+### Client: Endpoint Overrides
+
+Skip automatic discovery by providing explicit endpoint URLs.
+Useful when the server does not publish well-known metadata documents:
+
+```swift
+let config = OAuthConfiguration(
+ grantType: .clientCredentials,
+ authentication: .clientSecretBasic(clientID: "app", clientSecret: "secret"),
+ endpointOverrides: OAuthConfiguration.EndpointOverrides(
+ tokenEndpoint: URL(string: "https://auth.example.com/oauth/token")!
+ )
+)
+```
+
+### Server: Serving Protected Resource Metadata
+
+Per the MCP authorization specification, servers **MUST** serve Protected Resource Metadata
+at `/.well-known/oauth-protected-resource` so clients can discover authorization server endpoints.
+
+Use `ProtectedResourceMetadataValidator` as the first validator in your pipeline so that
+unauthenticated discovery requests are handled before the bearer token check:
+
+```swift
+let metadata = OAuthProtectedResourceServerMetadata(
+ resource: "https://api.example.com",
+ authorizationServers: [URL(string: "https://auth.example.com")!],
+ scopesSupported: ["read", "write"]
+)
+let metadataValidator = ProtectedResourceMetadataValidator(metadata: metadata)
+```
+
+### Server: Validating Bearer Tokens
+
+Use `BearerTokenValidator` to authenticate incoming requests.
+Your `tokenValidator` closure **MUST** verify the token's `aud` claim to prevent
+token substitution attacks where a token intended for another resource is replayed against your server:
+
+```swift
+let resourceIdentifier = URL(string: "https://api.example.com")!
+
+let bearerValidator = BearerTokenValidator(
+ resourceMetadataURL: URL(string: "https://api.example.com/.well-known/oauth-protected-resource")!,
+ resourceIdentifier: resourceIdentifier,
+ tokenValidator: { token, request, context in
+ guard let claims = try? verifyAndDecodeJWT(token) else {
+ return .invalidToken(errorDescription: "Token verification failed")
+ }
+ // Pass audience and expiry to BearerTokenInfo; the SDK validates the
+ // audience claim against resourceIdentifier automatically.
+ return .valid(BearerTokenInfo(
+ audience: claims.audience,
+ expiresAt: claims.expiresAt
+ ))
+ }
+)
+
+let pipeline = StandardValidationPipeline(validators: [
+ metadataValidator, // serves /.well-known/oauth-protected-resource unauthenticated
+ bearerValidator, // validates Bearer tokens on all other requests
+ AcceptHeaderValidator(mode: .sseRequired),
+ ContentTypeValidator(),
+ SessionValidator(),
+])
+```
+
## Platform Availability
The Swift SDK has the following platform requirements:
-| Platform | Minimum Version |
-|----------|----------------|
-| macOS | 13.0+ |
-| iOS / Mac Catalyst | 16.0+ |
-| watchOS | 9.0+ |
-| tvOS | 16.0+ |
-| visionOS | 1.0+ |
-| Linux | Distributions with `glibc` or `musl`, including Ubuntu, Debian, Fedora, and Alpine Linux |
+
+| Platform | Minimum Version |
+| ------------------ | ---------------------------------------------------------------------------------------- |
+| macOS | 13.0+ |
+| iOS / Mac Catalyst | 16.0+ |
+| watchOS | 9.0+ |
+| tvOS | 16.0+ |
+| visionOS | 1.0+ |
+| Linux | Distributions with `glibc` or `musl`, including Ubuntu, Debian, Fedora, and Alpine Linux |
+
While the core library works on any platform supporting Swift 6
-(including Linux and Windows),
-running a client or server requires a compatible transport.
-
-We're working to add [Windows support](https://github.com/modelcontextprotocol/swift-sdk/pull/64).
+(including Linux), running a client or server requires a compatible transport.
## Debugging and Logging
@@ -868,7 +1614,7 @@ let transport = StdioTransport(logger: logger)
## Additional Resources
-- [MCP Specification](https://modelcontextprotocol.io/specification/2025-03-26/)
+- [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25)
- [Protocol Documentation](https://modelcontextprotocol.io)
- [GitHub Repository](https://github.com/modelcontextprotocol/swift-sdk)
@@ -883,7 +1629,7 @@ see the [GitHub Releases page](https://github.com/modelcontextprotocol/swift-sdk
## License
-This project is licensed under the MIT License.
+This project is licensed under Apache 2.0 for new contributions, with existing code under MIT. See the [LICENSE](LICENSE) file for details.
[mcp]: https://modelcontextprotocol.io
-[mcp-spec-2025-03-26]: https://modelcontextprotocol.io/specification/2025-03-26
\ No newline at end of file
+[mcp-spec-2025-11-25]: https://modelcontextprotocol.io/specification/2025-11-25
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..5029242
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,21 @@
+# Security Policy
+
+Thank you for helping keep the Model Context Protocol and its ecosystem secure.
+
+## Reporting Security Issues
+
+If you discover a security vulnerability in this repository, please report it through
+the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
+for this repository.
+
+Please **do not** report security vulnerabilities through public GitHub issues, discussions,
+or pull requests.
+
+## What to Include
+
+To help us triage and respond quickly, please include:
+
+- A description of the vulnerability
+- Steps to reproduce the issue
+- The potential impact
+- Any suggested fixes (optional)
diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
new file mode 100644
index 0000000..0c278f1
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
@@ -0,0 +1,253 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+// MARK: - OAuthAuthorizationCodeFlowing Protocol
+
+/// Internal protocol for driving the OAuth 2.1 authorization code flow.
+protocol OAuthAuthorizationCodeFlowing: Sendable {
+ func buildURL(
+ authorizationEndpoint: URL,
+ resource: URL,
+ redirectURI: URL,
+ clientID: String,
+ codeChallenge: String,
+ scopes: Set?,
+ state: String,
+ scopeSerializer: any OAuthScopeSelecting
+ ) throws -> URL
+
+ func perform(
+ authorizationURL: URL,
+ redirectURI: URL,
+ state: String,
+ delegate: (any OAuthAuthorizationDelegate)?,
+ session: URLSession
+ ) async throws -> String
+}
+
+// MARK: - No-Redirect Session Delegate
+
+final class OAuthNoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate,
+ @unchecked Sendable
+{
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void
+ ) {
+ completionHandler(nil)
+ }
+}
+
+// MARK: - Authorization Code Flow
+
+/// Handles the browser-facing steps of the OAuth 2.1 authorization_code flow.
+///
+/// Builds the authorization request URL, drives the redirect (via delegate or direct HTTP),
+/// and extracts the authorization code from the callback redirect URL.
+public struct OAuthAuthorizationCodeFlow: Sendable {
+
+ public init() {}
+
+ /// Builds the authorization request URL.
+ ///
+ /// - Parameters:
+ /// - authorizationEndpoint: The AS authorization endpoint.
+ /// - resource: The RFC 8707 resource indicator.
+ /// - redirectURI: The redirect URI registered for this client.
+ /// - clientID: The OAuth client identifier.
+ /// - codeChallenge: The PKCE S256 code challenge.
+ /// - scopes: Optional scope set to request.
+ /// - state: The CSRF state nonce.
+ /// - scopeSerializer: Serializes the scope set to a space-separated string.
+ /// - Returns: The full authorization request URL with all query parameters.
+ public func buildURL(
+ authorizationEndpoint: URL,
+ resource: URL,
+ redirectURI: URL,
+ clientID: String,
+ codeChallenge: String,
+ scopes: Set?,
+ state: String,
+ scopeSerializer: any OAuthScopeSelecting
+ ) throws -> URL {
+ guard var components = URLComponents(
+ url: authorizationEndpoint,
+ resolvingAgainstBaseURL: false
+ ) else {
+ throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
+ }
+
+ var queryItems: [URLQueryItem] = [
+ .init(name: OAuthParameterName.responseType, value: OAuthParameterName.code),
+ .init(name: OAuthParameterName.clientID, value: clientID),
+ .init(name: OAuthParameterName.redirectURI, value: redirectURI.absoluteString),
+ .init(name: OAuthParameterName.state, value: state),
+ .init(name: OAuthParameterName.resource, value: resource.absoluteString),
+ .init(name: OAuthParameterName.codeChallenge, value: codeChallenge),
+ .init(
+ name: OAuthParameterName.codeChallengeMethod, value: OAuthCodeChallengeMethod.s256),
+ ]
+
+ if let scope = scopes.flatMap(scopeSerializer.serialize) {
+ queryItems.append(.init(name: OAuthParameterName.scope, value: scope))
+ }
+
+ components.queryItems = queryItems
+ guard let url = components.url else {
+ throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
+ }
+ return url
+ }
+
+ /// Drives the interactive authorization redirect and returns the authorization code.
+ ///
+ /// When a delegate is provided, presents the authorization URL and awaits the redirect.
+ /// Without a delegate, sends a GET request and captures the redirect `Location` header.
+ ///
+ /// - Parameters:
+ /// - authorizationURL: The full authorization request URL.
+ /// - redirectURI: The expected redirect URI base for validation.
+ /// - state: The CSRF state nonce to verify in the redirect.
+ /// - delegate: Optional user-facing delegate for browser-based flows.
+ /// - session: The `URLSession` used for the no-redirect path.
+ /// - Returns: The extracted authorization code.
+ public func perform(
+ authorizationURL: URL,
+ redirectURI: URL,
+ state: String,
+ delegate: (any OAuthAuthorizationDelegate)?,
+ session: URLSession
+ ) async throws -> String {
+ if let delegate {
+ let redirectURL = try await delegate.presentAuthorizationURL(authorizationURL)
+ return try extractCode(
+ from: redirectURL,
+ expectedRedirectURI: redirectURI,
+ expectedState: state
+ )
+ }
+
+ var request = URLRequest(url: authorizationURL)
+ request.httpMethod = "GET"
+ request.setValue(
+ "text/html, \(ContentType.json)", forHTTPHeaderField: HTTPHeaderName.accept)
+
+ let noRedirectDelegate = OAuthNoRedirectSessionDelegate()
+ let noRedirectSession = URLSession(
+ configuration: session.configuration,
+ delegate: noRedirectDelegate,
+ delegateQueue: nil
+ )
+ defer { noRedirectSession.invalidateAndCancel() }
+
+ let (_, response) = try await noRedirectSession.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
+ }
+
+ guard (300..<400).contains(httpResponse.statusCode) else {
+ if httpResponse.statusCode >= 400 {
+ throw OAuthAuthorizationError.authorizationRequestFailed(statusCode: httpResponse.statusCode)
+ }
+ throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
+ }
+
+ guard let location = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.location),
+ !location.isEmpty,
+ let redirectURL = URL(string: location)
+ else {
+ throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
+ }
+
+ return try extractCode(
+ from: redirectURL,
+ expectedRedirectURI: redirectURI,
+ expectedState: state
+ )
+ }
+
+ /// Extracts and validates the authorization code from the redirect URL.
+ ///
+ /// - Parameters:
+ /// - redirectURL: The redirect URL received from the authorization server.
+ /// - expectedRedirectURI: The redirect URI used in the authorization request.
+ /// - expectedState: The CSRF state nonce sent in the authorization request.
+ /// - Returns: The authorization code.
+ public func extractCode(
+ from redirectURL: URL,
+ expectedRedirectURI: URL,
+ expectedState: String
+ ) throws -> String {
+ guard
+ let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
+ let expectedComponents = URLComponents(
+ url: expectedRedirectURI, resolvingAgainstBaseURL: false)
+ else {
+ throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
+ expected: expectedRedirectURI.absoluteString,
+ actual: redirectURL.absoluteString
+ )
+ }
+
+ if normalizedRedirectBase(redirectComponents) != normalizedRedirectBase(expectedComponents) {
+ throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
+ expected: expectedRedirectURI.absoluteString,
+ actual: redirectURL.absoluteString
+ )
+ }
+
+ guard
+ let state = redirectComponents.queryItems?.first(where: {
+ $0.name == OAuthParameterName.state
+ })?.value,
+ !state.isEmpty
+ else {
+ throw OAuthAuthorizationError.authorizationResponseMissingState
+ }
+
+ guard state == expectedState else {
+ throw OAuthAuthorizationError.authorizationResponseStateMismatch(
+ expected: expectedState,
+ actual: state
+ )
+ }
+
+ guard
+ let code = redirectComponents.queryItems?.first(where: {
+ $0.name == OAuthParameterName.code
+ })?.value,
+ !code.isEmpty
+ else {
+ throw OAuthAuthorizationError.authorizationResponseMissingCode
+ }
+
+ return code
+ }
+
+ // MARK: - Private Helpers
+
+ private func normalizedRedirectBase(_ components: URLComponents) -> String {
+ let scheme = components.scheme?.lowercased() ?? ""
+ let host = components.host?.lowercased() ?? ""
+ let port: Int
+ if let explicitPort = components.port {
+ port = explicitPort
+ } else if scheme == OAuthURLScheme.https {
+ port = OAuthDefaultPort.https
+ } else if scheme == OAuthURLScheme.http {
+ port = OAuthDefaultPort.http
+ } else {
+ port = -1
+ }
+ let path = components.path.isEmpty ? "/" : components.path
+ return "\(scheme)://\(host):\(port)\(path)"
+ }
+}
+
+extension OAuthAuthorizationCodeFlow: OAuthAuthorizationCodeFlowing {}
diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
new file mode 100644
index 0000000..b2e5be3
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
@@ -0,0 +1,873 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+#if canImport(CryptoKit)
+ import CryptoKit
+#endif
+
+// MARK: - HTTPClientAuthorizer Protocol
+
+/// Abstraction used by ``HTTPClientTransport`` to handle OAuth authorization challenges.
+///
+/// Implement this protocol to provide custom token acquisition strategies,
+/// or use the built-in ``OAuthAuthorizer`` for a full OAuth 2.1 implementation.
+///
+/// ``HTTPClientTransport`` calls these methods automatically when it receives
+/// `401 Unauthorized` or `403 Forbidden` responses from the server.
+public protocol HTTPClientAuthorizer: AnyObject, Sendable {
+ /// The maximum number of authorization retries permitted for a single request.
+ ///
+ /// ``HTTPClientTransport`` will not call ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)``
+ /// more than this many times for a single outgoing request.
+ var maxAuthorizationAttempts: Int { get }
+
+ /// Validates that the MCP endpoint URL satisfies the security requirements for OAuth.
+ ///
+ /// Called once before the first request is sent. Throw ``OAuthAuthorizationError/insecureOAuthEndpoint(context:url:)``
+ /// if the URL does not meet the requirements (e.g., non-HTTPS non-loopback).
+ /// - Parameter endpoint: The MCP endpoint URL to validate.
+ func validateEndpointSecurity(for endpoint: URL) throws
+
+ /// Returns the `Authorization` header value to attach to the next request, if a valid token is available.
+ ///
+ /// - Parameter endpoint: The MCP endpoint being requested.
+ /// - Returns: A `"Bearer "` string, or `nil` if no valid token is cached.
+ func authorizationHeader(for endpoint: URL) -> String?
+
+ /// Handles an authorization challenge received from the server and attempts to acquire a new token.
+ ///
+ /// Called by ``HTTPClientTransport`` when a `401` or `403` response is received.
+ /// The implementation should attempt to obtain a valid access token and store it
+ /// so that a subsequent call to ``authorizationHeader(for:)`` returns the new value.
+ ///
+ /// - Parameters:
+ /// - statusCode: The HTTP status code (401 or 403).
+ /// - headers: All response headers from the challenge response.
+ /// - endpoint: The MCP endpoint that returned the challenge.
+ /// - operationKey: An optional identifier for the MCP operation (e.g., the JSON-RPC method),
+ /// used to track step-up attempts per operation.
+ /// - session: The `URLSession` to use for discovery and token requests.
+ /// - Returns: `true` if a new token was acquired and the original request should be retried;
+ /// `false` if the challenge cannot be handled.
+ func handleChallenge(
+ statusCode: Int,
+ headers: [String: String],
+ endpoint: URL,
+ operationKey: String?,
+ session: URLSession
+ ) async throws -> Bool
+
+ /// Proactively refreshes the access token if it is close to expiry.
+ ///
+ /// Called by ``HTTPClientTransport`` before sending each request and before opening
+ /// an SSE stream, allowing the token to be silently renewed without a 401 round-trip.
+ /// Implementations should swallow refresh errors — if refresh fails, the normal
+ /// ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)`` path recovers.
+ ///
+ /// - Parameters:
+ /// - endpoint: The MCP endpoint about to be contacted.
+ /// - session: The `URLSession` to use for token refresh requests.
+ func prepareAuthorization(for endpoint: URL, session: URLSession) async throws
+}
+
+extension HTTPClientAuthorizer {
+ public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {}
+}
+
+// MARK: - OAuthAuthorizer
+
+/// Full OAuth 2.1 implementation of ``HTTPClientAuthorizer``.
+///
+/// `OAuthAuthorizer` orchestrates the complete MCP authorization flow on behalf of an HTTP client:
+///
+/// 1. **Protected Resource Metadata discovery** (RFC 9728) — fetches
+/// `/.well-known/oauth-protected-resource` to locate the authorization server.
+/// 2. **Authorization Server Metadata discovery** (RFC 8414 / OIDC Discovery 1.0) — fetches
+/// `/.well-known/oauth-authorization-server` or `/.well-known/openid-configuration`.
+/// 3. **Dynamic Client Registration** (RFC 7591) — registers the client if no credentials are
+/// pre-configured and the AS advertises a registration endpoint.
+/// 4. **Token acquisition** — performs the configured grant flow (`authorization_code` with PKCE,
+/// or `client_credentials`), binding tokens to the resource indicator (RFC 8707).
+/// 5. **Token refresh** — attempts a `refresh_token` grant before a full re-authorization.
+/// 6. **Scope step-up** — handles `403 insufficient_scope` challenges by re-requesting with
+/// the union of existing and required scopes.
+///
+/// Pass an instance to `HTTPClientTransport(authorizer:)` to enable automatic authorization:
+///
+/// ```swift
+/// let config = OAuthConfiguration(
+/// grantType: .clientCredentials,
+/// authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
+/// )
+/// let authorizer = OAuthAuthorizer(configuration: config)
+/// let transport = HTTPClientTransport(endpoint: serverURL, authorizer: authorizer)
+/// ```
+///
+/// - Important: This type is `@unchecked Sendable`. All mutable state is accessed
+/// exclusively through the `HTTPClientTransport` actor, which serializes every call.
+/// Do **not** share a single `OAuthAuthorizer` instance across multiple transports —
+/// doing so would violate the isolation contract and risk concurrent mutation.
+public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
+
+ // MARK: - Mutable State
+
+ private var configuration: OAuthConfiguration
+ private let tokenStorage: TokenStorage
+ private var selectedAuthorizationServer: URL?
+ private var protectedResourceMetadata: OAuthProtectedResourceMetadata?
+ private var authorizationServerMetadata: OAuthAuthorizationServerMetadata?
+ private var cachedProtectedResourceMetadataURL: URL?
+ private var stepUpAttempts: [String: Int] = [:]
+ private var clientRegistrationAttempted = false
+ private var clientSecretExpiresAt: Date?
+
+ // MARK: - Composable Dependencies
+
+ private let scopeSelector: any OAuthScopeSelecting
+ private let challengeParser: any OAuthWWWAuthenticateParsing
+ private let urlValidator: any OAuthURLValidating
+ private let discoveryClient: any OAuthDiscoveryFetching
+ private let tokenEndpointClient: any OAuthTokenRequesting
+ private let clientRegistrar: any OAuthClientRegistering
+ private let authCodeFlow: any OAuthAuthorizationCodeFlowing
+
+ /// Creates an `OAuthAuthorizer` with the given configuration and optional injectable dependencies.
+ ///
+ /// - Parameters:
+ /// - configuration: OAuth 2.1 configuration controlling the grant type, authentication method,
+ /// endpoint discovery overrides, and retry policy.
+ /// - tokenStorage: Stores acquired access tokens. Defaults to ``InMemoryTokenStorage``,
+ /// which loses tokens when the process exits. Supply a Keychain-backed implementation
+ /// to persist tokens across sessions.
+ /// - scopeSelector: Strategy for selecting OAuth scopes from challenge and metadata hints.
+ /// Defaults to ``DefaultOAuthScopeSelector``.
+ /// - challengeParser: Parses `WWW-Authenticate: Bearer` challenge headers.
+ /// Defaults to ``DefaultOAuthWWWAuthenticateParser``.
+ /// - metadataDiscovery: Constructs well-known discovery URLs and validates resource URI matching.
+ /// Defaults to ``DefaultOAuthMetadataDiscovery``.
+ public convenience init(
+ configuration: OAuthConfiguration,
+ tokenStorage: TokenStorage = InMemoryTokenStorage(),
+ scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
+ challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
+ metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
+ ) {
+ let urlValidator = OAuthURLValidator(
+ allowLoopbackHTTPForAuthorizationServer:
+ configuration.allowLoopbackHTTPAuthorizationServerEndpoints
+ )
+ self.init(
+ configuration: configuration,
+ tokenStorage: tokenStorage,
+ scopeSelector: scopeSelector,
+ challengeParser: challengeParser,
+ urlValidator: urlValidator,
+ discoveryClient: OAuthDiscoveryClient(
+ metadataDiscovery: metadataDiscovery,
+ urlValidator: urlValidator
+ ),
+ tokenEndpointClient: OAuthTokenEndpointClient(urlValidator: urlValidator),
+ clientRegistrar: OAuthClientRegistrar(urlValidator: urlValidator),
+ authCodeFlow: OAuthAuthorizationCodeFlow()
+ )
+ }
+
+ init(
+ configuration: OAuthConfiguration,
+ tokenStorage: TokenStorage = InMemoryTokenStorage(),
+ scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
+ challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
+ urlValidator: any OAuthURLValidating,
+ discoveryClient: any OAuthDiscoveryFetching,
+ tokenEndpointClient: any OAuthTokenRequesting,
+ clientRegistrar: any OAuthClientRegistering,
+ authCodeFlow: any OAuthAuthorizationCodeFlowing
+ ) {
+ self.configuration = configuration
+ self.tokenStorage = tokenStorage
+ self.scopeSelector = scopeSelector
+ self.challengeParser = challengeParser
+ self.urlValidator = urlValidator
+ self.discoveryClient = discoveryClient
+ self.tokenEndpointClient = tokenEndpointClient
+ self.clientRegistrar = clientRegistrar
+ self.authCodeFlow = authCodeFlow
+ }
+
+ // MARK: - HTTPClientAuthorizer
+
+ public var maxAuthorizationAttempts: Int {
+ configuration.retryPolicy.maxAuthorizationAttempts
+ }
+
+ public func validateEndpointSecurity(for endpoint: URL) throws {
+ try urlValidator.validateHTTPSOrLoopback(endpoint, context: "MCP endpoint")
+ }
+
+ public func authorizationHeader(for endpoint: URL) -> String? {
+ guard let accessToken = tokenStorage.load() else { return nil }
+ if let tokenAuthorizationServer = accessToken.authorizationServer,
+ let selectedAuthorizationServer,
+ !authorizationServersMatch(tokenAuthorizationServer, selectedAuthorizationServer)
+ {
+ tokenStorage.clear()
+ return nil
+ }
+ if accessToken.isExpired() {
+ return nil
+ }
+ return "\(OAuthTokenType.bearer) \(accessToken.value)"
+ }
+
+ public func handleChallenge(
+ statusCode: Int,
+ headers: [String: String],
+ endpoint: URL,
+ operationKey: String? = nil,
+ session: URLSession
+ ) async throws -> Bool {
+ try validateEndpointSecurity(for: endpoint)
+ let challenge = challengeParser.parseBearer(from: headers)
+
+ switch statusCode {
+ case 401:
+ if let refreshToken = tokenStorage.load()?.refreshToken {
+ tokenStorage.clear()
+ let metadata = try await discoverProtectedResourceMetadata(
+ endpoint: endpoint,
+ challenge: challenge,
+ session: session
+ )
+ let asMetadata = try await resolveAuthorizationServerMetadata(
+ metadata: metadata,
+ session: session
+ )
+ let resource = try canonicalResource(for: endpoint)
+ let requestedScopes = scopeSelector.selectScopes(
+ challengeScope: challenge?.scope,
+ scopesSupported: metadata.scopesSupported
+ )
+ if try await refreshAccessToken(
+ refreshToken: refreshToken,
+ resource: resource,
+ requestedScopes: requestedScopes,
+ asMetadata: asMetadata,
+ session: session
+ ) {
+ return true
+ }
+ } else {
+ tokenStorage.clear()
+ }
+
+ let metadata = try await discoverProtectedResourceMetadata(
+ endpoint: endpoint,
+ challenge: challenge,
+ session: session
+ )
+ let requestedScopes = scopeSelector.selectScopes(
+ challengeScope: challenge?.scope,
+ scopesSupported: metadata.scopesSupported
+ )
+
+ let providerContext = try await makeAccessTokenProviderContext(
+ statusCode: statusCode,
+ endpoint: endpoint,
+ challenge: challenge,
+ metadata: metadata,
+ requestedScopes: requestedScopes,
+ session: session
+ )
+ if let externalToken = try await fetchAccessTokenFromProvider(
+ context: providerContext,
+ session: session
+ ) {
+ storeExternalAccessToken(
+ externalToken,
+ requestedScopes: providerContext.requestedScopes,
+ authorizationServer: providerContext.authorizationServer
+ )
+ return true
+ }
+
+ try await acquireToken(
+ endpoint: endpoint,
+ metadata: metadata,
+ requestedScopes: requestedScopes,
+ session: session
+ )
+ return true
+
+ case 403:
+ guard challenge?.error?.lowercased() == "insufficient_scope" else { return false }
+
+ let metadata = try await discoverProtectedResourceMetadata(
+ endpoint: endpoint,
+ challenge: challenge,
+ session: session
+ )
+ let requiredScopes =
+ scopeSelector.selectScopes(
+ challengeScope: challenge?.scope,
+ scopesSupported: metadata.scopesSupported
+ ) ?? []
+
+ let existingScopes = tokenStorage.load()?.scopes ?? []
+ let upgradedScopes = existingScopes.union(requiredScopes)
+ let resourceKey = try discoveryClient.metadataDiscovery.canonicalResourceURI(
+ from: endpoint
+ ).absoluteString
+ let operationAttemptKey = normalizedOperationKey(operationKey)
+ let attemptKey =
+ "\(resourceKey)|\(operationAttemptKey)|\(upgradedScopes.sorted().joined(separator: " "))"
+ let attempts = stepUpAttempts[attemptKey, default: 0]
+ guard attempts < configuration.retryPolicy.maxScopeUpgradeAttempts else {
+ return false
+ }
+ stepUpAttempts[attemptKey] = attempts + 1
+
+ let providerRequestedScopes = upgradedScopes.isEmpty ? nil : upgradedScopes
+ let providerContext = try await makeAccessTokenProviderContext(
+ statusCode: statusCode,
+ endpoint: endpoint,
+ challenge: challenge,
+ metadata: metadata,
+ requestedScopes: providerRequestedScopes,
+ session: session
+ )
+ if let externalToken = try await fetchAccessTokenFromProvider(
+ context: providerContext,
+ session: session
+ ) {
+ storeExternalAccessToken(
+ externalToken,
+ requestedScopes: providerContext.requestedScopes,
+ authorizationServer: providerContext.authorizationServer
+ )
+ return true
+ }
+
+ try await acquireToken(
+ endpoint: endpoint,
+ metadata: metadata,
+ requestedScopes: upgradedScopes,
+ session: session
+ )
+ return true
+
+ default:
+ return false
+ }
+ }
+
+ public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {
+ guard configuration.proactiveRefreshWindowSeconds > 0 else { return }
+ guard let token = tokenStorage.load() else { return }
+ guard token.isExpired(skewSeconds: configuration.proactiveRefreshWindowSeconds) else {
+ return
+ }
+ guard let refreshToken = token.refreshToken else { return }
+ guard let asMeta = authorizationServerMetadata, asMeta.tokenEndpoint != nil else { return }
+
+ let resource: URL
+ do {
+ resource = try canonicalResource(for: endpoint)
+ } catch {
+ return
+ }
+
+ let requestedScopes = token.scopes.isEmpty ? nil : token.scopes
+ _ = try? await refreshAccessToken(
+ refreshToken: refreshToken,
+ resource: resource,
+ requestedScopes: requestedScopes,
+ asMetadata: asMeta,
+ session: session
+ )
+ }
+
+ // MARK: - Discovery
+
+ private func discoverProtectedResourceMetadata(
+ endpoint: URL,
+ challenge: OAuthBearerChallenge?,
+ session: URLSession
+ ) async throws -> OAuthProtectedResourceMetadata {
+ if let protectedResourceMetadata {
+ let incomingURL = challenge?.resourceMetadataURL
+ if let incomingURL, incomingURL != cachedProtectedResourceMetadataURL {
+ self.protectedResourceMetadata = nil
+ self.authorizationServerMetadata = nil
+ self.selectedAuthorizationServer = nil
+ self.cachedProtectedResourceMetadataURL = nil
+ } else {
+ return protectedResourceMetadata
+ }
+ }
+
+ var candidates: [URL] = []
+
+ if let challengeURL = challenge?.resourceMetadataURL {
+ try urlValidator.validateHTTPSOrLoopback(
+ challengeURL, context: "Protected resource metadata URL")
+ if let host = URLComponents(url: challengeURL, resolvingAgainstBaseURL: false)?.host?
+ .lowercased(), urlValidator.isPrivateIPHost(host)
+ {
+ throw OAuthAuthorizationError.privateIPAddressBlocked(
+ context: "Protected resource metadata URL",
+ url: challengeURL.absoluteString
+ )
+ }
+ candidates.append(challengeURL)
+ }
+ if let configuredURL = configuration.endpointOverrides.protectedResourceMetadataURL,
+ !candidates.contains(configuredURL)
+ {
+ try urlValidator.validateHTTPSOrLoopback(
+ configuredURL,
+ context: "Configured protected resource metadata URL"
+ )
+ candidates.append(configuredURL)
+ }
+
+ for fallback in discoveryClient.metadataDiscovery.protectedResourceMetadataURLs(
+ for: endpoint)
+ where !candidates.contains(fallback) {
+ candidates.append(fallback)
+ }
+
+ let fallbackIssuer = try? discoveryClient.metadataDiscovery
+ .authorizationServerFallbackIssuer(from: endpoint)
+ let metadata = try await discoveryClient.fetchProtectedResourceMetadata(
+ candidates: candidates, fallbackIssuer: fallbackIssuer, session: session)
+ try validateProtectedResource(metadata: metadata, endpoint: endpoint)
+
+ self.protectedResourceMetadata = metadata
+ self.cachedProtectedResourceMetadataURL = candidates.first
+ return metadata
+ }
+
+ private func validateProtectedResource(
+ metadata: OAuthProtectedResourceMetadata, endpoint: URL
+ ) throws {
+ guard let resource = metadata.resource?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !resource.isEmpty
+ else {
+ return
+ }
+
+ guard let resourceURL = URL(string: resource) else {
+ throw OAuthAuthorizationError.invalidResourceURI(
+ "Protected resource metadata contains an invalid resource URI: \(resource)"
+ )
+ }
+
+ let expected = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: endpoint)
+ let actual = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: resourceURL)
+ guard discoveryClient.metadataDiscovery.protectedResourceMatches(
+ resource: actual, endpoint: expected)
+ else {
+ throw OAuthAuthorizationError.protectedResourceMismatch(
+ expected: expected.absoluteString,
+ actual: actual.absoluteString
+ )
+ }
+ }
+
+ private func resolveAuthorizationServerMetadata(
+ metadata: OAuthProtectedResourceMetadata,
+ session: URLSession
+ ) async throws -> OAuthAuthorizationServerMetadata {
+ if let cached = authorizationServerMetadata {
+ return cached
+ }
+
+ let candidates: [URL]
+ if let override = configuration.endpointOverrides.authorizationServerURL {
+ try urlValidator.validateAuthorizationServer(
+ override, context: "Authorization server issuer")
+ candidates = [override]
+ } else if let selected = selectedAuthorizationServer {
+ candidates = [selected]
+ } else {
+ guard !metadata.authorizationServers.isEmpty else {
+ throw OAuthAuthorizationError.missingAuthorizationServer
+ }
+ candidates = metadata.authorizationServers
+ }
+
+ let (server, asMetadata) = try await discoveryClient.fetchAuthorizationServerMetadata(
+ candidates: candidates, session: session)
+ self.selectedAuthorizationServer = server
+ self.authorizationServerMetadata = asMetadata
+ return asMetadata
+ }
+
+ // MARK: - Token Acquisition
+
+ private func acquireToken(
+ endpoint: URL,
+ metadata: OAuthProtectedResourceMetadata,
+ requestedScopes: Set?,
+ session: URLSession
+ ) async throws {
+ let asMetadata = try await resolveAuthorizationServerMetadata(
+ metadata: metadata, session: session)
+ try await maybeRegisterClient(asMetadata: asMetadata, session: session)
+ let resource = try canonicalResource(for: endpoint)
+
+ switch configuration.grantType {
+ case .clientCredentials:
+ try await acquireTokenViaClientCredentials(
+ resource: resource,
+ requestedScopes: requestedScopes,
+ asMetadata: asMetadata,
+ session: session
+ )
+ case .authorizationCode:
+ try await acquireTokenViaAuthorizationCode(
+ resource: resource,
+ requestedScopes: requestedScopes,
+ asMetadata: asMetadata,
+ session: session
+ )
+ }
+ }
+
+ private func acquireTokenViaClientCredentials(
+ resource: URL,
+ requestedScopes: Set?,
+ asMetadata: OAuthAuthorizationServerMetadata,
+ session: URLSession
+ ) async throws {
+ let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
+ var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
+ bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.clientCredentials
+ bodyParameters[OAuthParameterName.resource] = resource.absoluteString
+ if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
+ bodyParameters[OAuthParameterName.scope] = scope
+ }
+ let decoded = try await tokenEndpointClient.request(
+ parameters: &bodyParameters,
+ endpoint: tokenEndpoint,
+ authentication: configuration.authentication,
+ session: session
+ )
+ storeTokenResponse(decoded, requestedScopes: requestedScopes)
+ }
+
+ private func acquireTokenViaAuthorizationCode(
+ resource: URL,
+ requestedScopes: Set?,
+ asMetadata: OAuthAuthorizationServerMetadata,
+ session: URLSession
+ ) async throws {
+ guard let authorizationEndpoint = asMetadata.authorizationEndpoint else {
+ throw OAuthAuthorizationError.tokenEndpointMissing
+ }
+ try urlValidator.validateAuthorizationServer(
+ authorizationEndpoint, context: "Authorization endpoint")
+ if let host = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false)?
+ .host?.lowercased(), urlValidator.isPrivateIPHost(host)
+ {
+ throw OAuthAuthorizationError.privateIPAddressBlocked(
+ context: "Authorization endpoint",
+ url: authorizationEndpoint.absoluteString
+ )
+ }
+ try urlValidator.validateRedirectURI(configuration.authorizationRedirectURI)
+ try PKCE.checkSupport(in: asMetadata)
+
+ let verifier = PKCE.makeVerifier()
+ let challenge = try PKCE.makeChallenge(from: verifier)
+ let state = UUID().uuidString
+
+ let authorizationURL = try authCodeFlow.buildURL(
+ authorizationEndpoint: authorizationEndpoint,
+ resource: resource,
+ redirectURI: configuration.authorizationRedirectURI,
+ clientID: configuration.authentication.clientID,
+ codeChallenge: challenge,
+ scopes: requestedScopes,
+ state: state,
+ scopeSerializer: scopeSelector
+ )
+
+ let authorizationCode = try await authCodeFlow.perform(
+ authorizationURL: authorizationURL,
+ redirectURI: configuration.authorizationRedirectURI,
+ state: state,
+ delegate: configuration.authorizationDelegate,
+ session: session
+ )
+
+ let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
+ var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
+ bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.authorizationCode
+ bodyParameters[OAuthParameterName.code] = authorizationCode
+ bodyParameters[OAuthParameterName.codeVerifier] = verifier
+ bodyParameters[OAuthParameterName.redirectURI] =
+ configuration.authorizationRedirectURI.absoluteString
+ bodyParameters[OAuthParameterName.resource] = resource.absoluteString
+ if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
+ bodyParameters[OAuthParameterName.scope] = scope
+ }
+
+ let decoded = try await tokenEndpointClient.request(
+ parameters: &bodyParameters,
+ endpoint: tokenEndpoint,
+ authentication: configuration.authentication,
+ session: session
+ )
+ storeTokenResponse(decoded, requestedScopes: requestedScopes)
+ }
+
+ // MARK: - Token Refresh
+
+ private func refreshAccessToken(
+ refreshToken: String,
+ resource: URL,
+ requestedScopes: Set?,
+ asMetadata: OAuthAuthorizationServerMetadata,
+ session: URLSession
+ ) async throws -> Bool {
+ let tokenEndpoint: URL
+ do {
+ tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
+ } catch {
+ return false
+ }
+
+ var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
+ bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.refreshToken
+ bodyParameters[OAuthParameterName.refreshToken] = refreshToken
+ bodyParameters[OAuthParameterName.resource] = resource.absoluteString
+ if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
+ bodyParameters[OAuthParameterName.scope] = scope
+ }
+
+ do {
+ let decoded = try await tokenEndpointClient.request(
+ parameters: &bodyParameters,
+ endpoint: tokenEndpoint,
+ authentication: configuration.authentication,
+ session: session
+ )
+ storeTokenResponse(decoded, requestedScopes: requestedScopes)
+ return true
+ } catch let error as OAuthAuthorizationError {
+ if case .tokenRequestFailed(let statusCode, _) = error,
+ (400..<500).contains(statusCode)
+ {
+ return false
+ }
+ throw error
+ }
+ }
+
+ // MARK: - Client Registration
+
+ private func maybeRegisterClient(
+ asMetadata: OAuthAuthorizationServerMetadata,
+ session: URLSession
+ ) async throws {
+ if let expiry = clientSecretExpiresAt, Date() >= expiry {
+ clientSecretExpiresAt = nil
+ clientRegistrationAttempted = false
+ configuration.authentication = .none(clientID: configuration.authentication.clientID)
+ }
+
+ guard !clientRegistrationAttempted else { return }
+ guard case .none = configuration.authentication else { return }
+
+ clientRegistrationAttempted = true
+
+ if let (registration, updatedAuth) = try await clientRegistrar.register(
+ configuration: configuration,
+ asMetadata: asMetadata,
+ session: session
+ ) {
+ configuration.authentication = updatedAuth
+ if let expiresAt = registration.clientSecretExpiresAt, expiresAt > 0 {
+ clientSecretExpiresAt = Date(timeIntervalSince1970: Double(expiresAt))
+ }
+ }
+ }
+
+ // MARK: - State Helpers
+
+ private func storeTokenResponse(
+ _ decoded: OAuthTokenResponse,
+ requestedScopes: Set?
+ ) {
+ let scopeSet: Set
+ if let scope = decoded.scope {
+ scopeSet = scopeSelector.parseScopeString(scope)
+ } else {
+ scopeSet = requestedScopes ?? []
+ }
+ let expiresAt = decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) }
+ tokenStorage.save(OAuthAccessToken(
+ value: decoded.accessToken,
+ tokenType: OAuthTokenType.bearer,
+ expiresAt: expiresAt,
+ scopes: scopeSet,
+ authorizationServer: selectedAuthorizationServer,
+ refreshToken: decoded.refreshToken,
+ clientID: nonEmptyClientID()
+ ))
+ }
+
+ /// Returns the configured `client_id` or `nil` if the authorizer has not yet been assigned one.
+ private func nonEmptyClientID() -> String? {
+ let id = configuration.authentication.clientID
+ return id.isEmpty ? nil : id
+ }
+
+ private func resolveTokenEndpoint(
+ asMetadata: OAuthAuthorizationServerMetadata
+ ) throws -> URL {
+ if let configuredEndpoint = configuration.endpointOverrides.tokenEndpoint {
+ try urlValidator.validateAuthorizationServer(
+ configuredEndpoint, context: "Configured token endpoint")
+ return configuredEndpoint
+ }
+
+ guard let tokenEndpoint = asMetadata.tokenEndpoint else {
+ throw OAuthAuthorizationError.tokenEndpointMissing
+ }
+ try urlValidator.validateAuthorizationServer(tokenEndpoint, context: "Token endpoint")
+ if let host = URLComponents(url: tokenEndpoint, resolvingAgainstBaseURL: false)?.host?
+ .lowercased(), urlValidator.isPrivateIPHost(host)
+ {
+ throw OAuthAuthorizationError.privateIPAddressBlocked(
+ context: "Token endpoint",
+ url: tokenEndpoint.absoluteString
+ )
+ }
+ return tokenEndpoint
+ }
+
+ private func canonicalResource(for endpoint: URL) throws -> URL {
+ let endpointCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
+ from: endpoint)
+
+ if let configuredResource = configuration.endpointOverrides.resource {
+ let configuredCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
+ from: configuredResource)
+ guard discoveryClient.metadataDiscovery.protectedResourceMatches(
+ resource: configuredCanonical, endpoint: endpointCanonical)
+ else {
+ throw OAuthAuthorizationError.protectedResourceMismatch(
+ expected: endpointCanonical.absoluteString,
+ actual: configuredCanonical.absoluteString
+ )
+ }
+ return configuredCanonical
+ }
+
+ if let prmResourceString = protectedResourceMetadata?.resource,
+ let prmResourceURL = URL(string: prmResourceString)
+ {
+ let prmCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
+ from: prmResourceURL)
+ guard discoveryClient.metadataDiscovery.protectedResourceMatches(
+ resource: prmCanonical, endpoint: endpointCanonical)
+ else {
+ throw OAuthAuthorizationError.protectedResourceMismatch(
+ expected: endpointCanonical.absoluteString,
+ actual: prmCanonical.absoluteString
+ )
+ }
+ return prmCanonical
+ }
+
+ return endpointCanonical
+ }
+
+ private func authorizationServersMatch(_ lhs: URL, _ rhs: URL) -> Bool {
+ normalizedAuthorizationServer(lhs) == normalizedAuthorizationServer(rhs)
+ }
+
+ private func normalizedAuthorizationServer(_ url: URL) -> URL? {
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ scheme == OAuthURLScheme.http || scheme == OAuthURLScheme.https
+ else {
+ return nil
+ }
+ components.scheme = scheme
+ components.host = host
+ components.query = nil
+ components.fragment = nil
+ if components.path == "/" { components.path = "" }
+ return components.url
+ }
+
+ // MARK: - External Token Provider
+
+ private func fetchAccessTokenFromProvider(
+ context: OAuthConfiguration.AccessTokenProviderContext,
+ session: URLSession
+ ) async throws -> String? {
+ guard let provider = configuration.accessTokenProvider else { return nil }
+ guard let token = try await provider(context, session), !token.isEmpty else { return nil }
+ return token
+ }
+
+ private func storeExternalAccessToken(
+ _ token: String,
+ requestedScopes: Set?,
+ authorizationServer: URL?
+ ) {
+ tokenStorage.save(OAuthAccessToken(
+ value: token,
+ tokenType: OAuthTokenType.bearer,
+ expiresAt: nil,
+ scopes: requestedScopes ?? [],
+ authorizationServer: authorizationServer,
+ refreshToken: nil,
+ clientID: nonEmptyClientID()
+ ))
+ }
+
+ private func makeAccessTokenProviderContext(
+ statusCode: Int,
+ endpoint: URL,
+ challenge: OAuthBearerChallenge?,
+ metadata: OAuthProtectedResourceMetadata,
+ requestedScopes: Set?,
+ session: URLSession
+ ) async throws -> OAuthConfiguration.AccessTokenProviderContext {
+ let asMetadata = try await resolveAuthorizationServerMetadata(
+ metadata: metadata, session: session)
+ let resource = try canonicalResource(for: endpoint)
+ let authorizationServer = configuration.endpointOverrides.authorizationServerURL
+ ?? selectedAuthorizationServer
+ ?? metadata.authorizationServers.first
+ let tokenEndpoint = configuration.endpointOverrides.tokenEndpoint ?? asMetadata.tokenEndpoint
+
+ return OAuthConfiguration.AccessTokenProviderContext(
+ statusCode: statusCode,
+ endpoint: endpoint,
+ resource: resource,
+ authorizationServer: authorizationServer,
+ authorizationEndpoint: asMetadata.authorizationEndpoint,
+ tokenEndpoint: tokenEndpoint,
+ registrationEndpoint: asMetadata.registrationEndpoint,
+ challengedScope: challenge?.scope,
+ scopesSupported: metadata.scopesSupported,
+ requestedScopes: requestedScopes
+ )
+ }
+
+ private func normalizedOperationKey(_ operationKey: String?) -> String {
+ guard let operationKey else { return "" }
+ let normalized = operationKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? "" : normalized
+ }
+}
diff --git a/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift b/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
new file mode 100644
index 0000000..6267421
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
@@ -0,0 +1,186 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+/// Internal protocol for OAuth dynamic client registration.
+protocol OAuthClientRegistering: Sendable {
+ func register(
+ configuration: OAuthConfiguration,
+ asMetadata: OAuthAuthorizationServerMetadata,
+ session: URLSession
+ ) async throws -> (
+ response: OAuthClientRegistrationResponse,
+ updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
+ )?
+}
+
+/// Stateless OAuth dynamic client registration logic.
+///
+/// Handles Client ID Metadata Document (CIMD) detection and RFC 7591 dynamic registration.
+/// State tracking (`clientRegistrationAttempted`, `clientSecretExpiresAt`) is the caller's responsibility.
+struct OAuthClientRegistrar: Sendable {
+ let urlValidator: OAuthURLValidator
+
+ init(urlValidator: OAuthURLValidator) {
+ self.urlValidator = urlValidator
+ }
+
+ /// Attempts to register the client, if applicable.
+ ///
+ /// Returns `nil` if registration is not needed:
+ /// - Credentials are already configured (not `.none`)
+ /// - CIMD is in use and the server supports it (pre-registered)
+ /// - No registration endpoint is available and no CIMD mismatch error
+ ///
+ /// Throws if registration was attempted but failed (4xx, 5xx, or unexpected response).
+ func register(
+ configuration: OAuthConfiguration,
+ asMetadata: OAuthAuthorizationServerMetadata,
+ session: URLSession
+ ) async throws -> (
+ response: OAuthClientRegistrationResponse,
+ updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
+ )? {
+ guard case .none(let clientID) = configuration.authentication else {
+ return nil
+ }
+
+ let hasClientIDMetadataDocument = isHTTPSURLWithPath(clientID)
+ let supportsClientIDMetadataDocument = asMetadata.clientIDMetadataDocumentSupported == true
+
+ if supportsClientIDMetadataDocument,
+ clientIDLooksLikeURL(clientID),
+ !hasClientIDMetadataDocument
+ {
+ throw OAuthAuthorizationError.invalidClientIDMetadataURL(clientID)
+ }
+
+ if hasClientIDMetadataDocument, supportsClientIDMetadataDocument {
+ return nil
+ }
+
+ guard let registrationEndpoint = asMetadata.registrationEndpoint else {
+ if hasClientIDMetadataDocument && !supportsClientIDMetadataDocument {
+ throw OAuthAuthorizationError.cimdNotSupported(clientID: clientID)
+ }
+ return nil
+ }
+ try urlValidator.validateAuthorizationServer(
+ registrationEndpoint, context: "Client registration endpoint")
+
+ var request = URLRequest(url: registrationEndpoint)
+ request.httpMethod = "POST"
+ request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
+ request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
+
+ let grantTypes: [String]
+ let responseTypes: [String]
+ switch configuration.grantType {
+ case .authorizationCode:
+ grantTypes = [OAuthGrantTypeValue.authorizationCode, OAuthGrantTypeValue.refreshToken]
+ responseTypes = [OAuthParameterName.code]
+ case .clientCredentials:
+ grantTypes = [OAuthGrantTypeValue.clientCredentials]
+ responseTypes = []
+ }
+
+ var registrationPayload: [String: Any] = [
+ "client_name": configuration.clientName,
+ "grant_types": grantTypes,
+ "token_endpoint_auth_method": configuration.authentication.methodName,
+ ]
+ if !responseTypes.isEmpty {
+ registrationPayload["response_types"] = responseTypes
+ }
+ if configuration.grantType == .authorizationCode {
+ registrationPayload["redirect_uris"] = [
+ configuration.authorizationRedirectURI.absoluteString
+ ]
+ }
+
+ let httpBody = try JSONSerialization.data(withJSONObject: registrationPayload)
+
+ request.httpBody = httpBody
+
+ let (data, response) = try await session.data(for: request)
+ if let httpResponse = response as? HTTPURLResponse,
+ (400..<500).contains(httpResponse.statusCode)
+ {
+ let oauthError =
+ (try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
+ throw OAuthAuthorizationError.tokenRequestFailed(
+ statusCode: httpResponse.statusCode,
+ oauthError: oauthError
+ )
+ }
+ guard let httpResponse = response as? HTTPURLResponse,
+ (200..<300).contains(httpResponse.statusCode)
+ else {
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
+ let oauthError =
+ (try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
+ throw OAuthAuthorizationError.tokenRequestFailed(
+ statusCode: statusCode,
+ oauthError: oauthError
+ )
+ }
+
+ let registration = try JSONDecoder().decode(OAuthClientRegistrationResponse.self, from: data)
+
+ let updatedAuth = OAuthClientRegistrar.updatedAuthentication(
+ from: registration, current: configuration.authentication)
+ return (response: registration, updatedAuthentication: updatedAuth)
+ }
+
+ /// Derives the updated token endpoint authentication from a registration response.
+ ///
+ /// Updates the client ID (and secret, if issued) while preserving the authentication method.
+ static func updatedAuthentication(
+ from registration: OAuthClientRegistrationResponse,
+ current: OAuthConfiguration.TokenEndpointAuthentication
+ ) -> OAuthConfiguration.TokenEndpointAuthentication {
+ switch current {
+ case .none:
+ return .none(clientID: registration.clientID)
+ case .clientSecretBasic(_, let currentSecret):
+ return .clientSecretBasic(
+ clientID: registration.clientID,
+ clientSecret: registration.clientSecret ?? currentSecret
+ )
+ case .clientSecretPost(_, let currentSecret):
+ return .clientSecretPost(
+ clientID: registration.clientID,
+ clientSecret: registration.clientSecret ?? currentSecret
+ )
+ case .privateKeyJWT(_, let factory):
+ return .privateKeyJWT(clientID: registration.clientID, assertionFactory: factory)
+ }
+ }
+
+ // MARK: - CIMD Helpers
+
+ private func isHTTPSURLWithPath(_ value: String) -> Bool {
+ guard let url = URL(string: value),
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+ else {
+ return false
+ }
+ return components.scheme?.lowercased() == OAuthURLScheme.https
+ && !components.path.isEmpty
+ && components.path != "/"
+ }
+
+ private func clientIDLooksLikeURL(_ value: String) -> Bool {
+ guard let url = URL(string: value),
+ let scheme = url.scheme,
+ !scheme.isEmpty
+ else {
+ return false
+ }
+ return true
+ }
+}
+
+extension OAuthClientRegistrar: OAuthClientRegistering {}
diff --git a/Sources/MCP/Base/Authorization/OAuthConfiguration.swift b/Sources/MCP/Base/Authorization/OAuthConfiguration.swift
new file mode 100644
index 0000000..a1c015f
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthConfiguration.swift
@@ -0,0 +1,415 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+#if canImport(CryptoKit)
+ import CryptoKit
+#endif
+
+/// Configuration for OAuth 2.1 authorization used by ``HTTPClientTransport``.
+///
+/// Authorization is optional and disabled by default. Configure this type and pass it
+/// to `HTTPClientTransport(oauth:)` to enable automatic Bearer token acquisition for HTTP
+/// transports.
+///
+/// Supports both `authorization_code` (interactive, browser-based) and `client_credentials`
+/// (machine-to-machine) grant types via the ``grantType`` property.
+public struct OAuthConfiguration: Sendable {
+ /// The OAuth 2.1 grant type to use for token acquisition.
+ public enum GrantType: Sendable {
+ /// OAuth 2.1 authorization_code flow with PKCE.
+ case authorizationCode
+
+ /// OAuth 2.1 client_credentials flow.
+ case clientCredentials
+ }
+
+ /// How the client authenticates to the OAuth token endpoint.
+ public enum TokenEndpointAuthentication: Sendable, Equatable {
+ /// `client_secret_basic` authentication using the Authorization header.
+ case clientSecretBasic(clientID: String, clientSecret: String)
+
+ /// `client_secret_post` authentication using form parameters.
+ case clientSecretPost(clientID: String, clientSecret: String)
+
+ /// Public client authentication (`token_endpoint_auth_method=none`).
+ case none(clientID: String)
+
+ /// `private_key_jwt` authentication.
+ ///
+ /// The built-in ``OAuthConfiguration/makePrivateKeyJWTAssertion(clientID:tokenEndpoint:privateKeyPEM:signingAlgorithm:audience:issuedAt:expiresIn:)``
+ /// helper generates ES256 (P-256 ECDSA) assertions only. To use other algorithms
+ /// (e.g., RS256, ES384), provide a custom ``OAuthConfiguration/JWTAssertionFactory`` closure.
+ case privateKeyJWT(clientID: String, assertionFactory: JWTAssertionFactory)
+
+ var clientID: String {
+ switch self {
+ case .clientSecretBasic(let clientID, _), .clientSecretPost(let clientID, _),
+ .none(let clientID), .privateKeyJWT(let clientID, _):
+ return clientID
+ }
+ }
+
+ /// The token endpoint auth method name per RFC 7591.
+ var methodName: String {
+ switch self {
+ case .clientSecretBasic: return OAuthTokenEndpointAuthMethod.clientSecretBasic
+ case .clientSecretPost: return OAuthTokenEndpointAuthMethod.clientSecretPost
+ case .none: return OAuthTokenEndpointAuthMethod.none
+ case .privateKeyJWT: return OAuthTokenEndpointAuthMethod.privateKeyJWT
+ }
+ }
+
+ func apply(
+ to request: inout URLRequest,
+ bodyParameters: inout [String: String],
+ tokenEndpoint: URL
+ ) async throws {
+ switch self {
+ case .clientSecretBasic(let clientID, let clientSecret):
+ let basic = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
+ request.setValue("Basic \(basic)", forHTTPHeaderField: HTTPHeaderName.authorization)
+ case .clientSecretPost(let clientID, let clientSecret):
+ bodyParameters[OAuthParameterName.clientID] = clientID
+ bodyParameters[OAuthParameterName.clientSecret] = clientSecret
+ case .none(let clientID):
+ bodyParameters[OAuthParameterName.clientID] = clientID
+ case .privateKeyJWT(let clientID, let assertionFactory):
+ bodyParameters[OAuthParameterName.clientID] = clientID
+ bodyParameters[OAuthParameterName.clientAssertionType] =
+ OAuthClientAssertionType.jwtBearer
+ bodyParameters[OAuthParameterName.clientAssertion] =
+ try await assertionFactory(tokenEndpoint, clientID)
+ }
+ }
+ }
+
+ /// Closure used to generate a `private_key_jwt` assertion.
+ public typealias JWTAssertionFactory = @Sendable (_ tokenEndpoint: URL, _ clientID: String)
+ async throws -> String
+
+ /// Supported signing algorithms for SDK-generated `private_key_jwt` assertions.
+ public enum PrivateKeyJWTSigningAlgorithm: String, Sendable {
+ case ES256
+ }
+
+ /// Errors thrown while creating SDK-generated `private_key_jwt` assertions.
+ public enum PrivateKeyJWTAssertionError: LocalizedError, Sendable {
+ case invalidLifetime(TimeInterval)
+ case cryptographyUnavailable
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidLifetime(let lifetime):
+ return "private_key_jwt assertion lifetime must be greater than zero seconds, got \(lifetime)"
+ case .cryptographyUnavailable:
+ return "private_key_jwt assertion signing requires CryptoKit support"
+ }
+ }
+ }
+
+ /// Creates a signed `private_key_jwt` client assertion (RFC 7523).
+ ///
+ /// Use this helper to build a `JWTAssertionFactory` closure when configuring
+ /// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``:
+ ///
+ /// ```swift
+ /// let factory: OAuthConfiguration.JWTAssertionFactory = { tokenEndpoint, clientID in
+ /// try OAuthConfiguration.makePrivateKeyJWTAssertion(
+ /// clientID: clientID,
+ /// tokenEndpoint: tokenEndpoint,
+ /// privateKeyPEM: myPEMString
+ /// )
+ /// }
+ /// ```
+ ///
+ /// The assertion is a compact-serialized JWS signed with the specified private key.
+ /// Only ES256 (P-256 ECDSA) is supported by this helper; the algorithm requires CryptoKit.
+ /// To use other algorithms, supply a custom ``JWTAssertionFactory`` closure directly to
+ /// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``.
+ ///
+ /// - Parameters:
+ /// - clientID: The OAuth client identifier, used as both `iss` and `sub` claims.
+ /// - tokenEndpoint: Token endpoint URL, used as the default `aud` claim.
+ /// - privateKeyPEM: PEM-encoded EC private key for signing.
+ /// - signingAlgorithm: The JWS signing algorithm. Currently only `.ES256` is supported.
+ /// - audience: Explicit `aud` claim override. Defaults to `tokenEndpoint.absoluteString`.
+ /// - issuedAt: The `iat` claim. Defaults to `Date()`.
+ /// - expiresIn: Lifetime of the assertion in seconds. Defaults to 300 (5 minutes). Must be > 0.
+ /// - Returns: A compact-serialized JWT string (`header.payload.signature`).
+ /// - Throws: ``PrivateKeyJWTAssertionError/invalidLifetime(_:)`` if `expiresIn` ≤ 0,
+ /// or ``PrivateKeyJWTAssertionError/cryptographyUnavailable`` on platforms without CryptoKit.
+ public static func makePrivateKeyJWTAssertion(
+ clientID: String,
+ tokenEndpoint: URL,
+ privateKeyPEM: String,
+ signingAlgorithm: PrivateKeyJWTSigningAlgorithm = .ES256,
+ audience: String? = nil,
+ issuedAt: Date = Date(),
+ expiresIn: TimeInterval = 300
+ ) throws -> String {
+ guard expiresIn > 0 else {
+ throw PrivateKeyJWTAssertionError.invalidLifetime(expiresIn)
+ }
+
+ let header = try JSONSerialization.data(withJSONObject: [
+ JWTClaimName.algorithm: signingAlgorithm.rawValue,
+ JWTClaimName.type: JWTClaimName.typeValue,
+ ])
+
+ let issuedAtUnix = Int(issuedAt.timeIntervalSince1970)
+ let lifetimeSeconds = max(1, Int(expiresIn.rounded(.down)))
+ let payload = try JSONSerialization.data(withJSONObject: [
+ JWTClaimName.issuer: clientID,
+ JWTClaimName.subject: clientID,
+ JWTClaimName.audience: audience ?? tokenEndpoint.absoluteString,
+ JWTClaimName.issuedAt: issuedAtUnix,
+ JWTClaimName.expiration: issuedAtUnix + lifetimeSeconds,
+ JWTClaimName.jwtID: UUID().uuidString,
+ ])
+
+ let signingInput = "\(header.base64URLEncodedString()).\(payload.base64URLEncodedString())"
+
+ #if canImport(CryptoKit)
+ switch signingAlgorithm {
+ case .ES256:
+ let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKeyPEM)
+ let signature = try privateKey.signature(for: Data(signingInput.utf8)).rawRepresentation
+ return "\(signingInput).\(signature.base64URLEncodedString())"
+ }
+ #else
+ throw PrivateKeyJWTAssertionError.cryptographyUnavailable
+ #endif
+ }
+
+ static func defaultAuthorizationRedirectURI() -> URL {
+ let port = Int.random(in: 49152...65535)
+ return URL(string: "http://\(OAuthLoopbackHost.ipv4):\(port)/callback")!
+ }
+
+ // MARK: - Retry Policy
+
+ /// Controls retry behavior for authorization challenges.
+ public struct RetryPolicy: Sendable {
+ /// Maximum number of authentication retries for a single MCP request.
+ public let maxAuthorizationAttempts: Int
+
+ /// Maximum number of scope step-up attempts for a resource and operation.
+ public let maxScopeUpgradeAttempts: Int
+
+ /// Creates a retry policy.
+ ///
+ /// Both values are clamped to a minimum of `1` to prevent infinite loops
+ /// or zero-retry configurations.
+ ///
+ /// - Parameters:
+ /// - maxAuthorizationAttempts: Maximum authorization retries per request. Defaults to 3.
+ /// - maxScopeUpgradeAttempts: Maximum scope step-up attempts per resource+operation. Defaults to 2.
+ public init(
+ maxAuthorizationAttempts: Int = 3,
+ maxScopeUpgradeAttempts: Int = 2
+ ) {
+ self.maxAuthorizationAttempts = max(1, maxAuthorizationAttempts)
+ self.maxScopeUpgradeAttempts = max(1, maxScopeUpgradeAttempts)
+ }
+
+ public static let `default` = RetryPolicy()
+ }
+
+ // MARK: - Endpoint Overrides
+
+ /// Optional endpoint overrides for discovery.
+ public struct EndpointOverrides: Sendable {
+ /// Optional override for the protected resource metadata URL.
+ public let protectedResourceMetadataURL: URL?
+
+ /// Optional override for the authorization server issuer URL.
+ public let authorizationServerURL: URL?
+
+ /// Optional override for the token endpoint.
+ public let tokenEndpoint: URL?
+
+ /// Optional override for the resource indicator used in token requests.
+ public let resource: URL?
+
+ public init(
+ protectedResourceMetadataURL: URL? = nil,
+ authorizationServerURL: URL? = nil,
+ tokenEndpoint: URL? = nil,
+ resource: URL? = nil
+ ) {
+ self.protectedResourceMetadataURL = protectedResourceMetadataURL
+ self.authorizationServerURL = authorizationServerURL
+ self.tokenEndpoint = tokenEndpoint
+ self.resource = resource
+ }
+
+ public static let none = EndpointOverrides()
+ }
+
+ // MARK: - Access Token Provider
+
+ /// Context supplied to ``AccessTokenProvider`` after SDK discovery is complete.
+ public struct AccessTokenProviderContext: Sendable {
+ /// HTTP status code that triggered authorization handling (typically 401 or 403).
+ public let statusCode: Int
+ /// Target MCP endpoint URL.
+ public let endpoint: URL
+ /// Canonical RFC8707 resource URI for token audience binding.
+ public let resource: URL
+ /// Selected authorization server issuer URL, if discovered.
+ public let authorizationServer: URL?
+ /// Authorization endpoint from AS metadata, if available.
+ public let authorizationEndpoint: URL?
+ /// Token endpoint from AS metadata (or configuration override), if available.
+ public let tokenEndpoint: URL?
+ /// Dynamic registration endpoint from AS metadata, if available.
+ public let registrationEndpoint: URL?
+ /// `scope` value from the latest challenge header, when present.
+ public let challengedScope: String?
+ /// `scopes_supported` from protected resource metadata, when present.
+ public let scopesSupported: [String]?
+ /// Scope set selected by SDK for the pending authorization attempt.
+ public let requestedScopes: Set?
+
+ public init(
+ statusCode: Int,
+ endpoint: URL,
+ resource: URL,
+ authorizationServer: URL?,
+ authorizationEndpoint: URL?,
+ tokenEndpoint: URL?,
+ registrationEndpoint: URL?,
+ challengedScope: String?,
+ scopesSupported: [String]?,
+ requestedScopes: Set?
+ ) {
+ self.statusCode = statusCode
+ self.endpoint = endpoint
+ self.resource = resource
+ self.authorizationServer = authorizationServer
+ self.authorizationEndpoint = authorizationEndpoint
+ self.tokenEndpoint = tokenEndpoint
+ self.registrationEndpoint = registrationEndpoint
+ self.challengedScope = challengedScope
+ self.scopesSupported = scopesSupported
+ self.requestedScopes = requestedScopes
+ }
+ }
+
+ /// Optional provider for externally acquired access tokens.
+ public typealias AccessTokenProvider = @Sendable (
+ _ context: AccessTokenProviderContext,
+ _ session: URLSession
+ ) async throws -> String?
+
+ // MARK: - Properties
+
+ /// The grant type used for token acquisition.
+ public let grantType: GrantType
+
+ /// The configured token endpoint authentication method.
+ public var authentication: TokenEndpointAuthentication
+
+ /// Controls retry behavior for authorization challenges.
+ public let retryPolicy: RetryPolicy
+
+ /// Optional endpoint overrides for discovery.
+ public let endpointOverrides: EndpointOverrides
+
+ /// Package-scoped compatibility option for local environments.
+ package var allowLoopbackHTTPAuthorizationServerEndpoints: Bool
+
+ /// Redirect URI used for authorization requests.
+ public let authorizationRedirectURI: URL
+
+ /// Additional form fields to include in token requests.
+ public let additionalTokenRequestParameters: [String: String]
+
+ /// The `client_name` sent during dynamic client registration (RFC 7591).
+ /// Defaults to `"mcp-swift-sdk"`. Override with your application's name.
+ public let clientName: String
+
+ /// Optional provider for externally acquired access tokens.
+ public let accessTokenProvider: AccessTokenProvider?
+
+ /// Optional delegate for browser-based authorization code flows.
+ public let authorizationDelegate: (any OAuthAuthorizationDelegate)?
+
+ /// How many seconds before token expiry ``OAuthAuthorizer`` proactively refreshes a token
+ /// when `prepareAuthorization(for:session:)` is called.
+ ///
+ /// Set to `0` to disable proactive refresh. Defaults to 60 seconds.
+ /// Must be greater than ``OAuthTokenExpirySkew/defaultSeconds`` (30 s) to have any effect,
+ /// since tokens within the default skew window are already treated as expired.
+ public let proactiveRefreshWindowSeconds: TimeInterval
+
+ /// Creates an OAuth configuration.
+ ///
+ /// - Parameters:
+ /// - grantType: The OAuth 2.1 grant type. Defaults to `.clientCredentials`.
+ /// - authentication: How the client authenticates to the token endpoint. **Required.**
+ /// - retryPolicy: Controls how many retries are allowed for authorization challenges.
+ /// - endpointOverrides: Optional URL overrides that bypass automatic discovery.
+ /// - authorizationRedirectURI: Redirect URI for the `authorization_code` flow.
+ /// Defaults to a random loopback URI (`http://127.0.0.1:/callback`).
+ /// - clientName: The `client_name` sent during dynamic client registration. Defaults to `"mcp-swift-sdk"`.
+ /// - additionalTokenRequestParameters: Extra form fields appended to every token request.
+ /// - accessTokenProvider: Optional closure invoked after discovery, allowing the host app
+ /// to supply an externally acquired token (e.g., from a system credential store).
+ /// - authorizationDelegate: Optional delegate that presents the authorization URL to the
+ /// user for interactive `authorization_code` flows.
+ /// - proactiveRefreshWindowSeconds: Seconds before expiry at which a token is proactively
+ /// refreshed. Defaults to 60. Set to 0 to disable proactive refresh.
+ public init(
+ grantType: GrantType = .clientCredentials,
+ authentication: TokenEndpointAuthentication,
+ retryPolicy: RetryPolicy = .default,
+ endpointOverrides: EndpointOverrides = .none,
+ authorizationRedirectURI: URL? = nil,
+ clientName: String = "mcp-swift-sdk",
+ additionalTokenRequestParameters: [String: String] = [:],
+ accessTokenProvider: AccessTokenProvider? = nil,
+ authorizationDelegate: (any OAuthAuthorizationDelegate)? = nil,
+ proactiveRefreshWindowSeconds: TimeInterval = 60
+ ) {
+ self.grantType = grantType
+ self.authentication = authentication
+ self.retryPolicy = retryPolicy
+ self.endpointOverrides = endpointOverrides
+ self.allowLoopbackHTTPAuthorizationServerEndpoints = false
+ self.authorizationRedirectURI =
+ authorizationRedirectURI ?? Self.defaultAuthorizationRedirectURI()
+ self.clientName = clientName
+ self.additionalTokenRequestParameters = additionalTokenRequestParameters
+ self.accessTokenProvider = accessTokenProvider
+ self.authorizationDelegate = authorizationDelegate
+ self.proactiveRefreshWindowSeconds = proactiveRefreshWindowSeconds
+ }
+}
+
+extension OAuthConfiguration.TokenEndpointAuthentication {
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ switch (lhs, rhs) {
+ case (.clientSecretBasic(let li, let ls), .clientSecretBasic(let ri, let rs)):
+ return li == ri && ls == rs
+ case (.clientSecretPost(let li, let ls), .clientSecretPost(let ri, let rs)):
+ return li == ri && ls == rs
+ case (.none(let li), .none(let ri)):
+ return li == ri
+ case (.privateKeyJWT(let li, _), .privateKeyJWT(let ri, _)):
+ return li == ri
+ default:
+ return false
+ }
+ }
+}
+
+/// Delegate that handles user-facing authorization steps for the authorization_code flow.
+public protocol OAuthAuthorizationDelegate: Sendable {
+ /// Presents the authorization URL to the user and returns the redirect URL containing
+ /// the authorization code.
+ func presentAuthorizationURL(_ url: URL) async throws -> URL
+}
diff --git a/Sources/MCP/Base/Authorization/OAuthConstants.swift b/Sources/MCP/Base/Authorization/OAuthConstants.swift
new file mode 100644
index 0000000..73cde0f
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthConstants.swift
@@ -0,0 +1,139 @@
+import Foundation
+
+// MARK: - OAuth Grant Types
+
+enum OAuthGrantTypeValue {
+ static let clientCredentials = "client_credentials"
+ static let authorizationCode = "authorization_code"
+ static let refreshToken = "refresh_token"
+}
+
+// MARK: - OAuth Parameter Names
+
+enum OAuthParameterName {
+ static let grantType = "grant_type"
+ static let resource = "resource"
+ static let scope = "scope"
+ static let code = "code"
+ static let codeVerifier = "code_verifier"
+ static let codeChallenge = "code_challenge"
+ static let codeChallengeMethod = "code_challenge_method"
+ static let redirectURI = "redirect_uri"
+ static let responseType = "response_type"
+ static let clientID = "client_id"
+ static let clientSecret = "client_secret"
+ static let clientAssertion = "client_assertion"
+ static let clientAssertionType = "client_assertion_type"
+ static let refreshToken = "refresh_token"
+ static let state = "state"
+}
+
+// MARK: - OAuth Well-Known Paths
+
+enum OAuthWellKnownPath {
+ static let protectedResource = "/.well-known/oauth-protected-resource"
+ static let authorizationServer = "/.well-known/oauth-authorization-server"
+ static let openIDConfiguration = "/.well-known/openid-configuration"
+}
+
+// MARK: - OAuth Token Type
+
+enum OAuthTokenType {
+ static let bearer = "Bearer"
+}
+
+// MARK: - OAuth Code Challenge Method
+
+enum OAuthCodeChallengeMethod {
+ static let s256 = "S256"
+}
+
+// MARK: - OAuth Token Endpoint Auth Method
+
+enum OAuthTokenEndpointAuthMethod {
+ static let clientSecretBasic = "client_secret_basic"
+ static let clientSecretPost = "client_secret_post"
+ static let none = "none"
+ static let privateKeyJWT = "private_key_jwt"
+}
+
+// MARK: - URL Scheme
+
+enum OAuthURLScheme {
+ static let http = "http"
+ static let https = "https"
+}
+
+// MARK: - Default Ports
+
+enum OAuthDefaultPort {
+ static let http = 80
+ static let https = 443
+}
+
+// MARK: - Loopback Hosts
+
+enum OAuthLoopbackHost {
+ static let localhost = "localhost"
+ static let ipv4 = "127.0.0.1"
+ static let ipv6 = "::1"
+
+ static func isLoopback(_ host: String) -> Bool {
+ host == localhost || host == ipv4 || host == ipv6
+ }
+}
+
+// MARK: - Token Expiry Skew
+
+/// Clock skew tolerance applied when checking token expiry.
+///
+/// ``OAuthAccessToken/isExpired(now:skewSeconds:)`` treats a token as expired
+/// when `now + skewSeconds >= expiresAt`, giving the client a safety margin to
+/// refresh tokens before they actually expire on the server.
+public enum OAuthTokenExpirySkew {
+ /// Default clock skew buffer: 30 seconds.
+ public static let defaultSeconds: TimeInterval = 30
+}
+
+// MARK: - JWT Claim Names
+
+enum JWTClaimName {
+ static let algorithm = "alg"
+ static let type = "typ"
+ static let typeValue = "JWT"
+ static let issuer = "iss"
+ static let subject = "sub"
+ static let audience = "aud"
+ static let issuedAt = "iat"
+ static let expiration = "exp"
+ static let jwtID = "jti"
+}
+
+// MARK: - Client Assertion Type
+
+enum OAuthClientAssertionType {
+ static let jwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+}
+
+// MARK: - HTTPHeaderName Extensions
+
+extension HTTPHeaderName {
+ static let location = "Location"
+}
+
+// MARK: - ContentType Extensions
+
+extension ContentType {
+ static let formURLEncoded = "application/x-www-form-urlencoded"
+}
+
+// MARK: - Data Base64URL Extension
+
+extension Data {
+ func base64URLEncodedString() -> String {
+ base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
diff --git a/Sources/MCP/Base/Authorization/OAuthDiscovery.swift b/Sources/MCP/Base/Authorization/OAuthDiscovery.swift
new file mode 100644
index 0000000..332a898
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthDiscovery.swift
@@ -0,0 +1,361 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+// MARK: - Scope Selection Protocol
+
+/// Determines which OAuth scopes to request during token acquisition.
+///
+/// ``OAuthAuthorizer`` uses this protocol to translate raw scope strings from
+/// `WWW-Authenticate` challenges and Protected Resource Metadata into the
+/// scope set passed to the token endpoint.
+///
+/// Override the default ``DefaultOAuthScopeSelector`` to apply custom scope
+/// filtering or transformation logic.
+public protocol OAuthScopeSelecting {
+ /// Selects the scope set to request for a token.
+ ///
+ /// Priority order (highest first):
+ /// 1. `challengeScope` — the `scope` parameter from the `WWW-Authenticate` header.
+ /// 2. `scopesSupported` — the `scopes_supported` array from Protected Resource Metadata.
+ /// 3. `nil` — no scope restriction.
+ ///
+ /// - Parameters:
+ /// - challengeScope: Space-separated scope string from the Bearer challenge, or `nil`.
+ /// - scopesSupported: Array of supported scopes from the resource metadata, or `nil`.
+ /// - Returns: The set of scopes to request, or `nil` to omit the `scope` parameter entirely.
+ func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set?
+
+ /// Parses a space-separated OAuth scope string into individual scope tokens.
+ /// - Parameter scope: A scope string such as `"read write"`.
+ /// - Returns: A set of individual scope strings with whitespace-only tokens removed.
+ func parseScopeString(_ scope: String) -> Set
+
+ /// Serializes a set of scopes into a space-separated string suitable for the `scope` parameter.
+ /// - Parameter scopes: The scope set to serialize.
+ /// - Returns: A sorted, space-separated string, or `nil` if the set is empty.
+ func serialize(_ scopes: Set) -> String?
+}
+
+// MARK: - Default Scope Selector
+
+/// Default ``OAuthScopeSelecting`` implementation.
+///
+/// Selects scopes in priority order: challenge scope > `scopes_supported` > `nil`.
+/// Serializes scopes sorted alphabetically to produce deterministic `scope` parameters.
+public struct DefaultOAuthScopeSelector: OAuthScopeSelecting {
+ public init() {}
+
+ public func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set? {
+ if let challengeScope {
+ let parsed = parseScopeString(challengeScope)
+ return parsed.isEmpty ? nil : parsed
+ }
+
+ if let scopesSupported {
+ let parsed = Set(scopesSupported.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
+ return parsed.isEmpty ? nil : parsed
+ }
+
+ return nil
+ }
+
+ public func parseScopeString(_ scope: String) -> Set {
+ Set(
+ scope
+ .split(whereSeparator: { $0.isWhitespace })
+ .map(String.init)
+ .filter { !$0.isEmpty }
+ )
+ }
+
+ public func serialize(_ scopes: Set) -> String? {
+ guard !scopes.isEmpty else { return nil }
+ return scopes.sorted().joined(separator: " ")
+ }
+}
+
+// MARK: - Metadata Discovery Protocol
+
+/// Builds discovery URLs and normalizes resource identifiers for OAuth metadata discovery.
+///
+/// ``OAuthAuthorizer`` uses this protocol to construct the candidate URL list for both
+/// Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414 / OIDC)
+/// discovery, and to normalize resource URIs for RFC 8707 audience binding.
+///
+/// Override the default ``DefaultOAuthMetadataDiscovery`` to customise discovery URL
+/// construction or resource-matching logic.
+public protocol OAuthMetadataDiscovering: Sendable {
+ /// Returns candidate URLs for Protected Resource Metadata, ordered by priority.
+ ///
+ /// ``OAuthAuthorizer`` tries each URL in order and uses the first successful response.
+ ///
+ /// - Parameter endpoint: The MCP endpoint URL.
+ /// - Returns: An ordered list of discovery URLs (typically `/.well-known/oauth-protected-resource`
+ /// variants).
+ func protectedResourceMetadataURLs(for endpoint: URL) -> [URL]
+
+ /// Returns candidate URLs for Authorization Server Metadata, ordered by priority.
+ ///
+ /// Covers RFC 8414 (`/.well-known/oauth-authorization-server`) and
+ /// OIDC Discovery 1.0 (`/.well-known/openid-configuration`), including
+ /// path-inserted variants for issuers with non-root paths.
+ ///
+ /// - Parameter issuer: The authorization server issuer URL.
+ /// - Returns: An ordered list of metadata discovery URLs.
+ func authorizationServerMetadataURLs(for issuer: URL) -> [URL]
+
+ /// Derives the canonical RFC 8707 resource URI from an endpoint URL.
+ ///
+ /// The canonical form strips the query string, fragment, and trailing slash
+ /// while preserving the scheme, host, port, and path.
+ ///
+ /// - Parameter endpoint: The MCP endpoint URL.
+ /// - Returns: The canonical resource URI.
+ /// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL does not
+ /// satisfy the HTTPS-or-loopback-HTTP requirement.
+ func canonicalResourceURI(from endpoint: URL) throws -> URL
+
+ /// Derives a fallback authorization server issuer URL from an endpoint URL.
+ ///
+ /// Used when no authorization server is listed in Protected Resource Metadata.
+ /// Typically returns the scheme+host+port of the endpoint with an empty path.
+ ///
+ /// - Parameter endpoint: The MCP endpoint URL.
+ /// - Returns: A candidate issuer URL.
+ /// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL is invalid.
+ func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL
+
+ /// Returns `true` if `resource` is a prefix of `endpoint` in the URL hierarchy.
+ ///
+ /// A resource matches an endpoint when the two share the same scheme, host, and port,
+ /// and the endpoint path starts with the resource path.
+ ///
+ /// - Parameters:
+ /// - resource: The canonical resource URI from Protected Resource Metadata.
+ /// - endpoint: The canonical endpoint URI being requested.
+ /// - Returns: `true` if the endpoint falls within the resource's scope.
+ func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool
+}
+
+// MARK: - Default Metadata Discovery
+
+/// Default ``OAuthMetadataDiscovering`` implementation following RFC 9728 and RFC 8414.
+///
+/// - Builds `/.well-known/oauth-protected-resource` URLs with and without the endpoint path suffix.
+/// - Builds RFC 8414 and OIDC discovery URLs with path-inserted and path-appended variants.
+/// - Canonicalises resource URIs by stripping query, fragment, and trailing slash.
+/// - Matches resources using scheme/host/port equality and path prefix rules.
+public struct DefaultOAuthMetadataDiscovery: OAuthMetadataDiscovering {
+ public init() {}
+
+ public func protectedResourceMetadataURLs(for endpoint: URL) -> [URL] {
+ guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ Self.isSecureOAuthScheme(scheme: scheme, host: host)
+ else {
+ return []
+ }
+
+ components.query = nil
+ components.fragment = nil
+
+ let endpointPath = components.path
+ let normalizedPath = endpointPath == "/" ? "" : endpointPath
+
+ var urls: [URL] = []
+
+ var pathSpecific = components
+ pathSpecific.path = "\(OAuthWellKnownPath.protectedResource)\(normalizedPath)"
+ if let url = pathSpecific.url {
+ urls.append(url)
+ }
+
+ var root = components
+ root.path = OAuthWellKnownPath.protectedResource
+ if let url = root.url {
+ urls.append(url)
+ }
+
+ return urls
+ }
+
+ public func authorizationServerMetadataURLs(for issuer: URL) -> [URL] {
+ guard var components = URLComponents(url: issuer, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ Self.isSecureOAuthScheme(scheme: scheme, host: host)
+ else {
+ return []
+ }
+
+ components.query = nil
+ components.fragment = nil
+
+ let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ let hasPath = !path.isEmpty
+
+ var urls: [URL] = []
+
+ if hasPath {
+ var oauthInserted = components
+ oauthInserted.path = "\(OAuthWellKnownPath.authorizationServer)/\(path)"
+ if let url = oauthInserted.url {
+ urls.append(url)
+ }
+
+ var oidcInserted = components
+ oidcInserted.path = "\(OAuthWellKnownPath.openIDConfiguration)/\(path)"
+ if let url = oidcInserted.url {
+ urls.append(url)
+ }
+
+ var oidcAppended = components
+ oidcAppended.path = "/\(path)\(OAuthWellKnownPath.openIDConfiguration)"
+ if let url = oidcAppended.url {
+ urls.append(url)
+ }
+ } else {
+ var oauth = components
+ oauth.path = OAuthWellKnownPath.authorizationServer
+ if let url = oauth.url {
+ urls.append(url)
+ }
+
+ var oidc = components
+ oidc.path = OAuthWellKnownPath.openIDConfiguration
+ if let url = oidc.url {
+ urls.append(url)
+ }
+ }
+
+ return urls
+ }
+
+ public func canonicalResourceURI(from endpoint: URL) throws -> URL {
+ guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ Self.isSecureOAuthScheme(scheme: scheme, host: host)
+ else {
+ throw OAuthAuthorizationError.invalidResourceURI(
+ "Resource URI must use https or loopback http"
+ )
+ }
+
+ if components.fragment != nil {
+ throw OAuthAuthorizationError.invalidResourceURI("Resource URI must not contain a fragment")
+ }
+
+ components.scheme = scheme
+ components.host = host
+ components.query = nil
+ components.fragment = nil
+
+ if components.path == "/" {
+ components.path = ""
+ }
+
+ guard let url = components.url else {
+ throw OAuthAuthorizationError.invalidResourceURI("Failed to normalize resource URI")
+ }
+
+ return url
+ }
+
+ public func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL {
+ guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ Self.isSecureOAuthScheme(scheme: scheme, host: host)
+ else {
+ throw OAuthAuthorizationError.invalidResourceURI(
+ "Resource URI must use https or loopback http"
+ )
+ }
+
+ components.scheme = scheme
+ components.host = host
+ components.path = ""
+ components.query = nil
+ components.fragment = nil
+
+ guard let url = components.url else {
+ throw OAuthAuthorizationError.invalidResourceURI("Failed to derive issuer URI")
+ }
+ return url
+ }
+
+ public func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool {
+ guard let resourceComponents = URLComponents(
+ url: resource,
+ resolvingAgainstBaseURL: false
+ ),
+ let endpointComponents = URLComponents(
+ url: endpoint,
+ resolvingAgainstBaseURL: false
+ )
+ else {
+ return false
+ }
+
+ let resourceScheme = resourceComponents.scheme?.lowercased()
+ let endpointScheme = endpointComponents.scheme?.lowercased()
+ let resourceHost = resourceComponents.host?.lowercased()
+ let endpointHost = endpointComponents.host?.lowercased()
+ let resourcePort = resourceComponents.port ?? Self.defaultPort(for: resourceScheme)
+ let endpointPort = endpointComponents.port ?? Self.defaultPort(for: endpointScheme)
+
+ guard resourceScheme == endpointScheme,
+ resourceHost == endpointHost,
+ resourcePort == endpointPort
+ else {
+ return false
+ }
+
+ let resourcePath = Self.normalizedResourcePath(resourceComponents.path)
+ let endpointPath = Self.normalizedResourcePath(endpointComponents.path)
+ if resourcePath.isEmpty {
+ return true
+ }
+ if endpointPath == resourcePath {
+ return true
+ }
+ return endpointPath.hasPrefix(resourcePath + "/")
+ }
+
+ private static func normalizedResourcePath(_ rawPath: String) -> String {
+ if rawPath.isEmpty || rawPath == "/" {
+ return ""
+ }
+ if rawPath.count > 1 && rawPath.hasSuffix("/") {
+ return String(rawPath.dropLast())
+ }
+ return rawPath
+ }
+
+ private static func defaultPort(for scheme: String?) -> Int? {
+ switch scheme?.lowercased() {
+ case OAuthURLScheme.http:
+ return OAuthDefaultPort.http
+ case OAuthURLScheme.https:
+ return OAuthDefaultPort.https
+ default:
+ return nil
+ }
+ }
+
+ static func isSecureOAuthScheme(scheme: String, host: String) -> Bool {
+ if scheme == OAuthURLScheme.https {
+ return true
+ }
+ if scheme == OAuthURLScheme.http {
+ return OAuthLoopbackHost.isLoopback(host)
+ }
+ return false
+ }
+}
+
diff --git a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
new file mode 100644
index 0000000..8795ed7
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
@@ -0,0 +1,142 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+/// Internal protocol for fetching OAuth discovery metadata.
+protocol OAuthDiscoveryFetching: Sendable {
+ var metadataDiscovery: any OAuthMetadataDiscovering { get }
+ func fetchProtectedResourceMetadata(candidates: [URL], fallbackIssuer: URL?, session: URLSession) async throws -> OAuthProtectedResourceMetadata
+ func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata)
+}
+
+/// Stateless OAuth metadata fetcher.
+///
+/// Fetches Protected Resource Metadata (RFC 9728) and Authorization Server Metadata
+/// (RFC 8414 / OIDC Discovery 1.0) from ordered candidate URL lists.
+/// Cache management is the caller's responsibility.
+struct OAuthDiscoveryClient: Sendable {
+ let metadataDiscovery: any OAuthMetadataDiscovering
+ let urlValidator: OAuthURLValidator
+
+ init(
+ metadataDiscovery: any OAuthMetadataDiscovering,
+ urlValidator: OAuthURLValidator
+ ) {
+ self.metadataDiscovery = metadataDiscovery
+ self.urlValidator = urlValidator
+ }
+
+ /// Fetches Protected Resource Metadata from the first candidate that returns a valid response.
+ ///
+ /// If all candidates fail and `fallbackIssuer` is provided, returns synthetic metadata
+ /// using that issuer as the authorization server — for servers that do not expose a
+ /// PRM document at any well-known path.
+ func fetchProtectedResourceMetadata(
+ candidates: [URL],
+ fallbackIssuer: URL?,
+ session: URLSession
+ ) async throws -> OAuthProtectedResourceMetadata {
+ let decoder = JSONDecoder()
+ for url in candidates {
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
+
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse,
+ (200..<300).contains(httpResponse.statusCode)
+ else {
+ continue
+ }
+
+ let metadata = try decoder.decode(OAuthProtectedResourceMetadata.self, from: data)
+ guard !metadata.authorizationServers.isEmpty else { continue }
+ return metadata
+ } catch let error as OAuthAuthorizationError {
+ throw error
+ } catch {
+ continue
+ }
+ }
+ if let fallbackIssuer {
+ return OAuthProtectedResourceMetadata(
+ resource: nil,
+ authorizationServers: [fallbackIssuer],
+ scopesSupported: nil
+ )
+ }
+ throw OAuthAuthorizationError.metadataDiscoveryFailed
+ }
+
+ /// Fetches Authorization Server Metadata from candidates, preferring a response whose
+ /// `issuer` matches the candidate URL (RFC 8414 §3). If no candidate produces a matching
+ /// issuer, the first valid response is accepted and its own `issuer` is used as the
+ /// server identity — accommodating servers that serve metadata at one host but advertise
+ /// a different issuer.
+ func fetchAuthorizationServerMetadata(
+ candidates: [URL],
+ session: URLSession
+ ) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
+ let decoder = JSONDecoder()
+ var firstValid: (server: URL, metadata: OAuthAuthorizationServerMetadata)?
+
+ for candidateServer in candidates {
+ guard (try? urlValidator.validateAuthorizationServer(
+ candidateServer, context: "Authorization server issuer")) != nil
+ else {
+ continue
+ }
+ if let host = URLComponents(url: candidateServer, resolvingAgainstBaseURL: false)?
+ .host?.lowercased(), urlValidator.isPrivateIPHost(host)
+ {
+ continue
+ }
+
+ for metadataURL in metadataDiscovery.authorizationServerMetadataURLs(
+ for: candidateServer)
+ {
+ var request = URLRequest(url: metadataURL)
+ request.httpMethod = "GET"
+ request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
+
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse,
+ (200..<300).contains(httpResponse.statusCode)
+ else {
+ continue
+ }
+
+ let asMetadata = try decoder.decode(
+ OAuthAuthorizationServerMetadata.self, from: data)
+
+ // Prefer metadata whose issuer matches the candidate URL (RFC 8414 §3).
+ let issuerMatches =
+ asMetadata.issuer == nil
+ || asMetadata.issuer?.absoluteString.lowercased()
+ == candidateServer.absoluteString.lowercased()
+ if issuerMatches {
+ return (server: candidateServer, metadata: asMetadata)
+ }
+ // Keep as fallback in case no issuer-matching candidate is found.
+ if firstValid == nil {
+ let server = asMetadata.issuer ?? candidateServer
+ firstValid = (server: server, metadata: asMetadata)
+ }
+ } catch {
+ continue
+ }
+ }
+ }
+
+ if let firstValid {
+ return firstValid
+ }
+ throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
+ }
+}
+
+extension OAuthDiscoveryClient: OAuthDiscoveryFetching {}
diff --git a/Sources/MCP/Base/Authorization/OAuthErrors.swift b/Sources/MCP/Base/Authorization/OAuthErrors.swift
new file mode 100644
index 0000000..2f8249c
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthErrors.swift
@@ -0,0 +1,203 @@
+import Foundation
+
+/// Errors thrown during OAuth 2.1 authorization by ``OAuthAuthorizer``.
+///
+/// These errors surface when the authorizer is unable to complete the authorization flow,
+/// either due to discovery failures, token exchange problems, security policy violations,
+/// or authorization code flow issues.
+public enum OAuthAuthorizationError: LocalizedError {
+ /// No authorization server URL could be found in the Protected Resource Metadata.
+ case missingAuthorizationServer
+
+ /// All Protected Resource Metadata discovery candidates returned errors or invalid documents.
+ case metadataDiscoveryFailed
+
+ /// All Authorization Server Metadata discovery candidates (RFC 8414 / OIDC) returned errors.
+ case authorizationServerMetadataDiscoveryFailed
+
+ /// The authorization server metadata does not include a `token_endpoint`.
+ case tokenEndpointMissing
+
+ /// The token endpoint returned a non-2xx HTTP response.
+ ///
+ /// - Parameters:
+ /// - statusCode: HTTP status code from the token endpoint.
+ /// - oauthError: The `error` field from the OAuth error response body, if present.
+ case tokenRequestFailed(statusCode: Int, oauthError: String?)
+
+ /// The token response body is missing `access_token`, has an empty token, or specifies
+ /// a non-Bearer token type.
+ case tokenResponseInvalid
+
+ /// A URL that must be a valid resource identifier (RFC 8707) failed validation.
+ case invalidResourceURI(String)
+
+ /// The configured client ID looks like a URL but is not a valid HTTPS URL with a path,
+ /// which is required for Client ID Metadata Documents.
+ case invalidClientIDMetadataURL(String)
+
+ /// The `resource` field in the Protected Resource Metadata does not match the requested endpoint.
+ ///
+ /// - Parameters:
+ /// - expected: The canonical URI derived from the endpoint.
+ /// - actual: The canonical URI derived from the metadata's `resource` field.
+ case protectedResourceMismatch(expected: String, actual: String)
+
+ /// The authorization server does not support dynamic registration and no pre-registered
+ /// credentials were supplied.
+ case registrationInformationRequired
+
+ /// The authorization server does not support Client ID Metadata Documents and no dynamic
+ /// registration endpoint is available.
+ ///
+ /// - Parameter clientID: The client ID URL that was provided as a CIMD URL.
+ case cimdNotSupported(clientID: String)
+
+ /// A URL received from a discovery response resolves to a private or reserved IP address,
+ /// which is blocked to prevent SSRF attacks.
+ ///
+ /// - Parameters:
+ /// - context: Human-readable label identifying which URL was blocked.
+ /// - url: The blocked URL string.
+ case privateIPAddressBlocked(context: String, url: String)
+
+ /// An endpoint URL used during the OAuth flow does not satisfy the HTTPS-or-loopback requirement.
+ ///
+ /// - Parameters:
+ /// - context: Human-readable label identifying which endpoint failed (e.g., `"Token endpoint"`).
+ /// - url: The offending URL string.
+ case insecureOAuthEndpoint(context: String, url: String)
+
+ /// An authorization server endpoint does not satisfy the HTTPS-only requirement.
+ ///
+ /// - Parameters:
+ /// - context: Human-readable label identifying which endpoint failed.
+ /// - url: The offending URL string.
+ case insecureAuthorizationServerEndpoint(context: String, url: String)
+
+ /// The redirect URI supplied for the `authorization_code` flow is not a valid HTTPS or
+ /// loopback HTTP URI, or it contains a fragment.
+ case invalidRedirectURI(String)
+
+ /// The authorization endpoint returned an HTTP error response during the authorization code flow.
+ ///
+ /// - Parameter statusCode: HTTP status code from the authorization endpoint.
+ case authorizationRequestFailed(statusCode: Int)
+
+ /// The authorization response did not include a `Location` redirect header.
+ case authorizationResponseMissingRedirectLocation
+
+ /// The redirect URI in the authorization response does not match the expected redirect URI.
+ ///
+ /// - Parameters:
+ /// - expected: The redirect URI supplied in the authorization request.
+ /// - actual: The redirect URI received in the authorization response.
+ case authorizationResponseRedirectMismatch(expected: String, actual: String)
+
+ /// The authorization response redirect URL is missing the `state` parameter.
+ case authorizationResponseMissingState
+
+ /// The `state` in the authorization response does not match the one sent in the request.
+ ///
+ /// This may indicate a CSRF attack.
+ ///
+ /// - Parameters:
+ /// - expected: The `state` value sent in the authorization request.
+ /// - actual: The `state` value received in the authorization response.
+ case authorizationResponseStateMismatch(expected: String, actual: String)
+
+ /// The authorization response redirect URL is missing the `code` parameter.
+ case authorizationResponseMissingCode
+
+ /// The authorization server metadata does not include `code_challenge_methods_supported`,
+ /// which is required for PKCE (RFC 7636).
+ case pkceCodeChallengeMethodsMissing
+
+ /// The authorization server does not advertise `S256` in `code_challenge_methods_supported`.
+ ///
+ /// The MCP specification mandates S256; plain PKCE is not accepted.
+ ///
+ /// - Parameter advertisedMethods: The methods listed in the server metadata.
+ case pkceS256NotSupported(advertisedMethods: [String])
+
+ /// PKCE S256 challenge generation is unavailable because CryptoKit is not present on this platform.
+ case pkceS256Unavailable
+
+ /// The `issuer` field in the Authorization Server Metadata does not match the URL used to
+ /// resolve the metadata document, as required by RFC 8414 §3.
+ ///
+ /// - Parameters:
+ /// - expected: The issuer URL derived from the discovery candidate.
+ /// - actual: The `issuer` field value found in the metadata document.
+ case authorizationServerIssuerMismatch(expected: String, actual: String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingAuthorizationServer:
+ return "No authorization server was found in protected resource metadata"
+ case .metadataDiscoveryFailed:
+ return "Failed to discover protected resource metadata"
+ case .authorizationServerMetadataDiscoveryFailed:
+ return "Failed to discover authorization server metadata"
+ case .tokenEndpointMissing:
+ return "Authorization server metadata is missing token_endpoint"
+ case .tokenRequestFailed(let statusCode, let oauthError):
+ if let oauthError, !oauthError.isEmpty {
+ return "Token request failed with status \(statusCode) (oauth_error: \(oauthError))"
+ }
+ return "Token request failed with status \(statusCode)"
+ case .tokenResponseInvalid:
+ return "Token response is invalid"
+ case .invalidResourceURI(let detail):
+ return "Invalid resource URI: \(detail)"
+ case .invalidClientIDMetadataURL(let value):
+ return
+ "Client ID metadata document URL must use https and include a path: \(value)"
+ case .protectedResourceMismatch(let expected, let actual):
+ return
+ "Protected resource metadata resource mismatch. Expected \(expected), got \(actual)"
+ case .registrationInformationRequired:
+ return
+ "No supported client registration mechanism was available; provide pre-registered client credentials"
+ case .cimdNotSupported(let clientID):
+ return
+ "Authorization server does not support Client ID Metadata Documents; configure pre-registered credentials or ensure the server advertises client_id_metadata_document_supported: \(clientID)"
+ case .privateIPAddressBlocked(let context, let url):
+ return
+ "\(context) resolves to a private or reserved IP address which is blocked for SSRF protection: \(url)"
+ case .insecureOAuthEndpoint(let context, let url):
+ return "\(context) must use https or loopback http: \(url)"
+ case .insecureAuthorizationServerEndpoint(let context, let url):
+ return "\(context) must use https: \(url)"
+ case .invalidRedirectURI(let url):
+ return
+ "Redirect URI must use https or loopback http and must not include fragments: \(url)"
+ case .authorizationRequestFailed(let statusCode):
+ return "Authorization request failed with status \(statusCode)"
+ case .authorizationResponseMissingRedirectLocation:
+ return "Authorization response is missing redirect location"
+ case .authorizationResponseRedirectMismatch(let expected, let actual):
+ return
+ "Authorization response redirect URI mismatch. Expected \(expected), got \(actual)"
+ case .authorizationResponseMissingState:
+ return "Authorization response is missing state"
+ case .authorizationResponseStateMismatch(let expected, let actual):
+ return "Authorization response state mismatch. Expected \(expected), got \(actual)"
+ case .authorizationResponseMissingCode:
+ return "Authorization response is missing the authorization code"
+ case .pkceCodeChallengeMethodsMissing:
+ return
+ "Authorization server metadata must include code_challenge_methods_supported for PKCE"
+ case .pkceS256NotSupported(let advertisedMethods):
+ let methods = advertisedMethods.joined(separator: ", ")
+ return
+ "Authorization server metadata must support PKCE S256 (advertised: \(methods))"
+ case .pkceS256Unavailable:
+ return
+ "PKCE S256 code challenge generation is unavailable on this platform"
+ case .authorizationServerIssuerMismatch(let expected, let actual):
+ return
+ "Authorization server issuer mismatch. Expected \(expected), got \(actual)"
+ }
+ }
+}
diff --git a/Sources/MCP/Base/Authorization/OAuthModels.swift b/Sources/MCP/Base/Authorization/OAuthModels.swift
new file mode 100644
index 0000000..28043b9
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthModels.swift
@@ -0,0 +1,257 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+struct HTTPAuthenticationChallengeError: Error {
+ let statusCode: Int
+ let headers: [String: String]
+}
+
+/// Parsed representation of a `WWW-Authenticate: Bearer` challenge header.
+///
+/// Servers return this challenge in `401 Unauthorized` and `403 Forbidden` responses
+/// to indicate that a Bearer token is required or that the presented token lacks
+/// sufficient scope.
+///
+/// The ``OAuthWWWAuthenticateParsing`` protocol produces instances of this type.
+public struct OAuthBearerChallenge: Sendable {
+ /// Raw key-value parameters extracted from the `Bearer` challenge.
+ public let parameters: [String: String]
+
+ /// Creates a challenge from raw parsed parameters.
+ /// - Parameter parameters: Key-value pairs from the `WWW-Authenticate` header.
+ public init(parameters: [String: String]) {
+ self.parameters = parameters
+ }
+
+ /// The `resource_metadata` parameter, parsed as a URL.
+ ///
+ /// Points to the server's RFC 9728 Protected Resource Metadata document.
+ /// When present, ``OAuthAuthorizer`` uses this URL as the highest-priority
+ /// discovery candidate.
+ public var resourceMetadataURL: URL? {
+ guard let value = parameters["resource_metadata"] else { return nil }
+ return URL(string: value)
+ }
+
+ /// The `scope` parameter from the Bearer challenge.
+ ///
+ /// Specifies the scopes required or recommended for this resource.
+ /// ``OAuthAuthorizer`` uses this value as the highest-priority scope hint.
+ public var scope: String? {
+ parameters["scope"]
+ }
+
+ /// The `error` parameter from the Bearer challenge (e.g., `"invalid_token"`, `"insufficient_scope"`).
+ public var error: String? {
+ parameters["error"]
+ }
+
+ /// The `error_description` parameter from the Bearer challenge.
+ public var errorDescription: String? {
+ parameters["error_description"]
+ }
+}
+
+/// RFC9728 OAuth Protected Resource metadata (client-side, decode-only).
+struct OAuthProtectedResourceMetadata: Decodable, Sendable, Equatable {
+ let resource: String?
+ let authorizationServers: [URL]
+ let scopesSupported: [String]?
+
+ enum CodingKeys: String, CodingKey {
+ case resource
+ case authorizationServers = "authorization_servers"
+ case scopesSupported = "scopes_supported"
+ }
+}
+
+/// RFC8414/OIDC authorization server metadata.
+struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable {
+ let issuer: URL?
+ let authorizationEndpoint: URL?
+ let tokenEndpoint: URL?
+ let registrationEndpoint: URL?
+ let codeChallengeMethodsSupported: [String]?
+ let tokenEndpointAuthMethodsSupported: [String]?
+ let clientIDMetadataDocumentSupported: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case issuer
+ case authorizationEndpoint = "authorization_endpoint"
+ case tokenEndpoint = "token_endpoint"
+ case registrationEndpoint = "registration_endpoint"
+ case codeChallengeMethodsSupported = "code_challenge_methods_supported"
+ case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"
+ case clientIDMetadataDocumentSupported = "client_id_metadata_document_supported"
+ }
+}
+
+struct OAuthTokenResponse: Decodable, Sendable, Equatable {
+ let accessToken: String
+ let tokenType: String
+ let expiresIn: Int?
+ let scope: String?
+ let refreshToken: String?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case tokenType = "token_type"
+ case expiresIn = "expires_in"
+ case scope
+ case refreshToken = "refresh_token"
+ }
+}
+
+struct OAuthTokenErrorResponse: Decodable {
+ let error: String
+}
+
+/// An OAuth 2.1 access token and its associated metadata.
+///
+/// Stored by ``TokenStorage`` and produced by ``OAuthAuthorizer`` after a successful
+/// token request. Use ``isExpired(now:skewSeconds:)`` to check validity before use.
+public struct OAuthAccessToken: Sendable, Codable {
+ /// The raw bearer token string for use in the `Authorization` header.
+ public let value: String
+
+ /// The token type returned by the authorization server (should be `"Bearer"`).
+ public let tokenType: String
+
+ /// The UTC date after which the token is considered expired, or `nil` if no expiry was specified.
+ public let expiresAt: Date?
+
+ /// The set of OAuth scopes granted with this token.
+ public let scopes: Set
+
+ /// The issuer URL of the authorization server that issued this token.
+ ///
+ /// Used to detect when the active authorization server changes between requests,
+ /// triggering a token invalidation.
+ public let authorizationServer: URL?
+
+ /// The refresh token, if the authorization server issued one alongside the access token.
+ public let refreshToken: String?
+
+ /// The OAuth `client_id` this token was issued to, if the authorizer had one at storage time.
+ ///
+ /// Captures the `client_id` held by the authorizer's ``OAuthConfiguration/authentication`` when
+ /// the token was saved — including identifiers assigned by Dynamic Client Registration
+ /// (RFC 7591). `nil` when no `client_id` was configured (for example, the placeholder used
+ /// before registration completes).
+ public let clientID: String?
+
+ /// Creates a new access token record.
+ public init(
+ value: String,
+ tokenType: String,
+ expiresAt: Date?,
+ scopes: Set,
+ authorizationServer: URL?,
+ refreshToken: String?,
+ clientID: String? = nil
+ ) {
+ self.value = value
+ self.tokenType = tokenType
+ self.expiresAt = expiresAt
+ self.scopes = scopes
+ self.authorizationServer = authorizationServer
+ self.refreshToken = refreshToken
+ self.clientID = clientID
+ }
+
+ /// Returns `true` if the token has expired or will expire within the skew window.
+ ///
+ /// - Parameters:
+ /// - now: The reference time to compare against. Defaults to `Date()`.
+ /// - skewSeconds: Clock skew buffer in seconds. Defaults to ``OAuthTokenExpirySkew/defaultSeconds`` (30 s).
+ /// Tokens are considered expired when `now + skewSeconds >= expiresAt`.
+ /// - Returns: `false` if `expiresAt` is `nil` (no expiry).
+ public func isExpired(now: Date = Date(), skewSeconds: TimeInterval = OAuthTokenExpirySkew.defaultSeconds) -> Bool {
+ guard let expiresAt else { return false }
+ return now.addingTimeInterval(skewSeconds) >= expiresAt
+ }
+}
+
+/// Token introspection result passed from the caller's token validator to ``BearerTokenValidator``.
+///
+/// The validator uses this to enforce expiry and audience checks before allowing a request through.
+/// Produce an instance in your ``BearerTokenValidator/TokenValidator`` closure after verifying
+/// the token's signature and extracting its claims.
+public struct BearerTokenInfo: Sendable, Equatable {
+ /// Audience values from the token (`aud` JWT claim or introspection response).
+ ///
+ /// `nil` indicates an opaque token whose audience cannot be inspected;
+ /// ``BearerTokenValidator`` skips the audience check in that case.
+ public let audience: [String]?
+
+ /// Scopes granted by the token.
+ public let scopes: Set?
+
+ /// UTC date after which the token is considered expired.
+ ///
+ /// `nil` means no expiry information is available and the expiry check is skipped.
+ public let expiresAt: Date?
+
+ public init(
+ audience: [String]? = nil,
+ scopes: Set? = nil,
+ expiresAt: Date? = nil
+ ) {
+ self.audience = audience
+ self.scopes = scopes
+ self.expiresAt = expiresAt
+ }
+}
+
+struct OAuthClientRegistrationResponse: Decodable {
+ let clientID: String
+ let clientSecret: String?
+ let tokenEndpointAuthMethod: String?
+ /// Unix timestamp after which `clientSecret` is no longer valid, per RFC 7591 §3.2.
+ /// A value of `0` means the secret does not expire.
+ let clientSecretExpiresAt: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case clientID = "client_id"
+ case clientSecret = "client_secret"
+ case tokenEndpointAuthMethod = "token_endpoint_auth_method"
+ case clientSecretExpiresAt = "client_secret_expires_at"
+ }
+}
+
+/// Server-side encodable RFC 9728 Protected Resource Metadata.
+///
+/// Use this type to construct the metadata document that MCP servers **MUST** serve
+/// at `/.well-known/oauth-protected-resource` per the MCP authorization specification.
+///
+/// Pair with ``ProtectedResourceMetadataValidator`` to automatically serve this
+/// document in the server's validation pipeline.
+public struct OAuthProtectedResourceServerMetadata: Codable, Sendable {
+ /// The canonical resource identifier (RFC 8707).
+ public let resource: String
+
+ /// One or more authorization server URLs that protect this resource.
+ public let authorizationServers: [URL]
+
+ /// The scopes supported by this resource server.
+ public let scopesSupported: [String]?
+
+ enum CodingKeys: String, CodingKey {
+ case resource
+ case authorizationServers = "authorization_servers"
+ case scopesSupported = "scopes_supported"
+ }
+
+ public init(
+ resource: String,
+ authorizationServers: [URL],
+ scopesSupported: [String]? = nil
+ ) {
+ self.resource = resource
+ self.authorizationServers = authorizationServers
+ self.scopesSupported = scopesSupported
+ }
+}
diff --git a/Sources/MCP/Base/Authorization/OAuthTokenEndpointClient.swift b/Sources/MCP/Base/Authorization/OAuthTokenEndpointClient.swift
new file mode 100644
index 0000000..3c61600
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthTokenEndpointClient.swift
@@ -0,0 +1,90 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+/// Internal protocol for making OAuth token endpoint requests.
+protocol OAuthTokenRequesting: Sendable {
+ func request(
+ parameters: inout [String: String],
+ endpoint: URL,
+ authentication: OAuthConfiguration.TokenEndpointAuthentication,
+ session: URLSession
+ ) async throws -> OAuthTokenResponse
+}
+
+/// Stateless OAuth token endpoint HTTP client.
+///
+/// Handles the low-level HTTP mechanics of making token requests.
+struct OAuthTokenEndpointClient: Sendable {
+ let urlValidator: OAuthURLValidator
+
+ init(urlValidator: OAuthURLValidator) {
+ self.urlValidator = urlValidator
+ }
+
+ /// Makes a token request to the given endpoint.
+ func request(
+ parameters: inout [String: String],
+ endpoint: URL,
+ authentication: OAuthConfiguration.TokenEndpointAuthentication,
+ session: URLSession
+ ) async throws -> OAuthTokenResponse {
+ var urlRequest = URLRequest(url: endpoint)
+ urlRequest.httpMethod = "POST"
+ urlRequest.setValue(ContentType.formURLEncoded, forHTTPHeaderField: HTTPHeaderName.contentType)
+ urlRequest.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
+
+ try await authentication.apply(
+ to: &urlRequest,
+ bodyParameters: ¶meters,
+ tokenEndpoint: endpoint
+ )
+ urlRequest.httpBody = encodeForm(parameters)
+
+ let (data, response) = try await session.data(for: urlRequest)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw OAuthAuthorizationError.tokenRequestFailed(statusCode: -1, oauthError: nil)
+ }
+
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ let oauthError =
+ (try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
+ throw OAuthAuthorizationError.tokenRequestFailed(
+ statusCode: httpResponse.statusCode,
+ oauthError: oauthError
+ )
+ }
+
+ let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
+ guard !decoded.accessToken.isEmpty else {
+ throw OAuthAuthorizationError.tokenResponseInvalid
+ }
+ let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !tokenType.isEmpty,
+ tokenType.caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame
+ else {
+ throw OAuthAuthorizationError.tokenResponseInvalid
+ }
+ return decoded
+ }
+
+ private func encodeForm(_ params: [String: String]) -> Data {
+ let body = params
+ .sorted { $0.key < $1.key }
+ .map { key, value in "\(percentEncode(key))=\(percentEncode(value))" }
+ .joined(separator: "&")
+ return Data(body.utf8)
+ }
+
+ private func percentEncode(_ string: String) -> String {
+ let allowed = CharacterSet(
+ charactersIn:
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
+ )
+ return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
+ }
+}
+
+extension OAuthTokenEndpointClient: OAuthTokenRequesting {}
diff --git a/Sources/MCP/Base/Authorization/OAuthURLValidator.swift b/Sources/MCP/Base/Authorization/OAuthURLValidator.swift
new file mode 100644
index 0000000..ddc180c
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthURLValidator.swift
@@ -0,0 +1,135 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+/// Internal protocol for URL security validation in OAuth flows.
+protocol OAuthURLValidating: Sendable {
+ func validateHTTPSOrLoopback(_ url: URL, context: String) throws
+ func validateAuthorizationServer(_ url: URL, context: String) throws
+ func validateRedirectURI(_ url: URL) throws
+ func isPrivateIPHost(_ host: String) -> Bool
+}
+
+/// URL security rules for OAuth endpoints.
+///
+/// Validates that URLs used in the OAuth flow satisfy HTTPS requirements and SSRF protections.
+/// Configured once and shared across the discovery, token, and registration components.
+public struct OAuthURLValidator: Sendable {
+
+ /// When `true`, loopback HTTP URLs are accepted for authorization server endpoints.
+ ///
+ /// This is a package-level compatibility option for local test environments.
+ public let allowLoopbackHTTPForAuthorizationServer: Bool
+
+ public init(allowLoopbackHTTPForAuthorizationServer: Bool = false) {
+ self.allowLoopbackHTTPForAuthorizationServer = allowLoopbackHTTPForAuthorizationServer
+ }
+
+ /// Validates that the URL uses HTTPS or loopback HTTP, and has no fragment.
+ ///
+ /// Used for MCP endpoints and protected resource metadata URLs.
+ public func validateHTTPSOrLoopback(_ url: URL, context: String) throws {
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ !host.isEmpty,
+ components.fragment == nil
+ else {
+ throw OAuthAuthorizationError.invalidResourceURI(
+ "Invalid \(context): \(url.absoluteString)"
+ )
+ }
+
+ guard scheme == OAuthURLScheme.https
+ || (scheme == OAuthURLScheme.http && OAuthLoopbackHost.isLoopback(host))
+ else {
+ throw OAuthAuthorizationError.insecureOAuthEndpoint(
+ context: context,
+ url: url.absoluteString
+ )
+ }
+ }
+
+ /// Validates that the URL is an HTTPS authorization server endpoint.
+ ///
+ /// Loopback HTTP is permitted when `allowLoopbackHTTPForAuthorizationServer` is `true`.
+ public func validateAuthorizationServer(_ url: URL, context: String) throws {
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ let host = components.host?.lowercased(),
+ !host.isEmpty,
+ components.fragment == nil
+ else {
+ throw OAuthAuthorizationError.invalidResourceURI(
+ "Invalid \(context): \(url.absoluteString)"
+ )
+ }
+
+ if allowLoopbackHTTPForAuthorizationServer,
+ scheme == OAuthURLScheme.http,
+ OAuthLoopbackHost.isLoopback(host)
+ {
+ return
+ }
+
+ guard scheme == OAuthURLScheme.https else {
+ throw OAuthAuthorizationError.insecureAuthorizationServerEndpoint(
+ context: context,
+ url: url.absoluteString
+ )
+ }
+ }
+
+ /// Validates the redirect URI for authorization_code flows.
+ ///
+ /// Accepts HTTPS or loopback HTTP; rejects any URI containing a fragment.
+ public func validateRedirectURI(_ url: URL) throws {
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ components.fragment == nil
+ else {
+ throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
+ }
+
+ if scheme == OAuthURLScheme.https { return }
+
+ if scheme == OAuthURLScheme.http,
+ let host = components.host?.lowercased(),
+ OAuthLoopbackHost.isLoopback(host)
+ {
+ return
+ }
+
+ throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
+ }
+
+ /// Returns `true` if `host` is a literal IPv4 or IPv6 address in a private or reserved range.
+ ///
+ /// Blocked ranges:
+ /// - IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`
+ /// - IPv6: `fc00::/7` (ULA), `fe80::/10` (link-local)
+ ///
+ /// **Limitation**: only literal IP addresses are checked. DNS rebinding is not prevented here.
+ public func isPrivateIPHost(_ host: String) -> Bool {
+ let octets = host.split(separator: ".").compactMap { UInt8($0) }
+ if octets.count == 4 && !host.contains(":") {
+ let (a, b) = (octets[0], octets[1])
+ return a == 10 // 10.0.0.0/8
+ || (a == 172 && (16...31).contains(b)) // 172.16.0.0/12
+ || (a == 192 && b == 168) // 192.168.0.0/16
+ || (a == 169 && b == 254) // 169.254.0.0/16 (link-local / cloud metadata)
+ || (a == 100 && (64...127).contains(b)) // 100.64.0.0/10 (CGNAT)
+ }
+ let lower = host.lowercased()
+ if lower.hasPrefix("fc") || lower.hasPrefix("fd") { return true } // fc00::/7 (ULA)
+ if lower.hasPrefix("fe") && lower.count > 2 {
+ let idx = lower.index(lower.startIndex, offsetBy: 2)
+ if "89ab".contains(lower[idx]) { return true } // fe80::/10 (link-local)
+ }
+ return false
+ }
+}
+
+extension OAuthURLValidator: OAuthURLValidating {}
diff --git a/Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift b/Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift
new file mode 100644
index 0000000..51f3c6f
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift
@@ -0,0 +1,189 @@
+import Foundation
+
+// MARK: - WWW-Authenticate Parsing Protocol
+
+/// Parses the `WWW-Authenticate` response header to extract Bearer challenge parameters.
+///
+/// ``OAuthAuthorizer`` uses this protocol to inspect 401 and 403 responses from the server.
+/// Override the default ``DefaultOAuthWWWAuthenticateParser`` to handle custom challenge formats.
+public protocol OAuthWWWAuthenticateParsing {
+ /// Parses the `WWW-Authenticate` header from a response header dictionary.
+ ///
+ /// - Parameter headers: The full set of HTTP response headers (case-insensitive lookup is expected).
+ /// - Returns: An ``OAuthBearerChallenge`` if a `Bearer` scheme is found, or `nil` otherwise.
+ func parseBearer(from headers: [String: String]) -> OAuthBearerChallenge?
+}
+
+// MARK: - Default Implementation
+
+/// Default ``OAuthWWWAuthenticateParsing`` implementation.
+///
+/// Parses `WWW-Authenticate` headers following RFC 6750 §3, handling:
+/// - Multiple challenge schemes in a single header value (e.g., `Bearer …, Basic …`)
+/// - Quoted-string parameter values with backslash escaping
+/// - Case-insensitive scheme and parameter key matching
+public struct DefaultOAuthWWWAuthenticateParser: OAuthWWWAuthenticateParsing {
+ public init() {}
+ private let tokenCharacters = CharacterSet(
+ charactersIn: "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ )
+
+ public func parseBearer(from headers: [String: String]) -> OAuthBearerChallenge? {
+ guard let value = headers.first(where: {
+ $0.key.caseInsensitiveCompare(HTTPHeaderName.wwwAuthenticate) == .orderedSame
+ })?.value else {
+ return nil
+ }
+ return parseBearerHeader(value)
+ }
+
+ private func parseBearerHeader(_ header: String) -> OAuthBearerChallenge? {
+ let trimmed = header.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ guard let parametersPart = extractBearerParameters(from: trimmed) else { return nil }
+
+ if parametersPart.isEmpty {
+ return OAuthBearerChallenge(parameters: [:])
+ }
+
+ let components = splitParameters(parametersPart)
+ var parameters: [String: String] = [:]
+
+ for component in components {
+ let pair = component.split(separator: "=", maxSplits: 1)
+ guard pair.count == 2 else { continue }
+ let key = pair[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ var value = pair[1].trimmingCharacters(in: .whitespacesAndNewlines)
+ if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
+ value.removeFirst()
+ value.removeLast()
+ value = value.replacingOccurrences(of: "\\\"", with: "\"")
+ }
+ parameters[key] = value
+ }
+
+ return OAuthBearerChallenge(parameters: parameters)
+ }
+
+ private func extractBearerParameters(from header: String) -> String? {
+ let segments = splitParameters(header)
+
+ for index in segments.indices {
+ let segment = segments[index].trimmingCharacters(in: .whitespacesAndNewlines)
+ guard isBearerChallengeStart(segment) else { continue }
+
+ var parameters: [String] = []
+ let initial = stripBearerScheme(from: segment)
+ if !initial.isEmpty {
+ parameters.append(initial)
+ }
+
+ var nextIndex = segments.index(after: index)
+ while nextIndex < segments.endIndex {
+ let next = segments[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
+ if next.isEmpty {
+ nextIndex = segments.index(after: nextIndex)
+ continue
+ }
+ if startsNewChallenge(next) {
+ break
+ }
+ parameters.append(next)
+ nextIndex = segments.index(after: nextIndex)
+ }
+
+ return parameters.joined(separator: ",")
+ }
+
+ return nil
+ }
+
+ private func isBearerChallengeStart(_ segment: String) -> Bool {
+ guard segment.count >= OAuthTokenType.bearer.count else { return false }
+ let schemeEnd = segment.index(segment.startIndex, offsetBy: OAuthTokenType.bearer.count)
+ let scheme = segment[.. String {
+ guard segment.count > OAuthTokenType.bearer.count else { return "" }
+ let index = segment.index(segment.startIndex, offsetBy: OAuthTokenType.bearer.count)
+ return String(segment[index...]).trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private func startsNewChallenge(_ segment: String) -> Bool {
+ if isBearerChallengeStart(segment) {
+ return true
+ }
+
+ if let equalsIndex = segment.firstIndex(of: "=") {
+ let parameterName = String(segment[.. Bool {
+ guard !value.isEmpty else { return false }
+ return value.rangeOfCharacter(from: tokenCharacters.inverted) == nil
+ }
+
+ private func splitParameters(_ value: String) -> [String] {
+ var components: [String] = []
+ var current = ""
+ var inQuotes = false
+ var escaping = false
+
+ for character in value {
+ if escaping {
+ current.append(character)
+ escaping = false
+ continue
+ }
+ if character == "\\" {
+ current.append(character)
+ escaping = true
+ continue
+ }
+ if character == "\"" {
+ inQuotes.toggle()
+ current.append(character)
+ continue
+ }
+ if character == "," && !inQuotes {
+ components.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
+ current = ""
+ continue
+ }
+ current.append(character)
+ }
+
+ if !current.isEmpty {
+ components.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+
+ return components.filter { !$0.isEmpty }
+ }
+}
+
diff --git a/Sources/MCP/Base/Authorization/PKCE.swift b/Sources/MCP/Base/Authorization/PKCE.swift
new file mode 100644
index 0000000..4515454
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/PKCE.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+#if canImport(CryptoKit)
+ import CryptoKit
+#endif
+
+/// Pure PKCE (RFC 7636) helpers required by the authorization_code flow.
+public enum PKCE {
+
+ /// Generates a cryptographically random PKCE code verifier.
+ ///
+ /// - Parameter length: Number of characters in the verifier. Defaults to 64.
+ /// RFC 7636 requires 43–128 characters.
+ /// - Returns: A URL-safe random string suitable for use as a PKCE code verifier.
+ public static func makeVerifier(length: Int = 64) -> String {
+ let charset = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
+ // 66 characters; 256 % 66 == 52, so reject bytes > 252 to eliminate modulo bias.
+ let limit = UInt8(255 - (255 % charset.count)) // 252
+ var rng = SystemRandomNumberGenerator()
+ var result = ""
+ result.reserveCapacity(length)
+ while result.count < length {
+ let byte = UInt8.random(in: 0...255, using: &rng)
+ if byte <= limit {
+ result.append(charset[Int(byte % UInt8(charset.count))])
+ }
+ }
+ return result
+ }
+
+ /// Derives the PKCE S256 code challenge from a verifier.
+ ///
+ /// - Parameter verifier: A code verifier produced by ``makeVerifier(length:)``.
+ /// - Returns: The base64url-encoded SHA-256 hash of the verifier.
+ /// - Throws: ``OAuthAuthorizationError/pkceS256Unavailable`` on platforms without CryptoKit.
+ public static func makeChallenge(from verifier: String) throws -> String {
+ #if canImport(CryptoKit)
+ let hash = SHA256.hash(data: Data(verifier.utf8))
+ return Data(hash).base64URLEncodedString()
+ #else
+ throw OAuthAuthorizationError.pkceS256Unavailable
+ #endif
+ }
+
+ /// Verifies that the authorization server metadata advertises S256 PKCE support.
+ ///
+ /// - Parameter metadata: Authorization server metadata to inspect.
+ /// - Throws: ``OAuthAuthorizationError/pkceCodeChallengeMethodsMissing`` if
+ /// `code_challenge_methods_supported` is absent or empty, or
+ /// ``OAuthAuthorizationError/pkceS256NotSupported(advertisedMethods:)`` if S256 is not listed.
+ static func checkSupport(in metadata: OAuthAuthorizationServerMetadata) throws {
+ guard let methods = metadata.codeChallengeMethodsSupported, !methods.isEmpty else {
+ throw OAuthAuthorizationError.pkceCodeChallengeMethodsMissing
+ }
+ let supportsS256 = methods.contains {
+ $0.caseInsensitiveCompare(OAuthCodeChallengeMethod.s256) == .orderedSame
+ }
+ guard supportsS256 else {
+ throw OAuthAuthorizationError.pkceS256NotSupported(advertisedMethods: methods)
+ }
+ }
+}
diff --git a/Sources/MCP/Base/Authorization/TokenStorage.swift b/Sources/MCP/Base/Authorization/TokenStorage.swift
new file mode 100644
index 0000000..07a5e3c
--- /dev/null
+++ b/Sources/MCP/Base/Authorization/TokenStorage.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+// MARK: - Token Storage Protocol
+
+/// Abstraction for persisting OAuth access tokens.
+///
+/// Implement this protocol to provide custom token storage (e.g., Keychain-backed).
+/// The default ``InMemoryTokenStorage`` stores tokens in memory only.
+public protocol TokenStorage: AnyObject, Sendable {
+ func save(_ token: OAuthAccessToken)
+ func load() -> OAuthAccessToken?
+ func clear()
+}
+
+// MARK: - In-Memory Implementation
+
+/// Default ``TokenStorage`` that stores the access token in memory only.
+///
+/// The token is lost when the process exits. For persistent storage
+/// (e.g., system Keychain), implement ``TokenStorage`` directly.
+public final class InMemoryTokenStorage: TokenStorage, @unchecked Sendable {
+ private var token: OAuthAccessToken?
+
+ public init() {}
+
+ public func save(_ token: OAuthAccessToken) {
+ self.token = token
+ }
+
+ public func load() -> OAuthAccessToken? {
+ token
+ }
+
+ public func clear() {
+ token = nil
+ }
+}
diff --git a/Sources/MCP/Base/Error.swift b/Sources/MCP/Base/Error.swift
index 0c461a4..b7196f0 100644
--- a/Sources/MCP/Base/Error.swift
+++ b/Sources/MCP/Base/Error.swift
@@ -6,6 +6,25 @@ import Foundation
@preconcurrency import SystemPackage
#endif
+/// Information about a required URL elicitation
+public struct URLElicitationInfo: Codable, Hashable, Sendable {
+ /// Elicitation mode (must be "url")
+ public var mode: String
+ /// Unique identifier for this elicitation
+ public var elicitationId: String
+ /// URL for the user to visit
+ public var url: String
+ /// Message describing the elicitation
+ public var message: String
+
+ public init(mode: String = "url", elicitationId: String, url: String, message: String) {
+ self.mode = mode
+ self.elicitationId = elicitationId
+ self.url = url
+ self.message = message
+ }
+}
+
/// A model context protocol error.
public enum MCPError: Swift.Error, Sendable {
// Standard JSON-RPC 2.0 errors (-32700 to -32603)
@@ -18,6 +37,9 @@ public enum MCPError: Swift.Error, Sendable {
// Server errors (-32000 to -32099)
case serverError(code: Int, message: String)
+ // MCP specific errors
+ case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042
+
// Transport specific errors
case connectionClosed
case transportError(Swift.Error)
@@ -31,6 +53,7 @@ public enum MCPError: Swift.Error, Sendable {
case .invalidParams: return -32602
case .internalError: return -32603
case .serverError(let code, _): return code
+ case .urlElicitationRequired: return -32042
case .connectionClosed: return -32000
case .transportError: return -32001
}
@@ -68,6 +91,8 @@ extension MCPError: LocalizedError {
return "Internal error" + (detail.map { ": \($0)" } ?? "")
case .serverError(_, let message):
return "Server error: \(message)"
+ case .urlElicitationRequired(let message, _):
+ return "URL elicitation required: \(message)"
case .connectionClosed:
return "Connection closed"
case .transportError(let error):
@@ -89,6 +114,8 @@ extension MCPError: LocalizedError {
return "Internal JSON-RPC error"
case .serverError:
return "Server-defined error occurred"
+ case .urlElicitationRequired:
+ return "The server requires user authentication or input via external URL"
case .connectionClosed:
return "The connection to the server was closed"
case .transportError(let error):
@@ -106,6 +133,11 @@ extension MCPError: LocalizedError {
return "Check the method name and ensure it is supported by the server"
case .invalidParams:
return "Verify the parameters match the method's expected parameters"
+ case .urlElicitationRequired(_, let elicitations):
+ if let first = elicitations.first {
+ return "Visit \(first.url) to complete the required authentication or input"
+ }
+ return "Complete the required URL-based elicitation"
case .connectionClosed:
return "Try reconnecting to the server"
default:
@@ -139,7 +171,6 @@ extension MCPError: Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
- try container.encode(errorDescription ?? "Unknown error", forKey: .message)
// Encode additional data if available
switch self {
@@ -148,15 +179,34 @@ extension MCPError: Codable {
.methodNotFound(let detail),
.invalidParams(let detail),
.internalError(let detail):
+ try container.encode(errorDescription ?? "Unknown error", forKey: .message)
if let detail = detail {
try container.encode(["detail": detail], forKey: .data)
}
case .serverError(_, _):
// No additional data for server errors
+ try container.encode(errorDescription ?? "Unknown error", forKey: .message)
break
+ case .urlElicitationRequired(let message, let elicitations):
+ // Encode the raw message so decode can round-trip without prefix doubling
+ try container.encode(message, forKey: .message)
+ // Encode elicitations array as structured data
+ let elicitationsData = elicitations.map { info -> [String: Value] in
+ return [
+ "mode": .string(info.mode),
+ "elicitationId": .string(info.elicitationId),
+ "url": .string(info.url),
+ "message": .string(info.message)
+ ]
+ }
+ try container.encode(
+ ["elicitations": Value.array(elicitationsData.map { .object($0) })],
+ forKey: .data
+ )
case .connectionClosed:
- break
+ try container.encode(errorDescription ?? "Unknown error", forKey: .message)
case .transportError(let error):
+ try container.encode(errorDescription ?? "Unknown error", forKey: .message)
try container.encode(
["error": error.localizedDescription],
forKey: .data
@@ -188,6 +238,25 @@ extension MCPError: Codable {
self = .invalidParams(unwrapDetail(message))
case -32603:
self = .internalError(unwrapDetail(nil))
+ case -32042:
+ // Extract elicitations array from data
+ var elicitations: [URLElicitationInfo] = []
+ if case .array(let items) = data?["elicitations"] {
+ for item in items {
+ if case .object(let dict) = item,
+ case .string(let mode) = dict["mode"],
+ case .string(let elicitationId) = dict["elicitationId"],
+ case .string(let url) = dict["url"],
+ case .string(let msg) = dict["message"] {
+ elicitations.append(URLElicitationInfo(
+ mode: mode,
+ elicitationId: elicitationId,
+ url: url,
+ message: msg))
+ }
+ }
+ }
+ self = .urlElicitationRequired(message: message, elicitations: elicitations)
case -32000:
self = .connectionClosed
case -32001:
@@ -214,7 +283,21 @@ extension MCPError: Codable {
extension MCPError: Equatable {
public static func == (lhs: MCPError, rhs: MCPError) -> Bool {
- lhs.code == rhs.code
+ switch (lhs, rhs) {
+ case (.parseError(let a), .parseError(let b)): return a == b
+ case (.invalidRequest(let a), .invalidRequest(let b)): return a == b
+ case (.methodNotFound(let a), .methodNotFound(let b)): return a == b
+ case (.invalidParams(let a), .invalidParams(let b)): return a == b
+ case (.internalError(let a), .internalError(let b)): return a == b
+ case (.serverError(let c1, let m1), .serverError(let c2, let m2)):
+ return c1 == c2 && m1 == m2
+ case (.urlElicitationRequired(let m1, let e1), .urlElicitationRequired(let m2, let e2)):
+ return m1 == m2 && e1 == e2
+ case (.connectionClosed, .connectionClosed): return true
+ case (.transportError(let a), .transportError(let b)):
+ return a.localizedDescription == b.localizedDescription
+ default: return false
+ }
}
}
@@ -236,6 +319,9 @@ extension MCPError: Hashable {
hasher.combine(detail)
case .serverError(_, let message):
hasher.combine(message)
+ case .urlElicitationRequired(let message, let elicitations):
+ hasher.combine(message)
+ hasher.combine(elicitations)
case .connectionClosed:
break
case .transportError(let error):
diff --git a/Sources/MCP/Base/Icon.swift b/Sources/MCP/Base/Icon.swift
new file mode 100644
index 0000000..9587199
--- /dev/null
+++ b/Sources/MCP/Base/Icon.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+/// An optionally-sized icon that can be displayed in a user interface.
+///
+/// Icons can be used to provide visual representation of tools, resources, prompts,
+/// and other MCP entities.
+///
+/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/schema#icon
+public struct Icon: Hashable, Codable, Sendable {
+ /// The theme this icon is designed for.
+ public enum Theme: String, Hashable, Codable, Sendable {
+ /// Icon is designed for light backgrounds
+ case light
+ /// Icon is designed for dark backgrounds
+ case dark
+ }
+
+ /// A standard URI pointing to an icon resource.
+ ///
+ /// May be an HTTP/HTTPS URL or a `data:` URI with Base64-encoded image data.
+ ///
+ /// - Note: Consumers SHOULD take steps to ensure URLs serving icons are from
+ /// the same domain as the client/server or a trusted domain.
+ /// - Note: Consumers SHOULD take appropriate precautions when consuming SVGs
+ /// as they can contain executable JavaScript.
+ public let src: String
+
+ /// Optional MIME type override if the source MIME type is missing or generic.
+ ///
+ /// For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`.
+ public let mimeType: String?
+
+ /// Optional array of strings that specify sizes at which the icon can be used.
+ ///
+ /// Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"`
+ /// for scalable formats like SVG.
+ ///
+ /// If not provided, the client should assume that the icon can be used at any size.
+ public let sizes: [String]?
+
+ /// Optional specifier for the theme this icon is designed for.
+ ///
+ /// - `light`: Icon is designed to be used with a light background
+ /// - `dark`: Icon is designed to be used with a dark background
+ ///
+ /// If not provided, the client should assume the icon can be used with any theme.
+ public let theme: Theme?
+
+ /// Creates a new icon.
+ ///
+ /// - Parameters:
+ /// - src: A standard URI pointing to an icon resource (HTTP/HTTPS URL or data: URI)
+ /// - mimeType: Optional MIME type override
+ /// - sizes: Optional array of size strings (e.g., ["48x48", "96x96"])
+ /// - theme: Optional theme specifier
+ public init(
+ src: String,
+ mimeType: String? = nil,
+ sizes: [String]? = nil,
+ theme: Theme? = nil
+ ) {
+ self.src = src
+ self.mimeType = mimeType
+ self.sizes = sizes
+ self.theme = theme
+ }
+}
diff --git a/Sources/MCP/Base/Lifecycle.swift b/Sources/MCP/Base/Lifecycle.swift
index 7d3e711..08ea967 100644
--- a/Sources/MCP/Base/Lifecycle.swift
+++ b/Sources/MCP/Base/Lifecycle.swift
@@ -46,6 +46,43 @@ public enum Initialize: Method {
public let capabilities: Server.Capabilities
public let serverInfo: Server.Info
public let instructions: String?
+ public var _meta: Metadata?
+
+ public init(
+ protocolVersion: String,
+ capabilities: Server.Capabilities,
+ serverInfo: Server.Info,
+ instructions: String? = nil,
+ _meta: Metadata? = nil
+ ) {
+ self.protocolVersion = protocolVersion
+ self.capabilities = capabilities
+ self.serverInfo = serverInfo
+ self.instructions = instructions
+ self._meta = _meta
+ }
+
+ private enum CodingKeys: String, CodingKey, CaseIterable {
+ case protocolVersion, capabilities, serverInfo, instructions, _meta
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(protocolVersion, forKey: .protocolVersion)
+ try container.encode(capabilities, forKey: .capabilities)
+ try container.encode(serverInfo, forKey: .serverInfo)
+ try container.encodeIfPresent(instructions, forKey: .instructions)
+ try container.encodeIfPresent(_meta, forKey: ._meta)
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ protocolVersion = try container.decode(String.self, forKey: .protocolVersion)
+ capabilities = try container.decode(Server.Capabilities.self, forKey: .capabilities)
+ serverInfo = try container.decode(Server.Info.self, forKey: .serverInfo)
+ instructions = try container.decodeIfPresent(String.self, forKey: .instructions)
+ _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
+ }
}
}
diff --git a/Sources/MCP/Base/Messages.swift b/Sources/MCP/Base/Messages.swift
index 53e462a..e53bb72 100644
--- a/Sources/MCP/Base/Messages.swift
+++ b/Sources/MCP/Base/Messages.swift
@@ -20,7 +20,7 @@ extension Value: NotRequired {
// MARK: -
/// A method that can be used to send requests and receive responses.
-public protocol Method {
+public protocol Method: Sendable {
/// The parameters of the method.
associatedtype Parameters: Codable, Hashable, Sendable = Empty
/// The result of the method.
@@ -37,7 +37,9 @@ struct AnyMethod: Method, Sendable {
}
extension Method where Parameters == Empty {
- public static func request(id: ID = .random) -> Request {
+ public static func request(
+ id: ID = .random
+ ) -> Request {
Request(id: id, method: name, params: Empty())
}
}
@@ -50,17 +52,26 @@ extension Method where Result == Empty {
extension Method {
/// Create a request with the given parameters.
- public static func request(id: ID = .random, _ parameters: Self.Parameters) -> Request {
+ public static func request(
+ id: ID = .random,
+ _ parameters: Self.Parameters
+ ) -> Request {
Request(id: id, method: name, params: parameters)
}
/// Create a response with the given result.
- public static func response(id: ID, result: Self.Result) -> Response {
+ public static func response(
+ id: ID,
+ result: Self.Result
+ ) -> Response {
Response(id: id, result: result)
}
/// Create a response with the given error.
- public static func response(id: ID, error: MCPError) -> Response {
+ public static func response(
+ id: ID,
+ error: MCPError
+ ) -> Response {
Response(id: id, error: error)
}
}
@@ -76,13 +87,17 @@ public struct Request: Hashable, Identifiable, Codable, Sendable {
/// The request parameters.
public let params: M.Parameters
- init(id: ID = .random, method: String, params: M.Parameters) {
+ init(
+ id: ID = .random,
+ method: String,
+ params: M.Parameters
+ ) {
self.id = id
self.method = method
self.params = params
}
- private enum CodingKeys: String, CodingKey {
+ private enum CodingKeys: String, CodingKey, CaseIterable {
case jsonrpc, id, method, params
}
@@ -197,17 +212,29 @@ public struct Response: Hashable, Identifiable, Codable, Sendable {
/// The response result.
public let result: Swift.Result
- public init(id: ID, result: M.Result) {
+ public init(
+ id: ID,
+ result: Swift.Result
+ ) {
self.id = id
- self.result = .success(result)
+ self.result = result
}
- public init(id: ID, error: MCPError) {
- self.id = id
- self.result = .failure(error)
+ public init(
+ id: ID,
+ result: M.Result
+ ) {
+ self.init(id: id, result: .success(result))
}
- private enum CodingKeys: String, CodingKey {
+ public init(
+ id: ID,
+ error: MCPError
+ ) {
+ self.init(id: id, result: .failure(error))
+ }
+
+ private enum CodingKeys: String, CodingKey, CaseIterable {
case jsonrpc, id, result, error
}
@@ -215,6 +242,7 @@ public struct Response: Hashable, Identifiable, Codable, Sendable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(jsonrpc, forKey: .jsonrpc)
try container.encode(id, forKey: .id)
+
switch result {
case .success(let result):
try container.encode(result, forKey: .result)
@@ -249,18 +277,19 @@ typealias AnyResponse = Response
extension AnyResponse {
init(_ response: Response) throws {
- // Instead of re-encoding/decoding which might double-wrap the error,
- // directly transfer the properties
- self.id = response.id
switch response.result {
case .success(let result):
- // For success, we still need to convert the result to a Value
let data = try JSONEncoder().encode(result)
let resultValue = try JSONDecoder().decode(Value.self, from: data)
- self.result = .success(resultValue)
+ self = Response(
+ id: response.id,
+ result: .success(resultValue)
+ )
case .failure(let error):
- // Keep the original error without re-encoding/decoding
- self.result = .failure(error)
+ self = Response(
+ id: response.id,
+ result: .failure(error)
+ )
}
}
}
diff --git a/Sources/MCP/Base/PendingRequest.swift b/Sources/MCP/Base/PendingRequest.swift
new file mode 100644
index 0000000..b6e0a83
--- /dev/null
+++ b/Sources/MCP/Base/PendingRequest.swift
@@ -0,0 +1,43 @@
+import class Foundation.JSONDecoder
+import class Foundation.JSONEncoder
+
+/// An error indicating a type mismatch when decoding a pending request response.
+struct TypeMismatchError: Swift.Error {}
+
+/// A pending request with a continuation for the result.
+struct PendingRequest {
+ let continuation: CheckedContinuation
+}
+
+/// A type-erased pending request.
+struct AnyPendingRequest: Sendable {
+ private let _resume: @Sendable (Result) -> Void
+
+ init(_ request: PendingRequest) {
+ _resume = { result in
+ switch result {
+ case .success(let value):
+ if let typedValue = value as? T {
+ request.continuation.resume(returning: typedValue)
+ } else if let value = value as? Value,
+ let data = try? JSONEncoder().encode(value),
+ let decoded = try? JSONDecoder().decode(T.self, from: data)
+ {
+ request.continuation.resume(returning: decoded)
+ } else {
+ request.continuation.resume(throwing: TypeMismatchError())
+ }
+ case .failure(let error):
+ request.continuation.resume(throwing: error)
+ }
+ }
+ }
+
+ func resume(returning value: Any) {
+ _resume(.success(value))
+ }
+
+ func resume(throwing error: Swift.Error) {
+ _resume(.failure(error))
+ }
+}
diff --git a/Sources/MCP/Base/Transports/HTTPClientTransport.swift b/Sources/MCP/Base/Transports/HTTPClientTransport.swift
index 11a4455..060cd13 100644
--- a/Sources/MCP/Base/Transports/HTTPClientTransport.swift
+++ b/Sources/MCP/Base/Transports/HTTPClientTransport.swift
@@ -9,16 +9,20 @@ import Logging
import FoundationNetworking
#endif
+// MARK: - Timeout Helpers
+
+/// Error thrown when an operation times out
/// An implementation of the MCP Streamable HTTP transport protocol for clients.
///
-/// This transport implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http)
-/// specification from the Model Context Protocol.
+/// This transport implements the [Streamable HTTP transport](https://spec.modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)
+/// specification from the Model Context Protocol (version 2025-11-25).
///
/// It supports:
/// - Sending JSON-RPC messages via HTTP POST requests
/// - Receiving responses via both direct JSON responses and SSE streams
-/// - Session management using the `Mcp-Session-Id` header
-/// - Automatic reconnection for dropped SSE streams
+/// - Session management using the `MCP-Session-Id` header
+/// - Protocol version negotiation via `MCP-Protocol-Version` header
+/// - Automatic reconnection for dropped SSE streams with resumability support
/// - Platform-specific optimizations for different operating systems
///
/// The transport supports two modes:
@@ -56,6 +60,10 @@ public actor HTTPClientTransport: Transport {
/// The session ID assigned by the server, used for maintaining state across requests
public private(set) var sessionID: String?
+
+ /// The negotiated protocol version to send in MCP-Protocol-Version header
+ public var protocolVersion: String?
+
private let streaming: Bool
private var streamingTask: Task?
@@ -68,6 +76,9 @@ public actor HTTPClientTransport: Transport {
/// Closure to modify requests before they are sent
private let requestModifier: (URLRequest) -> URLRequest
+ /// Optional OAuth 2.1 authorizer.
+ private let authorizer: (any HTTPClientAuthorizer)?
+
private var isConnected = false
private let messageStream: AsyncThrowingStream
private let messageContinuation: AsyncThrowingStream.Continuation
@@ -75,6 +86,16 @@ public actor HTTPClientTransport: Transport {
private var initialSessionIDSignalTask: Task?
private var initialSessionIDContinuation: CheckedContinuation?
+ /// The last event ID received from the server for SSE stream resumability
+ private var lastEventID: String?
+
+ /// The retry interval (in milliseconds) from the server's SSE `retry:` field
+ private var retryInterval: Int = 3000 // Default 3000ms per SSE spec
+
+ /// The underlying URLSession task for the active GET SSE stream.
+ /// Used to trigger reconnection when a POST SSE stream closes without delivering data.
+ private var activeGETSessionTask: URLSessionDataTask?
+
/// Creates a new HTTP transport client with the specified endpoint
///
/// - Parameters:
@@ -82,6 +103,8 @@ public actor HTTPClientTransport: Transport {
/// - configuration: URLSession configuration to use for HTTP requests
/// - streaming: Whether to enable SSE streaming mode (default: true)
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds)
+ /// - protocolVersion: The MCP protocol version to use (default: "2025-11-25")
+ /// - authorizer: Optional ``HTTPClientAuthorizer`` for automatic Bearer token acquisition and retries.
/// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
/// - logger: Optional logger instance for transport events
public init(
@@ -89,6 +112,8 @@ public actor HTTPClientTransport: Transport {
configuration: URLSessionConfiguration = .default,
streaming: Bool = true,
sseInitializationTimeout: TimeInterval = 10,
+ protocolVersion: String = Version.latest,
+ authorizer: (any HTTPClientAuthorizer)? = nil,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@@ -97,6 +122,8 @@ public actor HTTPClientTransport: Transport {
session: URLSession(configuration: configuration),
streaming: streaming,
sseInitializationTimeout: sseInitializationTimeout,
+ protocolVersion: protocolVersion,
+ authorizer: authorizer,
requestModifier: requestModifier,
logger: logger
)
@@ -107,6 +134,8 @@ public actor HTTPClientTransport: Transport {
session: URLSession,
streaming: Bool = false,
sseInitializationTimeout: TimeInterval = 10,
+ protocolVersion: String = Version.latest,
+ authorizer: (any HTTPClientAuthorizer)? = nil,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@@ -114,7 +143,9 @@ public actor HTTPClientTransport: Transport {
self.session = session
self.streaming = streaming
self.sseInitializationTimeout = sseInitializationTimeout
+ self.protocolVersion = protocolVersion
self.requestModifier = requestModifier
+ self.authorizer = authorizer
// Create message stream
var continuation: AsyncThrowingStream.Continuation!
@@ -134,7 +165,6 @@ public actor HTTPClientTransport: Transport {
self.initialSessionIDSignalTask = Task {
await withCheckedContinuation { continuation in
self.initialSessionIDContinuation = continuation
- // This task will suspend here until continuation.resume() is called
}
}
}
@@ -143,25 +173,21 @@ public actor HTTPClientTransport: Transport {
private func triggerInitialSessionIDSignal() {
if let continuation = self.initialSessionIDContinuation {
continuation.resume()
- self.initialSessionIDContinuation = nil // Consume the continuation
- logger.trace("Initial session ID signal triggered for SSE task.")
+ self.initialSessionIDContinuation = nil
+ logger.debug("✓ Initial session ID signal triggered for SSE task")
+ } else {
+ logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting")
}
}
/// Establishes connection with the transport
- ///
- /// This prepares the transport for communication and sets up SSE streaming
- /// if streaming mode is enabled. The actual HTTP connection happens with the
- /// first message sent.
public func connect() async throws {
guard !isConnected else { return }
isConnected = true
- // Setup initial session ID signal
setupInitialSessionIDSignal()
if streaming {
- // Start listening to server events
streamingTask = Task { await startListeningForServerEvents() }
}
@@ -169,91 +195,127 @@ public actor HTTPClientTransport: Transport {
}
/// Disconnects from the transport
- ///
- /// This terminates any active connections, cancels the streaming task,
- /// and releases any resources being used by the transport.
public func disconnect() async {
guard isConnected else { return }
isConnected = false
- // Cancel streaming task if active
streamingTask?.cancel()
streamingTask = nil
- // Cancel any in-progress requests
session.invalidateAndCancel()
-
- // Clean up message stream
messageContinuation.finish()
- // Cancel the initial session ID signal task if active
initialSessionIDSignalTask?.cancel()
initialSessionIDSignalTask = nil
- // Resume the continuation if it's still pending to avoid leaks
initialSessionIDContinuation?.resume()
initialSessionIDContinuation = nil
logger.debug("HTTP clienttransport disconnected")
}
+ /// Updates the protocol version used for `MCP-Protocol-Version` headers on subsequent requests.
+ public func updateNegotiatedProtocolVersion(_ version: String) {
+ self.protocolVersion = version
+ }
+
/// Sends data through an HTTP POST request
- ///
- /// This sends a JSON-RPC message to the server via HTTP POST and processes
- /// the response according to the MCP Streamable HTTP specification. It handles:
- ///
- /// - Adding appropriate Accept headers for both JSON and SSE
- /// - Including the session ID in requests if one has been established
- /// - Processing different response types (JSON vs SSE)
- /// - Handling HTTP error codes according to the specification
- ///
- /// - Parameter data: The JSON-RPC message to send
- /// - Throws: MCPError for transport failures or server errors
public func send(_ data: Data) async throws {
guard isConnected else {
throw MCPError.internalError("Transport not connected")
}
- var request = URLRequest(url: endpoint)
- request.httpMethod = "POST"
- request.addValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
- request.addValue("application/json", forHTTPHeaderField: "Content-Type")
- request.httpBody = data
-
- // Add session ID if available
- if let sessionID = sessionID {
- request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
+ if let authorizer {
+ do {
+ try authorizer.validateEndpointSecurity(for: endpoint)
+ } catch {
+ throw MCPError.internalError(
+ "Authorization flow failed: \(error.localizedDescription)"
+ )
+ }
}
- // Apply request modifier
- request = requestModifier(request)
+ if let authorizer {
+ try? await authorizer.prepareAuthorization(for: endpoint, session: session)
+ }
- #if os(Linux)
- // Linux implementation using data(for:) instead of bytes(for:)
- let (responseData, response) = try await session.data(for: request)
- try await processResponse(response: response, data: responseData)
- #else
- // macOS and other platforms with bytes(for:) support
- let (responseStream, response) = try await session.bytes(for: request)
- try await processResponse(response: response, stream: responseStream)
- #endif
+ var attempts = 0
+ let operationKey = jsonRPCOperationKey(from: data)
+
+ while true {
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.addValue(
+ "\(ContentType.json), \(ContentType.sse)",
+ forHTTPHeaderField: HTTPHeaderName.accept
+ )
+ request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
+ request.httpBody = data
+
+ if let protocolVersion = protocolVersion {
+ request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
+ }
+
+ if let sessionID = sessionID {
+ request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
+ }
+
+ if let authValue = authorizer?.authorizationHeader(for: endpoint) {
+ request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
+ }
+
+ request = requestModifier(request)
+
+ do {
+ #if os(Linux)
+ let (responseData, response) = try await session.data(for: request)
+ try await processResponse(response: response, data: responseData)
+ #else
+ let (responseStream, response) = try await session.bytes(for: request)
+ try await processResponse(response: response, stream: responseStream)
+ #endif
+ return
+ } catch let authError as HTTPAuthenticationChallengeError {
+ guard let authorizer else {
+ throw mapAuthenticationChallengeError(authError)
+ }
+
+ let handled: Bool
+ do {
+ handled = try await authorizer.handleChallenge(
+ statusCode: authError.statusCode,
+ headers: authError.headers,
+ endpoint: endpoint,
+ operationKey: operationKey,
+ session: session
+ )
+ } catch {
+ throw MCPError.internalError(
+ "Authorization flow failed: \(error.localizedDescription)")
+ }
+
+ attempts += 1
+
+ if handled, attempts < authorizer.maxAuthorizationAttempts {
+ continue
+ }
+
+ throw mapAuthenticationChallengeError(authError)
+ }
+ }
}
#if os(Linux)
- // Process response with data payload (Linux)
private func processResponse(response: URLResponse, data: Data) async throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw MCPError.internalError("Invalid HTTP response")
}
- // Process the response based on content type and status code
- let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
+ let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
- // Extract session ID if present
- if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
+ if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
- // Trigger signal on first session ID
triggerInitialSessionIDSignal()
}
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@@ -262,11 +324,10 @@ public actor HTTPClientTransport: Transport {
try processHTTPResponse(httpResponse, contentType: contentType)
guard case 200..<300 = httpResponse.statusCode else { return }
- // For JSON responses, yield the data
- if contentType.contains("text/event-stream") {
+ if contentType.contains(ContentType.sse) {
logger.warning("SSE responses aren't fully supported on Linux")
messageContinuation.yield(data)
- } else if contentType.contains("application/json") {
+ } else if contentType.contains(ContentType.json) {
logger.trace("Received JSON response", metadata: ["size": "\(data.count)"])
messageContinuation.yield(data)
} else {
@@ -274,7 +335,6 @@ public actor HTTPClientTransport: Transport {
}
}
#else
- // Process response with byte stream (macOS, iOS, etc.)
private func processResponse(response: URLResponse, stream: URLSession.AsyncBytes)
async throws
{
@@ -282,15 +342,12 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Invalid HTTP response")
}
- // Process the response based on content type and status code
- let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
+ let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
- // Extract session ID if present
- if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
+ if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
- // Trigger signal on first session ID
triggerInitialSessionIDSignal()
}
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@@ -299,12 +356,15 @@ public actor HTTPClientTransport: Transport {
try processHTTPResponse(httpResponse, contentType: contentType)
guard case 200..<300 = httpResponse.statusCode else { return }
- if contentType.contains("text/event-stream") {
- // For SSE, processing happens via the stream
+ if contentType.contains(ContentType.sse) {
logger.trace("Received SSE response, processing in streaming task")
- try await self.processSSE(stream)
- } else if contentType.contains("application/json") {
- // For JSON responses, collect and deliver the data
+ let hadData = try await self.processSSE(stream)
+
+ if !hadData {
+ logger.debug("POST SSE stream closed without data, triggering GET reconnection")
+ self.activeGETSessionTask?.cancel()
+ }
+ } else if contentType.contains(ContentType.json) {
var buffer = Data()
for try await byte in stream {
buffer.append(byte)
@@ -317,25 +377,27 @@ public actor HTTPClientTransport: Transport {
}
#endif
- // Common HTTP response handling for all platforms
private func processHTTPResponse(_ response: HTTPURLResponse, contentType: String) throws {
- // Handle status codes according to HTTP semantics
switch response.statusCode {
case 200..<300:
- // Success range - these are handled by the platform-specific code
return
case 400:
throw MCPError.internalError("Bad request")
case 401:
- throw MCPError.internalError("Authentication required")
+ throw HTTPAuthenticationChallengeError(
+ statusCode: response.statusCode,
+ headers: responseHeaders(from: response)
+ )
case 403:
- throw MCPError.internalError("Access forbidden")
+ throw HTTPAuthenticationChallengeError(
+ statusCode: response.statusCode,
+ headers: responseHeaders(from: response)
+ )
case 404:
- // If we get a 404 with a session ID, it means our session is invalid
if sessionID != nil {
logger.warning("Session has expired")
sessionID = nil
@@ -344,8 +406,6 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Endpoint not found")
case 405:
- // If we get a 405, it means the server does not support the requested method
- // If streaming was requested, we should cancel the streaming task
if streaming {
self.streamingTask?.cancel()
throw MCPError.internalError("Server does not support streaming")
@@ -359,7 +419,6 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Too many requests")
case 500..<600:
- // Server error range
throw MCPError.internalError("Server error: \(response.statusCode)")
default:
@@ -368,199 +427,260 @@ public actor HTTPClientTransport: Transport {
}
}
+ private func mapAuthenticationChallengeError(_ error: HTTPAuthenticationChallengeError) -> MCPError {
+ switch error.statusCode {
+ case 401:
+ return MCPError.internalError("Authentication required")
+ case 403:
+ return MCPError.internalError("Access forbidden")
+ default:
+ return MCPError.internalError("HTTP authorization error: \(error.statusCode)")
+ }
+ }
+
+ private func responseHeaders(from response: HTTPURLResponse) -> [String: String] {
+ var headers: [String: String] = [:]
+ for (key, value) in response.allHeaderFields {
+ guard let key = key as? String, let value = value as? String else { continue }
+ headers[key] = value
+ }
+ return headers
+ }
+
+ private func jsonRPCOperationKey(from data: Data) -> String? {
+ guard
+ let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let method = jsonObject["method"] as? String
+ else {
+ return nil
+ }
+
+ let normalized = method.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+
/// Receives data in an async sequence
- ///
- /// This returns an AsyncThrowingStream that emits Data objects representing
- /// each JSON-RPC message received from the server. This includes:
- ///
- /// - Direct responses to client requests
- /// - Server-initiated messages delivered via SSE streams
- ///
- /// - Returns: An AsyncThrowingStream of Data objects
public func receive() -> AsyncThrowingStream {
return messageStream
}
// MARK: - SSE
- /// Starts listening for server events using SSE
- ///
- /// This establishes a long-lived HTTP connection using Server-Sent Events (SSE)
- /// to enable server-to-client push messaging. It handles:
- ///
- /// - Waiting for session ID if needed
- /// - Opening the SSE connection
- /// - Automatic reconnection on connection drops
- /// - Processing received events
private func startListeningForServerEvents() async {
#if os(Linux)
- // SSE is not fully supported on Linux
if streaming {
logger.warning(
"SSE streaming was requested but is not fully supported on Linux. SSE connection will not be attempted."
)
}
#else
- // This is the original code for platforms that support SSE
guard isConnected else { return }
- // Wait for the initial session ID signal, but only if sessionID isn't already set
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
- logger.trace("SSE streaming task waiting for initial sessionID signal...")
+ logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...")
- // Race the signalTask against a timeout
- let timeoutTask = Task {
- try? await Task.sleep(for: .seconds(self.sseInitializationTimeout))
- return false
- }
-
- let signalCompletionTask = Task {
- await signalTask.value
- return true // Indicates signal received
- }
-
- // Use TaskGroup to race the two tasks
- var signalReceived = false
+ let startTime = Date()
+ let timeout = self.sseInitializationTimeout
do {
- signalReceived = try await withThrowingTaskGroup(of: Bool.self) { group in
+ try await withThrowingTaskGroup { group in
group.addTask {
- await signalCompletionTask.value
+ try await Task.sleep(for: .seconds(timeout))
}
+
group.addTask {
- await timeoutTask.value
+ await signalTask.value
}
- // Take the first result and cancel the other task
if let firstResult = try await group.next() {
group.cancelAll()
return firstResult
}
- return false
}
} catch {
- logger.error("Error while waiting for session ID signal: \(error)")
+ logger.warning("⏱️ Timeout waiting for session ID (\(timeout)s). SSE stream will proceed anyway.")
}
- // Clean up tasks
- timeoutTask.cancel()
-
- if signalReceived {
- logger.trace("SSE streaming task proceeding after initial sessionID signal.")
- } else {
- logger.warning(
- "Timeout waiting for initial sessionID signal. SSE stream will proceed (sessionID might be nil)."
- )
+ if self.sessionID != nil {
+ let elapsed = Date().timeIntervalSince(startTime)
+ logger.debug("✓ Session ID received after \(Int(elapsed * 1000))ms, proceeding with SSE connection")
}
- } else if self.sessionID != nil {
- logger.trace(
- "Initial sessionID already available. Proceeding with SSE streaming task immediately."
- )
} else {
- logger.trace(
- "Proceeding with SSE connection attempt; sessionID is nil. This might be expected for stateless servers or if initialize hasn't provided one yet."
- )
+ logger.debug("✓ Session ID already available, proceeding with SSE connection immediately")
}
- // Retry loop for connection drops
+ var isFirstAttempt = true
+ var attemptCount = 0
+
+ logger.debug("🔄 Starting SSE retry loop", metadata: [
+ "isConnected": "\(isConnected)",
+ "isCancelled": "\(Task.isCancelled)"
+ ])
+
while isConnected && !Task.isCancelled {
+ attemptCount += 1
+ logger.debug("🔄 SSE retry loop iteration", metadata: [
+ "attempt": "\(attemptCount)",
+ "isFirstAttempt": "\(isFirstAttempt)"
+ ])
+
do {
- try await connectToEventStream()
+ if !isFirstAttempt {
+ let delayMs = self.retryInterval
+ logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"])
+ try await Task.sleep(for: .milliseconds(delayMs))
+ logger.debug("✓ Wait complete, reconnecting now")
+ }
+ isFirstAttempt = false
+
+ logger.debug("📡 Calling connectToEventStream (attempt #\(attemptCount))")
+
+ try await self.connectToEventStream()
+
+ logger.info("🔌 SSE stream closed gracefully, will reconnect", metadata: [
+ "attempt": "\(attemptCount)",
+ "willRetryAfter": "\(self.retryInterval)ms"
+ ])
} catch {
if !Task.isCancelled {
- logger.error("SSE connection error: \(error)")
- // Wait before retrying
- try? await Task.sleep(for: .seconds(1))
+ logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)")
+ } else {
+ logger.debug("⏹️ SSE task cancelled")
}
}
+
+ logger.debug("🔄 End of retry loop iteration", metadata: [
+ "isConnected": "\(isConnected)",
+ "isCancelled": "\(Task.isCancelled)",
+ "willContinue": "\(isConnected && !Task.isCancelled)"
+ ])
}
+
+ logger.debug("⏹️ SSE retry loop exited", metadata: [
+ "isConnected": "\(isConnected)",
+ "isCancelled": "\(Task.isCancelled)",
+ "totalAttempts": "\(attemptCount)"
+ ])
#endif
}
#if !os(Linux)
- /// Establishes an SSE connection to the server
- ///
- /// This initiates a GET request to the server endpoint with appropriate
- /// headers to establish an SSE stream according to the MCP specification.
- ///
- /// - Throws: MCPError for connection failures or server errors
private func connectToEventStream() async throws {
- guard isConnected else { return }
+ guard isConnected else {
+ logger.debug("⚠️ Skipping connectToEventStream - transport not connected")
+ return
+ }
+
+ if let authorizer {
+ do {
+ try authorizer.validateEndpointSecurity(for: endpoint)
+ } catch {
+ throw MCPError.internalError(
+ "Authorization flow failed: \(error.localizedDescription)"
+ )
+ }
+ }
+
+ if let authorizer {
+ try? await authorizer.prepareAuthorization(for: endpoint, session: session)
+ }
+
+ logger.debug("🔌 Preparing SSE connection request")
var request = URLRequest(url: endpoint)
request.httpMethod = "GET"
- request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
- request.addValue("no-cache", forHTTPHeaderField: "Cache-Control")
+ request.addValue(ContentType.sse, forHTTPHeaderField: HTTPHeaderName.accept)
+ request.addValue("no-cache", forHTTPHeaderField: HTTPHeaderName.cacheControl)
- // Add session ID if available
+ if let protocolVersion = protocolVersion {
+ request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
+ }
+
if let sessionID = sessionID {
- request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
+ request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
+ }
+
+ if let lastEventID = lastEventID {
+ request.addValue(lastEventID, forHTTPHeaderField: HTTPHeaderName.lastEventID)
+ logger.info("→ Resuming SSE stream with Last-Event-ID", metadata: ["lastEventID": "\(lastEventID)"])
+ } else {
+ logger.info("→ Connecting to SSE stream (no last event ID to resume from)")
+ }
+
+ if let authValue = authorizer?.authorizationHeader(for: endpoint) {
+ request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
}
- // Apply request modifier
request = requestModifier(request)
logger.debug("Starting SSE connection")
- // Create URLSession task for SSE
let (stream, response) = try await session.bytes(for: request)
+ self.activeGETSessionTask = stream.task
guard let httpResponse = response as? HTTPURLResponse else {
throw MCPError.internalError("Invalid HTTP response")
}
- // Check response status
guard httpResponse.statusCode == 200 else {
- // If the server returns 405 Method Not Allowed,
- // it indicates that the server doesn't support SSE streaming.
- // We should cancel the task instead of retrying the connection.
if httpResponse.statusCode == 405 {
self.streamingTask?.cancel()
}
throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)")
}
- // Extract session ID if present
- if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
+ if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
- // Trigger signal on first session ID, though this is unlikely to happen here
- // as GET usually follows a POST that would have already set the session ID
triggerInitialSessionIDSignal()
}
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
}
+ defer { self.activeGETSessionTask = nil }
try await self.processSSE(stream)
}
- /// Processes an SSE byte stream, extracting events and delivering them
- ///
- /// - Parameter stream: The URLSession.AsyncBytes stream to process
- /// - Throws: Error for stream processing failures
- private func processSSE(_ stream: URLSession.AsyncBytes) async throws {
- do {
- for try await event in stream.events {
- // Check if task has been cancelled
- if Task.isCancelled { break }
+ @discardableResult
+ private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool {
+ logger.debug("📥 Starting SSE event processing")
+ var eventCount = 0
+ var hadDataEvent = false
- logger.trace(
- "SSE event received",
- metadata: [
- "type": "\(event.event ?? "message")",
- "id": "\(event.id ?? "none")",
- ]
- )
+ for try await event in stream.events {
+ eventCount += 1
- // Convert the event data to Data and yield it to the message stream
- if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
- messageContinuation.yield(data)
- }
+ if Task.isCancelled {
+ logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"])
+ break
+ }
+
+ logger.trace(
+ "SSE event received",
+ metadata: [
+ "type": "\(event.event ?? "message")",
+ "id": "\(event.id ?? "none")",
+ ]
+ )
+
+ if let eventID = event.id, !eventID.isEmpty {
+ self.lastEventID = eventID
+ logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"])
+ }
+
+ if let retry = event.retry {
+ self.retryInterval = retry
+ logger.debug("SSE retry interval updated", metadata: ["retryMs": "\(retry)"])
+ }
+
+ if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
+ hadDataEvent = true
+ messageContinuation.yield(data)
}
- } catch {
- logger.error("Error processing SSE events: \(error)")
- throw error
}
+
+ logger.debug("✓ SSE event stream completed", metadata: ["eventsProcessed": "\(eventCount)", "hadData": "\(hadDataEvent)"])
+ return hadDataEvent
}
#endif
}
diff --git a/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift b/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift
new file mode 100644
index 0000000..51c7aae
--- /dev/null
+++ b/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift
@@ -0,0 +1,724 @@
+import Foundation
+
+// MARK: - Validation Protocol
+
+/// Validates an incoming HTTP request before the transport processes it.
+///
+/// Validators are composed into a pipeline and executed in order. The first validator
+/// that returns a non-nil response short-circuits the pipeline and that error response
+/// is returned to the client.
+///
+/// Conform to this protocol to add custom validation (e.g., authentication):
+/// ```swift
+/// struct BearerTokenValidator: HTTPRequestValidator {
+/// func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+/// guard let auth = request.header("Authorization"),
+/// auth.hasPrefix("Bearer ") else {
+/// return .error(statusCode: 401, .invalidRequest("Missing bearer token"))
+/// }
+/// return nil
+/// }
+/// }
+/// ```
+public protocol HTTPRequestValidator: Sendable {
+ /// Validates the request. Returns an error response if invalid, or `nil` if valid.
+ func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
+}
+
+// MARK: - Validation Context
+
+/// Context provided to validators for making validation decisions.
+public struct HTTPValidationContext: Sendable {
+ /// The HTTP method of the request (GET, POST, DELETE).
+ public let httpMethod: String
+
+ /// The current session ID, if any (nil in stateless mode or before initialization).
+ public let sessionID: String?
+
+ /// Whether the request body contains an `initialize` JSON-RPC request.
+ public let isInitializationRequest: Bool
+
+ /// The set of protocol versions this server supports.
+ public let supportedProtocolVersions: Set
+
+ public init(
+ httpMethod: String,
+ sessionID: String? = nil,
+ isInitializationRequest: Bool = false,
+ supportedProtocolVersions: Set = Version.supported
+ ) {
+ self.httpMethod = httpMethod
+ self.sessionID = sessionID
+ self.isInitializationRequest = isInitializationRequest
+ self.supportedProtocolVersions = supportedProtocolVersions
+ }
+}
+
+// MARK: - Accept Header Validator
+
+/// Validates the `Accept` header based on the HTTP method and transport response mode.
+///
+/// - Stateful (SSE) mode: POST requests must accept both `application/json` and `text/event-stream`
+/// - Stateless (JSON) mode: POST requests only need to accept `application/json`
+/// - GET requests always require `text/event-stream`
+public struct AcceptHeaderValidator: HTTPRequestValidator {
+ /// The response mode determines which content types are required.
+ public enum Mode: Sendable {
+ /// POST requires both `application/json` and `text/event-stream`.
+ case sseRequired
+ /// POST only requires `application/json`.
+ case jsonOnly
+ }
+
+ public let mode: Mode
+
+ public init(mode: Mode) {
+ self.mode = mode
+ }
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ let accept = request.header(HTTPHeaderName.accept) ?? ""
+ let acceptTypes = accept.split(separator: ",").map {
+ $0.trimmingCharacters(in: .whitespaces)
+ }
+
+ let hasJSON = acceptTypes.contains { $0.hasPrefix(ContentType.json) }
+ let hasSSE = acceptTypes.contains { $0.hasPrefix(ContentType.sse) }
+
+ switch context.httpMethod {
+ case "POST":
+ switch mode {
+ case .sseRequired:
+ guard hasJSON, hasSSE else {
+ return .error(
+ statusCode: 406,
+ .invalidRequest(
+ "Not Acceptable: Client must accept both application/json and text/event-stream"
+ ),
+ sessionID: context.sessionID
+ )
+ }
+ case .jsonOnly:
+ guard hasJSON else {
+ return .error(
+ statusCode: 406,
+ .invalidRequest(
+ "Not Acceptable: Client must accept application/json"
+ ),
+ sessionID: context.sessionID
+ )
+ }
+ }
+ case "GET":
+ guard hasSSE else {
+ return .error(
+ statusCode: 406,
+ .invalidRequest(
+ "Not Acceptable: Client must accept text/event-stream"
+ ),
+ sessionID: context.sessionID
+ )
+ }
+ default:
+ break
+ }
+
+ return nil
+ }
+}
+
+// MARK: - Content-Type Validator
+
+/// Validates that POST requests have `Content-Type: application/json`.
+public struct ContentTypeValidator: HTTPRequestValidator {
+ public init() {}
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ guard context.httpMethod == "POST" else { return nil }
+
+ let contentType = request.header(HTTPHeaderName.contentType) ?? ""
+ let mainType = contentType.split(separator: ";").first?
+ .trimmingCharacters(in: .whitespaces) ?? ""
+
+ guard mainType == ContentType.json else {
+ return .error(
+ statusCode: 415,
+ .invalidRequest(
+ "Unsupported Media Type: Content-Type must be application/json"
+ ),
+ sessionID: context.sessionID
+ )
+ }
+
+ return nil
+ }
+}
+
+// MARK: - Protocol Version Validator
+
+/// Validates the `MCP-Protocol-Version` header against supported versions.
+///
+/// Per spec:
+/// - If the header is absent, the server assumes the default negotiated version
+/// - If the header is present but unsupported, the server returns 400 Bad Request
+/// - Initialization requests are exempt (protocol version comes from the request body)
+public struct ProtocolVersionValidator: HTTPRequestValidator {
+ public init() {}
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ // Skip for initialization requests (version is in the body, not the header)
+ guard !context.isInitializationRequest else { return nil }
+
+ // Skip for non-POST methods (GET/DELETE don't carry protocol version)
+ // Actually, per spec, all subsequent requests should include it
+ guard let version = request.header(HTTPHeaderName.protocolVersion) else {
+ // Per spec: if not received, assume default version
+ return nil
+ }
+
+ guard context.supportedProtocolVersions.contains(version) else {
+ let supported = context.supportedProtocolVersions.sorted().joined(separator: ", ")
+ return .error(
+ statusCode: 400,
+ .invalidRequest(
+ "Bad Request: Unsupported protocol version: \(version). Supported: \(supported)"
+ ),
+ sessionID: context.sessionID
+ )
+ }
+
+ return nil
+ }
+}
+
+// MARK: - Session Validator
+
+/// Validates the `Mcp-Session-Id` header for stateful transports.
+///
+/// - Initialization requests are exempt (no session exists yet)
+/// - Non-initialization requests must include the session ID header
+/// - The session ID must match the active session
+public struct SessionValidator: HTTPRequestValidator {
+ public init() {}
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ // Skip validation for initialization requests
+ guard !context.isInitializationRequest else { return nil }
+
+ // Non-initialization requests require an established session.
+ guard let expectedSessionID = context.sessionID else {
+ return .error(
+ statusCode: 400,
+ .invalidRequest("Bad Request: Session not initialized"),
+ sessionID: nil
+ )
+ }
+
+ let requestSessionID = request.header(HTTPHeaderName.sessionID)
+
+ guard let requestSessionID else {
+ return .error(
+ statusCode: 400,
+ .invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header"),
+ sessionID: expectedSessionID
+ )
+ }
+
+ guard requestSessionID == expectedSessionID else {
+ return .error(
+ statusCode: 404,
+ .invalidRequest("Not Found: Invalid or expired session ID"),
+ sessionID: expectedSessionID
+ )
+ }
+
+ return nil
+ }
+}
+
+// MARK: - Origin Validator
+
+/// DNS rebinding protection: validates `Origin` and `Host` headers.
+///
+/// Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.
+/// This is particularly important for servers running on localhost.
+///
+/// Use `.localhost()` for local development servers.
+/// Use `.disabled` to skip validation (e.g., cloud deployments).
+/// Use `init(allowedHosts:allowedOrigins:)` for custom configurations.
+public struct OriginValidator: HTTPRequestValidator {
+ public let allowedHosts: [String]
+ public let allowedOrigins: [String]
+ private let enabled: Bool
+
+ public init(allowedHosts: [String], allowedOrigins: [String]) {
+ self.allowedHosts = allowedHosts
+ self.allowedOrigins = allowedOrigins
+ self.enabled = true
+ }
+
+ private init(disabled: Void) {
+ self.allowedHosts = []
+ self.allowedOrigins = []
+ self.enabled = false
+ }
+
+ /// Protection for localhost-bound servers.
+ /// Allows requests from `localhost`, `127.0.0.1`, and `[::1]` with the specified port.
+ public static func localhost(port: Int? = nil) -> OriginValidator {
+ let portPattern = port.map { String($0) } ?? "*"
+ return OriginValidator(
+ allowedHosts: [
+ "127.0.0.1:\(portPattern)",
+ "localhost:\(portPattern)",
+ "[::1]:\(portPattern)",
+ ],
+ allowedOrigins: [
+ "http://127.0.0.1:\(portPattern)",
+ "http://localhost:\(portPattern)",
+ "http://[::1]:\(portPattern)",
+ ]
+ )
+ }
+
+ /// Disables DNS rebinding protection.
+ /// Use for cloud deployments where DNS rebinding is not a threat.
+ public static var disabled: OriginValidator {
+ OriginValidator(disabled: ())
+ }
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ guard enabled else { return nil }
+
+ // Validate Host header
+ if let host = request.header(HTTPHeaderName.host) {
+ let hostAllowed = allowedHosts.contains { pattern in
+ matchesPattern(host, pattern: pattern)
+ }
+ if !hostAllowed {
+ return .error(
+ statusCode: 421,
+ .invalidRequest("Misdirected Request: Host header not allowed"),
+ sessionID: context.sessionID
+ )
+ }
+ }
+
+ // Validate Origin header (only if present — non-browser clients won't send it)
+ if let origin = request.header(HTTPHeaderName.origin) {
+ let originAllowed = allowedOrigins.contains { pattern in
+ matchesPattern(origin, pattern: pattern)
+ }
+ if !originAllowed {
+ return .error(
+ statusCode: 403,
+ .invalidRequest("Forbidden: Origin not allowed"),
+ sessionID: context.sessionID
+ )
+ }
+ }
+
+ return nil
+ }
+
+ /// Matches a value against a pattern that may contain a port wildcard `:*`.
+ ///
+ /// Examples:
+ /// - `"localhost:*"` matches `"localhost:8080"`, `"localhost:3000"`
+ /// - `"http://localhost:*"` matches `"http://localhost:8080"`
+ /// - `"localhost:8080"` matches only `"localhost:8080"` exactly
+ private func matchesPattern(_ value: String, pattern: String) -> Bool {
+ guard pattern.hasSuffix(":*") else {
+ return value == pattern
+ }
+
+ let prefix = String(pattern.dropLast(2))
+ guard value.hasPrefix(prefix + ":") else { return false }
+
+ let portPart = value.dropFirst(prefix.count + 1)
+ return !portPart.isEmpty && portPart.allSatisfy(\.isNumber)
+ }
+}
+
+// MARK: - Protected Resource Metadata Validator
+
+/// Serves the RFC 9728 Protected Resource Metadata document for discovery.
+///
+/// Per the MCP authorization specification, servers **MUST** serve Protected Resource
+/// Metadata at `/.well-known/oauth-protected-resource` so that clients can discover
+/// authorization server endpoints automatically.
+///
+/// Place this validator **before** ``BearerTokenValidator`` in the pipeline so that
+/// unauthenticated metadata discovery requests succeed.
+///
+/// ```swift
+/// let prmValidator = ProtectedResourceMetadataValidator(
+/// metadata: OAuthProtectedResourceServerMetadata(
+/// resource: "https://api.example.com",
+/// authorizationServers: [URL(string: "https://auth.example.com")!]
+/// )
+/// )
+/// let pipeline = StandardValidationPipeline(validators: [
+/// prmValidator,
+/// bearerTokenValidator,
+/// // ...
+/// ])
+/// ```
+public struct ProtectedResourceMetadataValidator: HTTPRequestValidator {
+ private let encodedMetadata: Data
+
+ public init(metadata: OAuthProtectedResourceServerMetadata) {
+ self.encodedMetadata = (try? JSONEncoder().encode(metadata)) ?? Data()
+ }
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ guard context.httpMethod == "GET",
+ let path = request.path,
+ path == OAuthWellKnownPath.protectedResource
+ || path.hasPrefix("\(OAuthWellKnownPath.protectedResource)/")
+ else {
+ return nil
+ }
+ return .data(encodedMetadata, headers: [HTTPHeaderName.contentType: ContentType.json])
+ }
+}
+
+// MARK: - OAuth Bearer Validator
+
+/// Result produced by ``BearerTokenValidator`` when validating an access token.
+public enum BearerTokenValidationResult: Sendable, Equatable {
+ /// Access token is valid for this request, with its extracted claims.
+ ///
+ /// Supply a ``BearerTokenInfo`` with `audience` and `expiresAt` populated so that
+ /// ``BearerTokenValidator`` can enforce expiry and audience checks automatically.
+ /// Pass `BearerTokenInfo()` (all `nil`) to delegate all enforcement to the caller.
+ case valid(BearerTokenInfo)
+
+ /// Access token is missing required privileges, and new scopes are required.
+ case insufficientScope(requiredScopes: Set, errorDescription: String? = nil)
+
+ /// Access token is invalid or expired.
+ case invalidToken(errorDescription: String? = nil)
+
+ /// Authorization request is malformed.
+ case malformedRequest(errorDescription: String? = nil)
+}
+
+/// Validates OAuth 2.1 Bearer authorization for protected MCP HTTP endpoints.
+///
+/// This validator implements resource-server error semantics aligned with the MCP auth spec:
+/// - `401` with `WWW-Authenticate: Bearer ...` for missing/invalid tokens
+/// - `403` with `error="insufficient_scope"` for insufficient permissions
+/// - `400` for malformed authorization requests
+///
+/// Include this validator early in your pipeline, before `SessionValidator`, so unauthenticated
+/// initialization requests can return a challenge.
+///
+/// ## Audience Validation (MUST)
+///
+/// Per the MCP authorization specification, **the resource server MUST validate the audience
+/// (`aud` claim) of the access token** to ensure it matches the resource server's own identifier.
+/// Failure to validate the audience allows token substitution attacks where a token intended
+/// for a different resource is replayed against your server.
+///
+/// Your ``TokenValidator`` closure **MUST** verify the audience. Example:
+///
+/// ```swift
+/// let validator = BearerTokenValidator(
+/// resourceMetadataURL: metadataURL,
+/// tokenValidator: { token, request, context in
+/// guard let claims = verifyAndDecode(token) else {
+/// return .invalidToken(errorDescription: "Token verification failed")
+/// }
+/// // MUST: Verify token audience matches this resource server
+/// guard claims.audience.contains("https://api.example.com") else {
+/// return .invalidToken(errorDescription: "Token audience mismatch")
+/// }
+/// return .valid
+/// }
+/// )
+/// ```
+public struct BearerTokenValidator: HTTPRequestValidator {
+ /// Validates a bearer token and returns token info for audience and expiry enforcement.
+ public typealias TokenValidator = @Sendable (
+ _ token: String,
+ _ request: HTTPRequest,
+ _ context: HTTPValidationContext
+ ) -> BearerTokenValidationResult
+
+ /// Closure that returns the scopes to advertise in `WWW-Authenticate` challenge headers.
+ ///
+ /// Return `nil` to omit the `scope` parameter from the challenge.
+ public typealias ChallengeScopeProvider = @Sendable (
+ _ request: HTTPRequest,
+ _ context: HTTPValidationContext
+ ) -> Set?
+
+ /// Closure that decides whether a request requires Bearer authentication.
+ ///
+ /// Return `false` to allow a request through unauthenticated (e.g., public health-check endpoints).
+ /// Defaults to requiring authentication on all requests.
+ public typealias RequirementPredicate = @Sendable (
+ _ request: HTTPRequest,
+ _ context: HTTPValidationContext
+ ) -> Bool
+
+ public let resourceMetadataURL: URL
+ public let resourceIdentifier: URL
+ private let tokenValidator: TokenValidator
+ private let challengeScopeProvider: ChallengeScopeProvider?
+ private let requiresAuthentication: RequirementPredicate
+ private let metadataDiscovery: any OAuthMetadataDiscovering
+
+ /// Creates a `BearerTokenValidator`.
+ ///
+ /// - Parameters:
+ /// - resourceMetadataURL: Included in `WWW-Authenticate` challenge headers as the
+ /// `resource_metadata` parameter, pointing to the RFC 9728 Protected Resource Metadata document.
+ /// - resourceIdentifier: The canonical URI of this resource server. Used to validate the
+ /// `aud` claim in tokens that supply audience information via ``BearerTokenInfo``.
+ /// - tokenValidator: Validates the Bearer token and returns ``BearerTokenInfo`` with
+ /// claims for SDK-side expiry and audience enforcement.
+ /// - challengeScopeProvider: Optional closure supplying scopes to include in challenge headers.
+ /// - requiresAuthentication: Predicate controlling which requests require a Bearer token.
+ /// Defaults to requiring authentication on all requests.
+ /// - metadataDiscovery: Used for audience URL matching. Defaults to ``DefaultOAuthMetadataDiscovery``.
+ public init(
+ resourceMetadataURL: URL,
+ resourceIdentifier: URL,
+ tokenValidator: @escaping TokenValidator,
+ challengeScopeProvider: ChallengeScopeProvider? = nil,
+ requiresAuthentication: @escaping RequirementPredicate = { _, _ in true },
+ metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
+ ) {
+ self.resourceMetadataURL = resourceMetadataURL
+ self.resourceIdentifier = resourceIdentifier
+ self.tokenValidator = tokenValidator
+ self.challengeScopeProvider = challengeScopeProvider
+ self.requiresAuthentication = requiresAuthentication
+ self.metadataDiscovery = metadataDiscovery
+ }
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ guard requiresAuthentication(request, context) else { return nil }
+
+ guard let authorizationHeader = request.header(HTTPHeaderName.authorization) else {
+ return unauthorizedResponse(
+ challengeScope: challengeScopeProvider?(request, context),
+ error: nil,
+ errorDescription: nil,
+ sessionID: context.sessionID
+ )
+ }
+
+ let parsedToken: String
+ switch parseBearerToken(from: authorizationHeader) {
+ case .success(let token):
+ parsedToken = token
+ case .failure(let error):
+ return .error(
+ statusCode: 400,
+ .invalidRequest("Bad Request: \(error.message)"),
+ sessionID: context.sessionID
+ )
+ }
+
+ switch tokenValidator(parsedToken, request, context) {
+ case .valid(let info):
+ // Expiry check
+ if let exp = info.expiresAt, exp <= Date() {
+ return unauthorizedResponse(
+ challengeScope: challengeScopeProvider?(request, context),
+ error: "invalid_token",
+ errorDescription: "Token has expired",
+ sessionID: context.sessionID
+ )
+ }
+ // Audience check — skipped for opaque tokens (audience == nil)
+ if let audience = info.audience {
+ let matches = audience.contains { audString in
+ guard let audURL = URL(string: audString) else { return false }
+ return metadataDiscovery.protectedResourceMatches(
+ resource: audURL, endpoint: resourceIdentifier)
+ }
+ if !matches {
+ return unauthorizedResponse(
+ challengeScope: challengeScopeProvider?(request, context),
+ error: "invalid_token",
+ errorDescription: "Token audience mismatch",
+ sessionID: context.sessionID
+ )
+ }
+ }
+ return nil
+
+ case .invalidToken(let errorDescription):
+ return unauthorizedResponse(
+ challengeScope: challengeScopeProvider?(request, context),
+ error: "invalid_token",
+ errorDescription: errorDescription,
+ sessionID: context.sessionID
+ )
+
+ case .insufficientScope(let requiredScopes, let errorDescription):
+ return forbiddenInsufficientScopeResponse(
+ requiredScopes: requiredScopes,
+ errorDescription: errorDescription,
+ sessionID: context.sessionID
+ )
+
+ case .malformedRequest(let errorDescription):
+ let message = errorDescription ?? "Malformed authorization request"
+ return .error(
+ statusCode: 400,
+ .invalidRequest("Bad Request: \(message)"),
+ sessionID: context.sessionID
+ )
+ }
+ }
+
+ private func unauthorizedResponse(
+ challengeScope: Set?,
+ error: String?,
+ errorDescription: String?,
+ sessionID: String?
+ ) -> HTTPResponse {
+ let challenge = makeBearerChallenge(
+ resourceMetadataURL: resourceMetadataURL,
+ scope: challengeScope,
+ error: error,
+ errorDescription: errorDescription
+ )
+ return .error(
+ statusCode: 401,
+ .invalidRequest("Unauthorized"),
+ sessionID: sessionID,
+ extraHeaders: [HTTPHeaderName.wwwAuthenticate: challenge]
+ )
+ }
+
+ private func forbiddenInsufficientScopeResponse(
+ requiredScopes: Set,
+ errorDescription: String?,
+ sessionID: String?
+ ) -> HTTPResponse {
+ let challenge = makeBearerChallenge(
+ resourceMetadataURL: resourceMetadataURL,
+ scope: requiredScopes,
+ error: "insufficient_scope",
+ errorDescription: errorDescription
+ )
+ return .error(
+ statusCode: 403,
+ .invalidRequest("Forbidden: Insufficient scope"),
+ sessionID: sessionID,
+ extraHeaders: [HTTPHeaderName.wwwAuthenticate: challenge]
+ )
+ }
+
+ private func makeBearerChallenge(
+ resourceMetadataURL: URL,
+ scope: Set?,
+ error: String?,
+ errorDescription: String?
+ ) -> String {
+ var parameters: [String] = []
+ parameters.append("resource_metadata=\"\(escapeAuthParameter(resourceMetadataURL.absoluteString))\"")
+
+ if let scope, !scope.isEmpty {
+ let serializedScope = scope.sorted().joined(separator: " ")
+ parameters.append("scope=\"\(escapeAuthParameter(serializedScope))\"")
+ }
+
+ if let error {
+ parameters.append("error=\"\(escapeAuthParameter(error))\"")
+ }
+
+ if let errorDescription, !errorDescription.isEmpty {
+ parameters.append("error_description=\"\(escapeAuthParameter(errorDescription))\"")
+ }
+
+ return "\(OAuthTokenType.bearer) " + parameters.joined(separator: ", ")
+ }
+
+ private func escapeAuthParameter(_ value: String) -> String {
+ value
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ }
+
+ private struct BearerTokenParseError: Swift.Error {
+ let message: String
+ }
+
+ private func parseBearerToken(
+ from authorizationHeader: String
+ ) -> Result {
+ let trimmed = authorizationHeader.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ return .failure(.init(message: "Authorization header is empty"))
+ }
+
+ let parts = trimmed.split(
+ maxSplits: 1,
+ whereSeparator: { $0.isWhitespace }
+ )
+
+ guard parts.count == 2 else {
+ return .failure(
+ .init(message: "Authorization header must be in the form: Bearer ")
+ )
+ }
+
+ guard String(parts[0]).caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame else {
+ return .failure(.init(message: "Authorization scheme must be Bearer"))
+ }
+
+ let token = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !token.isEmpty else {
+ return .failure(.init(message: "Bearer token is empty"))
+ }
+
+ if token.contains(where: \.isWhitespace) {
+ return .failure(.init(message: "Bearer token must not contain whitespace"))
+ }
+
+ return .success(token)
+ }
+}
+
+// MARK: - Validation Pipeline Protocol
+
+/// Runs a validation pipeline against an HTTP request.
+///
+/// Implementations execute a sequence of validators and return the first error,
+/// or `nil` if all validations pass.
+public protocol HTTPRequestValidationPipeline: Sendable {
+ /// Validates the request using the configured pipeline.
+ /// Returns an error response if validation fails, or `nil` if the request is valid.
+ func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
+}
+
+// MARK: - Standard Validation Pipeline
+
+/// Standard implementation of `HTTPRequestValidationPipeline` that runs validators in sequence.
+///
+/// The first validator that returns a non-nil error response short-circuits the pipeline.
+public struct StandardValidationPipeline: HTTPRequestValidationPipeline {
+ private let validators: [any HTTPRequestValidator]
+
+ /// Creates a pipeline with the given validators.
+ /// Validators are executed in the order provided.
+ public init(validators: [any HTTPRequestValidator]) {
+ self.validators = validators
+ }
+
+ public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
+ for validator in validators {
+ if let errorResponse = validator.validate(request, context: context) {
+ return errorResponse
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift b/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift
new file mode 100644
index 0000000..bd56191
--- /dev/null
+++ b/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift
@@ -0,0 +1,285 @@
+import Foundation
+
+// MARK: - Session ID Generator
+
+/// Generates unique session identifiers for stateful HTTP server transports.
+///
+/// Conform to this protocol to provide custom session ID generation logic.
+/// Session IDs **MUST** contain only visible ASCII characters (0x21–0x7E)
+/// per the MCP specification.
+///
+/// A default implementation using UUID is provided via ``UUIDSessionIDGenerator``.
+public protocol SessionIDGenerator: Sendable {
+ /// Generates a new unique session identifier.
+ func generateSessionID() -> String
+}
+
+/// Default session ID generator that produces UUID strings.
+///
+/// UUID strings consist of hexadecimal characters and hyphens,
+/// which are all within the valid ASCII range (0x21–0x7E).
+public struct UUIDSessionIDGenerator: SessionIDGenerator {
+ public init() {}
+
+ public func generateSessionID() -> String {
+ UUID().uuidString
+ }
+}
+
+// MARK: - HTTP Request
+
+/// A framework-agnostic HTTP request representation.
+///
+/// This type decouples the transport from any specific HTTP framework.
+/// The HTTP framework adapter converts its native request type into this before passing to the transport.
+public struct HTTPRequest: Sendable {
+ /// The HTTP method (e.g., "GET", "POST", "DELETE").
+ public let method: String
+
+ /// HTTP headers as key-value pairs.
+ public let headers: [String: String]
+
+ /// The request body data, if any.
+ public let body: Data?
+
+ /// The request path (e.g., "/mcp", "/.well-known/oauth-protected-resource").
+ /// Used by validators that need to match on specific paths.
+ public let path: String?
+
+ public init(method: String, headers: [String: String] = [:], body: Data? = nil, path: String? = nil) {
+ self.method = method
+ self.headers = headers
+ self.body = body
+ self.path = path
+ }
+
+ /// Case-insensitive header lookup.
+ public func header(_ name: String) -> String? {
+ let lowercased = name.lowercased()
+ return headers.first { $0.key.lowercased() == lowercased }?.value
+ }
+}
+
+// MARK: - HTTP Response
+
+/// A framework-agnostic HTTP response.
+///
+/// The HTTP framework adapter converts this into its native response type.
+///
+/// Use computed properties (`statusCode`, `headers`, `bodyData`) for generic access,
+/// or switch on the enum for case-specific handling (e.g., streaming):
+///
+/// ```swift
+/// let response = await transport.handleRequest(request)
+/// switch response {
+/// case .stream(let sseStream, _):
+/// // Pipe the async stream to the HTTP response body
+/// default:
+/// // Use response.bodyData for the body
+/// }
+/// ```
+public enum HTTPResponse: Sendable {
+ /// 202 Accepted, no body. Used for notifications and client responses.
+ case accepted(headers: [String: String] = [:])
+
+ /// 200 OK, no body. Used for DELETE confirmation.
+ case ok(headers: [String: String] = [:])
+
+ /// 200 OK with data body (typically JSON).
+ case data(Data, headers: [String: String] = [:])
+
+ /// 200 OK with SSE streaming body.
+ case stream(AsyncThrowingStream, headers: [String: String] = [:])
+
+ /// Error response with a JSON-RPC error body.
+ /// The status code, headers, and body are derived automatically.
+ case error(statusCode: Int, MCPError, sessionID: String? = nil, extraHeaders: [String: String] = [:])
+
+ // MARK: - Computed Properties
+
+ public var statusCode: Int {
+ switch self {
+ case .accepted: 202
+ case .ok, .data, .stream: 200
+ case .error(let code, _, _, _): code
+ }
+ }
+
+ public var headers: [String: String] {
+ switch self {
+ case .accepted(let headers), .ok(let headers), .data(_, let headers), .stream(_, let headers):
+ return headers
+ case .error(_, _, let sessionID, let extraHeaders):
+ var headers: [String: String] = [HTTPHeaderName.contentType: ContentType.json]
+ if let sessionID { headers[HTTPHeaderName.sessionID] = sessionID }
+ headers.merge(extraHeaders) { _, new in new }
+ return headers
+ }
+ }
+
+ /// The response body as data. `nil` for `.accepted`, `.ok`, and `.stream`.
+ public var bodyData: Data? {
+ switch self {
+ case .accepted, .ok, .stream:
+ return nil
+ case .data(let data, _):
+ return data
+ case .error(_, let error, _, _):
+ let errorBody: [String: Any] = [
+ "jsonrpc": "2.0",
+ "error": [
+ "code": error.code,
+ "message": error.errorDescription ?? "Unknown error",
+ ],
+ "id": NSNull(),
+ ]
+ return try? JSONSerialization.data(withJSONObject: errorBody)
+ }
+ }
+}
+
+// MARK: - HTTP Header Names
+
+/// Standard header names used by the MCP Streamable HTTP transport.
+public enum HTTPHeaderName {
+ public static let sessionID = "MCP-Session-Id"
+ public static let protocolVersion = "MCP-Protocol-Version"
+ public static let lastEventID = "Last-Event-ID"
+ public static let accept = "Accept"
+ public static let contentType = "Content-Type"
+ public static let authorization = "Authorization"
+ public static let wwwAuthenticate = "WWW-Authenticate"
+ public static let origin = "Origin"
+ public static let host = "Host"
+ public static let cacheControl = "Cache-Control"
+ public static let connection = "Connection"
+ public static let allow = "Allow"
+}
+
+// MARK: - Content Types
+
+enum ContentType {
+ static let json = "application/json"
+ static let sse = "text/event-stream"
+}
+
+// MARK: - SSE Event
+
+/// A Server-Sent Event (SSE) data structure.
+///
+/// Formats according to the SSE specification:
+/// https://html.spec.whatwg.org/multipage/server-sent-events.html
+struct SSEEvent: Sendable {
+ var id: String?
+ var event: String?
+ var data: String
+ var retry: Int?
+
+ /// Formats the event as SSE wire data.
+ func formatted() -> Data {
+ var result = ""
+ if let id {
+ result += "id: \(id)\n"
+ }
+ if let event {
+ result += "event: \(event)\n"
+ }
+ if let retry {
+ result += "retry: \(retry)\n"
+ }
+ result += "data: \(data)\n\n"
+ return Data(result.utf8)
+ }
+
+ /// Creates a priming event with an empty data field.
+ /// Per spec, this is sent immediately to prime the client for reconnection.
+ static func priming(id: String, retry: Int? = nil) -> SSEEvent {
+ SSEEvent(id: id, event: nil, data: "", retry: retry)
+ }
+
+ /// Creates a message event wrapping JSON-RPC data.
+ static func message(data: Data, id: String? = nil) -> SSEEvent {
+ SSEEvent(
+ id: id,
+ event: "message",
+ data: String(decoding: data, as: UTF8.self)
+ )
+ }
+}
+
+// MARK: - HTTP Context Providing
+
+/// A transport that can surface the originating HTTP request for an in-flight JSON-RPC
+/// request, keyed by its JSON-RPC id.
+///
+/// The server consults this during dispatch so that registered method handlers can
+/// observe the HTTP request that triggered them (headers, auth, path, body) via
+/// ``Server/currentHandlerContext``, without changing the `withMethodHandler` signature.
+///
+/// Only JSON-RPC *requests* (with an id) are addressable — notifications have no id and
+/// are not correlated.
+public protocol HTTPContextProviding: Sendable {
+ /// Returns the HTTP request associated with the given JSON-RPC id, if the
+ /// transport still has it on hand. Implementations must return `nil` once the
+ /// corresponding response has been delivered or the session has terminated.
+ func httpRequestContext(for id: ID) async -> HTTPRequest?
+}
+
+// MARK: - JSON-RPC Message Classification
+
+/// Classifies a raw JSON-RPC message for routing purposes.
+///
+/// Used by transports to determine where to route outgoing messages:
+/// - Responses are routed to the originating request's stream
+/// - Notifications and server requests are routed to the standalone GET stream
+package enum JSONRPCMessageKind {
+ case request(id: String, method: String)
+ case notification(method: String)
+ case response(id: String)
+
+ /// Attempts to classify raw JSON-RPC data.
+ /// Returns `nil` if the data cannot be parsed or classified.
+ package init?(data: Data) {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+
+ let id = Self.extractID(from: json)
+
+ if let method = json["method"] as? String {
+ if let id {
+ self = .request(id: id, method: method)
+ } else {
+ self = .notification(method: method)
+ }
+ } else if json["result"] != nil || json["error"] != nil {
+ guard let id else { return nil }
+ self = .response(id: id)
+ } else {
+ return nil
+ }
+ }
+
+ /// Whether this message is a JSON-RPC response (success or error).
+ var isResponse: Bool {
+ if case .response = self { return true }
+ return false
+ }
+
+ /// Whether this message is an `initialize` request.
+ package var isInitializeRequest: Bool {
+ if case .request(_, let method) = self {
+ return method == "initialize"
+ }
+ return false
+ }
+
+ private static func extractID(from json: [String: Any]) -> String? {
+ if let stringID = json["id"] as? String {
+ return stringID
+ } else if let intID = json["id"] as? Int {
+ return String(intID)
+ }
+ return nil
+ }
+}
diff --git a/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift b/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift
new file mode 100644
index 0000000..c7a4d48
--- /dev/null
+++ b/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift
@@ -0,0 +1,581 @@
+import Foundation
+import Logging
+
+/// A stateful HTTP server transport that manages sessions and uses SSE for streaming responses.
+///
+/// This transport implements the MCP Streamable HTTP specification with full session management:
+/// - Assigns a session ID during initialization (via `Mcp-Session-Id` header)
+/// - POST requests receive SSE-streamed responses
+/// - GET requests establish a standalone SSE stream for server-initiated messages
+/// - DELETE requests terminate the session
+/// - Built-in event store for resumability (reconnection with `Last-Event-ID`)
+///
+/// ## Usage
+///
+/// ```swift
+/// let transport = StatefulHTTPServerTransport() // Uses UUID by default
+///
+/// // Start the MCP server with this transport
+/// try await server.start(transport: transport)
+///
+/// // In your HTTP framework handler:
+/// let response = await transport.handleRequest(httpRequest)
+/// // Convert response to your framework's response type and return it
+/// ```
+///
+/// ## Framework Integration
+///
+/// This transport is framework-agnostic. You provide incoming requests as `HTTPRequest`
+/// and receive `HTTPResponse` values to convert to your framework's native types.
+/// For SSE responses, the `.stream` case provides an `AsyncThrowingStream`
+/// to pipe to the client.
+public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
+ public nonisolated let logger: Logger
+
+ // MARK: - Dependencies
+
+ private let sessionIDGenerator: any SessionIDGenerator
+ private let validationPipeline: any HTTPRequestValidationPipeline
+ private let retryInterval: Int?
+
+ // MARK: - State
+
+ private var sessionID: String?
+ private var terminated = false
+ private var started = false
+
+ // MARK: - Incoming message stream (client → server)
+
+ private let incomingStream: AsyncThrowingStream
+ private let incomingContinuation: AsyncThrowingStream.Continuation
+
+ // MARK: - SSE streams for POST request responses
+
+ /// Maps request ID → SSE stream continuation for active POST request streams.
+ private var requestSSEContinuations: [String: AsyncThrowingStream.Continuation] = [:]
+
+ /// Maps request ID → originating HTTP request, surfaced to handlers via
+ /// ``Server/currentHTTPContext``. Cleared when the response is routed,
+ /// the stream is closed, or the session terminates.
+ private var httpRequestContexts: [String: HTTPRequest] = [:]
+
+ // MARK: - Standalone GET SSE stream
+
+ /// The standalone SSE stream continuation for server-initiated messages.
+ /// Only one GET stream is allowed per session.
+ private var standaloneSSEContinuation: AsyncThrowingStream.Continuation?
+
+ /// Internal identifier for the standalone GET stream in the event store.
+ private let standaloneStreamID = "_GET_stream"
+
+ // MARK: - Event Store (Resumability)
+
+ private struct StoredEvent {
+ let streamID: String
+ let eventID: String
+ let message: Data?
+ }
+
+ private var storedEvents: [StoredEvent] = []
+ private var eventCounter: Int = 0
+
+ // MARK: - Init
+
+ /// Creates a new stateful HTTP server transport.
+ ///
+ /// - Parameters:
+ /// - sessionIDGenerator: Generator for session IDs. The IDs MUST contain
+ /// only visible ASCII characters (0x21-0x7E) per the MCP specification.
+ /// Defaults to ``UUIDSessionIDGenerator``.
+ /// - validationPipeline: Custom validation pipeline. If `nil`, uses sensible defaults:
+ /// origin validation (localhost), Accept header (SSE required), Content-Type,
+ /// protocol version, and session validation.
+ /// - retryInterval: Retry interval in milliseconds for SSE priming events.
+ /// Controls how long clients wait before attempting to reconnect.
+ /// - logger: Optional logger. If `nil`, a no-op logger is used.
+ public init(
+ sessionIDGenerator: any SessionIDGenerator = UUIDSessionIDGenerator(),
+ validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
+ retryInterval: Int? = nil,
+ logger: Logger? = nil
+ ) {
+ self.sessionIDGenerator = sessionIDGenerator
+ self.validationPipeline = validationPipeline ?? StandardValidationPipeline(validators: [
+ OriginValidator.localhost(),
+ AcceptHeaderValidator(mode: .sseRequired),
+ ContentTypeValidator(),
+ ProtocolVersionValidator(),
+ SessionValidator(),
+ ])
+ self.retryInterval = retryInterval
+ self.logger = logger ?? Logger(
+ label: "mcp.transport.http.server.stateful",
+ factory: { _ in SwiftLogNoOpLogHandler() }
+ )
+
+ let (stream, continuation) = AsyncThrowingStream.makeStream()
+ self.incomingStream = stream
+ self.incomingContinuation = continuation
+ }
+
+ // MARK: - Transport Conformance
+
+ public func connect() async throws {
+ guard !started else {
+ throw MCPError.internalError("Transport already started")
+ }
+ started = true
+ logger.debug("Stateful HTTP server transport started")
+ }
+
+ public func disconnect() async {
+ terminate()
+ }
+
+ /// Routes outgoing server messages to the appropriate client connection.
+ ///
+ /// - Responses are routed to the SSE stream matching the response's JSON-RPC ID.
+ /// - Notifications and server-initiated requests are routed to the standalone GET stream.
+ public func send(_ data: Data) async throws {
+ guard !terminated else {
+ throw MCPError.connectionClosed
+ }
+
+ guard let kind = JSONRPCMessageKind(data: data) else {
+ logger.warning("Could not classify outgoing message for routing")
+ return
+ }
+
+ switch kind {
+ case .response(let id):
+ routeResponse(data, requestID: id)
+ case .notification, .request:
+ routeServerInitiatedMessage(data)
+ }
+ }
+
+ public func receive() -> AsyncThrowingStream {
+ incomingStream
+ }
+
+ // MARK: - HTTP Request Handler
+
+ /// Handles an incoming HTTP request from the framework adapter.
+ ///
+ /// Routes by HTTP method:
+ /// - **POST**: JSON-RPC messages (requests, notifications)
+ /// - **GET**: Establish standalone SSE stream for server-initiated messages
+ /// - **DELETE**: Terminate the session
+ /// - Others: 405 Method Not Allowed
+ public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
+ if terminated {
+ return .error(
+ statusCode: 404,
+ .invalidRequest("Not Found: Session has been terminated"),
+ sessionID: sessionID
+ )
+ }
+
+ switch request.method.uppercased() {
+ case "POST":
+ return handlePost(request)
+ case "GET":
+ return handleGet(request)
+ case "DELETE":
+ return handleDelete(request)
+ default:
+ return .error(
+ statusCode: 405,
+ .invalidRequest("Method Not Allowed"),
+ sessionID: sessionID,
+ extraHeaders: [HTTPHeaderName.allow: "GET, POST, DELETE"]
+ )
+ }
+ }
+
+ // MARK: - POST Handler
+
+ private func handlePost(_ request: HTTPRequest) -> HTTPResponse {
+ // Parse body first so we can determine if it's an initialization request
+ guard let body = request.body, !body.isEmpty else {
+ return .error(
+ statusCode: 400,
+ .parseError("Empty request body"),
+ sessionID: sessionID
+ )
+ }
+
+ guard let messageKind = JSONRPCMessageKind(data: body) else {
+ return .error(
+ statusCode: 400,
+ .parseError("Invalid JSON-RPC message"),
+ sessionID: sessionID
+ )
+ }
+
+ // Build validation context
+ let context = HTTPValidationContext(
+ httpMethod: "POST",
+ sessionID: sessionID,
+ isInitializationRequest: messageKind.isInitializeRequest,
+ supportedProtocolVersions: Version.supported
+ )
+
+ // Run validation pipeline
+ if let errorResponse = validationPipeline.validate(request, context: context) {
+ return errorResponse
+ }
+
+ // Handle initialization request specially
+ if messageKind.isInitializeRequest {
+ return handleInitializationRequest(body, request: request)
+ }
+
+ // Handle by message type
+ switch messageKind {
+ case .notification, .response:
+ // Yield to server and return 202 Accepted
+ incomingContinuation.yield(body)
+ return .accepted(headers: sessionHeaders())
+
+ case .request(let id, _):
+ return handleJSONRPCRequest(body, requestID: id, request: request)
+ }
+ }
+
+ private func handleInitializationRequest(_ body: Data, request: HTTPRequest) -> HTTPResponse {
+ // Reject re-initialization at the transport level
+ if sessionID != nil {
+ return .error(
+ statusCode: 400,
+ .invalidRequest("Bad Request: Session already initialized"),
+ sessionID: sessionID
+ )
+ }
+
+ // Generate session ID
+ let newSessionID = sessionIDGenerator.generateSessionID()
+
+ // Validate session ID contains only visible ASCII (0x21-0x7E)
+ guard isValidSessionID(newSessionID) else {
+ logger.error("Generated session ID contains invalid characters")
+ return .error(
+ statusCode: 500,
+ .internalError("Internal error: Invalid session ID generated")
+ )
+ }
+
+ self.sessionID = newSessionID
+ logger.info("Session initialized", metadata: ["sessionID": "\(newSessionID)"])
+
+ // Extract request ID for routing the response
+ guard case .request(let requestID, _) = JSONRPCMessageKind(data: body) else {
+ return .error(
+ statusCode: 400,
+ .parseError("Invalid initialize request"),
+ sessionID: newSessionID
+ )
+ }
+
+ // For the initialize request, use SSE streaming like any other request
+ return handleJSONRPCRequest(body, requestID: requestID, request: request)
+ }
+
+ private func handleJSONRPCRequest(_ body: Data, requestID: String, request: HTTPRequest) -> HTTPResponse {
+ // Create SSE stream for this request
+ let (sseStream, sseContinuation) = AsyncThrowingStream.makeStream()
+ requestSSEContinuations[requestID] = sseContinuation
+ httpRequestContexts[requestID] = request
+
+ // Extract protocol version for priming event decision
+ let protocolVersion = extractProtocolVersion(from: body, request: request)
+
+ // Send priming event for resumability
+ sendPrimingEvent(
+ streamID: requestID,
+ continuation: sseContinuation,
+ protocolVersion: protocolVersion
+ )
+
+ // Yield the incoming message to the server
+ incomingContinuation.yield(body)
+
+ // Build response headers
+ var headers = sessionHeaders()
+ headers[HTTPHeaderName.contentType] = ContentType.sse
+ headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
+ headers[HTTPHeaderName.connection] = "keep-alive"
+
+ return .stream(sseStream, headers: headers)
+ }
+
+ // MARK: - GET Handler
+
+ private func handleGet(_ request: HTTPRequest) -> HTTPResponse {
+ // Build validation context (GET is never an initialization request)
+ let context = HTTPValidationContext(
+ httpMethod: "GET",
+ sessionID: sessionID,
+ isInitializationRequest: false,
+ supportedProtocolVersions: Version.supported
+ )
+
+ // Run validation pipeline
+ if let errorResponse = validationPipeline.validate(request, context: context) {
+ return errorResponse
+ }
+
+ // Handle resumability: check for Last-Event-ID header
+ if let lastEventID = request.header(HTTPHeaderName.lastEventID) {
+ return handleResumeRequest(lastEventID: lastEventID, request: request)
+ }
+
+ // Only one standalone GET stream per session
+ guard standaloneSSEContinuation == nil else {
+ return .error(
+ statusCode: 409,
+ .invalidRequest("Conflict: Only one SSE stream is allowed per session"),
+ sessionID: sessionID
+ )
+ }
+
+ // Create standalone SSE stream
+ let (sseStream, sseContinuation) = AsyncThrowingStream.makeStream()
+ standaloneSSEContinuation = sseContinuation
+
+ // Extract protocol version for priming event
+ let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
+
+ // Send priming event
+ sendPrimingEvent(
+ streamID: standaloneStreamID,
+ continuation: sseContinuation,
+ protocolVersion: protocolVersion
+ )
+
+ // Build response headers
+ var headers = sessionHeaders()
+ headers[HTTPHeaderName.contentType] = ContentType.sse
+ headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
+ headers[HTTPHeaderName.connection] = "keep-alive"
+
+ return .stream(sseStream, headers: headers)
+ }
+
+ // MARK: - DELETE Handler
+
+ private func handleDelete(_ request: HTTPRequest) -> HTTPResponse {
+ // Validate session
+ let context = HTTPValidationContext(
+ httpMethod: "DELETE",
+ sessionID: sessionID,
+ isInitializationRequest: false,
+ supportedProtocolVersions: Version.supported
+ )
+
+ if let errorResponse = validationPipeline.validate(request, context: context) {
+ return errorResponse
+ }
+
+ terminate()
+
+ return .ok(headers: sessionHeaders())
+ }
+
+ // MARK: - Message Routing
+
+ /// Routes a message to a specific request's SSE stream without closing it.
+ /// Used for server-initiated messages during request handling.
+ private func routeToRequestStream(_ data: Data, requestID: String) {
+ let eventID = storeEvent(streamID: requestID, message: data)
+
+ guard let continuation = requestSSEContinuations[requestID] else {
+ logger.debug(
+ "No active stream for request, message stored for replay",
+ metadata: ["requestID": "\(requestID)"]
+ )
+ return
+ }
+
+ // Format as SSE and yield (but don't close the stream)
+ let sseEvent = SSEEvent.message(data: data, id: eventID)
+ continuation.yield(sseEvent.formatted())
+ }
+
+ /// Routes a response to a specific request's SSE stream and closes it.
+ /// Used for final responses to client requests.
+ private func routeResponse(_ data: Data, requestID: String) {
+ routeToRequestStream(data, requestID: requestID)
+
+ // Response means the request is complete — close the stream
+ if let continuation = requestSSEContinuations[requestID] {
+ continuation.finish()
+ requestSSEContinuations.removeValue(forKey: requestID)
+ }
+ httpRequestContexts.removeValue(forKey: requestID)
+ }
+
+ private func routeServerInitiatedMessage(_ data: Data) {
+ let eventID = storeEvent(streamID: standaloneStreamID, message: data)
+
+ guard let continuation = standaloneSSEContinuation else {
+ logger.debug("No standalone GET stream connected, message stored for replay")
+ return
+ }
+
+ let sseEvent = SSEEvent.message(data: data, id: eventID)
+ continuation.yield(sseEvent.formatted())
+ }
+
+ // MARK: - Resumability
+
+ private func handleResumeRequest(lastEventID: String, request: HTTPRequest) -> HTTPResponse {
+ guard let replay = replayEventsAfter(lastEventID) else {
+ return .error(
+ statusCode: 400,
+ .invalidRequest("Invalid Last-Event-ID"),
+ sessionID: sessionID
+ )
+ }
+
+ let (sseStream, sseContinuation) = AsyncThrowingStream.makeStream()
+
+ // Replay stored events
+ for (eventID, message) in replay.events {
+ let sseEvent = SSEEvent.message(data: message, id: eventID)
+ sseContinuation.yield(sseEvent.formatted())
+ }
+
+ // Re-register the stream for future messages
+ if replay.streamID == standaloneStreamID {
+ standaloneSSEContinuation = sseContinuation
+ } else {
+ requestSSEContinuations[replay.streamID] = sseContinuation
+ }
+
+ // Send a new priming event so the client can resume again if disconnected
+ let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
+ sendPrimingEvent(
+ streamID: replay.streamID,
+ continuation: sseContinuation,
+ protocolVersion: protocolVersion
+ )
+
+ var headers = sessionHeaders()
+ headers[HTTPHeaderName.contentType] = ContentType.sse
+ headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
+ headers[HTTPHeaderName.connection] = "keep-alive"
+
+ return .stream(sseStream, headers: headers)
+ }
+
+ // MARK: - Internal Event Store
+
+ private func storeEvent(streamID: String, message: Data?) -> String {
+ eventCounter += 1
+ let eventID = "\(streamID)_\(eventCounter)"
+ storedEvents.append(StoredEvent(streamID: streamID, eventID: eventID, message: message))
+ return eventID
+ }
+
+ private func replayEventsAfter(_ lastEventID: String) -> (streamID: String, events: [(eventID: String, message: Data)])? {
+ guard let index = storedEvents.firstIndex(where: { $0.eventID == lastEventID }) else {
+ return nil
+ }
+ let streamID = storedEvents[index].streamID
+ let eventsToReplay = storedEvents[(index + 1)...]
+ .filter { $0.streamID == streamID && $0.message != nil }
+ .map { (eventID: $0.eventID, message: $0.message!) }
+ return (streamID, eventsToReplay)
+ }
+
+ // MARK: - SSE Helpers
+
+ private func sendPrimingEvent(
+ streamID: String,
+ continuation: AsyncThrowingStream.Continuation,
+ protocolVersion: String
+ ) {
+ // Priming events with empty data are only safe for clients >= 2025-03-26
+ guard protocolVersion >= "2025-03-26" else { return }
+
+ let primingEventID = storeEvent(streamID: streamID, message: nil)
+ let primingEvent = SSEEvent.priming(id: primingEventID, retry: retryInterval)
+ continuation.yield(primingEvent.formatted())
+ }
+
+ private func extractProtocolVersion(from body: Data, request: HTTPRequest) -> String {
+ // For initialize requests, extract from the request body params
+ if let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
+ let method = json["method"] as? String, method == "initialize",
+ let params = json["params"] as? [String: Any],
+ let version = params["protocolVersion"] as? String
+ {
+ return version
+ }
+ // For other requests, use the header
+ return request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
+ }
+
+ // MARK: - Session Helpers
+
+ private func sessionHeaders() -> [String: String] {
+ var headers: [String: String] = [:]
+ if let sessionID {
+ headers[HTTPHeaderName.sessionID] = sessionID
+ }
+ return headers
+ }
+
+ private func isValidSessionID(_ id: String) -> Bool {
+ guard !id.isEmpty else { return false }
+ return id.utf8.allSatisfy { $0 >= 0x21 && $0 <= 0x7E }
+ }
+
+ // MARK: - Stream Control
+
+ /// Closes the SSE stream for a specific request without sending a response.
+ ///
+ /// Used to trigger client reconnection mid-call (SEP-1699). The response,
+ /// when eventually sent, will be stored and replayed to the reconnected stream.
+ package func closeSSEStream(forRequestID requestID: String) {
+ guard let continuation = requestSSEContinuations[requestID] else { return }
+ continuation.finish()
+ requestSSEContinuations.removeValue(forKey: requestID)
+ httpRequestContexts.removeValue(forKey: requestID)
+ }
+
+ // MARK: - HTTPContextProviding
+
+ public func httpRequestContext(for id: ID) -> HTTPRequest? {
+ httpRequestContexts[id.description]
+ }
+
+ // MARK: - Termination
+
+ /// Terminates the session, closing all active streams.
+ /// After termination, all requests receive 404 Not Found.
+ private func terminate() {
+ guard !terminated else { return }
+ terminated = true
+
+ logger.info("Terminating session", metadata: ["sessionID": "\(sessionID ?? "none")"])
+
+ // Close all request SSE streams
+ for (_, continuation) in requestSSEContinuations {
+ continuation.finish()
+ }
+ requestSSEContinuations.removeAll()
+ httpRequestContexts.removeAll()
+
+ // Close standalone GET stream
+ standaloneSSEContinuation?.finish()
+ standaloneSSEContinuation = nil
+
+ // Clear stored events
+ storedEvents.removeAll()
+
+ // Close incoming stream
+ incomingContinuation.finish()
+ }
+}
diff --git a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift
new file mode 100644
index 0000000..abe9576
--- /dev/null
+++ b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift
@@ -0,0 +1,271 @@
+import Foundation
+import Logging
+
+/// A stateless HTTP server transport that returns single JSON responses.
+///
+/// This transport implements a minimal subset of the MCP Streamable HTTP specification:
+/// - No session management (no `Mcp-Session-Id` header)
+/// - POST requests receive direct JSON responses (no SSE streaming)
+/// - GET and DELETE requests return 405 Method Not Allowed
+///
+/// ## Usage
+///
+/// ```swift
+/// let transport = StatelessHTTPServerTransport()
+///
+/// // Start the MCP server with this transport
+/// try await server.start(transport: transport)
+///
+/// // In your HTTP framework handler:
+/// let response = await transport.handleRequest(httpRequest)
+/// // Convert response to your framework's response type and return it
+/// ```
+///
+/// ## When to Use
+///
+/// Use this transport when:
+/// - You don't need server-initiated messages (no GET SSE stream)
+/// - You want simple request-response semantics
+/// - Session management is handled externally or not needed
+///
+/// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead.
+public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
+ public nonisolated let logger: Logger
+
+ // MARK: - Dependencies
+
+ private let validationPipeline: any HTTPRequestValidationPipeline
+
+ // MARK: - State
+
+ private var terminated = false
+ private var started = false
+
+ // MARK: - Incoming message stream (client → server)
+
+ private let incomingStream: AsyncThrowingStream
+ private let incomingContinuation: AsyncThrowingStream.Continuation
+
+ // MARK: - Response waiters
+
+ /// Maps request ID → continuation waiting for the server's response.
+ /// When the server calls `send()` with a response, the matching continuation is resumed.
+ private var responseWaiters: [String: CheckedContinuation] = [:]
+
+ /// Maps request ID → originating HTTP request, surfaced to handlers via
+ /// ``Server/currentHTTPContext``. Entries live only while a JSON-RPC request
+ /// is in flight.
+ private var httpRequestContexts: [String: HTTPRequest] = [:]
+
+ // MARK: - Init
+
+ /// Creates a new stateless HTTP server transport.
+ ///
+ /// - Parameters:
+ /// - validationPipeline: Custom validation pipeline. If `nil`, uses sensible defaults:
+ /// origin validation (localhost), Accept header (JSON only), Content-Type,
+ /// and protocol version validation.
+ /// - logger: Optional logger. If `nil`, a no-op logger is used.
+ public init(
+ validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
+ logger: Logger? = nil
+ ) {
+ self.validationPipeline = validationPipeline ?? StandardValidationPipeline(validators: [
+ OriginValidator.localhost(),
+ AcceptHeaderValidator(mode: .jsonOnly),
+ ContentTypeValidator(),
+ ProtocolVersionValidator(),
+ ])
+ self.logger = logger ?? Logger(
+ label: "mcp.transport.http.server.stateless",
+ factory: { _ in SwiftLogNoOpLogHandler() }
+ )
+
+ let (stream, continuation) = AsyncThrowingStream.makeStream()
+ self.incomingStream = stream
+ self.incomingContinuation = continuation
+ }
+
+ // MARK: - Transport Conformance
+
+ public func connect() async throws {
+ guard !started else {
+ throw MCPError.internalError("Transport already started")
+ }
+ started = true
+ logger.debug("Stateless HTTP server transport started")
+ }
+
+ public func disconnect() async {
+ await terminate()
+ }
+
+ /// Routes outgoing server messages to the appropriate waiting HTTP handler.
+ ///
+ /// - Responses are matched by JSON-RPC ID and delivered to the waiting `handleRequest` call.
+ /// - Notifications and server-initiated requests are logged and dropped
+ /// (no streaming channel available in stateless mode).
+ public func send(_ data: Data) async throws {
+ guard !terminated else {
+ throw MCPError.connectionClosed
+ }
+
+ guard let kind = JSONRPCMessageKind(data: data) else {
+ logger.warning("Could not classify outgoing message for routing")
+ return
+ }
+
+ switch kind {
+ case .response(let id):
+ guard let continuation = responseWaiters.removeValue(forKey: id) else {
+ logger.debug(
+ "No waiter for response, may have timed out",
+ metadata: ["requestID": "\(id)"]
+ )
+ return
+ }
+ continuation.resume(returning: data)
+
+ case .notification(let method):
+ logger.debug(
+ "Server-initiated notification dropped in stateless mode (no GET SSE stream)",
+ metadata: ["method": "\(method)"]
+ )
+
+ case .request(_, let method):
+ logger.debug(
+ "Server-initiated request dropped in stateless mode (no GET SSE stream)",
+ metadata: ["method": "\(method)"]
+ )
+ }
+ }
+
+ public func receive() -> AsyncThrowingStream {
+ incomingStream
+ }
+
+ // MARK: - HTTP Request Handler
+
+ /// Handles an incoming HTTP request from the framework adapter.
+ ///
+ /// Only POST is supported:
+ /// - **POST**: JSON-RPC messages (requests, notifications)
+ /// - **GET**: 405 Method Not Allowed
+ /// - **DELETE**: 405 Method Not Allowed
+ /// - Others: 405 Method Not Allowed
+ public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
+ if terminated {
+ return .error(
+ statusCode: 404,
+ .invalidRequest("Not Found: Transport has been terminated")
+ )
+ }
+
+ switch request.method.uppercased() {
+ case "POST":
+ return await handlePost(request)
+ default:
+ return .error(
+ statusCode: 405,
+ .invalidRequest("Method Not Allowed"),
+ extraHeaders: [HTTPHeaderName.allow: "POST"]
+ )
+ }
+ }
+
+ // MARK: - POST Handler
+
+ private func handlePost(_ request: HTTPRequest) async -> HTTPResponse {
+ // Parse body first to determine message type
+ guard let body = request.body, !body.isEmpty else {
+ return .error(
+ statusCode: 400,
+ .parseError("Empty request body")
+ )
+ }
+
+ guard let messageKind = JSONRPCMessageKind(data: body) else {
+ return .error(
+ statusCode: 400,
+ .parseError("Invalid JSON-RPC message")
+ )
+ }
+
+ // Build validation context
+ let context = HTTPValidationContext(
+ httpMethod: "POST",
+ sessionID: nil,
+ isInitializationRequest: messageKind.isInitializeRequest,
+ supportedProtocolVersions: Version.supported
+ )
+
+ // Run validation pipeline
+ if let errorResponse = validationPipeline.validate(request, context: context) {
+ return errorResponse
+ }
+
+ // Handle by message type
+ switch messageKind {
+ case .notification, .response:
+ // Yield to server and return 202 Accepted
+ incomingContinuation.yield(body)
+ return .accepted()
+
+ case .request(let id, _):
+ return await handleJSONRPCRequest(body, requestID: id, request: request)
+ }
+ }
+
+ private func handleJSONRPCRequest(
+ _ body: Data,
+ requestID: String,
+ request: HTTPRequest
+ ) async -> HTTPResponse {
+ httpRequestContexts[requestID] = request
+ // Yield the incoming message to the server
+ incomingContinuation.yield(body)
+
+ // Wait for the server to process and send a response
+ let responseData: Data
+ do {
+ responseData = try await withCheckedThrowingContinuation { continuation in
+ responseWaiters[requestID] = continuation
+ }
+ } catch {
+ httpRequestContexts.removeValue(forKey: requestID)
+ return .error(
+ statusCode: 500,
+ .internalError("Error processing request: \(error.localizedDescription)")
+ )
+ }
+
+ httpRequestContexts.removeValue(forKey: requestID)
+ return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json])
+ }
+
+ // MARK: - HTTPContextProviding
+
+ public func httpRequestContext(for id: ID) -> HTTPRequest? {
+ httpRequestContexts[id.description]
+ }
+
+ // MARK: - Termination
+
+ private func terminate() async {
+ guard !terminated else { return }
+ terminated = true
+
+ logger.debug("Stateless HTTP server transport terminated")
+
+ // Cancel all waiting continuations
+ for (id, continuation) in responseWaiters {
+ continuation.resume(throwing: MCPError.connectionClosed)
+ logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"])
+ }
+ responseWaiters.removeAll()
+ httpRequestContexts.removeAll()
+
+ // Close incoming stream
+ incomingContinuation.finish()
+ }
+}
diff --git a/Sources/MCP/Base/Transports/NetworkTransport.swift b/Sources/MCP/Base/Transports/NetworkTransport.swift
index b34af41..7c4d00f 100644
--- a/Sources/MCP/Base/Transports/NetworkTransport.swift
+++ b/Sources/MCP/Base/Transports/NetworkTransport.swift
@@ -242,9 +242,6 @@ import Logging
private let messageStream: AsyncThrowingStream
private let messageContinuation: AsyncThrowingStream.Continuation
- // Track connection state for continuations
- private var connectionContinuationResumed = false
-
// Connection is marked nonisolated(unsafe) to allow access from closures
private nonisolated(unsafe) var connection: NetworkConnectionProtocol
@@ -317,67 +314,101 @@ import Logging
isStopping = false
reconnectAttempt = 0
- // Reset continuation state
- connectionContinuationResumed = false
+ // Retry loop with exponential backoff
+ while !isConnected && reconnectAttempt <= reconnectionConfig.maxAttempts {
+ do {
+ try await attemptConnection()
+ return // Success
+ } catch {
+ guard !isStopping,
+ reconnectionConfig.enabled,
+ reconnectAttempt < reconnectionConfig.maxAttempts
+ else {
+ throw error
+ }
- // Wait for connection to be ready
- try await withCheckedThrowingContinuation {
- [weak self] (continuation: CheckedContinuation) in
- guard let self = self else {
- continuation.resume(throwing: MCPError.internalError("Transport deallocated"))
- return
+ // Schedule retry with backoff
+ reconnectAttempt += 1
+ let delay = reconnectionConfig.backoffDelay(for: reconnectAttempt)
+ logger.debug(
+ "Attempting reconnection (\(reconnectAttempt)/\(reconnectionConfig.maxAttempts))..."
+ )
+
+ try await Task.sleep(for: .seconds(delay))
+ connection.cancel()
+ }
+ }
+
+ throw MCPError.internalError("Failed to connect after \(reconnectAttempt) attempts")
+ }
+
+ /// Attempts a single connection
+ ///
+ /// Creates a stream for connection state changes and waits for a terminal state.
+ ///
+ /// - Throws: Error if the connection fails
+ private func attemptConnection() async throws {
+ // Create stream for state changes with proper cleanup
+ let stateStream = AsyncStream { continuation in
+ continuation.onTermination = { [weak self] _ in
+ self?.connection.stateUpdateHandler = nil
}
- connection.stateUpdateHandler = { [weak self] state in
- guard let self = self else { return }
-
- Task { @MainActor in
- switch state {
- case .ready:
- await self.handleConnectionReady(continuation: continuation)
- case .failed(let error):
- await self.handleConnectionFailed(
- error: error, continuation: continuation)
- case .cancelled:
- await self.handleConnectionCancelled(continuation: continuation)
- case .waiting(let error):
- self.logger.debug("Connection waiting: \(error)")
- case .preparing:
- self.logger.debug("Connection preparing...")
- case .setup:
- self.logger.debug("Connection setup...")
- @unknown default:
- self.logger.warning("Unknown connection state")
- }
+ connection.stateUpdateHandler = { state in
+ continuation.yield(state)
+ switch state {
+ case .ready, .failed, .cancelled:
+ continuation.finish()
+ default:
+ break
}
}
+ }
- connection.start(queue: .main)
+ connection.start(queue: .main)
+
+ // Process states until terminal state
+ for await state in stateStream {
+ switch state {
+ case .ready:
+ await handleConnectionReady()
+ return // Success - exits loop and stream
+
+ case .failed(let error):
+ logger.error("Connection failed: \(error)")
+ throw error // Exits loop and stream
+
+ case .cancelled:
+ logger.warning("Connection cancelled")
+ throw MCPError.internalError("Connection cancelled")
+
+ case .waiting(let error):
+ logger.debug("Connection waiting: \(error)")
+
+ case .preparing:
+ logger.debug("Connection preparing...")
+
+ case .setup:
+ logger.debug("Connection setup...")
+
+ @unknown default:
+ logger.warning("Unknown connection state")
+ }
}
}
/// Handles when the connection reaches the ready state
- ///
- /// - Parameter continuation: The continuation to resume when connection is ready
- private func handleConnectionReady(continuation: CheckedContinuation)
- async
- {
- if !connectionContinuationResumed {
- connectionContinuationResumed = true
- isConnected = true
+ private func handleConnectionReady() async {
+ isConnected = true
+ reconnectAttempt = 0
+ logger.debug("Network transport connected successfully")
- // Reset reconnect attempt counter on successful connection
- reconnectAttempt = 0
- logger.debug("Network transport connected successfully")
- continuation.resume()
+ // Start the receive loop after connection is established
+ Task { await self.receiveLoop() }
- // Start the receive loop after connection is established
- Task { await self.receiveLoop() }
-
- // Start heartbeat task if enabled
- if heartbeatConfig.enabled {
- startHeartbeat()
- }
+ // Start heartbeat task if enabled
+ if heartbeatConfig.enabled {
+ startHeartbeat()
}
}
@@ -443,86 +474,6 @@ import Logging
logger.trace("Heartbeat sent")
}
- /// Handles connection failure
- ///
- /// - Parameters:
- /// - error: The error that caused the connection to fail
- /// - continuation: The continuation to resume with the error
- private func handleConnectionFailed(
- error: Swift.Error, continuation: CheckedContinuation
- ) async {
- if !connectionContinuationResumed {
- connectionContinuationResumed = true
- logger.error("Connection failed: \(error)")
-
- await handleReconnection(
- error: error,
- continuation: continuation,
- context: "failure"
- )
- }
- }
-
- /// Handles connection cancellation
- ///
- /// - Parameter continuation: The continuation to resume with cancellation error
- private func handleConnectionCancelled(continuation: CheckedContinuation)
- async
- {
- if !connectionContinuationResumed {
- connectionContinuationResumed = true
- logger.warning("Connection cancelled")
-
- await handleReconnection(
- error: MCPError.internalError("Connection cancelled"),
- continuation: continuation,
- context: "cancellation"
- )
- }
- }
-
- /// Common reconnection handling logic
- ///
- /// - Parameters:
- /// - error: The error that triggered the reconnection
- /// - continuation: The continuation to resume with the error
- /// - context: The context of the reconnection (for logging)
- private func handleReconnection(
- error: Swift.Error,
- continuation: CheckedContinuation,
- context: String
- ) async {
- if !isStopping,
- reconnectionConfig.enabled,
- reconnectAttempt < reconnectionConfig.maxAttempts
- {
- // Try to reconnect with exponential backoff
- reconnectAttempt += 1
- logger.debug(
- "Attempting reconnection after \(context) (\(reconnectAttempt)/\(reconnectionConfig.maxAttempts))..."
- )
-
- // Calculate backoff delay
- let delay = reconnectionConfig.backoffDelay(for: reconnectAttempt)
-
- // Schedule reconnection attempt after delay
- Task {
- try? await Task.sleep(for: .seconds(delay))
- if !isStopping {
- // Cancel the current connection before attempting to reconnect.
- self.connection.cancel()
- // Resume original continuation with error; outer logic or a new call to connect() will handle retry.
- continuation.resume(throwing: error)
- } else {
- continuation.resume(throwing: error) // Stopping, so fail.
- }
- }
- } else {
- // Not configured to reconnect, exceeded max attempts, or stopping
- self.connection.cancel() // Ensure connection is cancelled
- continuation.resume(throwing: error)
- }
- }
/// Disconnects from the transport
///
@@ -560,8 +511,8 @@ import Logging
var messageWithNewline = message
messageWithNewline.append(UInt8(ascii: "\n"))
- // Use a local actor-isolated variable to track continuation state
- var sendContinuationResumed = false
+ // Use a main-isolated variable to track continuation state
+ let sendContinuationResumed = MainFlag()
try await withCheckedThrowingContinuation {
[weak self] (continuation: CheckedContinuation) in
@@ -578,8 +529,8 @@ import Logging
guard let self = self else { return }
Task { @MainActor in
- if !sendContinuationResumed {
- sendContinuationResumed = true
+ if !sendContinuationResumed.flag {
+ sendContinuationResumed.flag = true
if let error = error {
self.logger.error("Send error: \(error)")
@@ -796,7 +747,8 @@ import Logging
/// - Returns: The received data chunk
/// - Throws: Network errors or transport failures
private func receiveData() async throws -> Data {
- var receiveContinuationResumed = false
+ // Use a main-isolated variable to track continuation state
+ let receiveContinuationResumed = MainFlag()
return try await withCheckedThrowingContinuation {
[weak self] (continuation: CheckedContinuation) in
@@ -809,8 +761,8 @@ import Logging
connection.receive(minimumIncompleteLength: 1, maximumLength: maxLength) {
content, _, isComplete, error in
Task { @MainActor in
- if !receiveContinuationResumed {
- receiveContinuationResumed = true
+ if !receiveContinuationResumed.flag {
+ receiveContinuationResumed.flag = true
if let error = error {
continuation.resume(throwing: MCPError.transportError(error))
} else if let content = content {
@@ -836,6 +788,7 @@ import Logging
self.isConnected = connected
}
}
+ @MainActor final class MainFlag { var flag = false }
extension NWError {
/// Whether this error indicates a connection has been lost or reset.
diff --git a/Sources/MCP/Base/Transports/StdioTransport.swift b/Sources/MCP/Base/Transports/StdioTransport.swift
index 84bfd93..45522fa 100644
--- a/Sources/MCP/Base/Transports/StdioTransport.swift
+++ b/Sources/MCP/Base/Transports/StdioTransport.swift
@@ -20,7 +20,7 @@ import struct Foundation.Data
#if canImport(Darwin) || canImport(Glibc) || canImport(Musl)
/// An implementation of the MCP stdio transport protocol.
///
- /// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)
+ /// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio)
/// specification from the Model Context Protocol.
///
/// The stdio transport works by:
diff --git a/Sources/MCP/Base/Utilities/Cancellation.swift b/Sources/MCP/Base/Utilities/Cancellation.swift
new file mode 100644
index 0000000..a8635ce
--- /dev/null
+++ b/Sources/MCP/Base/Utilities/Cancellation.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+/// The Model Context Protocol supports cancellation of requests through
+/// notification messages. Either side can send a cancellation notification
+/// to indicate that a previously-issued request should be cancelled.
+///
+/// Cancellation is advisory: the receiver should make a best-effort attempt
+/// to cancel the operation, but may not always be able to do so.
+///
+/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
+public struct CancelledNotification: Notification {
+ public static let name: String = "notifications/cancelled"
+
+ public struct Parameters: Hashable, Codable, Sendable {
+ /// The ID of the request to cancel.
+ ///
+ /// This MUST correspond to the ID of a request previously issued
+ /// in the same direction. Optional per the spec (`requestId?`).
+ public let requestId: ID?
+
+ /// An optional human-readable reason for the cancellation.
+ public let reason: String?
+
+ public init(requestId: ID? = nil, reason: String? = nil) {
+ self.requestId = requestId
+ self.reason = reason
+ }
+ }
+}
diff --git a/Sources/MCP/Base/Utilities/Progress.swift b/Sources/MCP/Base/Utilities/Progress.swift
new file mode 100644
index 0000000..df2ab66
--- /dev/null
+++ b/Sources/MCP/Base/Utilities/Progress.swift
@@ -0,0 +1,153 @@
+import Foundation
+
+/// Progress notifications are used to report progress on long-running operations.
+///
+/// The sender (either client or server) that issued the original request may
+/// include a progress token in the request's `_meta` field. If the receiver
+/// supports progress reporting, it can send progress notifications
+/// containing that token to indicate how the operation is proceeding.
+///
+/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
+public struct ProgressNotification: Notification {
+ public static let name: String = "notifications/progress"
+
+ public struct Parameters: Hashable, Codable, Sendable {
+ /// The progress token from the original request.
+ ///
+ /// This is used to associate the progress notification with its
+ /// originating request.
+ public let progressToken: ProgressToken
+
+ /// The current progress value.
+ ///
+ /// This should increase monotonically as the operation proceeds.
+ /// It represents the amount of work completed so far.
+ public let progress: Double
+
+ /// The total expected progress value, if known.
+ ///
+ /// When provided, `progress / total` can be used to calculate
+ /// a percentage completion.
+ public let total: Double?
+
+ /// An optional human-readable message describing the current progress.
+ public let message: String?
+
+ public init(
+ progressToken: ProgressToken,
+ progress: Double,
+ total: Double? = nil,
+ message: String? = nil
+ ) {
+ self.progressToken = progressToken
+ self.progress = progress
+ self.total = total
+ self.message = message
+ }
+ }
+}
+
+/// A token used to associate progress notifications with their originating request.
+///
+/// Progress tokens can be either strings or integers.
+/// Progress tokens MUST be unique across all active requests.
+public enum ProgressToken: Hashable, Codable, Sendable {
+ case string(String)
+ case integer(Int)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let stringValue = try? container.decode(String.self) {
+ self = .string(stringValue)
+ } else if let intValue = try? container.decode(Int.self) {
+ self = .integer(intValue)
+ } else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Progress token must be a string or integer"
+ )
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value):
+ try container.encode(value)
+ case .integer(let value):
+ try container.encode(value)
+ }
+ }
+
+ /// Creates a unique progress token using UUID.
+ public static func unique() -> ProgressToken {
+ .string(UUID().uuidString)
+ }
+}
+
+/// Metadata that can be included in request parameters.
+///
+/// This structure represents the `_meta` field in MCP request parameters,
+/// which can contain a progress token for receiving progress notifications.
+///
+/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
+public struct Metadata: Hashable, Codable, Sendable {
+ public subscript(_ key: String) -> Value? {
+ fields[key]
+ }
+
+ /// The underlying fields dictionary.
+ public var fields: [String: Value]
+
+ /// The progress token for receiving progress notifications.
+ ///
+ /// If specified, the caller is requesting out-of-band progress notifications
+ /// for this request. The value of this parameter is an opaque token that will
+ /// be attached to any subsequent notifications. The receiver is not obligated
+ /// to provide these notifications.
+ public var progressToken: ProgressToken? {
+ get {
+ guard let value = fields["progressToken"] else { return nil }
+ if let stringValue = value.stringValue {
+ return .string(stringValue)
+ } else if let intValue = value.intValue {
+ return .integer(intValue)
+ }
+ return nil
+ }
+ set {
+ if let token = newValue {
+ switch token {
+ case .string(let s):
+ fields["progressToken"] = .string(s)
+ case .integer(let i):
+ fields["progressToken"] = .int(i)
+ }
+ } else {
+ fields.removeValue(forKey: "progressToken")
+ }
+ }
+ }
+
+ /// Creates request metadata.
+ ///
+ /// - Parameters:
+ /// - progressToken: Optional progress token for receiving progress notifications.
+ /// If specified, the caller is requesting out-of-band progress notifications for this request.
+ /// - additionalFields: Optional dictionary of additional metadata fields.
+ /// These fields will be included in the `_meta` object alongside the progress token.
+ public init(progressToken: ProgressToken? = nil, additionalFields: [String: Value] = [:]) {
+ self.fields = additionalFields
+ self.progressToken = progressToken
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ self.fields = try container.decode([String: Value].self)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(fields)
+ }
+}
diff --git a/Sources/MCP/Base/Utilities/RequestContext.swift b/Sources/MCP/Base/Utilities/RequestContext.swift
new file mode 100644
index 0000000..8caf1c4
--- /dev/null
+++ b/Sources/MCP/Base/Utilities/RequestContext.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// A context object that wraps a pending request, providing both the request ID
+/// and a Task handle for the asynchronous operation.
+///
+/// This allows you to track and cancel in-flight requests by sending
+/// a CancelledNotification to the receiver (client or server).
+///
+/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
+public struct RequestContext: Sendable {
+ /// The unique identifier for this request.
+ public let requestID: ID
+
+ /// The Task representing the asynchronous work for this request.
+ private let requestTask: Task