using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using System.Text; using System.Text.Json; namespace ModelContextProtocol.Client; /// Provides the client side of a stream-based session transport. internal class StreamClientSessionTransport : TransportBase { internal static UTF8Encoding NoBomUtf8Encoding { get; } = new(encoderShouldEmitUTF8Identifier: false); private readonly TextReader _serverOutput; private readonly TextWriter _serverInput; private readonly SemaphoreSlim _sendLock = new(1, 1); private CancellationTokenSource? _shutdownCts = new(); private Task? _readTask; /// /// Initializes a new instance of the class. /// /// /// The text writer connected to the server's input stream. /// Messages written to this writer will be sent to the server. /// /// /// The text reader connected to the server's output stream. /// Messages read from this reader will be received from the server. /// /// /// A name that identifies this transport endpoint in logs. /// /// /// Optional factory for creating loggers. If null, a NullLogger will be used. /// /// /// This constructor starts a background task to read messages from the server output stream. /// The transport will be marked as connected once initialized. /// public StreamClientSessionTransport( TextWriter serverInput, TextReader serverOutput, string endpointName, ILoggerFactory? loggerFactory) : base(endpointName, loggerFactory) { _serverOutput = serverOutput; _serverInput = serverInput; SetConnected(); // Start reading messages in the background. We use the rarer pattern of new Task + Start // in order to ensure that the body of the task will always see _readTask initialized. // It is then able to reliably null it out on completion. var readTask = new Task( thisRef => ((StreamClientSessionTransport)thisRef!).ReadMessagesAsync(_shutdownCts.Token), this, TaskCreationOptions.DenyChildAttach); _readTask = readTask.Unwrap(); readTask.Start(); } /// /// Initializes a new instance of the class. /// /// /// The server's input stream. Messages written to this stream will be sent to the server. /// /// /// The server's output stream. Messages read from this stream will be received from the server. /// /// /// The encoding used for reading and writing messages from the input and output streams. Defaults to UTF-8 without BOM if null. /// /// /// A name that identifies this transport endpoint in logs. /// /// /// Optional factory for creating loggers. If null, a NullLogger will be used. /// /// /// This constructor starts a background task to read messages from the server output stream. /// The transport will be marked as connected once initialized. /// public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory) : this( new StreamWriter(serverInput, encoding ?? NoBomUtf8Encoding), #if NET new StreamReader(serverOutput, encoding ?? NoBomUtf8Encoding), #else new CancellableStreamReader(serverOutput, encoding ?? NoBomUtf8Encoding), #endif endpointName, loggerFactory) { Throw.IfNull(serverInput); Throw.IfNull(serverOutput); } /// public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { string id = "(no id)"; if (message is JsonRpcMessageWithId messageWithId) { id = messageWithId.Id.ToString(); } var json = JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false); try { // Write the message followed by a newline using our UTF-8 writer await _serverInput.WriteLineAsync(json).ConfigureAwait(false); await _serverInput.FlushAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { LogTransportSendFailed(Name, id, ex); throw new IOException("Failed to send message.", ex); } } /// public override ValueTask DisposeAsync() => CleanupAsync(cancellationToken: CancellationToken.None); private async Task ReadMessagesAsync(CancellationToken cancellationToken) { Exception? error = null; try { LogTransportEnteringReadMessagesLoop(Name); while (true) { if (await _serverOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false) is not string line) { LogTransportEndOfStream(Name); break; } if (string.IsNullOrWhiteSpace(line)) { continue; } LogTransportReceivedMessageSensitive(Name, line); await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) { LogTransportReadMessagesCancelled(Name); } catch (Exception ex) { error = ex; LogTransportReadMessagesFailed(Name, ex); } finally { _readTask = null; await CleanupAsync(error, cancellationToken).ConfigureAwait(false); } } private async Task ProcessMessageAsync(string line, CancellationToken cancellationToken) { try { var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); if (message != null) { await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false); } else { LogTransportMessageParseUnexpectedTypeSensitive(Name, line); } } catch (JsonException ex) { if (Logger.IsEnabled(LogLevel.Trace)) { LogTransportMessageParseFailedSensitive(Name, line, ex); } else { LogTransportMessageParseFailed(Name, ex); } } } protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default) { LogTransportShuttingDown(Name); if (Interlocked.Exchange(ref _shutdownCts, null) is { } shutdownCts) { await shutdownCts.CancelAsync().ConfigureAwait(false); shutdownCts.Dispose(); } if (Interlocked.Exchange(ref _readTask, null) is Task readTask) { try { await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { } catch (Exception ex) { LogTransportCleanupReadTaskFailed(Name, ex); } } SetDisconnected(error); LogTransportShutDown(Name); } }