* Logging
Fixed bug in stdio transport argument promotion

* Logging
Fixed bug in stdio transport argument promotion

* Version bump
This commit is contained in:
Peder Holdgaard Pedersen 2025-01-09 19:39:11 +01:00 • committed by GitHub
commit 4b8df6638e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 674 additions and 298 deletions

View file

@ -0,0 +1,49 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using Microsoft.Extensions.Logging;
namespace McpDotNet.Tests;
public class IntegrationTestFixture : IDisposable
{
public ILoggerFactory LoggerFactory { get; }
public McpClientFactory Factory { get; }
public McpClientOptions DefaultOptions { get; }
public McpServerConfig DefaultConfig { get; }
public IntegrationTestFixture()
{
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
DefaultOptions = new()
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
DefaultConfig = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything",
}
};
// Inject the mock transport into the factory
Factory = new McpClientFactory(
[DefaultConfig],
DefaultOptions,
LoggerFactory
);
}
public void Dispose()
{
LoggerFactory?.Dispose();
}
}

View file

@ -1,51 +1,26 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using McpDotNet.Protocol.Types;
using Moq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Mail;
using System.Text;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace McpDotNet.Tests;
public class IntegrationTests
public class IntegrationTests : IClassFixture<IntegrationTestFixture>
{
private readonly McpClientOptions _defaultOptions = new()
private readonly IntegrationTestFixture _fixture;
public IntegrationTests(IntegrationTestFixture fixture)
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
_fixture = fixture;
}
[Fact]
public async Task ConnectAndPing_Stdio_EverythingServer()
{
// Arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything",
}
};
// Inject the mock transport into the factory
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
// Act
var client = await factory.GetClientAsync("everything");
var client = await _fixture.Factory.GetClientAsync("everything");
await client.PingAsync(CancellationToken.None);
// Assert
@ -56,25 +31,9 @@ public class IntegrationTests
public async Task ListTools_Stdio_EverythingServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
// act
var client = await factory.GetClientAsync("everything");
var client = await _fixture.Factory.GetClientAsync("everything");
var tools = await client.ListToolsAsync();
// assert
@ -87,25 +46,9 @@ public class IntegrationTests
public async Task CallTool_Stdio_EchoServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
// act
var client = await factory.GetClientAsync("everything");
var client = await _fixture.Factory.GetClientAsync("everything");
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object>
@ -126,24 +69,9 @@ public class IntegrationTests
public async Task ListPrompts_Stdio_EverythingServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var prompts = await client.ListPromptsAsync();
// assert
@ -158,24 +86,9 @@ public class IntegrationTests
public async Task GetPrompt_Stdio_SimplePrompt()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var result = await client.GetPromptAsync("simple_prompt", null, CancellationToken.None);
// assert
@ -187,24 +100,9 @@ public class IntegrationTests
public async Task GetPrompt_Stdio_ComplexPrompt()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var arguments = new Dictionary<string, object>
{
{ "temperature", "0.7" },
@ -221,24 +119,9 @@ public class IntegrationTests
public async Task GetPrompt_NonExistent_ThrowsException()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
await Assert.ThrowsAsync<McpClientException>(() =>
client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None));
}
@ -247,24 +130,9 @@ public class IntegrationTests
public async Task ListResources_Stdio_EverythingServer()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
List<Resource> allResources = new();
string? cursor = null;
@ -284,24 +152,9 @@ public class IntegrationTests
public async Task ReadResource_Stdio_TextResource()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
// 1 is index 0, which is "even" in the 0-based index
var result = await client.ReadResourceAsync("test://static/resource/1", CancellationToken.None);
@ -315,24 +168,9 @@ public class IntegrationTests
public async Task ReadResource_Stdio_BinaryResource()
{
// arrange
var config = new McpServerConfig
{
Id = "everything",
Name = "Everything",
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
}
};
var factory = new McpClientFactory(
new[] { config },
_defaultOptions
);
var client = await factory.GetClientAsync("everything");
// act
var client = await _fixture.Factory.GetClientAsync("everything");
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
// 2 is index 1, which is "odd" in the 0-based index
var result = await client.ReadResourceAsync("test://static/resource/2", CancellationToken.None);
@ -354,7 +192,7 @@ public class IntegrationTests
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",
["arguments"] = "-y @modelcontextprotocol/server-everything"
["arguments"] = "-y @modelcontextprotocol/server-everything",
}
};
@ -368,13 +206,15 @@ public class IntegrationTests
};
var factory = new McpClientFactory(
new[] { config },
options
[config],
options,
_fixture.LoggerFactory
);
var client = await factory.GetClientAsync("everything");
// Set up the sampling handler
int samplingHandlerCalls = 0;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
client.SamplingHandler = async (_) =>
{
samplingHandlerCalls++;
@ -389,6 +229,7 @@ public class IntegrationTests
}
};
};
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync(
@ -404,6 +245,6 @@ public class IntegrationTests
Assert.NotNull(result);
var textContent = Assert.Single(result.Content);
Assert.Equal("text", textContent.Type);
Assert.Contains("Test response", textContent.Text);
Assert.False(string.IsNullOrEmpty(textContent.Text));
}
}

View file

