* 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>
251 lines
8.3 KiB
Swift
251 lines
8.3 KiB
Swift
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()
|
|
}
|
|
}
|