V0.11.x (#199)
* 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:
parent
6fa1a97b41
commit
6112a3995a
1 changed files with 11854 additions and 828 deletions
429
Sources/MCPConformance/Server/HTTPApp.swift
Normal file
429
Sources/MCPConformance/Server/HTTPApp.swift
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
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) 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)
|
||||
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
|
||||
}
|
||||
|
||||
return HTTPRequest(
|
||||
method: state.head.method.rawValue,
|
||||
headers: headers,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue