Initial commit

This commit is contained in:
PederHP 2024-12-30 20:30:31 +01:00
commit fdbd94b3f9
34 changed files with 2833 additions and 0 deletions

View file

@ -0,0 +1 @@
global using Xunit;

View file

@ -0,0 +1,122 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using Moq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace McpDotNet.Tests;
public class IntegrationTests
{
private readonly McpClientOptions _defaultOptions = new()
{
ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }
};
[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");
await client.PingAsync(CancellationToken.None);
// Assert
Assert.NotNull(client);
}
[Fact]
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 tools = await client.ListToolsAsync(CancellationToken.None);
// assert
Assert.NotNull(tools);
Assert.NotEmpty(tools.Tools);
// We could add more specific assertions about expected tools
}
[Fact]
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 result = await client.CallToolAsync(
"echo",
new Dictionary<string, object>
{
["message"] = "Hello MCP!"
},
CancellationToken.None
);
// assert
Assert.NotNull(result);
Assert.False(result.IsError);
var textContent = Assert.Single(result.Content.Where(c => c.Type == "text"));
Assert.Equal("Echo: Hello MCP!", textContent.Text);
}
}

View file

@ -0,0 +1,198 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Transport;
using Moq;
using System.Threading.Channels;
using Xunit;
namespace McpDotNet.Tests.Client;
public class McpClientFactoryTests
{
private readonly McpClientOptions _defaultOptions = new()
{
ClientInfo = new() { Name = "TestClient", Version = "1.0.0" }
};
[Fact]
public async Task GetClientAsync_WithValidStdioConfig_CreatesNewClient()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "stdio",
Location = "/path/to/server",
TransportOptions = new Dictionary<string, string>
{
["arguments"] = "--test arg",
["workingDirectory"] = "/working/dir"
}
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
// Inject the mock transport into the factory
var factory = new McpClientFactory(
new[] { config },
_defaultOptions,
transportFactoryMethod: _ => mockTransport.Object,
clientFactoryMethod: (_, _) => mockClient.Object
);
// Act
var client = await factory.GetClientAsync("test-server");
// Assert
Assert.NotNull(client);
// We could add more assertions here about the client's configuration
}
[Fact]
public async Task GetClientAsync_CalledTwice_ReturnsSameInstance()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "stdio",
Location = "/path/to/server"
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
var factory = new McpClientFactory(new[] { config }, _defaultOptions, transportFactoryMethod: _ => mockTransport.Object, clientFactoryMethod: (_,_) => mockClient.Object);
// Act
var client1 = await factory.GetClientAsync("test-server");
var client2 = await factory.GetClientAsync("test-server");
// Assert
Assert.Same(client1, client2);
}
[Fact]
public async Task GetClientAsync_WithInvalidServerId_ThrowsArgumentException()
{
// Arrange
var factory = new McpClientFactory(Array.Empty<McpServerConfig>(), _defaultOptions);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => factory.GetClientAsync("non-existent-server")
);
}
[Fact]
public async Task GetClientAsync_WithUnsupportedTransport_ThrowsArgumentException()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "unsupported",
Location = "/path/to/server"
};
var factory = new McpClientFactory(new[] { config }, _defaultOptions);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => factory.GetClientAsync("test-server")
);
}
[Fact]
public async Task GetClientAsync_WithNoTransportOptions_CreatesClientWithDefaults()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "stdio",
Location = "/path/to/server"
};
// Create a mock transport
var mockTransport = new Mock<IMcpTransport>();
mockTransport.Setup(t => t.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockTransport.Setup(t => t.IsConnected).Returns(true);
mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of<ChannelReader<IJsonRpcMessage>>());
// Create a mock client
var mockClient = new Mock<IMcpClient>();
mockClient.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClient.Setup(c => c.IsInitialized).Returns(true);
var factory = new McpClientFactory(new[] { config }, _defaultOptions, transportFactoryMethod: _ => mockTransport.Object, clientFactoryMethod: (_, _) => mockClient.Object);
// Act
var client = await factory.GetClientAsync("test-server");
// Assert
Assert.NotNull(client);
}
[Fact]
public void Constructor_WithDuplicateServerIds_ThrowsArgumentException()
{
// Arrange
var configs = new[]
{
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));
}
[Fact]
public async Task GetClientAsync_WithHttpTransport_ThrowsNotImplementedException()
{
// Arrange
var config = new McpServerConfig
{
Id = "test-server",
Name = "Test Server",
TransportType = "http",
Location = "http://localhost:8080"
};
var factory = new McpClientFactory(new[] { config }, _defaultOptions);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => factory.GetClientAsync("test-server")
);
}
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\mcpdotnet\mcpdotnet.csproj" />
</ItemGroup>
</Project>

