Make supposedly unreachable code less reachable (#178)

* Move WriteJsonRpcMessageToBuffer to method

* Fix indentation in McpClient

* Make supposedly unreachable code less reachable

* Guard against multiple RunAsync calls

* Move RunAsync inside of Try in MapMcp
This commit is contained in:
Stephen Halter 2025-04-01 19:29:59 -07:00 • committed by GitHub
commit 674cb1515c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 94 additions and 80 deletions

View file

@ -47,11 +47,11 @@ public static class McpEndpointRouteBuilderExtensions
{ {
throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created."); throw new Exception($"Unreachable given good entropy! Session with ID '{sessionId}' has already been created.");
} }
await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
try try
{ {
var transportTask = transport.RunAsync(cancellationToken: requestAborted); var transportTask = transport.RunAsync(cancellationToken: requestAborted);
await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
try try
{ {
@ -85,7 +85,7 @@ public static class McpEndpointRouteBuilderExtensions
if (!_sessions.TryGetValue(sessionId.ToString(), out var transport)) if (!_sessions.TryGetValue(sessionId.ToString(), out var transport))
{ {
await Results.BadRequest($"Session {sessionId} not found.").ExecuteAsync(context); await Results.BadRequest($"Session ID not found.").ExecuteAsync(context);
return; return;
} }

View file

@ -82,29 +82,27 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
{ {
// Connect transport // Connect transport
_sessionTransport = await _clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); _sessionTransport = await _clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
// We don't want the ConnectAsync token to cancel the session after we've successfully connected. StartSession(_sessionTransport);
// The base class handles cleaning up the session in DisposeAsync without our help.
StartSession(_sessionTransport, fullSessionCancellationToken: CancellationToken.None);
// Perform initialization sequence // Perform initialization sequence
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initializationCts.CancelAfter(_options.InitializationTimeout); initializationCts.CancelAfter(_options.InitializationTimeout);
try try
{ {
// Send initialize request // Send initialize request
var initializeResponse = await SendRequestAsync<InitializeResult>( var initializeResponse = await SendRequestAsync<InitializeResult>(
new JsonRpcRequest new JsonRpcRequest
{
Method = RequestMethods.Initialize,
Params = new InitializeRequestParams()
{ {
ProtocolVersion = _options.ProtocolVersion, Method = RequestMethods.Initialize,
Capabilities = _options.Capabilities ?? new ClientCapabilities(), Params = new InitializeRequestParams()
ClientInfo = _options.ClientInfo {
} ProtocolVersion = _options.ProtocolVersion,
}, Capabilities = _options.Capabilities ?? new ClientCapabilities(),
initializationCts.Token).ConfigureAwait(false); ClientInfo = _options.ClientInfo
}
},
initializationCts.Token).ConfigureAwait(false);
// Store server information // Store server information
_logger.ServerCapabilitiesReceived(EndpointName, _logger.ServerCapabilitiesReceived(EndpointName,

View file

@ -32,19 +32,6 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
/// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns> /// <returns>A task representing the send loop that writes JSON-RPC messages to the SSE response stream.</returns>
public Task RunAsync(CancellationToken cancellationToken) public Task RunAsync(CancellationToken cancellationToken)
{ {
void WriteJsonRpcMessageToBuffer(SseItem<IJsonRpcMessage?> item, IBufferWriter<byte> writer)
{
if (item.EventType == "endpoint")
{
writer.Write(Encoding.UTF8.GetBytes(messageEndpoint));
return;
}
JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage?>());
}
IsConnected = true;
// The very first SSE event isn't really an IJsonRpcMessage, but there's no API to write a single item of a different type, // The very first SSE event isn't really an IJsonRpcMessage, but there's no API to write a single item of a different type,
// so we fib and special-case the "endpoint" event type in the formatter. // so we fib and special-case the "endpoint" event type in the formatter.
if (!_outgoingSseChannel.Writer.TryWrite(new SseItem<IJsonRpcMessage?>(null, "endpoint"))) if (!_outgoingSseChannel.Writer.TryWrite(new SseItem<IJsonRpcMessage?>(null, "endpoint")))
@ -52,10 +39,23 @@ public sealed class SseResponseStreamTransport(Stream sseResponseStream, string
throw new InvalidOperationException($"You must call ${nameof(RunAsync)} before calling ${nameof(SendMessageAsync)}."); throw new InvalidOperationException($"You must call ${nameof(RunAsync)} before calling ${nameof(SendMessageAsync)}.");
} }
IsConnected = true;
var sseItems = _outgoingSseChannel.Reader.ReadAllAsync(cancellationToken); var sseItems = _outgoingSseChannel.Reader.ReadAllAsync(cancellationToken);
return _sseWriteTask = SseFormatter.WriteAsync(sseItems, sseResponseStream, WriteJsonRpcMessageToBuffer, cancellationToken); return _sseWriteTask = SseFormatter.WriteAsync(sseItems, sseResponseStream, WriteJsonRpcMessageToBuffer, cancellationToken);
} }
private void WriteJsonRpcMessageToBuffer(SseItem<IJsonRpcMessage?> item, IBufferWriter<byte> writer)
{
if (item.EventType == "endpoint")
{
writer.Write(Encoding.UTF8.GetBytes(messageEndpoint));
return;
}
JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage?>());
}
/// <inheritdoc/> /// <inheritdoc/>
public ChannelReader<IJsonRpcMessage> MessageReader => _incomingChannel.Reader; public ChannelReader<IJsonRpcMessage> MessageReader => _incomingChannel.Reader;

View file

@ -14,8 +14,8 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
private readonly EventHandler? _toolsChangedDelegate; private readonly EventHandler? _toolsChangedDelegate;
private readonly EventHandler? _promptsChangedDelegate; private readonly EventHandler? _promptsChangedDelegate;
private ITransport _sessionTransport;
private string _endpointName; private string _endpointName;
private int _started;
/// <summary> /// <summary>
/// Creates a new instance of <see cref="McpServer"/>. /// Creates a new instance of <see cref="McpServer"/>.
@ -32,7 +32,6 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
Throw.IfNull(transport); Throw.IfNull(transport);
Throw.IfNull(options); Throw.IfNull(options);
_sessionTransport = transport;
ServerOptions = options; ServerOptions = options;
Services = serviceProvider; Services = serviceProvider;
_endpointName = $"Server ({options.ServerInfo.Name} {options.ServerInfo.Version})"; _endpointName = $"Server ({options.ServerInfo.Name} {options.ServerInfo.Version})";
@ -74,6 +73,8 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
SetPromptsHandler(options); SetPromptsHandler(options);
SetResourcesHandler(options); SetResourcesHandler(options);
SetSetLoggingLevelHandler(options); SetSetLoggingLevelHandler(options);
StartSession(transport);
} }
public ServerCapabilities? ServerCapabilities { get; set; } public ServerCapabilities? ServerCapabilities { get; set; }
@ -96,11 +97,16 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
/// <inheritdoc /> /// <inheritdoc />
public async Task RunAsync(CancellationToken cancellationToken = default) public async Task RunAsync(CancellationToken cancellationToken = default)
{ {
if (Interlocked.Exchange(ref _started, 1) != 0)
{
throw new InvalidOperationException($"{nameof(RunAsync)} must only be called once.");
}
try try
{ {
// Start processing messages using var _ = cancellationToken.Register(static s => ((McpServer)s!).CancelSession(), this);
StartSession(_sessionTransport, fullSessionCancellationToken: cancellationToken); // The McpServer ctor always calls StartSession, so MessageProcessingTask is always set.
await MessageProcessingTask.ConfigureAwait(false); await MessageProcessingTask!.ConfigureAwait(false);
} }
finally finally
{ {

View file

@ -22,7 +22,6 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
private McpSession? _session; private McpSession? _session;
private CancellationTokenSource? _sessionCts; private CancellationTokenSource? _sessionCts;
private int _started;
private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly SemaphoreSlim _disposeLock = new(1, 1);
private bool _disposed; private bool _disposed;
@ -61,18 +60,15 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
protected Task? MessageProcessingTask { get; set; } protected Task? MessageProcessingTask { get; set; }
[MemberNotNull(nameof(MessageProcessingTask))] [MemberNotNull(nameof(MessageProcessingTask))]
protected void StartSession(ITransport sessionTransport, CancellationToken fullSessionCancellationToken = default) protected void StartSession(ITransport sessionTransport)
{ {
if (Interlocked.Exchange(ref _started, 1) != 0) _sessionCts = new CancellationTokenSource();
{
throw new InvalidOperationException("The MCP session has already stared.");
}
_sessionCts = CancellationTokenSource.CreateLinkedTokenSource(fullSessionCancellationToken);
_session = new McpSession(sessionTransport, EndpointName, _requestHandlers, _notificationHandlers, _logger); _session = new McpSession(sessionTransport, EndpointName, _requestHandlers, _notificationHandlers, _logger);
MessageProcessingTask = _session.ProcessMessagesAsync(_sessionCts.Token); MessageProcessingTask = _session.ProcessMessagesAsync(_sessionCts.Token);
} }
protected void CancelSession() => _sessionCts?.Cancel();
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
using var _ = await _disposeLock.LockAsync().ConfigureAwait(false); using var _ = await _disposeLock.LockAsync().ConfigureAwait(false);

View file

@ -1,8 +1,6 @@
using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils; using ModelContextProtocol.Tests.Utils;
using Moq;
namespace ModelContextProtocol.Tests.Server; namespace ModelContextProtocol.Tests.Server;
@ -25,7 +23,8 @@ public class McpServerFactoryTests : LoggedTest
public async Task Create_Should_Initialize_With_Valid_Parameters() public async Task Create_Should_Initialize_With_Valid_Parameters()
{ {
// Arrange & Act // Arrange & Act
await using IMcpServer server = McpServerFactory.Create(Mock.Of<ITransport>(), _options, LoggerFactory); await using var transport = new TestServerTransport();
await using IMcpServer server = McpServerFactory.Create(transport, _options, LoggerFactory);
// Assert // Assert
Assert.NotNull(server); Assert.NotNull(server);
@ -39,9 +38,10 @@ public class McpServerFactoryTests : LoggedTest
} }
[Fact] [Fact]
public void Create_Throws_For_Null_Options() public async Task Create_Throws_For_Null_Options()
{ {
// Arrange, Act & Assert // Arrange, Act & Assert
Assert.Throws<ArgumentNullException>("serverOptions", () => McpServerFactory.Create(Mock.Of<ITransport>(), null!, LoggerFactory)); await using var transport = new TestServerTransport();
Assert.Throws<ArgumentNullException>("serverOptions", () => McpServerFactory.Create(transport, null!, LoggerFactory));
} }
} }

View file

@ -1,27 +1,21 @@
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils; using ModelContextProtocol.Tests.Utils;
using Moq;
using System.Reflection; using System.Reflection;
namespace ModelContextProtocol.Tests.Server; namespace ModelContextProtocol.Tests.Server;
public class McpServerTests : LoggedTest public class McpServerTests : LoggedTest
{ {
private readonly Mock<ITransport> _serverTransport;
private readonly McpServerOptions _options; private readonly McpServerOptions _options;
private readonly IServiceProvider _serviceProvider;
public McpServerTests(ITestOutputHelper testOutputHelper) public McpServerTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper) : base(testOutputHelper)
{ {
_serverTransport = new Mock<ITransport>();
_options = CreateOptions(); _options = CreateOptions();
_serviceProvider = new ServiceCollection().BuildServiceProvider();
} }
private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = null) private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = null)
@ -39,7 +33,8 @@ public class McpServerTests : LoggedTest
public async Task Constructor_Should_Initialize_With_Valid_Parameters() public async Task Constructor_Should_Initialize_With_Valid_Parameters()
{ {
// Arrange & Act // Arrange & Act
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, LoggerFactory, _serviceProvider); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
// Assert // Assert
Assert.NotNull(server); Assert.NotNull(server);
@ -49,21 +44,23 @@ public class McpServerTests : LoggedTest
public void Constructor_Throws_For_Null_Transport() public void Constructor_Throws_For_Null_Transport()
{ {
// Arrange, Act & Assert // Arrange, Act & Assert
Assert.Throws<ArgumentNullException>(() => McpServerFactory.Create(null!, _options, LoggerFactory, _serviceProvider)); Assert.Throws<ArgumentNullException>(() => McpServerFactory.Create(null!, _options, LoggerFactory));
} }
[Fact] [Fact]
public void Constructor_Throws_For_Null_Options() public async Task Constructor_Throws_For_Null_Options()
{ {
// Arrange, Act & Assert // Arrange, Act & Assert
Assert.Throws<ArgumentNullException>(() => McpServerFactory.Create(_serverTransport.Object, null!, LoggerFactory, _serviceProvider)); await using var transport = new TestServerTransport();
Assert.Throws<ArgumentNullException>(() => McpServerFactory.Create(transport, null!, LoggerFactory));
} }
[Fact] [Fact]
public async Task Constructor_Does_Not_Throw_For_Null_Logger() public async Task Constructor_Does_Not_Throw_For_Null_Logger()
{ {
// Arrange & Act // Arrange & Act
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, null, _serviceProvider); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, null);
// Assert // Assert
Assert.NotNull(server); Assert.NotNull(server);
@ -73,7 +70,8 @@ public class McpServerTests : LoggedTest
public async Task Constructor_Does_Not_Throw_For_Null_ServiceProvider() public async Task Constructor_Does_Not_Throw_For_Null_ServiceProvider()
{ {
// Arrange & Act // Arrange & Act
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, LoggerFactory, null); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory, null);
// Assert // Assert
Assert.NotNull(server); Assert.NotNull(server);
@ -83,27 +81,23 @@ public class McpServerTests : LoggedTest
public async Task RunAsync_Should_Throw_InvalidOperationException_If_Already_Running() public async Task RunAsync_Should_Throw_InvalidOperationException_If_Already_Running()
{ {
// Arrange // Arrange
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, LoggerFactory, _serviceProvider); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken); var runTask = server.RunAsync(TestContext.Current.CancellationToken);
// Act & Assert // Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => server.RunAsync(TestContext.Current.CancellationToken)); await Assert.ThrowsAsync<InvalidOperationException>(() => server.RunAsync(TestContext.Current.CancellationToken));
try await transport.DisposeAsync();
{ await runTask;
await runTask;
}
catch (NullReferenceException)
{
// _serverTransport.Object returns a null MessageReader
}
} }
[Fact] [Fact]
public async Task RequestSamplingAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Sampling() public async Task RequestSamplingAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Sampling()
{ {
// Arrange // Arrange
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, LoggerFactory, _serviceProvider); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
SetClientCapabilities(server, new ClientCapabilities()); SetClientCapabilities(server, new ClientCapabilities());
var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None);
@ -117,7 +111,7 @@ public class McpServerTests : LoggedTest
{ {
// Arrange // Arrange
await using var transport = new TestServerTransport(); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory, _serviceProvider); await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
SetClientCapabilities(server, new ClientCapabilities { Sampling = new SamplingCapability() }); SetClientCapabilities(server, new ClientCapabilities { Sampling = new SamplingCapability() });
var runTask = server.RunAsync(TestContext.Current.CancellationToken); var runTask = server.RunAsync(TestContext.Current.CancellationToken);
@ -138,7 +132,8 @@ public class McpServerTests : LoggedTest
public async Task RequestRootsAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Roots() public async Task RequestRootsAsync_Should_Throw_McpServerException_If_Client_Does_Not_Support_Roots()
{ {
// Arrange // Arrange
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, LoggerFactory, _serviceProvider); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
SetClientCapabilities(server, new ClientCapabilities()); SetClientCapabilities(server, new ClientCapabilities());
// Act & Assert // Act & Assert
@ -150,7 +145,7 @@ public class McpServerTests : LoggedTest
{ {
// Arrange // Arrange
await using var transport = new TestServerTransport(); await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory, _serviceProvider); await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
SetClientCapabilities(server, new ClientCapabilities { Roots = new RootsCapability() }); SetClientCapabilities(server, new ClientCapabilities { Roots = new RootsCapability() });
var runTask = server.RunAsync(TestContext.Current.CancellationToken); var runTask = server.RunAsync(TestContext.Current.CancellationToken);
@ -507,7 +502,7 @@ public class McpServerTests : LoggedTest
var options = CreateOptions(serverCapabilities); var options = CreateOptions(serverCapabilities);
configureOptions?.Invoke(options); configureOptions?.Invoke(options);
await using var server = McpServerFactory.Create(transport, options, LoggerFactory, _serviceProvider); await using var server = McpServerFactory.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken); var runTask = server.RunAsync(TestContext.Current.CancellationToken);
@ -542,7 +537,7 @@ public class McpServerTests : LoggedTest
await using var transport = new TestServerTransport(); await using var transport = new TestServerTransport();
var options = CreateOptions(serverCapabilities); var options = CreateOptions(serverCapabilities);
Assert.Throws<McpServerException>(() => McpServerFactory.Create(transport, options, LoggerFactory, _serviceProvider)); Assert.Throws<McpServerException>(() => McpServerFactory.Create(transport, options, LoggerFactory));
} }
[Fact] [Fact]
@ -553,7 +548,6 @@ public class McpServerTests : LoggedTest
Assert.Throws<ArgumentException>("server", () => server.AsSamplingChatClient()); Assert.Throws<ArgumentException>("server", () => server.AsSamplingChatClient());
} }
[Fact] [Fact]
public async Task AsSamplingChatClient_HandlesRequestResponse() public async Task AsSamplingChatClient_HandlesRequestResponse()
{ {
@ -583,6 +577,26 @@ public class McpServerTests : LoggedTest
Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); Assert.Equal(ChatRole.Assistant, response.Messages[0].Role);
} }
[Fact]
public async Task Can_SendMessage_Before_RunAsync()
{
await using var transport = new TestServerTransport();
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
var logNotification = new JsonRpcNotification()
{
Method = NotificationMethods.LoggingMessageNotification
};
await server.SendMessageAsync(logNotification, TestContext.Current.CancellationToken);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
await transport.DisposeAsync();
await runTask;
Assert.NotEmpty(transport.SentMessages);
Assert.Same(logNotification, transport.SentMessages[0]);
}
private static void SetClientCapabilities(IMcpServer server, ClientCapabilities capabilities) private static void SetClientCapabilities(IMcpServer server, ClientCapabilities capabilities)
{ {
PropertyInfo? property = server.GetType().GetProperty("ClientCapabilities", BindingFlags.Public | BindingFlags.Instance); PropertyInfo? property = server.GetType().GetProperty("ClientCapabilities", BindingFlags.Public | BindingFlags.Instance);
@ -644,7 +658,7 @@ public class McpServerTests : LoggedTest
var notificationReceived = new TaskCompletionSource<JsonRpcNotification>(); var notificationReceived = new TaskCompletionSource<JsonRpcNotification>();
var server = McpServerFactory.Create(transport, options, LoggerFactory, _serviceProvider); var server = McpServerFactory.Create(transport, options, LoggerFactory);
server.AddNotificationHandler(NotificationMethods.ProgressNotification, notification => server.AddNotificationHandler(NotificationMethods.ProgressNotification, notification =>
{ {
notificationReceived.SetResult(notification); notificationReceived.SetResult(notification);