Conditionalize availability of StdioTransport (#65)

* Conditionalize availability of StdioTransport

* Update README with information about platform support
This commit is contained in:
Mattt 2025-04-10 09:15:13 -07:00 • committed by GitHub
commit 960c1dda0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 141 additions and 131 deletions

View file

@ -6,7 +6,7 @@ Swift implementation of the [Model Context Protocol][mcp] (MCP).
- Swift 6.0+ (Xcode 16+) - Swift 6.0+ (Xcode 16+)
### Supported Platforms ## Platform Support
| Platform | Minimum Version | | Platform | Minimum Version |
|----------|----------------| |----------|----------------|
@ -15,9 +15,19 @@ Swift implementation of the [Model Context Protocol][mcp] (MCP).
| watchOS | 9.0+ | | watchOS | 9.0+ |
| tvOS | 16.0+ | | tvOS | 16.0+ |
| visionOS | 1.0+ | | visionOS | 1.0+ |
| Linux | ✓ [^1] | | Linux | ✓ |
| Windows | ✓ |
[^1]: Linux support requires glibc-based distributions such as Ubuntu, Debian, Fedora, CentOS, or RHEL. Alpine Linux and other musl-based distributions are not supported. > [!IMPORTANT]
> MCP's transport layer handles communication between clients and servers.
> The Swift SDK supports multiple transport mechanisms,
> with different platform availability:
>
> * `StdioTransport` is available on Apple platforms
> and Linux distributions with glibc, such as
> Ubuntu, Debian, Fedora, CentOS, or RHEL.
>
> * `NetworkTransport` is available only on Apple platforms.
## Installation ## Installation

View file

@ -9,164 +9,164 @@ import struct Foundation.Data
#endif #endif
// Import for specific low-level operations not yet in Swift System // Import for specific low-level operations not yet in Swift System
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) #if canImport(Darwin)
import Darwin.POSIX import Darwin.POSIX
#elseif os(Linux) #elseif canImport(Glibc)
import Glibc import Glibc
#endif #endif
/// Standard input/output transport implementation #if canImport(Darwin) || canImport(Glibc)
public actor StdioTransport: Transport { /// Standard input/output transport implementation
private let input: FileDescriptor public actor StdioTransport: Transport {
private let output: FileDescriptor private let input: FileDescriptor
public nonisolated let logger: Logger private let output: FileDescriptor
public nonisolated let logger: Logger
private var isConnected = false private var isConnected = false
private let messageStream: AsyncStream<Data> private let messageStream: AsyncStream<Data>
private let messageContinuation: AsyncStream<Data>.Continuation private let messageContinuation: AsyncStream<Data>.Continuation
public init( public init(
input: FileDescriptor = FileDescriptor.standardInput, input: FileDescriptor = FileDescriptor.standardInput,
output: FileDescriptor = FileDescriptor.standardOutput, output: FileDescriptor = FileDescriptor.standardOutput,
logger: Logger? = nil logger: Logger? = nil
) { ) {
self.input = input self.input = input
self.output = output self.output = output
self.logger = self.logger =
logger logger
?? Logger( ?? Logger(
label: "mcp.transport.stdio", label: "mcp.transport.stdio",
factory: { _ in SwiftLogNoOpLogHandler() }) factory: { _ in SwiftLogNoOpLogHandler() })
// Create message stream // Create message stream
var continuation: AsyncStream<Data>.Continuation! var continuation: AsyncStream<Data>.Continuation!
self.messageStream = AsyncStream { continuation = $0 } self.messageStream = AsyncStream { continuation = $0 }
self.messageContinuation = continuation self.messageContinuation = continuation
}
public func connect() async throws {
guard !isConnected else { return }
// Set non-blocking mode
try setNonBlocking(fileDescriptor: input)
try setNonBlocking(fileDescriptor: output)
isConnected = true
logger.info("Transport connected successfully")
// Start reading loop in background
Task {
await readLoop()
} }
}
private func setNonBlocking(fileDescriptor: FileDescriptor) throws { public func connect() async throws {
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(Linux) guard !isConnected else { return }
// Get current flags
let flags = fcntl(fileDescriptor.rawValue, F_GETFL)
guard flags >= 0 else {
throw MCPError.transportError(Errno(rawValue: CInt(errno)))
}
// Set non-blocking flag // Set non-blocking mode
let result = fcntl(fileDescriptor.rawValue, F_SETFL, flags | O_NONBLOCK) try setNonBlocking(fileDescriptor: input)
guard result >= 0 else { try setNonBlocking(fileDescriptor: output)
throw MCPError.transportError(Errno(rawValue: CInt(errno)))
}
#else
// For platforms where non-blocking operations aren't supported
throw MCPError.internalError("Setting non-blocking mode not supported on this platform")
#endif
}
private func readLoop() async { isConnected = true
let bufferSize = 4096 logger.info("Transport connected successfully")
var buffer = [UInt8](repeating: 0, count: bufferSize)
var pendingData = Data()
while isConnected && !Task.isCancelled { // Start reading loop in background
do { Task {
let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in await readLoop()
try input.read(into: UnsafeMutableRawBufferPointer(pointer))
}
if bytesRead == 0 {
logger.notice("EOF received")
break
}
pendingData.append(Data(buffer[..<bytesRead]))
// Process complete messages
while let newlineIndex = pendingData.firstIndex(of: UInt8(ascii: "\n")) {
let messageData = pendingData[..<newlineIndex]
pendingData = pendingData[(newlineIndex + 1)...]
if !messageData.isEmpty {
logger.debug("Message received", metadata: ["size": "\(messageData.count)"])
messageContinuation.yield(Data(messageData))
}
}
} catch let error where MCPError.isResourceTemporarilyUnavailable(error) {
try? await Task.sleep(for: .milliseconds(10))
continue
} catch {
if !Task.isCancelled {
logger.error("Read error occurred", metadata: ["error": "\(error)"])
}
break
} }
} }
messageContinuation.finish() private func setNonBlocking(fileDescriptor: FileDescriptor) throws {
} #if canImport(Darwin) || canImport(Glibc)
// Get current flags
let flags = fcntl(fileDescriptor.rawValue, F_GETFL)
guard flags >= 0 else {
throw MCPError.transportError(Errno(rawValue: CInt(errno)))
}
public func disconnect() async { // Set non-blocking flag
guard isConnected else { return } let result = fcntl(fileDescriptor.rawValue, F_SETFL, flags | O_NONBLOCK)
isConnected = false guard result >= 0 else {
messageContinuation.finish() throw MCPError.transportError(Errno(rawValue: CInt(errno)))
logger.info("Transport disconnected") }
}
public func send(_ message: Data) async throws {
guard isConnected else {
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(Linux)
throw MCPError.transportError(Errno(rawValue: ENOTCONN))
#else #else
throw MCPError.internalError("Transport not connected") // For platforms where non-blocking operations aren't supported
throw MCPError.internalError(
"Setting non-blocking mode not supported on this platform")
#endif #endif
} }
// Add newline as delimiter private func readLoop() async {
var messageWithNewline = message let bufferSize = 4096
messageWithNewline.append(UInt8(ascii: "\n")) var buffer = [UInt8](repeating: 0, count: bufferSize)
var pendingData = Data()
var remaining = messageWithNewline while isConnected && !Task.isCancelled {
while !remaining.isEmpty { do {
do { let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in
let written = try remaining.withUnsafeBytes { buffer in try input.read(into: UnsafeMutableRawBufferPointer(pointer))
try output.write(UnsafeRawBufferPointer(buffer)) }
if bytesRead == 0 {
logger.notice("EOF received")
break
}
pendingData.append(Data(buffer[..<bytesRead]))
// Process complete messages
while let newlineIndex = pendingData.firstIndex(of: UInt8(ascii: "\n")) {
let messageData = pendingData[..<newlineIndex]
pendingData = pendingData[(newlineIndex + 1)...]
if !messageData.isEmpty {
logger.debug(
"Message received", metadata: ["size": "\(messageData.count)"])
messageContinuation.yield(Data(messageData))
}
}
} catch let error where MCPError.isResourceTemporarilyUnavailable(error) {
try? await Task.sleep(for: .milliseconds(10))
continue
} catch {
if !Task.isCancelled {
logger.error("Read error occurred", metadata: ["error": "\(error)"])
}
break
} }
if written > 0 { }
remaining = remaining.dropFirst(written)
messageContinuation.finish()
}
public func disconnect() async {
guard isConnected else { return }
isConnected = false
messageContinuation.finish()
logger.info("Transport disconnected")
}
public func send(_ message: Data) async throws {
guard isConnected else {
throw MCPError.transportError(Errno(rawValue: ENOTCONN))
}
// Add newline as delimiter
var messageWithNewline = message
messageWithNewline.append(UInt8(ascii: "\n"))
var remaining = messageWithNewline
while !remaining.isEmpty {
do {
let written = try remaining.withUnsafeBytes { buffer in
try output.write(UnsafeRawBufferPointer(buffer))
}
if written > 0 {
remaining = remaining.dropFirst(written)
}
} catch let error where MCPError.isResourceTemporarilyUnavailable(error) {
try await Task.sleep(for: .milliseconds(10))
continue
} catch {
throw MCPError.transportError(error)
}
}
}
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return AsyncThrowingStream { continuation in
Task {
for await message in messageStream {
continuation.yield(message)
}
continuation.finish()
} }
} catch let error where MCPError.isResourceTemporarilyUnavailable(error) {
try await Task.sleep(for: .milliseconds(10))
continue
} catch {
throw MCPError.transportError(error)
} }
} }
} }
#endif
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return AsyncThrowingStream { continuation in
Task {
for await message in messageStream {
continuation.yield(message)
}
continuation.finish()
}
}
}
}