diff --git a/Demos/SwiftMCPDemo/Commands/HTTPSSECommand.swift b/Demos/SwiftMCPDemo/Commands/HTTPSSECommand.swift index a994a00..1aa3998 100644 --- a/Demos/SwiftMCPDemo/Commands/HTTPSSECommand.swift +++ b/Demos/SwiftMCPDemo/Commands/HTTPSSECommand.swift @@ -3,6 +3,7 @@ import Foundation import ArgumentParser import SwiftMCP import Logging +import ServiceLifecycle #if canImport(OSLog) import OSLog #endif @@ -99,9 +100,6 @@ final class HTTPSSECommand: AsyncParsableCommand { @Option(name: .long, help: "Path to OAuth configuration JSON file") var oauth: String? - // Make this a computed property instead of stored property - private var signalHandler: SignalHandler? - required init() {} // Add manual Decodable conformance @@ -139,17 +137,24 @@ final class HTTPSSECommand: AsyncParsableCommand { try configureAuthentication(on: transport) transport.serveOpenAPI = openapi - let tcpTransport = try await startTCPTransportIfNeeded(server: calculator) - await setupSignalHandling(httpTransport: transport, tcpTransport: tcpTransport) - - do { - try await transport.run() - } catch { - if let tcpTransport { - try? await tcpTransport.stop() - } - throw error + // Each transport is a `Service`. The `ServiceGroup` starts them, traps + // SIGINT/SIGTERM, and drives an ordered graceful shutdown (with a + // timeout) — replacing the bespoke signal handler. + var services: [ServiceGroupConfiguration.ServiceConfiguration] = [ + .init(service: transport, successTerminationBehavior: .gracefullyShutdownGroup) + ] + if let tcpTransport = makeTCPTransportIfNeeded(server: calculator) { + services.append(.init(service: tcpTransport, successTerminationBehavior: .gracefullyShutdownGroup)) } + + let group = ServiceGroup( + configuration: .init( + services: services, + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Logging.Logger(label: "com.cocoanetics.SwiftMCP.ServiceGroup") + ) + ) + try await group.run() } private func configureAuthentication(on transport: HTTPSSETransport) throws { @@ -207,24 +212,12 @@ final class HTTPSSECommand: AsyncParsableCommand { } } - private func startTCPTransportIfNeeded(server: DemoServer) async throws -> TCPBonjourTransport? { + /// Builds the optional TCP+Bonjour transport. It is returned unstarted — + /// the `ServiceGroup` starts it by calling `run()`. + private func makeTCPTransportIfNeeded(server: DemoServer) -> TCPBonjourTransport? { guard tcp else { return nil } - let transport = TCPBonjourTransport(server: server) - try await transport.start() - print("MCP Server \(server.serverName) started with TCP+Bonjour transport") - return transport - } - - private func setupSignalHandling( - httpTransport: HTTPSSETransport, - tcpTransport: TCPBonjourTransport? - ) async { - if let tcpTransport { - signalHandler = SignalHandler(transports: [httpTransport, tcpTransport]) - } else { - signalHandler = SignalHandler(transport: httpTransport) - } - await signalHandler?.setup() + print("MCP Server \(server.serverName) will also expose a TCP+Bonjour transport") + return TCPBonjourTransport(server: server) } } #endif diff --git a/Demos/SwiftMCPDemo/Commands/StdioCommand.swift b/Demos/SwiftMCPDemo/Commands/StdioCommand.swift index 83bdfed..5ae9251 100644 --- a/Demos/SwiftMCPDemo/Commands/StdioCommand.swift +++ b/Demos/SwiftMCPDemo/Commands/StdioCommand.swift @@ -4,6 +4,7 @@ import ArgumentParser import SwiftMCP import Logging import NIOCore +import ServiceLifecycle #if canImport(OSLog) import OSLog #endif @@ -40,6 +41,16 @@ struct StdioCommand: AsyncParsableCommand { """ ) + /// A logger bound to stderr so `ServiceGroup` lifecycle messages never + /// interleave with the JSON-RPC responses written to stdout. + private static let lifecycleLogger: Logging.Logger = { + var logger = Logging.Logger(label: "com.cocoanetics.SwiftMCP.ServiceGroup") { + StreamLogHandler.standardError(label: $0) + } + logger.logLevel = .notice + return logger + }() + func run() async throws { #if canImport(OSLog) LoggingSystem.bootstrapWithOSLog() @@ -52,7 +63,20 @@ struct StdioCommand: AsyncParsableCommand { logToStderr("MCP Server \(calculator.serverName) (\(calculator.serverVersion)) started with Stdio transport") let transport = StdioTransport(server: calculator) - try await transport.run() + + // A `ServiceGroup` owns the run loop and traps SIGINT/SIGTERM, + // driving a graceful shutdown of the transport. The lifecycle logs + // go to stderr so they never corrupt the stdout JSON-RPC stream. + let group = ServiceGroup( + configuration: .init( + services: [ + .init(service: transport, successTerminationBehavior: .gracefullyShutdownGroup) + ], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Self.lifecycleLogger + ) + ) + try await group.run() } catch let error as TransportError { // Handle transport errors let errorMessage = """ diff --git a/Demos/SwiftMCPDemo/Commands/TCPBonjourCommand.swift b/Demos/SwiftMCPDemo/Commands/TCPBonjourCommand.swift index c755729..f725ce3 100644 --- a/Demos/SwiftMCPDemo/Commands/TCPBonjourCommand.swift +++ b/Demos/SwiftMCPDemo/Commands/TCPBonjourCommand.swift @@ -3,6 +3,7 @@ import Foundation import ArgumentParser import SwiftMCP import Logging +import ServiceLifecycle #if canImport(OSLog) import OSLog #endif @@ -58,7 +59,19 @@ struct TCPBonjourCommand: AsyncParsableCommand { acceptLocalOnly: true, preferIPv4: ipv4Only ) - try await transport.run() + + // A `ServiceGroup` owns the run loop and traps SIGINT/SIGTERM to + // drive a graceful shutdown of the transport. + let group = ServiceGroup( + configuration: .init( + services: [ + .init(service: transport, successTerminationBehavior: .gracefullyShutdownGroup) + ], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Logging.Logger(label: "com.cocoanetics.SwiftMCP.ServiceGroup") + ) + ) + try await group.run() } catch let error as TransportError { let errorMessage = """ Transport Error: \(error.localizedDescription) diff --git a/Demos/SwiftMCPDemo/SignalHandler.swift b/Demos/SwiftMCPDemo/SignalHandler.swift deleted file mode 100644 index 9ff39f3..0000000 --- a/Demos/SwiftMCPDemo/SignalHandler.swift +++ /dev/null @@ -1,98 +0,0 @@ -#if Server -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#elseif canImport(Musl) -import Musl -#elseif canImport(Android) -import Android -#elseif canImport(WinSDK) -import WinSDK -#endif - -import Foundation -import Dispatch -import SwiftMCP - -/// Handles SIGINT signals for graceful shutdown of one or more transports. -public final class SignalHandler { - /// Actor to manage signal handling state in a thread-safe way - private actor State { - private var sigintSource: DispatchSourceSignal? - private var isShuttingDown = false - private var transports: [any Transport] - - init(transports: [any Transport]) { - self.transports = transports - } - - func setupHandler(on queue: DispatchQueue) { - // Create a dispatch source on the provided queue - sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: queue) - - // Tell the system to ignore the default SIGINT handler - signal(SIGINT, SIG_IGN) - - // Specify what to do when the signal is received - sigintSource?.setEventHandler { [weak self] in - Task { [weak self] in - await self?.handleSignal() - } - } - - // Start listening for the signal - sigintSource?.resume() - } - - private func handleSignal() async { - // Prevent multiple shutdown attempts - guard !isShuttingDown else { return } - isShuttingDown = true - - print("\nShutting down...") - - guard !transports.isEmpty else { - print("No transports available") - Foundation.exit(1) - } - - var errors: [Error] = [] - for transport in transports { - do { - try await transport.stop() - } catch { - errors.append(error) - } - } - - if errors.isEmpty { - Foundation.exit(0) - } else { - print("Error during shutdown: \(errors)") - Foundation.exit(1) - } - } - } - - // Instance state - private let state: State - - /// Creates a new signal handler for a single transport. - public init(transport: HTTPSSETransport) { - self.state = State(transports: [transport]) - } - - /// Creates a new signal handler for multiple transports. - public init(transports: [any Transport]) { - self.state = State(transports: transports) - } - - /// Sets up the SIGINT handler - public func setup() async { - // Create a dedicated dispatch queue for signal handling - let signalQueue = DispatchQueue(label: "com.cocoanetics.signalQueue") - await state.setupHandler(on: signalQueue) - } -} -#endif diff --git a/Demos/SwiftMCPIntentsDemo/Commands/HTTPSSECommand.swift b/Demos/SwiftMCPIntentsDemo/Commands/HTTPSSECommand.swift index 8ae17f5..46158e2 100644 --- a/Demos/SwiftMCPIntentsDemo/Commands/HTTPSSECommand.swift +++ b/Demos/SwiftMCPIntentsDemo/Commands/HTTPSSECommand.swift @@ -3,6 +3,7 @@ import Foundation import ArgumentParser import SwiftMCP import Logging +import ServiceLifecycle #if canImport(OSLog) import OSLog #endif @@ -47,8 +48,6 @@ final class HTTPSSECommand: AsyncParsableCommand { @Option(name: .long, help: "Path to OAuth configuration JSON file") var oauth: String? - private var signalHandler: SignalHandler? - required init() {} required init(from decoder: Decoder) throws { @@ -87,17 +86,24 @@ final class HTTPSSECommand: AsyncParsableCommand { try configureAuthentication(on: transport) transport.serveOpenAPI = openapi - let tcpTransport = try await startTCPTransportIfNeeded(server: server) - await setupSignalHandling(httpTransport: transport, tcpTransport: tcpTransport) - - do { - try await transport.run() - } catch { - if let tcpTransport { - try? await tcpTransport.stop() - } - throw error + // Each transport is a `Service`. The `ServiceGroup` starts them, traps + // SIGINT/SIGTERM, and drives an ordered graceful shutdown (with a + // timeout) — replacing the bespoke signal handler. + var services: [ServiceGroupConfiguration.ServiceConfiguration] = [ + .init(service: transport, successTerminationBehavior: .gracefullyShutdownGroup) + ] + if let tcpTransport = makeTCPTransportIfNeeded(server: server) { + services.append(.init(service: tcpTransport, successTerminationBehavior: .gracefullyShutdownGroup)) } + + let group = ServiceGroup( + configuration: .init( + services: services, + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Logging.Logger(label: "com.cocoanetics.SwiftMCP.ServiceGroup") + ) + ) + try await group.run() } private func configureAuthentication(on transport: HTTPSSETransport) throws { @@ -155,24 +161,12 @@ final class HTTPSSECommand: AsyncParsableCommand { } } - private func startTCPTransportIfNeeded(server: any MCPServer) async throws -> TCPBonjourTransport? { + /// Builds the optional TCP+Bonjour transport. It is returned unstarted — + /// the `ServiceGroup` starts it by calling `run()`. + private func makeTCPTransportIfNeeded(server: any MCPServer) -> TCPBonjourTransport? { guard tcp else { return nil } - let transport = TCPBonjourTransport(server: server) - try await transport.start() - print("MCP Server \(server.serverName) started with TCP+Bonjour transport") - return transport - } - - private func setupSignalHandling( - httpTransport: HTTPSSETransport, - tcpTransport: TCPBonjourTransport? - ) async { - if let tcpTransport { - signalHandler = SignalHandler(transports: [httpTransport, tcpTransport]) - } else { - signalHandler = SignalHandler(transport: httpTransport) - } - await signalHandler?.setup() + print("MCP Server \(server.serverName) will also expose a TCP+Bonjour transport") + return TCPBonjourTransport(server: server) } } #endif diff --git a/Demos/SwiftMCPIntentsDemo/Commands/StdioCommand.swift b/Demos/SwiftMCPIntentsDemo/Commands/StdioCommand.swift index 1fc1cbd..648f74e 100644 --- a/Demos/SwiftMCPIntentsDemo/Commands/StdioCommand.swift +++ b/Demos/SwiftMCPIntentsDemo/Commands/StdioCommand.swift @@ -4,6 +4,7 @@ import ArgumentParser import SwiftMCP import Logging import NIOCore +import ServiceLifecycle #if canImport(OSLog) import OSLog #endif @@ -27,6 +28,16 @@ struct StdioCommand: AsyncParsableCommand { """ ) + /// A logger bound to stderr so `ServiceGroup` lifecycle messages never + /// interleave with the JSON-RPC responses written to stdout. + private static let lifecycleLogger: Logging.Logger = { + var logger = Logging.Logger(label: "com.cocoanetics.SwiftMCP.ServiceGroup") { + StreamLogHandler.standardError(label: $0) + } + logger.logLevel = .notice + return logger + }() + func run() async throws { #if canImport(OSLog) LoggingSystem.bootstrapWithOSLog() @@ -39,7 +50,20 @@ struct StdioCommand: AsyncParsableCommand { do { logToStderr("MCP Server \(server.serverName) (\(server.serverVersion)) started with Stdio transport") let transport = StdioTransport(server: server) - try await transport.run() + + // A `ServiceGroup` owns the run loop and traps SIGINT/SIGTERM, + // driving a graceful shutdown of the transport. The lifecycle logs + // go to stderr so they never corrupt the stdout JSON-RPC stream. + let group = ServiceGroup( + configuration: .init( + services: [ + .init(service: transport, successTerminationBehavior: .gracefullyShutdownGroup) + ], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Self.lifecycleLogger + ) + ) + try await group.run() } catch let error as TransportError { let errorMessage = """ Transport Error: \(error.localizedDescription) diff --git a/Demos/SwiftMCPIntentsDemo/Commands/TCPBonjourCommand.swift b/Demos/SwiftMCPIntentsDemo/Commands/TCPBonjourCommand.swift index 1bd73a0..e7f7f2e 100644 --- a/Demos/SwiftMCPIntentsDemo/Commands/TCPBonjourCommand.swift +++ b/Demos/SwiftMCPIntentsDemo/Commands/TCPBonjourCommand.swift @@ -3,6 +3,7 @@ import Foundation import ArgumentParser import SwiftMCP import Logging +import ServiceLifecycle #if canImport(OSLog) import OSLog #endif @@ -57,7 +58,19 @@ struct TCPBonjourCommand: AsyncParsableCommand { acceptLocalOnly: true, preferIPv4: ipv4Only ) - try await transport.run() + + // A `ServiceGroup` owns the run loop and traps SIGINT/SIGTERM to + // drive a graceful shutdown of the transport. + let group = ServiceGroup( + configuration: .init( + services: [ + .init(service: transport, successTerminationBehavior: .gracefullyShutdownGroup) + ], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Logging.Logger(label: "com.cocoanetics.SwiftMCP.ServiceGroup") + ) + ) + try await group.run() } catch let error as TransportError { let errorMessage = """ Transport Error: \(error.localizedDescription) diff --git a/Demos/SwiftMCPIntentsDemo/SignalHandler.swift b/Demos/SwiftMCPIntentsDemo/SignalHandler.swift deleted file mode 100644 index 242d077..0000000 --- a/Demos/SwiftMCPIntentsDemo/SignalHandler.swift +++ /dev/null @@ -1,84 +0,0 @@ -#if Server -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#elseif canImport(Musl) -import Musl -#elseif canImport(Android) -import Android -#elseif canImport(WinSDK) -import WinSDK -#endif - -import Foundation -import Dispatch -import SwiftMCP - -/// Handles SIGINT signals for graceful shutdown of one or more transports. -public final class SignalHandler { - private actor State { - private var sigintSource: DispatchSourceSignal? - private var isShuttingDown = false - private var transports: [any Transport] - - init(transports: [any Transport]) { - self.transports = transports - } - - func setupHandler(on queue: DispatchQueue) { - sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: queue) - signal(SIGINT, SIG_IGN) - sigintSource?.setEventHandler { [weak self] in - Task { [weak self] in - await self?.handleSignal() - } - } - sigintSource?.resume() - } - - private func handleSignal() async { - guard !isShuttingDown else { return } - isShuttingDown = true - - print("\nShutting down...") - - guard !transports.isEmpty else { - print("No transports available") - Foundation.exit(1) - } - - var errors: [Error] = [] - for transport in transports { - do { - try await transport.stop() - } catch { - errors.append(error) - } - } - - if errors.isEmpty { - Foundation.exit(0) - } else { - print("Error during shutdown: \(errors)") - Foundation.exit(1) - } - } - } - - private let state: State - - public init(transport: HTTPSSETransport) { - self.state = State(transports: [transport]) - } - - public init(transports: [any Transport]) { - self.state = State(transports: transports) - } - - public func setup() async { - let signalQueue = DispatchQueue(label: "com.cocoanetics.signalQueue") - await state.setupHandler(on: signalQueue) - } -} -#endif diff --git a/Package.swift b/Package.swift index 9fa1547..1d662ea 100644 --- a/Package.swift +++ b/Package.swift @@ -91,6 +91,10 @@ let package = Package( // bump is safe for our macOS 12 / iOS 15 floor. .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.1.0"), + // Graceful startup/shutdown + signal handling for the server transports. + // NIO-free (only swift-log + swift-async-algorithms), so it is linked + // only under the `Server` trait to keep the core dependency-light. + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.0"), .package(url: "https://github.com/Cocoanetics/SwiftCross.git", from: "1.2.0") ], targets: [ @@ -119,14 +123,16 @@ let package = Package( .product(name: "NIOFoundationCompat", package: "swift-nio", condition: .when(traits: ["Server"])), .product(name: "Crypto", package: "swift-crypto", condition: .when(traits: ["Server"])), .product(name: "_CryptoExtras", package: "swift-crypto", condition: .when(traits: ["Server"])), - .product(name: "X509", package: "swift-certificates", condition: .when(traits: ["Server"])) + .product(name: "X509", package: "swift-certificates", condition: .when(traits: ["Server"])), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle", condition: .when(traits: ["Server"])) ] ), .executableTarget( name: "SwiftMCPDemo", dependencies: [ "SwiftMCP", - .product(name: "ArgumentParser", package: "swift-argument-parser") + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle", condition: .when(traits: ["Server"])) ], path: "Demos/SwiftMCPDemo" ), @@ -134,7 +140,8 @@ let package = Package( name: "SwiftMCPIntentsDemo", dependencies: [ "SwiftMCP", - .product(name: "ArgumentParser", package: "swift-argument-parser") + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle", condition: .when(traits: ["Server"])) ], path: "Demos/SwiftMCPIntentsDemo" ), diff --git a/Sources/SwiftMCP/Transport/HTTPSSETransport.swift b/Sources/SwiftMCP/Transport/HTTPSSETransport.swift index 15b4f1f..9a43283 100644 --- a/Sources/SwiftMCP/Transport/HTTPSSETransport.swift +++ b/Sources/SwiftMCP/Transport/HTTPSSETransport.swift @@ -5,6 +5,7 @@ import NIOCore import NIOFoundationCompat import NIOHTTP1 import NIOPosix +import ServiceLifecycle /** A transport that exposes an HTTP server with Server-Sent Events (SSE) and JSON-RPC endpoints. @@ -18,7 +19,7 @@ import NIOPosix - Configurable authorization - Keep-alive mechanisms */ -public final class HTTPSSETransport: Transport, @unchecked Sendable { +public final class HTTPSSETransport: Transport, Service, @unchecked Sendable { /// The MCP server instance that this transport exposes. public let server: MCPServer @@ -152,9 +153,22 @@ public final class HTTPSSETransport: Transport, @unchecked Sendable { } } + /// Runs the transport and blocks until it is stopped. + /// + /// When executed inside a `ServiceGroup`, a graceful shutdown signal (e.g. + /// `SIGINT`/`SIGTERM`) closes the listening channel via ``stop()`` so this + /// method returns and the group can drain. When called standalone, it + /// behaves exactly as before and returns once the channel is closed. public func run() async throws { try await start() - try await channel?.closeFuture.get() + try await withGracefulShutdownHandler { + try await channel?.closeFuture.get() + } onGracefulShutdown: { [weak self] in + // `onGracefulShutdown` is synchronous; bridge to the async `stop()`. + // Closing the channel completes `closeFuture`, unblocking the + // operation above so `run()` returns. + Task { [weak self] in try? await self?.stop() } + } } public func stop() async throws { diff --git a/Sources/SwiftMCP/Transport/StdioTransport.swift b/Sources/SwiftMCP/Transport/StdioTransport.swift index c7f25f7..73d5a48 100644 --- a/Sources/SwiftMCP/Transport/StdioTransport.swift +++ b/Sources/SwiftMCP/Transport/StdioTransport.swift @@ -5,12 +5,13 @@ import Foundation import Logging +import ServiceLifecycle /// A transport that exposes an MCP server over standard input/output. /// /// This transport allows communication with an MCP server through standard input and output streams, /// making it suitable for command-line interfaces and pipe-based communication. -public final class StdioTransport: Transport, @unchecked Sendable { +public final class StdioTransport: Transport, Service, @unchecked Sendable { /// The MCP server instance that this transport exposes. /// /// This server handles the actual business logic while the transport handles I/O. @@ -56,60 +57,63 @@ public final class StdioTransport: Transport, @unchecked Sendable { public func start() async throws { await state.start() - // Capture immutable properties in a @Sendable closure. + // Read on a background task so this method returns immediately. Task { @Sendable in - let session = Session(id: UUID()) - await session.setTransport(self) do { - try await session.work { _ in - while await state.isCurrentlyRunning() { - if let input = readLine(), - !input.isEmpty, - let data = input.data(using: .utf8) { - - let string = String(data: data, encoding: .utf8)! - logger.trace( "STDIN:\n\n\(string)") - - try await handleReceived(data) - } else { - // If no input is available, sleep briefly and try again. - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - } - } - } + try await readLoop() } catch { logger.error("Error processing input: \(error)") } } } - /// Runs the transport synchronously and blocks until the transport is stopped. + /// Runs the transport, processing stdin on the calling task until the + /// transport is stopped or stdin reaches end-of-file. /// - /// This method processes input directly on the calling task and will not return until - /// `stop()` is called from another task. + /// Returns when `stop()` is called from another task, when a `ServiceGroup` + /// graceful shutdown is triggered, or when the peer closes stdin (EOF). /// /// - Throws: An error if the transport fails to process input. public func run() async throws { await state.start() + // A `ServiceGroup` graceful shutdown signal calls `stop()`, clearing the + // running flag so the read loop exits; the loop also returns on stdin + // EOF. Standalone callers drive shutdown via `stop()`. + try await withGracefulShutdownHandler { + try await readLoop() + } onGracefulShutdown: { [weak self] in + Task { [weak self] in try? await self?.stop() } + } + } + + /// Reads newline-delimited JSON-RPC messages from stdin until stdin reaches + /// end-of-file or the transport is stopped. + /// + /// `readLine()` blocks until a complete line or EOF is available, so the loop + /// needs no polling delay. A `nil` result means the peer closed stdin (EOF), + /// in which case the loop returns so the caller can shut down cleanly. Blank + /// or non-UTF8 lines are skipped. + private func readLoop() async throws { let session = Session(id: UUID()) await session.setTransport(self) try await session.work { _ in while await state.isCurrentlyRunning() { - if let input = readLine(), - !input.isEmpty, - let data = input.data(using: .utf8) { - - let string = String(data: data, encoding: .utf8)! - logger.trace( "STDIN:\n\n\(string)") - - try await handleReceived(data) - } else { - // If no input is available, sleep briefly and try again. - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + guard let input = readLine() else { + // EOF: stdin was closed by the peer. + break } + guard !input.isEmpty, let data = input.data(using: .utf8) else { + // Blank or non-UTF8 line — skip and keep reading. + continue + } + logger.trace("STDIN:\n\n\(input)") + try await handleReceived(data) } } + // The read loop ended (EOF or an explicit stop); ensure the running flag + // is cleared so the transport's state stays consistent. + await state.stop() } /// Stops the transport. diff --git a/Sources/SwiftMCP/Transport/TCPBonjourTransport+Lifecycle.swift b/Sources/SwiftMCP/Transport/TCPBonjourTransport+Lifecycle.swift index b9c9b5f..6c62f32 100644 --- a/Sources/SwiftMCP/Transport/TCPBonjourTransport+Lifecycle.swift +++ b/Sources/SwiftMCP/Transport/TCPBonjourTransport+Lifecycle.swift @@ -1,5 +1,6 @@ #if Server import Foundation +import ServiceLifecycle #if canImport(Network) import Network @@ -20,7 +21,14 @@ extension TCPBonjourTransport { public func run() async throws { try await start() - await state.waitUntilStopped() + // Inside a `ServiceGroup`, a graceful shutdown signal calls `stop()`, + // which cancels the listener/connections and resumes `waitUntilStopped()` + // so this method returns. Standalone callers drive shutdown via `stop()`. + await withGracefulShutdownHandler { + await state.waitUntilStopped() + } onGracefulShutdown: { [weak self] in + Task { [weak self] in try? await self?.stop() } + } } public func stop() async throws { diff --git a/Sources/SwiftMCP/Transport/TCPBonjourTransport.swift b/Sources/SwiftMCP/Transport/TCPBonjourTransport.swift index ddd2f6f..c262006 100644 --- a/Sources/SwiftMCP/Transport/TCPBonjourTransport.swift +++ b/Sources/SwiftMCP/Transport/TCPBonjourTransport.swift @@ -1,12 +1,13 @@ #if Server import Foundation import Logging +import ServiceLifecycle #if canImport(Network) import Network /// A TCP transport that advertises via Bonjour and exchanges newline-delimited JSON-RPC. -public final class TCPBonjourTransport: Transport, @unchecked Sendable { +public final class TCPBonjourTransport: Transport, Service, @unchecked Sendable { /// Base DNS-SD service type for MCP over TCP. public static let serviceType = MCPBonjourServiceType.base @@ -178,7 +179,7 @@ public final class TCPBonjourTransport: Transport, @unchecked Sendable { #else /// Stub implementation for platforms without Network framework. -public final class TCPBonjourTransport: Transport, @unchecked Sendable { +public final class TCPBonjourTransport: Transport, Service, @unchecked Sendable { /// Base DNS-SD service type for MCP over TCP. public static let serviceType = MCPBonjourServiceType.base