Initial commit

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

60
.gitignore vendored Normal file
View file

@ -0,0 +1,60 @@
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio files
.vs/
*.user
*.userosscache
*.sln.docstates
*.userprefs
# ReSharper
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# NuGet
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# VS Code files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Peder Holdgaard Pedersen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
README.MD Normal file
View file

@ -0,0 +1,41 @@
# mcpdotnet
A .NET client implementation of the Model Context Protocol (MCP), enabling .NET applications to connect to and interact with MCP servers.
## About MCP
The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context to Large Language Models (LLMs). It enables secure integration between LLMs and various data sources and tools.
For more information about MCP:
- [Official Documentation](https://modelcontextprotocol.io/)
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
## Status
This project is in early development. While functional, it may undergo significant changes as the protocol and implementation evolve.
### Current Limitations
- Only supports the Tool capability (Resources, Prompts, Sampling, and Roots not yet implemented)
- Only stdio transport is currently supported (HTTP/SSE transport coming soon)
- Client library only - server implementation support may be added in the future
- Provider-agnostic implementation focused purely on the MCP protocol
- Applications using this library need to implement their own integration with LLM APIs/clients
The library's current focus is on implementing the core MCP protocol, with the main utility being the ability to launch and connect to MCP servers based on configuration objects.
## Features
- MCP client implementation for .NET applications
- Support for stdio transport
- Tool discovery and execution
- Async/await pattern throughout
- Compatible with .NET 8.0 and later
## Getting Started
Documentation and examples are coming soon. For now, please refer to the test project for usage examples.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

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;
}

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