31
src/mcpdotnet.sln Normal file
View file

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35507.96
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet", "mcpdotnet\mcpdotnet.csproj", "{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet.Tests", "mcpdotnet.Tests\mcpdotnet.Tests.csproj", "{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{12260CD2-AFFC-4B2E-8898-F442CAA1FA0F}.Release|Any CPU.Build.0 = Release|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF41F619-833D-4FA2-8C53-04D0A1D5AA61}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,57 @@
namespace McpDotNet.Client;
using McpDotNet.Protocol.Types;
using McpDotNet.Protocol.Messages;
/// <summary>
/// Represents an instance of the MCP client connecting to a specific server.
/// </summary>
public interface IMcpClient : IAsyncDisposable
{
/// <summary>
/// Gets a value indicating whether the client has been initialized.
/// </summary>
bool IsInitialized { get; }
/// <summary>
/// Gets the capabilities that the server supports.
/// </summary>
ServerCapabilities? ServerCapabilities { get; }
/// <summary>
/// Gets the version information of the server.
/// </summary>
Implementation? ServerInfo { get; }
/// <summary>
/// Connects to the server.
/// </summary>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// A simple ping to the server to check if it is alive. Task completes when the ping is successful.
/// </summary>
Task PingAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Queries the server for a list of tools that are available (if any)
/// </summary>
Task<ListToolsResponse> ListToolsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Calls a tool on the server.
/// </summary>
/// <param name="toolName">The name of the tool as given when listing tool definitions.</param>
/// <param name="arguments">Arguments for the tool (if any)</param>
Task<CallToolResponse> CallToolAsync(string toolName, Dictionary<string, object>? arguments = null, CancellationToken cancellationToken = default);
/// <summary>
/// A generic method to send a request to the server and get a response.
/// </summary>
Task<T> SendRequestAsync<T>(JsonRpcRequest request, CancellationToken cancellationToken) where T : class;
/// <summary>
/// Registers a handler for a specific notification method.
/// </summary>
void OnNotification(string method, Func<JsonRpcNotification, Task> handler);
}

View file

@ -0,0 +1,325 @@
// Client/McpClientOptions.cs
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;
/// <summary>
/// Main MCP client implementation that handles protocol lifecycle and message routing.
/// </summary>
public class McpClient : IMcpClient
{
private readonly IMcpTransport _transport;
private readonly McpClientOptions _options;
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests;
private readonly ConcurrentDictionary<string, List<Func<JsonRpcNotification,Task>>> _notificationHandlers;
private int _nextRequestId;
private bool _isInitialized;
private Task? _messageProcessingTask;
private CancellationTokenSource? _cts;
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions().ConfigureForMcp();
/// <summary>
/// Gets whether the client is connected and initialized.
/// </summary>
public bool IsInitialized => _isInitialized;
/// <summary>
/// Gets the server's capabilities after initialization.
/// </summary>
public ServerCapabilities? ServerCapabilities { get; private set; }
/// <summary>
/// Gets information about the server implementation after initialization.
/// </summary>
public Implementation? ServerInfo { get; private set; }
public McpClient(IMcpTransport transport, McpClientOptions options)
{
_transport = transport;
_options = options;
_pendingRequests = new();
_notificationHandlers = new();
_nextRequestId = 1;
}
/// <summary>
/// Connects to the server and performs the initialization sequence.
/// </summary>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (_isInitialized)
{
throw new InvalidOperationException("Client is already initialized");
}
try
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// Connect transport
await _transport.ConnectAsync(_cts.Token).ConfigureAwait(false);
// Start processing messages
_messageProcessingTask = ProcessMessagesAsync(_cts.Token);
// Perform initialization sequence
await InitializeAsync(_cts.Token).ConfigureAwait(false);
_isInitialized = true;
}
catch (Exception e)
{
Debug.WriteLine($"Client connection error: {e}");
await CleanupAsync().ConfigureAwait(false);
throw;
}
}
private async Task InitializeAsync(CancellationToken cancellationToken)
{
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initializationCts.CancelAfter(_options.InitializationTimeout);
try
{
// Send initialize request
var initializeResponse = await SendRequestAsync<InitializeResult>(
new JsonRpcRequest
{
Method = "initialize",
Params = new
{
protocolVersion = _options.ProtocolVersion,
capabilities = _options.Capabilities ?? new ClientCapabilities(),
clientInfo = _options.ClientInfo
}
},
initializationCts.Token
).ConfigureAwait(false);
// Store server information
ServerCapabilities = initializeResponse.Capabilities;
ServerInfo = initializeResponse.ServerInfo;
// Validate protocol version
if (initializeResponse.ProtocolVersion != _options.ProtocolVersion)
{
throw new McpClientException($"Server protocol version mismatch. Expected {_options.ProtocolVersion}, got {initializeResponse.ProtocolVersion}");
}
// Send initialized notification
await SendNotificationAsync(
new JsonRpcNotification
{
Method = "notifications/initialized"
},
initializationCts.Token
).ConfigureAwait(false);
}
catch (OperationCanceledException) when (initializationCts.IsCancellationRequested)
{
throw new McpClientException("Initialization timed out");
}
}
public async Task PingAsync(CancellationToken cancellationToken)
{
await SendRequestAsync<dynamic>(
new JsonRpcRequest
{
Method = "ping"
},
cancellationToken
).ConfigureAwait(false);
}
public async Task<ListToolsResponse> ListToolsAsync(CancellationToken cancellationToken)
{
return await SendRequestAsync<ListToolsResponse>(
new JsonRpcRequest
{
Method = "tools/list",
// No parameters needed for tools/list
},
cancellationToken
).ConfigureAwait(false);
}
public async Task<CallToolResponse> CallToolAsync(string toolName, Dictionary<string, object>? arguments = null,
CancellationToken cancellationToken = default)
{
return await SendRequestAsync<CallToolResponse>(
new JsonRpcRequest
{
Method = "tools/call",
Params = new Dictionary<string, object?>
{
["name"] = toolName,
["arguments"] = arguments
}
},
cancellationToken
).ConfigureAwait(false);
}
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var message in _transport.MessageReader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
await HandleMessageAsync(message).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Normal shutdown
}
catch (NullReferenceException)
{
// Ignore reader disposal and mocked transport
}
}
private async Task HandleMessageAsync(IJsonRpcMessage message)
{
switch (message)
{
case IJsonRpcMessageWithId messageWithId:
if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs))
{
tcs.TrySetResult(message);
}
break;
case JsonRpcNotification notification:
if (_notificationHandlers.TryGetValue(notification.Method, out var handlers))
{
foreach (var handler in handlers)
{
try
{
await handler(notification).ConfigureAwait(false);
}
catch (Exception ex)
{
// Log handler error but continue processing
Debug.WriteLine($"Notification handler error: {ex}");
}
}
}
break;
}
}
public async Task<T> SendRequestAsync<T>(JsonRpcRequest request, CancellationToken cancellationToken) where T : class
{
if (!_transport.IsConnected)
{
throw new McpClientException("Transport is not connected");
}
// Set request ID
request.Id = RequestId.FromNumber(Interlocked.Increment(ref _nextRequestId));
var tcs = new TaskCompletionSource<IJsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests[request.Id] = tcs;
try
{
Debug.WriteLine($"Sending initialize request: {JsonSerializer.Serialize(request)}");
await _transport.SendMessageAsync(request, cancellationToken).ConfigureAwait(false);
var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
if (response is JsonRpcError error)
{
throw new McpClientException($"Request failed: {error.Error.Message}", error.Error.Code);
}
if (response is JsonRpcResponse success)
{
// 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);
if (resultObject != null)
{
return resultObject;
}
Debug.WriteLine($"Received response: {JsonSerializer.Serialize(success.Result)}");
Debug.WriteLine($"Expected type: {typeof(T)}");
throw new McpClientException($"Unexpected response type {JsonSerializer.Serialize(success.Result)}, expected {typeof(T)}");
}
throw new McpClientException("Invalid response type");
}
finally
{
_pendingRequests.TryRemove(request.Id, out _);
}
}
private async Task SendNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken)
{
if (!_transport.IsConnected)
{
throw new McpClientException("Transport is not connected");
}
await _transport.SendMessageAsync(notification, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Registers a handler for notifications of a specific method.
/// </summary>
public void OnNotification(string method, Func<JsonRpcNotification,Task> handler)
{
var handlers = _notificationHandlers.GetOrAdd(method, _ => new());
lock (handlers)
{
handlers.Add(handler);
}
}
private async Task CleanupAsync()
{
_cts?.Cancel();
if (_messageProcessingTask != null)
{
try
{
await _messageProcessingTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Ignore cancellation
}
}
// Complete all pending requests with cancellation
foreach (var (_, tcs) in _pendingRequests)
{
tcs.TrySetCanceled();
}
_pendingRequests.Clear();
await _transport.DisposeAsync().ConfigureAwait(false);
_cts?.Dispose();
_isInitialized = false;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await CleanupAsync().ConfigureAwait(false);
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,26 @@
// Client/McpClientOptions.cs
namespace McpDotNet.Client;
/// <summary>
/// Represents errors that occur in the MCP client.
/// </summary>
public class McpClientException : Exception
{
/// <summary>
/// Gets the error code if this exception was caused by a JSON-RPC error response.
/// </summary>
public int? ErrorCode { get; }
public McpClientException(string message) : base(message)
{
}
public McpClientException(string message, int errorCode) : base(message)
{
ErrorCode = errorCode;
}
public McpClientException(string message, Exception innerException) : base(message, innerException)
{
}
}

View file

@ -0,0 +1,95 @@
// Client/McpClientFactory.cs
namespace McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Transport;
/// <summary>
/// Factory for creating MCP clients based on configuration.
/// </summary>
public class McpClientFactory
{
private readonly Dictionary<string, McpServerConfig> _serverConfigs;
private readonly McpClientOptions _clientOptions;
private readonly Dictionary<string, IMcpClient> _clients = new();
private readonly Func<McpServerConfig, IMcpTransport> _transportFactoryMethod;
private readonly Func<IMcpTransport, McpClientOptions, IMcpClient> _clientFactoryMethod;
public McpClientFactory(
IEnumerable<McpServerConfig> serverConfigs,
McpClientOptions clientOptions,
Func<McpServerConfig, IMcpTransport>? transportFactoryMethod = null,
Func<IMcpTransport, McpClientOptions, IMcpClient>? clientFactoryMethod = null)
{
_serverConfigs = serverConfigs.ToDictionary(c => c.Id);
_clientOptions = clientOptions;
_transportFactoryMethod = transportFactoryMethod ?? CreateTransport;
_clientFactoryMethod = clientFactoryMethod ?? ((transport, options) => new McpClient(transport, options));
}
/// <summary>
/// Gets or creates a client for the specified server.
/// </summary>
public async Task<IMcpClient> GetClientAsync(string serverId, CancellationToken cancellationToken = default)
{
if (!_serverConfigs.TryGetValue(serverId, out var config))
{
throw new ArgumentException($"Server with ID '{serverId}' not found.", nameof(serverId));
}
if (_clients.TryGetValue(serverId, out var existingClient))
{
return existingClient;
}
var transport = _transportFactoryMethod(config);
var client = _clientFactoryMethod(transport, _clientOptions);
await client.ConnectAsync(cancellationToken);
_clients[serverId] = client;
return client;
}
private IMcpTransport CreateTransport(McpServerConfig config)
{
return config.TransportType.ToLowerInvariant() switch
{
"stdio" => new StdioTransport(new StdioTransportOptions
{
Command = GetCommand(config),
Arguments = config.TransportOptions?.GetValueOrDefault("arguments")?.Split(' '),
WorkingDirectory = config.TransportOptions?.GetValueOrDefault("workingDirectory"),
EnvironmentVariables = config.TransportOptions?
.Where(kv => kv.Key.StartsWith("env:"))
.ToDictionary(kv => kv.Key.Substring(4), kv => kv.Value)
}),
// Add other transport types here
_ => throw new ArgumentException($"Unsupported transport type '{config.TransportType}'.", nameof(config))
};
}
private string GetCommand(McpServerConfig config)
{
if (config.TransportOptions == null)
{
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";
}
}

View file

@ -0,0 +1,30 @@
// Client/McpClientOptions.cs
namespace McpDotNet.Client;
using global::McpDotNet.Protocol.Types;
/// <summary>
/// Configuration options for the MCP client.
/// </summary>
public record McpClientOptions
{
/// <summary>
/// Information about this client implementation.
/// </summary>
public required Implementation ClientInfo { get; init; }
/// <summary>
/// Client capabilities to advertise to the server.
/// </summary>
public ClientCapabilities? Capabilities { get; init; }
/// <summary>
/// Protocol version to request from the server.
/// </summary>
public string ProtocolVersion { get; init; } = "2024-11-05";
/// <summary>
/// Timeout for initialization sequence.
/// </summary>
public TimeSpan InitializationTimeout { get; init; } = TimeSpan.FromSeconds(60);
}

View file

@ -0,0 +1,39 @@
// Configuration/McpServerConfig.cs
namespace McpDotNet.Configuration;
/// <summary>
/// Configuration for an MCP server connection.
/// </summary>
public record McpServerConfig
{
/// <summary>
/// Unique identifier for this server configuration.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Display name for the server.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The type of transport to use.
/// </summary>
public required string TransportType { get; init; }
/// <summary>
/// For stdio transport: path to the executable
/// For HTTP transport: base URL of the server
/// </summary>
public string? Location { get; set; }
/// <summary>
/// Arguments (if any) to pass to the executable.
/// </summary>
public string[]? Arguments { get; init; }
/// <summary>
/// Additional transport-specific configuration.
/// </summary>
public Dictionary<string, string>? TransportOptions { get; init; }
}

View file

@ -0,0 +1,188 @@
// Features/LlmIntegration/DefaultMcpToolProvider.cs
namespace McpDotNet.Features.LlmIntegration;
using System.Text.Json;
using mcpdotnet.Features.Tools;
using McpDotNet.Client;
using McpDotNet.Features.Tools;
/// <summary>
/// Default implementation of IMcpToolProvider that handles conversion between MCP and LLM-friendly formats.
/// </summary>
public class DefaultMcpToolProvider : IMcpToolProvider
{
private readonly McpClient _client;
private readonly JsonSerializerOptions _jsonOptions;
public DefaultMcpToolProvider(McpClient client, JsonSerializerOptions? jsonOptions = null)
{
_client = client;
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
/// <inheritdoc/>
public async Task<List<ToolDefinition>> GetToolsAsync(CancellationToken cancellationToken = default)
{
return await _client.ListToolDefsAsync(cancellationToken);
}
/// <inheritdoc/>
public async Task<ToolResult> ExecuteToolCallAsync(
string toolName,
Dictionary<string, object> arguments,
CancellationToken cancellationToken = default)
{
try
{
// Execute tool call
var result = await _client.CallToolAsync(toolName, arguments, cancellationToken);
if (result != null)
{
return new ToolResult()
{
Succeeded = !result.IsError,
Content = result.Content.Select(c => new ToolContent()
{
Type = c.Type,
Value = (c.Data ?? c.Text) ?? "",
MimeType = c.MimeType
}).ToList()
};
}
else
{
return new ToolResult
{
Succeeded = false,
Content = [new ToolContent
{
Type = "error",
Value = "Tool execution failed with no error message"
}],
};
}
}
catch (Exception ex)
{
return new ToolResult
{
Succeeded = false,
Content = [new ToolContent
{
Type = "error",
Value = ex.Message
}],
};
}
}
//private static (Dictionary<string, ToolArgument> Arguments, List<string> Required) ParseInputSchema(ToolInputSchema schema)
//{
// var arguments = new Dictionary<string, ToolArgument>();
// var required = new List<string>();
// if (schema.Properties == null)
// {
// return (arguments, required);
// }
// foreach (var (name, propObj) in schema.Properties)
// {
// // Convert property to JsonElement for easier parsing
// var prop = (propObj as JsonElement?) ?? JsonSerializer.SerializeToElement(propObj);
// if (prop.TryGetProperty("type", out var typeElement))
// {
// var type = typeElement.GetString() ?? "unknown";
// var description = prop.TryGetProperty("description", out var descElement)
// ? descElement.GetString()
// : string.Empty;
// var isRequired = prop.TryGetProperty("required", out var reqElement) && reqElement.GetBoolean();
// if (isRequired)
// {
// required.Add(name);
// }
// string[]? enumValues = null;
// if (type == "string" && prop.TryGetProperty("enum", out var enumElement))
// {
// enumValues = enumElement.EnumerateArray()
// .Select(e => e.GetString())
// .Where(s => s != null)
// .Cast<string>()
// .ToArray();
// }
// arguments[name] = new ToolArgument
// {
// Name = name,
// Type = type,
// Description = description ?? string.Empty,
// Required = isRequired,
// EnumValues = enumValues
// };
// }
// }
// // Also check for required array at schema root
// if (schema.Properties.TryGetValue("required", out var rootRequired))
// {
// var requiredArray = rootRequired as JsonElement?;
// if (requiredArray?.ValueKind == JsonValueKind.Array)
// {
// foreach (var item in requiredArray.Value.EnumerateArray())
// {
// var name = item.GetString();
// if (name != null && !required.Contains(name))
// {
// required.Add(name);
// // Update the argument's required flag if it exists
// if (arguments.TryGetValue(name, out var arg))
// {
// arguments[name] = arg with { Required = true };
// }
// }
// }
// }
// }
// return (arguments, required);
//}
//private static ToolResultContent ConvertContent(IContent content)
//{
// return content switch
// {
// TextContent text => new ToolResultContent
// {
// Type = "text",
// Value = text.Text,
// },
// ImageContent image => new ToolResultContent
// {
// Type = "image",
// Value = image.Data,
// MimeType = image.MimeType
// },
// _ => new ToolResultContent
// {
// Type = "unknown",
// Value = content.ToString() ?? string.Empty
// }
// };
//}
//private static string ExtractErrorMessage(IReadOnlyList<IContent> content)
//{
// // Try to extract error message from content, defaulting to first text content if available
// var textContent = content.OfType<TextContent>().FirstOrDefault();
// return textContent?.Text ?? "Tool execution failed with no error message";
//}
}

View file

@ -0,0 +1,23 @@
// Features/LlmIntegration/IMcpToolProvider.cs
using mcpdotnet.Features.Tools;
namespace McpDotNet.Features.LlmIntegration;
/// <summary>
/// Provides MCP tool information and execution capabilities to an LLM.
/// </summary>
public interface IMcpToolProvider
{
/// <summary>
/// Gets the available tools and their descriptions.
/// </summary>
Task<List<ToolDefinition>> GetToolsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Executes a tool call.
/// </summary>
Task<ToolResult> ExecuteToolCallAsync(
string toolName,
Dictionary<string, object> arguments,
CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,115 @@
using System.Text.Json;
using mcpdotnet.Features.Tools;
using McpDotNet.Protocol.Types;
namespace McpDotNet.Features.Tools;
internal static class ToolMapper
{
public static ToolDefinition ToToolDefinition(Tool protocolTool)
{
ArgumentNullException.ThrowIfNull(protocolTool);
ArgumentNullException.ThrowIfNull(protocolTool.InputSchema);
var parameters = new Dictionary<string, ToolParameter>();
if (protocolTool.InputSchema.Properties != null)
{
foreach (var property in protocolTool.InputSchema.Properties)
{
// Convert the dynamic property value to a JsonElement to access its properties
var propertySchema = property.Value;
parameters[property.Key] = new ToolParameter
{
Name = property.Key,
Type = propertySchema.Type,
Description = propertySchema.Description,
Required = IsPropertyRequired(protocolTool.InputSchema, property.Key),
AllowedValues = GetEnumValues(propertySchema)
};
}
}
return new ToolDefinition
{
Name = protocolTool.Name,
Description = protocolTool.Description ?? string.Empty,
Parameters = parameters
};
}
public static ToolResult ToToolResult(CallToolResponse protocolResult)
{
ArgumentNullException.ThrowIfNull(protocolResult);
return new ToolResult
{
Succeeded = protocolResult.IsError,
Content = protocolResult.Content?.Select(ToToolContent).ToList(),
ErrorMessage = protocolResult.IsError == true ?
GetErrorMessage(protocolResult.Content) : null
};
}
private static mcpdotnet.Features.Tools.ToolContent ToToolContent(Protocol.Types.ToolContent c)
{
return new mcpdotnet.Features.Tools.ToolContent()
{
Type = c.Type,
Value = (c.Data ?? c.Text) ?? "",
MimeType = c.MimeType
};
}
private static string GetSchemaType(JsonElement schema)
{
if (!schema.TryGetProperty("type", out var typeElement))
{
return "object";
}
return typeElement.GetString() ?? "object";
}
private static string GetSchemaDescription(JsonElement schema)
{
if (!schema.TryGetProperty("description", out var descElement))
{
return string.Empty;
}
return descElement.GetString() ?? string.Empty;
}
private static bool IsPropertyRequired(JsonSchema? schema, string propertyName)
{
if (schema?.Required != null && schema.Required.Contains(propertyName))
{
return true;
}
return false;
}
private static string[]? GetEnumValues(JsonSchemaProperty? schema)
{
// TODO
return null;
}
private static string GetErrorMessage(IReadOnlyList<Protocol.Types.ToolContent>? contents)
{
if (contents == null || contents.Count == 0)
{
return "Unknown error";
}
// Concatenate all text content as the error message
var errorMessages = contents
.Where(c => !string.IsNullOrEmpty(c.Text))
.Select(c => c.Text);
return string.Join(" ", errorMessages);
}
}

View file

@ -0,0 +1,61 @@
using mcpdotnet.Features.Tools;
using McpDotNet.Client;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Types;
namespace McpDotNet.Features.Tools;
/// <summary>
/// Extension methods for working with MCP tools.
/// </summary>
public static class ToolsExtensions
{
/// <summary>
/// Lists all available tools from the server.
/// </summary>
public static async Task<List<ToolDefinition>> ListToolDefsAsync(
this McpClient client,
CancellationToken cancellationToken = default)
{
if (client.ServerCapabilities?.Tools == null)
{
throw new McpClientException("Server does not support tools");
}
var response = await client.ListToolsAsync(cancellationToken);
return response.Tools
.Select(ToolMapper.ToToolDefinition)
.ToList();
}
/// <summary>
/// Calls a tool with the specified arguments.
/// </summary>
public static async Task<ToolResult> CallToolAsync(
this McpClient client,
string toolName,
object? arguments = null,
CancellationToken cancellationToken = default)
{
if (client.ServerCapabilities?.Tools == null)
{
throw new McpClientException("Server does not support tools");
}
var response = await client.SendRequestAsync<CallToolResponse>(
new JsonRpcRequest
{
Method = "tools/call",
Params = new
{
name = toolName,
arguments = arguments
}
},
cancellationToken
);
return ToolMapper.ToToolResult(response);
}
}

View file

@ -0,0 +1,74 @@
// Features/Tools/ToolTypes.cs
namespace mcpdotnet.Features.Tools;
/// <summary>
/// Represents a tool available from an MCP server.
/// </summary>
public record ToolDefinition
{
public required string Name { get; init; }
public required string Description { get; init; }
public required Dictionary<string, ToolParameter> Parameters { get; init; }
}
/// <summary>
/// Represents a tool parameter.
/// </summary>
public record ToolParameter
{
public required string Name { get; init; }
public required string Type { get; init; }
public required string Description { get; init; }
public bool Required { get; init; }
public string[]? AllowedValues { get; init; }
}
/// <summary>
/// Result of a tool execution, from server to client
public record McpToolCallResponse
{
// TODO
}
/// <summary>
/// Result of a tool execution, from client to host.
/// </summary>
public record ToolResult
{
public required bool Succeeded { get; init; }
public List<ToolContent>? Content { get; init; }
public string? ErrorMessage { get; init; }
}
/// <summary>
/// Content returned by a tool execution.
/// </summary>
public record ToolContent
{
/// <summary>
/// The type of content.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// The content value.
/// </summary>
public required object Value { get; init; }
/// <summary>
/// Optional MIME type for binary content.
/// </summary>
public string? MimeType { get; init; }
}
/// <summary>
/// A list of tools available from an MCP server.
/// </summary>
public record ToolDefinitionList
{
/// <summary>
/// The list of tools.
/// </summary>
public required List<ToolDefinition> Tools { get; init; }
}

View file

@ -0,0 +1,32 @@
namespace McpDotNet.Protocol.Messages;
/// <summary>
/// Standard JSON-RPC error codes as defined in the MCP specification.
/// </summary>
public static class ErrorCodes
{
/// <summary>
/// Invalid JSON was received by the server.
/// </summary>
public const int ParseError = -32700;
/// <summary>
/// The JSON sent is not a valid Request object.
/// </summary>
public const int InvalidRequest = -32600;
/// <summary>
/// The method does not exist / is not available.
/// </summary>
public const int MethodNotFound = -32601;
/// <summary>
/// Invalid method parameter(s).
/// </summary>
public const int InvalidParams = -32602;
/// <summary>
/// Internal JSON-RPC error.
/// </summary>
public const int InternalError = -32603;
}

View file

@ -0,0 +1,151 @@
namespace McpDotNet.Protocol.Messages;
using System.Text.Json.Serialization;
/// <summary>
/// Base interface for all JSON-RPC messages in the MCP protocol.
/// </summary>
public interface IJsonRpcMessage
{
/// <summary>
/// JSON-RPC protocol version. Must be "2.0".
/// </summary>
string JsonRpc { get; }
}
/// <summary>
/// Base interface for JSON-RPC messages that include an ID.
/// </summary>
public interface IJsonRpcMessageWithId : IJsonRpcMessage
{
/// <summary>
/// The message identifier.
/// </summary>
RequestId Id { get; }
}
/// <summary>
/// A request message in the JSON-RPC protocol.
/// </summary>
public record JsonRpcRequest : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Request identifier. Must be a string or number and unique within the session.
/// </summary>
[JsonPropertyName("id")]
public RequestId Id { get; set; }
/// <summary>
/// Name of the method to invoke.
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; init; }
/// <summary>
/// Optional parameters for the method.
/// </summary>
[JsonPropertyName("params")]
public object? Params { get; init; }
}
/// <summary>
/// A notification message in the JSON-RPC protocol (a request that doesn't expect a response).
/// </summary>
public record JsonRpcNotification : IJsonRpcMessage
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Name of the notification method.
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; init; }
/// <summary>
/// Optional parameters for the notification.
/// </summary>
[JsonPropertyName("params")]
public object? Params { get; init; }
}
/// <summary>
/// A successful response message in the JSON-RPC protocol.
/// </summary>
public record JsonRpcResponse : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Request identifier matching the original request.
/// </summary>
[JsonPropertyName("id")]
public required RequestId Id { get; init; }
/// <summary>
/// The result of the method invocation.
/// </summary>
[JsonPropertyName("result")]
public required object Result { get; init; }
}
/// <summary>
/// An error response message in the JSON-RPC protocol.
/// </summary>
public record JsonRpcError : IJsonRpcMessageWithId
{
/// <summary>
/// JSON-RPC protocol version. Always "2.0".
/// </summary>
[JsonPropertyName("jsonrpc")]
public string JsonRpc { get; init; } = "2.0";
/// <summary>
/// Request identifier matching the original request.
/// </summary>
[JsonPropertyName("id")]
public required RequestId Id { get; init; }
/// <summary>
/// Error information.
/// </summary>
[JsonPropertyName("error")]
public required JsonRpcErrorDetail Error { get; init; }
}
/// <summary>
/// Detailed error information for JSON-RPC error responses.
/// </summary>
public record JsonRpcErrorDetail
{
/// <summary>
/// Integer error code.
/// </summary>
[JsonPropertyName("code")]
public required int Code { get; init; }
/// <summary>
/// Short description of the error.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>
/// Optional additional error data.
/// </summary>
[JsonPropertyName("data")]
public object? Data { get; init; }
}

View file

@ -0,0 +1,63 @@
namespace McpDotNet.Protocol.Messages;
using System.Text.Json;
using System.Text.Json.Serialization;
/// <summary>
/// Represents a JSON-RPC request identifier which can be either a string or a number.
/// </summary>
[JsonConverter(typeof(RequestIdConverter))]
public readonly struct RequestId : IEquatable<RequestId>
{
private readonly object _value;
private RequestId(object value)
{
_value = value;
}
public static RequestId FromString(string value) => new(value);
public static RequestId FromNumber(long value) => new(value);
public bool IsString => _value is string;
public bool IsNumber => _value is long;
public string AsString => _value as string ?? throw new InvalidOperationException("RequestId is not a string");
public long AsNumber => _value is long number ? number : throw new InvalidOperationException("RequestId is not a number");
public override string ToString() => _value.ToString() ?? "";
public bool Equals(RequestId other) => _value.Equals(other._value);
public override bool Equals(object? obj) => obj is RequestId other && Equals(other);
public override int GetHashCode() => _value.GetHashCode();
public static bool operator ==(RequestId left, RequestId right) => left.Equals(right);
public static bool operator !=(RequestId left, RequestId right) => !left.Equals(right);
}
/// <summary>
/// JSON converter for RequestId that handles both string and number values.
/// </summary>
public class RequestIdConverter : JsonConverter<RequestId>
{
public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.String:
return RequestId.FromString(reader.GetString()!);
case JsonTokenType.Number:
return RequestId.FromNumber(reader.GetInt64());
default:
throw new JsonException("RequestId must be either a string or a number");
}
}
public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options)
{
if (value.IsString)
writer.WriteStringValue(value.AsString);
else
writer.WriteNumberValue(value.AsNumber);
}
}

View file

@ -0,0 +1,34 @@
// Protocol/Transport/IMcpTransport.cs
namespace McpDotNet.Protocol.Transport;
using System.Threading.Channels;
using global::McpDotNet.Protocol.Messages;
/// <summary>
/// Represents a transport mechanism for MCP communication.
/// </summary>
public interface IMcpTransport : IAsyncDisposable
{
/// <summary>
/// Gets whether the transport is currently connected.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Channel for receiving messages from the transport.
/// </summary>
ChannelReader<IJsonRpcMessage> MessageReader { get; }
/// <summary>
/// Establishes the transport connection.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a message through the transport.
/// </summary>
/// <param name="message">The message to send.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default);
}

Some files were not shown because too many files have changed in this diff Show more