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
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:
parent
cb6a62f7c2
commit
a0ae212ebf
8 changed files with 345 additions and 14 deletions
30
README.md
30
README.md
|
|
@ -41,6 +41,7 @@ of the MCP specification.
|
|||
- [Logging](#logging-1)
|
||||
- [Progress Tracking](#progress-tracking-1)
|
||||
- [Initialize Hook](#initialize-hook)
|
||||
- [HTTP Request Context in Handlers](#http-request-context-in-handlers)
|
||||
- [Graceful Shutdown](#graceful-shutdown)
|
||||
- [Transports](#transports)
|
||||
- [Authentication](#authentication)
|
||||
|
|
@ -1184,6 +1185,35 @@ try await server.start(transport: transport) { clientInfo, clientCapabilities in
|
|||
}
|
||||
```
|
||||
|
||||
### HTTP Request Context in Handlers
|
||||
|
||||
When a server is connected over `StatefulHTTPServerTransport` or `StatelessHTTPServerTransport`,
|
||||
method handlers can observe the originating HTTP request (headers, body, path, method) via
|
||||
`Server.currentHandlerContext` — a task-local set automatically before each handler runs:
|
||||
|
||||
```swift
|
||||
await server.withMethodHandler(CallTool.self) { params in
|
||||
let httpRequest = Server.currentHandlerContext?.httpContext
|
||||
let authHeader = httpRequest?.headers["Authorization"]
|
||||
// …use the header to scope the response, audit, etc.
|
||||
return .init(content: [.text("ok")], isError: false)
|
||||
}
|
||||
```
|
||||
|
||||
`httpContext` is `nil` for transports that don't carry HTTP context (e.g. `StdioTransport`,
|
||||
`InMemoryTransport`) and for handlers reached off the dispatch path.
|
||||
|
||||
Task-locals are not inherited by `Task.detached`. If you spawn a detached task from a handler,
|
||||
capture the context up front:
|
||||
|
||||
```swift
|
||||
let ctx = Server.currentHandlerContext
|
||||
Task.detached { await doWork(with: ctx?.httpContext) }
|
||||
```
|
||||
|
||||
Custom HTTP transports can opt in by conforming to `HTTPContextProviding` and returning the
|
||||
`HTTPRequest` for a given JSON-RPC id while it is in flight.
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
We recommend using
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1052,3 +1052,218 @@ struct StatelessHTTPServerTransportTests {
|
|||
#expect(response.statusCode == 500)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTPContextProviding / Server.currentHandlerContext
|
||||
|
||||
@Suite("Server.currentHandlerContext Tests")
|
||||
struct ServerHandlerContextTests {
|
||||
|
||||
// MARK: Stateful transport — HTTPContextProviding
|
||||
|
||||
@Test("Stateful: httpRequestContext returns in-flight request, cleared after response")
|
||||
func testStatefulContextLifecycle() async throws {
|
||||
let sessionID = "test-session"
|
||||
let transport = makeStatefulTransport(
|
||||
sessionIDGenerator: FixedSessionIDGenerator(sessionID: sessionID)
|
||||
)
|
||||
_ = try await initializeSession(transport: transport, sessionID: sessionID)
|
||||
|
||||
// POST a tools/list request with a distinctive Authorization header and path.
|
||||
let body = makeRequestBody(id: "42", method: "tools/list")
|
||||
let httpRequest = HTTPRequest(
|
||||
method: "POST",
|
||||
headers: [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Mcp-Session-Id": sessionID,
|
||||
"Authorization": "Bearer token-xyz",
|
||||
],
|
||||
body: body,
|
||||
path: "/mcp"
|
||||
)
|
||||
let response = await transport.handleRequest(httpRequest)
|
||||
#expect(response.statusCode == 200)
|
||||
|
||||
// In-flight: context surfaces through the protocol lookup.
|
||||
let inFlight = await transport.httpRequestContext(for: .string("42"))
|
||||
#expect(inFlight != nil)
|
||||
#expect(inFlight?.header("Authorization") == "Bearer token-xyz")
|
||||
#expect(inFlight?.path == "/mcp")
|
||||
|
||||
// Drain the SSE stream in the background so the response routes cleanly.
|
||||
if case .stream(let stream, _) = response {
|
||||
Task { for try await _ in stream {} }
|
||||
}
|
||||
|
||||
// Send the response — transport clears the context alongside the SSE map.
|
||||
try await transport.send(makeResponseBody(id: "42"))
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
|
||||
let afterResponse = await transport.httpRequestContext(for: .string("42"))
|
||||
#expect(afterResponse == nil)
|
||||
}
|
||||
|
||||
@Test("Stateful: context cleared on session termination")
|
||||
func testStatefulContextClearedOnTerminate() async throws {
|
||||
let sessionID = "term-session"
|
||||
let transport = makeStatefulTransport(
|
||||
sessionIDGenerator: FixedSessionIDGenerator(sessionID: sessionID)
|
||||
)
|
||||
_ = try await initializeSession(transport: transport, sessionID: sessionID)
|
||||
|
||||
// Register an in-flight request to populate the context map.
|
||||
let body = makeRequestBody(id: "7", method: "tools/list")
|
||||
_ = await transport.handleRequest(
|
||||
makeStatefulPOSTRequest(
|
||||
body: body,
|
||||
sessionID: sessionID,
|
||||
authorization: "Bearer keep"
|
||||
)
|
||||
)
|
||||
#expect(await transport.httpRequestContext(for: .string("7")) != nil)
|
||||
|
||||
// DELETE terminates the session.
|
||||
_ = await transport.handleRequest(makeDELETERequest(sessionID: sessionID))
|
||||
|
||||
#expect(await transport.httpRequestContext(for: .string("7")) == nil)
|
||||
}
|
||||
|
||||
// MARK: Stateless transport — HTTPContextProviding
|
||||
|
||||
@Test("Stateless: context available during handling, cleared after response")
|
||||
func testStatelessContextLifecycle() async throws {
|
||||
let transport = makeStatelessTransport()
|
||||
try await transport.connect()
|
||||
|
||||
let httpRequest = HTTPRequest(
|
||||
method: "POST",
|
||||
headers: [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer stateless-token",
|
||||
],
|
||||
body: makeRequestBody(id: "99", method: "tools/list"),
|
||||
path: "/mcp"
|
||||
)
|
||||
|
||||
// handleRequest blocks until a response is sent — run it concurrently.
|
||||
let handleTask = Task { await transport.handleRequest(httpRequest) }
|
||||
|
||||
// Give the handler time to register its waiter (and store the context).
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
|
||||
let inFlight = await transport.httpRequestContext(for: .string("99"))
|
||||
#expect(inFlight != nil)
|
||||
#expect(inFlight?.header("Authorization") == "Bearer stateless-token")
|
||||
#expect(inFlight?.path == "/mcp")
|
||||
|
||||
// Send the response to unblock the waiter.
|
||||
try await transport.send(makeResponseBody(id: "99"))
|
||||
_ = await handleTask.value
|
||||
|
||||
let afterResponse = await transport.httpRequestContext(for: .string("99"))
|
||||
#expect(afterResponse == nil)
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Stateless: unknown request id returns nil")
|
||||
func testStatelessContextUnknownID() async throws {
|
||||
let transport = makeStatelessTransport()
|
||||
try await transport.connect()
|
||||
let none = await transport.httpRequestContext(for: .string("does-not-exist"))
|
||||
#expect(none == nil)
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
// MARK: Server integration — end-to-end
|
||||
|
||||
@Test("Server handler reads HTTP context via Server.currentHandlerContext")
|
||||
func testServerHandlerReadsHTTPContext() async throws {
|
||||
actor Captured {
|
||||
var auth: String?
|
||||
var path: String?
|
||||
var id: ID?
|
||||
func record(auth: String?, path: String?, id: ID?) {
|
||||
self.auth = auth
|
||||
self.path = path
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
let captured = Captured()
|
||||
|
||||
let transport = makeStatelessTransport()
|
||||
let server = Server(name: "TestServer", version: "1.0")
|
||||
|
||||
await server.withMethodHandler(CallTool.self) { _ in
|
||||
let ctx = Server.currentHandlerContext
|
||||
await captured.record(
|
||||
auth: ctx?.httpContext?.header("Authorization"),
|
||||
path: ctx?.httpContext?.path,
|
||||
id: ctx?.id
|
||||
)
|
||||
return CallTool.Result(content: [.text(text: "ok", annotations: nil, _meta: nil)])
|
||||
}
|
||||
|
||||
try await server.start(transport: transport)
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let body = try JSONSerialization.data(withJSONObject: [
|
||||
"jsonrpc": "2.0",
|
||||
"id": "call-1",
|
||||
"method": "tools/call",
|
||||
"params": ["name": "whoami"] as [String: Any],
|
||||
])
|
||||
let httpRequest = HTTPRequest(
|
||||
method: "POST",
|
||||
headers: [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer server-integration-token",
|
||||
],
|
||||
body: body,
|
||||
path: "/mcp"
|
||||
)
|
||||
|
||||
let httpResponse = await transport.handleRequest(httpRequest)
|
||||
#expect(httpResponse.statusCode == 200)
|
||||
|
||||
#expect(await captured.auth == "Bearer server-integration-token")
|
||||
#expect(await captured.path == "/mcp")
|
||||
#expect(await captured.id == .string("call-1"))
|
||||
}
|
||||
|
||||
@Test("Non-HTTP transport yields nil httpContext")
|
||||
func testNonHTTPTransportNilContext() async throws {
|
||||
actor Captured {
|
||||
var httpContext: HTTPRequest?
|
||||
var sawDispatch = false
|
||||
func set(_ v: HTTPRequest?) { httpContext = v; sawDispatch = true }
|
||||
}
|
||||
let captured = Captured()
|
||||
|
||||
let transport = MockTransport()
|
||||
let server = Server(name: "TestServer", version: "1.0")
|
||||
|
||||
// Start first — `start` calls registerDefaultHandlers which would otherwise
|
||||
// clobber our override.
|
||||
try await server.start(transport: transport)
|
||||
|
||||
// Override the Ping handler so we can observe the task-local during dispatch.
|
||||
await server.withMethodHandler(Ping.self) { _ in
|
||||
await captured.set(Server.currentHandlerContext?.httpContext)
|
||||
return Empty()
|
||||
}
|
||||
|
||||
try await transport.queue(request: Ping.request(.init()))
|
||||
|
||||
// Wait for dispatch.
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
#expect(await captured.sawDispatch == true)
|
||||
#expect(await captured.httpContext == nil)
|
||||
|
||||
await server.stop()
|
||||
await transport.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue