Skip to content

Add StdioTransport support for Linux with musl #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
55 changes: 41 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,57 @@ permissions:
pull-requests: write

jobs:
test:
setup:
name: Setup
runs-on: ubuntu-latest
outputs:
swift-versions: ${{ steps.set-matrix.outputs.swift-versions }}
env:
SWIFT_VERSIONS: |
6.0.3
6.1.0
steps:
- id: set-matrix
run: |
# Convert multi-line string to JSON array (jq is pre-installed on GitHub runners)
VERSIONS=$(echo "$SWIFT_VERSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "swift-versions=$VERSIONS" >> $GITHUB_OUTPUT

test-macos:
name: Test (macOS, Swift ${{ matrix.swift-version }})
needs: setup
strategy:
matrix:
os: [macos-latest, ubuntu-latest]
swift-version:
- 6.0.3
- 6.1.0
swift-version: ${{ fromJson(needs.setup.outputs.swift-versions) }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Setup Swift
uses: swift-actions/setup-swift@v2
with:
swift-version: ${{ matrix.swift-version }}

- name: Build
run: swift build -v

runs-on: ${{ matrix.os }}
name: Test (${{ matrix.os }}, Swift ${{ matrix.swift-version }})
- name: Run tests
run: swift test -v

test-ubuntu:
name: Test (Ubuntu, Swift ${{ matrix.swift-version }})
needs: setup
strategy:
matrix:
swift-version: ${{ fromJson(needs.setup.outputs.swift-versions) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Swift on Linux
if: matrix.os == 'ubuntu-latest'
- name: Setup Swift
uses: vapor/[email protected]
with:
toolchain: ${{ matrix.swift-version }}
- name: Setup Swift on macOS
if: matrix.os == 'macos-latest'
uses: swift-actions/setup-swift@v2
with:
swift-version: ${{ matrix.swift-version }}

- name: Build
run: swift build -v
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Swift implementation of the [Model Context Protocol][mcp] (MCP).
| visionOS | 1.0+ |
| Linux | ✓ [^1] |

[^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.
[^1]: Linux support includes glibc-based distributions (Ubuntu, Debian, Fedora, CentOS, RHEL) and musl-based distributions (Alpine Linux).

## Installation

Expand Down
230 changes: 113 additions & 117 deletions Sources/MCP/Base/Transports/StdioTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,59 +9,61 @@ import struct Foundation.Data
#endif

// 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
#elseif os(Linux)
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#endif

/// Standard input/output transport implementation
public actor StdioTransport: Transport {
private let input: FileDescriptor
private let output: FileDescriptor
public nonisolated let logger: Logger

private var isConnected = false
private let messageStream: AsyncStream<Data>
private let messageContinuation: AsyncStream<Data>.Continuation

public init(
input: FileDescriptor = FileDescriptor.standardInput,
output: FileDescriptor = FileDescriptor.standardOutput,
logger: Logger? = nil
) {
self.input = input
self.output = output
self.logger =
logger
?? Logger(
label: "mcp.transport.stdio",
factory: { _ in SwiftLogNoOpLogHandler() })

// Create message stream
var continuation: AsyncStream<Data>.Continuation!
self.messageStream = AsyncStream { continuation = $0 }
self.messageContinuation = continuation
}
#if canImport(Darwin) || canImport(Glibc) || canImport(Musl)
/// Standard input/output transport implementation
public actor StdioTransport: Transport {
private let input: FileDescriptor
private let output: FileDescriptor
public nonisolated let logger: Logger

private var isConnected = false
private let messageStream: AsyncStream<Data>
private let messageContinuation: AsyncStream<Data>.Continuation

public init(
input: FileDescriptor = FileDescriptor.standardInput,
output: FileDescriptor = FileDescriptor.standardOutput,
logger: Logger? = nil
) {
self.input = input
self.output = output
self.logger =
logger
?? Logger(
label: "mcp.transport.stdio",
factory: { _ in SwiftLogNoOpLogHandler() })

// Create message stream
var continuation: AsyncStream<Data>.Continuation!
self.messageStream = AsyncStream { continuation = $0 }
self.messageContinuation = continuation
}

public func connect() async throws {
guard !isConnected else { return }
public func connect() async throws {
guard !isConnected else { return }

// Set non-blocking mode
try setNonBlocking(fileDescriptor: input)
try setNonBlocking(fileDescriptor: output)
// Set non-blocking mode
try setNonBlocking(fileDescriptor: input)
try setNonBlocking(fileDescriptor: output)

isConnected = true
logger.info("Transport connected successfully")
isConnected = true
logger.info("Transport connected successfully")

// Start reading loop in background
Task {
await readLoop()
// Start reading loop in background
Task {
await readLoop()
}
}
}

private func setNonBlocking(fileDescriptor: FileDescriptor) throws {
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(Linux)
private func setNonBlocking(fileDescriptor: FileDescriptor) throws {
// Get current flags
let flags = fcntl(fileDescriptor.rawValue, F_GETFL)
guard flags >= 0 else {
Expand All @@ -73,100 +75,94 @@ public actor StdioTransport: Transport {
guard result >= 0 else {
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 {
let bufferSize = 4096
var buffer = [UInt8](repeating: 0, count: bufferSize)
var pendingData = Data()
private func readLoop() async {
let bufferSize = 4096
var buffer = [UInt8](repeating: 0, count: bufferSize)
var pendingData = Data()

while isConnected && !Task.isCancelled {
do {
let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in
try input.read(into: UnsafeMutableRawBufferPointer(pointer))
}
while isConnected && !Task.isCancelled {
do {
let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in
try input.read(into: UnsafeMutableRawBufferPointer(pointer))
}

if bytesRead == 0 {
logger.notice("EOF received")
break
}
if bytesRead == 0 {
logger.notice("EOF received")
break
}

pendingData.append(Data(buffer[..<bytesRead]))
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)...]
// 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))
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
}
} 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()
}
messageContinuation.finish()
}

public func disconnect() async {
guard isConnected else { return }
isConnected = false
messageContinuation.finish()
logger.info("Transport disconnected")
}
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 {
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(Linux)
public func send(_ message: Data) async throws {
guard isConnected else {
throw MCPError.transportError(Errno(rawValue: ENOTCONN))
#else
throw MCPError.internalError("Transport not connected")
#endif
}
}

// Add newline as delimiter
var messageWithNewline = message
messageWithNewline.append(UInt8(ascii: "\n"))
// 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)
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)
}
} 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)
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return AsyncThrowingStream { continuation in
Task {
for await message in messageStream {
continuation.yield(message)
}
continuation.finish()
}
continuation.finish()
}
}
}
}
#endif