Compare commits

..
Author SHA1 Message Date
yehorsobko
a0ae212ebf
Improvements and fixes (#223)
Some checks failed
CI / Test (macos-latest, Swift 6.1.0) (push) Has been cancelled
CI / Test (ubuntu-latest, Swift 6.1.0) (push) Has been cancelled
CI / MCP Conformance Tests (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / Linux Static SDK Build (6.1.2 - ubuntu-latest) (push) Has been cancelled
* feat: added http transport context to the task local execution

* fix: added refresh_token to the grantTypes for DCR

* chore: updated documentation
2026-04-29 14:34:21 +03:00
yehorsobko
cb6a62f7c2
feat: auth improvements (#217)
* feat: improvement to oauth resources resolution

* feat: improvements to refreshToken procedure and updates to unit tests

* feat: improvements to documentation of clien_id
2026-04-17 17:34:59 +03:00
Maksym Mova
6132fd4b5b
V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206)

Co-authored-by: wti <wti@users.noreply.github.com>

* feat/auth: Authentication implementation (#205)

* feat: added authentication

* feat(auth): improvement to error handling

* feat(auth): updated errors and tests in OAuthClientRegistrar

* feat(auth): updated ci conformance package version

* feat(auth): bumped node version

* feat(auth): updated ci conformance step

* feat(auth): improvement to Linux support

* chore: added docc plugin

* chore: added spi yml

* chore: added documentation step to ci

* chore: increased timeout for test from 5 to 10m

---------

Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com>
Co-authored-by: wti <wti@users.noreply.github.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-03-24 11:05:22 +02:00
yehorsobko
973b46b8f7
feat: 2025-11-25 review fixes (#201)
* fix: review fixes of the latest changes

* fix: updated unit tests

* chore: more conformance compliance improvements

* chore: reverted protocol changes

* chore: more compliance improvements

* chore: more conformance compliance improvements

* chore: improvements to the backcompat and updated to Tool unit tests

* chore: ci update
2026-03-03 17:33:28 +02:00
Maksym Mova
6112a3995a
V0.11.x (#199)
* Support 2025-06-18 Spec (#167)

* Support OpenAI _meta for Tool

* Update init

* Add title and outputSchema

* Concurreny issue

* General fields

* Add Generate Fields to Server elements

* 4 tab spacing

* Update Version

* Elicitation

* Format

* More tests and inits

* Elicitation in README

* Fix orphaned test

* Add title to more structs

* Public init

* Fix meta validation and make tests more generic

* Format and make Result properties public again

* Revert more visibility changes

* feat: 2025-11-25 phase 1, includes Version, Icon, Progress (#181)

* chore: fixed unit tests

* feat: added icon and progess base structs

* feat: added progress notification and meta to callTool

* feat: added tests

* feat: simplified RequestMeta encoding/decoding

* tests: added unit test to track progress

* chore: rebased v0.11.x

* feat: implemented cancellation

* feat: added overloaded callTool method

* fix: encoding Response _meta fix

* chore: added old version to the versions list

* feat: 2025-11-25 phase 2; added Logging and Completions (#196)

* feat: added Logging and Completions

* feat: updated EmbeddedResource for the latest spec

* chore: changed encoding of meta in Prompts to encodeIfPresent

* feat: compliance tests + http server transport (#197)

* feat: http server transport

* feat: added http server transport

* feat: removed unused files

* feat: added conformance tests

* feat: improved conformace testing

* chore: removed redundant files

* feat/2025-11-25: sampling, elicitation, roots (#198)

* feat: Roots implementation

* feat: added unit testing

* feat: improved unit tests and fixed NetworkTransport crash

* feat: implemented sampling and elicitation

* feat: improved sampling tests

* feat: improved elicitation tests

* feat: improved confirmance tests

* feat: improved Prompts unit tests

* feat: removed test code

* feat: updated conformance tests

* chore: removed redundant commas

* chore: removed 6.0.3 swift version from ci

* chore: improved unit tests a little bit

* chore: update README.md

* chore: add server progress tracking example to README.md

* chore: updated README.md

* chore: updated transports table formatting

* chore: updated MockNetworkConnection

---------

Co-authored-by: Austin Evans <austinjevans@me.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-02-19 13:09:31 +02:00
Den Delimarsky
6fa1a97b41
Add SECURITY.md with GitHub Security Advisories guidance (#200) 2026-02-18 23:09:41 +02:00
adam jones
8eef5fda00
chore: update licensing to Apache 2.0 for new contributions (#177) 2026-01-28 00:03:51 +02:00
Semen Kudriavtsev
c0407a0b52
Add Mac Catalyst to target dependencies for EventSource. (#163) 2025-09-23 13:54:32 -07:00
Yifan Liu
fb6fffbecd
fix: allow description field to be absent from Tool (#159)
* fix: allow description field to be absent from `Tool`
* description field is not a requried field of Tool

* Apply suggestions from code review

* Update Tests/MCPTests/ToolTests.swift

---------

Co-authored-by: Mattt <mattt@me.com>
2025-09-07 04:30:19 -07:00
Mattt
ac90e679cd
Make Tool.description optional (#160)
* Make Tool.description optional

* Make description parameter in Tool initializer optional
2025-09-07 04:24:38 -07:00
Alexey Ivashko
78d187716e
Adopt strict concurrency for project (#157)
* Adopt strict concurrency for project

* Fix one warning related to that

* Add version-specific manifest for Swift 6.0

---------

Co-authored-by: Alexey Ivashko <a_ivashko@apple.com>
Co-authored-by: Mattt Zmuda <mattt@me.com>
2025-09-07 04:21:15 -07:00
Mattt
f1b50e6de2
Update README to document support for Alpine Linux (#153) 2025-08-14 04:37:46 -07:00
Tobias
e019cb6909
Enable building with the Swift Linux SDK (#151) 2025-08-14 04:30:58 -07:00
Mattt Zmuda
e76d61155b
Bump version to 0.10.0 2025-08-10 06:30:53 -07:00
Mattt
0121dccf7e
Add requestModifier property to HTTPClientTransport (#152)
* Add requestModifier property to HTTPClientTransport

Co-authored-by: Tim Wang <tim.wang@ing.com>

* Add test coverage

---------

Co-authored-by: Tim Wang <tim.wang@ing.com>
2025-08-10 06:28:08 -07:00
Eric Marchand
8a50d896c1
docs: fix code example in README.md (#141) 2025-08-10 06:02:15 -07:00
Mattt
7c58239ff6
Revert "Revert "Remove deprecated MCP.Error typealias (#102)" (#104)" (#105)
This reverts commit bc5360ecce.
2025-08-10 05:59:35 -07:00
Pedro Piñera Buendía
778ab33d23
Replace compile-time directives with target platform conditions (#143)
* Replace compile-time directives with target platform conditions

Use Package.swift target platform conditions instead of compile-time #if directives for EventSource dependency. This leverages Swift Package Manager's native dependency support for platform-specific targets at runtime rather than compile-time.

* Update reference for EventSource package

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mattt <mattt@me.com>
2025-08-10 05:52:50 -07:00
Glacio
e2afccdcaa
Add plumbing to set instructions on Server (#145)
* Add plumbing to set instructions on Server

* Use instructions description from MCP schema

---------

Co-authored-by: Peter Liddle <development.peter@liddle.network>
Co-authored-by: Mattt <mattt@me.com>
2025-08-10 05:52:28 -07:00
Mattt
63fd276dbd
Implement in-memory / passthrough transport (#122)
* Implement in-memory / passthrough transport

* Add InMemoryTransport to README
2025-08-10 05:52:20 -07:00
Sébastien Stormacq
9ad382d397
remove info level logging message and use debug instead (#132) 2025-08-10 05:40:56 -07:00
Mattt
f3b9ba4a59
Make Tool.inputSchema nonoptional (#123) 2025-08-10 05:34:47 -07:00
Carl Peaslee
c95d0c0354
Remove Loopwork copyright from license (#148) 2025-07-24 13:54:45 -07:00
Mattt
494b5b55d3
Add configuration for maximum receive buffer size to NetworkTransport (#146) 2025-07-08 14:48:29 -07:00
Mattt
36865fb056
Update inputSchema example usage (#136)
* Update Tool inputSchema in README example usage

* Update inputSchema usage in Tool tests
2025-06-12 02:57:04 -07:00
Mattt
7a76001187
Adjust logging level for frequent / low-level events (#130) 2025-06-05 05:36:37 -07:00
Carl Peaslee
609cdc709b
Add Client.listResourceTemplates method (#125)
* adds listResourceTemplates to client for #124

* add pagination to resources/templates/list

from @mattt

Co-authored-by: Mattt <mattt@loopwork.com>

* adds cursor functionality to ListResourceTemplates

* updates the client method to use the updated signature

---------

Co-authored-by: Mattt <mattt@loopwork.com>
2025-05-30 12:35:34 -07:00
Mattt
14fddc44c4
Move Sampling from Server to Client directory (#121) 2025-05-26 06:36:23 -07:00
Mattt Zmuda
9742933d2f
Bump version to 0.9.0 2025-05-26 06:07:28 -07:00
Mattt
a59b50bf5a
Update Client.connect(transport:) to automatically send initialize request (#100)
* Update Client to automatically call initialize in connect

* Make Client.connect(transport:) return value discardable

* Fix example usage in README

* Update example code in transports to remove initialize call

* Deprecate initialize method

* Update tests

* Update README

* Fix sampling tests

* Fix race condition tests
2025-05-26 06:04:38 -07:00
Mattt
3b505ee575
Improve ergonomics of prompt and sampling message initialization (#120)
* Improve ergonomics of prompt and sampling message initialization

* Deprecate previous / existing Message initializers
2025-05-26 05:25:02 -07:00
Mattt
fac4df6b1a
Add support for sampling (#119)
* Add UnitInterval type

* Add test coverage for UnitInterval

* Add support for sampling

* Add integration tests for sampling
2025-05-26 04:57:24 -07:00
Mattt
ddde66dc08
Add test coverage for base64 data helper methods (#114) 2025-05-26 04:36:13 -07:00
Mattt
fec680a243
Prevent double continuation resumption in client (#113)
* Prevent double continuation resumption in client

Consolidate request removal and continuation resumption logic to ensure each request's continuation is resumed exactly once, preventing "SWIFT TASK CONTINUATION MISUSE" errors during network failures.

* Fix race condition test
2025-05-26 04:36:01 -07:00
Mattt
87f33d022a
Improve NetworkTransport and add test coverage (#108)
* Improve NetworkTransport and add test coverage

* Remove the weakTransport check since actors have special memory management rules

* Use Task.sleep(for:) consistently

* Always start connection in connect

* Run NetworkTransportTests serially
2025-05-07 10:36:54 -07:00
Mattt Zmuda
266c02d2d1
Bump version to 0.8.2 2025-05-07 05:59:20 -07:00
Mattt
720a8102e9
Implement client protocol version negotiation in server initialization (#107)
* Implement client version negotiation in server initialization

* Add test coverage for version negotiation

* Make Version.supported and Version.negotiate() APIs internal to not create new surface area
2025-05-07 05:58:11 -07:00
Mattt Zmuda
3690509fa0
Bump version to 0.8.1 2025-05-07 03:26:31 -07:00
Mattt
bc5360ecce
Revert "Remove deprecated MCP.Error typealias (#102)" (#104)
This reverts commit d3f061dacd.
2025-05-07 03:26:07 -07:00
Mattt
800131fb9c
Don't throw error if there's a protocol mismatch between client and server (#103) 2025-05-06 11:53:02 -07:00
Mattt
d3f061dacd
Remove deprecated MCP.Error typealias (#102) 2025-05-06 11:23:04 -07:00
Mattt Zmuda
305cf7e13f
Bump version to 0.8.0 2025-05-06 09:30:43 -07:00
Mattt
23cb19268a
Set latest version to 2025-03-26 (#80)
* Set latest version to 2025-03-26

* Specify support for 2025-03-26 (latest) version
2025-05-06 09:30:21 -07:00
Mattt
32cf6cb7e6
Expand README with more information about client and server usage (#99)
* Expand README with more information about client and server usage

* Add discussion of initialize hook for server

* Add section about request batching

* Trim trailing whitespace

* Rename section to Request Batching
2025-05-06 09:24:20 -07:00
Mattt
daef492821
Improve documentation comments for transport types (#98)
* Add example usage to HTTPClientTransport documentation comments

* Add documentation comments to NetworkTransport

* Improve documentation comments for StdioTransport
2025-05-06 06:56:24 -07:00
Mattt
bdaa2585c5
Delay SSE GET connection until after session ID is established (#97)
* Delay SSE GET connection until after session ID is established

- Add a signaling mechanism to ensure the SSE streaming task waits for the initial session ID to be set before attempting the GET connection.
- Use a TaskGroup to race between session ID signal and a 10-second timeout, logging the outcome.
- Trigger the signal when a session ID is received for the first time from any response.
- Add detailed logging for all code paths to aid debugging and clarify connection timing.
- Clean up signal resources on disconnect to prevent leaks.

This addresses the initialization timing issue where the SSE GET could be attempted before the session was established, as discussed in MCP PR #91.

* Update tests for SSE timing

* Use configurable timeout for acquiring session ID

* Remove Actor protocol conformance for HTTPClientTransport

* Configure shorter session ID timeout for tests

* Add documentation comments to HTTPClientTransport

* Rename sessionIDWaitTimeout to sseInitializationTimeout
2025-05-06 06:49:48 -07:00
Mattt
3ff1085e01
Use EventSource for SSE implementation (#96)
* Add package dependency for github.com/loopwork-ai/EventSource

* Use EventSource for SSE implementation

* Refactor response stream and content type handling

Co-authored-by: Stephen Tallent <stallent@mercury.io>

* Conditionalize use of streaming for supported platforms

* Refactor conditional platform handling

* Conditionalize code and package dependency on Linux

---------

Co-authored-by: Stephen Tallent <stallent@mercury.io>
2025-05-06 05:45:40 -07:00
Mattt
2b67a1233f
Resume with EOF instead of throwing error when no data is received (#94) 2025-05-02 14:24:37 -07:00
Mattt
48c19c282b
Document Client usage with HTTPClientTransport (#77)
* Add test coverage for Client with HTTPClientTransport

* Configure HTTP client transport to stream by default

* Add example usage of streaming HTTP transport to README

* Explicitly set streaming to false in test
2025-04-27 09:29:24 -07:00
Stephen Tallent
ccb070f2f0
support 405 response from servers that don’t support streaming (#90)
* support 405 response from servers that don’t support streaming

* Rephrase comment

---------

Co-authored-by: Stephen Tallent <stallent@mercury.io>
Co-authored-by: Mattt Zmuda <mattt@loopwork.com>
2025-04-27 07:27:14 -07:00
Ayal Spitz
f6e401aa97
Fix event stream line parsing to handle CRLF and empty lines correctly (#89)
* Optimized code in `connectToEventStream` to use `.lines` computed value of `AsyncSequence` to produce a `AsyncLineSequence` instead of building a lines manually.

* - Reverter use of `lines` (it swallows newlines)
- Fiexed original issue I experienced with line 229 `while let newlineIndex = buffer.firstIndex(of: "\n") {` not handling "\r\n" properly
- Added unit test to test for "\r\n" scenario
- Simplified `end of event` test - `line` creation trims newline, added code trims carriage return. Only thing that should be left is an empty line
2025-04-27 06:50:08 -07:00
Stephen Tallent
99517b0564
Update AnyNotification to include notification params (#86)
* Update AnyNotification to not wipe out params that come with a notification

* Add test coverage for notification with params

* Allow params to be omitted when empty

---------

Co-authored-by: Stephen Tallent <stallent@mercury.io>
Co-authored-by: Mattt Zmuda <mattt@loopwork.com>
2025-04-27 06:47:38 -07:00
Mattt
25dee3bb4c
Send initialized notification from client instead of server (#85) 2025-04-21 14:43:33 -07:00
Mattt
29aa74d137
Simplify receive implementation for transports (#78) 2025-04-18 02:37:31 -07:00
Mattt
046fa3ee96
Update README with information about transports and platform availability (#76)
* Update README with information about transports and platform availability

* Add footnote about WIP Alpine Linux and Windows support

* Move discussion of platform availability to separate section

* Add link from requirements to platform availability section
2025-04-15 10:26:26 -07:00
Mattt
d583151871
Add streamable HTTP transport for clients (#70)
* Rename file to Transport.swift

* Rename file to StdioTransportTests.swift

* Add .index-build/ to .gitignore

* Implement HTTP client transport

* Add test coverage for streamable HTTP client transport

* Conditionally import FoundationNetworking

* Conditionalize streaming for unsupported platforms

* Import FoundationNetworking in tests

* Add 5 minute timeout to CI jobs

* Remove debug logging

* Formatting

* Skip SSE tests on platforms that don't support streaming
2025-04-15 09:06:29 -07:00
Mattt
4143f35b41
Implement Tool.Annotations (#47) 2025-04-15 05:42:44 -07:00
Mattt
c748bc1549
Add support for audio content in prompts and tool results (#73) 2025-04-15 05:41:28 -07:00
Mattt
82f4fd29b3
Add support for JSON-RPC batching (#67)
* Update StdioTransport documentation comments to indicate support for JSON-RPC batches

* Add server support for JSON-RPC batching

* Add client support for JSON-RPC batching

* Fix bug that caused error messages to be wrapped unnecessarily
2025-04-10 12:13:41 -07:00
Mattt
2a978d175e
Fix testServerHandleInitialize flake (#68) 2025-04-10 12:09:06 -07:00
Mattt
960c1dda0b
Conditionalize availability of StdioTransport (#65)
* Conditionalize availability of StdioTransport

* Update README with information about platform support
2025-04-10 09:15:13 -07:00
Mattt
1e61f205a1
Add support for Linux with glibc (#61)
* Conditionally import Glibc on Linux for fcntl calls

* Extract transport implementations into separate files

* Fix warning in test

* Add ubuntu-latest to CI test matrix

* Switch to vapor/swiftly-action for setup step

* Configure Prettier extension to format GitHub Actions workflows

* Setup with different actions depending on platform

* Rename step

* Rename job

* Update README
2025-04-10 05:22:56 -07:00
Mattt
20d4fe6f5a
Update Client.listTools return type to include next cursor (#54) 2025-04-09 16:20:55 -07:00
Mattt Zmuda
cfcead8121
Bump version to 0.7.1 2025-04-04 12:11:49 -07:00
Mattt
0725c46969
Remove top-level MCP enumeration (#53) 2025-04-04 12:11:27 -07:00
Mattt Zmuda
402904eab2
Bump version to 0.7.0 2025-04-03 05:33:06 -07:00
Mattt
7c84c20faf
Add Changelog section to README (#45) 2025-04-03 05:32:30 -07:00
Mattt
09da2a1c01
Rename Error to MCPError (#44)
* Rename Error to MCPError

* Conform MCPError to Swift.Error

* Use unqualified Error instead of Swift.Error

* Use unqualified MCPError instead of MCP.MCPError

* Rename file back to Error.swift

* Introduce top-level MCP namespace with nested Error typealias to provide deprecation notice for existing usage of MCP.Error

* Fix markup for MCP enum warning
2025-04-03 05:25:00 -07:00
Mattt
a9bcbf9ad9
Update transports to send and receive data instead of strings (#42)
* Update transports to send and receive data instead of strings

* Rename MockTransport helper methods

* Adopt Task.sleep(for:...) instead of variant taking nanoseconds

* Update comment

* Revert inadvertant change to visibility of send method
2025-04-03 04:42:56 -07:00
Mattt
de829ee648
Add configurable strict mode for MCP client initialization (#43)
* Add configurable strict mode for MCP client initialization

* Rename checkCapability to validateServerCapability
2025-04-03 04:33:50 -07:00
Mattt
5551f66d0e
Update README to reflect MIT License (#41) 2025-04-03 02:16:05 -07:00
Mattt Zmuda
3372a14a10
Bump version to 0.6.0
Update README
2025-03-31 10:24:53 -07:00
Mattt
daa7dad30a
Update platform requirements (#30)
* Relax platform requirements to macOS 13 and iOS 16

* Expand supported platforms
2025-03-31 10:22:39 -07:00
Adam Wulf
19857af5bf
Fix/decode tool call (#36)
* Adding unit test

* Decode entire request to strongly typed Method call. This allows NotRequired parameters to properly decode from null, which wouldn't be possible when decoding from the 'null' JSON encoding directly.
2025-03-31 05:34:33 -07:00
Mattt
d55140bc46
Import System package when available (#34)
* Import System package when available

* Add isResourceTemporarilyUnavailable helper method to reduce number of imports of System / SystemPackage
2025-03-31 05:30:45 -07:00
Adam Wulf
97b756c0f4
Add waitUntilComplete() method to Server (#26)
* Add waitUntilComplete() method to Server

* Update Sources/MCP/Server/Server.swift

Co-authored-by: Mattt <mattt@me.com>

---------

Co-authored-by: Mattt <mattt@me.com>
2025-03-29 04:26:16 -07:00
Mattt
8dfc9c7875
Transfer project to @modelcontextprotocol (#28)
* Update package URL to github.com/modelcontextprotocol/swift-sdk

* Remove link to iMCP

* Change to MIT License

* Apply suggestions from code review
2025-03-27 07:51:20 -07:00
Mattt
b5c9978922
Set permissions for CI workflow (#29) 2025-03-27 07:22:13 -07:00
Mattt Zmuda
ef647106b9
Bump version to 0.5.1 2025-03-19 05:42:19 -07:00
Adam Wulf
da97a3db3c
Add unit test to check that AnyRequest handles missing parameters (#24)
* Add unit test to check that AnyRequest handles missing parameters

* Conform Value to NotRequired

* Add test for decoding AnyRequest with empty parameters

---------

Co-authored-by: Mattt Zmuda <mattt@loopwork.com>
2025-03-19 05:41:50 -07:00
Mattt Zmuda
36a55a5b57
Bump version to 0.5.0 2025-03-18 10:02:29 -07:00
Mattt
75322c3baf
Ensure Request decoder handles missing/null params correctly (#22) 2025-03-18 10:02:05 -07:00
Mattt
2510dda70e
Add server initialization hook (#19) 2025-03-18 09:47:54 -07:00
Mattt
e0186b5ad5
Configure JSON encoding format options (#21) 2025-03-18 09:44:44 -07:00
Mattt Zmuda
c672358e7b
Bump version to 0.4.2 2025-03-17 05:42:51 -07:00
Mattt
59b6b755c0
Revert "Update capabilities to be expressible by Boolean literals (#16)" (#18)
This reverts commit 3e628db6d8.
2025-03-17 05:42:27 -07:00
Mattt Zmuda
eba4f62bd5
Bump version to 0.4.1 2025-03-17 05:27:22 -07:00
Mattt
6f1f65a392
Allow params field to be omitted in requests for methods that don't require them (#17)
* Allow `params` field to be omitted in requests for methods with empty parameters

* Add ping request to roundtrip test

* Generalize decoding of non-required parameters
2025-03-17 05:26:47 -07:00
Mattt
3e628db6d8
Update capabilities to be expressible by Boolean literals (#16)
* Conform Server.Capabilities types to ExpressibleByBooleanLiteral

* Conform Client.Capabilities types to ExpressibleByBooleanLiteral

* Use Boolean literatls for capabilities in tests

* Update README
2025-03-17 04:35:03 -07:00
Mattt Zmuda
4ed52bc2c3
Bump version to 0.4.0 2025-03-17 04:17:51 -07:00
Mattt
a2a0044abb
Remove nonstandard read and list capabilities for resources (#15)
* Remove nonstandard read and list capabilities for server

* Update README
2025-03-17 04:17:23 -07:00
Mattt Zmuda
dccaa3a411
Bump version to 0.3.2 2025-03-14 06:25:29 -07:00
Mattt
a62e51a61c
Make CallTool.Result.isError optional (#11) 2025-03-14 06:24:46 -07:00
96 changed files with 32112 additions and 1135 deletions

View file

@ -2,27 +2,130 @@ name: CI
on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]
permissions:
contents: read
pull-requests: write
jobs:
test:
runs-on: macos-latest
timeout-minutes: 10
strategy:
matrix:
os: [macos-latest, ubuntu-latest]
swift-version:
- ^6
- 6.1.0
name: Build and Test (Swift ${{ matrix.swift-version }})
runs-on: ${{ matrix.os }}
name: Test (${{ matrix.os }}, Swift ${{ matrix.swift-version }})
steps:
- uses: actions/checkout@v4
- uses: swift-actions/setup-swift@v2
with:
swift-version: ${{ matrix.swift-version }}
- name: Build
run: swift build -v
- name: Run tests
run: swift test -v
- 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
with:
toolchain: ${{ matrix.swift-version }}
- name: Setup Swift on macOS
if: matrix.os == 'macos-latest'
uses: swift-actions/setup-swift@v2
with:
swift-version: ${{ matrix.swift-version }}
- name: Build
run: swift build -v
- 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:
matrix:
os: [ubuntu-latest]
swift-version:
- 6.1.2
runs-on: ${{ matrix.os }}
container: swift:${{ matrix.swift-version }}-noble
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install static SDK and build
env:
BUILD_FLAGS: ""
run: |
which curl || (apt -q update && apt -yq install curl)
curl -s --retry 3 https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/install-and-build-with-sdk.sh | \
bash -s -- --static --flags="$BUILD_FLAGS" ${{ matrix.swift-version }}

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.index-build/

4
.spi.yml Normal file
View file

@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [MCP]

View file

@ -1,3 +1,3 @@
{
"recommendations": ["swiftlang.swift-vscode"]
"recommendations": ["swiftlang.swift-vscode", "esbenp.prettier-vscode"]
}

View file

@ -4,5 +4,12 @@
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"[github-actions-workflow]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 4
}
}

59
LICENSE
View file

@ -1,3 +1,11 @@
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/
@ -48,9 +56,9 @@
"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 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"
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,
@ -175,27 +183,34 @@
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
---
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
MIT License
Copyright 2025 Loopwork Limited
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
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
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
http://www.apache.org/licenses/LICENSE-2.0
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
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.

View file

@ -1,6 +1,51 @@
{
"originHash" : "cce36cb33302c2f1c28458e19b8439f736fc28106e4c6ea95d7992c74594c242",
"originHash" : "67fa9b6a0d1f334fd836cabb7af8c36110a6b88c5b49fa5a42a50125b18a056b",
"pins" : [
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"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",
@ -10,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",

View file

@ -1,4 +1,4 @@
// 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
@ -6,34 +6,75 @@ import PackageDescription
let package = Package(
name: "mcp-swift-sdk",
platforms: [
.macOS(.v14),
.iOS(.v17),
.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"])
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"),
],
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: [
.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",
.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"
)
]
)

49
Package@swift-6.0.swift Normal file
View file

@ -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),
]
)

1628
README.md

File diff suppressed because it is too large Load diff

21
SECURITY.md Normal file
View file

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

View file

@ -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<String>?,
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<String>?,
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 {}

View file

@ -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 <token>"` 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<String>?,
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<String>?,
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<String>?,
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<String>?,
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<String>?
) {
let scopeSet: Set<String>
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<String>?,
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<String>?,
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 "<unknown>" }
let normalized = operationKey.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty ? "<unknown>" : normalized
}
}

View file

@ -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 {}

View file

@ -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<String>?
public init(
statusCode: Int,
endpoint: URL,
resource: URL,
authorizationServer: URL?,
authorizationEndpoint: URL?,
tokenEndpoint: URL?,
registrationEndpoint: URL?,
challengedScope: String?,
scopesSupported: [String]?,
requestedScopes: Set<String>?
) {
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:<port>/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
}

View file

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

View file

@ -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<String>?
/// 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<String>
/// 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>) -> 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<String>? {
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<String> {
Set(
scope
.split(whereSeparator: { $0.isWhitespace })
.map(String.init)
.filter { !$0.isEmpty }
)
}
public func serialize(_ scopes: Set<String>) -> 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
}
}

View file

@ -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 {}

View file

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

View file

@ -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<String>
/// 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<String>,
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<String>?
/// 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<String>? = 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
}
}

Some files were not shown because too many files have changed in this diff Show more