Refactor transports to help enable graceful shutdown (#142)
- This also paves the way for better multi-session support - We should definitely rethink names for the transport API - For now, I kept the names similar as possible, so we can focus on the API shape Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
parent
d47c834fa9
commit
2ab9db0878
48 changed files with 2015 additions and 1847 deletions
|
|
@ -198,11 +198,7 @@ McpServerOptions options = new()
|
||||||
};
|
};
|
||||||
|
|
||||||
await using IMcpServer server = McpServerFactory.Create(new StdioServerTransport("MyServer"), options);
|
await using IMcpServer server = McpServerFactory.Create(new StdioServerTransport("MyServer"), options);
|
||||||
|
await server.RunAsync();
|
||||||
await server.StartAsync();
|
|
||||||
|
|
||||||
// Run until process is stopped by the client (parent process)
|
|
||||||
await Task.Delay(Timeout.Infinite);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ public static class McpEndpointRouteBuilderExtensions
|
||||||
{
|
{
|
||||||
public static IEndpointConventionBuilder MapMcpSse(this IEndpointRouteBuilder endpoints)
|
public static IEndpointConventionBuilder MapMcpSse(this IEndpointRouteBuilder endpoints)
|
||||||
{
|
{
|
||||||
IMcpServer? server = null;
|
|
||||||
SseResponseStreamTransport? transport = null;
|
SseResponseStreamTransport? transport = null;
|
||||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||||
var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
|
var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>();
|
||||||
|
|
@ -19,17 +18,17 @@ public static class McpEndpointRouteBuilderExtensions
|
||||||
|
|
||||||
routeGroup.MapGet("/sse", async (HttpResponse response, CancellationToken requestAborted) =>
|
routeGroup.MapGet("/sse", async (HttpResponse response, CancellationToken requestAborted) =>
|
||||||
{
|
{
|
||||||
await using var localTransport = transport = new SseResponseStreamTransport(response.Body);
|
|
||||||
await using var localServer = server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
|
|
||||||
|
|
||||||
await localServer.StartAsync(requestAborted);
|
|
||||||
|
|
||||||
response.Headers.ContentType = "text/event-stream";
|
response.Headers.ContentType = "text/event-stream";
|
||||||
response.Headers.CacheControl = "no-cache";
|
response.Headers.CacheControl = "no-cache";
|
||||||
|
|
||||||
|
await using var localTransport = transport = new SseResponseStreamTransport(response.Body);
|
||||||
|
await using var server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await transport.RunAsync(requestAborted);
|
var transportTask = transport.RunAsync(cancellationToken: requestAborted);
|
||||||
|
await server.RunAsync(cancellationToken: requestAborted);
|
||||||
|
await transportTask;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
|
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
using ModelContextProtocol.Configuration;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ModelContextProtocol.Configuration;
|
||||||
using ModelContextProtocol.Logging;
|
using ModelContextProtocol.Logging;
|
||||||
using ModelContextProtocol.Protocol.Messages;
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
using ModelContextProtocol.Protocol.Transport;
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
using ModelContextProtocol.Protocol.Types;
|
using ModelContextProtocol.Protocol.Types;
|
||||||
using ModelContextProtocol.Shared;
|
using ModelContextProtocol.Shared;
|
||||||
using ModelContextProtocol.Utils.Json;
|
using ModelContextProtocol.Utils.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Client;
|
namespace ModelContextProtocol.Client;
|
||||||
|
|
@ -13,23 +13,24 @@ namespace ModelContextProtocol.Client;
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
||||||
{
|
{
|
||||||
private readonly McpClientOptions _options;
|
|
||||||
private readonly IClientTransport _clientTransport;
|
private readonly IClientTransport _clientTransport;
|
||||||
|
private readonly McpClientOptions _options;
|
||||||
|
|
||||||
private volatile bool _isInitializing;
|
private ITransport? _sessionTransport;
|
||||||
|
private CancellationTokenSource? _connectCts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="McpClient"/> class.
|
/// Initializes a new instance of the <see cref="McpClient"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="transport">The transport to use for communication with the server.</param>
|
/// <param name="clientTransport">The transport to use for communication with the server.</param>
|
||||||
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
|
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
|
||||||
/// <param name="serverConfig">The server configuration.</param>
|
/// <param name="serverConfig">The server configuration.</param>
|
||||||
/// <param name="loggerFactory">The logger factory.</param>
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
public McpClient(IClientTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
public McpClient(IClientTransport clientTransport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
||||||
: base(transport, loggerFactory)
|
: base(loggerFactory)
|
||||||
{
|
{
|
||||||
|
_clientTransport = clientTransport;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clientTransport = transport;
|
|
||||||
|
|
||||||
EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
|
EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
|
||||||
|
|
||||||
|
|
@ -70,95 +71,95 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override string EndpointName { get; }
|
public override string EndpointName { get; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (IsInitialized)
|
_connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
{
|
cancellationToken = _connectCts.Token;
|
||||||
_logger.ClientAlreadyInitialized(EndpointName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isInitializing)
|
|
||||||
{
|
|
||||||
_logger.ClientAlreadyInitializing(EndpointName);
|
|
||||||
throw new InvalidOperationException("Client is already initializing");
|
|
||||||
}
|
|
||||||
|
|
||||||
_isInitializing = true;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
|
|
||||||
// Connect transport
|
// Connect transport
|
||||||
await _clientTransport.ConnectAsync(CancellationTokenSource.Token).ConfigureAwait(false);
|
_sessionTransport = await _clientTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
InitializeSession(_sessionTransport);
|
||||||
// Start processing messages
|
// We don't want the ConnectAsync token to cancel the session after we've successfully connected.
|
||||||
MessageProcessingTask = ProcessMessagesAsync(CancellationTokenSource.Token);
|
// The base class handles cleaning up the session in DisposeAsync without our help.
|
||||||
|
StartSession(fullSessionCancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
// Perform initialization sequence
|
// Perform initialization sequence
|
||||||
await InitializeAsync(CancellationTokenSource.Token).ConfigureAwait(false);
|
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
initializationCts.CancelAfter(_options.InitializationTimeout);
|
||||||
|
|
||||||
IsInitialized = true;
|
try
|
||||||
|
{
|
||||||
|
// Send initialize request
|
||||||
|
var initializeResponse = await SendRequestAsync<InitializeResult>(
|
||||||
|
new JsonRpcRequest
|
||||||
|
{
|
||||||
|
Method = "initialize",
|
||||||
|
Params = new InitializeRequestParams()
|
||||||
|
{
|
||||||
|
ProtocolVersion = _options.ProtocolVersion,
|
||||||
|
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
|
||||||
|
ClientInfo = _options.ClientInfo,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initializationCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Store server information
|
||||||
|
_logger.ServerCapabilitiesReceived(EndpointName,
|
||||||
|
capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
|
||||||
|
serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
|
||||||
|
|
||||||
|
ServerCapabilities = initializeResponse.Capabilities;
|
||||||
|
ServerInfo = initializeResponse.ServerInfo;
|
||||||
|
ServerInstructions = initializeResponse.Instructions;
|
||||||
|
|
||||||
|
// Validate protocol version
|
||||||
|
if (initializeResponse.ProtocolVersion != _options.ProtocolVersion)
|
||||||
|
{
|
||||||
|
_logger.ServerProtocolVersionMismatch(EndpointName, _options.ProtocolVersion, initializeResponse.ProtocolVersion);
|
||||||
|
throw new McpClientException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initialized notification
|
||||||
|
await SendMessageAsync(
|
||||||
|
new JsonRpcNotification { Method = "notifications/initialized" },
|
||||||
|
initializationCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (initializationCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.ClientInitializationTimeout(EndpointName);
|
||||||
|
throw new McpClientException("Initialization timed out");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.ClientInitializationError(EndpointName, e);
|
_logger.ClientInitializationError(EndpointName, e);
|
||||||
await CleanupAsync().ConfigureAwait(false);
|
await DisposeAsync().ConfigureAwait(false);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isInitializing = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitializeAsync(CancellationToken cancellationToken)
|
/// <inheritdoc/>
|
||||||
|
public override async ValueTask DisposeUnsynchronizedAsync()
|
||||||
{
|
{
|
||||||
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
if (_connectCts is not null)
|
||||||
initializationCts.CancelAfter(_options.InitializationTimeout);
|
{
|
||||||
|
await _connectCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Send initialize request
|
await base.DisposeUnsynchronizedAsync().ConfigureAwait(false);
|
||||||
var initializeResponse = await SendRequestAsync<InitializeResult>(
|
}
|
||||||
new JsonRpcRequest
|
finally
|
||||||
{
|
{
|
||||||
Method = "initialize",
|
if (_sessionTransport is not null)
|
||||||
Params = new InitializeRequestParams()
|
|
||||||
{
|
|
||||||
ProtocolVersion = _options.ProtocolVersion,
|
|
||||||
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
|
|
||||||
ClientInfo = _options.ClientInfo
|
|
||||||
}
|
|
||||||
},
|
|
||||||
initializationCts.Token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Store server information
|
|
||||||
_logger.ServerCapabilitiesReceived(EndpointName,
|
|
||||||
capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities),
|
|
||||||
serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation));
|
|
||||||
|
|
||||||
ServerCapabilities = initializeResponse.Capabilities;
|
|
||||||
ServerInfo = initializeResponse.ServerInfo;
|
|
||||||
ServerInstructions = initializeResponse.Instructions;
|
|
||||||
|
|
||||||
// Validate protocol version
|
|
||||||
if (initializeResponse.ProtocolVersion != _options.ProtocolVersion)
|
|
||||||
{
|
{
|
||||||
_logger.ServerProtocolVersionMismatch(EndpointName, _options.ProtocolVersion, initializeResponse.ProtocolVersion);
|
await _sessionTransport.DisposeAsync().ConfigureAwait(false);
|
||||||
throw new McpClientException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initialized notification
|
_connectCts?.Dispose();
|
||||||
await SendMessageAsync(
|
|
||||||
new JsonRpcNotification { Method = "notifications/initialized" },
|
|
||||||
initializationCts.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (initializationCts.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.ClientInitializationTimeout(EndpointName);
|
|
||||||
throw new McpClientException("Initialization timed out");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,14 @@ public static class McpClientFactory
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
await transport.DisposeAsync().ConfigureAwait(false);
|
if (transport is IAsyncDisposable asyncDisposableTransport)
|
||||||
|
{
|
||||||
|
await asyncDisposableTransport.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (transport is IDisposable disposableTransport)
|
||||||
|
{
|
||||||
|
disposableTransport.Dispose();
|
||||||
|
}
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ using ModelContextProtocol.Hosting;
|
||||||
using ModelContextProtocol.Protocol.Transport;
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
using ModelContextProtocol.Utils;
|
using ModelContextProtocol.Utils;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
namespace ModelContextProtocol;
|
namespace ModelContextProtocol;
|
||||||
|
|
||||||
|
|
@ -19,8 +22,18 @@ public static partial class McpServerBuilderExtensions
|
||||||
{
|
{
|
||||||
Throw.IfNull(builder);
|
Throw.IfNull(builder);
|
||||||
|
|
||||||
builder.Services.AddSingleton<IServerTransport, StdioServerTransport>();
|
builder.Services.AddSingleton<ITransport, StdioServerTransport>();
|
||||||
builder.Services.AddHostedService<McpServerHostedService>();
|
builder.Services.AddHostedService<McpServerSingleSessionHostedService>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(services =>
|
||||||
|
{
|
||||||
|
ITransport serverTransport = services.GetRequiredService<ITransport>();
|
||||||
|
IOptions<McpServerOptions> options = services.GetRequiredService<IOptions<McpServerOptions>>();
|
||||||
|
ILoggerFactory? loggerFactory = services.GetService<ILoggerFactory>();
|
||||||
|
|
||||||
|
return McpServerFactory.Create(serverTransport, options.Value, loggerFactory, services);
|
||||||
|
});
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +46,7 @@ public static partial class McpServerBuilderExtensions
|
||||||
Throw.IfNull(builder);
|
Throw.IfNull(builder);
|
||||||
|
|
||||||
builder.Services.AddSingleton<IServerTransport, HttpListenerSseServerTransport>();
|
builder.Services.AddSingleton<IServerTransport, HttpListenerSseServerTransport>();
|
||||||
builder.Services.AddHostedService<McpServerHostedService>();
|
builder.Services.AddHostedService<McpServerMultiSessionHostedService>();
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
|
||||||
namespace ModelContextProtocol;
|
namespace ModelContextProtocol;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension to host the MCP server
|
/// Extension to host an MCP server
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class McpServerServiceCollectionExtension
|
public static class McpServerServiceCollectionExtension
|
||||||
{
|
{
|
||||||
|
|
@ -20,15 +20,6 @@ public static class McpServerServiceCollectionExtension
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static IMcpServerBuilder AddMcpServer(this IServiceCollection services, Action<McpServerOptions>? configureOptions = null)
|
public static IMcpServerBuilder AddMcpServer(this IServiceCollection services, Action<McpServerOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
services.AddSingleton(services =>
|
|
||||||
{
|
|
||||||
IServerTransport serverTransport = services.GetRequiredService<IServerTransport>();
|
|
||||||
IOptions<McpServerOptions> options = services.GetRequiredService<IOptions<McpServerOptions>>();
|
|
||||||
ILoggerFactory? loggerFactory = services.GetService<ILoggerFactory>();
|
|
||||||
|
|
||||||
return McpServerFactory.Create(serverTransport, options.Value, loggerFactory, services);
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddOptions();
|
services.AddOptions();
|
||||||
services.AddTransient<IConfigureOptions<McpServerOptions>, McpServerOptionsSetup>();
|
services.AddTransient<IConfigureOptions<McpServerOptions>, McpServerOptionsSetup>();
|
||||||
if (configureOptions is not null)
|
if (configureOptions is not null)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
using ModelContextProtocol.Utils;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hosted service for the MCP server.
|
|
||||||
/// </summary>
|
|
||||||
public class McpServerHostedService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IMcpServer _server;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new instance of the McpServerHostedService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="server">The MCP server instance</param>
|
|
||||||
/// <exception cref="ArgumentNullException"></exception>
|
|
||||||
public McpServerHostedService(IMcpServer server)
|
|
||||||
{
|
|
||||||
Throw.IfNull(server);
|
|
||||||
|
|
||||||
_server = server;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
await _server.StartAsync(stoppingToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosted service for a multi-session (i.e. HTTP) MCP server.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class McpServerMultiSessionHostedService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServerTransport _serverTransport;
|
||||||
|
private readonly McpServerOptions _serverOptions;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
public McpServerMultiSessionHostedService(
|
||||||
|
IServerTransport serverTransport,
|
||||||
|
IOptions<McpServerOptions> serverOptions,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_serverTransport = serverTransport;
|
||||||
|
_serverOptions = serverOptions.Value;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (await AcceptSessionAsync(stoppingToken).ConfigureAwait(false) is { } server)
|
||||||
|
{
|
||||||
|
// TODO: Track all running sessions and wait for all sessions to complete for graceful shutdown.
|
||||||
|
_ = server.RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<IMcpServer> AcceptSessionAsync(CancellationToken cancellationToken)
|
||||||
|
=> McpServerFactory.AcceptAsync(_serverTransport, _serverOptions, _loggerFactory, _serviceProvider, cancellationToken);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosted service for a single-session (i.e stdio) MCP server.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class McpServerSingleSessionHostedService(IMcpServer session) : BackgroundService
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Task ExecuteAsync(CancellationToken stoppingToken) => session.RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions"/>
|
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using ModelContextProtocol.Server;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol.Transport;
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HTTP server provider using HttpListener.
|
/// HTTP server provider using HttpListener.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class HttpListenerServerProvider : IDisposable
|
internal sealed class HttpListenerServerProvider : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private static readonly byte[] s_accepted = "Accepted"u8.ToArray();
|
private static readonly byte[] s_accepted = "Accepted"u8.ToArray();
|
||||||
|
|
||||||
private const string SseEndpoint = "/sse";
|
private const string SseEndpoint = "/sse";
|
||||||
private const string MessageEndpoint = "/message";
|
private const string MessageEndpoint = "/message";
|
||||||
|
|
||||||
private readonly int _port;
|
private readonly HttpListener _listener;
|
||||||
private HttpListener? _listener;
|
private readonly CancellationTokenSource _shutdownTokenSource = new();
|
||||||
private CancellationTokenSource? _cts;
|
private Task _listeningTask = Task.CompletedTask;
|
||||||
private bool _isRunning;
|
|
||||||
|
private readonly TaskCompletionSource<bool> _completed = new();
|
||||||
|
private int _outstandingOperations;
|
||||||
|
|
||||||
|
private int _state;
|
||||||
|
private const int StateNotStarted = 0;
|
||||||
|
private const int StateRunning = 1;
|
||||||
|
private const int StateStopped = 2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new instance of the HTTP server provider.
|
/// Creates a new instance of the HTTP server provider.
|
||||||
|
|
@ -24,84 +30,100 @@ internal class HttpListenerServerProvider : IDisposable
|
||||||
/// <param name="port">The port to listen on</param>
|
/// <param name="port">The port to listen on</param>
|
||||||
public HttpListenerServerProvider(int port)
|
public HttpListenerServerProvider(int port)
|
||||||
{
|
{
|
||||||
_port = port;
|
if (port < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(port));
|
||||||
|
}
|
||||||
|
|
||||||
|
_listener = new();
|
||||||
|
_listener.Prefixes.Add($"http://localhost:{port}/");
|
||||||
}
|
}
|
||||||
|
|
||||||
public required Func<Stream, CancellationToken, Task> OnSseConnectionAsync { get; set; }
|
public required Func<Stream, CancellationToken, Task> OnSseConnectionAsync { get; set; }
|
||||||
public required Func<Stream, CancellationToken, Task<bool>> OnMessageAsync { get; set; }
|
public required Func<Stream, CancellationToken, Task<bool>> OnMessageAsync { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
public void Start()
|
||||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
if (_isRunning)
|
if (Interlocked.CompareExchange(ref _state, StateRunning, StateNotStarted) != StateNotStarted)
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
|
||||||
_listener = new HttpListener();
|
|
||||||
_listener.Prefixes.Add($"http://localhost:{_port}/");
|
|
||||||
_listener.Start();
|
|
||||||
_isRunning = true;
|
|
||||||
|
|
||||||
// Start listening for connections
|
|
||||||
_ = Task.Run(() => ListenForConnectionsAsync(_cts.Token), cancellationToken).ConfigureAwait(false);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!_isRunning)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
_cts?.Cancel();
|
|
||||||
_listener?.Stop();
|
|
||||||
|
|
||||||
_isRunning = false;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ListenForConnectionsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (_listener == null)
|
|
||||||
{
|
{
|
||||||
throw new McpServerException("Listener not initialized");
|
throw new ObjectDisposedException("Server may not be started twice.");
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
// Start listening for connections
|
||||||
|
_listener.Start();
|
||||||
|
|
||||||
|
OperationAdded(); // for the listening task
|
||||||
|
_listeningTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var context = await _listener.GetContextAsync().ConfigureAwait(false);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownTokenSource.Token);
|
||||||
|
cts.Token.Register(_listener.Stop);
|
||||||
// Process the request in a separate task
|
while (!cts.IsCancellationRequested)
|
||||||
_ = Task.Run(() => ProcessRequestAsync(context, cancellationToken), cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Shutdown requested, exit gracefully
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// Log error but continue listening
|
|
||||||
if (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
// Continue listening if not shutting down
|
try
|
||||||
continue;
|
{
|
||||||
|
var context = await _listener.GetContextAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Process the request in a separate task
|
||||||
|
OperationAdded(); // for the processing task; decremented in ProcessRequestAsync
|
||||||
|
_ = Task.Run(() => ProcessRequestAsync(context, cts.Token), CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
if (cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Shutdown requested, exit gracefully
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
OperationCompleted(); // for the listening task
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.CompareExchange(ref _state, StateStopped, StateRunning) != StateRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _shutdownTokenSource.CancelAsync().ConfigureAwait(false);
|
||||||
|
_listener.Stop();
|
||||||
|
await _listeningTask.ConfigureAwait(false);
|
||||||
|
await _completed.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a <see cref="Task"/> that completes when the server has finished its work.</summary>
|
||||||
|
public Task Completed => _completed.Task;
|
||||||
|
|
||||||
|
private void OperationAdded() => Interlocked.Increment(ref _outstandingOperations);
|
||||||
|
|
||||||
|
private void OperationCompleted()
|
||||||
|
{
|
||||||
|
if (Interlocked.Decrement(ref _outstandingOperations) == 0)
|
||||||
|
{
|
||||||
|
// All operations completed
|
||||||
|
_completed.TrySetResult(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
|
private async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var request = context.Request;
|
||||||
|
var response = context.Response;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = context.Request;
|
if (request is null || response is null)
|
||||||
var response = context.Response;
|
{
|
||||||
|
return;
|
||||||
if (request == null)
|
}
|
||||||
throw new McpServerException("Request is null");
|
|
||||||
|
|
||||||
// Handle SSE connection
|
// Handle SSE connection
|
||||||
if (request.HttpMethod == "GET" && request.Url?.LocalPath == SseEndpoint)
|
if (request.HttpMethod == "GET" && request.Url?.LocalPath == SseEndpoint)
|
||||||
|
|
@ -124,11 +146,15 @@ internal class HttpListenerServerProvider : IDisposable
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 500;
|
response.StatusCode = 500;
|
||||||
context.Response.Close();
|
response.Close();
|
||||||
}
|
}
|
||||||
catch { /* Ignore errors during error handling */ }
|
catch { /* Ignore errors during error handling */ }
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
OperationCompleted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleSseConnectionAsync(HttpListenerContext context, CancellationToken cancellationToken)
|
private async Task HandleSseConnectionAsync(HttpListenerContext context, CancellationToken cancellationToken)
|
||||||
|
|
@ -145,13 +171,8 @@ internal class HttpListenerServerProvider : IDisposable
|
||||||
{
|
{
|
||||||
await OnSseConnectionAsync(response.OutputStream, cancellationToken).ConfigureAwait(false);
|
await OnSseConnectionAsync(response.OutputStream, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
// Normal shutdown
|
|
||||||
}
|
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Client disconnected or other error
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -186,20 +207,4 @@ internal class HttpListenerServerProvider : IDisposable
|
||||||
|
|
||||||
response.Close();
|
response.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
protected virtual void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
StopAsync().GetAwaiter().GetResult();
|
|
||||||
_cts?.Dispose();
|
|
||||||
_listener?.Close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ModelContextProtocol.Logging;
|
||||||
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
|
using ModelContextProtocol.Utils;
|
||||||
|
using ModelContextProtocol.Utils.Json;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements the MCP transport protocol using <see cref="HttpListener"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HttpListenerSseServerSessionTransport : TransportBase
|
||||||
|
{
|
||||||
|
private readonly string _serverName;
|
||||||
|
private readonly ILogger<HttpListenerSseServerTransport> _logger;
|
||||||
|
private SseResponseStreamTransport _responseStreamTransport;
|
||||||
|
|
||||||
|
private string EndpointName => $"Server (SSE) ({_serverName})";
|
||||||
|
|
||||||
|
public HttpListenerSseServerSessionTransport(string serverName, SseResponseStreamTransport responseStreamTransport, ILoggerFactory loggerFactory)
|
||||||
|
: base(loggerFactory)
|
||||||
|
{
|
||||||
|
Throw.IfNull(serverName);
|
||||||
|
|
||||||
|
_serverName = serverName;
|
||||||
|
_responseStreamTransport = responseStreamTransport;
|
||||||
|
_logger = loggerFactory.CreateLogger<HttpListenerSseServerTransport>();
|
||||||
|
SetConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
_logger.TransportNotConnected(EndpointName);
|
||||||
|
throw new McpTransportException("Transport is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = "(no id)";
|
||||||
|
if (message is IJsonRpcMessageWithId messageWithId)
|
||||||
|
{
|
||||||
|
id = messageWithId.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||||
|
_logger.TransportSendingMessage(EndpointName, id, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _responseStreamTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.TransportSentMessage(EndpointName, id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportSendFailed(EndpointName, id, ex);
|
||||||
|
throw new McpTransportException("Failed to send message", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task OnMessageReceivedAsync(IJsonRpcMessage message, CancellationToken cancellationToken)
|
||||||
|
=> WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
SetConnected(false);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,18 +6,23 @@ using ModelContextProtocol.Utils.Json;
|
||||||
using ModelContextProtocol.Logging;
|
using ModelContextProtocol.Logging;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Utils;
|
using ModelContextProtocol.Utils;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol.Transport;
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implements the MCP transport protocol using <see cref="HttpListener"/>.
|
/// Implements the MCP transport protocol using <see cref="HttpListener"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HttpListenerSseServerTransport : TransportBase, IServerTransport
|
public sealed class HttpListenerSseServerTransport : IServerTransport, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly string _serverName;
|
private readonly string _serverName;
|
||||||
private readonly HttpListenerServerProvider _httpServerProvider;
|
private readonly HttpListenerServerProvider _httpServerProvider;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ILogger<HttpListenerSseServerTransport> _logger;
|
private readonly ILogger<HttpListenerSseServerTransport> _logger;
|
||||||
private SseResponseStreamTransport? _sseResponseStreamTransport;
|
|
||||||
|
private readonly Channel<ITransport> _incomingSessions;
|
||||||
|
|
||||||
|
private HttpListenerSseServerSessionTransport? _sessionTransport;
|
||||||
|
|
||||||
private string EndpointName => $"Server (SSE) ({_serverName})";
|
private string EndpointName => $"Server (SSE) ({_serverName})";
|
||||||
|
|
||||||
|
|
@ -39,81 +44,69 @@ public sealed class HttpListenerSseServerTransport : TransportBase, IServerTrans
|
||||||
/// <param name="port">The port to listen on.</param>
|
/// <param name="port">The port to listen on.</param>
|
||||||
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
||||||
public HttpListenerSseServerTransport(string serverName, int port, ILoggerFactory loggerFactory)
|
public HttpListenerSseServerTransport(string serverName, int port, ILoggerFactory loggerFactory)
|
||||||
: base(loggerFactory)
|
|
||||||
{
|
{
|
||||||
|
Throw.IfNull(serverName);
|
||||||
|
|
||||||
_serverName = serverName;
|
_serverName = serverName;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
_logger = loggerFactory.CreateLogger<HttpListenerSseServerTransport>();
|
_logger = loggerFactory.CreateLogger<HttpListenerSseServerTransport>();
|
||||||
_httpServerProvider = new HttpListenerServerProvider(port)
|
_httpServerProvider = new HttpListenerServerProvider(port)
|
||||||
{
|
{
|
||||||
OnSseConnectionAsync = OnSseConnectionAsync,
|
OnSseConnectionAsync = OnSseConnectionAsync,
|
||||||
OnMessageAsync = OnMessageAsync,
|
OnMessageAsync = OnMessageAsync,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Until we support session IDs, there's no way to support more than one concurrent session.
|
||||||
|
// Any new SSE connection overwrites the old session and any new /messages go to the new session.
|
||||||
|
_incomingSessions = Channel.CreateBounded<ITransport>(new BoundedChannelOptions(1)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest,
|
||||||
|
});
|
||||||
|
|
||||||
|
// REVIEW: We could add another layer of async for binding similar to Kestrel's IConnectionListenerFactory,
|
||||||
|
// but this wouldn't play well with a static factory method to accept new sessions. Ultimately,
|
||||||
|
// ASP.NET Core is not going to hand over binding to the MCP SDK, so I decided to just bind in the transport
|
||||||
|
// constructor for now.
|
||||||
|
_httpServerProvider.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Task StartListeningAsync(CancellationToken cancellationToken = default)
|
public async Task<ITransport?> AcceptAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _httpServerProvider.StartAsync(cancellationToken);
|
while (await _incomingSessions.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!IsConnected || _sseResponseStreamTransport is null)
|
|
||||||
{
|
{
|
||||||
_logger.TransportNotConnected(EndpointName);
|
if (_incomingSessions.Reader.TryRead(out var session))
|
||||||
throw new McpTransportException("Transport is not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
string id = "(no id)";
|
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
|
||||||
{
|
|
||||||
id = messageWithId.Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>());
|
return session;
|
||||||
_logger.TransportSendingMessage(EndpointName, id, json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _sseResponseStreamTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
_logger.TransportSentMessage(EndpointName, id);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.TransportSendFailed(EndpointName, id, ex);
|
|
||||||
throw new McpTransportException("Failed to send message", ex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
|
||||||
await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task CleanupAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
_logger.TransportCleaningUp(EndpointName);
|
_logger.TransportCleaningUp(EndpointName);
|
||||||
|
|
||||||
_httpServerProvider.Dispose();
|
await _httpServerProvider.DisposeAsync().ConfigureAwait(false);
|
||||||
SetConnected(false);
|
_incomingSessions.Writer.TryComplete();
|
||||||
|
|
||||||
_logger.TransportCleanedUp(EndpointName);
|
_logger.TransportCleanedUp(EndpointName);
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnSseConnectionAsync(Stream responseStream, CancellationToken cancellationToken)
|
private async Task OnSseConnectionAsync(Stream responseStream, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var sseResponseStreamTransport = new SseResponseStreamTransport(responseStream);
|
var sseResponseStreamTransport = new SseResponseStreamTransport(responseStream);
|
||||||
_sseResponseStreamTransport = sseResponseStreamTransport;
|
var sessionTransport = new HttpListenerSseServerSessionTransport(_serverName, sseResponseStreamTransport, _loggerFactory);
|
||||||
SetConnected(true);
|
|
||||||
await sseResponseStreamTransport.RunAsync(cancellationToken);
|
await using (sseResponseStreamTransport.ConfigureAwait(false))
|
||||||
|
await using (sseResponseStreamTransport.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_sessionTransport = sessionTransport;
|
||||||
|
await _incomingSessions.Writer.WriteAsync(sessionTransport).ConfigureAwait(false);
|
||||||
|
await sseResponseStreamTransport.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -140,7 +133,7 @@ public sealed class HttpListenerSseServerTransport : TransportBase, IServerTrans
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
message ??= await JsonSerializer.DeserializeAsync(requestStream, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>());
|
message ??= await JsonSerializer.DeserializeAsync(requestStream, McpJsonUtilities.DefaultOptions.GetTypeInfo<IJsonRpcMessage>()).ConfigureAwait(false);
|
||||||
if (message != null)
|
if (message != null)
|
||||||
{
|
{
|
||||||
string messageId = "(no id)";
|
string messageId = "(no id)";
|
||||||
|
|
@ -150,7 +143,14 @@ public sealed class HttpListenerSseServerTransport : TransportBase, IServerTrans
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
if (_sessionTransport is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sessionTransport.OnMessageReceivedAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -173,6 +173,7 @@ public sealed class HttpListenerSseServerTransport : TransportBase, IServerTrans
|
||||||
{
|
{
|
||||||
Throw.IfNull(serverOptions);
|
Throw.IfNull(serverOptions);
|
||||||
Throw.IfNull(serverOptions.ServerInfo);
|
Throw.IfNull(serverOptions.ServerInfo);
|
||||||
|
Throw.IfNull(serverOptions.ServerInfo.Name);
|
||||||
|
|
||||||
return serverOptions.ServerInfo.Name;
|
return serverOptions.ServerInfo.Name;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a transport mechanism for MCP communication (from the client).
|
/// Represents a transport mechanism for MCP communication (from the client).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IClientTransport : ITransport
|
public interface IClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Establishes the transport connection.
|
/// Asynchronously establishes a transport session with an MCP server and returns an interface for the duplex JSON-RPC message stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
/// <returns>Returns an interface for the duplex JSON-RPC message stream.</returns>
|
||||||
|
Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a transport mechanism for MCP communication (from the server).
|
/// Represents a transport mechanism for MCP communication (from the server).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IServerTransport : ITransport
|
public interface IServerTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts listening for incoming messages.
|
/// Asynchronously accepts a transport session initiated by an MCP client and returns an interface for the duplex JSON-RPC message stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
/// <param name="cancellationToken">Used to signal the cancellation of the asynchronous operation.</param>
|
||||||
Task StartListeningAsync(CancellationToken cancellationToken = default);
|
/// <returns>Returns an interface for the duplex JSON-RPC message stream.</returns>
|
||||||
|
Task<ITransport?> AcceptAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ModelContextProtocol.Configuration;
|
||||||
|
using ModelContextProtocol.Logging;
|
||||||
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
|
using ModelContextProtocol.Utils;
|
||||||
|
using ModelContextProtocol.Utils.Json;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.ServerSentEvents;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ServerSideEvents client transport implementation
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SseClientSessionTransport : TransportBase
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly SseClientTransportOptions _options;
|
||||||
|
private readonly Uri _sseEndpoint;
|
||||||
|
private Uri? _messageEndpoint;
|
||||||
|
private readonly CancellationTokenSource _connectionCts;
|
||||||
|
private Task? _receiveTask;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly McpServerConfig _serverConfig;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
||||||
|
|
||||||
|
private string EndpointName => $"Client (SSE) for ({_serverConfig.Id}: {_serverConfig.Name})";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
|
||||||
|
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||||
|
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
|
||||||
|
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
||||||
|
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||||
|
public SseClientSessionTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory)
|
||||||
|
: base(loggerFactory)
|
||||||
|
{
|
||||||
|
Throw.IfNull(transportOptions);
|
||||||
|
Throw.IfNull(serverConfig);
|
||||||
|
Throw.IfNull(httpClient);
|
||||||
|
|
||||||
|
_options = transportOptions;
|
||||||
|
_serverConfig = serverConfig;
|
||||||
|
_sseEndpoint = new Uri(serverConfig.Location!);
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_connectionCts = new CancellationTokenSource();
|
||||||
|
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
|
||||||
|
_jsonOptions = McpJsonUtilities.DefaultOptions;
|
||||||
|
_connectionEstablished = new TaskCompletionSource<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
{
|
||||||
|
_logger.TransportAlreadyConnected(EndpointName);
|
||||||
|
throw new McpTransportException("Transport is already connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start message receiving loop
|
||||||
|
_receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
|
||||||
|
|
||||||
|
_logger.TransportReadingMessages(EndpointName);
|
||||||
|
|
||||||
|
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (McpTransportException)
|
||||||
|
{
|
||||||
|
// Rethrow transport exceptions
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportConnectFailed(EndpointName, ex);
|
||||||
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
throw new McpTransportException("Failed to connect transport", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task SendMessageAsync(
|
||||||
|
IJsonRpcMessage message,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_messageEndpoint == null)
|
||||||
|
throw new InvalidOperationException("Transport not connected");
|
||||||
|
|
||||||
|
using var content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
string messageId = "(no id)";
|
||||||
|
|
||||||
|
if (message is IJsonRpcMessageWithId messageWithId)
|
||||||
|
{
|
||||||
|
messageId = messageWithId.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _httpClient.PostAsync(
|
||||||
|
_messageEndpoint,
|
||||||
|
content,
|
||||||
|
cancellationToken
|
||||||
|
).ConfigureAwait(false);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Check if the message was an initialize request
|
||||||
|
if (message is JsonRpcRequest request && request.Method == "initialize")
|
||||||
|
{
|
||||||
|
// If the response is not a JSON-RPC response, it is an SSE message
|
||||||
|
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
||||||
|
// The response will arrive as an SSE message
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, _jsonOptions.GetTypeInfo<JsonRpcResponse>()) ??
|
||||||
|
throw new McpTransportException("Failed to initialize client");
|
||||||
|
|
||||||
|
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||||
|
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, check if the response was accepted (the response will come as an SSE message)
|
||||||
|
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.SSETransportPostNotAccepted(EndpointName, messageId, responseContent);
|
||||||
|
throw new McpTransportException("Failed to send message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_connectionCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await _connectionCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
_connectionCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_receiveTask != null)
|
||||||
|
{
|
||||||
|
await _receiveTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SetConnected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CloseAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore exceptions on close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Uri? MessageEndpoint => _messageEndpoint;
|
||||||
|
|
||||||
|
internal SseClientTransportOptions Options => _options;
|
||||||
|
|
||||||
|
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int reconnectAttempts = 0;
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested && !IsConnected)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken
|
||||||
|
).ConfigureAwait(false);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await foreach (SseItem<string> sseEvent in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
switch (sseEvent.EventType)
|
||||||
|
{
|
||||||
|
case "endpoint":
|
||||||
|
HandleEndpointEvent(sseEvent.Data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message":
|
||||||
|
await ProcessSseMessage(sseEvent.Data, cancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.TransportReadMessagesCancelled(EndpointName);
|
||||||
|
// Normal shutdown
|
||||||
|
}
|
||||||
|
catch (IOException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.TransportReadMessagesCancelled(EndpointName);
|
||||||
|
// Normal shutdown
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.TransportConnectionError(EndpointName, ex);
|
||||||
|
|
||||||
|
reconnectAttempts++;
|
||||||
|
if (reconnectAttempts >= _options.MaxReconnectAttempts)
|
||||||
|
{
|
||||||
|
throw new McpTransportException("Exceeded reconnect limit", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(_options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetConnected(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSseMessage(string data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
_logger.TransportMessageReceivedBeforeConnected(EndpointName, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = JsonSerializer.Deserialize(data, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||||
|
if (message == null)
|
||||||
|
{
|
||||||
|
_logger.TransportMessageParseUnexpectedType(EndpointName, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string messageId = "(no id)";
|
||||||
|
if (message is IJsonRpcMessageWithId messageWithId)
|
||||||
|
{
|
||||||
|
messageId = messageWithId.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||||
|
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.TransportMessageParseFailed(EndpointName, data, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleEndpointEvent(string data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(data))
|
||||||
|
{
|
||||||
|
_logger.TransportEndpointEventInvalid(EndpointName, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if data is absolute URI
|
||||||
|
if (data.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || data.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Since the endpoint is an absolute URI, we can use it directly
|
||||||
|
_messageEndpoint = new Uri(data);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If the endpoint is a relative URI, we need to combine it with the relative path of the SSE endpoint
|
||||||
|
var hostUrl = _sseEndpoint.AbsoluteUri;
|
||||||
|
if (hostUrl.EndsWith("/sse", StringComparison.Ordinal))
|
||||||
|
hostUrl = hostUrl[..^4];
|
||||||
|
|
||||||
|
var endpointUri = $"{hostUrl.TrimEnd('/')}/{data.TrimStart('/')}";
|
||||||
|
|
||||||
|
_messageEndpoint = new Uri(endpointUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connected state
|
||||||
|
SetConnected(true);
|
||||||
|
_connectionEstablished.TrySetResult(true);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.TransportEndpointEventParseFailed(EndpointName, data, ex);
|
||||||
|
throw new McpTransportException("Failed to parse endpoint event", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,20 @@
|
||||||
using System.Net.Http.Headers;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Net.ServerSentEvents;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using ModelContextProtocol.Configuration;
|
using ModelContextProtocol.Configuration;
|
||||||
using ModelContextProtocol.Logging;
|
|
||||||
using ModelContextProtocol.Protocol.Messages;
|
|
||||||
using ModelContextProtocol.Utils;
|
using ModelContextProtocol.Utils;
|
||||||
using ModelContextProtocol.Utils.Json;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol.Transport;
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ServerSideEvents client transport implementation
|
/// The ServerSideEvents client transport implementation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SseClientTransport : TransportBase, IClientTransport
|
public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly SseClientTransportOptions _options;
|
private readonly SseClientTransportOptions _options;
|
||||||
private readonly Uri _sseEndpoint;
|
|
||||||
private Uri? _messageEndpoint;
|
|
||||||
private CancellationTokenSource? _connectionCts;
|
|
||||||
private Task? _receiveTask;
|
|
||||||
private readonly ILogger _logger;
|
|
||||||
private readonly McpServerConfig _serverConfig;
|
private readonly McpServerConfig _serverConfig;
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
private readonly bool _ownsHttpClient;
|
private readonly bool _ownsHttpClient;
|
||||||
|
|
||||||
private string EndpointName => $"Client (SSE) for ({_serverConfig.Id}: {_serverConfig.Name})";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
|
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
|
||||||
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||||
|
|
@ -53,7 +37,6 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
||||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||||
/// <param name="ownsHttpClient">True to dispose HTTP client on close connection.</param>
|
/// <param name="ownsHttpClient">True to dispose HTTP client on close connection.</param>
|
||||||
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory, bool ownsHttpClient = false)
|
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory, bool ownsHttpClient = false)
|
||||||
: base(loggerFactory)
|
|
||||||
{
|
{
|
||||||
Throw.IfNull(transportOptions);
|
Throw.IfNull(transportOptions);
|
||||||
Throw.IfNull(serverConfig);
|
Throw.IfNull(serverConfig);
|
||||||
|
|
@ -61,281 +44,36 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
||||||
|
|
||||||
_options = transportOptions;
|
_options = transportOptions;
|
||||||
_serverConfig = serverConfig;
|
_serverConfig = serverConfig;
|
||||||
_sseEndpoint = new Uri(serverConfig.Location!);
|
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_connectionCts = new CancellationTokenSource();
|
_loggerFactory = loggerFactory;
|
||||||
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
|
|
||||||
_jsonOptions = McpJsonUtilities.DefaultOptions;
|
|
||||||
_connectionEstablished = new TaskCompletionSource<bool>();
|
|
||||||
_ownsHttpClient = ownsHttpClient;
|
_ownsHttpClient = ownsHttpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc />
|
||||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var sessionTransport = new SseClientSessionTransport(_options, _serverConfig, _httpClient, _loggerFactory);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsConnected)
|
await sessionTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
{
|
return sessionTransport;
|
||||||
_logger.TransportAlreadyConnected(EndpointName);
|
|
||||||
throw new McpTransportException("Transport is already connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start message receiving loop
|
|
||||||
_receiveTask = ReceiveMessagesAsync(_connectionCts!.Token);
|
|
||||||
|
|
||||||
_logger.TransportReadingMessages(EndpointName);
|
|
||||||
|
|
||||||
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
catch (McpTransportException)
|
catch
|
||||||
{
|
{
|
||||||
// Rethrow transport exceptions
|
await sessionTransport.DisposeAsync().ConfigureAwait(false);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.TransportConnectFailed(EndpointName, ex);
|
|
||||||
await CloseAsync().ConfigureAwait(false);
|
|
||||||
throw new McpTransportException("Failed to connect transport", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc />
|
||||||
public override async Task SendMessageAsync(
|
public ValueTask DisposeAsync()
|
||||||
IJsonRpcMessage message,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
if (_messageEndpoint == null)
|
|
||||||
throw new InvalidOperationException("Transport not connected");
|
|
||||||
|
|
||||||
using var content = new StringContent(
|
|
||||||
JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()),
|
|
||||||
Encoding.UTF8,
|
|
||||||
"application/json"
|
|
||||||
);
|
|
||||||
|
|
||||||
string messageId = "(no id)";
|
|
||||||
|
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
|
||||||
{
|
|
||||||
messageId = messageWithId.Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await _httpClient.PostAsync(
|
|
||||||
_messageEndpoint,
|
|
||||||
content,
|
|
||||||
cancellationToken
|
|
||||||
).ConfigureAwait(false);
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Check if the message was an initialize request
|
|
||||||
if (message is JsonRpcRequest request && request.Method == "initialize")
|
|
||||||
{
|
|
||||||
// If the response is not a JSON-RPC response, it is an SSE message
|
|
||||||
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
|
||||||
// The response will arrive as an SSE message
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, _jsonOptions.GetTypeInfo<JsonRpcResponse>()) ??
|
|
||||||
throw new McpTransportException("Failed to initialize client");
|
|
||||||
|
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
|
||||||
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
|
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, check if the response was accepted (the response will come as an SSE message)
|
|
||||||
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.SSETransportPostNotAccepted(EndpointName, messageId, responseContent);
|
|
||||||
throw new McpTransportException("Failed to send message");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public async Task CloseAsync()
|
|
||||||
{
|
|
||||||
if (_connectionCts != null)
|
|
||||||
{
|
|
||||||
await _connectionCts.CancelAsync().ConfigureAwait(false);
|
|
||||||
_connectionCts.Dispose();
|
|
||||||
_connectionCts = null;
|
|
||||||
}
|
|
||||||
if (_receiveTask != null)
|
|
||||||
await _receiveTask.ConfigureAwait(false);
|
|
||||||
|
|
||||||
SetConnected(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await CloseAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// Ignore exceptions on close
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_ownsHttpClient)
|
if (_ownsHttpClient)
|
||||||
_httpClient?.Dispose();
|
|
||||||
|
|
||||||
_connectionCts?.Dispose();
|
|
||||||
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Uri? MessageEndpoint => _messageEndpoint;
|
|
||||||
|
|
||||||
internal SseClientTransportOptions Options => _options;
|
|
||||||
|
|
||||||
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
int reconnectAttempts = 0;
|
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
try
|
_httpClient.Dispose();
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(
|
|
||||||
request,
|
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
cancellationToken
|
|
||||||
).ConfigureAwait(false);
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Reset reconnect attempts on successful connection
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
|
|
||||||
await foreach (SseItem<string> sseEvent in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
switch (sseEvent.EventType)
|
|
||||||
{
|
|
||||||
case "endpoint":
|
|
||||||
HandleEndpointEvent(sseEvent.Data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "message":
|
|
||||||
await ProcessSseMessage(sseEvent.Data, cancellationToken).ConfigureAwait(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
|
||||||
// Normal shutdown
|
|
||||||
}
|
|
||||||
catch (IOException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
|
||||||
// Normal shutdown
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.TransportConnectionError(EndpointName, ex);
|
|
||||||
|
|
||||||
reconnectAttempts++;
|
|
||||||
if (reconnectAttempts >= _options.MaxReconnectAttempts)
|
|
||||||
{
|
|
||||||
throw new McpTransportException("Exceeded reconnect limit", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(_options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessSseMessage(string data, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!IsConnected)
|
|
||||||
{
|
|
||||||
_logger.TransportMessageReceivedBeforeConnected(EndpointName, data);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
return default;
|
||||||
{
|
|
||||||
var message = JsonSerializer.Deserialize(data, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
|
||||||
if (message == null)
|
|
||||||
{
|
|
||||||
_logger.TransportMessageParseUnexpectedType(EndpointName, data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string messageId = "(no id)";
|
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
|
||||||
{
|
|
||||||
messageId = messageWithId.Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
|
||||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
_logger.TransportMessageParseFailed(EndpointName, data, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleEndpointEvent(string data)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(data))
|
|
||||||
{
|
|
||||||
_logger.TransportEndpointEventInvalid(EndpointName, data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if data is absolute URI
|
|
||||||
if (data.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || data.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Since the endpoint is an absolute URI, we can use it directly
|
|
||||||
_messageEndpoint = new Uri(data);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// If the endpoint is a relative URI, we need to combine it with the relative path of the SSE endpoint
|
|
||||||
var hostUrl = _sseEndpoint.AbsoluteUri;
|
|
||||||
if (hostUrl.EndsWith("/sse", StringComparison.Ordinal))
|
|
||||||
hostUrl = hostUrl[..^4];
|
|
||||||
|
|
||||||
var endpointUri = $"{hostUrl.TrimEnd('/')}/{data.TrimStart('/')}";
|
|
||||||
|
|
||||||
_messageEndpoint = new Uri(endpointUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set connected state
|
|
||||||
SetConnected(true);
|
|
||||||
_connectionEstablished.TrySetResult(true);
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
_logger.TransportEndpointEventParseFailed(EndpointName, data, ex);
|
|
||||||
throw new McpTransportException("Failed to parse endpoint event", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ModelContextProtocol.Configuration;
|
||||||
|
using ModelContextProtocol.Logging;
|
||||||
|
using ModelContextProtocol.Protocol.Messages;
|
||||||
|
using ModelContextProtocol.Utils;
|
||||||
|
using ModelContextProtocol.Utils.Json;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements the MCP transport protocol over standard input/output streams.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StdioClientStreamTransport : TransportBase
|
||||||
|
{
|
||||||
|
private readonly StdioClientTransportOptions _options;
|
||||||
|
private readonly McpServerConfig _serverConfig;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
private readonly DataReceivedEventHandler _logProcessErrors;
|
||||||
|
private Process? _process;
|
||||||
|
private Task? _readTask;
|
||||||
|
private CancellationTokenSource? _shutdownCts;
|
||||||
|
private bool _processStarted;
|
||||||
|
|
||||||
|
private string EndpointName => $"Client (stdio) for ({_serverConfig.Id}: {_serverConfig.Name})";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the StdioTransport class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Configuration options for the transport.</param>
|
||||||
|
/// <param name="serverConfig">The server configuration for the transport.</param>
|
||||||
|
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
||||||
|
public StdioClientStreamTransport(StdioClientTransportOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory = null)
|
||||||
|
: base(loggerFactory)
|
||||||
|
{
|
||||||
|
Throw.IfNull(options);
|
||||||
|
Throw.IfNull(serverConfig);
|
||||||
|
|
||||||
|
_options = options;
|
||||||
|
_serverConfig = serverConfig;
|
||||||
|
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
||||||
|
_logProcessErrors = (sender, args) => _logger.TransportError(EndpointName, args.Data ?? "(no data)");
|
||||||
|
_jsonOptions = McpJsonUtilities.DefaultOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
{
|
||||||
|
_logger.TransportAlreadyConnected(EndpointName);
|
||||||
|
throw new McpTransportException("Transport is already connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.TransportConnecting(EndpointName);
|
||||||
|
|
||||||
|
_shutdownCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = _options.Command,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory,
|
||||||
|
StandardOutputEncoding = noBomUTF8,
|
||||||
|
StandardErrorEncoding = noBomUTF8,
|
||||||
|
#if NET
|
||||||
|
StandardInputEncoding = noBomUTF8,
|
||||||
|
#endif
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Arguments))
|
||||||
|
{
|
||||||
|
startInfo.Arguments = _options.Arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_options.EnvironmentVariables != null)
|
||||||
|
{
|
||||||
|
foreach (var entry in _options.EnvironmentVariables)
|
||||||
|
{
|
||||||
|
startInfo.Environment[entry.Key] = entry.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.CreateProcessForTransport(EndpointName, _options.Command,
|
||||||
|
startInfo.Arguments, string.Join(", ", startInfo.Environment.Select(kvp => kvp.Key + "=" + kvp.Value)),
|
||||||
|
startInfo.WorkingDirectory, _options.ShutdownTimeout.ToString());
|
||||||
|
|
||||||
|
_process = new Process { StartInfo = startInfo };
|
||||||
|
|
||||||
|
// Set up error logging
|
||||||
|
_process.ErrorDataReceived += _logProcessErrors;
|
||||||
|
|
||||||
|
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
|
||||||
|
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
|
||||||
|
// StandardInputEncoding doesn't exist on .NET Framework; instead, it always picks
|
||||||
|
// up the encoding from Console.InputEncoding. As such, when not targeting .NET Core,
|
||||||
|
// we temporarily change Console.InputEncoding to no-BOM UTF-8 around the Process.Start
|
||||||
|
// call, to ensure it picks up the correct encoding.
|
||||||
|
#if NET
|
||||||
|
_processStarted = _process.Start();
|
||||||
|
#else
|
||||||
|
Encoding originalInputEncoding = Console.InputEncoding;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.InputEncoding = noBomUTF8;
|
||||||
|
_processStarted = _process.Start();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.InputEncoding = originalInputEncoding;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (!_processStarted)
|
||||||
|
{
|
||||||
|
_logger.TransportProcessStartFailed(EndpointName);
|
||||||
|
throw new McpTransportException("Failed to start MCP server process");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.TransportProcessStarted(EndpointName, _process.Id);
|
||||||
|
|
||||||
|
_process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
// Start reading messages in the background
|
||||||
|
_readTask = Task.Run(() => ReadMessagesAsync(_shutdownCts.Token), CancellationToken.None);
|
||||||
|
_logger.TransportReadingMessages(EndpointName);
|
||||||
|
|
||||||
|
SetConnected(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportConnectFailed(EndpointName, ex);
|
||||||
|
await CleanupAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
throw new McpTransportException("Failed to connect transport", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!IsConnected || _process?.HasExited == true)
|
||||||
|
{
|
||||||
|
_logger.TransportNotConnected(EndpointName);
|
||||||
|
throw new McpTransportException("Transport is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = "(no id)";
|
||||||
|
if (message is IJsonRpcMessageWithId messageWithId)
|
||||||
|
{
|
||||||
|
id = messageWithId.Id.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||||
|
_logger.TransportSendingMessage(EndpointName, id, json);
|
||||||
|
_logger.TransportMessageBytesUtf8(EndpointName, json);
|
||||||
|
|
||||||
|
// Write the message followed by a newline using our UTF-8 writer
|
||||||
|
await _process!.StandardInput.WriteLineAsync(json).ConfigureAwait(false);
|
||||||
|
await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.TransportSentMessage(EndpointName, id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportSendFailed(EndpointName, id, ex);
|
||||||
|
throw new McpTransportException("Failed to send message", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.TransportEnteringReadMessagesLoop(EndpointName);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested && !_process!.HasExited)
|
||||||
|
{
|
||||||
|
_logger.TransportWaitingForMessage(EndpointName);
|
||||||
|
var line = await _process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (line == null)
|
||||||
|
{
|
||||||
|
_logger.TransportEndOfStream(EndpointName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.TransportReceivedMessage(EndpointName, line);
|
||||||
|
_logger.TransportMessageBytesUtf8(EndpointName, line);
|
||||||
|
|
||||||
|
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
_logger.TransportExitingReadMessagesLoop(EndpointName);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.TransportReadMessagesCancelled(EndpointName);
|
||||||
|
// Normal shutdown
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportReadMessagesFailed(EndpointName, ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await CleanupAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessageAsync(string line, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
line=line.Trim();//Fixes an error when the service prefixes nonprintable characters
|
||||||
|
var message = JsonSerializer.Deserialize(line, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||||
|
if (message != null)
|
||||||
|
{
|
||||||
|
string messageId = "(no id)";
|
||||||
|
if (message is IJsonRpcMessageWithId messageWithId)
|
||||||
|
{
|
||||||
|
messageId = messageWithId.Id.ToString();
|
||||||
|
}
|
||||||
|
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||||
|
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.TransportMessageParseUnexpectedType(EndpointName, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.TransportMessageParseFailed(EndpointName, line, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.TransportCleaningUp(EndpointName);
|
||||||
|
|
||||||
|
if (_process is Process process && _processStarted && !process.HasExited)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Wait for the process to exit
|
||||||
|
_logger.TransportWaitingForShutdown(EndpointName);
|
||||||
|
|
||||||
|
// Kill the while process tree because the process may spawn child processes
|
||||||
|
// and Node.js does not kill its children when it exits properly
|
||||||
|
process.KillTree(_options.ShutdownTimeout);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportShutdownFailed(EndpointName, ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process.ErrorDataReceived -= _logProcessErrors;
|
||||||
|
process.Dispose();
|
||||||
|
_process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_shutdownCts is { } shutdownCts)
|
||||||
|
{
|
||||||
|
await shutdownCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
shutdownCts.Dispose();
|
||||||
|
_shutdownCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_readTask is Task readTask)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.TransportWaitingForReadTask(EndpointName);
|
||||||
|
await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_logger.TransportCleanupReadTaskTimeout(EndpointName);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.TransportCleanupReadTaskCancelled(EndpointName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_logger.TransportReadTaskCleanedUp(EndpointName);
|
||||||
|
_readTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetConnected(false);
|
||||||
|
_logger.TransportCleanedUp(EndpointName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,17 @@
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using ModelContextProtocol.Configuration;
|
using ModelContextProtocol.Configuration;
|
||||||
using ModelContextProtocol.Logging;
|
|
||||||
using ModelContextProtocol.Protocol.Messages;
|
|
||||||
using ModelContextProtocol.Utils;
|
using ModelContextProtocol.Utils;
|
||||||
using ModelContextProtocol.Utils.Json;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.Protocol.Transport;
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implements the MCP transport protocol over standard input/output streams.
|
/// Implements the MCP transport protocol over standard input/output streams.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StdioClientTransport : TransportBase, IClientTransport
|
public sealed class StdioClientTransport : IClientTransport
|
||||||
{
|
{
|
||||||
private readonly StdioClientTransportOptions _options;
|
private readonly StdioClientTransportOptions _options;
|
||||||
private readonly McpServerConfig _serverConfig;
|
private readonly McpServerConfig _serverConfig;
|
||||||
private readonly ILogger _logger;
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
|
||||||
private Process? _process;
|
|
||||||
private Task? _readTask;
|
|
||||||
private CancellationTokenSource? _shutdownCts;
|
|
||||||
private bool _processStarted;
|
|
||||||
|
|
||||||
private string EndpointName => $"Client (stdio) for ({_serverConfig.Id}: {_serverConfig.Name})";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the StdioTransport class.
|
/// Initializes a new instance of the StdioTransport class.
|
||||||
|
|
@ -34,290 +20,29 @@ public sealed class StdioClientTransport : TransportBase, IClientTransport
|
||||||
/// <param name="serverConfig">The server configuration for the transport.</param>
|
/// <param name="serverConfig">The server configuration for the transport.</param>
|
||||||
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
||||||
public StdioClientTransport(StdioClientTransportOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory = null)
|
public StdioClientTransport(StdioClientTransportOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory = null)
|
||||||
: base(loggerFactory)
|
|
||||||
{
|
{
|
||||||
Throw.IfNull(options);
|
Throw.IfNull(options);
|
||||||
Throw.IfNull(serverConfig);
|
Throw.IfNull(serverConfig);
|
||||||
|
|
||||||
_options = options;
|
_options = options;
|
||||||
_serverConfig = serverConfig;
|
_serverConfig = serverConfig;
|
||||||
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
_loggerFactory = loggerFactory;
|
||||||
_jsonOptions = McpJsonUtilities.DefaultOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc />
|
||||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (IsConnected)
|
var streamTransport = new StdioClientStreamTransport(_options, _serverConfig, _loggerFactory);
|
||||||
{
|
|
||||||
_logger.TransportAlreadyConnected(EndpointName);
|
|
||||||
throw new McpTransportException("Transport is already connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.TransportConnecting(EndpointName);
|
await streamTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return streamTransport;
|
||||||
_shutdownCts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _options.Command,
|
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory,
|
|
||||||
StandardOutputEncoding = noBomUTF8,
|
|
||||||
StandardErrorEncoding = noBomUTF8,
|
|
||||||
#if NET
|
|
||||||
StandardInputEncoding = noBomUTF8,
|
|
||||||
#endif
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_options.Arguments))
|
|
||||||
{
|
|
||||||
startInfo.Arguments = _options.Arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_options.EnvironmentVariables != null)
|
|
||||||
{
|
|
||||||
foreach (var entry in _options.EnvironmentVariables)
|
|
||||||
{
|
|
||||||
startInfo.Environment[entry.Key] = entry.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.CreateProcessForTransport(EndpointName, _options.Command,
|
|
||||||
startInfo.Arguments, string.Join(", ", startInfo.Environment.Select(kvp => kvp.Key + "=" + kvp.Value)),
|
|
||||||
startInfo.WorkingDirectory, _options.ShutdownTimeout.ToString());
|
|
||||||
|
|
||||||
_process = new Process { StartInfo = startInfo };
|
|
||||||
|
|
||||||
// Set up error logging
|
|
||||||
_process.ErrorDataReceived += (sender, args) => _logger.TransportError(EndpointName, args.Data ?? "(no data)");
|
|
||||||
|
|
||||||
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
|
|
||||||
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
|
|
||||||
// StandardInputEncoding doesn't exist on .NET Framework; instead, it always picks
|
|
||||||
// up the encoding from Console.InputEncoding. As such, when not targeting .NET Core,
|
|
||||||
// we temporarily change Console.InputEncoding to no-BOM UTF-8 around the Process.Start
|
|
||||||
// call, to ensure it picks up the correct encoding.
|
|
||||||
#if NET
|
|
||||||
_processStarted = _process.Start();
|
|
||||||
#else
|
|
||||||
Encoding originalInputEncoding = Console.InputEncoding;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Console.InputEncoding = noBomUTF8;
|
|
||||||
_processStarted = _process.Start();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Console.InputEncoding = originalInputEncoding;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (!_processStarted)
|
|
||||||
{
|
|
||||||
_logger.TransportProcessStartFailed(EndpointName);
|
|
||||||
throw new McpTransportException("Failed to start MCP server process");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.TransportProcessStarted(EndpointName, _process.Id);
|
|
||||||
|
|
||||||
_process.BeginErrorReadLine();
|
|
||||||
|
|
||||||
// Start reading messages in the background
|
|
||||||
_readTask = Task.Run(() => ReadMessagesAsync(_shutdownCts.Token), CancellationToken.None);
|
|
||||||
_logger.TransportReadingMessages(EndpointName);
|
|
||||||
|
|
||||||
SetConnected(true);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
_logger.TransportConnectFailed(EndpointName, ex);
|
await streamTransport.DisposeAsync().ConfigureAwait(false);
|
||||||
await CleanupAsync(cancellationToken).ConfigureAwait(false);
|
throw;
|
||||||
throw new McpTransportException("Failed to connect transport", ex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!IsConnected || _process?.HasExited == true)
|
|
||||||
{
|
|
||||||
_logger.TransportNotConnected(EndpointName);
|
|
||||||
throw new McpTransportException("Transport is not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
string id = "(no id)";
|
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
|
||||||
{
|
|
||||||
id = messageWithId.Id.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
|
||||||
_logger.TransportSendingMessage(EndpointName, id, json);
|
|
||||||
_logger.TransportMessageBytesUtf8(EndpointName, json);
|
|
||||||
|
|
||||||
// Write the message followed by a newline using our UTF-8 writer
|
|
||||||
await _process!.StandardInput.WriteLineAsync(json).ConfigureAwait(false);
|
|
||||||
await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
_logger.TransportSentMessage(EndpointName, id);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.TransportSendFailed(EndpointName, id, ex);
|
|
||||||
throw new McpTransportException("Failed to send message", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.TransportEnteringReadMessagesLoop(EndpointName);
|
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested && !_process!.HasExited)
|
|
||||||
{
|
|
||||||
_logger.TransportWaitingForMessage(EndpointName);
|
|
||||||
var line = await _process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
if (line == null)
|
|
||||||
{
|
|
||||||
_logger.TransportEndOfStream(EndpointName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.TransportReceivedMessage(EndpointName, line);
|
|
||||||
_logger.TransportMessageBytesUtf8(EndpointName, line);
|
|
||||||
|
|
||||||
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
_logger.TransportExitingReadMessagesLoop(EndpointName);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
|
||||||
// Normal shutdown
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.TransportReadMessagesFailed(EndpointName, ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await CleanupAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessMessageAsync(string line, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
line=line.Trim();//Fixes an error when the service prefixes nonprintable characters
|
|
||||||
var message = JsonSerializer.Deserialize(line, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
|
||||||
if (message != null)
|
|
||||||
{
|
|
||||||
string messageId = "(no id)";
|
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
|
||||||
{
|
|
||||||
messageId = messageWithId.Id.ToString();
|
|
||||||
}
|
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
|
||||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.TransportMessageParseUnexpectedType(EndpointName, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
_logger.TransportMessageParseFailed(EndpointName, line, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CleanupAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.TransportCleaningUp(EndpointName);
|
|
||||||
|
|
||||||
if (_process is Process process && _processStarted && !process.HasExited)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Wait for the process to exit
|
|
||||||
_logger.TransportWaitingForShutdown(EndpointName);
|
|
||||||
|
|
||||||
// Kill the while process tree because the process may spawn child processes
|
|
||||||
// and Node.js does not kill its children when it exits properly
|
|
||||||
process.KillTree(_options.ShutdownTimeout);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.TransportShutdownFailed(EndpointName, ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
process.Dispose();
|
|
||||||
_process = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_shutdownCts is { } shutdownCts)
|
|
||||||
{
|
|
||||||
await shutdownCts.CancelAsync().ConfigureAwait(false);
|
|
||||||
shutdownCts.Dispose();
|
|
||||||
_shutdownCts = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_readTask is Task readTask)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.TransportWaitingForReadTask(EndpointName);
|
|
||||||
await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (TimeoutException)
|
|
||||||
{
|
|
||||||
_logger.TransportCleanupReadTaskTimeout(EndpointName);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
_logger.TransportCleanupReadTaskCancelled(EndpointName);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_logger.TransportReadTaskCleanedUp(EndpointName);
|
|
||||||
_readTask = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SetConnected(false);
|
|
||||||
_logger.TransportCleanedUp(EndpointName);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ModelContextProtocol.Logging;
|
using ModelContextProtocol.Logging;
|
||||||
|
|
@ -14,7 +14,7 @@ namespace ModelContextProtocol.Protocol.Transport;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides an implementation of the MCP transport protocol over standard input/output streams.
|
/// Provides an implementation of the MCP transport protocol over standard input/output streams.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StdioServerTransport : TransportBase, IServerTransport
|
public sealed class StdioServerTransport : TransportBase, ITransport
|
||||||
{
|
{
|
||||||
private static readonly byte[] s_newlineBytes = "\n"u8.ToArray();
|
private static readonly byte[] s_newlineBytes = "\n"u8.ToArray();
|
||||||
|
|
||||||
|
|
@ -26,8 +26,10 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
private readonly Stream _stdOutStream;
|
private readonly Stream _stdOutStream;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
private Task? _readTask;
|
private readonly CancellationTokenSource _shutdownCts = new();
|
||||||
private CancellationTokenSource? _shutdownCts;
|
|
||||||
|
private readonly Task _readLoopCompleted;
|
||||||
|
private int _disposed = 0;
|
||||||
|
|
||||||
private string EndpointName => $"Server (stdio) ({_serverName})";
|
private string EndpointName => $"Server (stdio) ({_serverName})";
|
||||||
|
|
||||||
|
|
@ -44,8 +46,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
/// to <see cref="Console.Out"/>, as that will interfere with the transport's output.
|
/// to <see cref="Console.Out"/>, as that will interfere with the transport's output.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public StdioServerTransport(McpServerOptions serverOptions, ILoggerFactory? loggerFactory = null)
|
public StdioServerTransport(IOptions<McpServerOptions> serverOptions, ILoggerFactory? loggerFactory = null)
|
||||||
: this(GetServerName(serverOptions), loggerFactory)
|
: this(serverOptions?.Value!, loggerFactory: loggerFactory)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,11 +64,19 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
/// to <see cref="Console.Out"/>, as that will interfere with the transport's output.
|
/// to <see cref="Console.Out"/>, as that will interfere with the transport's output.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public StdioServerTransport(IOptions<McpServerOptions> serverOptions, ILoggerFactory? loggerFactory = null)
|
public StdioServerTransport(McpServerOptions serverOptions, ILoggerFactory? loggerFactory = null)
|
||||||
: this(GetServerName(serverOptions.Value), loggerFactory)
|
: this(GetServerName(serverOptions), loggerFactory: loggerFactory)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetServerName(McpServerOptions serverOptions)
|
||||||
|
{
|
||||||
|
Throw.IfNull(serverOptions);
|
||||||
|
Throw.IfNull(serverOptions.ServerInfo);
|
||||||
|
Throw.IfNull(serverOptions.ServerInfo.Name);
|
||||||
|
return serverOptions.ServerInfo.Name;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="StdioServerTransport"/> class, using
|
/// Initializes a new instance of the <see cref="StdioServerTransport"/> class, using
|
||||||
/// <see cref="Console.In"/> and <see cref="Console.Out"/> for input and output streams.
|
/// <see cref="Console.In"/> and <see cref="Console.Out"/> for input and output streams.
|
||||||
|
|
@ -80,25 +90,17 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
/// to <see cref="Console.Out"/>, as that will interfere with the transport's output.
|
/// to <see cref="Console.Out"/>, as that will interfere with the transport's output.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory = null)
|
public StdioServerTransport(string serverName, ILoggerFactory? loggerFactory)
|
||||||
: base(loggerFactory)
|
: this(serverName, stdinStream: null, stdoutStream: null, loggerFactory)
|
||||||
{
|
{
|
||||||
Throw.IfNull(serverName);
|
|
||||||
|
|
||||||
_serverName = serverName;
|
|
||||||
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
|
||||||
|
|
||||||
// Get raw console streams and wrap them with UTF-8 encoding
|
|
||||||
_stdInReader = new StreamReader(Console.OpenStandardInput(), Encoding.UTF8);
|
|
||||||
_stdOutStream = new BufferedStream(Console.OpenStandardOutput());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="StdioServerTransport"/> class with explicit input/output streams.
|
/// Initializes a new instance of the <see cref="StdioServerTransport"/> class with explicit input/output streams.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="serverName">The name of the server.</param>
|
/// <param name="serverName">The name of the server.</param>
|
||||||
/// <param name="stdinStream">The input TextReader to use.</param>
|
/// <param name="stdinStream">The input <see cref="Stream"/> to use as standard input. If <see langword="null"/>, <see cref="Console.In"/> will be used.</param>
|
||||||
/// <param name="stdoutStream">The output TextWriter to use.</param>
|
/// <param name="stdoutStream">The output <see cref="Stream"/> to use as standard output. If <see langword="null"/>, <see cref="Console.Out"/> will be used.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory used for logging employed by the transport.</param>
|
/// <param name="loggerFactory">Optional logger factory used for logging employed by the transport.</param>
|
||||||
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> is <see langword="null"/>.</exception>
|
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> is <see langword="null"/>.</exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|
@ -106,33 +108,19 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
/// This constructor is useful for testing scenarios where you want to redirect input/output.
|
/// This constructor is useful for testing scenarios where you want to redirect input/output.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public StdioServerTransport(string serverName, Stream stdinStream, Stream stdoutStream, ILoggerFactory? loggerFactory = null)
|
public StdioServerTransport(string serverName, Stream? stdinStream = null, Stream? stdoutStream = null, ILoggerFactory? loggerFactory = null)
|
||||||
: base(loggerFactory)
|
: base(loggerFactory)
|
||||||
{
|
{
|
||||||
Throw.IfNull(serverName);
|
Throw.IfNull(serverName);
|
||||||
Throw.IfNull(stdinStream);
|
|
||||||
Throw.IfNull(stdoutStream);
|
|
||||||
|
|
||||||
_serverName = serverName;
|
_serverName = serverName;
|
||||||
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
_logger = (ILogger?)loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
||||||
|
|
||||||
_stdInReader = new StreamReader(stdinStream, Encoding.UTF8);
|
_stdInReader = new StreamReader(stdinStream ?? Console.OpenStandardInput(), Encoding.UTF8);
|
||||||
_stdOutStream = stdoutStream;
|
_stdOutStream = stdoutStream ?? new BufferedStream(Console.OpenStandardOutput());
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public Task StartListeningAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Starting StdioServerTransport listener for {EndpointName}", EndpointName);
|
|
||||||
|
|
||||||
_shutdownCts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
_readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token).ConfigureAwait(false), CancellationToken.None);
|
|
||||||
|
|
||||||
SetConnected(true);
|
SetConnected(true);
|
||||||
_logger.LogDebug("StdioServerTransport now connected for {EndpointName}", EndpointName);
|
_readLoopCompleted = Task.Run(ReadMessagesAsync, _shutdownCts.Token);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|
@ -169,33 +157,26 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
private async Task ReadMessagesAsync()
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await CleanupAsync(CancellationToken.None).ConfigureAwait(false);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
|
CancellationToken shutdownToken = _shutdownCts.Token;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.TransportEnteringReadMessagesLoop(EndpointName);
|
_logger.TransportEnteringReadMessagesLoop(EndpointName);
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!shutdownToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.TransportWaitingForMessage(EndpointName);
|
_logger.TransportWaitingForMessage(EndpointName);
|
||||||
|
|
||||||
var reader = _stdInReader;
|
var line = await _stdInReader.ReadLineAsync(shutdownToken).ConfigureAwait(false);
|
||||||
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
if (line == null)
|
|
||||||
{
|
|
||||||
_logger.TransportEndOfStream(EndpointName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
{
|
{
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
_logger.TransportEndOfStream(EndpointName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,8 +185,7 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Deserialize(line, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
if (JsonSerializer.Deserialize(line, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()) is { } message)
|
||||||
if (message != null)
|
|
||||||
{
|
{
|
||||||
string messageId = "(no id)";
|
string messageId = "(no id)";
|
||||||
if (message is IJsonRpcMessageWithId messageWithId)
|
if (message is IJsonRpcMessageWithId messageWithId)
|
||||||
|
|
@ -213,7 +193,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
messageId = messageWithId.Id.ToString();
|
messageId = messageWithId.Id.ToString();
|
||||||
}
|
}
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
await WriteMessageAsync(message, shutdownToken).ConfigureAwait(false);
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -227,12 +208,12 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
// Continue reading even if we fail to parse a message
|
// Continue reading even if we fail to parse a message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.TransportExitingReadMessagesLoop(EndpointName);
|
_logger.TransportExitingReadMessagesLoop(EndpointName);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
_logger.TransportReadMessagesCancelled(EndpointName);
|
||||||
// Normal shutdown
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -240,63 +221,55 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await CleanupAsync(cancellationToken).ConfigureAwait(false);
|
SetConnected(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CleanupAsync(CancellationToken cancellationToken)
|
/// <inheritdoc />
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_logger.TransportCleaningUp(EndpointName);
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
|
||||||
if (_shutdownCts is { } shutdownCts)
|
|
||||||
{
|
{
|
||||||
await shutdownCts.CancelAsync().ConfigureAwait(false);
|
return;
|
||||||
shutdownCts.Dispose();
|
|
||||||
|
|
||||||
_shutdownCts = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_readTask is { } readTask)
|
try
|
||||||
{
|
{
|
||||||
|
_logger.TransportCleaningUp(EndpointName);
|
||||||
|
|
||||||
|
// Signal to the stdin reading loop to stop.
|
||||||
|
await _shutdownCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
_shutdownCts.Dispose();
|
||||||
|
|
||||||
|
// Dispose of stdin/out. Cancellation may not be able to wake up operations
|
||||||
|
// synchronously blocked in a syscall; we need to forcefully close the handle / file descriptor.
|
||||||
|
_stdInReader?.Dispose();
|
||||||
|
_stdOutStream?.Dispose();
|
||||||
|
|
||||||
|
// Make sure the work has quiesced.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.TransportWaitingForReadTask(EndpointName);
|
_logger.TransportWaitingForReadTask(EndpointName);
|
||||||
await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
await _readLoopCompleted.ConfigureAwait(false);
|
||||||
|
_logger.TransportReadTaskCleanedUp(EndpointName);
|
||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException)
|
||||||
{
|
{
|
||||||
_logger.TransportCleanupReadTaskTimeout(EndpointName);
|
_logger.TransportCleanupReadTaskTimeout(EndpointName);
|
||||||
// Continue with cleanup
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.TransportCleanupReadTaskCancelled(EndpointName);
|
_logger.TransportCleanupReadTaskCancelled(EndpointName);
|
||||||
// Ignore cancellation
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
|
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
_logger.TransportReadTaskCleanedUp(EndpointName);
|
|
||||||
_readTask = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
_stdInReader?.Dispose();
|
{
|
||||||
_stdOutStream?.Dispose();
|
SetConnected(false);
|
||||||
|
_logger.TransportCleanedUp(EndpointName);
|
||||||
SetConnected(false);
|
}
|
||||||
_logger.TransportCleanedUp(EndpointName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Validates the <paramref name="serverOptions"/> and extracts from it the server name to use.</summary>
|
|
||||||
private static string GetServerName(McpServerOptions serverOptions)
|
|
||||||
{
|
|
||||||
Throw.IfNull(serverOptions);
|
|
||||||
Throw.IfNull(serverOptions.ServerInfo);
|
|
||||||
|
|
||||||
return serverOptions.ServerInfo.Name;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue