* fix #203 NetworkTransport race, with main-isolated variables (#206) Co-authored-by: wti <wti@users.noreply.github.com> * feat/auth: Authentication implementation (#205) * feat: added authentication * feat(auth): improvement to error handling * feat(auth): updated errors and tests in OAuthClientRegistrar * feat(auth): updated ci conformance package version * feat(auth): bumped node version * feat(auth): updated ci conformance step * feat(auth): improvement to Linux support * chore: added docc plugin * chore: added spi yml * chore: added documentation step to ci * chore: increased timeout for test from 5 to 10m --------- Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com> Co-authored-by: wti <wti@users.noreply.github.com> Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
432 lines
14 KiB
Swift
432 lines
14 KiB
Swift
import Foundation
|
|
import Logging
|
|
import MCP
|
|
@preconcurrency import NIOCore
|
|
@preconcurrency import NIOPosix
|
|
@preconcurrency import NIOHTTP1
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
actor HTTPApp {
|
|
/// Configuration for the HTTP application.
|
|
struct Configuration: Sendable {
|
|
/// The host address to bind to.
|
|
var host: String
|
|
|
|
/// The port to bind to.
|
|
var port: Int
|
|
|
|
/// The MCP endpoint path.
|
|
var endpoint: String
|
|
|
|
/// Session timeout in seconds.
|
|
var sessionTimeout: TimeInterval
|
|
|
|
/// SSE retry interval in milliseconds for priming events.
|
|
var retryInterval: Int?
|
|
|
|
init(
|
|
host: String = "127.0.0.1",
|
|
port: Int = 3000,
|
|
endpoint: String = "/mcp",
|
|
sessionTimeout: TimeInterval = 3600,
|
|
retryInterval: Int? = nil
|
|
) {
|
|
self.host = host
|
|
self.port = port
|
|
self.endpoint = endpoint
|
|
self.sessionTimeout = sessionTimeout
|
|
self.retryInterval = retryInterval
|
|
}
|
|
}
|
|
|
|
/// Factory function to create MCP Server instances for each session.
|
|
typealias ServerFactory = @Sendable (String, StatefulHTTPServerTransport) async throws -> Server
|
|
|
|
private let configuration: Configuration
|
|
private let serverFactory: ServerFactory
|
|
private let validationPipeline: (any HTTPRequestValidationPipeline)?
|
|
private var channel: Channel?
|
|
private var sessions: [String: SessionContext] = [:]
|
|
|
|
nonisolated let logger: Logger
|
|
|
|
struct SessionContext {
|
|
let server: Server
|
|
let transport: StatefulHTTPServerTransport
|
|
let createdAt: Date
|
|
var lastAccessedAt: Date
|
|
}
|
|
|
|
// MARK: - Init
|
|
|
|
/// Creates a new HTTP application.
|
|
///
|
|
/// - Parameters:
|
|
/// - configuration: Application configuration.
|
|
/// - validationPipeline: Custom validation pipeline passed to each transport.
|
|
/// If `nil`, transports use their sensible defaults.
|
|
/// - serverFactory: Factory function to create Server instances for each session.
|
|
/// - logger: Optional logger instance.
|
|
init(
|
|
configuration: Configuration = Configuration(),
|
|
validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
|
|
serverFactory: @escaping ServerFactory,
|
|
logger: Logger? = nil
|
|
) {
|
|
self.configuration = configuration
|
|
self.serverFactory = serverFactory
|
|
self.validationPipeline = validationPipeline
|
|
self.logger = logger ?? Logger(
|
|
label: "mcp.http.app",
|
|
factory: { _ in SwiftLogNoOpLogHandler() }
|
|
)
|
|
}
|
|
|
|
/// Convenience initializer with individual parameters.
|
|
init(
|
|
host: String = "127.0.0.1",
|
|
port: Int = 3000,
|
|
endpoint: String = "/mcp",
|
|
serverFactory: @escaping ServerFactory,
|
|
logger: Logger? = nil
|
|
) {
|
|
self.init(
|
|
configuration: Configuration(host: host, port: port, endpoint: endpoint),
|
|
serverFactory: serverFactory,
|
|
logger: logger
|
|
)
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// Starts the HTTP application.
|
|
///
|
|
/// This starts the NIO HTTP server and begins accepting connections.
|
|
/// The call blocks until the server is shut down via ``stop()``.
|
|
func start() async throws {
|
|
let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
|
|
|
let bootstrap = ServerBootstrap(group: group)
|
|
.serverChannelOption(ChannelOptions.backlog, value: 256)
|
|
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
|
.childChannelInitializer { channel in
|
|
channel.pipeline.configureHTTPServerPipeline().flatMap {
|
|
channel.pipeline.addHandler(HTTPHandler(app: self))
|
|
}
|
|
}
|
|
.childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
|
.childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1)
|
|
|
|
logger.info(
|
|
"Starting MCP HTTP application",
|
|
metadata: [
|
|
"host": "\(configuration.host)",
|
|
"port": "\(configuration.port)",
|
|
"endpoint": "\(configuration.endpoint)",
|
|
]
|
|
)
|
|
|
|
let channel = try await bootstrap.bind(host: configuration.host, port: configuration.port).get()
|
|
self.channel = channel
|
|
|
|
Task { await sessionCleanupLoop() }
|
|
|
|
try await channel.closeFuture.get()
|
|
}
|
|
|
|
/// Stops the HTTP application gracefully, closing all sessions.
|
|
func stop() async {
|
|
await closeAllSessions()
|
|
try? await channel?.close()
|
|
channel = nil
|
|
logger.info("MCP HTTP application stopped")
|
|
}
|
|
|
|
// MARK: - Request Routing
|
|
|
|
var endpoint: String { configuration.endpoint }
|
|
|
|
/// Routes an incoming HTTP request to the appropriate session transport.
|
|
///
|
|
/// - Requests with a valid `Mcp-Session-Id` are forwarded to the matching transport.
|
|
/// - POST requests with an `initialize` body create a new session.
|
|
/// - All other requests without a session return an error.
|
|
func handleHTTPRequest(_ request: HTTPRequest) async -> HTTPResponse {
|
|
let sessionID = request.header(HTTPHeaderName.sessionID)
|
|
|
|
// Route to existing session
|
|
if let sessionID, var session = sessions[sessionID] {
|
|
session.lastAccessedAt = Date()
|
|
sessions[sessionID] = session
|
|
|
|
let response = await session.transport.handleRequest(request)
|
|
|
|
// Clean up on successful DELETE
|
|
if request.method.uppercased() == "DELETE" && response.statusCode == 200 {
|
|
sessions.removeValue(forKey: sessionID)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
// No session — check for initialize request
|
|
if request.method.uppercased() == "POST",
|
|
let body = request.body,
|
|
let kind = JSONRPCMessageKind(data: body),
|
|
kind.isInitializeRequest
|
|
{
|
|
return await createSessionAndHandle(request)
|
|
}
|
|
|
|
// No session and not initialize
|
|
if sessionID != nil {
|
|
return .error(statusCode: 404, .invalidRequest("Not Found: Session not found or expired"))
|
|
}
|
|
return .error(
|
|
statusCode: 400,
|
|
.invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header")
|
|
)
|
|
}
|
|
|
|
// MARK: - Session Management
|
|
|
|
private struct FixedSessionIDGenerator: SessionIDGenerator {
|
|
let sessionID: String
|
|
func generateSessionID() -> String { sessionID }
|
|
}
|
|
|
|
private func createSessionAndHandle(_ request: HTTPRequest) async -> HTTPResponse {
|
|
let sessionID = UUID().uuidString
|
|
|
|
let transport = StatefulHTTPServerTransport(
|
|
sessionIDGenerator: FixedSessionIDGenerator(sessionID: sessionID),
|
|
validationPipeline: validationPipeline,
|
|
retryInterval: configuration.retryInterval,
|
|
logger: logger
|
|
)
|
|
|
|
do {
|
|
let server = try await serverFactory(sessionID, transport)
|
|
try await server.start(transport: transport)
|
|
|
|
sessions[sessionID] = SessionContext(
|
|
server: server,
|
|
transport: transport,
|
|
createdAt: Date(),
|
|
lastAccessedAt: Date()
|
|
)
|
|
|
|
let response = await transport.handleRequest(request)
|
|
|
|
// If transport returned an error, clean up
|
|
if case .error = response {
|
|
sessions.removeValue(forKey: sessionID)
|
|
await transport.disconnect()
|
|
}
|
|
|
|
return response
|
|
} catch {
|
|
await transport.disconnect()
|
|
return .error(
|
|
statusCode: 500,
|
|
.internalError("Failed to create session: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
}
|
|
|
|
private func closeSession(_ sessionID: String) async {
|
|
guard let session = sessions.removeValue(forKey: sessionID) else { return }
|
|
await session.transport.disconnect()
|
|
logger.info("Closed session", metadata: ["sessionID": "\(sessionID)"])
|
|
}
|
|
|
|
private func closeAllSessions() async {
|
|
for sessionID in sessions.keys {
|
|
await closeSession(sessionID)
|
|
}
|
|
}
|
|
|
|
private func sessionCleanupLoop() async {
|
|
while true {
|
|
try? await Task.sleep(for: .seconds(60))
|
|
|
|
let now = Date()
|
|
let expired = sessions.filter { _, context in
|
|
now.timeIntervalSince(context.lastAccessedAt) > configuration.sessionTimeout
|
|
}
|
|
|
|
for (sessionID, _) in expired {
|
|
logger.info("Session expired", metadata: ["sessionID": "\(sessionID)"])
|
|
await closeSession(sessionID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - NIO HTTP Handler
|
|
|
|
/// Thin NIO adapter that converts between NIO HTTP types and the framework-agnostic
|
|
/// `HTTPRequest`/`HTTPResponse` types, delegating all logic to the `HTTPApp`.
|
|
private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable {
|
|
typealias InboundIn = HTTPServerRequestPart
|
|
typealias OutboundOut = HTTPServerResponsePart
|
|
|
|
private let app: HTTPApp
|
|
|
|
private struct RequestState {
|
|
var head: HTTPRequestHead
|
|
var bodyBuffer: ByteBuffer
|
|
}
|
|
|
|
private var requestState: RequestState?
|
|
|
|
init(app: HTTPApp) {
|
|
self.app = app
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
let part = unwrapInboundIn(data)
|
|
|
|
switch part {
|
|
case .head(let head):
|
|
requestState = RequestState(
|
|
head: head,
|
|
bodyBuffer: context.channel.allocator.buffer(capacity: 0)
|
|
)
|
|
case .body(var buffer):
|
|
requestState?.bodyBuffer.writeBuffer(&buffer)
|
|
case .end:
|
|
guard let state = requestState else { return }
|
|
requestState = nil
|
|
|
|
nonisolated(unsafe) let ctx = context
|
|
Task { @MainActor in
|
|
await self.handleRequest(state: state, context: ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Request Processing
|
|
|
|
private func handleRequest(state: RequestState, context: ChannelHandlerContext) async {
|
|
let head = state.head
|
|
let path = head.uri.split(separator: "?").first.map(String.init) ?? head.uri
|
|
let endpoint = await app.endpoint
|
|
|
|
guard path == endpoint else {
|
|
await writeResponse(
|
|
.error(statusCode: 404, .invalidRequest("Not Found")),
|
|
version: head.version,
|
|
context: context
|
|
)
|
|
return
|
|
}
|
|
|
|
let httpRequest = makeHTTPRequest(from: state)
|
|
let response = await app.handleHTTPRequest(httpRequest)
|
|
await writeResponse(response, version: head.version, context: context)
|
|
}
|
|
|
|
// MARK: - NIO ↔ HTTPRequest/HTTPResponse Conversion
|
|
|
|
private func makeHTTPRequest(from state: RequestState) -> HTTPRequest {
|
|
// Combine multiple header values per RFC 7230
|
|
var headers: [String: String] = [:]
|
|
for (name, value) in state.head.headers {
|
|
if let existing = headers[name] {
|
|
headers[name] = existing + ", " + value
|
|
} else {
|
|
headers[name] = value
|
|
}
|
|
}
|
|
|
|
let body: Data?
|
|
if state.bodyBuffer.readableBytes > 0,
|
|
let bytes = state.bodyBuffer.getBytes(at: 0, length: state.bodyBuffer.readableBytes)
|
|
{
|
|
body = Data(bytes)
|
|
} else {
|
|
body = nil
|
|
}
|
|
|
|
let path = String(state.head.uri.split(separator: "?").first ?? Substring(state.head.uri))
|
|
|
|
return HTTPRequest(
|
|
method: state.head.method.rawValue,
|
|
headers: headers,
|
|
body: body,
|
|
path: path
|
|
)
|
|
}
|
|
|
|
private func writeResponse(
|
|
_ response: HTTPResponse,
|
|
version: HTTPVersion,
|
|
context: ChannelHandlerContext
|
|
) async {
|
|
nonisolated(unsafe) let ctx = context
|
|
let eventLoop = ctx.eventLoop
|
|
|
|
// Write response head
|
|
let statusCode = response.statusCode
|
|
let headers = response.headers
|
|
|
|
switch response {
|
|
case .stream(let stream, _):
|
|
eventLoop.execute {
|
|
var head = HTTPResponseHead(
|
|
version: version,
|
|
status: HTTPResponseStatus(statusCode: statusCode)
|
|
)
|
|
for (name, value) in headers {
|
|
head.headers.add(name: name, value: value)
|
|
}
|
|
ctx.write(self.wrapOutboundOut(.head(head)), promise: nil)
|
|
ctx.flush()
|
|
}
|
|
|
|
// Await the SSE stream directly — no Task needed since we're already in one
|
|
do {
|
|
for try await chunk in stream {
|
|
eventLoop.execute {
|
|
var buffer = ctx.channel.allocator.buffer(capacity: chunk.count)
|
|
buffer.writeBytes(chunk)
|
|
ctx.writeAndFlush(
|
|
self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
|
|
}
|
|
}
|
|
} catch {
|
|
// Stream ended with error — close connection
|
|
}
|
|
|
|
eventLoop.execute {
|
|
ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
|
}
|
|
|
|
default:
|
|
let bodyData = response.bodyData
|
|
eventLoop.execute {
|
|
var head = HTTPResponseHead(
|
|
version: version,
|
|
status: HTTPResponseStatus(statusCode: statusCode)
|
|
)
|
|
for (name, value) in headers {
|
|
head.headers.add(name: name, value: value)
|
|
}
|
|
|
|
ctx.write(self.wrapOutboundOut(.head(head)), promise: nil)
|
|
|
|
if let body = bodyData {
|
|
var buffer = ctx.channel.allocator.buffer(capacity: body.count)
|
|
buffer.writeBytes(body)
|
|
ctx.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
|
|
}
|
|
|
|
ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|