Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions Sources/MCP/Base/Transports/StdioTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import struct Foundation.Data
import Musl
#endif

#if os(Windows)
import class Foundation.FileHandle
import class Foundation.Thread
#endif

#if canImport(Darwin) || canImport(Glibc) || canImport(Musl)
/// An implementation of the MCP stdio transport protocol.
///
Expand Down Expand Up @@ -221,6 +226,142 @@ import struct Foundation.Data
}
}

/// Receives messages from the transport.
///
/// Messages may be individual JSON-RPC requests, notifications, responses,
/// or batches containing multiple requests/notifications encoded as JSON arrays.
/// Each message is guaranteed to be a complete JSON object or array.
///
/// - Returns: An AsyncThrowingStream of Data objects representing JSON-RPC messages
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return messageStream
}
}
#elseif os(Windows)
/// An implementation of the MCP stdio transport protocol for Windows.
///
/// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio)
/// specification from the Model Context Protocol.
///
/// Windows has no POSIX non-blocking file-descriptor I/O, so this
/// implementation speaks the same wire contract — newline-delimited
/// JSON-RPC messages on standard input/output — over Foundation's
/// `FileHandle`. Reads run on a dedicated thread so that a blocking
/// stdin read never parks a thread of the cooperative pool.
///
/// A trailing carriage return is stripped from each received line in
/// case a Windows-side client writes CRLF line endings.
///
/// ## Example Usage
///
/// ```swift
/// import MCP
///
/// // Initialize the client
/// let client = Client(name: "MyApp", version: "1.0.0")
///
/// // Create a transport and connect
/// let transport = StdioTransport()
/// try await client.connect(transport: transport)
/// ```
public actor StdioTransport: Transport {
/// Logger instance for transport-related events
public nonisolated let logger: Logger

private var isConnected = false
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation

/// Creates a new stdio transport over standard input/output
///
/// - Parameter logger: Optional logger instance for transport events
public init(logger: Logger? = nil) {
self.logger =
logger
?? Logger(
label: "mcp.transport.stdio",
factory: { _ in SwiftLogNoOpLogHandler() })

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

/// Establishes connection with the transport
///
/// This starts the background message reading thread.
public func connect() async throws {
guard !isConnected else { return }
isConnected = true
logger.debug("Transport connected successfully")

let continuation = messageContinuation
let logger = logger
Thread.detachNewThread {
let standardInput = FileHandle.standardInput
var pendingData = Data()
while true {
let chunk = standardInput.availableData
if chunk.isEmpty {
// EOF — the client closed our standard input
logger.notice("EOF received")
continuation.finish()
return
}
pendingData.append(chunk)

// Process complete messages
while let newlineIndex = pendingData.firstIndex(of: UInt8(ascii: "\n")) {
var messageData = pendingData[pendingData.startIndex..<newlineIndex]
if messageData.last == UInt8(ascii: "\r") {
messageData = messageData.dropLast()
}
if !messageData.isEmpty {
logger.trace(
"Message received", metadata: ["size": "\(messageData.count)"])
continuation.yield(Data(messageData))
}
pendingData.removeSubrange(pendingData.startIndex...newlineIndex)
}
}
}
}

/// Disconnects from the transport
///
/// This finishes the message stream. The reading thread exits when
/// standard input reaches EOF.
public func disconnect() async {
guard isConnected else { return }
isConnected = false
messageContinuation.finish()
logger.debug("Transport disconnected")
}

/// Sends a message over the transport.
///
/// This method supports sending both individual JSON-RPC messages and JSON-RPC batches.
/// Batches should be encoded as a JSON array containing multiple request/notification objects
/// according to the JSON-RPC 2.0 specification.
///
/// - Parameter message: The message data to send (without a trailing newline)
/// - Throws: Error if the message cannot be sent
public func send(_ message: Data) async throws {
guard isConnected else {
throw MCPError.internalError("Transport not connected")
}

// Add newline as delimiter
var messageWithNewline = message
messageWithNewline.append(UInt8(ascii: "\n"))
do {
try FileHandle.standardOutput.write(contentsOf: messageWithNewline)
} catch {
throw MCPError.transportError(error)
}
}

/// Receives messages from the transport.
///
/// Messages may be individual JSON-RPC requests, notifications, responses,
Expand Down