commit fdbd94b3f9fd5823b932fb5913cbe66ef7881c8a Author: PederHP Date: Mon Dec 30 20:30:31 2024 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09856b4 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..342f471 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..13f4598 --- /dev/null +++ b/README.MD @@ -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. \ No newline at end of file diff --git a/src/mcpdotnet.Tests/GlobalUsings.cs b/src/mcpdotnet.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/src/mcpdotnet.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/src/mcpdotnet.Tests/IntegrationTests.cs b/src/mcpdotnet.Tests/IntegrationTests.cs new file mode 100644 index 0000000..2b06a6e --- /dev/null +++ b/src/mcpdotnet.Tests/IntegrationTests.cs @@ -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 + { + ["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 + { + ["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 + { + ["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 + { + ["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); + } +} diff --git a/src/mcpdotnet.Tests/McpClientFactoryTests.cs b/src/mcpdotnet.Tests/McpClientFactoryTests.cs new file mode 100644 index 0000000..e728751 --- /dev/null +++ b/src/mcpdotnet.Tests/McpClientFactoryTests.cs @@ -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 + { + ["arguments"] = "--test arg", + ["workingDirectory"] = "/working/dir" + } + }; + + // Create a mock transport + var mockTransport = new Mock(); + mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockTransport.Setup(t => t.IsConnected).Returns(true); + mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of>()); + + // Create a mock client + var mockClient = new Mock(); + mockClient.Setup(c => c.ConnectAsync(It.IsAny())) + .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(); + mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockTransport.Setup(t => t.IsConnected).Returns(true); + mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of>()); + + // Create a mock client + var mockClient = new Mock(); + mockClient.Setup(c => c.ConnectAsync(It.IsAny())) + .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(), _defaultOptions); + + // Act & Assert + await Assert.ThrowsAsync( + () => 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( + () => 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(); + mockTransport.Setup(t => t.ConnectAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockTransport.Setup(t => t.IsConnected).Returns(true); + mockTransport.Setup(t => t.MessageReader).Returns(Mock.Of>()); + + // Create a mock client + var mockClient = new Mock(); + mockClient.Setup(c => c.ConnectAsync(It.IsAny())) + .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(() => 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( + () => factory.GetClientAsync("test-server") + ); + } +} \ No newline at end of file diff --git a/src/mcpdotnet.Tests/mcpdotnet.Tests.csproj b/src/mcpdotnet.Tests/mcpdotnet.Tests.csproj new file mode 100644 index 0000000..aeab76a --- /dev/null +++ b/src/mcpdotnet.Tests/mcpdotnet.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/mcpdotnet.sln b/src/mcpdotnet.sln new file mode 100644 index 0000000..874f1b3 --- /dev/null +++ b/src/mcpdotnet.sln @@ -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 diff --git a/src/mcpdotnet/Client/IMcpClient.cs b/src/mcpdotnet/Client/IMcpClient.cs new file mode 100644 index 0000000..6d2daa4 --- /dev/null +++ b/src/mcpdotnet/Client/IMcpClient.cs @@ -0,0 +1,57 @@ +namespace McpDotNet.Client; + +using McpDotNet.Protocol.Types; +using McpDotNet.Protocol.Messages; + +/// +/// Represents an instance of the MCP client connecting to a specific server. +/// +public interface IMcpClient : IAsyncDisposable +{ + /// + /// Gets a value indicating whether the client has been initialized. + /// + bool IsInitialized { get; } + + /// + /// Gets the capabilities that the server supports. + /// + ServerCapabilities? ServerCapabilities { get; } + + /// + /// Gets the version information of the server. + /// + Implementation? ServerInfo { get; } + + /// + /// Connects to the server. + /// + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// + /// A simple ping to the server to check if it is alive. Task completes when the ping is successful. + /// + Task PingAsync(CancellationToken cancellationToken = default); + + /// + /// Queries the server for a list of tools that are available (if any) + /// + Task ListToolsAsync(CancellationToken cancellationToken = default); + + /// + /// Calls a tool on the server. + /// + /// The name of the tool as given when listing tool definitions. + /// Arguments for the tool (if any) + Task CallToolAsync(string toolName, Dictionary? arguments = null, CancellationToken cancellationToken = default); + + /// + /// A generic method to send a request to the server and get a response. + /// + Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class; + + /// + /// Registers a handler for a specific notification method. + /// + void OnNotification(string method, Func handler); +} diff --git a/src/mcpdotnet/Client/McpClient.cs b/src/mcpdotnet/Client/McpClient.cs new file mode 100644 index 0000000..f0b56fb --- /dev/null +++ b/src/mcpdotnet/Client/McpClient.cs @@ -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; + +/// +/// Main MCP client implementation that handles protocol lifecycle and message routing. +/// +public class McpClient : IMcpClient +{ + private readonly IMcpTransport _transport; + private readonly McpClientOptions _options; + private readonly ConcurrentDictionary> _pendingRequests; + private readonly ConcurrentDictionary>> _notificationHandlers; + private int _nextRequestId; + private bool _isInitialized; + private Task? _messageProcessingTask; + private CancellationTokenSource? _cts; + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(); + + /// + /// Gets whether the client is connected and initialized. + /// + public bool IsInitialized => _isInitialized; + + /// + /// Gets the server's capabilities after initialization. + /// + public ServerCapabilities? ServerCapabilities { get; private set; } + + /// + /// Gets information about the server implementation after initialization. + /// + public Implementation? ServerInfo { get; private set; } + + public McpClient(IMcpTransport transport, McpClientOptions options) + { + _transport = transport; + _options = options; + _pendingRequests = new(); + _notificationHandlers = new(); + _nextRequestId = 1; + } + + /// + /// Connects to the server and performs the initialization sequence. + /// + 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( + 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( + new JsonRpcRequest + { + Method = "ping" + }, + cancellationToken + ).ConfigureAwait(false); + } + + public async Task ListToolsAsync(CancellationToken cancellationToken) + { + return await SendRequestAsync( + new JsonRpcRequest + { + Method = "tools/list", + // No parameters needed for tools/list + }, + cancellationToken + ).ConfigureAwait(false); + } + + public async Task CallToolAsync(string toolName, Dictionary? arguments = null, + CancellationToken cancellationToken = default) + { + return await SendRequestAsync( + new JsonRpcRequest + { + Method = "tools/call", + Params = new Dictionary + { + ["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 SendRequestAsync(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(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(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); + } + + /// + /// Registers a handler for notifications of a specific method. + /// + public void OnNotification(string method, Func 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; + } + + /// + public async ValueTask DisposeAsync() + { + await CleanupAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } +} diff --git a/src/mcpdotnet/Client/McpClientException.cs b/src/mcpdotnet/Client/McpClientException.cs new file mode 100644 index 0000000..900542f --- /dev/null +++ b/src/mcpdotnet/Client/McpClientException.cs @@ -0,0 +1,26 @@ +// Client/McpClientOptions.cs +namespace McpDotNet.Client; + +/// +/// Represents errors that occur in the MCP client. +/// +public class McpClientException : Exception +{ + /// + /// Gets the error code if this exception was caused by a JSON-RPC error response. + /// + 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) + { + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Client/McpClientFactory.cs b/src/mcpdotnet/Client/McpClientFactory.cs new file mode 100644 index 0000000..2561907 --- /dev/null +++ b/src/mcpdotnet/Client/McpClientFactory.cs @@ -0,0 +1,95 @@ +// Client/McpClientFactory.cs +namespace McpDotNet.Client; + +using McpDotNet.Configuration; +using McpDotNet.Protocol.Transport; + +/// +/// Factory for creating MCP clients based on configuration. +/// +public class McpClientFactory +{ + private readonly Dictionary _serverConfigs; + private readonly McpClientOptions _clientOptions; + private readonly Dictionary _clients = new(); + private readonly Func _transportFactoryMethod; + private readonly Func _clientFactoryMethod; + + public McpClientFactory( + IEnumerable serverConfigs, + McpClientOptions clientOptions, + Func? transportFactoryMethod = null, + Func? clientFactoryMethod = null) + { + _serverConfigs = serverConfigs.ToDictionary(c => c.Id); + _clientOptions = clientOptions; + _transportFactoryMethod = transportFactoryMethod ?? CreateTransport; + _clientFactoryMethod = clientFactoryMethod ?? ((transport, options) => new McpClient(transport, options)); + } + + /// + /// Gets or creates a client for the specified server. + /// + public async Task 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"; + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Client/McpClientOptions.cs b/src/mcpdotnet/Client/McpClientOptions.cs new file mode 100644 index 0000000..00d6475 --- /dev/null +++ b/src/mcpdotnet/Client/McpClientOptions.cs @@ -0,0 +1,30 @@ +// Client/McpClientOptions.cs +namespace McpDotNet.Client; + +using global::McpDotNet.Protocol.Types; + +/// +/// Configuration options for the MCP client. +/// +public record McpClientOptions +{ + /// + /// Information about this client implementation. + /// + public required Implementation ClientInfo { get; init; } + + /// + /// Client capabilities to advertise to the server. + /// + public ClientCapabilities? Capabilities { get; init; } + + /// + /// Protocol version to request from the server. + /// + public string ProtocolVersion { get; init; } = "2024-11-05"; + + /// + /// Timeout for initialization sequence. + /// + public TimeSpan InitializationTimeout { get; init; } = TimeSpan.FromSeconds(60); +} diff --git a/src/mcpdotnet/Configuration/McpServerConfig.cs b/src/mcpdotnet/Configuration/McpServerConfig.cs new file mode 100644 index 0000000..f245abd --- /dev/null +++ b/src/mcpdotnet/Configuration/McpServerConfig.cs @@ -0,0 +1,39 @@ +// Configuration/McpServerConfig.cs +namespace McpDotNet.Configuration; + +/// +/// Configuration for an MCP server connection. +/// +public record McpServerConfig +{ + /// + /// Unique identifier for this server configuration. + /// + public required string Id { get; init; } + + /// + /// Display name for the server. + /// + public required string Name { get; init; } + + /// + /// The type of transport to use. + /// + public required string TransportType { get; init; } + + /// + /// For stdio transport: path to the executable + /// For HTTP transport: base URL of the server + /// + public string? Location { get; set; } + + /// + /// Arguments (if any) to pass to the executable. + /// + public string[]? Arguments { get; init; } + + /// + /// Additional transport-specific configuration. + /// + public Dictionary? TransportOptions { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Features/LlmIntegration/DefaultMcpToolProvider.cs b/src/mcpdotnet/Features/LlmIntegration/DefaultMcpToolProvider.cs new file mode 100644 index 0000000..ba92f9c --- /dev/null +++ b/src/mcpdotnet/Features/LlmIntegration/DefaultMcpToolProvider.cs @@ -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; + +/// +/// Default implementation of IMcpToolProvider that handles conversion between MCP and LLM-friendly formats. +/// +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 + }; + } + + /// + public async Task> GetToolsAsync(CancellationToken cancellationToken = default) + { + return await _client.ListToolDefsAsync(cancellationToken); + } + + /// + public async Task ExecuteToolCallAsync( + string toolName, + Dictionary 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 Arguments, List Required) ParseInputSchema(ToolInputSchema schema) + //{ + // var arguments = new Dictionary(); + // var required = new List(); + + // 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() + // .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 content) + //{ + // // Try to extract error message from content, defaulting to first text content if available + // var textContent = content.OfType().FirstOrDefault(); + // return textContent?.Text ?? "Tool execution failed with no error message"; + //} +} \ No newline at end of file diff --git a/src/mcpdotnet/Features/LlmIntegration/IMcpToolProvider.cs b/src/mcpdotnet/Features/LlmIntegration/IMcpToolProvider.cs new file mode 100644 index 0000000..bab0503 --- /dev/null +++ b/src/mcpdotnet/Features/LlmIntegration/IMcpToolProvider.cs @@ -0,0 +1,23 @@ +// Features/LlmIntegration/IMcpToolProvider.cs +using mcpdotnet.Features.Tools; + +namespace McpDotNet.Features.LlmIntegration; + +/// +/// Provides MCP tool information and execution capabilities to an LLM. +/// +public interface IMcpToolProvider +{ + /// + /// Gets the available tools and their descriptions. + /// + Task> GetToolsAsync(CancellationToken cancellationToken = default); + + /// + /// Executes a tool call. + /// + Task ExecuteToolCallAsync( + string toolName, + Dictionary arguments, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/mcpdotnet/Features/Tools/ToolMapper.cs b/src/mcpdotnet/Features/Tools/ToolMapper.cs new file mode 100644 index 0000000..421253c --- /dev/null +++ b/src/mcpdotnet/Features/Tools/ToolMapper.cs @@ -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(); + + 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? 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); + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Features/Tools/ToolsExtensions.cs b/src/mcpdotnet/Features/Tools/ToolsExtensions.cs new file mode 100644 index 0000000..efb65fe --- /dev/null +++ b/src/mcpdotnet/Features/Tools/ToolsExtensions.cs @@ -0,0 +1,61 @@ +using mcpdotnet.Features.Tools; +using McpDotNet.Client; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Types; + +namespace McpDotNet.Features.Tools; + +/// +/// Extension methods for working with MCP tools. +/// +public static class ToolsExtensions +{ + /// + /// Lists all available tools from the server. + /// + public static async Task> 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(); + } + + /// + /// Calls a tool with the specified arguments. + /// + public static async Task 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( + new JsonRpcRequest + { + Method = "tools/call", + Params = new + { + name = toolName, + arguments = arguments + } + }, + cancellationToken + ); + + return ToolMapper.ToToolResult(response); + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Features/Tools/ToolsTypes.cs b/src/mcpdotnet/Features/Tools/ToolsTypes.cs new file mode 100644 index 0000000..740ccc1 --- /dev/null +++ b/src/mcpdotnet/Features/Tools/ToolsTypes.cs @@ -0,0 +1,74 @@ +// Features/Tools/ToolTypes.cs +namespace mcpdotnet.Features.Tools; + +/// +/// Represents a tool available from an MCP server. +/// +public record ToolDefinition +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required Dictionary Parameters { get; init; } +} + + +/// +/// Represents a tool parameter. +/// +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; } +} + +/// +/// Result of a tool execution, from server to client +public record McpToolCallResponse +{ + // TODO +} + +/// +/// Result of a tool execution, from client to host. +/// +public record ToolResult +{ + public required bool Succeeded { get; init; } + public List? Content { get; init; } + public string? ErrorMessage { get; init; } +} + +/// +/// Content returned by a tool execution. +/// +public record ToolContent +{ + /// + /// The type of content. + /// + public required string Type { get; init; } + + /// + /// The content value. + /// + public required object Value { get; init; } + + /// + /// Optional MIME type for binary content. + /// + public string? MimeType { get; init; } +} + +/// +/// A list of tools available from an MCP server. +/// +public record ToolDefinitionList +{ + /// + /// The list of tools. + /// + public required List Tools { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Messages/ErrorCodes.cs b/src/mcpdotnet/Protocol/Messages/ErrorCodes.cs new file mode 100644 index 0000000..db24fad --- /dev/null +++ b/src/mcpdotnet/Protocol/Messages/ErrorCodes.cs @@ -0,0 +1,32 @@ +namespace McpDotNet.Protocol.Messages; + +/// +/// Standard JSON-RPC error codes as defined in the MCP specification. +/// +public static class ErrorCodes +{ + /// + /// Invalid JSON was received by the server. + /// + public const int ParseError = -32700; + + /// + /// The JSON sent is not a valid Request object. + /// + public const int InvalidRequest = -32600; + + /// + /// The method does not exist / is not available. + /// + public const int MethodNotFound = -32601; + + /// + /// Invalid method parameter(s). + /// + public const int InvalidParams = -32602; + + /// + /// Internal JSON-RPC error. + /// + public const int InternalError = -32603; +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Messages/JsonRpcMessage.cs b/src/mcpdotnet/Protocol/Messages/JsonRpcMessage.cs new file mode 100644 index 0000000..b5b5681 --- /dev/null +++ b/src/mcpdotnet/Protocol/Messages/JsonRpcMessage.cs @@ -0,0 +1,151 @@ +namespace McpDotNet.Protocol.Messages; + +using System.Text.Json.Serialization; + +/// +/// Base interface for all JSON-RPC messages in the MCP protocol. +/// +public interface IJsonRpcMessage +{ + /// + /// JSON-RPC protocol version. Must be "2.0". + /// + string JsonRpc { get; } +} + +/// +/// Base interface for JSON-RPC messages that include an ID. +/// +public interface IJsonRpcMessageWithId : IJsonRpcMessage +{ + /// + /// The message identifier. + /// + RequestId Id { get; } +} + +/// +/// A request message in the JSON-RPC protocol. +/// +public record JsonRpcRequest : IJsonRpcMessageWithId +{ + /// + /// JSON-RPC protocol version. Always "2.0". + /// + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = "2.0"; + + /// + /// Request identifier. Must be a string or number and unique within the session. + /// + [JsonPropertyName("id")] + public RequestId Id { get; set; } + + /// + /// Name of the method to invoke. + /// + [JsonPropertyName("method")] + public required string Method { get; init; } + + /// + /// Optional parameters for the method. + /// + [JsonPropertyName("params")] + public object? Params { get; init; } +} + +/// +/// A notification message in the JSON-RPC protocol (a request that doesn't expect a response). +/// +public record JsonRpcNotification : IJsonRpcMessage +{ + /// + /// JSON-RPC protocol version. Always "2.0". + /// + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = "2.0"; + + /// + /// Name of the notification method. + /// + [JsonPropertyName("method")] + public required string Method { get; init; } + + /// + /// Optional parameters for the notification. + /// + [JsonPropertyName("params")] + public object? Params { get; init; } +} + +/// +/// A successful response message in the JSON-RPC protocol. +/// +public record JsonRpcResponse : IJsonRpcMessageWithId +{ + /// + /// JSON-RPC protocol version. Always "2.0". + /// + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = "2.0"; + + /// + /// Request identifier matching the original request. + /// + [JsonPropertyName("id")] + public required RequestId Id { get; init; } + + /// + /// The result of the method invocation. + /// + [JsonPropertyName("result")] + public required object Result { get; init; } +} + +/// +/// An error response message in the JSON-RPC protocol. +/// +public record JsonRpcError : IJsonRpcMessageWithId +{ + /// + /// JSON-RPC protocol version. Always "2.0". + /// + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = "2.0"; + + /// + /// Request identifier matching the original request. + /// + [JsonPropertyName("id")] + public required RequestId Id { get; init; } + + /// + /// Error information. + /// + [JsonPropertyName("error")] + public required JsonRpcErrorDetail Error { get; init; } +} + +/// +/// Detailed error information for JSON-RPC error responses. +/// +public record JsonRpcErrorDetail +{ + /// + /// Integer error code. + /// + [JsonPropertyName("code")] + public required int Code { get; init; } + + /// + /// Short description of the error. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } + + /// + /// Optional additional error data. + /// + [JsonPropertyName("data")] + public object? Data { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Messages/RequestId.cs b/src/mcpdotnet/Protocol/Messages/RequestId.cs new file mode 100644 index 0000000..0a53c8a --- /dev/null +++ b/src/mcpdotnet/Protocol/Messages/RequestId.cs @@ -0,0 +1,63 @@ +namespace McpDotNet.Protocol.Messages; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Represents a JSON-RPC request identifier which can be either a string or a number. +/// +[JsonConverter(typeof(RequestIdConverter))] +public readonly struct RequestId : IEquatable +{ + 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); +} + +/// +/// JSON converter for RequestId that handles both string and number values. +/// +public class RequestIdConverter : JsonConverter +{ + 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); + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Transport/IMcpTransport.cs b/src/mcpdotnet/Protocol/Transport/IMcpTransport.cs new file mode 100644 index 0000000..140c61b --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/IMcpTransport.cs @@ -0,0 +1,34 @@ +// Protocol/Transport/IMcpTransport.cs +namespace McpDotNet.Protocol.Transport; + +using System.Threading.Channels; +using global::McpDotNet.Protocol.Messages; + +/// +/// Represents a transport mechanism for MCP communication. +/// +public interface IMcpTransport : IAsyncDisposable +{ + /// + /// Gets whether the transport is currently connected. + /// + bool IsConnected { get; } + + /// + /// Channel for receiving messages from the transport. + /// + ChannelReader MessageReader { get; } + + /// + /// Establishes the transport connection. + /// + /// Token to cancel the operation. + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// + /// Sends a message through the transport. + /// + /// The message to send. + /// Token to cancel the operation. + Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/mcpdotnet/Protocol/Transport/McpTransportException.cs b/src/mcpdotnet/Protocol/Transport/McpTransportException.cs new file mode 100644 index 0000000..1d23bfe --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/McpTransportException.cs @@ -0,0 +1,37 @@ +// Protocol/Transport/IMcpTransport.cs +namespace McpDotNet.Protocol.Transport; + +// Protocol/Transport/McpTransportException.cs + +/// +/// Represents errors that occur in MCP transport operations. +/// +public class McpTransportException : Exception +{ + /// + /// Initializes a new instance of the McpTransportException class. + /// + public McpTransportException() + { + } + + /// + /// Initializes a new instance of the McpTransportException class with a specified error message. + /// + /// The message that describes the error. + public McpTransportException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the McpTransportException class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public McpTransportException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/mcpdotnet/Protocol/Transport/StdioTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioTransport.cs new file mode 100644 index 0000000..43a172c --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/StdioTransport.cs @@ -0,0 +1,259 @@ +// Protocol/Transport/StdioTransport.cs +namespace McpDotNet.Protocol.Transport; + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Threading; +using McpDotNet.Protocol.Messages; +using McpDotNet.Utils.Json; + +/// +/// Represents configuration options for the stdio transport. +/// +public record StdioTransportOptions +{ + public required string Command { get; set; } + + public string[]? Arguments { get; set; } = Array.Empty(); + + public string? WorkingDirectory { get; set; } + + public Dictionary? EnvironmentVariables { get; set; } + + public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(5); +} + +/// +/// Implements the MCP transport protocol over standard input/output streams. +/// +public sealed class StdioTransport : TransportBase +{ + private readonly StdioTransportOptions _options; + private readonly JsonSerializerOptions _jsonOptions; + private Process? _process; + private Task? _readTask; + private CancellationTokenSource? _shutdownCts; + private bool _processStarted; + + /// + /// Initializes a new instance of the StdioTransport class. + /// + /// Configuration options for the transport. + public StdioTransport(StdioTransportOptions options) + { + _options = options; + _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(); + } + + /// + public override async Task ConnectAsync(CancellationToken cancellationToken = default) + { + if (IsConnected) + { + throw new McpTransportException("Transport is already connected"); + } + + try + { + _shutdownCts = new CancellationTokenSource(); + + var startInfo = new ProcessStartInfo + { + FileName = _options.Command, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory, + }; + + if (_options.Arguments != null) + { + foreach (var arg in _options.Arguments) + { + startInfo.ArgumentList.Add(arg); + } + } + + if (_options.EnvironmentVariables != null) + { + foreach (var (key, value) in _options.EnvironmentVariables) + { + startInfo.Environment[key] = value; + } + } + + _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}"); + } + }; + + if (!_process.Start()) + { + Debug.WriteLine("Failed to start MCP server process"); + throw new McpTransportException("Failed to start MCP server process"); + } + _processStarted = true; + _process.BeginErrorReadLine(); + + + // Start reading messages in the background + _readTask = Task.Run(async () => await ReadMessagesAsync(_shutdownCts.Token)); + + SetConnected(true); + } + catch (Exception ex) + { + await CleanupAsync(cancellationToken); + throw new McpTransportException("Failed to connect transport", ex); + } + } + + /// + public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) + { + if (!IsConnected || _process?.HasExited == true) + { + throw new McpTransportException("Transport is not connected"); + } + + try + { + var json = JsonSerializer.Serialize(message, _jsonOptions); + + // Write the message followed by a newline + await _process!.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken); + await _process.StandardInput.FlushAsync(); + } + catch (Exception ex) + { + throw new McpTransportException("Failed to send message", ex); + } + } + + /// + public override async ValueTask DisposeAsync() + { + await CleanupAsync(CancellationToken.None); + GC.SuppressFinalize(this); + } + + private async Task ReadMessagesAsync(CancellationToken cancellationToken) + { + try + { + using var reader = _process!.StandardOutput; + + while (!cancellationToken.IsCancellationRequested && !_process.HasExited) + { + Debug.WriteLine("Reading message..."); + var line = await reader.ReadLineAsync(cancellationToken); + if (line == null) + { + break; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + Debug.WriteLine($"Received stdio message: {line}"); + + try + { + var message = JsonSerializer.Deserialize(line, _jsonOptions); + if (message != null) + { + + await WriteMessageAsync(message, cancellationToken); + } + } + catch (JsonException ex) + { + Debug.WriteLine($"Failed to parse message: {ex.Message}"); + // Continue reading even if we fail to parse a message + } + } + Debug.WriteLine("Exiting read loop"); + } + catch (OperationCanceledException) + { + // Normal shutdown + } + catch (Exception ex) + { + Debug.WriteLine($"Error reading messages: {ex}"); + } + finally + { + Debug.WriteLine("Entering ReadMessages cleanup"); + await CleanupAsync(cancellationToken); + Debug.WriteLine("Completed ReadMessages cleanup"); + } + } + + private async Task CleanupAsync(CancellationToken cancellationToken) + { + if (_process != null && _processStarted && !_process.HasExited) + { + + try + { + // Try to close stdin to signal the process to exit + _process.StandardInput.Close(); + + // Wait for the process to exit + if (!_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds)) + { + // If it doesn't exit gracefully, terminate it + _process.Kill(true); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error during cleanup: {ex}"); + } + + _process.Dispose(); + _process = null; + } + + _shutdownCts?.Cancel(); + _shutdownCts?.Dispose(); + _shutdownCts = null; + + if (_readTask != null) + { + try + { + await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (TimeoutException) + { + Debug.WriteLine("Cleanup timeout waiting for read task"); + // Continue with cleanup + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + catch (Exception ex) + { + Debug.WriteLine($"Error waiting for read task: {ex}"); + } + _readTask = null; + } + + SetConnected(false); + } + +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Transport/TransportBase.cs b/src/mcpdotnet/Protocol/Transport/TransportBase.cs new file mode 100644 index 0000000..530bcb5 --- /dev/null +++ b/src/mcpdotnet/Protocol/Transport/TransportBase.cs @@ -0,0 +1,77 @@ +// Protocol/Transport/IMcpTransport.cs +namespace McpDotNet.Protocol.Transport; + +using System.Text.Json; +using System.Threading.Channels; +using global::McpDotNet.Protocol.Messages; +using McpDotNet.Utils.Json; + +// Protocol/Transport/TransportBase.cs + +/// +/// Base class for implementing MCP transports with common functionality. +/// +public abstract class TransportBase : IMcpTransport +{ + private readonly Channel _messageChannel; + private bool _isConnected; + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions().ConfigureForMcp(); + + protected TransportBase() + { + // Unbounded channel to prevent blocking on writes + _messageChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + } + + /// + public bool IsConnected => _isConnected; + + /// + public ChannelReader MessageReader => _messageChannel.Reader; + + /// + public abstract Task ConnectAsync(CancellationToken cancellationToken = default); + + /// + public abstract Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default); + + /// + public abstract ValueTask DisposeAsync(); + + /// + /// Writes a message to the message channel. + /// + /// The message to write. + /// Token to cancel the operation. + protected async Task WriteMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) + { + if (!_isConnected) + { + throw new McpTransportException("Transport is not connected"); + } + + await _messageChannel.Writer.WriteAsync(message, cancellationToken); + } + + /// + /// Sets the connected state of the transport. + /// + /// Whether the transport is connected. + protected void SetConnected(bool isConnected) + { + if (_isConnected == isConnected) + { + return; + } + + _isConnected = isConnected; + if (!isConnected) + { + _messageChannel.Writer.Complete(); + } + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/Capabilities.cs b/src/mcpdotnet/Protocol/Types/Capabilities.cs new file mode 100644 index 0000000..e6c7782 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/Capabilities.cs @@ -0,0 +1,133 @@ +namespace McpDotNet.Protocol.Types; + +using System.Text.Json.Serialization; + +/// +/// Represents the capabilities that a client may support. +/// +public record ClientCapabilities +{ + /// + /// Experimental, non-standard capabilities that the client supports. + /// + [JsonPropertyName("experimental")] + public Dictionary? Experimental { get; init; } + + /// + /// Present if the client supports listing roots. + /// + [JsonPropertyName("roots")] + public RootsCapability? Roots { get; init; } + + /// + /// Present if the client supports sampling from an LLM. + /// + [JsonPropertyName("sampling")] + public SamplingCapability? Sampling { get; init; } +} + +/// +/// Represents the capabilities that a server may support. +/// +public record ServerCapabilities +{ + /// + /// Experimental, non-standard capabilities that the server supports. + /// + [JsonPropertyName("experimental")] + public Dictionary? Experimental { get; init; } + + /// + /// Present if the server supports sending log messages to the client. + /// + [JsonPropertyName("logging")] + public LoggingCapability? Logging { get; init; } + + /// + /// Present if the server offers any prompt templates. + /// + [JsonPropertyName("prompts")] + public PromptsCapability? Prompts { get; init; } + + /// + /// Present if the server offers any resources to read. + /// + [JsonPropertyName("resources")] + public ResourcesCapability? Resources { get; init; } + + /// + /// Present if the server offers any tools to call. + /// + [JsonPropertyName("tools")] + public ToolsCapability? Tools { get; init; } +} + +/// +/// Represents the roots capability configuration. +/// +public record RootsCapability +{ + /// + /// Whether the client supports notifications for changes to the roots list. + /// + [JsonPropertyName("listChanged")] + public bool? ListChanged { get; init; } +} + +/// +/// Represents the sampling capability configuration. +/// +public record SamplingCapability +{ + // Currently empty in the spec, but may be extended in the future +} + +/// +/// Represents the logging capability configuration. +/// +public record LoggingCapability +{ + // Currently empty in the spec, but may be extended in the future +} + +/// +/// Represents the prompts capability configuration. +/// +public record PromptsCapability +{ + /// + /// Whether this server supports notifications for changes to the prompt list. + /// + [JsonPropertyName("listChanged")] + public bool? ListChanged { get; init; } +} + +/// +/// Represents the resources capability configuration. +/// +public record ResourcesCapability +{ + /// + /// Whether this server supports subscribing to resource updates. + /// + [JsonPropertyName("subscribe")] + public bool? Subscribe { get; init; } + + /// + /// Whether this server supports notifications for changes to the resource list. + /// + [JsonPropertyName("listChanged")] + public bool? ListChanged { get; init; } +} + +/// +/// Represents the tools capability configuration. +/// +public record ToolsCapability +{ + /// + /// Whether this server supports notifications for changes to the tool list. + /// + [JsonPropertyName("listChanged")] + public bool? ListChanged { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/Content.cs b/src/mcpdotnet/Protocol/Types/Content.cs new file mode 100644 index 0000000..d7120ad --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/Content.cs @@ -0,0 +1,97 @@ +// Protocol/Types/Content.cs +namespace McpDotNet.Protocol.Types; + +using System.Text.Json.Serialization; + +/// +/// Represents the type of role in the conversation. +/// +public enum Role +{ + [JsonPropertyName("user")] + User, + + [JsonPropertyName("assistant")] + Assistant +} + +/// +/// Base interface for content types in messages. +/// +public interface IContent +{ + string Type { get; } + Annotations? Annotations { get; } +} + +/// +/// Represents text content in messages. +/// +public record TextContent : IContent +{ + /// + /// The type of content. Always "text". + /// + [JsonPropertyName("type")] + public string Type => "text"; + + /// + /// The text content of the message. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } + + /// + /// Optional annotations for the content. + /// + [JsonPropertyName("annotations")] + public Annotations? Annotations { get; init; } +} + +/// +/// Represents image content in messages. +/// +public record ImageContent : IContent +{ + /// + /// The type of content. Always "image". + /// + [JsonPropertyName("type")] + public string Type => "image"; + + /// + /// The base64-encoded image data. + /// + [JsonPropertyName("data")] + public required string Data { get; init; } + + /// + /// The MIME type of the image. + /// + [JsonPropertyName("mimeType")] + public required string MimeType { get; init; } + + /// + /// Optional annotations for the content. + /// + [JsonPropertyName("annotations")] + public Annotations? Annotations { get; init; } +} + +/// +/// Represents annotations that can be attached to content. +/// +public record Annotations +{ + /// + /// Describes who the intended customer of this object or data is. + /// + [JsonPropertyName("audience")] + public Role[]? Audience { get; init; } + + /// + /// Describes how important this data is for operating the server (0 to 1). + /// + [JsonPropertyName("priority")] + public float? Priority { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/Implementation.cs b/src/mcpdotnet/Protocol/Types/Implementation.cs new file mode 100644 index 0000000..d4e730d --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/Implementation.cs @@ -0,0 +1,22 @@ +// Protocol/Types/Implementation.cs +namespace McpDotNet.Protocol.Types; + +using System.Text.Json.Serialization; + +/// +/// Describes the name and version of an MCP implementation. +/// +public record Implementation +{ + /// + /// Name of the implementation. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Version of the implementation. + /// + [JsonPropertyName("version")] + public required string Version { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/InitializeResult.cs b/src/mcpdotnet/Protocol/Types/InitializeResult.cs new file mode 100644 index 0000000..738f971 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/InitializeResult.cs @@ -0,0 +1,33 @@ +namespace McpDotNet.Protocol.Types; + +using System.Text.Json.Serialization; + +/// +/// Result of the initialization request sent to the server. +/// +public record InitializeResult +{ + /// + /// The version of the Model Context Protocol that the server wants to use. + /// + [JsonPropertyName("protocolVersion")] + public required string ProtocolVersion { get; init; } + + /// + /// The server's capabilities. + /// + [JsonPropertyName("capabilities")] + public required ServerCapabilities Capabilities { get; init; } + + /// + /// Information about the server implementation. + /// + [JsonPropertyName("serverInfo")] + public required Implementation ServerInfo { get; init; } + + /// + /// Optional instructions for using the server and its features. + /// + [JsonPropertyName("instructions")] + public string? Instructions { get; init; } +} diff --git a/src/mcpdotnet/Protocol/Types/Resources.cs b/src/mcpdotnet/Protocol/Types/Resources.cs new file mode 100644 index 0000000..9f8e8c9 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/Resources.cs @@ -0,0 +1,84 @@ +namespace McpDotNet.Protocol.Types; + +using System.Text.Json.Serialization; + +/// +/// Represents a known resource that the server is capable of reading. +/// +public record Resource +{ + /// + /// The URI of this resource. + /// + [JsonPropertyName("uri")] + public required string Uri { get; init; } + + /// + /// A human-readable name for this resource. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// A description of what this resource represents. + /// + [JsonPropertyName("description")] + public string? Description { get; init; } + + /// + /// The MIME type of this resource, if known. + /// + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + /// + /// Optional annotations for the resource. + /// + [JsonPropertyName("annotations")] + public Annotations? Annotations { get; init; } +} + +/// +/// Interface for resource contents. +/// +public interface IResourceContents +{ + string Uri { get; } + string? MimeType { get; } +} + +/// +/// Represents text-based resource contents. +/// +public record TextResourceContents : IResourceContents +{ + [JsonPropertyName("uri")] + public required string Uri { get; init; } + + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + /// + /// The text content of the resource. + /// + [JsonPropertyName("text")] + public required string Text { get; init; } +} + +/// +/// Represents binary resource contents. +/// +public record BlobResourceContents : IResourceContents +{ + [JsonPropertyName("uri")] + public required string Uri { get; init; } + + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + /// + /// The base64-encoded binary content of the resource. + /// + [JsonPropertyName("blob")] + public required string Blob { get; init; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/Tools.cs b/src/mcpdotnet/Protocol/Types/Tools.cs new file mode 100644 index 0000000..6527907 --- /dev/null +++ b/src/mcpdotnet/Protocol/Types/Tools.cs @@ -0,0 +1,44 @@ +// Protocol/Types/Tools.cs +namespace McpDotNet.Protocol.Types; + +using mcpdotnet.Features.Tools; +using System.Text.Json.Serialization; + +public class ListToolsResponse +{ + public List Tools { get; set; } = new(); +} + +public class Tool +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public JsonSchema? InputSchema { get; set; } +} + +public class JsonSchema +{ + public string Type { get; set; } = string.Empty; + public Dictionary? Properties { get; set; } + public List? Required { get; set; } +} + +public class JsonSchemaProperty +{ + public string Type { get; set; } = string.Empty; + public string? Description { get; set; } +} + +public class CallToolResponse +{ + public List Content { get; set; } = new(); + public bool IsError { get; set; } +} + +public class ToolContent +{ + public string Type { get; set; } = string.Empty; + public string? Text { get; set; } + public string? Data { get; set; } + public string? MimeType { get; set; } +} \ No newline at end of file diff --git a/src/mcpdotnet/Utils/Json/ContentJsonConverter.cs b/src/mcpdotnet/Utils/Json/ContentJsonConverter.cs new file mode 100644 index 0000000..0c8ca3d --- /dev/null +++ b/src/mcpdotnet/Utils/Json/ContentJsonConverter.cs @@ -0,0 +1,51 @@ +// Utils/Json/ContentJsonConverter.cs +namespace McpDotNet.Utils.Json; + +using System.Text.Json; +using System.Text.Json.Serialization; +using global::McpDotNet.Protocol.Types; + +/// +/// JSON converter for IContent that handles polymorphic deserialization based on the "type" property. +/// +public class ContentJsonConverter : JsonConverter +{ + public override IContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeProperty)) + { + throw new JsonException("Missing 'type' property"); + } + + var type = typeProperty.GetString(); + return type switch + { + "text" => JsonSerializer.Deserialize(root.GetRawText(), options), + "image" => JsonSerializer.Deserialize(root.GetRawText(), options), + _ => throw new JsonException($"Unknown content type: {type}") + }; + } + + public override void Write(Utf8JsonWriter writer, IContent value, JsonSerializerOptions options) + { + switch (value) + { + case TextContent text: + JsonSerializer.Serialize(writer, text, options); + break; + case ImageContent image: + JsonSerializer.Serialize(writer, image, options); + break; + default: + throw new JsonException($"Unknown content type: {value.GetType()}"); + } + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs b/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs new file mode 100644 index 0000000..89931f4 --- /dev/null +++ b/src/mcpdotnet/Utils/Json/JsonRpcMessageConverter.cs @@ -0,0 +1,86 @@ +// Utils/Json/JsonRpcMessageConverter.cs (continued) +namespace McpDotNet.Utils.Json; + +using System.Text.Json; +using System.Text.Json.Serialization; +using global::McpDotNet.Protocol.Messages; + +/// +/// JSON converter for IJsonRpcMessage that handles polymorphic deserialization of different message types. +/// +public class JsonRpcMessageConverter : JsonConverter +{ + public override IJsonRpcMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + // All JSON-RPC messages must have a jsonrpc property with value "2.0" + if (!root.TryGetProperty("jsonrpc", out var versionProperty) || + versionProperty.GetString() != "2.0") + { + throw new JsonException("Invalid or missing jsonrpc version"); + } + + // Determine the message type based on the presence of id, method, and error properties + bool hasId = root.TryGetProperty("id", out _); + bool hasMethod = root.TryGetProperty("method", out _); + bool hasError = root.TryGetProperty("error", out _); + + var rawText = root.GetRawText(); + + // Messages with an id but no method are responses + if (hasId && !hasMethod) + { + // Messages with an error property are error responses + if (hasError) + { + return JsonSerializer.Deserialize(rawText, options); + } + // Messages with a result property are success responses + else if (root.TryGetProperty("result", out _)) + { + return JsonSerializer.Deserialize(rawText, options); + } + throw new JsonException("Response must have either result or error"); + } + // Messages with a method but no id are notifications + else if (hasMethod && !hasId) + { + return JsonSerializer.Deserialize(rawText, options); + } + // Messages with both method and id are requests + else if (hasMethod && hasId) + { + return JsonSerializer.Deserialize(rawText, options); + } + + throw new JsonException("Invalid JSON-RPC message format"); + } + + public override void Write(Utf8JsonWriter writer, IJsonRpcMessage value, JsonSerializerOptions options) + { + switch (value) + { + case JsonRpcRequest request: + JsonSerializer.Serialize(writer, request, options); + break; + case JsonRpcNotification notification: + JsonSerializer.Serialize(writer, notification, options); + break; + case JsonRpcResponse response: + JsonSerializer.Serialize(writer, response, options); + break; + case JsonRpcError error: + JsonSerializer.Serialize(writer, error, options); + break; + default: + throw new JsonException($"Unknown JSON-RPC message type: {value.GetType()}"); + } + } +} \ No newline at end of file diff --git a/src/mcpdotnet/Utils/Json/JsonSerializerOptionsExtensions.cs b/src/mcpdotnet/Utils/Json/JsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..51fce6a --- /dev/null +++ b/src/mcpdotnet/Utils/Json/JsonSerializerOptionsExtensions.cs @@ -0,0 +1,31 @@ +namespace McpDotNet.Utils.Json; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Extensions for configuring System.Text.Json serialization options for MCP. +/// +public static class JsonSerializerOptionsExtensions +{ + /// + /// Configures JsonSerializerOptions with MCP-specific settings and converters. + /// + /// The options to configure. + /// The configured options. + public static JsonSerializerOptions ConfigureForMcp(this JsonSerializerOptions options) + { + // Add custom converters + options.Converters.Add(new JsonRpcMessageConverter()); + options.Converters.Add(new ContentJsonConverter()); + options.Converters.Add(new ResourceContentsJsonConverter()); + options.Converters.Add(new JsonStringEnumConverter()); + + // Configure general options + options.PropertyNameCaseInsensitive = true; + options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.NumberHandling = JsonNumberHandling.AllowReadingFromString; + + return options; + } +} diff --git a/src/mcpdotnet/Utils/Json/ResourceContentsJsonConverter.cs b/src/mcpdotnet/Utils/Json/ResourceContentsJsonConverter.cs new file mode 100644 index 0000000..c472d7a --- /dev/null +++ b/src/mcpdotnet/Utils/Json/ResourceContentsJsonConverter.cs @@ -0,0 +1,49 @@ +namespace McpDotNet.Utils.Json; + +using System.Text.Json; +using System.Text.Json.Serialization; +using global::McpDotNet.Protocol.Types; + +/// +/// JSON converter for IResourceContents that handles polymorphic deserialization based on content properties. +/// +public class ResourceContentsJsonConverter : JsonConverter +{ + public override IResourceContents? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + // Determine the type based on whether it has a 'text' or 'blob' property + if (root.TryGetProperty("text", out _)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + else if (root.TryGetProperty("blob", out _)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + + throw new JsonException("Resource contents must have either 'text' or 'blob' property"); + } + + public override void Write(Utf8JsonWriter writer, IResourceContents value, JsonSerializerOptions options) + { + switch (value) + { + case TextResourceContents text: + JsonSerializer.Serialize(writer, text, options); + break; + case BlobResourceContents blob: + JsonSerializer.Serialize(writer, blob, options); + break; + default: + throw new JsonException($"Unknown resource contents type: {value.GetType()}"); + } + } +} \ No newline at end of file diff --git a/src/mcpdotnet/mcpdotnet.csproj b/src/mcpdotnet/mcpdotnet.csproj new file mode 100644 index 0000000..c46b6f2 --- /dev/null +++ b/src/mcpdotnet/mcpdotnet.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + +