Improvements and fixes (#223)
Some checks failed
CI / Test (macos-latest, Swift 6.1.0) (push) Has been cancelled
CI / Test (ubuntu-latest, Swift 6.1.0) (push) Has been cancelled
CI / MCP Conformance Tests (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / Linux Static SDK Build (6.1.2 - ubuntu-latest) (push) Has been cancelled

* feat: added http transport context to the task local execution

* fix: added refresh_token to the grantTypes for DCR

* chore: updated documentation
This commit is contained in:
yehorsobko 2026-04-29 13:34:21 +02:00 • committed by GitHub
commit a0ae212ebf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 345 additions and 14 deletions

View file

@ -79,7 +79,7 @@ struct OAuthClientRegistrar: Sendable {
let responseTypes: [String]
switch configuration.grantType {
case .authorizationCode:
grantTypes = [OAuthGrantTypeValue.authorizationCode]
grantTypes = [OAuthGrantTypeValue.authorizationCode, OAuthGrantTypeValue.refreshToken]
responseTypes = [OAuthParameterName.code]
case .clientCredentials:
grantTypes = [OAuthGrantTypeValue.clientCredentials]

View file

@ -207,6 +207,24 @@ struct SSEEvent: Sendable {
}
}
// MARK: - HTTP Context Providing
/// A transport that can surface the originating HTTP request for an in-flight JSON-RPC
/// request, keyed by its JSON-RPC id.
///
/// The server consults this during dispatch so that registered method handlers can
/// observe the HTTP request that triggered them (headers, auth, path, body) via
/// ``Server/currentHandlerContext``, without changing the `withMethodHandler` signature.
///
/// Only JSON-RPC *requests* (with an id) are addressable — notifications have no id and
/// are not correlated.
public protocol HTTPContextProviding: Sendable {
/// Returns the HTTP request associated with the given JSON-RPC id, if the
/// transport still has it on hand. Implementations must return `nil` once the
/// corresponding response has been delivered or the session has terminated.
func httpRequestContext(for id: ID) async -> HTTPRequest?
}
// MARK: - JSON-RPC Message Classification
/// Classifies a raw JSON-RPC message for routing purposes.

View file

@ -29,7 +29,7 @@ import Logging
/// 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 actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
public nonisolated let logger: Logger
// MARK: - Dependencies
@ -54,6 +54,11 @@ public actor StatefulHTTPServerTransport: Transport {
/// Maps request ID → SSE stream continuation for active POST request streams.
private var requestSSEContinuations: [String: AsyncThrowingStream<Data, Swift.Error>.Continuation] = [:]
/// Maps request ID → originating HTTP request, surfaced to handlers via
/// ``Server/currentHTTPContext``. Cleared when the response is routed,
/// the stream is closed, or the session terminates.
private var httpRequestContexts: [String: HTTPRequest] = [:]
// MARK: - Standalone GET SSE stream
/// The standalone SSE stream continuation for server-initiated messages.
@ -280,6 +285,7 @@ public actor StatefulHTTPServerTransport: Transport {
// Create SSE stream for this request
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
requestSSEContinuations[requestID] = sseContinuation
httpRequestContexts[requestID] = request
// Extract protocol version for priming event decision
let protocolVersion = extractProtocolVersion(from: body, request: request)
@ -406,6 +412,7 @@ public actor StatefulHTTPServerTransport: Transport {
continuation.finish()
requestSSEContinuations.removeValue(forKey: requestID)
}
httpRequestContexts.removeValue(forKey: requestID)
}
private func routeServerInitiatedMessage(_ data: Data) {
@ -535,6 +542,13 @@ public actor StatefulHTTPServerTransport: Transport {
guard let continuation = requestSSEContinuations[requestID] else { return }
continuation.finish()
requestSSEContinuations.removeValue(forKey: requestID)
httpRequestContexts.removeValue(forKey: requestID)
}
// MARK: - HTTPContextProviding
public func httpRequestContext(for id: ID) -> HTTPRequest? {
httpRequestContexts[id.description]
}
// MARK: - Termination
@ -552,6 +566,7 @@ public actor StatefulHTTPServerTransport: Transport {
continuation.finish()
}
requestSSEContinuations.removeAll()
httpRequestContexts.removeAll()
// Close standalone GET stream
standaloneSSEContinuation?.finish()

View file

@ -29,7 +29,7 @@ import Logging
/// - Session management is handled externally or not needed
///
/// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead.
public actor StatelessHTTPServerTransport: Transport {
public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
public nonisolated let logger: Logger
// MARK: - Dependencies
@ -52,6 +52,11 @@ public actor StatelessHTTPServerTransport: Transport {
/// When the server calls `send()` with a response, the matching continuation is resumed.
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]
/// Maps request ID → originating HTTP request, surfaced to handlers via
/// ``Server/currentHTTPContext``. Entries live only while a JSON-RPC request
/// is in flight.
private var httpRequestContexts: [String: HTTPRequest] = [:]
// MARK: - Init
/// Creates a new stateless HTTP server transport.
@ -207,11 +212,16 @@ public actor StatelessHTTPServerTransport: Transport {
return .accepted()
case .request(let id, _):
return await handleJSONRPCRequest(body, requestID: id)
return await handleJSONRPCRequest(body, requestID: id, request: request)
}
}
private func handleJSONRPCRequest(_ body: Data, requestID: String) async -> HTTPResponse {
private func handleJSONRPCRequest(
_ body: Data,
requestID: String,
request: HTTPRequest
) async -> HTTPResponse {
httpRequestContexts[requestID] = request
// Yield the incoming message to the server
incomingContinuation.yield(body)
@ -222,15 +232,23 @@ public actor StatelessHTTPServerTransport: Transport {
responseWaiters[requestID] = continuation
}
} catch {
httpRequestContexts.removeValue(forKey: requestID)
return .error(
statusCode: 500,
.internalError("Error processing request: \(error.localizedDescription)")
)
}
httpRequestContexts.removeValue(forKey: requestID)
return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json])
}
// MARK: - HTTPContextProviding
public func httpRequestContext(for id: ID) -> HTTPRequest? {
httpRequestContexts[id.description]
}
// MARK: - Termination
private func terminate() async {
@ -245,6 +263,7 @@ public actor StatelessHTTPServerTransport: Transport {
logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"])
}
responseWaiters.removeAll()
httpRequestContexts.removeAll()
// Close incoming stream
incomingContinuation.finish()

View file

@ -298,13 +298,41 @@ public actor Server {
// MARK: - Request Context
/// The JSON-RPC request ID of the currently executing method handler.
/// Per-dispatch context the SDK attaches to the currently executing handler.
///
/// Exposed via the ``Server/currentHandlerContext`` task-local so method
/// handlers can observe the originating HTTP request (headers, auth, path,
/// body) without changing the `withMethodHandler` signature.
public struct HandlerContext: Sendable {
/// The JSON-RPC request id of the in-flight request. SDK-internal use
/// (e.g. transports closing an SSE stream mid-call per SEP-1699).
package let id: ID
/// The originating HTTP request, if the active transport conforms to
/// ``HTTPContextProviding``. `nil` for transports that don't carry HTTP
/// context (stdio, in-memory) or for handlers reached off the dispatch
/// path.
public let httpContext: HTTPRequest?
package init(id: ID, httpContext: HTTPRequest?) {
self.id = id
self.httpContext = httpContext
}
}
/// The handler context for the currently executing method handler.
///
/// Set via `@TaskLocal` before dispatching each request, so it propagates
/// automatically into the handler task. Accessible package-wide for
/// transports that need to identify the active request (e.g. closing an
/// SSE stream mid-call for reconnection testing per SEP-1699).
@TaskLocal package static var currentRequestID: ID? = nil
/// automatically into the handler task. `nil` outside of a handler.
///
/// When spawning `Task.detached { … }` from inside a handler, capture the
/// value up front — detached tasks do not inherit task-locals:
///
/// ```swift
/// let ctx = Server.currentHandlerContext
/// Task.detached { await doWork(with: ctx?.httpContext) }
/// ```
@TaskLocal public static var currentHandlerContext: HandlerContext? = nil
// MARK: - Registration
@ -750,10 +778,17 @@ public actor Server {
return response
}
// Ask the transport for the originating HTTP request so handlers can observe
// headers/auth via `Server.currentHandlerContext?.httpContext`. Transports
// that don't carry HTTP context (stdio, in-memory) don't conform.
let httpContext = await (connection as? any HTTPContextProviding)?
.httpRequestContext(for: request.id)
let handlerContext = HandlerContext(id: request.id, httpContext: httpContext)
// Create a task to handle the request with cancellation support.
// Set currentRequestID as a task local so handlers can identify the active request.
// Set currentHandlerContext as a task local so handlers see it.
var handlerTask: Task<Response<AnyMethod>, Error>!
Server.$currentRequestID.withValue(request.id) {
Server.$currentHandlerContext.withValue(handlerContext) {
handlerTask = Task<Response<AnyMethod>, Error> {
do {
// Check if task was cancelled before starting

View file

@ -180,8 +180,7 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr
// SEP-1699: Close the SSE stream mid-call to trigger client reconnection.
// The client should reconnect via GET with Last-Event-ID and receive the
// response on the new stream.
if let requestID = Server.currentRequestID {
if let requestID = Server.currentHandlerContext?.id {
await transport.closeSSEStream(forRequestID: requestID.description)
}
// Wait briefly for the client to reconnect before sending the response.