From 4597bbd7c2e173aab564e84d3b1d69caefb5bfba Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 17 Mar 2025 16:34:00 -0400 Subject: [PATCH] Integrate Microsoft.Extensions.AI as a foundational building block (#94) * Integrate Microsoft.Extensions.AI as a foundational building block As with logging abstractions coming from M.E.Logging and DI abstractions coming from M.E.DependencyInjection, M.E.AI provides the relevant AI abstractions. - Adds AsAIFunction and GetAIFunctionsAsync methods for getting functions for tools via a client. - Uses AIFunctionFactory to implement McpServerBuilderExtensions.WithTools. - Adds a RequestSamplingAsync overload that accepts ChatMessages/Options. - Adds an AsSamplingChatClient method that provides an IChatClient usable on the server to request sampling of the client. - Adds a CreateSamplingHandler method to enable an IChatClient to be easily used to satisfy sampling requests on the client. - Deprecate McpDotNet.Extensions.AI. * Address PR feedback --- README.MD | 15 + mcpdotnet.sln | 7 - .../tools/ToolsConsole/Program.cs | 145 +++++---- .../AIFunctionUtilities.cs | 26 -- src/McpDotNet.Extensions.AI/McpAIFunction.cs | 60 ---- .../McpDotNet.Extensions.AI.csproj | 11 - .../McpSessionScope.cs | 123 -------- .../McpToolExtensions.cs | 17 -- src/McpDotNet.Extensions.AI/README.md | 81 +---- src/mcpdotnet/Client/IMcpClient.cs | 5 - src/mcpdotnet/Client/McpClientExtensions.cs | 214 ++++++++++++++ .../McpServerBuilderExtensions.Tools.cs | 278 +++++++----------- src/mcpdotnet/Protocol/Types/Content.cs | 24 -- .../Types/CreateMessageRequestParams.cs | 2 +- src/mcpdotnet/Protocol/Types/JsonSchema.cs | 2 +- .../Protocol/Types/JsonSchemaProperty.cs | 2 +- src/mcpdotnet/Protocol/Types/Tool.cs | 10 +- src/mcpdotnet/Server/McpServerExtensions.cs | 198 ++++++++++++- src/mcpdotnet/mcpdotnet.csproj | 2 + .../AIFunctionUtilitiesTests.cs | 95 ------ .../GlobalUsings.cs | 1 - .../IntegrationTests.cs | 112 ------- .../McpDotNet.Extensions.AI.Tests.csproj | 35 --- .../mcpdotnet.Tests/ClientIntegrationTests.cs | 90 +++++- .../McpServerBuilderExtensionsToolsTests.cs | 93 ++---- .../mcpdotnet.Tests/Server/McpServerTests.cs | 99 ++++++- tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj | 1 + 27 files changed, 811 insertions(+), 937 deletions(-) delete mode 100644 src/McpDotNet.Extensions.AI/AIFunctionUtilities.cs delete mode 100644 src/McpDotNet.Extensions.AI/McpAIFunction.cs delete mode 100644 src/McpDotNet.Extensions.AI/McpSessionScope.cs delete mode 100644 src/McpDotNet.Extensions.AI/McpToolExtensions.cs delete mode 100644 tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs delete mode 100644 tests/McpDotNet.Extensions.AI.Tests/GlobalUsings.cs delete mode 100644 tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs delete mode 100644 tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj diff --git a/README.MD b/README.MD index 8cc534d..f1adf8b 100644 --- a/README.MD +++ b/README.MD @@ -99,6 +99,21 @@ Additional examples and documentation will be added as in the near future. Remember you can connect to any MCP server, not just ones created using mcpdotnet. The protocol is designed to be server-agnostic, so you can use this library to connect to any compliant server. +Tools can be exposed easily as `AIFunction` instances so that they are immediately usable with `IChatClient`s. +```csharp +// Get available functions. +IList tools = await client.GetAIFunctionsAsync(); + +// Call the chat client using the tools. +IChatClient chatClient = ...; +var response = await chatClient.GetResponseAsync( + "your prompt here", + new() + { + Tools = [.. tools], + }); +``` + ## Getting Started (Server) Here is an example of how to create an MCP server and register all tools from the current application. diff --git a/mcpdotnet.sln b/mcpdotnet.sln index 38eb5a5..4685b29 100644 --- a/mcpdotnet.sln +++ b/mcpdotnet.sln @@ -15,8 +15,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet.TestServer", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpDotNet.Extensions.AI", "src\McpDotNet.Extensions.AI\McpDotNet.Extensions.AI.csproj", "{B3E019B8-3459-4D52-864E-703DDA2C02AD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpDotNet.Extensions.AI.Tests", "tests\McpDotNet.Extensions.AI.Tests\McpDotNet.Extensions.AI.Tests.csproj", "{67BEF596-AD39-4BDD-B505-31DE7FF0C8D2}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServerWithHosting", "samples\TestServerWithHosting\TestServerWithHosting.csproj", "{6499876E-2F76-44A8-B6EB-5B889C6E9B7F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" @@ -53,10 +51,6 @@ Global {B3E019B8-3459-4D52-864E-703DDA2C02AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {B3E019B8-3459-4D52-864E-703DDA2C02AD}.Release|Any CPU.ActiveCfg = Release|Any CPU {B3E019B8-3459-4D52-864E-703DDA2C02AD}.Release|Any CPU.Build.0 = Release|Any CPU - {67BEF596-AD39-4BDD-B505-31DE7FF0C8D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {67BEF596-AD39-4BDD-B505-31DE7FF0C8D2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {67BEF596-AD39-4BDD-B505-31DE7FF0C8D2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {67BEF596-AD39-4BDD-B505-31DE7FF0C8D2}.Release|Any CPU.Build.0 = Release|Any CPU {6499876E-2F76-44A8-B6EB-5B889C6E9B7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6499876E-2F76-44A8-B6EB-5B889C6E9B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU {6499876E-2F76-44A8-B6EB-5B889C6E9B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -70,7 +64,6 @@ Global {76E295FA-9E85-7B99-332A-2CDBFCD5860A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {CA0BB450-1903-2F92-A68D-1285976551D6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {7C229573-A085-4ECC-8131-958CDA2BE731} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {67BEF596-AD39-4BDD-B505-31DE7FF0C8D2} = {2A77AF5C-138A-4EBB-9A13-9205DCD67928} {6499876E-2F76-44A8-B6EB-5B889C6E9B7F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs b/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs index 2ed9f75..fec8463 100644 --- a/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs +++ b/samples/microsoft.extensions.ai/tools/ToolsConsole/Program.cs @@ -1,92 +1,79 @@ using McpDotNet.Client; using McpDotNet.Configuration; -using McpDotNet.Extensions.AI; using McpDotNet.Protocol.Transport; using Microsoft.Extensions.AI; using OpenAI; -internal class Program +try { - private static async Task GetMcpClientAsync() + Console.WriteLine("Initializing MCP 'everything' server"); + await using var client = await GetMcpClientAsync(); + + Console.WriteLine("MCP 'everything' server initialized"); + Console.WriteLine("Listing tools..."); + var mappedTools = await client.GetAIFunctionsAsync(); + + Console.WriteLine("Tools available:"); + foreach (var tool in mappedTools) { - McpClientOptions clientOptions = new() - { - ClientInfo = new() { Name = "SimpleToolsConsole", Version = "1.0.0" } - }; - - McpServerConfig serverConfig = new() - { - Id = "everything", - Name = "Everything", - TransportType = TransportTypes.StdIo, - TransportOptions = new Dictionary - { - ["command"] = "npx", - ["arguments"] = "-y @modelcontextprotocol/server-everything", - } - }; - - return await McpClientFactory.CreateAsync(serverConfig, clientOptions); + Console.WriteLine($" {tool}"); } - private static async Task Main(string[] args) + Console.WriteLine("Starting chat with GPT-4o-mini..."); + + // Note: We use then Microsoft.Extensions.AI.OpenAI client here, but it could be any other MEAI client. + // Provide your own OPENAI_API_KEY via an environment variable, secret or file-based appsettings. Do not hardcode it. + IChatClient chatClient = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini") + .AsBuilder() + .UseFunctionInvocation() + .Build(); + + // Create message list + IList messages = + [ + // Add a system message + new(ChatRole.System, "You are a helpful assistant, helping us test MCP server functionality."), + ]; + // If MCP server provides instructions, add them as an additional system message (you could also add it as a content part) + if (!string.IsNullOrEmpty(client.ServerInstructions)) { - try - { - Console.WriteLine("Initializing MCP 'everything' server"); - await using var client = await GetMcpClientAsync(); - Console.WriteLine("MCP 'everything' server initialized"); - Console.WriteLine("Listing tools..."); - var mappedTools = await client.ListToolsAsync().Select(t => t.ToAITool(client)).ToListAsync(); - Console.WriteLine("Tools available:"); - foreach (var tool in mappedTools) - { - Console.WriteLine(" " + tool); - } - - Console.WriteLine("Starting chat with GPT-4o-mini..."); - - // Note: We use then Microsoft.Extensions.AI.OpenAI client here, but it could be any other MEAI client. - // Provide your own OPENAI_API_KEY via an environment variable, secret or file-based appsettings. Do not hardcode it. - IChatClient openaiClient = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); - - // Note: To use the ChatClientBuilder you need to install the Microsoft.Extensions.AI package - IChatClient chatClient = new ChatClientBuilder(openaiClient) - .UseFunctionInvocation() - .Build(); - - // Create message list - IList messages = - [ - // Add a system message - new(ChatRole.System, "You are a helpful assistant, helping us test MCP server functionality."), - ]; - // If MCP server provides instructions, add them as an additional system message (you could also add it as a content part) - if (!string.IsNullOrEmpty(client.ServerInstructions)) - { - messages.Add(new(ChatRole.System, client.ServerInstructions)); - } - // Add a user message - messages.Add(new(ChatRole.User, "Please call the echo tool with the string 'Hello MCP!' and give me the response as-is.")); - - // Call the chat client - Console.WriteLine("Asking GPT-4o-mini to call the Echo Tool..."); - var response = chatClient.GetStreamingResponseAsync( - messages, - new() { Tools = mappedTools }); - - await foreach (var update in response) - { - Console.Write(update); - } - Console.WriteLine(); - - Console.WriteLine("Chat with GPT-4o-mini complete"); - } - catch (Exception ex) - { - Console.WriteLine("Error occurred: " + ex.Message); - } + messages.Add(new(ChatRole.System, client.ServerInstructions)); } + // Add a user message + messages.Add(new(ChatRole.User, "Please call the echo tool with the string 'Hello MCP!' and give me the response as-is.")); + + // Call the chat client + Console.WriteLine("Asking GPT-4o-mini to call the Echo Tool..."); + await foreach (var update in chatClient.GetStreamingResponseAsync(messages, new() { Tools = [.. mappedTools] })) + { + Console.Write(update); + } + Console.WriteLine(); +} +catch (Exception ex) +{ + Console.WriteLine($"Error occurred: {ex.Message}"); +} + +async Task GetMcpClientAsync() +{ + McpClientOptions clientOptions = new() + { + ClientInfo = new() { Name = "SimpleToolsConsole", Version = "1.0.0" } + }; + + McpServerConfig serverConfig = new() + { + Id = "everything", + Name = "Everything", + TransportType = TransportTypes.StdIo, + TransportOptions = new Dictionary + { + ["command"] = "npx", + ["arguments"] = "-y @modelcontextprotocol/server-everything", + } + }; + + return await McpClientFactory.CreateAsync(serverConfig, clientOptions); } \ No newline at end of file diff --git a/src/McpDotNet.Extensions.AI/AIFunctionUtilities.cs b/src/McpDotNet.Extensions.AI/AIFunctionUtilities.cs deleted file mode 100644 index 6825cb2..0000000 --- a/src/McpDotNet.Extensions.AI/AIFunctionUtilities.cs +++ /dev/null @@ -1,26 +0,0 @@ -using McpDotNet.Protocol.Types; -using System.Text.Json; - -namespace McpDotNet.Extensions.AI; - -internal static class AIFunctionUtilities -{ - /// - /// Maps a Tool to a JsonElement that represents the tool's schema (top-level and input schema/parameters). - /// - public static JsonElement MapToJsonElement(Tool tool) - { - // Create a JsonObject that matches the MEAI schema format - var schemaObj = new Dictionary - { - ["type"] = "object", - ["title"] = tool.Name, - ["description"] = tool.Description ?? string.Empty, - ["properties"] = tool.InputSchema?.Properties ?? [], - ["required"] = tool.InputSchema?.Required ?? [] - }; - - // Convert to JsonElement - return JsonSerializer.SerializeToElement(schemaObj); - } -} diff --git a/src/McpDotNet.Extensions.AI/McpAIFunction.cs b/src/McpDotNet.Extensions.AI/McpAIFunction.cs deleted file mode 100644 index d1e292d..0000000 --- a/src/McpDotNet.Extensions.AI/McpAIFunction.cs +++ /dev/null @@ -1,60 +0,0 @@ -using McpDotNet.Client; -using McpDotNet.Protocol.Types; -using Microsoft.Extensions.AI; -using System.Text.Json; - -namespace McpDotNet.Extensions.AI; - -/// -/// Represents an AI function that calls a tool through mcpdotnet. -/// -public class McpAIFunction : AIFunction -{ - private readonly Tool _tool; - private readonly IMcpClient _client; - - /// - /// Initializes a new instance of the class. - /// - public McpAIFunction(Tool tool, IMcpClient client) - { - _tool = tool ?? throw new ArgumentNullException(nameof(tool)); - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - - /// - public override string Name => _tool.Name; - - /// - public override string Description => _tool.Description ?? string.Empty; - - /// - public override JsonElement JsonSchema => AIFunctionUtilities.MapToJsonElement(_tool); - - /// - protected async override Task InvokeCoreAsync(IEnumerable> arguments, CancellationToken cancellationToken) - { - if (arguments is null) - { - throw new ArgumentNullException(nameof(arguments)); - } - - // Convert arguments to dictionary format expected by mcpdotnet - Dictionary argDict = []; - foreach (var arg in arguments) - { - if (arg.Value is not null) - { - argDict[arg.Key] = arg.Value; - } - } - - // Call the tool through mcpdotnet - var result = await _client.CallToolAsync(_tool.Name, argDict, cancellationToken).ConfigureAwait(false); - - // Extract the text content from the result. - return string.Join("\n", result.Content - .Where(c => c.Type == "text") - .Select(c => c.Text)); - } -} diff --git a/src/McpDotNet.Extensions.AI/McpDotNet.Extensions.AI.csproj b/src/McpDotNet.Extensions.AI/McpDotNet.Extensions.AI.csproj index 91b7a04..924d3f4 100644 --- a/src/McpDotNet.Extensions.AI/McpDotNet.Extensions.AI.csproj +++ b/src/McpDotNet.Extensions.AI/McpDotNet.Extensions.AI.csproj @@ -33,17 +33,6 @@ - - - - - - - - - - - diff --git a/src/McpDotNet.Extensions.AI/McpSessionScope.cs b/src/McpDotNet.Extensions.AI/McpSessionScope.cs deleted file mode 100644 index 5f03de0..0000000 --- a/src/McpDotNet.Extensions.AI/McpSessionScope.cs +++ /dev/null @@ -1,123 +0,0 @@ -using McpDotNet.Client; -using McpDotNet.Configuration; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace McpDotNet.Extensions.AI; - -/// -/// Represents a scope that manages a connection session with one or more MCP servers. -/// Compatible with both stdio and SSE transports. -/// -/// Disposing the scope will dispose all clients and tools, and close all connections. -/// -public sealed class McpSessionScope : IAsyncDisposable -{ - private readonly List _clients = []; - private bool _disposed; - - /// - /// The list of tools exposed by the servers in this scope. - /// - public IList Tools { get; private set; } = []; - - /// - /// The server instructions provided by the servers in this scope. - /// If a server does not provide instructions, they will not be included, so the list may be empty. - /// - public IReadOnlyList ServerInstructions { get; private set; } = []; - - /// - /// Creates a new session scope with a single server. - /// - /// A configuration object describing the MCP server. - /// An options object with the client name and capabilities. Passed to the server. - /// A logger factory for mcpdotnet. - /// A session scope which will keep the connection alive until disposed. - public static async Task CreateAsync(McpServerConfig serverConfig, - McpClientOptions? options = null, - ILoggerFactory? loggerFactory = null) - { - if (serverConfig is null) - { - throw new ArgumentNullException(nameof(serverConfig)); - } - - var scope = new McpSessionScope(); - var client = await AddClientAsync(serverConfig, options, loggerFactory).ConfigureAwait(false); - - scope.Tools = []; - await foreach (var tool in client.ListToolsAsync().ConfigureAwait(false)) - { - scope.Tools.Add(new McpAIFunction(tool, client)); - } - - if (!string.IsNullOrEmpty(client.ServerInstructions)) - { - scope.ServerInstructions = [client.ServerInstructions!]; - } - - return scope; - } - - /// - /// Creates a new session scope with multiple servers. - /// - /// Configuration objects describing the MCP servers. - /// An options object with the client name and capabilities. Passed to the servers. - /// A logger factory for mcpdotnet. - /// A session scope which keep the connections alive until disposed. - public static async Task CreateAsync(IEnumerable serverConfigs, - McpClientOptions? options = null, - ILoggerFactory? loggerFactory = null) - { - if (serverConfigs is null) - { - throw new ArgumentNullException(nameof(serverConfigs)); - } - - var scope = new McpSessionScope(); - - foreach (var config in serverConfigs) - { - var client = await AddClientAsync(config, options, loggerFactory).ConfigureAwait(false); - - scope.Tools ??= []; - await foreach (var tool in client.ListToolsAsync().ConfigureAwait(false)) - { - scope.Tools.Add(new McpAIFunction(tool, client)); - } - } - - scope.ServerInstructions = scope._clients.Select(c => c.ServerInstructions).Where(s => !string.IsNullOrEmpty(s)).ToList()!; - - return scope; - } - - private static Task AddClientAsync( - McpServerConfig serverConfig, - McpClientOptions? clientOptions, - ILoggerFactory? loggerFactory = null) - { - return McpClientFactory.CreateAsync( - serverConfig, - clientOptions ?? new() { ClientInfo = new() { Name = "AnonymousClient", Version = "1.0.0.0" } }, - loggerFactory: loggerFactory); - } - - /// - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - foreach (var client in _clients) - { - await client.DisposeAsync().ConfigureAwait(false); - } - _clients.Clear(); - Tools = []; - - GC.SuppressFinalize(this); - } -} \ No newline at end of file diff --git a/src/McpDotNet.Extensions.AI/McpToolExtensions.cs b/src/McpDotNet.Extensions.AI/McpToolExtensions.cs deleted file mode 100644 index 0c2c09f..0000000 --- a/src/McpDotNet.Extensions.AI/McpToolExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using McpDotNet.Client; -using McpDotNet.Protocol.Types; -using Microsoft.Extensions.AI; - -namespace McpDotNet.Extensions.AI; - -/// -/// Utility class for converting an MCP Tool to an Microsoft.Extensions.AI.Abstractions AITool. -/// -public static class McpToolExtensions -{ - /// - /// Converts an MCP Tool to an AITool. - /// - public static AITool ToAITool(this Tool tool, IMcpClient client) - => new McpAIFunction(tool, client); -} \ No newline at end of file diff --git a/src/McpDotNet.Extensions.AI/README.md b/src/McpDotNet.Extensions.AI/README.md index 69f00ad..72e00ca 100644 --- a/src/McpDotNet.Extensions.AI/README.md +++ b/src/McpDotNet.Extensions.AI/README.md @@ -1,83 +1,4 @@ # McpDotNet.Extensions.AI [![NuGet version](https://img.shields.io/nuget/v/McpDotNet.Extensions.AI.svg)](https://www.nuget.org/packages/McpDotNet.Extensions.AI/) -Microsoft.Extensions.AI integration for the Model Context Protocol (MCP). Enables seamless use of MCP tools as AI functions in any IChatClient implementation. Built on top of [mcpdotnet](https://github.com/PederHP/mcpdotnet). - -## 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) - -## Design Goals - -The goal of this library is to provide a simple and efficient way to integrate MCP capabilities with Microsoft.Extensions.AI.Abstractions. It is designed to be easy to use, wrapping boilerplate code and providing a clean API for AI developers to use leverage MCP capabilities without having to worry about the underlying protocol. - -## Features - -- Easy lifecycle management of MCP connections -- Seamless mapping of MCP tools to Microsoft.Extensions.AI.Abstractions AIFunction objects -- Use convenience methods, while still having access to the full MCP protocol through the underlying mcpdotnet library -- Compatible with .NET 8.0 and later - -## Getting Started (Client) - -To use McpDotNet.Extensions.AI, first install it via NuGet: - -```powershell -dotnet add package McpDotNet.Extensions.AI -``` - -Create a configuration object for the MCP server you want to connect to: -```csharp -var config = new McpServerConfig -{ - Id = "everything", - Name = "Everything", - TransportType = TransportTypes.StdIo, - TransportOptions = new Dictionary - { - ["command"] = "npx", - ["arguments"] = "-y @modelcontextprotocol/server-everything", - } -}; -``` - -Then create an MCP session scope and use it with a chat client of your choice: -```csharp -await using var sessionScope = await McpSessionScope.CreateAsync(config); - -IChatClient openaiClient = new OpenAIClient("sk-your-key") - .AsChatClient("gpt-4o-mini"); - -IChatClient chatClient = new ChatClientBuilder(openaiClient) - .UseFunctionInvocation() - .Build(); - -// Create message list -IList messages = -[ - // Add a system message - new(ChatRole.System, "You are a helpful assistant."), - new(ChatRole.User, "Placeholder for the actual user message.") -]; - -// Call the chat client -var response = await chatClient.GetResponseAsync(messages, - new() { Tools = sessionScope.Tools }); -``` - -As you can see, all you really need to do is create one or more configuration objects matching the MCP servers you want to use, create an MCP session scope, and then the tools exposed by the server(s) will be available to you through the `Tools` property of the `McpSessionScope` object. - -The `McpSessionScope` object implements `IAsyncDisposable`, and managing the connection lifecycle is as simple as using the `await using` pattern. Note that you have to keep the `McpSessionScope` object alive for as long as you want to use the tools it provides. - -## Roadmap - -- Convenience methods for other capabilities: prompts, sampling, resources, etc. - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This package is deprecated and no longer needed. The functionality it exposes is part of the core mcpdotnet library. Please remove references to it. \ No newline at end of file diff --git a/src/mcpdotnet/Client/IMcpClient.cs b/src/mcpdotnet/Client/IMcpClient.cs index 0a49572..f160041 100644 --- a/src/mcpdotnet/Client/IMcpClient.cs +++ b/src/mcpdotnet/Client/IMcpClient.cs @@ -8,11 +8,6 @@ namespace McpDotNet.Client; /// public interface IMcpClient : IAsyncDisposable { - /// - /// Gets a value indicating whether the client has been initialized. - /// - bool IsInitialized { get; } - /// /// Gets the capabilities supported by the server. /// diff --git a/src/mcpdotnet/Client/McpClientExtensions.cs b/src/mcpdotnet/Client/McpClientExtensions.cs index d10fd4c..1eeebb2 100644 --- a/src/mcpdotnet/Client/McpClientExtensions.cs +++ b/src/mcpdotnet/Client/McpClientExtensions.cs @@ -1,7 +1,11 @@ using McpDotNet.Protocol.Messages; using McpDotNet.Protocol.Types; using McpDotNet.Utils; +using Microsoft.Extensions.AI; using System.Runtime.CompilerServices; +using System.Text.Json; + +#pragma warning disable CA1508 // Avoid dead conditional code namespace McpDotNet.Client; @@ -264,6 +268,175 @@ public static class McpClientExtensions cancellationToken); } + /// Gets instances for all of the tools available through the specified . + /// The client for which instances should be created. + /// A token to cancel the operation. + /// A task containing a list of the available functions. + public static async Task> GetAIFunctionsAsync(this IMcpClient client, CancellationToken cancellationToken = default) + { + Throw.IfNull(client); + + List functions = []; + await foreach (var tool in client.ListToolsAsync(cancellationToken).ConfigureAwait(false)) + { + functions.Add(AsAIFunction(client, tool)); + } + + return functions; + } + + /// Gets an for invoking via this . + /// The client with which to perform the invocation. + /// The tool to be invoked. + /// An for performing the call. + /// + /// This operation does not validate that is valid for the specified . + /// If the tool is not valid for the client, it will fail when invoked. + /// + public static AIFunction AsAIFunction(this IMcpClient client, Tool tool) + { + Throw.IfNull(client); + Throw.IfNull(tool); + + return new McpAIFunction(client, tool); + } + + /// + /// Converts the contents of a into a pair of + /// and instances to use + /// as inputs into a operation. + /// + /// + /// The created pair of messages and options. + internal static (IList Messages, ChatOptions? Options) ToChatClientArguments( + this CreateMessageRequestParams requestParams) + { + Throw.IfNull(requestParams); + + ChatOptions? options = null; + + if (requestParams.MaxTokens is int maxTokens) + { + (options ??= new()).MaxOutputTokens = maxTokens; + } + + if (requestParams.Temperature is float temperature) + { + (options ??= new()).Temperature = temperature; + } + + if (requestParams.StopSequences is { } stopSequences) + { + (options ??= new()).StopSequences = stopSequences.ToArray(); + } + + List messages = []; + foreach (SamplingMessage sm in requestParams.Messages) + { + ChatMessage message = new() + { + Role = sm.Role == Role.User ? ChatRole.User : ChatRole.Assistant, + }; + + if (sm.Content is { Type: "text" }) + { + message.Contents.Add(new TextContent(sm.Content.Text)); + } + else if (sm.Content is { Type: "image", MimeType: not null, Data: not null }) + { + message.Contents.Add(new DataContent(Convert.FromBase64String(sm.Content.Data), sm.Content.MimeType)); + } + else if (sm.Content is { Type: "resource", Resource: not null }) + { + ResourceContents resource = sm.Content.Resource; + + if (resource.Text is not null) + { + message.Contents.Add(new TextContent(resource.Text)); + } + + if (resource.Blob is not null && resource.MimeType is not null) + { + message.Contents.Add(new DataContent(Convert.FromBase64String(resource.Blob), resource.MimeType)); + } + } + + messages.Add(message); + } + + return (messages, options); + } + + /// Converts the contents of a into a . + /// The whose contents should be extracted. + /// The created . + internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chatResponse) + { + Throw.IfNull(chatResponse); + + // The ChatResponse can include multiple messages, of varying modalities, but CreateMessageResult supports + // only either a single blob of text or a single image. Heuristically, we'll use an image if there is one + // in any of the response messages, or we'll use all the text from them concatenated, otherwise. + + ChatMessage? lastMessage = chatResponse.Messages.LastOrDefault(); + + Content? content = null; + if (lastMessage is not null) + { + foreach (var lmc in lastMessage.Contents) + { + if (lmc is DataContent dc && dc.HasTopLevelMediaType("image")) + { + content = new() + { + Type = "image", + MimeType = dc.MediaType, + Data = Convert.ToBase64String(dc.Data +#if NET + .Span), +#else + .ToArray()), +#endif + }; + } + } + } + + content ??= new() + { + Text = lastMessage?.Text ?? string.Empty, + Type = "text", + }; + + return new() + { + Content = content, + Model = chatResponse.ModelId ?? "unknown", + Role = lastMessage?.Role == ChatRole.User ? "user" : "assistant", + StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn", + }; + } + + /// + /// Creates a sampling handler for use with that will + /// satisfy sampling requests using the specified . + /// + /// The with which to satisfy sampling requests. + /// The created handler delegate. + public static Func> CreateSamplingHandler(this IChatClient chatClient) + { + Throw.IfNull(chatClient); + + return async (requestParams, cancellationToken) => + { + Throw.IfNull(requestParams); + + var (messages, options) = requestParams.ToChatClientArguments(); + var response = await chatClient.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + return response.ToCreateMessageResult(); + }; + } + private static JsonRpcRequest CreateRequest(string method, Dictionary? parameters) => new JsonRpcRequest { @@ -288,4 +461,45 @@ public static class McpClientExtensions return parameters; } + + /// Provides an AI function that calls a tool through . + private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction + { + /// + public override string Name => tool.Name; + + /// + public override string Description => tool.Description ?? string.Empty; + + /// + public override JsonElement JsonSchema => + JsonSerializer.SerializeToElement(new Dictionary + { + ["type"] = "object", + ["title"] = tool.Name, + ["description"] = tool.Description ?? string.Empty, + ["properties"] = tool.InputSchema?.Properties ?? [], + ["required"] = tool.InputSchema?.Required ?? [] + }); + + /// + protected async override Task InvokeCoreAsync( + IEnumerable> arguments, CancellationToken cancellationToken) + { + Throw.IfNull(arguments); + + Dictionary argDict = []; + foreach (var arg in arguments) + { + if (arg.Value is not null) + { + argDict[arg.Key] = arg.Value; + } + } + + CallToolResponse result = await client.CallToolAsync(tool.Name, argDict, cancellationToken).ConfigureAwait(false); + + return JsonSerializer.SerializeToElement(result); + } + } } \ No newline at end of file diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs index 9b4b5a0..c12f0ed 100644 --- a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs @@ -1,13 +1,10 @@ -using System.ComponentModel; -using System.Globalization; -using System.Reflection; -using System.Runtime.ExceptionServices; +using System.Reflection; using System.Text.Json; using McpDotNet.Configuration; using McpDotNet.Protocol.Types; using McpDotNet.Server; using McpDotNet.Utils; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; namespace McpDotNet; @@ -21,6 +18,7 @@ public static partial class McpServerBuilderExtensions /// /// The tool type. /// The builder instance. + /// is . public static IMcpServerBuilder WithTool(this IMcpServerBuilder builder) { return WithTools(builder, typeof(TTool)); @@ -29,6 +27,7 @@ public static partial class McpServerBuilderExtensions /// Adds all tools marked with from the current assembly to the server. /// /// The builder instance. + /// is . public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder) { return WithToolsFromAssembly(builder, Assembly.GetCallingAssembly()); @@ -39,46 +38,110 @@ public static partial class McpServerBuilderExtensions /// /// The builder instance. /// Types with marked methods to add as tools to the server. - public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params Type[] toolTypes) + /// is . + /// is . + public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params IEnumerable toolTypes) { Throw.IfNull(builder); + Throw.IfNull(toolTypes); - if (toolTypes is null || toolTypes.Length == 0) + List functions = []; + + foreach (var toolType in toolTypes) { - throw new ArgumentException("At least one tool type must be provided.", nameof(toolTypes)); + if (toolType is null) + { + throw new ArgumentNullException(nameof(toolTypes), $"A tool type provided by the enumerator was null."); + } + + foreach (var method in toolType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (method.GetCustomAttribute() is not { } attribute) + { + continue; + } + + functions.Add(AIFunctionFactory.Create(method, target: null, new() + { + Name = attribute.Name ?? method.Name, + })); + } } + return WithTools(builder, functions); + } + + /// + /// Adds tools to the server. + /// + /// The builder instance. + /// instances to use as the tools. + /// is . + /// is . + public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params IEnumerable functions) + { + Throw.IfNull(builder); + Throw.IfNull(functions); + List tools = []; Dictionary, CancellationToken, Task>> callbacks = []; - foreach (var type in toolTypes) + foreach (AIFunction function in functions) { - var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); - foreach (var method in methods) + if (function is null) { - var attribute = method.GetCustomAttribute(); - if (attribute != null) - { - var tool = CreateTool(method, attribute); - tools.Add(tool); - - callbacks.Add(tool.Name, async (request, cancellationToken) => await CallTool(request, method, cancellationToken).ConfigureAwait(false)); - - // register type because method is not static and so we need an instance - if (!method.IsStatic) - builder.Services.AddScoped(type); - } + throw new ArgumentNullException(nameof(functions), $"A function provided by the enumerator was null."); } + + tools.Add(new() + { + Name = function.Name, + Description = function.Description, + InputSchema = JsonSerializer.Deserialize(function.JsonSchema), + }); + + callbacks.Add(function.Name, async (request, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + + object? result; + try + { + result = await function.InvokeAsync((request.Params?.Arguments ?? [])!, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is not OperationCanceledException) + { + return new CallToolResponse() + { + IsError = true, + Content = [new() { Text = e.Message, Type = "text" }], + }; + } + + switch (result) + { + case JsonElement je when je.ValueKind == JsonValueKind.Null: + return new() { Content = [] }; + + case JsonElement je when je.ValueKind == JsonValueKind.Array: + return new() { Content = je.EnumerateArray().Select(x => new Content() { Text = x.ToString(), Type = "text" }).ToList() }; + + default: + return new() { Content = [new() { Text = result?.ToString(), Type = "text" }] }; + } + }); } builder.WithListToolsHandler((_, _) => Task.FromResult(new ListToolsResult() { Tools = tools })); builder.WithCallToolHandler(async (request, cancellationToken) => { - if (request.Params != null && callbacks.TryGetValue(request.Params.Name, out var callback)) - return await callback(request, cancellationToken).ConfigureAwait(false); + if (request.Params is null || !callbacks.TryGetValue(request.Params.Name, out var callback)) + { + throw new McpServerException($"Unknown tool '{request.Params?.Name}'"); + } - throw new McpServerException($"Unknown tool: {request.Params?.Name}"); + return await callback(request, cancellationToken).ConfigureAwait(false); }); return builder; @@ -89,6 +152,7 @@ public static partial class McpServerBuilderExtensions /// /// The builder instance. /// The assembly to load the types from. Null to get the current assembly + /// is . public static IMcpServerBuilder WithToolsFromAssembly(this IMcpServerBuilder builder, Assembly? assembly = null) { assembly ??= Assembly.GetCallingAssembly(); @@ -97,15 +161,14 @@ public static partial class McpServerBuilderExtensions foreach (var type in assembly.GetTypes()) { - bool hasToolTypeAttribute = type.GetCustomAttribute() != null; - if (!hasToolTypeAttribute) - continue; - - var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); - foreach (var method in methods) + if (type.GetCustomAttribute() is null) { - var attribute = method.GetCustomAttribute(); - if (attribute != null) + continue; + } + + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (method.GetCustomAttribute() is not null) { toolTypes.Add(type); break; @@ -113,149 +176,8 @@ public static partial class McpServerBuilderExtensions } } - if (toolTypes.Count == 0) - { - throw new ArgumentException("No types with marked methods found in the assembly.", nameof(assembly)); - } - - return WithTools(builder, [.. toolTypes]); - } - - private static Tool CreateTool(MethodInfo method, McpToolAttribute mcpAttribute) - { - Dictionary properties = []; - List? requiredProperties = null; - - foreach (var parameter in method.GetParameters()) - { - if (parameter.ParameterType == typeof(CancellationToken)) - continue; - - var parameterDescriptionAttr = parameter.GetCustomAttribute(); - - properties.Add(parameter.Name ?? "NoName", new JsonSchemaProperty() - { - Type = GetParameterType(parameter.ParameterType), - Description = parameterDescriptionAttr?.Description, - }); - - if (!parameter.HasDefaultValue) - { - requiredProperties ??= []; - requiredProperties.Add(parameter.Name ?? "NoName"); - } - } - - return new Tool() - { - Name = mcpAttribute.Name ?? method.Name, - Description = method.GetCustomAttribute()?.Description, - InputSchema = new JsonSchema() - { - Type = "object", - Properties = properties, - Required = requiredProperties - }, - }; - } - - private static string GetParameterType(Type parameterType) - { - return parameterType switch - { - Type t when t == typeof(string) => "string", - Type t when t == typeof(int) || t == typeof(double) || t == typeof(float) => "number", - Type t when t == typeof(bool) => "boolean", - Type t when t.IsArray => "array", - Type t when t == typeof(DateTime) || t == typeof(DateTimeOffset) => "string", - _ => "object" - }; - } - - private static async Task CallTool(RequestContext request, MethodInfo method, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var methodParameters = method.GetParameters(); - List parameters = ResolveParameters(request, methodParameters, cancellationToken); - - using var scope = request.Server.ServiceProvider?.CreateScope(); - var objectInstance = CreateObjectInstance(method, scope?.ServiceProvider); - object? result; - try - { - const BindingFlags InvokeFlags = -#if NET - BindingFlags.DoNotWrapExceptions | -#endif - BindingFlags.Default; - result = method.Invoke(objectInstance, InvokeFlags, binder: null, [.. parameters], culture: null); - } - catch (TargetInvocationException e) when (e.InnerException is not null) - { - ExceptionDispatchInfo.Capture(e.InnerException).Throw(); - throw; // unreachable - } - - if (result is Task task) - { - await task.ConfigureAwait(false); - var resultProperty = task.GetType().GetProperty("Result"); - result = resultProperty?.GetValue(task); - } - - if (result is string resultString) - return new CallToolResponse { Content = [new Content() { Text = resultString, Type = "text" }] }; - - if (result is string[] resultStringArray) - return new CallToolResponse { Content = [.. resultStringArray.Select(s => new Content() { Text = s, Type = "text" })] }; - - if (result is null) - return new CallToolResponse { Content = [new Content() { Text = "null" }] }; - - if (result is JsonElement jsonElement) - return new CallToolResponse { Content = [new Content() { Text = jsonElement.GetRawText(), Type = "text" }] }; - - return new CallToolResponse { Content = [new Content() { Text = result.ToString(), Type = "text" }] }; - } - - private static List ResolveParameters(RequestContext request, ParameterInfo[] methodParameters, CancellationToken cancellationToken) - { - var parameters = new List(methodParameters.Length); - - foreach (var parameter in methodParameters) - { - if (parameter.ParameterType == typeof(CancellationToken)) - { - parameters.Add(cancellationToken); - } - else if (request.Params?.Arguments != null && request.Params.Arguments.TryGetValue(parameter.Name ?? "NoName", out var value)) - { - if (value is JsonElement element) - value = JsonSerializer.Deserialize(element.GetRawText(), parameter.ParameterType); - - parameters.Add(Convert.ChangeType(value, parameter.ParameterType, CultureInfo.InvariantCulture)); - } - else - { - if (!parameter.HasDefaultValue) - throw new McpServerException($"Missing required argument '{parameter.Name}'."); - - parameters.Add(parameter.DefaultValue); - } - } - - return parameters; - } - - private static object? CreateObjectInstance(MethodInfo method, IServiceProvider? serviceProvider) - { - if (method.IsStatic) - return null; - - if (serviceProvider != null) - return ActivatorUtilities.CreateInstance(serviceProvider, method.DeclaringType!); - - return Activator.CreateInstance(method.DeclaringType!); + return toolTypes.Count > 0 ? + WithTools(builder, toolTypes) : + builder; } } diff --git a/src/mcpdotnet/Protocol/Types/Content.cs b/src/mcpdotnet/Protocol/Types/Content.cs index 731eae3..a7f1b16 100644 --- a/src/mcpdotnet/Protocol/Types/Content.cs +++ b/src/mcpdotnet/Protocol/Types/Content.cs @@ -39,28 +39,4 @@ public class Content /// [JsonPropertyName("resource")] public ResourceContents? Resource { get; set; } - - /// - /// Validates the content object. - /// - /// Whether the type of tool content has the correct data fields provided. - public bool Validate() - { - if (Type == "text") - { - return !string.IsNullOrEmpty(Text); - } - else if (Type == "image") - { - return !string.IsNullOrEmpty(Data) && !string.IsNullOrEmpty(MimeType); - } - else if (Type == "resource") - { - return Resource != null; - } - else - { - return false; - } - } } \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs b/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs index c53d925..2faefea 100644 --- a/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs +++ b/src/mcpdotnet/Protocol/Types/CreateMessageRequestParams.cs @@ -21,7 +21,7 @@ public class CreateMessageRequestParams /// The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested. /// [System.Text.Json.Serialization.JsonPropertyName("maxTokens")] - public int MaxTokens { get; init; } + public int? MaxTokens { get; init; } /// /// Messages requested by the server to be included in the prompt. diff --git a/src/mcpdotnet/Protocol/Types/JsonSchema.cs b/src/mcpdotnet/Protocol/Types/JsonSchema.cs index 6881ffa..74203b1 100644 --- a/src/mcpdotnet/Protocol/Types/JsonSchema.cs +++ b/src/mcpdotnet/Protocol/Types/JsonSchema.cs @@ -10,7 +10,7 @@ public class JsonSchema /// The type of the schema, should be "object". /// [System.Text.Json.Serialization.JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; + public string Type { get; set; } = "object"; /// /// Map of property names to property definitions. diff --git a/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs b/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs index d07d609..8854bb9 100644 --- a/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs +++ b/src/mcpdotnet/Protocol/Types/JsonSchemaProperty.cs @@ -16,5 +16,5 @@ public class JsonSchemaProperty /// A human-readable description of the property. /// [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } + public string? Description { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/mcpdotnet/Protocol/Types/Tool.cs b/src/mcpdotnet/Protocol/Types/Tool.cs index 13de8e8..ba728a3 100644 --- a/src/mcpdotnet/Protocol/Types/Tool.cs +++ b/src/mcpdotnet/Protocol/Types/Tool.cs @@ -1,4 +1,6 @@ -namespace McpDotNet.Protocol.Types; +using System.Text.Json.Serialization; + +namespace McpDotNet.Protocol.Types; /// /// Represents a tool that the server is capable of calling. Part of the ListToolsResponse. @@ -9,18 +11,18 @@ public class Tool /// /// The name of the tool. /// - [System.Text.Json.Serialization.JsonPropertyName("name")] + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; /// /// A human-readable description of the tool. /// - [System.Text.Json.Serialization.JsonPropertyName("description")] + [JsonPropertyName("description")] public string? Description { get; set; } /// /// A JSON Schema object defining the expected parameters for the tool. /// - [System.Text.Json.Serialization.JsonPropertyName("inputSchema")] + [JsonPropertyName("inputSchema")] public JsonSchema? InputSchema { get; set; } } diff --git a/src/mcpdotnet/Server/McpServerExtensions.cs b/src/mcpdotnet/Server/McpServerExtensions.cs index 06e9ded..8ce3939 100644 --- a/src/mcpdotnet/Server/McpServerExtensions.cs +++ b/src/mcpdotnet/Server/McpServerExtensions.cs @@ -1,6 +1,9 @@ using McpDotNet.Protocol.Messages; using McpDotNet.Protocol.Types; using McpDotNet.Utils; +using Microsoft.Extensions.AI; +using System.Runtime.CompilerServices; +using System.Text; namespace McpDotNet.Server; @@ -8,8 +11,10 @@ namespace McpDotNet.Server; public static class McpServerExtensions { /// - /// Requests the client to create a new message. + /// Requests to sample an LLM via the client. /// + /// is . + /// The client does not support sampling. public static Task RequestSamplingAsync( this IMcpServer server, CreateMessageRequestParams request, CancellationToken cancellationToken) { @@ -17,7 +22,7 @@ public static class McpServerExtensions if (server.ClientCapabilities?.Sampling is null) { - throw new McpServerException("Client does not support sampling"); + throw new ArgumentException("Client connected to the server does not support sampling.", nameof(server)); } return server.SendRequestAsync( @@ -25,9 +30,161 @@ public static class McpServerExtensions cancellationToken); } + /// + /// Requests to sample an LLM via the client. + /// + /// The server issueing the request. + /// The messages to send as part of the request. + /// The options to use for the request. + /// A token to cancel the operation. + /// A task containing the response from the client. + /// is . + /// is . + /// The client does not support sampling. + public static async Task RequestSamplingAsync( + this IMcpServer server, + IEnumerable messages, ChatOptions? options = default, CancellationToken cancellationToken = default) + { + Throw.IfNull(server); + Throw.IfNull(messages); + + StringBuilder? systemPrompt = null; + + List samplingMessages = []; + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + if (systemPrompt is null) + { + systemPrompt = new(); + } + else + { + systemPrompt.AppendLine(); + } + + systemPrompt.Append(message.Text); + continue; + } + + if (message.Role == ChatRole.User || message.Role == ChatRole.Assistant) + { + Role role = message.Role == ChatRole.User ? Role.User : Role.Assistant; + + foreach (var content in message.Contents) + { + switch (content) + { + case TextContent textContent: + samplingMessages.Add(new() + { + Role = role, + Content = new() + { + Type = "text", + Text = textContent.Text, + }, + }); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + samplingMessages.Add(new() + { + Role = role, + Content = new() + { + Type = "image", + MimeType = dataContent.MediaType, + Data = Convert.ToBase64String(dataContent.Data. +#if NET + Span), +#else + ToArray()), +#endif + }, + }); + break; + } + } + } + } + + ModelPreferences? modelPreferences = null; + if (options?.ModelId is { } modelId) + { + modelPreferences = new() { Hints = [new() { Name = modelId }] }; + } + + var result = await server.RequestSamplingAsync(new() + { + Messages = samplingMessages, + MaxTokens = options?.MaxOutputTokens, + StopSequences = options?.StopSequences?.ToArray(), + SystemPrompt = systemPrompt?.ToString(), + Temperature = options?.Temperature, + ModelPreferences = modelPreferences, + }, cancellationToken).ConfigureAwait(false); + + ChatMessage responseMessage = new() + { + Role = result.Role == "user" ? ChatRole.User : ChatRole.Assistant + }; + + if (result.Content is { Type: "text" }) + { + responseMessage.Contents.Add(new TextContent(result.Content.Text)); + } + else if (result.Content is { Type: "image", MimeType: not null, Data: not null }) + { + responseMessage.Contents.Add(new DataContent(Convert.FromBase64String(result.Content.Data), result.Content.MimeType)); + } + else if (result.Content is { Type: "resource" } && result.Content.Resource is { } resourceContents) + { + if (resourceContents.Text is not null) + { + responseMessage.Contents.Add(new TextContent(resourceContents.Text)); + } + + if (resourceContents.Blob is not null && resourceContents.MimeType is not null) + { + responseMessage.Contents.Add(new DataContent(Convert.FromBase64String(resourceContents.Blob), resourceContents.MimeType)); + } + } + + return new(responseMessage) + { + ModelId = result.Model, + FinishReason = result.StopReason switch + { + "maxTokens" => ChatFinishReason.Length, + "endTurn" or "stopSequence" or _ => ChatFinishReason.Stop, + } + }; + } + + /// Creates an that can be used to send sampling requests to the client. + /// The server to be wrapped as an . + /// The that can be used to issue sampling requests to the client. + /// is . + /// The client does not support sampling. + public static IChatClient AsSamplingChatClient(this IMcpServer server) + { + Throw.IfNull(server); + + if (server.ClientCapabilities?.Sampling is null) + { + throw new ArgumentException("Client connected to the server does not support sampling.", nameof(server)); + } + + return new SamplingChatClient(server); + } + /// /// Requests the client to list the roots it exposes. /// + /// is . + /// The client does not support roots. public static Task RequestRootsAsync( this IMcpServer server, ListRootsRequestParams request, CancellationToken cancellationToken) { @@ -35,11 +192,46 @@ public static class McpServerExtensions if (server.ClientCapabilities?.Roots is null) { - throw new McpServerException("Client does not support roots"); + throw new ArgumentException("Client connected to the server does not support roots.", nameof(server)); } return server.SendRequestAsync( new JsonRpcRequest { Method = "roots/list", Params = request }, cancellationToken); } + + /// Provides an implementation that's implemented via client sampling. + /// + private sealed class SamplingChatClient(IMcpServer server) : IChatClient + { + /// + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + server.RequestSamplingAsync(messages, options, cancellationToken); + + /// + async IAsyncEnumerable IChatClient.GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + foreach (var update in response.ToChatResponseUpdates()) + { + yield return update; + } + } + + /// + object? IChatClient.GetService(Type serviceType, object? serviceKey) + { + Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType.IsInstanceOfType(this) ? this : + serviceType.IsInstanceOfType(server) ? server : + null; + } + + /// + void IDisposable.Dispose() { } // nop + } } diff --git a/src/mcpdotnet/mcpdotnet.csproj b/src/mcpdotnet/mcpdotnet.csproj index a36e4ec..9e67c34 100644 --- a/src/mcpdotnet/mcpdotnet.csproj +++ b/src/mcpdotnet/mcpdotnet.csproj @@ -38,6 +38,8 @@ + + diff --git a/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs b/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs deleted file mode 100644 index b5c115b..0000000 --- a/tests/McpDotNet.Extensions.AI.Tests/AIFunctionUtilitiesTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Text.Json; -using McpDotNet.Protocol.Types; - -namespace McpDotNet.Extensions.AI.Tests; - -public class AIFunctionUtilitiesTests -{ - [Fact] - public void MapToJsonElement_WithRequiredProperties_CreatesCorrectSchema() - { - // Arrange - var tool = new Tool() - { - Name = "calculator", - Description = "A simple calculator", - InputSchema = new JsonSchema - { - Properties = new Dictionary - { - ["a"] = new JsonSchemaProperty { Type = "number" }, - ["b"] = new JsonSchemaProperty { Type = "number" } - }, - Required = ["a", "b"] - } - }; - - // Act - var result = AIFunctionUtilities.MapToJsonElement(tool); - - // Assert - var resultObj = JsonSerializer.Deserialize(result.GetRawText()); - Assert.Equal("object", resultObj.GetProperty("type").GetString()); - Assert.Equal("calculator", resultObj.GetProperty("title").GetString()); - Assert.Equal("A simple calculator", resultObj.GetProperty("description").GetString()); - // Add more detailed property assertions - } - - [Fact] - public void MapToJsonElement_WithOptionalProperties_CreatesCorrectSchema() - { - // Arrange - var tool = new Tool() - { - Name = "format", - Description = "Text formatter", - InputSchema = new JsonSchema - { - Properties = new Dictionary - { - ["text"] = new JsonSchemaProperty { Type = "string" }, - ["style"] = new JsonSchemaProperty { Type = "string" } - }, - Required = ["text"] // only text is required - } - }; - - // Act - var result = AIFunctionUtilities.MapToJsonElement(tool); - - // Assert - var resultObj = JsonSerializer.Deserialize(result.GetRawText()); - Assert.Equal("object", resultObj.GetProperty("type").GetString()); - Assert.Equal("format", resultObj.GetProperty("title").GetString()); - Assert.Equal("Text formatter", resultObj.GetProperty("description").GetString()); - Assert.Contains("text", resultObj.GetProperty("required").EnumerateArray().Select(x => x.GetString())); - Assert.DoesNotContain("style", resultObj.GetProperty("required").EnumerateArray().Select(x => x.GetString())); - } - - [Fact] - public void MapToJsonElement_WithNullProperties_HandlesGracefully() - { - // Arrange - var tool = new Tool() - { - Name = "minimal", - Description = "Minimal tool", - InputSchema = new JsonSchema - { - Properties = null, - Required = null - } - }; - - // Act - var result = AIFunctionUtilities.MapToJsonElement(tool); - - // Assert - var resultObj = JsonSerializer.Deserialize(result.GetRawText()); - Assert.Equal("object", resultObj.GetProperty("type").GetString()); - Assert.Equal("minimal", resultObj.GetProperty("title").GetString()); - Assert.Equal("Minimal tool", resultObj.GetProperty("description").GetString()); - Assert.Equal("{}", resultObj.GetProperty("properties").GetRawText()); - Assert.Empty(resultObj.GetProperty("required").EnumerateArray()); - } -} \ No newline at end of file diff --git a/tests/McpDotNet.Extensions.AI.Tests/GlobalUsings.cs b/tests/McpDotNet.Extensions.AI.Tests/GlobalUsings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/tests/McpDotNet.Extensions.AI.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs b/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs deleted file mode 100644 index 8931b03..0000000 --- a/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -using McpDotNet.Client; -using McpDotNet.Configuration; -using Microsoft.Extensions.AI; -using OpenAI; - -namespace McpDotNet.Extensions.AI.Tests; - -[Trait("Execution", "Manual")] -public class IntegrationTests -{ - private string _openAIKey = Environment.GetEnvironmentVariable("Provide your own key when running the test. Do not commit it.")!; - - private static McpServerConfig GetEverythingServerConfig() - { - var config = new McpServerConfig - { - Id = "everything", - Name = "Everything", - TransportType = "stdio", - TransportOptions = new Dictionary - { - ["command"] = "npx", - ["arguments"] = "-y @modelcontextprotocol/server-everything", - } - }; - - return config; - } - - private static async Task GetMcpClientAsync() - { - McpClientOptions options = new() - { - ClientInfo = new() { Name = "McpDotNet.Extensions.AI.Tests", Version = "1.0.0" } - }; - - return await McpClientFactory.CreateAsync(GetEverythingServerConfig(), options); - } - - [Fact] - public async Task IntegrateWithMeai_UsingEverythingServer_ToolsAreProperlyCalled() - { - await using var client = await GetMcpClientAsync(); - var mappedTools = await client.ListToolsAsync().Select(t => t.ToAITool(client)).ToListAsync(); - - IChatClient openaiClient = new OpenAIClient(_openAIKey) - .AsChatClient("gpt-4o-mini"); - - IChatClient chatClient = new ChatClientBuilder(openaiClient) - .UseFunctionInvocation() - .Build(); - - // Create message list - IList messages = - [ - // Add a system message - new(ChatRole.System, "You are a helpful assistant, helping us test MCP server functionality."), - ]; - // If MCP server provides instructions, add them as an additional system message (you could also add it as a content part) - if (!string.IsNullOrEmpty(client.ServerInstructions)) - { - messages.Add(new(ChatRole.System, client.ServerInstructions)); - } - // Add a user message - messages.Add(new(ChatRole.User, "Please call the echo tool with the string 'Hello MCP!' and output the response ad verbatim.")); - - // Call the chat client - Console.WriteLine("Asking GPT-4o-mini to call the Echo Tool..."); - var response = await chatClient.GetResponseAsync( - messages, - new() { Tools = mappedTools, Temperature = 0 }); - - // Assert - Assert.Equal("Echo: Hello MCP!", response.Text); - } - - [Fact] - public async Task IntegrateWithMeai_UsingEverythingServer_AndSessionScope_ToolsAreProperlyCalled() - { - await using var sessionScope = await McpSessionScope.CreateAsync(GetEverythingServerConfig()); - - IChatClient openaiClient = new OpenAIClient(_openAIKey) - .AsChatClient("gpt-4o-mini"); - - IChatClient chatClient = new ChatClientBuilder(openaiClient) - .UseFunctionInvocation() - .Build(); - - // Create message list - IList messages = - [ - // Add a system message - new(ChatRole.System, "You are a helpful assistant, helping us test MCP server functionality."), - ]; - // If MCP server provides instructions, add them as an additional system message (you could also add it as a content part) - foreach (var serverInstruction in sessionScope.ServerInstructions) - { - messages.Add(new(ChatRole.System, serverInstruction)); - } - // Add a user message - messages.Add(new(ChatRole.User, "Please call the echo tool with the string 'Hello MCP!' and output the response ad verbatim.")); - - // Call the chat client - Console.WriteLine("Asking GPT-4o-mini to call the Echo Tool..."); - var response = await chatClient.GetResponseAsync( - messages, - new() { Tools = sessionScope.Tools, Temperature = 0 }); - - // Assert - Assert.Equal("Echo: Hello MCP!", response.Text); - } -} diff --git a/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj b/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj deleted file mode 100644 index be1bdbe..0000000 --- a/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - net8.0;net472 - enable - enable - Latest - - false - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - diff --git a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs index c115187..b7f68a6 100644 --- a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs @@ -3,12 +3,16 @@ using McpDotNet.Configuration; using McpDotNet.Protocol.Messages; using McpDotNet.Protocol.Transport; using McpDotNet.Protocol.Types; +using Microsoft.Extensions.AI; +using OpenAI; using System.Text.Json; namespace McpDotNet.Tests; public class ClientIntegrationTests : IClassFixture { + private static readonly string? s_openAIKey = Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!; + private readonly ClientIntegrationTestFixture _fixture; public ClientIntegrationTests(ClientIntegrationTestFixture fixture) @@ -58,10 +62,12 @@ public class ClientIntegrationTests : IClassFixture t.Name == "echo"); + var result = await echo.InvokeAsync([new KeyValuePair("message", "Hello MCP!")]); + + // assert + Assert.NotNull(result); + Assert.Contains("Echo: Hello MCP!", result.ToString()); + } + [Theory] [MemberData(nameof(GetClients))] public async Task ListPrompts_Stdio(string clientId) @@ -444,4 +467,69 @@ public class ClientIntegrationTests : IClassFixture messages = [new(ChatRole.System, "You are a helpful assistant.")]; + if (client.ServerInstructions is not null) + { + messages.Add(new(ChatRole.System, client.ServerInstructions)); + } + messages.Add(new(ChatRole.User, "Please call the echo tool with the string 'Hello MCP!' and output the response ad verbatim.")); + + // Call the chat client + var response = await chatClient.GetResponseAsync(messages, new() { Tools = [.. mappedTools], Temperature = 0 }); + + // Assert + Assert.Equal("Echo: Hello MCP!", response.Text); + } + + [Fact] + public async Task SamplingViaChatClient_RequestResponseProperlyPropagated() + { + if (s_openAIKey is null) + { + return; // Skip the test if the OpenAI key is not provided + } + + await using var client = await McpClientFactory.CreateAsync(_fixture.EverythingServerConfig, new() + { + ClientInfo = new() { Name = nameof(SamplingViaChatClient_RequestResponseProperlyPropagated), Version = "1.0.0" }, + Capabilities = new() + { + Sampling = new() + { + SamplingHandler = new OpenAIClient(s_openAIKey).AsChatClient("gpt-4o-mini").CreateSamplingHandler(), + }, + }, + }); + + var result = await client.CallToolAsync("sampleLLM", new() + { + ["prompt"] = "In just a few words, what is the most famous tower in Paris?", + }); + + Assert.NotNull(result); + Assert.NotEmpty(result.Content); + Assert.Equal("text", result.Content[0].Type); + Assert.Contains("LLM sampling result:", result.Content[0].Text); + Assert.Contains("Eiffel", result.Content[0].Text); + } } diff --git a/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index eaacf09..5885a67 100644 --- a/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/mcpdotnet.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text.Json; +using System.Text.RegularExpressions; using McpDotNet.Configuration; using McpDotNet.Server; using Microsoft.Extensions.DependencyInjection; @@ -108,10 +109,7 @@ public class McpServerBuilderExtensionsToolsTests var result = await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnNull" }), CancellationToken.None); Assert.NotNull(result); Assert.NotNull(result.Content); - Assert.NotEmpty(result.Content); - - Assert.Equal("null", result.Content[0].Text); - Assert.Empty(result.Content[0].Type); + Assert.Empty(result.Content); } [Fact] @@ -127,7 +125,7 @@ public class McpServerBuilderExtensionsToolsTests Assert.NotNull(result.Content); Assert.NotEmpty(result.Content); - Assert.Equal("{\"SomeProp\": false}", result.Content[0].Text); + Assert.Equal("{\"SomeProp\":false}", Regex.Replace(result.Content[0].Text ?? string.Empty, "\\s+", "")); Assert.Equal("text", result.Content[0].Type); } @@ -202,45 +200,18 @@ public class McpServerBuilderExtensionsToolsTests } [Fact] - public async Task Throws_Exception_When_Tool_Fails() + public async Task Returns_IsError_Content_When_Tool_Fails() { _builder.Object.WithTools(typeof(EchoTool)); var serviceProvider = _services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; - var action = async () => await options.CallToolHandler!(new(Mock.Of(), new() { Name = "ReturnError" }), CancellationToken.None); - - var exception = await Assert.ThrowsAsync(action); - Assert.Equal("Test error", exception.Message); - } - - [Fact] - public async Task Can_Call_Registered_Tool_With_Dependency_Injection() - { - bool dependencyInjectionCalled = false; - _services.AddTransient(sp => - { - dependencyInjectionCalled = true; - return Mock.Of(); - }); - - _builder.Object.WithTool(); - - var serviceProvider = _services.BuildServiceProvider(); - var options = serviceProvider.GetRequiredService>().Value; - - var mcpServer = new Mock(); - mcpServer.SetupGet(s => s.ServiceProvider).Returns(serviceProvider); - - var result = await options.CallToolHandler!(new(mcpServer.Object, new() { Name = "Echo", Arguments = new() { { "message", "Peter" } } }), CancellationToken.None); - Assert.NotNull(result); - Assert.NotNull(result.Content); - Assert.NotEmpty(result.Content); - - Assert.Equal("hello Peter", result.Content[0].Text); - - Assert.True(dependencyInjectionCalled); + var response = await options.CallToolHandler!(new(Mock.Of(), new() { Name = nameof(EchoTool.ThrowException) }), CancellationToken.None); + Assert.True(response.IsError); + Assert.NotNull(response.Content); + Assert.NotEmpty(response.Content); + Assert.Contains("Test error", response.Content[0].Text); } [Fact] @@ -252,37 +223,32 @@ public class McpServerBuilderExtensionsToolsTests var options = serviceProvider.GetRequiredService>().Value; var exception = await Assert.ThrowsAsync(async () => await options.CallToolHandler!(new(Mock.Of(), new() { Name = "NotRegisteredTool" }), CancellationToken.None)); - Assert.Equal("Unknown tool: NotRegisteredTool", exception.Message); + Assert.Contains("'NotRegisteredTool'", exception.Message); } - [Fact] - public async Task Throws_Exception_Missing_Parameter() - { - _builder.Object.WithTools(typeof(EchoTool)); + // TODO: https://github.com/dotnet/extensions/issues/6124 + //[Fact] + //public async Task Throws_Exception_Missing_Parameter() + //{ + // _builder.Object.WithTools(typeof(EchoTool)); - var serviceProvider = _services.BuildServiceProvider(); - var options = serviceProvider.GetRequiredService>().Value; + // var serviceProvider = _services.BuildServiceProvider(); + // var options = serviceProvider.GetRequiredService>().Value; - var exception = await Assert.ThrowsAsync(async () => await options.CallToolHandler!(new(Mock.Of(), new() { Name = "Echo" }), CancellationToken.None)); - Assert.Equal("Missing required argument 'message'.", exception.Message); - } + // var exception = await Assert.ThrowsAsync(async () => await options.CallToolHandler!(new(Mock.Of(), new() { Name = "Echo" }), CancellationToken.None)); + // Assert.Equal("Missing required argument 'message'.", exception.Message); + //} [Fact] public void Throws_Exception_For_Null_Types() { - var action = () => _builder.Object.WithTools(toolTypes: null!); - - var exception = Assert.Throws(action); - Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message); + Assert.Throws("toolTypes", () => _builder.Object.WithTools(toolTypes: null!)); } [Fact] - public void Throws_Exception_For_Empty_Types() + public void Empty_Types_Is_Allowed() { - var action = () => _builder.Object.WithTools(toolTypes: []); - - var exception = Assert.Throws(action); - Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message); + _builder.Object.WithTools(toolTypes: []); // no exception } [Fact] @@ -302,12 +268,9 @@ public class McpServerBuilderExtensionsToolsTests } [Fact] - public void Throws_Exception_When_No_Tools_Are_Found_In_Given_Assembly() + public void Ok_If_No_Tools_Are_Found_In_Given_Assembly() { - var action = () => _builder.Object.WithToolsFromAssembly(typeof(Mock).Assembly); - - var exception = Assert.Throws(action); - Assert.Equal("No types with marked methods found in the assembly. (Parameter 'assembly')", exception.Message); + _builder.Object.WithToolsFromAssembly(typeof(Mock).Assembly); } [Fact] @@ -324,14 +287,14 @@ public class McpServerBuilderExtensionsToolsTests var tool = result.Tools.First(t => t.Name == "TestTool"); Assert.Equal("TestTool", tool.Name); - Assert.Null(tool.Description); + Assert.Empty(tool.Description); Assert.NotNull(tool.InputSchema); Assert.Equal("object", tool.InputSchema.Type); Assert.NotNull(tool.InputSchema.Properties); Assert.NotEmpty(tool.InputSchema.Properties); Assert.Contains("number", tool.InputSchema.Properties); - Assert.Equal("number", tool.InputSchema.Properties["number"].Type); + Assert.Equal("integer", tool.InputSchema.Properties["number"].Type); Assert.Contains("otherNumber", tool.InputSchema.Properties); Assert.Equal("number", tool.InputSchema.Properties["otherNumber"].Type); @@ -398,7 +361,7 @@ public class McpServerBuilderExtensionsToolsTests } [McpTool] - public static string ReturnError() + public static string ThrowException() { throw new InvalidOperationException("Test error"); } diff --git a/tests/mcpdotnet.Tests/Server/McpServerTests.cs b/tests/mcpdotnet.Tests/Server/McpServerTests.cs index 39037ba..e8edec0 100644 --- a/tests/mcpdotnet.Tests/Server/McpServerTests.cs +++ b/tests/mcpdotnet.Tests/Server/McpServerTests.cs @@ -4,8 +4,10 @@ using McpDotNet.Protocol.Transport; using McpDotNet.Protocol.Types; using McpDotNet.Server; using McpDotNet.Tests.Utils; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Moq; +using System.Diagnostics; namespace McpDotNet.Tests.Server; @@ -98,8 +100,7 @@ public class McpServerTests server.GetType().GetField("_isInitializing", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.SetValue(server, true); // Act & Assert - var exception = await Assert.ThrowsAsync(() => server.StartAsync()); - Assert.Equal("Server is already initializing", exception.Message); + await Assert.ThrowsAsync(() => server.StartAsync()); } [Fact] @@ -159,8 +160,7 @@ public class McpServerTests var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None); // Act & Assert - var exception = await Assert.ThrowsAsync(action); - Assert.Equal("Client does not support sampling", exception.Message); + await Assert.ThrowsAsync("server", action); } [Fact] @@ -190,8 +190,7 @@ public class McpServerTests server.ClientCapabilities = new ClientCapabilities(); // Act & Assert - var exception = await Assert.ThrowsAsync(() => server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None)); - Assert.Equal("Client does not support roots", exception.Message); + await Assert.ThrowsAsync("server", () => server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None)); } [Fact] @@ -222,8 +221,7 @@ public class McpServerTests var action = async () => await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None); - var exception = await Assert.ThrowsAsync(action); - Assert.Equivalent("Transport is not connected", exception.Message); + await Assert.ThrowsAsync(action); } [Fact] @@ -563,4 +561,89 @@ public class McpServerTests Assert.Throws(() => McpServerFactory.Create(transport, options, _loggerFactory.Object, _serviceProvider)); } + + [Fact] + public async Task AsSamplingChatClient_NoSamplingSupport_Throws() + { + await using var server = new TestServerForIChatClient(supportsSampling: false); + + Assert.Throws("server", () => server.AsSamplingChatClient()); + } + + + [Fact] + public async Task AsSamplingChatClient_HandlesRequestResponse() + { + await using var server = new TestServerForIChatClient(supportsSampling: true); + + IChatClient client = server.AsSamplingChatClient(); + + ChatMessage[] messages = + [ + new (ChatRole.System, "You are a helpful assistant."), + new (ChatRole.User, "I am going to France."), + new (ChatRole.User, "What is the most famous tower in Paris?"), + new (ChatRole.System, "More system stuff."), + ]; + + ChatResponse response = await client.GetResponseAsync(messages, new ChatOptions + { + Temperature = 0.75f, + MaxOutputTokens = 42, + StopSequences = ["."], + }); + + Assert.Equal("amazingmodel", response.ModelId); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + Assert.Single(response.Messages); + Assert.Equal("The Eiffel Tower.", response.Text); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + } + + private sealed class TestServerForIChatClient(bool supportsSampling) : IMcpServer + { + public ClientCapabilities? ClientCapabilities => + supportsSampling ? new ClientCapabilities { Sampling = new SamplingCapability() } : + null; + + public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) where T : class + { + CreateMessageRequestParams rp = Assert.IsType(request.Params); + + Assert.Equal(0.75f, rp.Temperature); + Assert.Equal(42, rp.MaxTokens); + Assert.Equal(["."], rp.StopSequences); + Assert.Null(rp.IncludeContext); + Assert.Null(rp.Metadata); + Assert.Null(rp.ModelPreferences); + + Assert.Equal($"You are a helpful assistant.{Environment.NewLine}More system stuff.", rp.SystemPrompt); + + Assert.Equal(2, rp.Messages.Count); + Assert.Equal("I am going to France.", rp.Messages[0].Content.Text); + Assert.Equal("What is the most famous tower in Paris?", rp.Messages[1].Content.Text); + + CreateMessageResult result = new() + { + Content = new() { Text = "The Eiffel Tower.", Type = "text" }, + Model = "amazingmodel", + Role = "assistant", + StopReason = "endTurn", + }; + return (T)(object)result; + } + + public ValueTask DisposeAsync() => default; + + public bool IsInitialized => throw new NotImplementedException(); + + public Implementation? ClientInfo => throw new NotImplementedException(); + public IServiceProvider? ServiceProvider => throw new NotImplementedException(); + public void AddNotificationHandler(string method, Func handler) => + throw new NotImplementedException(); + public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + public Task StartAsync(CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } } diff --git a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj index c1d7664..fb8c869 100644 --- a/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj +++ b/tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj @@ -12,6 +12,7 @@ +