@ -1,7 +1,9 @@
using Castle.Core.Logging;
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using System.Threading.Channels;
using Xunit;
@ -47,10 +49,11 @@ public class McpClientFactoryTests
// Inject the mock transport into the factory
var factory = new McpClientFactory(
new[] { config },
[config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _) => mockClient.Object
clientFactoryMethod: (_, _, _) => mockClient.Object
);
// Act
@ -86,7 +89,11 @@ public class McpClientFactoryTests
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
var factory = new McpClientFactory(new[] { config }, _defaultOptions, transportFactoryMethod: _ => mockTransport.Object, clientFactoryMethod: (_,_) => mockClient.Object);
var factory = new McpClientFactory([config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_,_,_) => mockClient.Object);
// Act
var client1 = await factory.GetClientAsync("test-server");
@ -100,7 +107,9 @@ public class McpClientFactoryTests
public async Task GetClientAsync_WithInvalidServerId_ThrowsArgumentException()
{
// Arrange
var factory = new McpClientFactory(Array.Empty<McpServerConfig>(), _defaultOptions);
var factory = new McpClientFactory(Array.Empty<McpServerConfig>(),
_defaultOptions,
NullLoggerFactory.Instance);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
@ -120,7 +129,8 @@ public class McpClientFactoryTests
Location = "/path/to/server"
};
var factory = new McpClientFactory(new[] { config }, _defaultOptions);
var factory = new McpClientFactory([config], _defaultOptions,
NullLoggerFactory.Instance);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
@ -153,7 +163,11 @@ public class McpClientFactoryTests
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
var factory = new McpClientFactory(new[] { config }, _defaultOptions, transportFactoryMethod: _ => mockTransport.Object, clientFactoryMethod: (_, _) => mockClient.Object);
var factory = new McpClientFactory([config],
_defaultOptions,
NullLoggerFactory.Instance,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _, _) => mockClient.Object);
// Act
var client = await factory.GetClientAsync("test-server");
@ -166,14 +180,15 @@ public class McpClientFactoryTests
public void Constructor_WithDuplicateServerIds_ThrowsArgumentException()
{
// Arrange
var configs = new[]
{
McpServerConfig[] configs =
[
new McpServerConfig { Id = "duplicate", Name = "duplicate", TransportType = "stdio", Location = "/path1" },
new McpServerConfig { Id = "duplicate", Name = "duplicate", TransportType = "stdio", Location = "/path2" }
};
];
// Act & Assert
Assert.Throws<ArgumentException>(() => new McpClientFactory(configs, _defaultOptions));
Assert.Throws<ArgumentException>(() => new McpClientFactory(configs, _defaultOptions,
NullLoggerFactory.Instance));
}
[Fact]
@ -188,7 +203,8 @@ public class McpClientFactoryTests
Location = "http://localhost:8080"
};
var factory = new McpClientFactory(new[] { config }, _defaultOptions);
var factory = new McpClientFactory([config], _defaultOptions,
NullLoggerFactory.Instance);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(

View file

@ -10,6 +10,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />

View file

@ -1,14 +1,15 @@
namespace McpDotNet.Client;

using McpDotNet.Protocol.Types;
using McpDotNet.Protocol.Transport;
using System.Collections.Concurrent;
using McpDotNet.Protocol.Messages;
using System.Diagnostics;
using System.Text.Json;
using McpDotNet.Utils.Json;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using McpDotNet.Logging;
using McpDotNet.Configuration;
namespace McpDotNet.Client;
/// <inheritdoc/>
internal class McpClient : IMcpClient
@ -19,13 +20,16 @@ internal class McpClient : IMcpClient
private readonly ConcurrentDictionary<string, List<Func<JsonRpcNotification,Task>>> _notificationHandlers;
private readonly Dictionary<string, Func<JsonRpcRequest, Task<object>>> _requestHandlers = new();
private int _nextRequestId;
private readonly McpServerConfig _serverConfig;
private bool _isInitialized;
private Task? _messageProcessingTask;
private CancellationTokenSource? _cts;
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions().ConfigureForMcp();
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger<McpClient> _logger;
private volatile bool _isInitializing;
/// <inheritdoc/>
public bool IsInitialized => _isInitialized;
public bool IsInitialized => _isInitialized;
/// <inheritdoc/>
public ServerCapabilities? ServerCapabilities { get; private set; }
@ -38,20 +42,29 @@ internal class McpClient : IMcpClient
/// </summary>
/// <param name="transport">An MCP transport implementation.</param>
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
public McpClient(IMcpTransport transport, McpClientOptions options)
/// <param name="serverConfig">The server configuration.</param>
/// <param name="loggerFactory">The logger factory.</param>
public McpClient(IMcpTransport transport, McpClientOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory)
{
_transport = transport;
_options = options;
_pendingRequests = new();
_notificationHandlers = new();
_nextRequestId = 1;
_serverConfig = serverConfig;
_jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory);
_logger = loggerFactory.CreateLogger<McpClient>();
if (options.Capabilities?.Sampling != null)
{
SetRequestHandler<CreateMessageRequestParams, CreateMessageResult>("sampling/createMessage",
async (request) => {
if (SamplingHandler == null)
{
// Setting the capability, but not a handler means we have nothing to return to the server
_logger.SamplingHandlerNotConfigured(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Sampling handler not configured");
}
return await SamplingHandler(request);
});
@ -61,9 +74,17 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (_isInitializing)
{
_logger.ClientAlreadyInitializing(_serverConfig.Id, _serverConfig.Name);
throw new InvalidOperationException("Client is already initializing");
}
_isInitializing = true;
if (_isInitialized)
{
throw new InvalidOperationException("Client is already initialized");
_logger.ClientAlreadyInitialized(_serverConfig.Id, _serverConfig.Name);
return;
}
try
@ -83,10 +104,14 @@ internal class McpClient : IMcpClient
}
catch (Exception e)
{
Debug.WriteLine($"Client connection error: {e}");
_logger.ClientInitializationError(_serverConfig.Id, _serverConfig.Name, e);
await CleanupAsync().ConfigureAwait(false);
throw;
}
finally
{
_isInitializing = false;
}
}
private async Task InitializeAsync(CancellationToken cancellationToken)
@ -112,12 +137,14 @@ internal class McpClient : IMcpClient
).ConfigureAwait(false);
// Store server information
_logger.ServerCapabilitiesReceived(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(initializeResponse.Capabilities), JsonSerializer.Serialize(initializeResponse.ServerInfo));
ServerCapabilities = initializeResponse.Capabilities;
ServerInfo = initializeResponse.ServerInfo;
// Validate protocol version
if (initializeResponse.ProtocolVersion != _options.ProtocolVersion)
{
_logger.ServerProtocolVersionMismatch(_serverConfig.Id, _serverConfig.Name, _options.ProtocolVersion, initializeResponse.ProtocolVersion);
throw new McpClientException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}");
}
@ -132,6 +159,7 @@ internal class McpClient : IMcpClient
}
catch (OperationCanceledException) when (initializationCts.IsCancellationRequested)
{
_logger.ClientInitializationTimeout(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Initialization timed out");
}
}
@ -139,6 +167,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task PingAsync(CancellationToken cancellationToken)
{
_logger.PingingServer(_serverConfig.Id, _serverConfig.Name);
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
@ -151,6 +180,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ListToolsResult> ListToolsAsync(string? cursor = null, CancellationToken cancellationToken = default)
{
_logger.ListingTools(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)");
return await SendRequestAsync<ListToolsResult>(
new JsonRpcRequest
{
@ -164,6 +194,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ListPromptsResult> ListPromptsAsync(string? cursor = null, CancellationToken cancellationToken = default)
{
_logger.ListingPrompts(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)");
return await SendRequestAsync<ListPromptsResult>(
new JsonRpcRequest
{
@ -177,6 +208,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<GetPromptResult> GetPromptAsync(string name, Dictionary<string, object>? arguments = null, CancellationToken cancellationToken = default)
{
_logger.GettingPrompt(_serverConfig.Id, _serverConfig.Name, name, arguments == null ? "{}" : JsonSerializer.Serialize(arguments));
return await SendRequestAsync<GetPromptResult>(
new JsonRpcRequest
{
@ -190,6 +222,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ListResourcesResult> ListResourcesAsync(string? cursor = null, CancellationToken cancellationToken = default)
{
_logger.ListingResources(_serverConfig.Id, _serverConfig.Name, cursor ?? "(null)");
return await SendRequestAsync<ListResourcesResult>(
new JsonRpcRequest
{
@ -203,6 +236,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task<ReadResourceResult> ReadResourceAsync(string uri, CancellationToken cancellationToken = default)
{
_logger.ReadingResource(_serverConfig.Id, _serverConfig.Name, uri);
return await SendRequestAsync<ReadResourceResult>(
new JsonRpcRequest
{
@ -220,6 +254,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default)
{
_logger.SubscribingToResource(_serverConfig.Id, _serverConfig.Name, uri);
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
@ -237,6 +272,7 @@ internal class McpClient : IMcpClient
/// <inheritdoc/>
public async Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellationToken = default)
{
_logger.UnsubscribingFromResource(_serverConfig.Id, _serverConfig.Name, uri);
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
@ -254,6 +290,7 @@ internal class McpClient : IMcpClient
public async Task<CallToolResponse> CallToolAsync(string toolName, Dictionary<string, object>? arguments = null,
CancellationToken cancellationToken = default)
{
_logger.CallingTool(_serverConfig.Id, _serverConfig.Name, toolName, arguments == null ? "{}" : JsonSerializer.Serialize(arguments));
return await SendRequestAsync<CallToolResponse>(
new JsonRpcRequest
{
@ -291,6 +328,7 @@ internal class McpClient : IMcpClient
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Normal shutdown
_logger.ClientMessageProcessingCancelled(_serverConfig.Id, _serverConfig.Name);
}
catch (NullReferenceException)
{
@ -307,7 +345,9 @@ internal class McpClient : IMcpClient
{
try
{
_logger.RequestHandlerCalled(_serverConfig.Id, _serverConfig.Name, request.Method);
var result = await handler(request);
_logger.RequestHandlerCompleted(_serverConfig.Id, _serverConfig.Name, request.Method);
await _transport.SendMessageAsync(new JsonRpcResponse
{
Id = request.Id,
@ -317,6 +357,7 @@ internal class McpClient : IMcpClient
}
catch (Exception ex)
{
_logger.RequestHandlerError(_serverConfig.Id, _serverConfig.Name, request.Method, ex);
// Send error response
await _transport.SendMessageAsync(new JsonRpcError
{
@ -336,6 +377,10 @@ internal class McpClient : IMcpClient
{
tcs.TrySetResult(message);
}
else
{
_logger.NoRequestFoundForMessageWithId(_serverConfig.Id, _serverConfig.Name, messageWithId.Id.ToString());
}
break;
case JsonRpcNotification notification:
@ -350,7 +395,7 @@ internal class McpClient : IMcpClient
catch (Exception ex)
{
// Log handler error but continue processing
Debug.WriteLine($"Notification handler error: {ex}");
_logger.NotificationHandlerError(_serverConfig.Id, _serverConfig.Name, notification.Method, ex);
}
}
}
@ -363,6 +408,7 @@ internal class McpClient : IMcpClient
{
if (!_transport.IsConnected)
{
_logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Transport is not connected");
}
@ -374,13 +420,21 @@ internal class McpClient : IMcpClient
try
{
Debug.WriteLine($"Sending initialize request: {JsonSerializer.Serialize(request)}");
// Expensive logging, use the logging framework to check if the logger is enabled
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.SendingRequestPayload(_serverConfig.Id, _serverConfig.Name, JsonSerializer.Serialize(request));
}
// Less expensive information logging
_logger.SendingRequest(_serverConfig.Id, _serverConfig.Name, request.Method);
await _transport.SendMessageAsync(request, cancellationToken).ConfigureAwait(false);
var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
if (response is JsonRpcError error)
{
_logger.RequestFailed(_serverConfig.Id, _serverConfig.Name, request.Method, error.Error.Message, error.Error.Code);
throw new McpClientException($"Request failed: {error.Error.Message}", error.Error.Code);
}
@ -389,15 +443,23 @@ internal class McpClient : IMcpClient
// Convert the Result object to JSON and back to get our strongly-typed result
var resultJson = JsonSerializer.Serialize(success.Result, _jsonOptions);
var resultObject = JsonSerializer.Deserialize<T>(resultJson, _jsonOptions);
// Not expensive logging because we're already converting to JSON in order to get the result object
_logger.RequestResponseReceivedPayload(_serverConfig.Id, _serverConfig.Name, resultJson);
_logger.RequestResponseReceived(_serverConfig.Id, _serverConfig.Name, request.Method);
if (resultObject != null)
{
return resultObject;
}
Debug.WriteLine($"Received response: {JsonSerializer.Serialize(success.Result)}");
Debug.WriteLine($"Expected type: {typeof(T)}");
// Result object was null, this is unexpected
_logger.RequestResponseTypeConversionError(_serverConfig.Id, _serverConfig.Name, request.Method, typeof(T));
throw new McpClientException($"Unexpected response type {JsonSerializer.Serialize(success.Result)}, expected {typeof(T)}");
}
// Unexpected response type
_logger.RequestInvalidResponseType(_serverConfig.Id, _serverConfig.Name, request.Method);
throw new McpClientException("Invalid response type");
}
finally
@ -410,6 +472,7 @@ internal class McpClient : IMcpClient
{
if (!_transport.IsConnected)
{
_logger.ClientNotConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpClientException("Transport is not connected");
}
@ -434,12 +497,21 @@ internal class McpClient : IMcpClient
// Convert the params JsonElement to our type using the same options
var jsonString = JsonSerializer.Serialize(request.Params);
var typedRequest = JsonSerializer.Deserialize<TRequest>(jsonString, _jsonOptions);
if (typedRequest == null)
{
_logger.RequestParamsTypeConversionError(_serverConfig.Id, _serverConfig.Name, method, typeof(TRequest));
throw new McpClientException($"Invalid request parameters type {jsonString}, expected {typeof(TRequest)}");
}
return await handler(typedRequest);
};
}
private async Task CleanupAsync()
{
_logger.CleaningUpClient(_serverConfig.Id, _serverConfig.Name);
_cts?.Cancel();
if (_messageProcessingTask != null)
@ -465,6 +537,8 @@ internal class McpClient : IMcpClient
_cts?.Dispose();
_isInitialized = false;
_logger.ClientCleanedUp(_serverConfig.Id, _serverConfig.Name);
}
/// <inheritdoc/>

View file

@ -1,8 +1,9 @@
namespace McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Configuration;
using McpDotNet.Logging;
using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.Logging;
namespace McpDotNet.Client;
/// <summary>
/// Factory for creating MCP clients based on configuration. It caches clients for reuse, so it is safe to call GetClientAsync multiple times.
/// Call GetClientAsync to get a client for a specific server (by ID), which will create a new client and connect if it doesn't already exist.
@ -15,7 +16,9 @@ public class McpClientFactory
private readonly McpClientOptions _clientOptions;
private readonly Dictionary<string, IMcpClient> _clients = new();
private readonly Func<McpServerConfig, IMcpTransport> _transportFactoryMethod;
private readonly Func<IMcpTransport, McpClientOptions, IMcpClient> _clientFactoryMethod;
private readonly Func<IMcpTransport, McpServerConfig, McpClientOptions, IMcpClient> _clientFactoryMethod;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<McpClientFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="McpClientFactory"/> class.
@ -24,18 +27,33 @@ public class McpClientFactory
/// </summary>
/// <param name="serverConfigs">Configuration objects for each server the factory should support.</param>
/// <param name="clientOptions">A configuration object which specifies client capabilities and protocol version.</param>
/// <param name="loggerFactory">A logger factory for creating loggers for clients.</param>
/// <param name="transportFactoryMethod">An optional factory method which returns transport implementations based on a server configuration.</param>
/// <param name="clientFactoryMethod">An optional factory method which creates a client based on client options and transport implementation. </param>
public McpClientFactory(
IEnumerable<McpServerConfig> serverConfigs,
McpClientOptions clientOptions,
ILoggerFactory loggerFactory,
Func<McpServerConfig, IMcpTransport>? transportFactoryMethod = null,
Func<IMcpTransport, McpClientOptions, IMcpClient>? clientFactoryMethod = null)
Func<IMcpTransport, McpServerConfig, McpClientOptions, IMcpClient>? clientFactoryMethod = null)
{
_serverConfigs = serverConfigs.ToDictionary(c => c.Id);
_clientOptions = clientOptions;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<McpClientFactory>();
_transportFactoryMethod = transportFactoryMethod ?? CreateTransport;
_clientFactoryMethod = clientFactoryMethod ?? ((transport, options) => new McpClient(transport, options));
_clientFactoryMethod = clientFactoryMethod ?? ((transport, serverConfig, options) => new McpClient(transport, options, serverConfig, loggerFactory));
// Initialize commands for stdio transport, this is to run commands in a shell even if specified directly, as otherwise
// the stdio protocol will not work correctly.
_logger.InitializingStdioCommands();
foreach (var config in _serverConfigs.Values)
{
if (config.TransportType.ToLowerInvariant() == "stdio")
{
InitializeCommand(config);
}
}
}
/// <summary>
@ -49,24 +67,32 @@ public class McpClientFactory
{
if (!_serverConfigs.TryGetValue(serverId, out var config))
{
_logger.ServerNotFound(serverId);
throw new ArgumentException($"Server with ID '{serverId}' not found.", nameof(serverId));
}
if (_clients.TryGetValue(serverId, out var existingClient))
{
_logger.ClientExists(serverId, config.Name);
return existingClient;
}
_logger.CreatingClient(serverId, config.Name);
var transport = _transportFactoryMethod(config);
var client = _clientFactoryMethod(transport, _clientOptions);
var client = _clientFactoryMethod(transport, config, _clientOptions);
await client.ConnectAsync(cancellationToken);
_logger.ClientCreated(serverId, config.Name);
_clients[serverId] = client;
return client;
}
private IMcpTransport CreateTransport(McpServerConfig config)
{
var options = string.Join(", ", config.TransportOptions?.Select(kv => $"{kv.Key}={kv.Value}") ?? Enumerable.Empty<string>());
_logger.CreatingTransport(config.Id, config.Name, config.TransportType, options);
return config.TransportType.ToLowerInvariant() switch
{
"stdio" => new StdioTransport(new StdioTransportOptions
@ -77,34 +103,47 @@ public class McpClientFactory
EnvironmentVariables = config.TransportOptions?
.Where(kv => kv.Key.StartsWith("env:"))
.ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value)
}),
// Add other transport types here
}, config, _loggerFactory),
_ => throw new ArgumentException($"Unsupported transport type '{config.TransportType}'.", nameof(config))
};
}
private string GetCommand(McpServerConfig config)
{
if (config.TransportOptions == null)
if (config.TransportOptions == null ||
string.IsNullOrEmpty(config.TransportOptions.GetValueOrDefault("command")))
{
return config.Location!;
}
var command = config.TransportOptions.GetValueOrDefault("command");
if (string.IsNullOrEmpty(command))
{
return config.Location!;
}
if (config.TransportOptions.ContainsKey("arguments"))
{
var arguments = config.TransportOptions.GetValueOrDefault("arguments");
config.TransportOptions["arguments"] = $"/c {command} {arguments}";
}
else
{
config.TransportOptions["arguments"] = $"/c {command}";
}
return $"cmd.exe";
}
/// <summary>
/// Initializes a non-shell command by injecting a /c {command} argument, as the command will be run in a shell.
/// </summary>
private void InitializeCommand(McpServerConfig config)
{
// If the command is empty or already contains cmd.exe, we don't need to do anything
var command = config.TransportOptions?.GetValueOrDefault("command");
if (string.IsNullOrEmpty(command))
{
return;
}
if (command.Contains("cmd.exe"))
{
_logger.SkippingShellWrapper(config.Id, config.Name);
return;
}
// If the command is not empty and does not contain cmd.exe, we need to inject /c {command}
if (config.TransportOptions != null && !string.IsNullOrEmpty(command))
{
_logger.PromotingCommandToShellArgumentForStdio(config.Id, config.Name, command, config.TransportOptions.GetValueOrDefault("arguments") ?? "");
config.TransportOptions["arguments"] = config.TransportOptions.ContainsKey("arguments")
? $"/c {command} {config.TransportOptions["arguments"]}"
: $"/c {command}";
}
}
}

View file

@ -0,0 +1,271 @@
using McpDotNet.Configuration;
using Microsoft.Extensions.Logging;
namespace McpDotNet.Logging;
/// <summary>
/// Logging methods for the McpDotNet library.
/// </summary>
internal static partial class Log
{
[LoggerMessage(Level = LogLevel.Debug, Message = "Client {clientId} initializing connection to server {serverId}")]
internal static partial void ClientConnecting(this ILogger logger, string clientId, string serverId);
[LoggerMessage(Level = LogLevel.Information, Message = "Server capabilities received: {capabilities}")]
internal static partial void ServerCapabilitiesReceived(this ILogger logger, string capabilities);
[LoggerMessage(Level = LogLevel.Warning, Message = "Request {requestId} timed out")]
internal static partial void RequestTimeout(this ILogger logger, int requestId);
[LoggerMessage(Level = LogLevel.Debug, Message = "Command for {ServerId} ({Name}) already contains shell wrapper, skipping argument injection")]
internal static partial void SkippingShellWrapper(this ILogger logger, string serverId, string name);
[LoggerMessage(Level = LogLevel.Error, Message = "Server config for Id={serverId} not found")]
internal static partial void ServerNotFound(this ILogger logger, string serverId);
[LoggerMessage(Level = LogLevel.Debug, Message = "Client for {serverId} ({name}) already created, returning cached client")]
internal static partial void ClientExists(this ILogger logger, string serverId, string name);
[LoggerMessage(Level = LogLevel.Information, Message = "Creating client for {serverId} ({name})")]
internal static partial void CreatingClient(this ILogger logger, string serverId, string name);
[LoggerMessage(Level = LogLevel.Information, Message = "Client for {serverId} ({name}) created and connected")]
internal static partial void ClientCreated(this ILogger logger, string serverId, string name);
[LoggerMessage(Level = LogLevel.Information, Message = "Creating transport for {serverId} ({name}) with type {transportType} and options {options}")]
internal static partial void CreatingTransport(this ILogger logger, string serverId, string name, string transportType, string options);
[LoggerMessage(Level = LogLevel.Debug, Message = "Promoting command for {serverId} ({name}) to shell argument for stdio transport with command {command} and arguments {arguments}")]
internal static partial void PromotingCommandToShellArgumentForStdio(this ILogger logger, string serverId, string name, string command, string arguments);
[LoggerMessage(Level = LogLevel.Debug, Message = "Initializing stdio commands")]
internal static partial void InitializingStdioCommands(this ILogger logger);
[LoggerMessage(Level = LogLevel.Error, Message = "Sampling handler not configured for server {serverId} ({serverName}), always set a handler when using this capability")]
internal static partial void SamplingHandlerNotConfigured(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) already initializing")]
internal static partial void ClientAlreadyInitializing(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Client server {serverId} ({serverName}) already initialized")]
internal static partial void ClientAlreadyInitialized(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) initialization error")]
internal static partial void ClientInitializationError(this ILogger logger, string serverId, string serverName, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "Server {serverId} ({serverName}) capabilities received: {capabilities}, server info: {serverInfo}")]
internal static partial void ServerCapabilitiesReceived(this ILogger logger, string serverId, string serverName, string capabilities, string serverInfo);
[LoggerMessage(Level = LogLevel.Error, Message = "Server {serverId} ({serverName}) protocol version mismatch, expected {expected}, received {received}")]
internal static partial void ServerProtocolVersionMismatch(this ILogger logger, string serverId, string serverName, string expected, string received);
[LoggerMessage(Level = LogLevel.Error, Message = "Client server {serverId} ({serverName}) initialization timeout")]
internal static partial void ClientInitializationTimeout(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Pinging server {serverId} ({serverName})")]
internal static partial void PingingServer(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Listing tools for server {serverId} ({serverName}) with cursor {cursor}")]
internal static partial void ListingTools(this ILogger logger, string serverId, string serverName, string cursor);
[LoggerMessage(Level = LogLevel.Information, Message = "Listing prompts for server {serverId} ({serverName}) with cursor {cursor}")]
internal static partial void ListingPrompts(this ILogger logger, string serverId, string serverName, string cursor);
[LoggerMessage(Level = LogLevel.Information, Message = "Getting prompt {name} for server {serverId} ({serverName}) with arguments {arguments}")]
internal static partial void GettingPrompt(this ILogger logger, string serverId, string serverName, string name, string arguments);
[LoggerMessage(Level = LogLevel.Information, Message = "Listing resources for server {serverId} ({serverName}) with cursor {cursor}")]
internal static partial void ListingResources(this ILogger logger, string serverId, string serverName, string cursor);
[LoggerMessage(Level = LogLevel.Information, Message = "Reading resource {uri} for server {serverId} ({serverName})")]
internal static partial void ReadingResource(this ILogger logger, string serverId, string serverName, string uri);
[LoggerMessage(Level = LogLevel.Information, Message = "Subscribing to resource {uri} for server {serverId} ({serverName})")]
internal static partial void SubscribingToResource(this ILogger logger, string serverId, string serverName, string uri);
[LoggerMessage(Level = LogLevel.Information, Message = "Unsubscribing from resource {uri} for server {serverId} ({serverName})")]
internal static partial void UnsubscribingFromResource(this ILogger logger, string serverId, string serverName, string uri);
[LoggerMessage(Level = LogLevel.Information, Message = "Calling tool {toolName} for server {serverId} ({serverName}) with arguments {arguments}")]
internal static partial void CallingTool(this ILogger logger, string serverId, string serverName, string toolName, string arguments);
[LoggerMessage(Level = LogLevel.Information, Message = "Client message processing cancelled for server {serverId} ({serverName})")]
internal static partial void ClientMessageProcessingCancelled(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Request handler called for server {serverId} ({serverName}) with method {method}")]
internal static partial void RequestHandlerCalled(this ILogger logger, string serverId, string serverName, string method);
[LoggerMessage(Level = LogLevel.Information, Message = "Request handler completed for server {serverId} ({serverName}) with method {method}")]
internal static partial void RequestHandlerCompleted(this ILogger logger, string serverId, string serverName, string method);
[LoggerMessage(Level = LogLevel.Error, Message = "Request handler error for server {serverId} ({serverName}) with method {method}")]
internal static partial void RequestHandlerError(this ILogger logger, string serverId, string serverName, string method, Exception exception);
[LoggerMessage(Level = LogLevel.Warning, Message = "No request found for message with ID {messageWithId} for server {serverId} ({serverName})")]
internal static partial void NoRequestFoundForMessageWithId(this ILogger logger, string serverId, string serverName, string messageWithId);
[LoggerMessage(Level = LogLevel.Error, Message = "Notification handler error for server {serverId} ({serverName}) with method {method}")]
internal static partial void NotificationHandlerError(this ILogger logger, string serverId, string serverName, string method, Exception exception);
[LoggerMessage(Level = LogLevel.Error, Message = "Client not connected for server {serverId} ({serverName})")]
internal static partial void ClientNotConnected(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Sending request payload for server {serverId} ({serverName}): {payload}")]
internal static partial void SendingRequestPayload(this ILogger logger, string serverId, string serverName, string payload);
[LoggerMessage(Level = LogLevel.Information, Message = "Sending request for server {serverId} ({serverName}) with method {method}")]
internal static partial void SendingRequest(this ILogger logger, string serverId, string serverName, string method);
[LoggerMessage(Level = LogLevel.Error, Message = "Request failed for server {serverId} ({serverName}) with method {method}: {message} ({code})")]
internal static partial void RequestFailed(this ILogger logger, string serverId, string serverName, string method, string message, int code);
[LoggerMessage(Level = LogLevel.Information, Message = "Request response received payload for server {serverId} ({serverName}): {payload}")]
internal static partial void RequestResponseReceivedPayload(this ILogger logger, string serverId, string serverName, string payload);
[LoggerMessage(Level = LogLevel.Information, Message = "Request response received for server {serverId} ({serverName}) with method {method}")]
internal static partial void RequestResponseReceived(this ILogger logger, string serverId, string serverName, string method);
[LoggerMessage(Level = LogLevel.Error, Message = "Request response type conversion error for server {serverId} ({serverName}) with method {method}: expected {expectedType}")]
internal static partial void RequestResponseTypeConversionError(this ILogger logger, string serverId, string serverName, string method, Type expectedType);
[LoggerMessage(Level = LogLevel.Error, Message = "Request invalid response type for server {serverId} ({serverName}) with method {method}")]
internal static partial void RequestInvalidResponseType(this ILogger logger, string serverId, string serverName, string method);
[LoggerMessage(Level = LogLevel.Error, Message = "Request params type conversion error for server {serverId} ({serverName}) with method {method}: expected {expectedType}")]
internal static partial void RequestParamsTypeConversionError(this ILogger logger, string serverId, string serverName, string method, Type expectedType);
[LoggerMessage(Level = LogLevel.Information, Message = "Cleaning up client for server {serverId} ({serverName})")]
internal static partial void CleaningUpClient(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Client cleaned up for server {serverId} ({serverName})")]
internal static partial void ClientCleanedUp(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport for server {serverId} ({serverName}) already connected")]
internal static partial void TransportAlreadyConnected(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport for server {serverId} ({serverName}) connecting")]
internal static partial void TransportConnecting(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Creating process for transport for server {serverId} ({serverName}) with command {command}, arguments {arguments}, environment {environment}, working directory {workingDirectory}, shutdown timeout {shutdownTimeout}")]
internal static partial void CreateProcessForTransport(this ILogger logger, string serverId, string serverName, string command, string arguments, string environment, string workingDirectory, string shutdownTimeout);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport for server {serverId} ({serverName}) error: {data}")]
internal static partial void TransportError(this ILogger logger, string serverId, string serverName, string data);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport process start failed for server {serverId} ({serverName})")]
internal static partial void TransportProcessStartFailed(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport process started for server {serverId} ({serverName}) with PID {processId}")]
internal static partial void TransportProcessStarted(this ILogger logger, string serverId, string serverName, int processId);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport reading messages for server {serverId} ({serverName})")]
internal static partial void TransportReadingMessages(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport connect failed for server {serverId} ({serverName})")]
internal static partial void TransportConnectFailed(this ILogger logger, string serverId, string serverName, Exception exception);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport not connected for server {serverId} ({serverName})")]
internal static partial void TransportNotConnected(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport sending message for server {serverId} ({serverName}) with ID {messageId}, JSON {json}")]
internal static partial void TransportSendingMessage(this ILogger logger, string serverId, string serverName, string messageId, string json);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport message sent for server {serverId} ({serverName}) with ID {messageId}")]
internal static partial void TransportSentMessage(this ILogger logger, string serverId, string serverName, string messageId);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport send failed for server {serverId} ({serverName}) with ID {messageId}")]
internal static partial void TransportSendFailed(this ILogger logger, string serverId, string serverName, string messageId, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport entering read messages loop for server {serverId} ({serverName})")]
internal static partial void TransportEnteringReadMessagesLoop(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport waiting for message for server {serverId} ({serverName})")]
internal static partial void TransportWaitingForMessage(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport end of stream for server {serverId} ({serverName})")]
internal static partial void TransportEndOfStream(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport received message for server {serverId} ({serverName}): {line}")]
internal static partial void TransportReceivedMessage(this ILogger logger, string serverId, string serverName, string line);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport received message parsed for server {serverId} ({serverName}): {messageId}")]
internal static partial void TransportReceivedMessageParsed(this ILogger logger, string serverId, string serverName, string messageId);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport message written for server {serverId} ({serverName}) with ID {messageId}")]
internal static partial void TransportMessageWritten(this ILogger logger, string serverId, string serverName, string messageId);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport message parse failed due to unexpected message schema for server {serverId} ({serverName}): {line}")]
internal static partial void TransportMessageParseUnexpectedType(this ILogger logger, string serverId, string serverName, string line);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport message parse failed for server {serverId} ({serverName}): {line}")]
internal static partial void TransportMessageParseFailed(this ILogger logger, string serverId, string serverName, string line, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport exiting read messages loop for server {serverId} ({serverName})")]
internal static partial void TransportExitingReadMessagesLoop(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport read messages cancelled for server {serverId} ({serverName})")]
internal static partial void TransportReadMessagesCancelled(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport read messages failed for server {serverId} ({serverName})")]
internal static partial void TransportReadMessagesFailed(this ILogger logger, string serverId, string serverName, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaning up for server {serverId} ({serverName})")]
internal static partial void TransportCleaningUp(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Debug, Message = "Transport closing stdin for server {serverId} ({serverName})")]
internal static partial void TransportClosingStdin(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Debug, Message = "Transport waiting for shutdown for server {serverId} ({serverName})")]
internal static partial void TransportWaitingForShutdown(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Warning, Message = "Transport killing process for server {serverId} ({serverName})")]
internal static partial void TransportKillingProcess(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport shutdown failed for server {serverId} ({serverName})")]
internal static partial void TransportShutdownFailed(this ILogger logger, string serverId, string serverName, Exception exception);
[LoggerMessage(Level = LogLevel.Debug, Message = "Transport waiting for read task for server {serverId} ({serverName})")]
internal static partial void TransportWaitingForReadTask(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Warning, Message = "Transport cleanup read task timeout for server {serverId} ({serverName})")]
internal static partial void TransportCleanupReadTaskTimeout(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport cleanup read task cancelled for server {serverId} ({serverName})")]
internal static partial void TransportCleanupReadTaskCancelled(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "Transport cleanup read task failed for server {serverId} ({serverName})")]
internal static partial void TransportCleanupReadTaskFailed(this ILogger logger, string serverId, string serverName, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport read task cleaned up for server {serverId} ({serverName})")]
internal static partial void TransportReadTaskCleanedUp(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Information, Message = "Transport cleaned up for server {serverId} ({serverName})")]
internal static partial void TransportCleanedUp(this ILogger logger, string serverId, string serverName);
[LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message start object token expected")]
internal static partial void JsonRpcMessageConverterExpectedStartObjectToken(this ILogger logger);
[LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message invalid version")]
internal static partial void JsonRpcMessageConverterInvalidJsonRpcVersion(this ILogger logger);
[LoggerMessage(Level = LogLevel.Trace, Message = "JSON-RPC message deserializing error response: {rawText}")]
internal static partial void JsonRpcMessageConverterDeserializingErrorResponse(this ILogger logger, string rawText);
[LoggerMessage(Level = LogLevel.Trace, Message = "JSON-RPC message deserializing response: {rawText}")]
internal static partial void JsonRpcMessageConverterDeserializingResponse(this ILogger logger, string rawText);
[LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message response must have result or error: {rawText}")]
internal static partial void JsonRpcMessageConverterResponseMustHaveResultOrError(this ILogger logger, string rawText);
[LoggerMessage(Level = LogLevel.Trace, Message = "JSON-RPC message deserializing notification: {rawText}")]
internal static partial void JsonRpcMessageConverterDeserializingNotification(this ILogger logger, string rawText);
[LoggerMessage(Level = LogLevel.Trace, Message = "JSON-RPC message deserializing request: {rawText}")]
internal static partial void JsonRpcMessageConverterDeserializingRequest(this ILogger logger, string rawText);
[LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message invalid format: {rawText}")]
internal static partial void JsonRpcMessageConverterInvalidMessageFormat(this ILogger logger, string rawText);
[LoggerMessage(Level = LogLevel.Error, Message = "JSON-RPC message write unknown message type: {type}")]
internal static partial void JsonRpcMessageConverterWriteUnknownMessageType(this ILogger logger, string type);
}

View file

@ -1,42 +1,12 @@
// Protocol/Transport/StdioTransport.cs
namespace McpDotNet.Protocol.Transport;
using System.Diagnostics;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Utils.Json;
using Microsoft.Extensions.Logging;
using McpDotNet.Logging;
/// <summary>
/// Represents configuration options for the stdio transport.
/// </summary>
public record StdioTransportOptions
{
/// <summary>
/// The command to execute to start the server process.
/// </summary>
public required string Command { get; set; }
/// <summary>
/// Arguments to pass to the server process.
/// </summary>
public string[]? Arguments { get; set; } = Array.Empty<string>();
/// <summary>
/// The working directory for the server process.
/// </summary>
public string? WorkingDirectory { get; set; }
/// <summary>
/// Environment variables to set for the server process.
/// </summary>
public Dictionary<string, string>? EnvironmentVariables { get; set; }
/// <summary>
/// The timeout to wait for the server to shut down gracefully.
/// </summary>
public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(5);
}
namespace McpDotNet.Protocol.Transport;
/// <summary>
/// Implements the MCP transport protocol over standard input/output streams.
@ -44,6 +14,8 @@ public record StdioTransportOptions
public sealed class StdioTransport : TransportBase
{
private readonly StdioTransportOptions _options;
private readonly McpServerConfig _serverConfig;
private readonly ILogger<StdioTransport> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private Process? _process;
private Task? _readTask;
@ -54,10 +26,14 @@ public sealed class StdioTransport : TransportBase
/// Initializes a new instance of the StdioTransport class.
/// </summary>
/// <param name="options">Configuration options for the transport.</param>
public StdioTransport(StdioTransportOptions options)
/// <param name="serverConfig">The server configuration for the transport.</param>
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
public StdioTransport(StdioTransportOptions options, McpServerConfig serverConfig, ILoggerFactory loggerFactory)
{
_options = options;
_jsonOptions = new JsonSerializerOptions().ConfigureForMcp();
_serverConfig = serverConfig;
_logger = loggerFactory.CreateLogger<StdioTransport>();
_jsonOptions = new JsonSerializerOptions().ConfigureForMcp(loggerFactory);
}
/// <inheritdoc/>
@ -65,11 +41,14 @@ public sealed class StdioTransport : TransportBase
{
if (IsConnected)
{
_logger.TransportAlreadyConnected(_serverConfig.Id, _serverConfig.Name);
throw new McpTransportException("Transport is already connected");
}
try
{
_logger.TransportConnecting(_serverConfig.Id, _serverConfig.Name);
_shutdownCts = new CancellationTokenSource();
var startInfo = new ProcessStartInfo
@ -99,33 +78,37 @@ public sealed class StdioTransport : TransportBase
}
}
_logger.CreateProcessForTransport(_serverConfig.Id, _serverConfig.Name, _options.Command,
string.Join(", ", startInfo.ArgumentList), 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) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
Debug.WriteLine($"MCP Server Error: {args.Data}");
}
_logger.TransportError(_serverConfig.Id, _serverConfig.Name, args.Data ?? "(no data)");
};
if (!_process.Start())
{
Debug.WriteLine("Failed to start MCP server process");
_logger.TransportProcessStartFailed(_serverConfig.Id, _serverConfig.Name);
throw new McpTransportException("Failed to start MCP server process");
}
_logger.TransportProcessStarted(_serverConfig.Id, _serverConfig.Name, _process.Id);
_processStarted = true;
_process.BeginErrorReadLine();
// Start reading messages in the background
_readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token));
_logger.TransportReadingMessages(_serverConfig.Id, _serverConfig.Name);
SetConnected(true);
}
catch (Exception ex)
{
_logger.TransportConnectFailed(_serverConfig.Id, _serverConfig.Name, ex);
await CleanupAsync(cancellationToken);
throw new McpTransportException("Failed to connect transport", ex);
}
@ -136,20 +119,30 @@ public sealed class StdioTransport : TransportBase
{
if (!IsConnected || _process?.HasExited == true)
{
_logger.TransportNotConnected(_serverConfig.Id, _serverConfig.Name);
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);
Debug.WriteLine($"Sending message: {json}");
_logger.TransportSendingMessage(_serverConfig.Id, _serverConfig.Name, id, json);
// Write the message followed by a newline
await _process!.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken);
await _process.StandardInput.FlushAsync();
_logger.TransportSentMessage(_serverConfig.Id, _serverConfig.Name, id);
}
catch (Exception ex)
{
_logger.TransportSendFailed(_serverConfig.Id, _serverConfig.Name, id, ex);
throw new McpTransportException("Failed to send message", ex);
}
}
@ -165,14 +158,17 @@ public sealed class StdioTransport : TransportBase
{
try
{
_logger.TransportEnteringReadMessagesLoop(_serverConfig.Id, _serverConfig.Name);
using var reader = _process!.StandardOutput;
while (!cancellationToken.IsCancellationRequested && !_process.HasExited)
{
Debug.WriteLine("Reading message...");
_logger.TransportWaitingForMessage(_serverConfig.Id, _serverConfig.Name);
var line = await reader.ReadLineAsync(cancellationToken);
if (line == null)
{
_logger.TransportEndOfStream(_serverConfig.Id, _serverConfig.Name);
break;
}
@ -181,61 +177,73 @@ public sealed class StdioTransport : TransportBase
continue;
}
Debug.WriteLine($"Received stdio message: {line}");
_logger.TransportReceivedMessage(_serverConfig.Id, _serverConfig.Name, line);
try
{
var message = JsonSerializer.Deserialize<IJsonRpcMessage>(line, _jsonOptions);
if (message != null)
{
string messageId = "(no id)";
if (message is IJsonRpcMessageWithId messageWithId)
{
messageId = messageWithId.Id.ToString();
}
_logger.TransportReceivedMessageParsed(_serverConfig.Id, _serverConfig.Name, messageId);
await WriteMessageAsync(message, cancellationToken);
_logger.TransportMessageWritten(_serverConfig.Id, _serverConfig.Name, messageId);
}
else
{
_logger.TransportMessageParseUnexpectedType(_serverConfig.Id, _serverConfig.Name, line);
}
}
catch (JsonException ex)
{
Debug.WriteLine($"Failed to parse message: {ex.Message}");
_logger.TransportMessageParseFailed(_serverConfig.Id, _serverConfig.Name, line, ex);
// Continue reading even if we fail to parse a message
}
}
Debug.WriteLine("Exiting read loop");
_logger.TransportExitingReadMessagesLoop(_serverConfig.Id, _serverConfig.Name);
}
catch (OperationCanceledException)
{
_logger.TransportReadMessagesCancelled(_serverConfig.Id, _serverConfig.Name);
// Normal shutdown
}
catch (Exception ex)
{
Debug.WriteLine($"Error reading messages: {ex}");
_logger.TransportReadMessagesFailed(_serverConfig.Id, _serverConfig.Name, ex);
}
finally
{
Debug.WriteLine("Entering ReadMessages cleanup");
await CleanupAsync(cancellationToken);
Debug.WriteLine("Completed ReadMessages cleanup");
}
}
private async Task CleanupAsync(CancellationToken cancellationToken)
{
_logger.TransportCleaningUp(_serverConfig.Id, _serverConfig.Name);
if (_process != null && _processStarted && !_process.HasExited)
{
{
try
{
// Try to close stdin to signal the process to exit
_logger.TransportClosingStdin(_serverConfig.Id, _serverConfig.Name);
_process.StandardInput.Close();
// Wait for the process to exit
_logger.TransportWaitingForShutdown(_serverConfig.Id, _serverConfig.Name);
if (!_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
{
// If it doesn't exit gracefully, terminate it
_logger.TransportKillingProcess(_serverConfig.Id, _serverConfig.Name);
_process.Kill(true);
}
}
catch (Exception ex)
{
Debug.WriteLine($"Error during cleanup: {ex}");
_logger.TransportShutdownFailed(_serverConfig.Id, _serverConfig.Name, ex);
}
_process.Dispose();
@ -250,25 +258,29 @@ public sealed class StdioTransport : TransportBase
{
try
{
_logger.TransportWaitingForReadTask(_serverConfig.Id, _serverConfig.Name);
await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
Debug.WriteLine("Cleanup timeout waiting for read task");
_logger.TransportCleanupReadTaskTimeout(_serverConfig.Id, _serverConfig.Name);
// Continue with cleanup
}
catch (OperationCanceledException)
{
_logger.TransportCleanupReadTaskCancelled(_serverConfig.Id, _serverConfig.Name);
// Ignore cancellation
}
catch (Exception ex)
{
Debug.WriteLine($"Error waiting for read task: {ex}");
_logger.TransportCleanupReadTaskFailed(_serverConfig.Id, _serverConfig.Name, ex);
}
_readTask = null;
_logger.TransportReadTaskCleanedUp(_serverConfig.Id, _serverConfig.Name);
}
SetConnected(false);
_logger.TransportCleanedUp(_serverConfig.Id, _serverConfig.Name);
}
}

View file

@ -0,0 +1,33 @@
// Protocol/Transport/StdioTransport.cs
namespace McpDotNet.Protocol.Transport;
/// <summary>
/// Represents configuration options for the stdio transport.
/// </summary>
public record StdioTransportOptions
{
/// <summary>
/// The command to execute to start the server process.
/// </summary>
public required string Command { get; set; }
/// <summary>
/// Arguments to pass to the server process.
/// </summary>
public string[]? Arguments { get; set; } = Array.Empty<string>();
/// <summary>
/// The working directory for the server process.
/// </summary>
public string? WorkingDirectory { get; set; }
/// <summary>
/// Environment variables to set for the server process.
/// </summary>
public Dictionary<string, string>? EnvironmentVariables { get; set; }
/// <summary>
/// The timeout to wait for the server to shut down gracefully.
/// </summary>
public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(5);
}

View file

@ -5,7 +5,18 @@
/// </summary>
public enum ContextInclusion
{
/// <summary>
/// No context should be included.
/// </summary>
None,
/// <summary>
/// Include context from the server that sent the request.
/// </summary>
ThisServer,
/// <summary>
/// Include context from all servers that the client is connected to.
/// </summary>
AllServers
}

View file

@ -1,6 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using McpDotNet.Protocol.Messages;
using Microsoft.Extensions.Logging;
using McpDotNet.Logging;
namespace McpDotNet.Utils.Json;
@ -9,11 +11,19 @@ namespace McpDotNet.Utils.Json;
/// </summary>
internal class JsonRpcMessageConverter : JsonConverter<IJsonRpcMessage>
{
private readonly ILogger<JsonRpcMessageConverter> _logger;
public JsonRpcMessageConverter(ILogger<JsonRpcMessageConverter> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public override IJsonRpcMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
_logger.JsonRpcMessageConverterExpectedStartObjectToken();
throw new JsonException("Expected StartObject token");
}
@ -24,6 +34,7 @@ internal class JsonRpcMessageConverter : JsonConverter<IJsonRpcMessage>
if (!root.TryGetProperty("jsonrpc", out var versionProperty) ||
versionProperty.GetString() != "2.0")
{
_logger.JsonRpcMessageConverterInvalidJsonRpcVersion();
throw new JsonException("Invalid or missing jsonrpc version");
}
@ -40,26 +51,32 @@ internal class JsonRpcMessageConverter : JsonConverter<IJsonRpcMessage>
// Messages with an error property are error responses
if (hasError)
{
_logger.JsonRpcMessageConverterDeserializingErrorResponse(rawText);
return JsonSerializer.Deserialize<JsonRpcError>(rawText, options);
}
// Messages with a result property are success responses
else if (root.TryGetProperty("result", out _))
{
_logger.JsonRpcMessageConverterDeserializingResponse(rawText);
return JsonSerializer.Deserialize<JsonRpcResponse>(rawText, options);
}
_logger.JsonRpcMessageConverterResponseMustHaveResultOrError(rawText);
throw new JsonException("Response must have either result or error");
}
// Messages with a method but no id are notifications
else if (hasMethod && !hasId)
{
_logger.JsonRpcMessageConverterDeserializingNotification(rawText);
return JsonSerializer.Deserialize<JsonRpcNotification>(rawText, options);
}
// Messages with both method and id are requests
else if (hasMethod && hasId)
{
_logger.JsonRpcMessageConverterDeserializingRequest(rawText);
return JsonSerializer.Deserialize<JsonRpcRequest>(rawText, options);
}
_logger.JsonRpcMessageConverterInvalidMessageFormat(rawText);
throw new JsonException("Invalid JSON-RPC message format");
}
@ -81,6 +98,7 @@ internal class JsonRpcMessageConverter : JsonConverter<IJsonRpcMessage>
JsonSerializer.Serialize(writer, error, options);
break;
default:
_logger.JsonRpcMessageConverterWriteUnknownMessageType(value.GetType().ToString());
throw new JsonException($"Unknown JSON-RPC message type: {value.GetType()}");
}
}

View file

@ -1,4 +1,5 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace McpDotNet.Utils.Json;
@ -11,11 +12,12 @@ internal static class JsonSerializerOptionsExtensions
/// Configures JsonSerializerOptions with MCP-specific settings and converters.
/// </summary>
/// <param name="options">The options to configure.</param>
/// <param name="loggerFactory">The logger factory to use for logging.</param>
/// <returns>The configured options.</returns>
public static JsonSerializerOptions ConfigureForMcp(this JsonSerializerOptions options)
public static JsonSerializerOptions ConfigureForMcp(this JsonSerializerOptions options, ILoggerFactory loggerFactory)
{
// Add custom converters
options.Converters.Add(new JsonRpcMessageConverter());
options.Converters.Add(new JsonRpcMessageConverter(loggerFactory.CreateLogger<JsonRpcMessageConverter>()));
options.Converters.Add(new JsonStringEnumConverter());
// Configure general options

View file

@ -9,7 +9,7 @@
<PropertyGroup>
<!-- license and package properties -->
<PackageId>mcpdotnet</PackageId>
<Version>0.3.0.2</Version>
<Version>0.4.0.1</Version>
<Authors>PederHP</Authors>
<Description>.NET client library for the Model Context Protocol (MCP)</Description>
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl>
@ -29,5 +29,9 @@
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>