* 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>
This commit is contained in:
Maksym Mova 2026-02-19 13:09:31 +02:00 • committed by GitHub
commit 6112a3995a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 11854 additions and 828 deletions

View file

@ -17,7 +17,6 @@ jobs:
matrix:
os: [macos-latest, ubuntu-latest]
swift-version:
- 6.0.3
- 6.1.0
runs-on: ${{ matrix.os }}
@ -43,6 +42,58 @@ jobs:
- name: Run tests
run: swift test -v
conformance:
timeout-minutes: 10
runs-on: macos-latest
name: MCP Conformance Tests
steps:
- uses: actions/checkout@v4
- name: Setup Swift
uses: swift-actions/setup-swift@v2
with:
swift-version: 6.1.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- 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.11
with:
mode: client
command: '.build/debug/mcp-everything-client'
suite: 'core'
expected-failures: './conformance-baseline.yml'
- 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.11
with:
mode: server
url: 'http://localhost:3001/mcp'
suite: 'core'
expected-failures: './conformance-baseline.yml'
- name: Cleanup server
if: always()
run: |
if [ -n "$SERVER_PID" ]; then
kill $SERVER_PID 2>/dev/null || true
fi
static-linux-sdk-build:
name: Linux Static SDK Build (${{ matrix.swift-version }} - ${{ matrix.os }})
strategy:

View file

@ -7,5 +7,9 @@
},
"[github-actions-workflow]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 4
}
}

View file

@ -1,15 +1,42 @@
{
"originHash" : "08de61941b7919a65e36c0e34f8c1c41995469b86a39122158b75b4a68c4527d",
"originHash" : "06a30e0a3f4c69c306d3b14f13c2b4b3964674139bfeec9b920a2bc3d5b1ca20",
"pins" : [
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/loopwork-ai/eventsource.git",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "e83f076811f32757305b8bf69ac92d05626ffdd7",
"version" : "1.1.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"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-log",
"kind" : "remoteSourceControl",
@ -19,6 +46,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

@ -7,16 +7,22 @@ import PackageDescription
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/apple/swift-async-algorithms.git", from: "1.0.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"),
]
// Target dependencies needed on all platforms
var targetDependencies: [Target.Dependency] = [
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "Logging", package: "swift-log"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(
name: "EventSource", package: "eventsource",
condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS, .macCatalyst])),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
]
let package = Package(
@ -33,7 +39,13 @@ let package = Package(
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "MCP",
targets: ["MCP"])
targets: ["MCP"]),
.executable(
name: "mcp-everything-server",
targets: ["MCPConformanceServer"]),
.executable(
name: "mcp-everything-client",
targets: ["MCPConformanceClient"])
],
dependencies: dependencies,
targets: [
@ -49,5 +61,13 @@ let package = Package(
.testTarget(
name: "MCPTests",
dependencies: ["MCP"] + targetDependencies),
.executableTarget(
name: "MCPConformanceServer",
dependencies: ["MCP"] + targetDependencies,
path: "Sources/MCPConformance/Server"),
.executableTarget(
name: "MCPConformanceClient",
dependencies: ["MCP"] + targetDependencies,
path: "Sources/MCPConformance/Client")
]
)

628
README.md
View file

@ -7,9 +7,48 @@ Official Swift SDK for the [Model Context Protocol][mcp] (MCP).
The Model Context Protocol (MCP) defines a standardized way
for applications to communicate with AI and ML models.
This Swift SDK implements both client and server components
according to the [2025-03-26][mcp-spec-2025-03-26] (latest) version
according to the [2025-11-25][mcp-spec-2025-11-25] (latest) version
of the MCP specification.
## Table of contents
- [Requirements](#requirements)
- [Installation](#installation)
- [Client Usage](#client-usage)
- [Basic Client Setup](#basic-client-setup)
- [Transport Options for Clients](#transport-options-for-clients)
- [Tools](#tools)
- [Resources](#resources)
- [Prompts](#prompts)
- [Completions](#completions)
- [Sampling](#sampling)
- [Elicitation](#elicitation)
- [Roots](#roots)
- [Logging](#logging)
- [Error Handling](#error-handling)
- [Cancellation](#cancellation)
- [Progress Tracking](#progress-tracking)
- [Advanced Client Features](#advanced-client-features)
- [Server Usage](#server-usage)
- [Basic Server Setup](#basic-server-setup)
- [Tools](#tools-1)
- [Resources](#resources-1)
- [Prompts](#prompts-1)
- [Completions](#completions-1)
- [Sampling](#sampling-1)
- [Elicitations](#elicitations)
- [Roots](#roots-1)
- [Logging](#logging-1)
- [Progress Tracking](#progress-tracking-1)
- [Initialize Hook](#initialize-hook)
- [Graceful Shutdown](#graceful-shutdown)
- [Transports](#transports)
- [Platform Availability](#platform-availability)
- [Debugging and Logging](#debugging-and-logging)
- [Additional Resources](#additional-resources)
- [Changelog](#changelog)
- [License](#license)
## Requirements
- Swift 6.0+ (Xcode 16+)
@ -25,7 +64,7 @@ Add the following to your `Package.swift` file:
```swift
dependencies: [
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.10.0")
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0")
]
```
@ -101,7 +140,7 @@ Tools represent functions that can be called by the client:
let (tools, cursor) = try await client.listTools()
print("Available tools: \(tools.map { $0.name }.joined(separator: ", "))")
// Call a tool with arguments
// Call a tool with arguments and get the result
let (content, isError) = try await client.callTool(
name: "image-generator",
arguments: [
@ -125,11 +164,10 @@ for item in content {
}
case .audio(let data, let mimeType):
print("Received audio data of type \(mimeType)")
case .resource(let uri, let mimeType, let text):
print("Received resource from \(uri) of type \(mimeType)")
if let text = text {
print("Resource text: \(text)")
}
case .resource(let resource, _, _):
print("Received embedded resource: \(resource)")
case .resourceLink(let uri, let name, _, _, let mimeType, _):
print("Resource link: \(name) at \(uri), type: \(mimeType ?? "unknown")")
}
}
```
@ -148,7 +186,7 @@ let contents = try await client.readResource(uri: "resource://example")
print("Resource content: \(contents)")
// Subscribe to resource updates if supported
if result.capabilities.resources.subscribe {
if result.capabilities.resources?.subscribe == true {
try await client.subscribeToResource(uri: "resource://example")
// Register notification handler
@ -191,6 +229,61 @@ for message in messages {
}
```
### Completions
Completions allow servers to provide autocompletion suggestions for prompt and resource template arguments as users type:
```swift
// Request completions for a prompt argument
let completion = try await client.complete(
promptName: "code_review",
argumentName: "language",
argumentValue: "py"
)
// Display suggestions to the user
for value in completion.values {
print("Suggestion: \(value)")
}
if completion.hasMore == true {
print("More suggestions available (total: \(completion.total ?? 0))")
}
```
You can also provide context with already-resolved arguments:
```swift
// First, user selects a language
let languageCompletion = try await client.complete(
promptName: "code_review",
argumentName: "language",
argumentValue: "py"
)
// User selects "python"
// Then get framework suggestions based on the selected language
let frameworkCompletion = try await client.complete(
promptName: "code_review",
argumentName: "framework",
argumentValue: "fla",
context: ["language": .string("python")]
)
// Returns: ["flask"]
```
Completions work for resource templates as well:
```swift
// Get path completions for a resource URI template
let pathCompletion = try await client.complete(
resourceURI: "file:///{path}",
argumentName: "path",
argumentValue: "/usr/"
)
// Returns: ["/usr/bin", "/usr/lib", "/usr/local"]
```
### Sampling
Sampling allows servers to request LLM completions through the client,
@ -236,43 +329,129 @@ await client.withSamplingHandler { parameters in
}
```
The sampling flow follows these steps:
### Elicitation
```mermaid
sequenceDiagram
participant S as MCP Server
participant C as MCP Client
participant U as User/Human
participant L as LLM Service
Elicitation allows servers to request structured information directly from users through the client.
This is useful when servers need user input that wasn't provided in the original request,
such as credentials, configuration choices, or approval for sensitive operations.
Note over S,L: Server-initiated sampling request
S->>C: sampling/createMessage request
Note right of S: Server needs AI assistance<br/>for decision or content
> [!TIP]
> Elicitation requests flow from **server to client**,
> similar to sampling.
> Clients must register a handler to respond to elicitation requests from servers.
Note over C,U: Human-in-the-loop review #1
C->>U: Show sampling request
U->>U: Review & optionally modify<br/>messages, system prompt
U->>C: Approve request
#### Client-Side: Handling Elicitation Requests
Note over C,L: Client handles LLM interaction
C->>L: Send messages to LLM
L->>C: Return completion
Register an elicitation handler to respond to server requests:
Note over C,U: Human-in-the-loop review #2
C->>U: Show LLM completion
U->>U: Review & optionally modify<br/>or reject completion
U->>C: Approve completion
```swift
// Register an elicitation handler in the client
await client.withElicitationHandler { parameters in
switch parameters {
case .form(let form):
// Display the request to the user
print("Server requests: \(form.message)")
Note over C,S: Return result to server
C->>S: sampling/createMessage response
Note left of C: Contains model used,<br/>stop reason, final content
// If a schema was provided, inspect it
if let schema = form.requestedSchema {
print("Required fields: \(schema.required ?? [])")
print("Schema: \(schema.properties)")
}
Note over S: Server continues with<br/>AI-assisted result
// Present UI to collect user input
let userResponse = presentElicitationUI(form)
// Return the user's response
if userResponse.accepted {
return CreateElicitation.Result(
action: .accept,
content: userResponse.data
)
} else if userResponse.canceled {
return CreateElicitation.Result(action: .cancel)
} else {
return CreateElicitation.Result(action: .decline)
}
case .url(let url):
// Direct the user to an external URL (e.g., for OAuth)
openURL(url.url)
return CreateElicitation.Result(action: .accept)
}
}
```
This human-in-the-loop design ensures that users
maintain control over what the LLM sees and generates,
even when servers initiate the requests.
Common use cases for elicitation:
- **Authentication**: Request credentials when needed rather than upfront
- **Confirmation**: Ask for user approval before sensitive operations
- **Configuration**: Collect preferences or settings during operation
- **Missing information**: Request additional details not provided initially
### Roots
Roots define the filesystem boundaries that a client exposes to servers. Servers discover roots by sending a `roots/list` request to the client; clients notify servers when the list changes.
> [!TIP]
> To use roots, declare the `roots` capability when creating the client.
```swift
let client = Client(
name: "MyApp",
version: "1.0.0",
capabilities: .init(
roots: .init(listChanged: true)
)
)
// Register a handler for roots/list requests from servers
await client.withRootsHandler {
return [
Root(uri: "file:///Users/user/projects", name: "Projects"),
Root(uri: "file:///Users/user/documents", name: "Documents")
]
}
// Notify connected servers whenever roots change
try await client.notifyRootsChanged()
```
### Logging
Clients can control server logging levels and receive structured log messages:
```swift
// Set the minimum logging level
try await client.setLoggingLevel(.warning)
// Register a handler for log messages from the server
await client.onNotification(LogMessageNotification.self) { message in
let level = message.params.level // LogLevel (debug, info, warning, etc.)
let logger = message.params.logger // Optional logger name
let data = message.params.data // Arbitrary JSON data
// Display log message based on level
switch level {
case .error, .critical, .alert, .emergency:
print("❌ [\(logger ?? "server")] \(data)")
case .warning:
print("⚠️ [\(logger ?? "server")] \(data)")
default:
print("ℹ️ [\(logger ?? "server")] \(data)")
}
}
```
Log levels follow the standard syslog severity levels (RFC 5424):
- **debug**: Detailed debugging information
- **info**: General informational messages
- **notice**: Normal but significant events
- **warning**: Warning conditions
- **error**: Error conditions
- **critical**: Critical conditions
- **alert**: Action must be taken immediately
- **emergency**: System is unusable
### Error Handling
@ -289,6 +468,84 @@ do {
}
```
### Cancellation
Either side can cancel an in-progress request and handle incoming cancellations gracefully:
#### Option 1: Convenience Methods with RequestContext Overload
For common operations like tool calls, use the overloaded method that returns `RequestContext`:
```swift
// Call a tool and get a context for cancellation
let context = try client.callTool(
name: "long-running-analysis",
arguments: ["data": largeDataset]
)
// You can cancel the request at any time
try await client.cancelRequest(context.requestID, reason: "User cancelled")
// Await the result (will throw CancellationError if cancelled)
do {
let result = try await context.value
print("Result: \(result.content)")
} catch is CancellationError {
print("Request was cancelled")
}
```
#### Option 2: Direct send() for Maximum Flexibility
For full control or custom requests, use `send()` directly:
```swift
// Create any request type
let request = CallTool.request(.init(
name: "long-running-analysis",
arguments: ["data": largeDataset]
))
// Send and get a context for cancellation tracking
let context: RequestContext<CallTool.Result> = try client.send(request)
// Cancel when needed
try await client.cancelRequest(context.requestID, reason: "Timeout")
// Get the result
let result = try await context.value
let content = result.content
let isError = result.isError
```
### Progress tracking
Clients can attach a progress token to a request and receive incremental progress updates for long-running operations:
```swift
// Call a tool with progress tracking
let progressToken = ProgressToken.unique()
// Register a notification handler to receive progress updates
await client.onNotification(ProgressNotification.self) { message in
let params = message.params
// Filter by your progress token
if params.progressToken == progressToken {
print("Progress: \(params.progress)/\(params.total ?? 0)")
if let message = params.message {
print("Status: \(message)")
}
}
}
// Make the request with the progress token
let (content, isError) = try await client.callTool(
name: "long-running-tool",
arguments: ["input": "value"],
meta: Metadata(progressToken: progressToken)
)
```
### Advanced Client Features
#### Strict vs Non-Strict Configuration
@ -409,6 +666,8 @@ let server = Server(
name: "MyModelServer",
version: "1.0.0",
capabilities: .init(
completions: .init(),
logging: .init(),
prompts: .init(listChanged: true),
resources: .init(subscribe: true, listChanged: true),
tools: .init(listChanged: true)
@ -577,9 +836,9 @@ await server.withMethodHandler(GetPrompt.self) { params in
let description = "Job interview for \(position) position at \(company)"
let messages: [Prompt.Message] = [
.user("You are an interviewer for the \(position) position at \(company)."),
.user("Hello, I'm \(interviewee) and I'm here for the \(position) interview."),
.assistant("Hi \(interviewee), welcome to \(company)! I'd like to start by asking about your background and experience.")
.user(.text(text: "You are an interviewer for the \(position) position at \(company).")),
.user(.text(text: "Hello, I'm \(interviewee) and I'm here for the \(position) interview.")),
.assistant(.text(text: "Hi \(interviewee), welcome to \(company)! I'd like to start by asking about your background and experience."))
]
return .init(description: description, messages: messages)
@ -593,13 +852,93 @@ await server.withMethodHandler(GetPrompt.self) { params in
}
```
### Completions
Servers can provide autocompletion suggestions for prompt and resource template arguments:
```swift
// Enable completions capability
let server = Server(
name: "MyServer",
version: "1.0.0",
capabilities: .init(
completions: .init(),
prompts: .init(listChanged: true)
)
)
// Register a completion handler
await server.withMethodHandler(Complete.self) { params in
// Get the argument being completed
let argumentName = params.argument.name
let currentValue = params.argument.value
// Check which prompt or resource is being completed
switch params.ref {
case .prompt(let promptRef):
// Provide completions for a prompt argument
if promptRef.name == "code_review" && argumentName == "language" {
// Simple prefix matching
let allLanguages = ["python", "perl", "php", "javascript", "java", "swift"]
let matches = allLanguages.filter { $0.hasPrefix(currentValue.lowercased()) }
return .init(
completion: .init(
values: Array(matches.prefix(100)), // Max 100 items
total: matches.count,
hasMore: matches.count > 100
)
)
}
case .resource(let resourceRef):
// Provide completions for a resource template argument
if resourceRef.uri == "file:///{path}" && argumentName == "path" {
// Return directory suggestions
let suggestions = try getDirectoryCompletions(for: currentValue)
return .init(
completion: .init(
values: suggestions,
total: suggestions.count,
hasMore: false
)
)
}
}
// No completions available
return .init(completion: .init(values: [], total: 0, hasMore: false))
}
```
You can also use context from already-resolved arguments:
```swift
await server.withMethodHandler(Complete.self) { params in
// Access context from previous argument completions
if let context = params.context,
let language = context.arguments["language"]?.stringValue {
// Provide framework suggestions based on selected language
if language == "python" {
let frameworks = ["flask", "django", "fastapi", "tornado"]
let matches = frameworks.filter {
$0.hasPrefix(params.argument.value.lowercased())
}
return .init(
completion: .init(values: matches, total: matches.count, hasMore: false)
)
}
}
return .init(completion: .init(values: [], total: 0, hasMore: false))
}
```
### Sampling
Servers can request LLM completions from clients through sampling. This enables agentic behaviors where servers can ask for AI assistance while maintaining human oversight.
> [!NOTE]
> The current implementation provides the correct API design for sampling, but requires bidirectional communication support in the transport layer. This feature will be fully functional when bidirectional transport support is added.
```swift
// Enable sampling capability in server
let server = Server(
@ -611,7 +950,7 @@ let server = Server(
)
)
// Request sampling from the client (conceptual - requires bidirectional transport)
// Request sampling from the connected client
do {
let result = try await server.requestSampling(
messages: [
@ -631,11 +970,187 @@ do {
```
Sampling enables powerful agentic workflows:
- **Decision-making**: Ask the LLM to choose between options
- **Content generation**: Request drafts for user approval
- **Data analysis**: Get AI insights on complex data
- **Multi-step reasoning**: Chain AI completions with tool calls
### Elicitations
Servers can request information from users through elicitation:
```swift
// Ask the user to provide some additional information
let schema = Elicitation.RequestSchema(
title: "Additional Information Required",
description: "Please provide the following details to continue",
properties: [
"name": .object([
"type": .string("string"),
"description": .string("Your full name")
]),
"confirmed": .object([
"type": .string("boolean"),
"description": .string("Do you confirm the provided information?")
])
],
required: ["name", "confirmed"]
)
let result = try await server.requestElicitation(
message: "Some details are needed before proceeding",
requestedSchema: schema
)
switch result.action {
case .accept:
if let content = result.content {
let name = content["name"]?.stringValue
let confirmed = content["confirmed"]?.boolValue
// Use the collected data...
}
case .decline:
throw MCPError.invalidRequest("User declined to provide information")
case .cancel:
throw MCPError.invalidRequest("Operation canceled by user")
}
```
For URL-based elicitation (e.g., OAuth flows), use the URL overload:
```swift
let result = try await server.requestElicitation(
message: "Please sign in to continue",
url: "https://example.com/oauth/authorize?client_id=...",
elicitationId: UUID().uuidString
)
```
### Roots
Servers can request the list of filesystem roots that the client has exposed:
```swift
// Request roots from the connected client
// (requires the client to declare the roots capability)
let roots = try await server.listRoots()
for root in roots {
print("Root: \(root.name ?? root.uri) at \(root.uri)")
}
// React to root list changes
await server.onNotification(RootsListChangedNotification.self) { _ in
let updatedRoots = try await server.listRoots()
print("Roots updated: \(updatedRoots.map { $0.uri })")
}
```
### Logging
Servers can send structured log messages to clients:
```swift
// Enable logging capability
let server = Server(
name: "MyServer",
version: "1.0.0",
capabilities: .init(
logging: .init(),
tools: .init(listChanged: true)
)
)
// Send log messages at different severity levels
try await server.log(
level: .info,
logger: "database",
data: Value.object([
"message": .string("Database connected successfully"),
"host": .string("localhost"),
"port": .int(5432)
])
)
try await server.log(
level: .error,
logger: "api",
data: Value.object([
"message": .string("Request failed"),
"statusCode": .int(500),
"error": .string("Internal server error")
])
)
// You can also use codable types directly
struct ErrorLog: Codable {
let message: String
let code: Int
let timestamp: String
}
let errorLog = ErrorLog(
message: "Operation failed",
code: 500,
timestamp: ISO8601DateFormatter().string(from: Date())
)
try await server.log(level: .error, logger: "operations", data: errorLog)
```
Clients can control which log levels they receive:
```swift
// Register a handler for client's logging level preferences
await server.withMethodHandler(SetLoggingLevel.self) { params in
let minimumLevel = params.level
// Store the client's preference and filter log messages accordingly
// (Implementation depends on your server architecture)
storeLogLevel(minimumLevel)
return Empty()
}
```
### Progress Tracking
Servers can send incremental progress notifications during long-running tool calls by reading the `progressToken` from the request metadata and sending `ProgressNotification` messages:
```swift
await server.withMethodHandler(CallTool.self) { params in
// Read the progress token from request metadata
guard let token = params._meta?.progressToken else {
// No progress token provided — run without reporting progress
return .init(content: [.text("Done")], isError: false)
}
// Report initial progress
let started = ProgressNotification.message(
.init(progressToken: token, progress: 0, total: 100)
)
try await server.notify(started)
// ... do work ...
// Report intermediate progress
let halfway = ProgressNotification.message(
.init(progressToken: token, progress: 50, total: 100, message: "Halfway there")
)
try await server.notify(halfway)
// ... do more work ...
// Report completion
let done = ProgressNotification.message(
.init(progressToken: token, progress: 100, total: 100, message: "Complete")
)
try await server.notify(done)
return .init(content: [.text("Done")], isError: false)
}
```
#### Initialize Hook
Control client connections with an initialize hook:
@ -763,7 +1278,7 @@ This approach has several benefits:
- **Timeout-based shutdown**:
Configurable shutdown timeouts to prevent hanging processes
- **Advanced service management**:
[`ServiceLifecycle`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle)
`[ServiceLifecycle](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle)`
also supports service dependencies, conditional services,
and other useful features.
@ -772,13 +1287,17 @@ This approach has several benefits:
MCP's transport layer handles communication between clients and servers.
The Swift SDK provides multiple built-in transports:
| Transport | Description | Platforms | Best for |
|-----------|-------------|-----------|----------|
| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools |
| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications |
| --------- | ----------- | --------- | -------- |
| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools |
| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications |
| [`StatelessHTTPServerTransport`](/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift) | HTTP server transport with simple request-response semantics; no session management or SSE streaming | All platforms with Foundation | Simple HTTP servers, serverless/edge functions |
| [`StatefulHTTPServerTransport`](/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift) | HTTP server transport with full session management and SSE streaming for server-initiated messages | All platforms with Foundation | Full-featured HTTP servers, streaming notifications |
| [`InMemoryTransport`](/Sources/MCP/Base/Transports/InMemoryTransport.swift) | Custom in-memory transport for direct communication within the same process | All platforms | Testing, debugging, same-process client-server communication |
| [`NetworkTransport`](/Sources/MCP/Base/Transports/NetworkTransport.swift) | Custom transport using Apple's Network framework for TCP/UDP connections | Apple platforms only | Low-level networking, custom protocols |
### Custom Transport Implementation
You can implement a custom transport by conforming to the `Transport` protocol:
@ -826,8 +1345,9 @@ public actor MyCustomTransport: Transport {
The Swift SDK has the following platform requirements:
| Platform | Minimum Version |
|----------|----------------|
| ------------------ | ---------------------------------------------------------------------------------------- |
| macOS | 13.0+ |
| iOS / Mac Catalyst | 16.0+ |
| watchOS | 9.0+ |
@ -835,11 +1355,9 @@ The Swift SDK has the following platform requirements:
| visionOS | 1.0+ |
| Linux | Distributions with `glibc` or `musl`, including Ubuntu, Debian, Fedora, and Alpine Linux |
While the core library works on any platform supporting Swift 6
(including Linux and Windows),
running a client or server requires a compatible transport.
We're working to add [Windows support](https://github.com/modelcontextprotocol/swift-sdk/pull/64).
While the core library works on any platform supporting Swift 6
(including Linux), running a client or server requires a compatible transport.
## Debugging and Logging
@ -868,7 +1386,7 @@ let transport = StdioTransport(logger: logger)
## Additional Resources
- [MCP Specification](https://modelcontextprotocol.io/specification/2025-03-26/)
- [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25)
- [Protocol Documentation](https://modelcontextprotocol.io)
- [GitHub Repository](https://github.com/modelcontextprotocol/swift-sdk)
@ -886,4 +1404,4 @@ see the [GitHub Releases page](https://github.com/modelcontextprotocol/swift-sdk
This project is licensed under Apache 2.0 for new contributions, with existing code under MIT. See the [LICENSE](LICENSE) file for details.
[mcp]: https://modelcontextprotocol.io
[mcp-spec-2025-03-26]: https://modelcontextprotocol.io/specification/2025-03-26
[mcp-spec-2025-11-25]: https://modelcontextprotocol.io/specification/2025-11-25

View file

@ -6,6 +6,25 @@ import Foundation
@preconcurrency import SystemPackage
#endif
/// Information about a required URL elicitation
public struct URLElicitationInfo: Codable, Hashable, Sendable {
/// Elicitation mode (must be "url")
public var mode: String
/// Unique identifier for this elicitation
public var elicitationId: String
/// URL for the user to visit
public var url: String
/// Message describing the elicitation
public var message: String
public init(mode: String = "url", elicitationId: String, url: String, message: String) {
self.mode = mode
self.elicitationId = elicitationId
self.url = url
self.message = message
}
}
/// A model context protocol error.
public enum MCPError: Swift.Error, Sendable {
// Standard JSON-RPC 2.0 errors (-32700 to -32603)
@ -18,6 +37,9 @@ public enum MCPError: Swift.Error, Sendable {
// Server errors (-32000 to -32099)
case serverError(code: Int, message: String)
// MCP specific errors
case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042
// Transport specific errors
case connectionClosed
case transportError(Swift.Error)
@ -31,6 +53,7 @@ public enum MCPError: Swift.Error, Sendable {
case .invalidParams: return -32602
case .internalError: return -32603
case .serverError(let code, _): return code
case .urlElicitationRequired: return -32042
case .connectionClosed: return -32000
case .transportError: return -32001
}
@ -68,6 +91,8 @@ extension MCPError: LocalizedError {
return "Internal error" + (detail.map { ": \($0)" } ?? "")
case .serverError(_, let message):
return "Server error: \(message)"
case .urlElicitationRequired(let message, _):
return "URL elicitation required: \(message)"
case .connectionClosed:
return "Connection closed"
case .transportError(let error):
@ -89,6 +114,8 @@ extension MCPError: LocalizedError {
return "Internal JSON-RPC error"
case .serverError:
return "Server-defined error occurred"
case .urlElicitationRequired:
return "The server requires user authentication or input via external URL"
case .connectionClosed:
return "The connection to the server was closed"
case .transportError(let error):
@ -106,6 +133,11 @@ extension MCPError: LocalizedError {
return "Check the method name and ensure it is supported by the server"
case .invalidParams:
return "Verify the parameters match the method's expected parameters"
case .urlElicitationRequired(_, let elicitations):
if let first = elicitations.first {
return "Visit \(first.url) to complete the required authentication or input"
}
return "Complete the required URL-based elicitation"
case .connectionClosed:
return "Try reconnecting to the server"
default:
@ -154,6 +186,20 @@ extension MCPError: Codable {
case .serverError(_, _):
// No additional data for server errors
break
case .urlElicitationRequired(_, let elicitations):
// Encode elicitations array as structured data
let elicitationsData = elicitations.map { info -> [String: Value] in
return [
"mode": .string(info.mode),
"elicitationId": .string(info.elicitationId),
"url": .string(info.url),
"message": .string(info.message)
]
}
try container.encode(
["elicitations": Value.array(elicitationsData.map { .object($0) })],
forKey: .data
)
case .connectionClosed:
break
case .transportError(let error):
@ -188,6 +234,25 @@ extension MCPError: Codable {
self = .invalidParams(unwrapDetail(message))
case -32603:
self = .internalError(unwrapDetail(nil))
case -32042:
// Extract elicitations array from data
var elicitations: [URLElicitationInfo] = []
if case .array(let items) = data?["elicitations"] {
for item in items {
if case .object(let dict) = item,
case .string(let mode) = dict["mode"],
case .string(let elicitationId) = dict["elicitationId"],
case .string(let url) = dict["url"],
case .string(let msg) = dict["message"] {
elicitations.append(URLElicitationInfo(
mode: mode,
elicitationId: elicitationId,
url: url,
message: msg))
}
}
}
self = .urlElicitationRequired(message: message, elicitations: elicitations)
case -32000:
self = .connectionClosed
case -32001:
@ -236,6 +301,9 @@ extension MCPError: Hashable {
hasher.combine(detail)
case .serverError(_, let message):
hasher.combine(message)
case .urlElicitationRequired(let message, let elicitations):
hasher.combine(message)
hasher.combine(elicitations)
case .connectionClosed:
break
case .transportError(let error):

View file

@ -0,0 +1,67 @@
import Foundation
/// An optionally-sized icon that can be displayed in a user interface.
///
/// Icons can be used to provide visual representation of tools, resources, prompts,
/// and other MCP entities.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/schema#icon
public struct Icon: Hashable, Codable, Sendable {
/// The theme this icon is designed for.
public enum Theme: String, Hashable, Codable, Sendable {
/// Icon is designed for light backgrounds
case light
/// Icon is designed for dark backgrounds
case dark
}
/// A standard URI pointing to an icon resource.
///
/// May be an HTTP/HTTPS URL or a `data:` URI with Base64-encoded image data.
///
/// - Note: Consumers SHOULD take steps to ensure URLs serving icons are from
/// the same domain as the client/server or a trusted domain.
/// - Note: Consumers SHOULD take appropriate precautions when consuming SVGs
/// as they can contain executable JavaScript.
public let src: String
/// Optional MIME type override if the source MIME type is missing or generic.
///
/// For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`.
public let mimeType: String?
/// Optional array of strings that specify sizes at which the icon can be used.
///
/// Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"`
/// for scalable formats like SVG.
///
/// If not provided, the client should assume that the icon can be used at any size.
public let sizes: [String]?
/// Optional specifier for the theme this icon is designed for.
///
/// - `light`: Icon is designed to be used with a light background
/// - `dark`: Icon is designed to be used with a dark background
///
/// If not provided, the client should assume the icon can be used with any theme.
public let theme: Theme?
/// Creates a new icon.
///
/// - Parameters:
/// - src: A standard URI pointing to an icon resource (HTTP/HTTPS URL or data: URI)
/// - mimeType: Optional MIME type override
/// - sizes: Optional array of size strings (e.g., ["48x48", "96x96"])
/// - theme: Optional theme specifier
public init(
src: String,
mimeType: String? = nil,
sizes: [String]? = nil,
theme: Theme? = nil
) {
self.src = src
self.mimeType = mimeType
self.sizes = sizes
self.theme = theme
}
}

View file

@ -46,6 +46,43 @@ public enum Initialize: Method {
public let capabilities: Server.Capabilities
public let serverInfo: Server.Info
public let instructions: String?
public var _meta: Metadata?
public init(
protocolVersion: String,
capabilities: Server.Capabilities,
serverInfo: Server.Info,
instructions: String? = nil,
_meta: Metadata? = nil
) {
self.protocolVersion = protocolVersion
self.capabilities = capabilities
self.serverInfo = serverInfo
self.instructions = instructions
self._meta = _meta
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case protocolVersion, capabilities, serverInfo, instructions, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(protocolVersion, forKey: .protocolVersion)
try container.encode(capabilities, forKey: .capabilities)
try container.encode(serverInfo, forKey: .serverInfo)
try container.encodeIfPresent(instructions, forKey: .instructions)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
protocolVersion = try container.decode(String.self, forKey: .protocolVersion)
capabilities = try container.decode(Server.Capabilities.self, forKey: .capabilities)
serverInfo = try container.decode(Server.Info.self, forKey: .serverInfo)
instructions = try container.decodeIfPresent(String.self, forKey: .instructions)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
}

View file

@ -37,8 +37,11 @@ struct AnyMethod: Method, Sendable {
}
extension Method where Parameters == Empty {
public static func request(id: ID = .random) -> Request<Self> {
Request(id: id, method: name, params: Empty())
public static func request(
id: ID = .random,
_meta: Metadata? = nil
) -> Request<Self> {
Request(id: id, method: name, params: Empty(), _meta: _meta)
}
}
@ -50,18 +53,30 @@ extension Method where Result == Empty {
extension Method {
/// Create a request with the given parameters.
public static func request(id: ID = .random, _ parameters: Self.Parameters) -> Request<Self> {
Request(id: id, method: name, params: parameters)
public static func request(
id: ID = .random,
_ parameters: Self.Parameters,
_meta: Metadata? = nil
) -> Request<Self> {
Request(id: id, method: name, params: parameters, _meta: _meta)
}
/// Create a response with the given result.
public static func response(id: ID, result: Self.Result) -> Response<Self> {
Response(id: id, result: result)
public static func response(
id: ID,
result: Self.Result,
_meta: Metadata? = nil
) -> Response<Self> {
Response(id: id, result: result, _meta: _meta)
}
/// Create a response with the given error.
public static func response(id: ID, error: MCPError) -> Response<Self> {
Response(id: id, error: error)
public static func response(
id: ID,
error: MCPError,
_meta: Metadata? = nil
) -> Response<Self> {
Response(id: id, error: error, _meta: _meta)
}
}
@ -75,15 +90,23 @@ public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
public let method: String
/// The request parameters.
public let params: M.Parameters
/// Metadata for this request (see spec for _meta usage, includes progressToken)
public let _meta: Metadata?
init(id: ID = .random, method: String, params: M.Parameters) {
init(
id: ID = .random,
method: String,
params: M.Parameters,
_meta: Metadata? = nil
) {
self.id = id
self.method = method
self.params = params
self._meta = _meta
}
private enum CodingKeys: String, CodingKey {
case jsonrpc, id, method, params
private enum CodingKeys: String, CodingKey, CaseIterable {
case jsonrpc, id, method, params, _meta
}
public func encode(to encoder: Encoder) throws {
@ -92,6 +115,7 @@ public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
try container.encode(id, forKey: .id)
try container.encode(method, forKey: .method)
try container.encode(params, forKey: .params)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
}
@ -105,6 +129,7 @@ extension Request {
}
id = try container.decode(ID.self, forKey: .id)
method = try container.decode(String.self, forKey: .method)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
if M.Parameters.self is NotRequired.Type {
// For NotRequired parameters, use decodeIfPresent or init()
@ -196,25 +221,45 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
public let id: ID
/// The response result.
public let result: Swift.Result<M.Result, MCPError>
/// Metadata for this response (see spec for _meta usage)
public let _meta: Metadata?
public init(id: ID, result: M.Result) {
public init(
id: ID,
result: Swift.Result<M.Result, MCPError>,
_meta: Metadata? = nil
) {
self.id = id
self.result = .success(result)
self.result = result
self._meta = _meta
}
public init(id: ID, error: MCPError) {
self.id = id
self.result = .failure(error)
public init(
id: ID,
result: M.Result,
_meta: Metadata? = nil
) {
self.init(id: id, result: .success(result), _meta: _meta)
}
private enum CodingKeys: String, CodingKey {
case jsonrpc, id, result, error
public init(
id: ID,
error: MCPError,
_meta: Metadata? = nil
) {
self.init(id: id, result: .failure(error), _meta: _meta)
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case jsonrpc, id, result, error, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(jsonrpc, forKey: .jsonrpc)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(_meta, forKey: ._meta)
switch result {
case .success(let result):
try container.encode(result, forKey: .result)
@ -241,6 +286,7 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
codingPath: container.codingPath,
debugDescription: "Invalid response"))
}
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
@ -249,18 +295,21 @@ typealias AnyResponse = Response<AnyMethod>
extension AnyResponse {
init<T: Method>(_ response: Response<T>) throws {
// Instead of re-encoding/decoding which might double-wrap the error,
// directly transfer the properties
self.id = response.id
switch response.result {
case .success(let result):
// For success, we still need to convert the result to a Value
let data = try JSONEncoder().encode(result)
let resultValue = try JSONDecoder().decode(Value.self, from: data)
self.result = .success(resultValue)
self = Response<AnyMethod>(
id: response.id,
result: .success(resultValue),
_meta: response._meta
)
case .failure(let error):
// Keep the original error without re-encoding/decoding
self.result = .failure(error)
self = Response<AnyMethod>(
id: response.id,
result: .failure(error),
_meta: response._meta
)
}
}
}

View file

@ -9,16 +9,20 @@ import Logging
import FoundationNetworking
#endif
// MARK: - Timeout Helpers
/// Error thrown when an operation times out
/// An implementation of the MCP Streamable HTTP transport protocol for clients.
///
/// This transport implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http)
/// specification from the Model Context Protocol.
/// This transport implements the [Streamable HTTP transport](https://spec.modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)
/// specification from the Model Context Protocol (version 2025-11-25).
///
/// It supports:
/// - Sending JSON-RPC messages via HTTP POST requests
/// - Receiving responses via both direct JSON responses and SSE streams
/// - Session management using the `Mcp-Session-Id` header
/// - Automatic reconnection for dropped SSE streams
/// - Session management using the `MCP-Session-Id` header
/// - Protocol version negotiation via `MCP-Protocol-Version` header
/// - Automatic reconnection for dropped SSE streams with resumability support
/// - Platform-specific optimizations for different operating systems
///
/// The transport supports two modes:
@ -56,6 +60,10 @@ public actor HTTPClientTransport: Transport {
/// The session ID assigned by the server, used for maintaining state across requests
public private(set) var sessionID: String?
/// The negotiated protocol version to send in MCP-Protocol-Version header
public var protocolVersion: String?
private let streaming: Bool
private var streamingTask: Task<Void, Never>?
@ -75,6 +83,16 @@ public actor HTTPClientTransport: Transport {
private var initialSessionIDSignalTask: Task<Void, Never>?
private var initialSessionIDContinuation: CheckedContinuation<Void, Never>?
/// The last event ID received from the server for SSE stream resumability
private var lastEventID: String?
/// The retry interval (in milliseconds) from the server's SSE `retry:` field
private var retryInterval: Int = 3000 // Default 3000ms per SSE spec
/// The underlying URLSession task for the active GET SSE stream.
/// Used to trigger reconnection when a POST SSE stream closes without delivering data.
private var activeGETSessionTask: URLSessionDataTask?
/// Creates a new HTTP transport client with the specified endpoint
///
/// - Parameters:
@ -82,6 +100,7 @@ public actor HTTPClientTransport: Transport {
/// - configuration: URLSession configuration to use for HTTP requests
/// - streaming: Whether to enable SSE streaming mode (default: true)
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds)
/// - protocolVersion: The MCP protocol version to use (default: "2025-11-25")
/// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
/// - logger: Optional logger instance for transport events
public init(
@ -89,6 +108,7 @@ public actor HTTPClientTransport: Transport {
configuration: URLSessionConfiguration = .default,
streaming: Bool = true,
sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@ -97,6 +117,7 @@ public actor HTTPClientTransport: Transport {
session: URLSession(configuration: configuration),
streaming: streaming,
sseInitializationTimeout: sseInitializationTimeout,
protocolVersion: protocolVersion,
requestModifier: requestModifier,
logger: logger
)
@ -107,6 +128,7 @@ public actor HTTPClientTransport: Transport {
session: URLSession,
streaming: Bool = false,
sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@ -114,6 +136,7 @@ public actor HTTPClientTransport: Transport {
self.session = session
self.streaming = streaming
self.sseInitializationTimeout = sseInitializationTimeout
self.protocolVersion = protocolVersion
self.requestModifier = requestModifier
// Create message stream
@ -144,7 +167,9 @@ public actor HTTPClientTransport: Transport {
if let continuation = self.initialSessionIDContinuation {
continuation.resume()
self.initialSessionIDContinuation = nil // Consume the continuation
logger.trace("Initial session ID signal triggered for SSE task.")
logger.debug("✓ Initial session ID signal triggered for SSE task")
} else {
logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting")
}
}
@ -202,6 +227,7 @@ public actor HTTPClientTransport: Transport {
/// the response according to the MCP Streamable HTTP specification. It handles:
///
/// - Adding appropriate Accept headers for both JSON and SSE
/// - Including the MCP-Protocol-Version header as required by the specification
/// - Including the session ID in requests if one has been established
/// - Processing different response types (JSON vs SSE)
/// - Handling HTTP error codes according to the specification
@ -219,9 +245,14 @@ public actor HTTPClientTransport: Transport {
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
}
// Add session ID if available
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
}
// Apply request modifier
@ -249,7 +280,7 @@ public actor HTTPClientTransport: Transport {
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
@ -286,7 +317,7 @@ public actor HTTPClientTransport: Transport {
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
@ -302,7 +333,15 @@ public actor HTTPClientTransport: Transport {
if contentType.contains("text/event-stream") {
// For SSE, processing happens via the stream
logger.trace("Received SSE response, processing in streaming task")
try await self.processSSE(stream)
let hadData = try await self.processSSE(stream)
// If the POST SSE stream closed without delivering a JSON-RPC response,
// trigger GET reconnection so the server can deliver it there.
// This implements standard SSE reconnection behavior per the spec.
if !hadData {
logger.debug("POST SSE stream closed without data, triggering GET reconnection")
self.activeGETSessionTask?.cancel()
}
} else if contentType.contains("application/json") {
// For JSON responses, collect and deliver the data
var buffer = Data()
@ -404,75 +443,96 @@ public actor HTTPClientTransport: Transport {
// This is the original code for platforms that support SSE
guard isConnected else { return }
// Wait for the initial session ID signal, but only if sessionID isn't already set
// Wait for session ID to be available before opening SSE stream
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
logger.trace("SSE streaming task waiting for initial sessionID signal...")
logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...")
// Race the signalTask against a timeout
let timeoutTask = Task {
try? await Task.sleep(for: .seconds(self.sseInitializationTimeout))
return false
}
let signalCompletionTask = Task {
await signalTask.value
return true // Indicates signal received
}
// Use TaskGroup to race the two tasks
var signalReceived = false
let startTime = Date()
let timeout = self.sseInitializationTimeout
do {
signalReceived = try await withThrowingTaskGroup(of: Bool.self) { group in
try await withThrowingTaskGroup { group in
group.addTask {
await signalCompletionTask.value
try await Task.sleep(for: .seconds(timeout))
}
group.addTask {
await timeoutTask.value
await signalTask.value
}
// Take the first result and cancel the other task
if let firstResult = try await group.next() {
group.cancelAll()
return firstResult
}
return false
}
} catch {
logger.error("Error while waiting for session ID signal: \(error)")
logger.warning("⏱️ Timeout waiting for session ID (\(timeout)s). SSE stream will proceed anyway.")
}
// Clean up tasks
timeoutTask.cancel()
if signalReceived {
logger.trace("SSE streaming task proceeding after initial sessionID signal.")
} else {
logger.warning(
"Timeout waiting for initial sessionID signal. SSE stream will proceed (sessionID might be nil)."
)
if self.sessionID != nil {
let elapsed = Date().timeIntervalSince(startTime)
logger.debug("✓ Session ID received after \(Int(elapsed * 1000))ms, proceeding with SSE connection")
}
} else if self.sessionID != nil {
logger.trace(
"Initial sessionID already available. Proceeding with SSE streaming task immediately."
)
} else {
logger.trace(
"Proceeding with SSE connection attempt; sessionID is nil. This might be expected for stateless servers or if initialize hasn't provided one yet."
)
logger.debug("✓ Session ID already available, proceeding with SSE connection immediately")
}
// Retry loop for connection drops
var isFirstAttempt = true
var attemptCount = 0
logger.debug("🔄 Starting SSE retry loop", metadata: [
"isConnected": "\(isConnected)",
"isCancelled": "\(Task.isCancelled)"
])
while isConnected && !Task.isCancelled {
attemptCount += 1
logger.debug("🔄 SSE retry loop iteration", metadata: [
"attempt": "\(attemptCount)",
"isFirstAttempt": "\(isFirstAttempt)"
])
do {
try await connectToEventStream()
// Wait for retry interval before reconnecting (except first attempt)
if !isFirstAttempt {
let delayMs = self.retryInterval
logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"])
try await Task.sleep(for: .milliseconds(delayMs))
logger.debug("✓ Wait complete, reconnecting now")
}
isFirstAttempt = false
logger.debug("📡 Calling connectToEventStream (attempt #\(attemptCount))")
try await self.connectToEventStream()
// If connectToEventStream() returns without error,
// it means the stream closed gracefully - reconnect with retry interval
logger.info("🔌 SSE stream closed gracefully, will reconnect", metadata: [
"attempt": "\(attemptCount)",
"willRetryAfter": "\(self.retryInterval)ms"
])
} catch {
if !Task.isCancelled {
logger.error("SSE connection error: \(error)")
// Wait before retrying
try? await Task.sleep(for: .seconds(1))
logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)")
// Error case - will also use retry interval on next iteration
} else {
logger.debug("⏹️ SSE task cancelled")
}
}
logger.debug("🔄 End of retry loop iteration", metadata: [
"isConnected": "\(isConnected)",
"isCancelled": "\(Task.isCancelled)",
"willContinue": "\(isConnected && !Task.isCancelled)"
])
}
logger.debug("⏹️ SSE retry loop exited", metadata: [
"isConnected": "\(isConnected)",
"isCancelled": "\(Task.isCancelled)",
"totalAttempts": "\(attemptCount)"
])
#endif
}
@ -481,19 +541,38 @@ public actor HTTPClientTransport: Transport {
///
/// This initiates a GET request to the server endpoint with appropriate
/// headers to establish an SSE stream according to the MCP specification.
/// Supports stream resumability via Last-Event-ID header.
///
/// - Throws: MCPError for connection failures or server errors
private func connectToEventStream() async throws {
guard isConnected else { return }
guard isConnected else {
logger.debug("⚠️ Skipping connectToEventStream - transport not connected")
return
}
logger.debug("🔌 Preparing SSE connection request")
var request = URLRequest(url: endpoint)
request.httpMethod = "GET"
request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
request.addValue("no-cache", forHTTPHeaderField: "Cache-Control")
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
}
// Add session ID if available
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
}
// Add last event ID for resumability (if available)
if let lastEventID = lastEventID {
request.addValue(lastEventID, forHTTPHeaderField: "Last-Event-ID")
logger.info("→ Resuming SSE stream with Last-Event-ID", metadata: ["lastEventID": "\(lastEventID)"])
} else {
logger.info("→ Connecting to SSE stream (no last event ID to resume from)")
}
// Apply request modifier
@ -503,6 +582,7 @@ public actor HTTPClientTransport: Transport {
// Create URLSession task for SSE
let (stream, response) = try await session.bytes(for: request)
self.activeGETSessionTask = stream.task
guard let httpResponse = response as? HTTPURLResponse else {
throw MCPError.internalError("Invalid HTTP response")
@ -520,7 +600,7 @@ public actor HTTPClientTransport: Transport {
}
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
@ -531,18 +611,32 @@ public actor HTTPClientTransport: Transport {
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
}
defer { self.activeGETSessionTask = nil }
try await self.processSSE(stream)
}
/// Processes an SSE byte stream, extracting events and delivering them
/// Processes an SSE byte stream, extracting events and delivering them.
///
/// This method processes Server-Sent Events according to the MCP specification,
/// including support for event IDs for resumability.
///
/// - Parameter stream: The URLSession.AsyncBytes stream to process
/// - Returns: `true` if any data events were received, `false` otherwise.
/// - Throws: Error for stream processing failures
private func processSSE(_ stream: URLSession.AsyncBytes) async throws {
do {
@discardableResult
private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool {
logger.debug("📥 Starting SSE event processing")
var eventCount = 0
var hadDataEvent = false
for try await event in stream.events {
eventCount += 1
// Check if task has been cancelled
if Task.isCancelled { break }
if Task.isCancelled {
logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"])
break
}
logger.trace(
"SSE event received",
@ -552,15 +646,27 @@ public actor HTTPClientTransport: Transport {
]
)
// Store event ID for resumability support
if let eventID = event.id, !eventID.isEmpty {
self.lastEventID = eventID
logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"])
}
// Store retry interval if provided by server
if let retry = event.retry {
self.retryInterval = retry
logger.debug("SSE retry interval updated", metadata: ["retryMs": "\(retry)"])
}
// Convert the event data to Data and yield it to the message stream
if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
hadDataEvent = true
messageContinuation.yield(data)
}
}
} catch {
logger.error("Error processing SSE events: \(error)")
throw error
}
logger.debug("✓ SSE event stream completed", metadata: ["eventsProcessed": "\(eventCount)", "hadData": "\(hadDataEvent)"])
return hadDataEvent
}
#endif
}

View file

@ -0,0 +1,371 @@
import Foundation
// MARK: - Validation Protocol
/// Validates an incoming HTTP request before the transport processes it.
///
/// Validators are composed into a pipeline and executed in order. The first validator
/// that returns a non-nil response short-circuits the pipeline and that error response
/// is returned to the client.
///
/// Conform to this protocol to add custom validation (e.g., authentication):
/// ```swift
/// struct BearerTokenValidator: HTTPRequestValidator {
/// func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
/// guard let auth = request.header("Authorization"),
/// auth.hasPrefix("Bearer ") else {
/// return .error(statusCode: 401, .invalidRequest("Missing bearer token"))
/// }
/// return nil
/// }
/// }
/// ```
public protocol HTTPRequestValidator: Sendable {
/// Validates the request. Returns an error response if invalid, or `nil` if valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Validation Context
/// Context provided to validators for making validation decisions.
public struct HTTPValidationContext: Sendable {
/// The HTTP method of the request (GET, POST, DELETE).
public let httpMethod: String
/// The current session ID, if any (nil in stateless mode or before initialization).
public let sessionID: String?
/// Whether the request body contains an `initialize` JSON-RPC request.
public let isInitializationRequest: Bool
/// The set of protocol versions this server supports.
public let supportedProtocolVersions: Set<String>
public init(
httpMethod: String,
sessionID: String? = nil,
isInitializationRequest: Bool = false,
supportedProtocolVersions: Set<String> = Version.supported
) {
self.httpMethod = httpMethod
self.sessionID = sessionID
self.isInitializationRequest = isInitializationRequest
self.supportedProtocolVersions = supportedProtocolVersions
}
}
// MARK: - Accept Header Validator
/// Validates the `Accept` header based on the HTTP method and transport response mode.
///
/// - Stateful (SSE) mode: POST requests must accept both `application/json` and `text/event-stream`
/// - Stateless (JSON) mode: POST requests only need to accept `application/json`
/// - GET requests always require `text/event-stream`
public struct AcceptHeaderValidator: HTTPRequestValidator {
/// The response mode determines which content types are required.
public enum Mode: Sendable {
/// POST requires both `application/json` and `text/event-stream`.
case sseRequired
/// POST only requires `application/json`.
case jsonOnly
}
public let mode: Mode
public init(mode: Mode) {
self.mode = mode
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
let accept = request.header(HTTPHeaderName.accept) ?? ""
let acceptTypes = accept.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespaces)
}
let hasJSON = acceptTypes.contains { $0.hasPrefix(ContentType.json) }
let hasSSE = acceptTypes.contains { $0.hasPrefix(ContentType.sse) }
switch context.httpMethod {
case "POST":
switch mode {
case .sseRequired:
guard hasJSON, hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept both application/json and text/event-stream"
),
sessionID: context.sessionID
)
}
case .jsonOnly:
guard hasJSON else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept application/json"
),
sessionID: context.sessionID
)
}
}
case "GET":
guard hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept text/event-stream"
),
sessionID: context.sessionID
)
}
default:
break
}
return nil
}
}
// MARK: - Content-Type Validator
/// Validates that POST requests have `Content-Type: application/json`.
public struct ContentTypeValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard context.httpMethod == "POST" else { return nil }
let contentType = request.header(HTTPHeaderName.contentType) ?? ""
let mainType = contentType.split(separator: ";").first?
.trimmingCharacters(in: .whitespaces) ?? ""
guard mainType == ContentType.json else {
return .error(
statusCode: 415,
.invalidRequest(
"Unsupported Media Type: Content-Type must be application/json"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Protocol Version Validator
/// Validates the `MCP-Protocol-Version` header against supported versions.
///
/// Per spec:
/// - If the header is absent, the server assumes the default negotiated version
/// - If the header is present but unsupported, the server returns 400 Bad Request
/// - Initialization requests are exempt (protocol version comes from the request body)
public struct ProtocolVersionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip for initialization requests (version is in the body, not the header)
guard !context.isInitializationRequest else { return nil }
// Skip for non-POST methods (GET/DELETE don't carry protocol version)
// Actually, per spec, all subsequent requests should include it
guard let version = request.header(HTTPHeaderName.protocolVersion) else {
// Per spec: if not received, assume default version
return nil
}
guard context.supportedProtocolVersions.contains(version) else {
let supported = context.supportedProtocolVersions.sorted().joined(separator: ", ")
return .error(
statusCode: 400,
.invalidRequest(
"Bad Request: Unsupported protocol version: \(version). Supported: \(supported)"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Session Validator
/// Validates the `Mcp-Session-Id` header for stateful transports.
///
/// - Initialization requests are exempt (no session exists yet)
/// - Non-initialization requests must include the session ID header
/// - The session ID must match the active session
public struct SessionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip validation for initialization requests
guard !context.isInitializationRequest else { return nil }
// If no session exists yet, skip (server hasn't been initialized)
guard let expectedSessionID = context.sessionID else { return nil }
let requestSessionID = request.header(HTTPHeaderName.sessionID)
guard let requestSessionID else {
return .error(
statusCode: 400,
.invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header"),
sessionID: expectedSessionID
)
}
guard requestSessionID == expectedSessionID else {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Invalid or expired session ID"),
sessionID: expectedSessionID
)
}
return nil
}
}
// MARK: - Origin Validator
/// DNS rebinding protection: validates `Origin` and `Host` headers.
///
/// Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.
/// This is particularly important for servers running on localhost.
///
/// Use `.localhost()` for local development servers.
/// Use `.disabled` to skip validation (e.g., cloud deployments).
/// Use `init(allowedHosts:allowedOrigins:)` for custom configurations.
public struct OriginValidator: HTTPRequestValidator {
public let allowedHosts: [String]
public let allowedOrigins: [String]
private let enabled: Bool
public init(allowedHosts: [String], allowedOrigins: [String]) {
self.allowedHosts = allowedHosts
self.allowedOrigins = allowedOrigins
self.enabled = true
}
private init(disabled: Void) {
self.allowedHosts = []
self.allowedOrigins = []
self.enabled = false
}
/// Protection for localhost-bound servers.
/// Allows requests from `localhost`, `127.0.0.1`, and `[::1]` with the specified port.
public static func localhost(port: Int? = nil) -> OriginValidator {
let portPattern = port.map { String($0) } ?? "*"
return OriginValidator(
allowedHosts: [
"127.0.0.1:\(portPattern)",
"localhost:\(portPattern)",
"[::1]:\(portPattern)",
],
allowedOrigins: [
"http://127.0.0.1:\(portPattern)",
"http://localhost:\(portPattern)",
"http://[::1]:\(portPattern)",
]
)
}
/// Disables DNS rebinding protection.
/// Use for cloud deployments where DNS rebinding is not a threat.
public static var disabled: OriginValidator {
OriginValidator(disabled: ())
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard enabled else { return nil }
// Validate Host header
if let host = request.header(HTTPHeaderName.host) {
let hostAllowed = allowedHosts.contains { pattern in
matchesPattern(host, pattern: pattern)
}
if !hostAllowed {
return .error(
statusCode: 421,
.invalidRequest("Misdirected Request: Host header not allowed"),
sessionID: context.sessionID
)
}
}
// Validate Origin header (only if present — non-browser clients won't send it)
if let origin = request.header(HTTPHeaderName.origin) {
let originAllowed = allowedOrigins.contains { pattern in
matchesPattern(origin, pattern: pattern)
}
if !originAllowed {
return .error(
statusCode: 403,
.invalidRequest("Forbidden: Origin not allowed"),
sessionID: context.sessionID
)
}
}
return nil
}
/// Matches a value against a pattern that may contain a port wildcard `:*`.
///
/// Examples:
/// - `"localhost:*"` matches `"localhost:8080"`, `"localhost:3000"`
/// - `"http://localhost:*"` matches `"http://localhost:8080"`
/// - `"localhost:8080"` matches only `"localhost:8080"` exactly
private func matchesPattern(_ value: String, pattern: String) -> Bool {
guard pattern.hasSuffix(":*") else {
return value == pattern
}
let prefix = String(pattern.dropLast(2))
guard value.hasPrefix(prefix + ":") else { return false }
let portPart = value.dropFirst(prefix.count + 1)
return !portPart.isEmpty && portPart.allSatisfy(\.isNumber)
}
}
// MARK: - Validation Pipeline Protocol
/// Runs a validation pipeline against an HTTP request.
///
/// Implementations execute a sequence of validators and return the first error,
/// or `nil` if all validations pass.
public protocol HTTPRequestValidationPipeline: Sendable {
/// Validates the request using the configured pipeline.
/// Returns an error response if validation fails, or `nil` if the request is valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Standard Validation Pipeline
/// Standard implementation of `HTTPRequestValidationPipeline` that runs validators in sequence.
///
/// The first validator that returns a non-nil error response short-circuits the pipeline.
public struct StandardValidationPipeline: HTTPRequestValidationPipeline {
private let validators: [any HTTPRequestValidator]
/// Creates a pipeline with the given validators.
/// Validators are executed in the order provided.
public init(validators: [any HTTPRequestValidator]) {
self.validators = validators
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
for validator in validators {
if let errorResponse = validator.validate(request, context: context) {
return errorResponse
}
}
return nil
}
}

View file

@ -0,0 +1,259 @@
import Foundation
// MARK: - Session ID Generator
/// Generates unique session identifiers for stateful HTTP server transports.
///
/// Conform to this protocol to provide custom session ID generation logic.
/// Session IDs **MUST** contain only visible ASCII characters (0x21–0x7E)
/// per the MCP specification.
///
/// A default implementation using UUID is provided via ``UUIDSessionIDGenerator``.
public protocol SessionIDGenerator: Sendable {
/// Generates a new unique session identifier.
func generateSessionID() -> String
}
/// Default session ID generator that produces UUID strings.
///
/// UUID strings consist of hexadecimal characters and hyphens,
/// which are all within the valid ASCII range (0x21–0x7E).
public struct UUIDSessionIDGenerator: SessionIDGenerator {
public init() {}
public func generateSessionID() -> String {
UUID().uuidString
}
}
// MARK: - HTTP Request
/// A framework-agnostic HTTP request representation.
///
/// This type decouples the transport from any specific HTTP framework.
/// The HTTP framework adapter converts its native request type into this before passing to the transport.
public struct HTTPRequest: Sendable {
/// The HTTP method (e.g., "GET", "POST", "DELETE").
public let method: String
/// HTTP headers as key-value pairs.
public let headers: [String: String]
/// The request body data, if any.
public let body: Data?
public init(method: String, headers: [String: String] = [:], body: Data? = nil) {
self.method = method
self.headers = headers
self.body = body
}
/// Case-insensitive header lookup.
public func header(_ name: String) -> String? {
let lowercased = name.lowercased()
return headers.first { $0.key.lowercased() == lowercased }?.value
}
}
// MARK: - HTTP Response
/// A framework-agnostic HTTP response.
///
/// The HTTP framework adapter converts this into its native response type.
///
/// Use computed properties (`statusCode`, `headers`, `bodyData`) for generic access,
/// or switch on the enum for case-specific handling (e.g., streaming):
///
/// ```swift
/// let response = await transport.handleRequest(request)
/// switch response {
/// case .stream(let sseStream, _):
/// // Pipe the async stream to the HTTP response body
/// default:
/// // Use response.bodyData for the body
/// }
/// ```
public enum HTTPResponse: Sendable {
/// 202 Accepted, no body. Used for notifications and client responses.
case accepted(headers: [String: String] = [:])
/// 200 OK, no body. Used for DELETE confirmation.
case ok(headers: [String: String] = [:])
/// 200 OK with data body (typically JSON).
case data(Data, headers: [String: String] = [:])
/// 200 OK with SSE streaming body.
case stream(AsyncThrowingStream<Data, Swift.Error>, headers: [String: String] = [:])
/// Error response with a JSON-RPC error body.
/// The status code, headers, and body are derived automatically.
case error(statusCode: Int, MCPError, sessionID: String? = nil)
// MARK: - Computed Properties
public var statusCode: Int {
switch self {
case .accepted: 202
case .ok, .data, .stream: 200
case .error(let code, _, _): code
}
}
public var headers: [String: String] {
switch self {
case .accepted(let headers), .ok(let headers), .data(_, let headers), .stream(_, let headers):
return headers
case .error(_, _, let sessionID):
var headers: [String: String] = [HTTPHeaderName.contentType: ContentType.json]
if let sessionID { headers[HTTPHeaderName.sessionID] = sessionID }
return headers
}
}
/// The response body as data. `nil` for `.accepted`, `.ok`, and `.stream`.
public var bodyData: Data? {
switch self {
case .accepted, .ok, .stream:
return nil
case .data(let data, _):
return data
case .error(_, let error, _):
let errorBody: [String: Any] = [
"jsonrpc": "2.0",
"error": [
"code": error.code,
"message": error.errorDescription ?? "Unknown error",
],
"id": NSNull(),
]
return try? JSONSerialization.data(withJSONObject: errorBody)
}
}
}
// MARK: - HTTP Header Names
/// Standard header names used by the MCP Streamable HTTP transport.
public enum HTTPHeaderName {
public static let sessionID = "Mcp-Session-Id"
public static let protocolVersion = "Mcp-Protocol-Version"
public static let lastEventID = "Last-Event-Id"
public static let accept = "Accept"
public static let contentType = "Content-Type"
public static let origin = "Origin"
public static let host = "Host"
public static let cacheControl = "Cache-Control"
public static let connection = "Connection"
public static let allow = "Allow"
}
// MARK: - Content Types
enum ContentType {
static let json = "application/json"
static let sse = "text/event-stream"
}
// MARK: - SSE Event
/// A Server-Sent Event (SSE) data structure.
///
/// Formats according to the SSE specification:
/// https://html.spec.whatwg.org/multipage/server-sent-events.html
struct SSEEvent: Sendable {
var id: String?
var event: String?
var data: String
var retry: Int?
/// Formats the event as SSE wire data.
func formatted() -> Data {
var result = ""
if let id {
result += "id: \(id)\n"
}
if let event {
result += "event: \(event)\n"
}
if let retry {
result += "retry: \(retry)\n"
}
result += "data: \(data)\n\n"
return Data(result.utf8)
}
/// Creates a priming event with an empty data field.
/// Per spec, this is sent immediately to prime the client for reconnection.
static func priming(id: String, retry: Int? = nil) -> SSEEvent {
SSEEvent(id: id, event: nil, data: "", retry: retry)
}
/// Creates a message event wrapping JSON-RPC data.
static func message(data: Data, id: String? = nil) -> SSEEvent {
SSEEvent(
id: id,
event: "message",
data: String(decoding: data, as: UTF8.self)
)
}
}
// MARK: - JSON-RPC Message Classification
/// Classifies a raw JSON-RPC message for routing purposes.
///
/// Used by transports to determine where to route outgoing messages:
/// - Responses are routed to the originating request's stream
/// - Notifications and server requests are routed to the standalone GET stream
package enum JSONRPCMessageKind {
case request(id: String, method: String)
case notification(method: String)
case response(id: String)
/// Attempts to classify raw JSON-RPC data.
/// Returns `nil` if the data cannot be parsed or classified.
package init?(data: Data) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
let id = Self.extractID(from: json)
if let method = json["method"] as? String {
if let id {
self = .request(id: id, method: method)
} else {
self = .notification(method: method)
}
} else if json["result"] != nil || json["error"] != nil {
guard let id else { return nil }
self = .response(id: id)
} else {
return nil
}
}
/// Whether this message is a JSON-RPC response (success or error).
var isResponse: Bool {
if case .response = self { return true }
return false
}
/// Whether this message is an `initialize` request.
package var isInitializeRequest: Bool {
if case .request(_, let method) = self {
return method == "initialize"
}
return false
}
private static func extractID(from json: [String: Any]) -> String? {
if let stringID = json["id"] as? String {
return stringID
} else if let intID = json["id"] as? Int {
return String(intID)
}
return nil
}
}

View file

@ -0,0 +1,546 @@
import Foundation
import Logging
/// A stateful HTTP server transport that manages sessions and uses SSE for streaming responses.
///
/// This transport implements the MCP Streamable HTTP specification with full session management:
/// - Assigns a session ID during initialization (via `Mcp-Session-Id` header)
/// - POST requests receive SSE-streamed responses
/// - GET requests establish a standalone SSE stream for server-initiated messages
/// - DELETE requests terminate the session
/// - Built-in event store for resumability (reconnection with `Last-Event-ID`)
///
/// ## Usage
///
/// ```swift
/// let transport = StatefulHTTPServerTransport() // Uses UUID by default
///
/// // Start the MCP server with this transport
/// try await server.start(transport: transport)
///
/// // In your HTTP framework handler:
/// let response = await transport.handleRequest(httpRequest)
/// // Convert response to your framework's response type and return it
/// ```
///
/// ## Framework Integration
///
/// This transport is framework-agnostic. You provide incoming requests as `HTTPRequest`
/// and receive `HTTPResponse` values to convert to your framework's native types.
/// For SSE responses, the `.stream` case provides an `AsyncThrowingStream<Data, Error>`
/// to pipe to the client.
public actor StatefulHTTPServerTransport: Transport {
public nonisolated let logger: Logger
// MARK: - Dependencies
private let sessionIDGenerator: any SessionIDGenerator
private let validationPipeline: any HTTPRequestValidationPipeline
private let retryInterval: Int?
// MARK: - State
private var sessionID: String?
private var terminated = false
private var started = false
// MARK: - Incoming message stream (client → server)
private let incomingStream: AsyncThrowingStream<Data, Swift.Error>
private let incomingContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
// MARK: - SSE streams for POST request responses
/// Maps request ID → SSE stream continuation for active POST request streams.
private var requestSSEContinuations: [String: AsyncThrowingStream<Data, Swift.Error>.Continuation] = [:]
// MARK: - Standalone GET SSE stream
/// The standalone SSE stream continuation for server-initiated messages.
/// Only one GET stream is allowed per session.
private var standaloneSSEContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation?
/// Internal identifier for the standalone GET stream in the event store.
private let standaloneStreamID = "_GET_stream"
// MARK: - Event Store (Resumability)
private struct StoredEvent {
let streamID: String
let eventID: String
let message: Data?
}
private var storedEvents: [StoredEvent] = []
private var eventCounter: Int = 0
// MARK: - Init
/// Creates a new stateful HTTP server transport.
///
/// - Parameters:
/// - sessionIDGenerator: Generator for session IDs. The IDs MUST contain
/// only visible ASCII characters (0x21-0x7E) per the MCP specification.
/// Defaults to ``UUIDSessionIDGenerator``.
/// - validationPipeline: Custom validation pipeline. If `nil`, uses sensible defaults:
/// origin validation (localhost), Accept header (SSE required), Content-Type,
/// protocol version, and session validation.
/// - retryInterval: Retry interval in milliseconds for SSE priming events.
/// Controls how long clients wait before attempting to reconnect.
/// - logger: Optional logger. If `nil`, a no-op logger is used.
public init(
sessionIDGenerator: any SessionIDGenerator = UUIDSessionIDGenerator(),
validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
retryInterval: Int? = nil,
logger: Logger? = nil
) {
self.sessionIDGenerator = sessionIDGenerator
self.validationPipeline = validationPipeline ?? StandardValidationPipeline(validators: [
OriginValidator.localhost(),
AcceptHeaderValidator(mode: .sseRequired),
ContentTypeValidator(),
ProtocolVersionValidator(),
SessionValidator(),
])
self.retryInterval = retryInterval
self.logger = logger ?? Logger(
label: "mcp.transport.http.server.stateful",
factory: { _ in SwiftLogNoOpLogHandler() }
)
let (stream, continuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
self.incomingStream = stream
self.incomingContinuation = continuation
}
// MARK: - Transport Conformance
public func connect() async throws {
guard !started else {
throw MCPError.internalError("Transport already started")
}
started = true
logger.debug("Stateful HTTP server transport started")
}
public func disconnect() async {
terminate()
}
/// Routes outgoing server messages to the appropriate client connection.
///
/// - Responses are routed to the SSE stream matching the response's JSON-RPC ID.
/// - Notifications and server-initiated requests are routed to the standalone GET stream.
public func send(_ data: Data) async throws {
guard !terminated else {
throw MCPError.connectionClosed
}
guard let kind = JSONRPCMessageKind(data: data) else {
logger.warning("Could not classify outgoing message for routing")
return
}
switch kind {
case .response(let id):
routeResponse(data, requestID: id)
case .notification, .request:
routeServerInitiatedMessage(data)
}
}
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
incomingStream
}
// MARK: - HTTP Request Handler
/// Handles an incoming HTTP request from the framework adapter.
///
/// Routes by HTTP method:
/// - **POST**: JSON-RPC messages (requests, notifications)
/// - **GET**: Establish standalone SSE stream for server-initiated messages
/// - **DELETE**: Terminate the session
/// - Others: 405 Method Not Allowed
public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
if terminated {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Session has been terminated"),
sessionID: sessionID
)
}
switch request.method.uppercased() {
case "POST":
return handlePost(request)
case "GET":
return handleGet(request)
case "DELETE":
return handleDelete(request)
default:
return .error(
statusCode: 405,
.invalidRequest("Method Not Allowed"),
sessionID: sessionID
)
}
}
// MARK: - POST Handler
private func handlePost(_ request: HTTPRequest) -> HTTPResponse {
// Parse body first so we can determine if it's an initialization request
guard let body = request.body, !body.isEmpty else {
return .error(
statusCode: 400,
.parseError("Empty request body"),
sessionID: sessionID
)
}
guard let messageKind = JSONRPCMessageKind(data: body) else {
return .error(
statusCode: 400,
.parseError("Invalid JSON-RPC message"),
sessionID: sessionID
)
}
// Build validation context
let context = HTTPValidationContext(
httpMethod: "POST",
sessionID: sessionID,
isInitializationRequest: messageKind.isInitializeRequest,
supportedProtocolVersions: Version.supported
)
// Run validation pipeline
if let errorResponse = validationPipeline.validate(request, context: context) {
return errorResponse
}
// Handle initialization request specially
if messageKind.isInitializeRequest {
return handleInitializationRequest(body, request: request)
}
// Handle by message type
switch messageKind {
case .notification, .response:
// Yield to server and return 202 Accepted
incomingContinuation.yield(body)
return .accepted(headers: sessionHeaders())
case .request(let id, _):
return handleJSONRPCRequest(body, requestID: id, request: request)
}
}
private func handleInitializationRequest(_ body: Data, request: HTTPRequest) -> HTTPResponse {
// Generate session ID
let newSessionID = sessionIDGenerator.generateSessionID()
// Validate session ID contains only visible ASCII (0x21-0x7E)
guard isValidSessionID(newSessionID) else {
logger.error("Generated session ID contains invalid characters")
return .error(
statusCode: 500,
.internalError("Internal error: Invalid session ID generated")
)
}
self.sessionID = newSessionID
logger.info("Session initialized", metadata: ["sessionID": "\(newSessionID)"])
// Extract request ID for routing the response
guard case .request(let requestID, _) = JSONRPCMessageKind(data: body) else {
return .error(
statusCode: 400,
.parseError("Invalid initialize request"),
sessionID: newSessionID
)
}
// For the initialize request, use SSE streaming like any other request
return handleJSONRPCRequest(body, requestID: requestID, request: request)
}
private func handleJSONRPCRequest(_ body: Data, requestID: String, request: HTTPRequest) -> HTTPResponse {
// Create SSE stream for this request
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
requestSSEContinuations[requestID] = sseContinuation
// Extract protocol version for priming event decision
let protocolVersion = extractProtocolVersion(from: body, request: request)
// Send priming event for resumability
sendPrimingEvent(
streamID: requestID,
continuation: sseContinuation,
protocolVersion: protocolVersion
)
// Yield the incoming message to the server
incomingContinuation.yield(body)
// Build response headers
var headers = sessionHeaders()
headers[HTTPHeaderName.contentType] = ContentType.sse
headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
headers[HTTPHeaderName.connection] = "keep-alive"
return .stream(sseStream, headers: headers)
}
// MARK: - GET Handler
private func handleGet(_ request: HTTPRequest) -> HTTPResponse {
// Build validation context (GET is never an initialization request)
let context = HTTPValidationContext(
httpMethod: "GET",
sessionID: sessionID,
isInitializationRequest: false,
supportedProtocolVersions: Version.supported
)
// Run validation pipeline
if let errorResponse = validationPipeline.validate(request, context: context) {
return errorResponse
}
// Handle resumability: check for Last-Event-ID header
if let lastEventID = request.header(HTTPHeaderName.lastEventID) {
return handleResumeRequest(lastEventID: lastEventID, request: request)
}
// Only one standalone GET stream per session
guard standaloneSSEContinuation == nil else {
return .error(
statusCode: 409,
.invalidRequest("Conflict: Only one SSE stream is allowed per session"),
sessionID: sessionID
)
}
// Create standalone SSE stream
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
standaloneSSEContinuation = sseContinuation
// Extract protocol version for priming event
let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
// Send priming event
sendPrimingEvent(
streamID: standaloneStreamID,
continuation: sseContinuation,
protocolVersion: protocolVersion
)
// Build response headers
var headers = sessionHeaders()
headers[HTTPHeaderName.contentType] = ContentType.sse
headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
headers[HTTPHeaderName.connection] = "keep-alive"
return .stream(sseStream, headers: headers)
}
// MARK: - DELETE Handler
private func handleDelete(_ request: HTTPRequest) -> HTTPResponse {
// Validate session
let context = HTTPValidationContext(
httpMethod: "DELETE",
sessionID: sessionID,
isInitializationRequest: false,
supportedProtocolVersions: Version.supported
)
// Only run session validation for DELETE (not all validators)
let sessionValidator = SessionValidator()
if let errorResponse = sessionValidator.validate(request, context: context) {
return errorResponse
}
terminate()
return .ok(headers: sessionHeaders())
}
// MARK: - Message Routing
/// Routes a message to a specific request's SSE stream without closing it.
/// Used for server-initiated messages during request handling.
private func routeToRequestStream(_ data: Data, requestID: String) {
let eventID = storeEvent(streamID: requestID, message: data)
guard let continuation = requestSSEContinuations[requestID] else {
logger.debug(
"No active stream for request, message stored for replay",
metadata: ["requestID": "\(requestID)"]
)
return
}
// Format as SSE and yield (but don't close the stream)
let sseEvent = SSEEvent.message(data: data, id: eventID)
continuation.yield(sseEvent.formatted())
}
/// Routes a response to a specific request's SSE stream and closes it.
/// Used for final responses to client requests.
private func routeResponse(_ data: Data, requestID: String) {
routeToRequestStream(data, requestID: requestID)
// Response means the request is complete — close the stream
if let continuation = requestSSEContinuations[requestID] {
continuation.finish()
requestSSEContinuations.removeValue(forKey: requestID)
}
}
private func routeServerInitiatedMessage(_ data: Data) {
let eventID = storeEvent(streamID: standaloneStreamID, message: data)
guard let continuation = standaloneSSEContinuation else {
logger.debug("No standalone GET stream connected, message stored for replay")
return
}
let sseEvent = SSEEvent.message(data: data, id: eventID)
continuation.yield(sseEvent.formatted())
}
// MARK: - Resumability
private func handleResumeRequest(lastEventID: String, request: HTTPRequest) -> HTTPResponse {
guard let replay = replayEventsAfter(lastEventID) else {
return .error(
statusCode: 400,
.invalidRequest("Invalid Last-Event-ID"),
sessionID: sessionID
)
}
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
// Replay stored events
for (eventID, message) in replay.events {
let sseEvent = SSEEvent.message(data: message, id: eventID)
sseContinuation.yield(sseEvent.formatted())
}
// Re-register the stream for future messages
if replay.streamID == standaloneStreamID {
standaloneSSEContinuation = sseContinuation
} else {
requestSSEContinuations[replay.streamID] = sseContinuation
}
// Send a new priming event so the client can resume again if disconnected
let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
sendPrimingEvent(
streamID: replay.streamID,
continuation: sseContinuation,
protocolVersion: protocolVersion
)
var headers = sessionHeaders()
headers[HTTPHeaderName.contentType] = ContentType.sse
headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
headers[HTTPHeaderName.connection] = "keep-alive"
return .stream(sseStream, headers: headers)
}
// MARK: - Internal Event Store
private func storeEvent(streamID: String, message: Data?) -> String {
eventCounter += 1
let eventID = "\(streamID)_\(eventCounter)"
storedEvents.append(StoredEvent(streamID: streamID, eventID: eventID, message: message))
return eventID
}
private func replayEventsAfter(_ lastEventID: String) -> (streamID: String, events: [(eventID: String, message: Data)])? {
guard let index = storedEvents.firstIndex(where: { $0.eventID == lastEventID }) else {
return nil
}
let streamID = storedEvents[index].streamID
let eventsToReplay = storedEvents[(index + 1)...]
.filter { $0.streamID == streamID && $0.message != nil }
.map { (eventID: $0.eventID, message: $0.message!) }
return (streamID, eventsToReplay)
}
// MARK: - SSE Helpers
private func sendPrimingEvent(
streamID: String,
continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation,
protocolVersion: String
) {
// Priming events with empty data are only safe for clients >= 2025-11-25
guard protocolVersion >= "2025-11-25" else { return }
let primingEventID = storeEvent(streamID: streamID, message: nil)
let primingEvent = SSEEvent.priming(id: primingEventID, retry: retryInterval)
continuation.yield(primingEvent.formatted())
}
// MARK: - Session Helpers
private func sessionHeaders() -> [String: String] {
var headers: [String: String] = [:]
if let sessionID {
headers[HTTPHeaderName.sessionID] = sessionID
}
return headers
}
private func isValidSessionID(_ id: String) -> Bool {
guard !id.isEmpty else { return false }
return id.utf8.allSatisfy { $0 >= 0x21 && $0 <= 0x7E }
}
private func extractProtocolVersion(from body: Data, request: HTTPRequest) -> String {
// For initialize requests, extract from the request body params
if let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
let method = json["method"] as? String, method == "initialize",
let params = json["params"] as? [String: Any],
let version = params["protocolVersion"] as? String
{
return version
}
// For other requests, use the header
return request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
}
// MARK: - Termination
/// Terminates the session, closing all active streams.
/// After termination, all requests receive 404 Not Found.
private func terminate() {
guard !terminated else { return }
terminated = true
logger.info("Terminating session", metadata: ["sessionID": "\(sessionID ?? "none")"])
// Close all request SSE streams
for (_, continuation) in requestSSEContinuations {
continuation.finish()
}
requestSSEContinuations.removeAll()
// Close standalone GET stream
standaloneSSEContinuation?.finish()
standaloneSSEContinuation = nil
// Clear stored events
storedEvents.removeAll()
// Close incoming stream
incomingContinuation.finish()
}
}

View file

@ -0,0 +1,251 @@
import Foundation
import Logging
/// A stateless HTTP server transport that returns single JSON responses.
///
/// This transport implements a minimal subset of the MCP Streamable HTTP specification:
/// - No session management (no `Mcp-Session-Id` header)
/// - POST requests receive direct JSON responses (no SSE streaming)
/// - GET and DELETE requests return 405 Method Not Allowed
///
/// ## Usage
///
/// ```swift
/// let transport = StatelessHTTPServerTransport()
///
/// // Start the MCP server with this transport
/// try await server.start(transport: transport)
///
/// // In your HTTP framework handler:
/// let response = await transport.handleRequest(httpRequest)
/// // Convert response to your framework's response type and return it
/// ```
///
/// ## When to Use
///
/// Use this transport when:
/// - You don't need server-initiated messages (no GET SSE stream)
/// - You want simple request-response semantics
/// - Session management is handled externally or not needed
///
/// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead.
public actor StatelessHTTPServerTransport: Transport {
public nonisolated let logger: Logger
// MARK: - Dependencies
private let validationPipeline: any HTTPRequestValidationPipeline
// MARK: - State
private var terminated = false
private var started = false
// MARK: - Incoming message stream (client → server)
private let incomingStream: AsyncThrowingStream<Data, Swift.Error>
private let incomingContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
// MARK: - Response waiters
/// Maps request ID → continuation waiting for the server's response.
/// When the server calls `send()` with a response, the matching continuation is resumed.
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]
// MARK: - Init
/// Creates a new stateless HTTP server transport.
///
/// - Parameters:
/// - validationPipeline: Custom validation pipeline. If `nil`, uses sensible defaults:
/// origin validation (localhost), Accept header (JSON only), Content-Type,
/// and protocol version validation.
/// - logger: Optional logger. If `nil`, a no-op logger is used.
public init(
validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
logger: Logger? = nil
) {
self.validationPipeline = validationPipeline ?? StandardValidationPipeline(validators: [
OriginValidator.localhost(),
AcceptHeaderValidator(mode: .jsonOnly),
ContentTypeValidator(),
ProtocolVersionValidator(),
])
self.logger = logger ?? Logger(
label: "mcp.transport.http.server.stateless",
factory: { _ in SwiftLogNoOpLogHandler() }
)
let (stream, continuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
self.incomingStream = stream
self.incomingContinuation = continuation
}
// MARK: - Transport Conformance
public func connect() async throws {
guard !started else {
throw MCPError.internalError("Transport already started")
}
started = true
logger.debug("Stateless HTTP server transport started")
}
public func disconnect() async {
await terminate()
}
/// Routes outgoing server messages to the appropriate waiting HTTP handler.
///
/// - Responses are matched by JSON-RPC ID and delivered to the waiting `handleRequest` call.
/// - Notifications and server-initiated requests are logged and dropped
/// (no streaming channel available in stateless mode).
public func send(_ data: Data) async throws {
guard !terminated else {
throw MCPError.connectionClosed
}
guard let kind = JSONRPCMessageKind(data: data) else {
logger.warning("Could not classify outgoing message for routing")
return
}
switch kind {
case .response(let id):
guard let continuation = responseWaiters.removeValue(forKey: id) else {
logger.debug(
"No waiter for response, may have timed out",
metadata: ["requestID": "\(id)"]
)
return
}
continuation.resume(returning: data)
case .notification(let method):
logger.debug(
"Server-initiated notification dropped in stateless mode (no GET SSE stream)",
metadata: ["method": "\(method)"]
)
case .request(_, let method):
logger.debug(
"Server-initiated request dropped in stateless mode (no GET SSE stream)",
metadata: ["method": "\(method)"]
)
}
}
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
incomingStream
}
// MARK: - HTTP Request Handler
/// Handles an incoming HTTP request from the framework adapter.
///
/// Only POST is supported:
/// - **POST**: JSON-RPC messages (requests, notifications)
/// - **GET**: 405 Method Not Allowed
/// - **DELETE**: 405 Method Not Allowed
/// - Others: 405 Method Not Allowed
public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
if terminated {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Transport has been terminated")
)
}
switch request.method.uppercased() {
case "POST":
return await handlePost(request)
default:
return .error(
statusCode: 405,
.invalidRequest("Method Not Allowed")
)
}
}
// MARK: - POST Handler
private func handlePost(_ request: HTTPRequest) async -> HTTPResponse {
// Parse body first to determine message type
guard let body = request.body, !body.isEmpty else {
return .error(
statusCode: 400,
.parseError("Empty request body")
)
}
guard let messageKind = JSONRPCMessageKind(data: body) else {
return .error(
statusCode: 400,
.parseError("Invalid JSON-RPC message")
)
}
// Build validation context
let context = HTTPValidationContext(
httpMethod: "POST",
sessionID: nil,
isInitializationRequest: messageKind.isInitializeRequest,
supportedProtocolVersions: Version.supported
)
// Run validation pipeline
if let errorResponse = validationPipeline.validate(request, context: context) {
return errorResponse
}
// Handle by message type
switch messageKind {
case .notification, .response:
// Yield to server and return 202 Accepted
incomingContinuation.yield(body)
return .accepted()
case .request(let id, _):
return await handleJSONRPCRequest(body, requestID: id)
}
}
private func handleJSONRPCRequest(_ body: Data, requestID: String) async -> HTTPResponse {
// Yield the incoming message to the server
incomingContinuation.yield(body)
// Wait for the server to process and send a response
let responseData: Data
do {
responseData = try await withCheckedThrowingContinuation { continuation in
responseWaiters[requestID] = continuation
}
} catch {
return .error(
statusCode: 500,
.internalError("Error processing request: \(error.localizedDescription)")
)
}
return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json])
}
// MARK: - Termination
private func terminate() async {
guard !terminated else { return }
terminated = true
logger.debug("Stateless HTTP server transport terminated")
// Cancel all waiting continuations
for (id, continuation) in responseWaiters {
continuation.resume(throwing: MCPError.connectionClosed)
logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"])
}
responseWaiters.removeAll()
// Close incoming stream
incomingContinuation.finish()
}
}

View file

@ -242,9 +242,6 @@ import Logging
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
// Track connection state for continuations
private var connectionContinuationResumed = false
// Connection is marked nonisolated(unsafe) to allow access from closures
private nonisolated(unsafe) var connection: NetworkConnectionProtocol
@ -317,59 +314,94 @@ import Logging
isStopping = false
reconnectAttempt = 0
// Reset continuation state
connectionContinuationResumed = false
// Wait for connection to be ready
try await withCheckedThrowingContinuation {
[weak self] (continuation: CheckedContinuation<Void, Swift.Error>) in
guard let self = self else {
continuation.resume(throwing: MCPError.internalError("Transport deallocated"))
return
// Retry loop with exponential backoff
while !isConnected && reconnectAttempt <= reconnectionConfig.maxAttempts {
do {
try await attemptConnection()
return // Success
} catch {
guard !isStopping,
reconnectionConfig.enabled,
reconnectAttempt < reconnectionConfig.maxAttempts
else {
throw error
}
connection.stateUpdateHandler = { [weak self] state in
guard let self = self else { return }
// Schedule retry with backoff
reconnectAttempt += 1
let delay = reconnectionConfig.backoffDelay(for: reconnectAttempt)
logger.debug(
"Attempting reconnection (\(reconnectAttempt)/\(reconnectionConfig.maxAttempts))..."
)
Task { @MainActor in
try await Task.sleep(for: .seconds(delay))
connection.cancel()
}
}
throw MCPError.internalError("Failed to connect after \(reconnectAttempt) attempts")
}
/// Attempts a single connection
///
/// Creates a stream for connection state changes and waits for a terminal state.
///
/// - Throws: Error if the connection fails
private func attemptConnection() async throws {
// Create stream for state changes with proper cleanup
let stateStream = AsyncStream<NWConnection.State> { continuation in
continuation.onTermination = { [weak self] _ in
self?.connection.stateUpdateHandler = nil
}
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready:
await self.handleConnectionReady(continuation: continuation)
case .failed(let error):
await self.handleConnectionFailed(
error: error, continuation: continuation)
case .cancelled:
await self.handleConnectionCancelled(continuation: continuation)
case .waiting(let error):
self.logger.debug("Connection waiting: \(error)")
case .preparing:
self.logger.debug("Connection preparing...")
case .setup:
self.logger.debug("Connection setup...")
@unknown default:
self.logger.warning("Unknown connection state")
case .ready, .failed, .cancelled:
continuation.finish()
default:
break
}
}
}
connection.start(queue: .main)
// Process states until terminal state
for await state in stateStream {
switch state {
case .ready:
await handleConnectionReady()
return // Success - exits loop and stream
case .failed(let error):
logger.error("Connection failed: \(error)")
throw error // Exits loop and stream
case .cancelled:
logger.warning("Connection cancelled")
throw MCPError.internalError("Connection cancelled")
case .waiting(let error):
logger.debug("Connection waiting: \(error)")
case .preparing:
logger.debug("Connection preparing...")
case .setup:
logger.debug("Connection setup...")
@unknown default:
logger.warning("Unknown connection state")
}
}
}
/// Handles when the connection reaches the ready state
///
/// - Parameter continuation: The continuation to resume when connection is ready
private func handleConnectionReady(continuation: CheckedContinuation<Void, Swift.Error>)
async
{
if !connectionContinuationResumed {
connectionContinuationResumed = true
private func handleConnectionReady() async {
isConnected = true
// Reset reconnect attempt counter on successful connection
reconnectAttempt = 0
logger.debug("Network transport connected successfully")
continuation.resume()
// Start the receive loop after connection is established
Task { await self.receiveLoop() }
@ -379,7 +411,6 @@ import Logging
startHeartbeat()
}
}
}
/// Starts a task to periodically send heartbeats to check connection health
private func startHeartbeat() {
@ -443,86 +474,6 @@ import Logging
logger.trace("Heartbeat sent")
}
/// Handles connection failure
///
/// - Parameters:
/// - error: The error that caused the connection to fail
/// - continuation: The continuation to resume with the error
private func handleConnectionFailed(
error: Swift.Error, continuation: CheckedContinuation<Void, Swift.Error>
) async {
if !connectionContinuationResumed {
connectionContinuationResumed = true
logger.error("Connection failed: \(error)")
await handleReconnection(
error: error,
continuation: continuation,
context: "failure"
)
}
}
/// Handles connection cancellation
///
/// - Parameter continuation: The continuation to resume with cancellation error
private func handleConnectionCancelled(continuation: CheckedContinuation<Void, Swift.Error>)
async
{
if !connectionContinuationResumed {
connectionContinuationResumed = true
logger.warning("Connection cancelled")
await handleReconnection(
error: MCPError.internalError("Connection cancelled"),
continuation: continuation,
context: "cancellation"
)
}
}
/// Common reconnection handling logic
///
/// - Parameters:
/// - error: The error that triggered the reconnection
/// - continuation: The continuation to resume with the error
/// - context: The context of the reconnection (for logging)
private func handleReconnection(
error: Swift.Error,
continuation: CheckedContinuation<Void, Swift.Error>,
context: String
) async {
if !isStopping,
reconnectionConfig.enabled,
reconnectAttempt < reconnectionConfig.maxAttempts
{
// Try to reconnect with exponential backoff
reconnectAttempt += 1
logger.debug(
"Attempting reconnection after \(context) (\(reconnectAttempt)/\(reconnectionConfig.maxAttempts))..."
)
// Calculate backoff delay
let delay = reconnectionConfig.backoffDelay(for: reconnectAttempt)
// Schedule reconnection attempt after delay
Task {
try? await Task.sleep(for: .seconds(delay))
if !isStopping {
// Cancel the current connection before attempting to reconnect.
self.connection.cancel()
// Resume original continuation with error; outer logic or a new call to connect() will handle retry.
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: error) // Stopping, so fail.
}
}
} else {
// Not configured to reconnect, exceeded max attempts, or stopping
self.connection.cancel() // Ensure connection is cancelled
continuation.resume(throwing: error)
}
}
/// Disconnects from the transport
///

View file

@ -20,7 +20,7 @@ import struct Foundation.Data
#if canImport(Darwin) || canImport(Glibc) || canImport(Musl)
/// An implementation of the MCP stdio transport protocol.
///
/// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)
/// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio)
/// specification from the Model Context Protocol.
///
/// The stdio transport works by:

View file

@ -0,0 +1,29 @@
import Foundation
/// The Model Context Protocol supports cancellation of requests through
/// notification messages. Either side can send a cancellation notification
/// to indicate that a previously-issued request should be cancelled.
///
/// Cancellation is advisory: the receiver should make a best-effort attempt
/// to cancel the operation, but may not always be able to do so.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
public struct CancelledNotification: Notification {
public static let name: String = "notifications/cancelled"
public struct Parameters: Hashable, Codable, Sendable {
/// The ID of the request to cancel.
///
/// This MUST correspond to the ID of a request previously issued
/// in the same direction.
public let requestId: ID
/// An optional human-readable reason for the cancellation.
public let reason: String?
public init(requestId: ID, reason: String? = nil) {
self.requestId = requestId
self.reason = reason
}
}
}

View file

@ -0,0 +1,153 @@
import Foundation
/// Progress notifications are used to report progress on long-running operations.
///
/// The sender (either client or server) that issued the original request may
/// include a progress token in the request's `_meta` field. If the receiver
/// supports progress reporting, it can send progress notifications
/// containing that token to indicate how the operation is proceeding.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
public struct ProgressNotification: Notification {
public static let name: String = "notifications/progress"
public struct Parameters: Hashable, Codable, Sendable {
/// The progress token from the original request.
///
/// This is used to associate the progress notification with its
/// originating request.
public let progressToken: ProgressToken
/// The current progress value.
///
/// This should increase monotonically as the operation proceeds.
/// It represents the amount of work completed so far.
public let progress: Double
/// The total expected progress value, if known.
///
/// When provided, `progress / total` can be used to calculate
/// a percentage completion.
public let total: Double?
/// An optional human-readable message describing the current progress.
public let message: String?
public init(
progressToken: ProgressToken,
progress: Double,
total: Double? = nil,
message: String? = nil
) {
self.progressToken = progressToken
self.progress = progress
self.total = total
self.message = message
}
}
}
/// A token used to associate progress notifications with their originating request.
///
/// Progress tokens can be either strings or integers.
/// Progress tokens MUST be unique across all active requests.
public enum ProgressToken: Hashable, Codable, Sendable {
case string(String)
case integer(Int)
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let stringValue = try? container.decode(String.self) {
self = .string(stringValue)
} else if let intValue = try? container.decode(Int.self) {
self = .integer(intValue)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Progress token must be a string or integer"
)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value):
try container.encode(value)
case .integer(let value):
try container.encode(value)
}
}
/// Creates a unique progress token using UUID.
public static func unique() -> ProgressToken {
.string(UUID().uuidString)
}
}
/// Metadata that can be included in request parameters.
///
/// This structure represents the `_meta` field in MCP request parameters,
/// which can contain a progress token for receiving progress notifications.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
public struct Metadata: Hashable, Codable, Sendable {
public subscript(_ key: String) -> Value? {
fields[key]
}
/// The underlying fields dictionary.
public var fields: [String: Value]
/// The progress token for receiving progress notifications.
///
/// If specified, the caller is requesting out-of-band progress notifications
/// for this request. The value of this parameter is an opaque token that will
/// be attached to any subsequent notifications. The receiver is not obligated
/// to provide these notifications.
public var progressToken: ProgressToken? {
get {
guard let value = fields["progressToken"] else { return nil }
if let stringValue = value.stringValue {
return .string(stringValue)
} else if let intValue = value.intValue {
return .integer(intValue)
}
return nil
}
set {
if let token = newValue {
switch token {
case .string(let s):
fields["progressToken"] = .string(s)
case .integer(let i):
fields["progressToken"] = .int(i)
}
} else {
fields.removeValue(forKey: "progressToken")
}
}
}
/// Creates request metadata.
///
/// - Parameters:
/// - progressToken: Optional progress token for receiving progress notifications.
/// If specified, the caller is requesting out-of-band progress notifications for this request.
/// - additionalFields: Optional dictionary of additional metadata fields.
/// These fields will be included in the `_meta` object alongside the progress token.
public init(progressToken: ProgressToken? = nil, additionalFields: [String: Value] = [:]) {
self.fields = additionalFields
self.progressToken = progressToken
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.fields = try container.decode([String: Value].self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(fields)
}
}

View file

@ -0,0 +1,34 @@
import Foundation
/// A context object that wraps a pending request, providing both the request ID
/// and a Task handle for the asynchronous operation.
///
/// This allows you to track and cancel in-flight requests by sending
/// a CancelledNotification to the receiver (client or server).
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
public struct RequestContext<Output: Sendable & Decodable>: Sendable {
/// The unique identifier for this request.
public let requestID: ID
/// The Task representing the asynchronous work for this request.
private let requestTask: Task<Output, Error>
/// Convenience property to await the result.
///
/// Example:
/// ```swift
/// let context = try await client.send(request)
/// let result = try await context.value
/// ```
public var value: Output {
get async throws {
try await requestTask.value
}
}
public init(requestID: ID, requestTask: Task<Output, Error>) {
self.requestID = requestID
self.requestTask = requestTask
}
}

View file

@ -4,10 +4,12 @@ import Foundation
/// following the format YYYY-MM-DD, to indicate
/// the last date backwards incompatible changes were made.
///
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-03-26/
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/
public enum Version {
/// All protocol versions supported by this implementation, ordered from newest to oldest.
static let supported: Set<String> = [
public static let supported: Set<String> = [
"2025-11-25",
"2025-06-18",
"2025-03-26",
"2024-11-05",
]

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