From ec9bdfcf5ddcc5cf4e8cbaca20f476c2c68a8ec2 Mon Sep 17 00:00:00 2001 From: Toni Wenzel Date: Fri, 7 Mar 2025 16:48:55 +0100 Subject: [PATCH] Improve tool handling (#28) * Improve tool handling * Rework stuff with new hosting options * Update readme * Fix build * Try run client tests * Fix build * Fix issues on linux * Try to run testserver on linux * Fix test issue * Fix code smells --- .github/workflows/ci.yml | 20 +- .gitignore | 3 +- README.MD | 29 +- samples/TestServerWithHosting/Program.cs | 1 + .../TestServerWithHosting/Tools/EchoTool.cs | 13 + .../Tools/SampleLlmTool.cs | 47 +++ ... => McpServerBuilderExtensions.Handler.cs} | 43 +-- .../McpServerBuilderExtensions.Tools.cs | 249 ++++++++++++++ .../McpServerBuilderExtensions.Transports.cs | 21 ++ src/mcpdotnet/Server/IMcpServer.cs | 23 +- src/mcpdotnet/Server/McpParameterAttribute.cs | 29 ++ src/mcpdotnet/Server/McpServer.cs | 317 ++++++++++-------- src/mcpdotnet/Server/McpServerDelegates.cs | 18 +- src/mcpdotnet/Server/McpServerFactory.cs | 7 +- src/mcpdotnet/Server/McpToolAttribute.cs | 27 ++ src/mcpdotnet/Server/McpToolTypeAttribute.cs | 9 + src/mcpdotnet/Server/RequestContext.cs | 10 + src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs | 4 +- .../IntegrationTests.cs | 2 +- .../McpDotNet.Extensions.AI.Tests.csproj | 1 - tests/mcpdotnet.TestServer/Program.cs | 52 +-- .../mcpdotnet.Tests/ClientIntegrationTests.cs | 1 - .../McpServerBuilderExtensionsHandlerTests.cs | 138 ++++++++ .../McpServerBuilderExtensionsToolsTests.cs | 163 +++++++++ ...pServerBuilderExtensionsTransportsTests.cs | 23 ++ .../ServerIntegrationTestFixture.cs | 5 +- .../mcpdotnet.Tests/ServerIntegrationTests.cs | 8 +- tests/mcpdotnet.Tests/mcpdotnet.Tests.csproj | 6 +- 28 files changed, 1035 insertions(+), 234 deletions(-) create mode 100644 samples/TestServerWithHosting/Tools/EchoTool.cs create mode 100644 samples/TestServerWithHosting/Tools/SampleLlmTool.cs rename src/mcpdotnet/Configuration/{McpServerBuilderExtensions.cs => McpServerBuilderExtensions.Handler.cs} (68%) create mode 100644 src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs create mode 100644 src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs create mode 100644 src/mcpdotnet/Server/McpParameterAttribute.cs create mode 100644 src/mcpdotnet/Server/McpToolAttribute.cs create mode 100644 src/mcpdotnet/Server/McpToolTypeAttribute.cs create mode 100644 src/mcpdotnet/Server/RequestContext.cs create mode 100644 tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs create mode 100644 tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs create mode 100644 tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e0a2f5..b6bed11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,8 @@ name: .NET Build, Test and publish on: push: branches: [ "main", "master" ] - paths: [ 'src/**' ] pull_request: branches: [ "main", "master" ] - paths: [ 'src/**' ] release: types: [published,edited] @@ -24,7 +22,12 @@ jobs: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Fetch all history for all tags and branches - run: git fetch --prune --unshallow + run: | + if [ -f "$(git rev-parse --git-dir)/shallow" ]; then + git fetch --prune --unshallow + else + echo "Repository is already complete." + fi - name: Setup .NET uses: actions/setup-dotnet@v2 @@ -33,6 +36,17 @@ jobs: 8.0.x 9.0.x + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies for tests + run: npm install @modelcontextprotocol/server-everything + + - name: Install dependencies for tests + run: npm install @modelcontextprotocol/server-memory + - name: Run Cake script uses: cake-build/cake-action@v1 with: diff --git a/.gitignore b/.gitignore index 1c7489e..879b5d7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ ehthumbs.db Desktop.ini # Recycle Bin used on file shares -$RECYCLE.BIN/ \ No newline at end of file +$RECYCLE.BIN/ +/mcpdotnet.v3.ncrunchsolution diff --git a/README.MD b/README.MD index db3f88e..c063c0b 100644 --- a/README.MD +++ b/README.MD @@ -106,7 +106,33 @@ Remember you can connect to any MCP server, not just ones created using mcpdotne ## Getting Started (Server) -Here is an example of how to create an MCP server with a single tool. You can also refer to the TestServer in the tests folder, which implements a wider range of capabilities. +Here is an example of how to create an MCP server and register all tools from the current application. + +```csharp +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithTools() + +var app = builder.Build(); + +await app.RunAsync(); +``` + +The `EchoTool` class is a simple example of a tool implementation: +```csharp +[McpToolType] +public static class EchoTool +{ + [McpTool(Description = "Echoes the input back to the client.")] + public static string Echo([McpParameter(true)] string message) + { + return "hello " + message; + } +} +``` + +Here is an example of how to create an MCP server manually with a single tool. You can also refer to the TestServer in the tests folder, which implements a wider range of capabilities. ```csharp McpServerOptions options = new McpServerOptions() @@ -171,6 +197,7 @@ while (true) } ``` + ## Roadmap - Expand documentation with detailed guides for: diff --git a/samples/TestServerWithHosting/Program.cs b/samples/TestServerWithHosting/Program.cs index 4f0770c..f1bdf7c 100644 --- a/samples/TestServerWithHosting/Program.cs +++ b/samples/TestServerWithHosting/Program.cs @@ -19,6 +19,7 @@ try builder.Services.AddSerilog(); builder.Services.AddMcpServer() .WithStdioServerTransport() + .WithTools() .WithCallToolHandler((r, ct) => Task.FromResult(new McpDotNet.Protocol.Types.CallToolResponse())); var app = builder.Build(); diff --git a/samples/TestServerWithHosting/Tools/EchoTool.cs b/samples/TestServerWithHosting/Tools/EchoTool.cs new file mode 100644 index 0000000..4b3ba0d --- /dev/null +++ b/samples/TestServerWithHosting/Tools/EchoTool.cs @@ -0,0 +1,13 @@ +using McpDotNet.Server; + +namespace TestServerWithHosting.Tools; + +[McpToolType] +public static class EchoTool +{ + [McpTool(Description = "Echoes the input back to the client.")] + public static string Echo([McpParameter(true)] string message) + { + return "hello " + message; + } +} diff --git a/samples/TestServerWithHosting/Tools/SampleLlmTool.cs b/samples/TestServerWithHosting/Tools/SampleLlmTool.cs new file mode 100644 index 0000000..2ed764e --- /dev/null +++ b/samples/TestServerWithHosting/Tools/SampleLlmTool.cs @@ -0,0 +1,47 @@ +using McpDotNet.Protocol.Types; +using McpDotNet.Server; + +namespace TestServerWithHosting.Tools; + +/// +/// This tool uses depenency injection and async method +/// +[McpToolType] +public class SampleLlmTool +{ + private readonly IMcpServer _server; + + public SampleLlmTool(IMcpServer server) + { + _server = server ?? throw new ArgumentNullException(nameof(server)); + } + + [McpTool(Name = "sampleLLM", Description = "Samples from an LLM using MCP's sampling feature")] + public async Task SampleLLM([McpParameter(true, Description = "The prompt to send to the LLM")] string prompt, [McpParameter(true, Description = "Maximum number of tokens to generate")] int maxTokens, CancellationToken cancellationToken) + { + var samplingParams = CreateRequestSamplingParams(prompt ?? string.Empty, "sampleLLM", maxTokens); + var sampleResult = await _server.RequestSamplingAsync(samplingParams, cancellationToken); + + return $"LLM sampling result: {sampleResult.Content.Text}"; + } + + private static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) + { + return new CreateMessageRequestParams() + { + Messages = [new SamplingMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = $"Resource {uri} context: {context}" + } + }], + SystemPrompt = "You are a helpful test server.", + MaxTokens = maxTokens, + Temperature = 0.7f, + IncludeContext = ContextInclusion.ThisServer + }; + } +} diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Handler.cs similarity index 68% rename from src/mcpdotnet/Configuration/McpServerBuilderExtensions.cs rename to src/mcpdotnet/Configuration/McpServerBuilderExtensions.Handler.cs index b97171a..22bdd8f 100644 --- a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.cs +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Handler.cs @@ -1,5 +1,4 @@ using McpDotNet.Configuration; -using McpDotNet.Protocol.Transport; using McpDotNet.Protocol.Types; using McpDotNet.Server; using Microsoft.Extensions.DependencyInjection; @@ -7,28 +6,16 @@ using Microsoft.Extensions.DependencyInjection; namespace McpDotNet; /// -/// Extension to configure the MCP server +/// Extension to configure the MCP server with handlers /// -public static class McpServerBuilderExtensions +public static partial class McpServerBuilderExtensions { - /// - /// Adds a server transport that uses stdin/stdout for communication. - /// - /// The builder instance. - /// - public static IMcpServerBuilder WithStdioServerTransport(this IMcpServerBuilder builder) - { - builder.Services.AddSingleton(); - return builder; - } - /// /// Sets the handler for list tools requests. /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithListToolsHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithListToolsHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.ListToolsHandler = handler); return builder; @@ -39,8 +26,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithCallToolHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithCallToolHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.CallToolHandler = handler); return builder; @@ -51,8 +37,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithListPromptsHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithListPromptsHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.ListPromptsHandler = handler); return builder; @@ -63,8 +48,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithGetPromptHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithGetPromptHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.GetPromptHandler = handler); return builder; @@ -75,8 +59,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithListResourcesHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithListResourcesHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.ListResourcesHandler = handler); return builder; @@ -87,8 +70,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithReadResourceHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithReadResourceHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.ReadResourceHandler = handler); return builder; @@ -99,8 +81,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithGetCompletionHandler(this IMcpServerBuilder builder, Func> handler) + public static IMcpServerBuilder WithGetCompletionHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.GetCompletionHandler = handler); return builder; @@ -111,8 +92,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithSubscribeToResourcesHandler(this IMcpServerBuilder builder, Func handler) + public static IMcpServerBuilder WithSubscribeToResourcesHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.SubscribeToResourcesHandler = handler); return builder; @@ -123,8 +103,7 @@ public static class McpServerBuilderExtensions /// /// The builder instance. /// The handler. - /// - public static IMcpServerBuilder WithUnsubscribeFromResourcesHandler(this IMcpServerBuilder builder, Func handler) + public static IMcpServerBuilder WithUnsubscribeFromResourcesHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) { builder.Services.Configure(s => s.UnsubscribeFromResourcesHandler = handler); return builder; diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs new file mode 100644 index 0000000..c2feb80 --- /dev/null +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Tools.cs @@ -0,0 +1,249 @@ +using System.Reflection; +using System.Text.Json; +using McpDotNet.Configuration; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using Microsoft.Extensions.DependencyInjection; + +namespace McpDotNet; + +/// +/// Extension to configure the MCP server with tools +/// +public static partial class McpServerBuilderExtensions +{ + /// + /// Adds a tool to the server. + /// + /// The tool type. + /// The builder instance. + public static IMcpServerBuilder WithTool(this IMcpServerBuilder builder) + { + return WithTools(builder, typeof(TTool)); + } + /// + /// Adds all tools marked with from the current assembly to the server. + /// + /// The builder instance. + public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder) + { + return WithToolsFromAssembly(builder, Assembly.GetCallingAssembly()); + } + + /// + /// Adds tools to the server. + /// + /// The builder instance. + /// Types with marked methods to add as tools to the server. + public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params Type[] toolTypes) + { + ArgumentNullException.ThrowIfNull(toolTypes); + if (toolTypes.Length == 0) + throw new ArgumentException("At least one tool type must be provided.", nameof(toolTypes)); + + var tools = new List(); + Dictionary, CancellationToken, Task>> callbacks = []; + + foreach (var type in toolTypes) + { + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); + foreach (var method in methods) + { + 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)); + + // register type because method is not static and so we need an instance + if (!method.IsStatic) + builder.Services.AddScoped(type); + } + } + } + + 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); + + throw new McpServerException($"Unknown tool: {request.Params?.Name}"); + }); + + return builder; + } + + /// + /// Adds types marked with the attribute from the given assembly as tools to the server. + /// + /// The builder instance. + /// The assembly to load the types from. Null to get the current assembly + public static IMcpServerBuilder WithToolsFromAssembly(this IMcpServerBuilder builder, Assembly? assembly = null) + { + assembly ??= Assembly.GetCallingAssembly(); + + List toolTypes = []; + + 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) + { + var attribute = method.GetCustomAttribute(); + if (attribute != null) + { + toolTypes.Add(type); + break; + } + } + } + + if (toolTypes.Count == 0) + throw new ArgumentException("No types with marked methods found in the assembly.", nameof(assembly)); + + return WithTools(builder, toolTypes.ToArray()); + } + + private static Tool CreateTool(MethodInfo method, McpToolAttribute attribute) + { + Dictionary properties = []; + List? requiredProperties = null; + + foreach (var parameter in method.GetParameters()) + { + if (parameter.ParameterType == typeof(CancellationToken)) + continue; + + var parameterAttribute = parameter.GetCustomAttribute(); + + properties.Add(parameter.Name ?? "NoName", new JsonSchemaProperty() + { + Type = GetParameterType(parameter.ParameterType), + Description = parameterAttribute?.Description + }); + + if (parameterAttribute?.Required == true) + { + requiredProperties ??= []; + requiredProperties.Add(parameter.Name ?? "NoName"); + } + } + + return new Tool() + { + Name = attribute.Name ?? method.Name, + Description = attribute.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) => "string", + _ => "object" + }; + } + + private static async Task CallTool(RequestContext request, MethodInfo method, CancellationToken cancellationToken) + { + var methodParameters = method.GetParameters(); + List parameters = ResolveParameters(request, methodParameters, cancellationToken); + + if (cancellationToken.IsCancellationRequested) + return new CallToolResponse { Content = [new Content { Text = "Operation was cancelled" }] }; + + try + { + using var scope = request.Server.ServiceProvider?.CreateScope(); + var objectInstance = CreateObjectInstance(method, scope?.ServiceProvider); + var result = method.Invoke(objectInstance, parameters.ToArray()); + + + 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" }).ToList() }; + + 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" }] }; + } + catch (TargetInvocationException e) + { + throw new McpServerException(e.Message, e); + } + } + + 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)); + } + else + { + var parameterAttribute = parameter.GetCustomAttribute(); + + if (parameterAttribute?.Required == true) + throw new McpServerException($"Missing required argument '{parameter.Name}'."); + + parameters.Add(parameter.HasDefaultValue ? parameter.DefaultValue : null); + } + } + + 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!); + } +} diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs new file mode 100644 index 0000000..f64cc14 --- /dev/null +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs @@ -0,0 +1,21 @@ +using McpDotNet.Configuration; +using McpDotNet.Protocol.Transport; +using Microsoft.Extensions.DependencyInjection; + +namespace McpDotNet; + +/// +/// Extension to configure the MCP server with transports +/// +public static partial class McpServerBuilderExtensions +{ + /// + /// Adds a server transport that uses stdin/stdout for communication. + /// + /// The builder instance. + public static IMcpServerBuilder WithStdioServerTransport(this IMcpServerBuilder builder) + { + builder.Services.AddSingleton(); + return builder; + } +} diff --git a/src/mcpdotnet/Server/IMcpServer.cs b/src/mcpdotnet/Server/IMcpServer.cs index 6c533f6..d8cd4c5 100644 --- a/src/mcpdotnet/Server/IMcpServer.cs +++ b/src/mcpdotnet/Server/IMcpServer.cs @@ -23,6 +23,11 @@ public interface IMcpServer : IAsyncDisposable /// Implementation? ClientInfo { get; } + /// + /// Gets the service provider for the server. + /// + IServiceProvider? ServiceProvider { get; } + /// /// Starts the server and begins listening for client requests. /// @@ -31,47 +36,47 @@ public interface IMcpServer : IAsyncDisposable /// /// Gets or sets the handler for list tools requests. /// - Func>? ListToolsHandler { get; set; } + Func, CancellationToken, Task>? ListToolsHandler { get; set; } /// /// Gets or sets the handler for call tool requests. /// - Func>? CallToolHandler { get; set; } + Func, CancellationToken, Task>? CallToolHandler { get; set; } /// /// Gets or sets the handler for list prompts requests. /// - Func>? ListPromptsHandler { get; set; } + Func, CancellationToken, Task>? ListPromptsHandler { get; set; } /// /// Gets or sets the handler for get prompt requests. /// - Func>? GetPromptHandler { get; set; } + Func, CancellationToken, Task>? GetPromptHandler { get; set; } /// /// Gets or sets the handler for list resources requests. /// - Func>? ListResourcesHandler { get; set; } + Func, CancellationToken, Task>? ListResourcesHandler { get; set; } /// /// Gets or sets the handler for read resources requests. /// - Func>? ReadResourceHandler { get; set; } + Func, CancellationToken, Task>? ReadResourceHandler { get; set; } /// /// Gets or sets the handler for get resources requests. /// - Func>? GetCompletionHandler { get; set; } + Func, CancellationToken, Task>? GetCompletionHandler { get; set; } /// /// Gets or sets the handler for subscribe to resources messages. /// - Func? SubscribeToResourcesHandler { get; set; } + Func, CancellationToken, Task>? SubscribeToResourcesHandler { get; set; } /// /// Gets or sets the handler for subscribe to resources messages. /// - Func? UnsubscribeFromResourcesHandler { get; set; } + Func, CancellationToken, Task>? UnsubscribeFromResourcesHandler { get; set; } /// diff --git a/src/mcpdotnet/Server/McpParameterAttribute.cs b/src/mcpdotnet/Server/McpParameterAttribute.cs new file mode 100644 index 0000000..6bdf9e3 --- /dev/null +++ b/src/mcpdotnet/Server/McpParameterAttribute.cs @@ -0,0 +1,29 @@ +namespace McpDotNet.Server; + +/// +/// Attribute to mark a method parameter +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class McpParameterAttribute : Attribute +{ + /// + /// Defines if the parameter is required. + /// + public bool Required { get; set; } + + /// + /// A description of the parameter. + /// + public string? Description { get; set; } + + /// + /// Attribute to mark a method as an MCP tool. + /// + /// True if the parameter is mandatory. + /// A description of the tool. + public McpParameterAttribute(bool required = false, string? description = null) + { + Required = required; + Description = description; + } +} diff --git a/src/mcpdotnet/Server/McpServer.cs b/src/mcpdotnet/Server/McpServer.cs index 8aef11f..8d27bd8 100644 --- a/src/mcpdotnet/Server/McpServer.cs +++ b/src/mcpdotnet/Server/McpServer.cs @@ -12,8 +12,8 @@ namespace McpDotNet.Server; /// internal class McpServer : McpJsonRpcEndpoint, IMcpServer { - private IServerTransport _serverTransport; - private McpServerOptions _options; + private readonly IServerTransport _serverTransport; + private readonly McpServerOptions _options; private volatile bool _isInitializing; private readonly ILogger _logger; @@ -24,142 +24,24 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer /// Configuration options for this server, including capabilities. /// Make sure to accurately reflect exactly what capabilities the server supports and does not support. /// Logger factory to use for logging + /// Optional service provider to use for dependency injection /// - public McpServer(IServerTransport transport, McpServerOptions options, ILoggerFactory loggerFactory) + public McpServer(IServerTransport transport, McpServerOptions options, ILoggerFactory loggerFactory, IServiceProvider? serviceProvider) : base(transport, loggerFactory) { _serverTransport = transport; _options = options; _logger = loggerFactory.CreateLogger(); ServerInstructions = options.ServerInstructions; + ServiceProvider = serviceProvider; - if (options.Capabilities?.Tools != null) - { - SetRequestHandler("tools/list", - async (request) => - { - if (ListToolsHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.ListToolsHandlerNotConfigured(EndpointName); - throw new McpServerException("ListTools handler not configured"); - } - - return await ListToolsHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - - SetRequestHandler("tools/call", - async (request) => - { - if (CallToolHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.CallToolHandlerNotConfigured(EndpointName); - throw new McpServerException("CallTool handler not configured"); - } - - return await CallToolHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - } - if (options.Capabilities?.Prompts != null) - { - SetRequestHandler("prompts/list", - async (request) => - { - if (ListPromptsHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.ListPromptsHandlerNotConfigured(EndpointName); - throw new McpServerException("ListPrompts handler not configured"); - } - - return await ListPromptsHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - - SetRequestHandler("prompts/get", - async (request) => - { - if (GetPromptHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.GetPromptHandlerNotConfigured(EndpointName); - throw new McpServerException("GetPrompt handler not configured"); - } - - return await GetPromptHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - } - if (options.Capabilities?.Resources != null) - { - SetRequestHandler("resources/list", - async (request) => - { - if (ListResourcesHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.ListResourcesHandlerNotConfigured(EndpointName); - throw new McpServerException("ListResources handler not configured"); - } - - return await ListResourcesHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - - SetRequestHandler("resources/read", - async (request) => - { - if (ReadResourceHandler == null) - { - // Setting the capability, but not a handler means we have nothing to return to the server - _logger.ReadResourceHandlerNotConfigured(EndpointName); - throw new McpServerException("ReadResource handler not configured"); - } - - return await ReadResourceHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - } - SetRequestHandler("completion/complete", - async (request) => - { - if (GetCompletionHandler == null) - { - // This capability is not optional, so return an empty result if there is no handler - return new CompleteResult() - { - Completion = new() - { - Values = [], - Total = 0, - HasMore = false - } - }; - } - - return await GetCompletionHandler(request, CancellationTokenSource?.Token ?? CancellationToken.None); - }); - SetRequestHandler("initialize", - (request) => - { - ClientCapabilities = request?.Capabilities ?? new(); - ClientInfo = request?.ClientInfo; - return Task.FromResult(new InitializeResult() - { - ProtocolVersion = options.ProtocolVersion, - Instructions = ServerInstructions, - ServerInfo = _options.ServerInfo, - Capabilities = options.Capabilities ?? new ServerCapabilities(), - }); - }); - SetRequestHandler("ping", - (request) => - { - return Task.FromResult(new PingResult()); - }); - - OnNotification("notifications/initialized", (notification) => - { - IsInitialized = true; - return Task.CompletedTask; - }); + SetToolsHandler(options); + SetPromptsHandler(options); + SetResourcesHandler(options); + SetCompletionHandler(); + SetInitializeHandler(options); + SetPingHandler(); + SetNotificationHandler(); } public ClientCapabilities? ClientCapabilities { get; set; } @@ -171,31 +53,34 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer public string? ServerInstructions { get; set; } /// - public Func>? ListToolsHandler { get; set; } + public IServiceProvider? ServiceProvider { get; } /// - public Func>? CallToolHandler { get; set; } + public Func, CancellationToken, Task>? ListToolsHandler { get; set; } /// - public Func>? ListPromptsHandler { get; set; } + public Func, CancellationToken, Task>? CallToolHandler { get; set; } /// - public Func>? GetPromptHandler { get; set; } + public Func, CancellationToken, Task>? ListPromptsHandler { get; set; } /// - public Func>? ListResourcesHandler { get; set; } + public Func, CancellationToken, Task>? GetPromptHandler { get; set; } /// - public Func>? ReadResourceHandler { get; set; } + public Func, CancellationToken, Task>? ListResourcesHandler { get; set; } /// - public Func>? GetCompletionHandler { get; set; } + public Func, CancellationToken, Task>? ReadResourceHandler { get; set; } /// - public Func? SubscribeToResourcesHandler { get; set; } + public Func, CancellationToken, Task>? GetCompletionHandler { get; set; } /// - public Func? UnsubscribeFromResourcesHandler { get; set; } + public Func, CancellationToken, Task>? SubscribeToResourcesHandler { get; set; } + + /// + public Func, CancellationToken, Task>? UnsubscribeFromResourcesHandler { get; set; } /// public override string EndpointName @@ -277,4 +162,158 @@ internal class McpServer : McpJsonRpcEndpoint, IMcpServer cancellationToken ).ConfigureAwait(false); } + + private void SetNotificationHandler() + { + OnNotification("notifications/initialized", (notification) => + { + IsInitialized = true; + return Task.CompletedTask; + }); + } + + private void SetPingHandler() + { + SetRequestHandler("ping", + (request) => + { + return Task.FromResult(new PingResult()); + }); + } + + private void SetInitializeHandler(McpServerOptions options) + { + SetRequestHandler("initialize", + (request) => + { + ClientCapabilities = request?.Capabilities ?? new(); + ClientInfo = request?.ClientInfo; + return Task.FromResult(new InitializeResult() + { + ProtocolVersion = options.ProtocolVersion, + Instructions = ServerInstructions, + ServerInfo = _options.ServerInfo, + Capabilities = options.Capabilities ?? new ServerCapabilities(), + }); + }); + } + + private void SetCompletionHandler() + { + SetRequestHandler("completion/complete", + async (request) => + { + if (GetCompletionHandler == null) + { + // This capability is not optional, so return an empty result if there is no handler + return new CompleteResult() + { + Completion = new() + { + Values = [], + Total = 0, + HasMore = false + } + }; + } + + return await GetCompletionHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + + private void SetResourcesHandler(McpServerOptions options) + { + if (options.Capabilities?.Resources != null) + { + SetRequestHandler("resources/list", + async (request) => + { + if (ListResourcesHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.ListResourcesHandlerNotConfigured(EndpointName); + throw new McpServerException("ListResources handler not configured"); + } + + return await ListResourcesHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + + SetRequestHandler("resources/read", + async (request) => + { + if (ReadResourceHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.ReadResourceHandlerNotConfigured(EndpointName); + throw new McpServerException("ReadResource handler not configured"); + } + + return await ReadResourceHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + } + + private void SetPromptsHandler(McpServerOptions options) + { + if (options.Capabilities?.Prompts != null) + { + SetRequestHandler("prompts/list", + async (request) => + { + if (ListPromptsHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.ListPromptsHandlerNotConfigured(EndpointName); + throw new McpServerException("ListPrompts handler not configured"); + } + + return await ListPromptsHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + + SetRequestHandler("prompts/get", + async (request) => + { + if (GetPromptHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.GetPromptHandlerNotConfigured(EndpointName); + throw new McpServerException("GetPrompt handler not configured"); + } + + return await GetPromptHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + } + + private void SetToolsHandler(McpServerOptions options) + { + if (options.Capabilities?.Tools != null) + { + SetRequestHandler("tools/list", + async (request) => + { + if (ListToolsHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.ListToolsHandlerNotConfigured(EndpointName); + throw new McpServerException("ListTools handler not configured"); + } + + return await ListToolsHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + + SetRequestHandler("tools/call", + async (request) => + { + if (CallToolHandler == null) + { + // Setting the capability, but not a handler means we have nothing to return to the server + _logger.CallToolHandlerNotConfigured(EndpointName); + throw new McpServerException("CallTool handler not configured"); + } + + return await CallToolHandler(new(this, request), CancellationTokenSource?.Token ?? CancellationToken.None); + }); + } + } } diff --git a/src/mcpdotnet/Server/McpServerDelegates.cs b/src/mcpdotnet/Server/McpServerDelegates.cs index f5f0de7..e9f3d76 100644 --- a/src/mcpdotnet/Server/McpServerDelegates.cs +++ b/src/mcpdotnet/Server/McpServerDelegates.cs @@ -10,47 +10,47 @@ public class McpServerDelegates /// /// Gets or sets the handler for list tools requests. /// - public Func>? ListToolsHandler { get; set; } + public Func, CancellationToken, Task>? ListToolsHandler { get; set; } /// /// Gets or sets the handler for call tool requests. /// - public Func>? CallToolHandler { get; set; } + public Func, CancellationToken, Task>? CallToolHandler { get; set; } /// /// Gets or sets the handler for list prompts requests. /// - public Func>? ListPromptsHandler { get; set; } + public Func, CancellationToken, Task>? ListPromptsHandler { get; set; } /// /// Gets or sets the handler for get prompt requests. /// - public Func>? GetPromptHandler { get; set; } + public Func, CancellationToken, Task>? GetPromptHandler { get; set; } /// /// Gets or sets the handler for list resources requests. /// - public Func>? ListResourcesHandler { get; set; } + public Func, CancellationToken, Task>? ListResourcesHandler { get; set; } /// /// Gets or sets the handler for read resources requests. /// - public Func>? ReadResourceHandler { get; set; } + public Func, CancellationToken, Task>? ReadResourceHandler { get; set; } /// /// Gets or sets the handler for get resources requests. /// - public Func>? GetCompletionHandler { get; set; } + public Func, CancellationToken, Task>? GetCompletionHandler { get; set; } /// /// Gets or sets the handler for subscribe to resources messages. /// - public Func? SubscribeToResourcesHandler { get; set; } + public Func, CancellationToken, Task>? SubscribeToResourcesHandler { get; set; } /// /// Gets or sets the handler for subscribe to resources messages. /// - public Func? UnsubscribeFromResourcesHandler { get; set; } + public Func, CancellationToken, Task>? UnsubscribeFromResourcesHandler { get; set; } /// /// Applies the delegates to the server. diff --git a/src/mcpdotnet/Server/McpServerFactory.cs b/src/mcpdotnet/Server/McpServerFactory.cs index 37cfd93..07146d5 100644 --- a/src/mcpdotnet/Server/McpServerFactory.cs +++ b/src/mcpdotnet/Server/McpServerFactory.cs @@ -20,6 +20,7 @@ public class McpServerFactory : IMcpServerFactory private readonly McpServerOptions _options; private readonly ILoggerFactory _loggerFactory; private readonly McpServerDelegates? _serverDelegates; + private readonly IServiceProvider? _serviceProvider; /// /// Initializes a new instance of the class. @@ -27,14 +28,16 @@ public class McpServerFactory : IMcpServerFactory /// Transport to use for the server /// Configuration options for this server, including capabilities. /// Make sure to accurately reflect exactly what capabilities the server supports and does not support. + /// Optional service provider to create new instances. /// Logger factory to use for logging /// - public McpServerFactory(IServerTransport serverTransport, McpServerOptions options, ILoggerFactory loggerFactory, IOptions? serverDelegates = null) + public McpServerFactory(IServerTransport serverTransport, McpServerOptions options, ILoggerFactory loggerFactory, IOptions? serverDelegates = null, IServiceProvider? serviceProvider = null) { _serverTransport = serverTransport ?? throw new ArgumentNullException(nameof(serverTransport)); _options = options ?? throw new ArgumentNullException(nameof(options)); _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); _serverDelegates = serverDelegates?.Value; + _serviceProvider = serviceProvider; } /// @@ -44,7 +47,7 @@ public class McpServerFactory : IMcpServerFactory /// public IMcpServer CreateServer() { - var server = new McpServer(_serverTransport, _options, _loggerFactory); + var server = new McpServer(_serverTransport, _options, _loggerFactory, _serviceProvider); _serverDelegates?.Apply(server); diff --git a/src/mcpdotnet/Server/McpToolAttribute.cs b/src/mcpdotnet/Server/McpToolAttribute.cs new file mode 100644 index 0000000..5e0fd1f --- /dev/null +++ b/src/mcpdotnet/Server/McpToolAttribute.cs @@ -0,0 +1,27 @@ +namespace McpDotNet.Server; + +/// +/// Attribute to mark a method as an MCP tool. +/// +[AttributeUsage(AttributeTargets.Method)] +public class McpToolAttribute : Attribute +{ + /// The name of the tool. If not provided, the class name will be used. + public string? Name { get; set; } + + /// + /// A description of the tool. + /// + public string? Description { get; set; } + + /// + /// Attribute to mark a method as an MCP tool. + /// + /// The name of the tool. If not provided, the class name will be used. + /// A description of the tool. + public McpToolAttribute(string? name = null, string? description = null) + { + Name = name; + Description = description; + } +} diff --git a/src/mcpdotnet/Server/McpToolTypeAttribute.cs b/src/mcpdotnet/Server/McpToolTypeAttribute.cs new file mode 100644 index 0000000..01e8015 --- /dev/null +++ b/src/mcpdotnet/Server/McpToolTypeAttribute.cs @@ -0,0 +1,9 @@ +namespace McpDotNet.Server; + +/// +/// Attribute to mark a type as container for MCP tools. +/// +[AttributeUsage(AttributeTargets.Class)] +public class McpToolTypeAttribute : Attribute +{ +} diff --git a/src/mcpdotnet/Server/RequestContext.cs b/src/mcpdotnet/Server/RequestContext.cs new file mode 100644 index 0000000..e1be830 --- /dev/null +++ b/src/mcpdotnet/Server/RequestContext.cs @@ -0,0 +1,10 @@ +namespace McpDotNet.Server; + +/// +/// Container for the request context. +/// +/// Type of the request parameters +public record RequestContext(IMcpServer Server, TParams? Params) +{ + +} diff --git a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs index 0f1b236..7c0f57f 100644 --- a/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs +++ b/src/mcpdotnet/Shared/McpJsonRpcEndpoint.cs @@ -21,7 +21,7 @@ internal abstract class McpJsonRpcEndpoint private readonly ITransport _transport; private readonly ConcurrentDictionary> _pendingRequests; private readonly ConcurrentDictionary>> _notificationHandlers; - private readonly Dictionary>> _requestHandlers = []; + private readonly Dictionary>> _requestHandlers = []; private int _nextRequestId; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; @@ -349,7 +349,7 @@ internal abstract class McpJsonRpcEndpoint /// Type of response payload (not full RPC response /// Method identifier to register for /// Handler to be called when a request with specified method identifier is received - protected void SetRequestHandler(string method, Func> handler) where TResponse : class + protected void SetRequestHandler(string method, Func> handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs b/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs index 020fb0d..0d3c092 100644 --- a/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs +++ b/tests/McpDotNet.Extensions.AI.Tests/IntegrationTests.cs @@ -17,7 +17,7 @@ public class IntegrationTests { Id = "everything", Name = "Everything", - TransportType = McpDotNet.Protocol.Transport.TransportTypes.StdIo, + TransportType = "stdio", TransportOptions = new Dictionary { ["command"] = "npx", diff --git a/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj b/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj index 40484c0..643c3be 100644 --- a/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj +++ b/tests/McpDotNet.Extensions.AI.Tests/McpDotNet.Extensions.AI.Tests.csproj @@ -27,7 +27,6 @@ - diff --git a/tests/mcpdotnet.TestServer/Program.cs b/tests/mcpdotnet.TestServer/Program.cs index eb0c3ed..70744df 100644 --- a/tests/mcpdotnet.TestServer/Program.cs +++ b/tests/mcpdotnet.TestServer/Program.cs @@ -5,7 +5,7 @@ using McpDotNet.Server; using Microsoft.Extensions.Logging; using Serilog; -internal class Program +internal static class Program { private static ILoggerFactory CreateLoggerFactory() { @@ -17,7 +17,6 @@ internal class Program outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); - var logsPath = Path.Combine(AppContext.BaseDirectory, "testserver.log"); return LoggerFactory.Create(builder => { builder.AddSerilog(); @@ -26,7 +25,7 @@ internal class Program private static async Task Main(string[] args) { - Console.WriteLine("Starting server..."); + Log.Logger.Information("Starting server..."); McpServerOptions options = new McpServerOptions() { @@ -40,11 +39,12 @@ internal class Program ProtocolVersion = "2024-11-05", ServerInstructions = "This is a test server with only stub functionality", }; + var loggerFactory = CreateLoggerFactory(); McpServerFactory factory = new McpServerFactory(new StdioServerTransport("TestServer", loggerFactory), options, loggerFactory); IMcpServer server = factory.CreateServer(); - Console.WriteLine("Server object created, registering handlers."); + Log.Logger.Information("Server object created, registering handlers."); #region Helped method static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) @@ -69,6 +69,7 @@ internal class Program #endregion #region Tools + server.ListToolsHandler = (request, cancellationToken) => { return Task.FromResult(new ListToolsResult() @@ -108,22 +109,22 @@ internal class Program server.CallToolHandler = async (request, cancellationToken) => { - if (request?.Name == "echo") + if (request.Params?.Name == "echo") { - if (request.Arguments is null || !request.Arguments.TryGetValue("message", out var message)) + if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message)) { throw new McpServerException("Missing required argument 'message'"); } return new CallToolResponse() { - Content = [new Content() { Text = "Echo: " + message.ToString(), Type = "text" }] + Content = [new Content() { Text = "Echo: " + message?.ToString(), Type = "text" }] }; } - else if (request?.Name == "sampleLLM") + else if (request.Params?.Name == "sampleLLM") { - if (request.Arguments is null || - !request.Arguments.TryGetValue("prompt", out var prompt) || - !request.Arguments.TryGetValue("maxTokens", out var maxTokens)) + if (request.Params?.Arguments is null || + !request.Params.Arguments.TryGetValue("prompt", out var prompt) || + !request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens)) { throw new McpServerException("Missing required arguments 'prompt' and 'maxTokens'"); } @@ -137,7 +138,7 @@ internal class Program } else { - throw new McpServerException($"Unknown tool: {request?.Name}"); + throw new McpServerException($"Unknown tool: {request.Params?.Name}"); } }; #endregion @@ -186,12 +187,12 @@ internal class Program server.ListResourcesHandler = (request, cancellationToken) => { int startIndex = 0; - if (request is null) request = new(); - if (request.Cursor is not null) + request ??= new(server, new()); + if (request.Params?.Cursor is not null) { try { - var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)); + var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor)); startIndex = Convert.ToInt32(startIndexAsString); } catch @@ -216,12 +217,11 @@ internal class Program server.ReadResourceHandler = (request, cancellationToken) => { - if (request?.Uri is null) + if (request.Params?.Uri is null) { throw new McpServerException("Missing required argument 'uri'"); } - - ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Uri) + ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ?? throw new McpServerException("Resource not found"); return Task.FromResult(new ReadResourceResult() @@ -268,8 +268,8 @@ internal class Program server.GetPromptHandler = (request, cancellationToken) => { - List messages = []; - if (request?.Name == "simple_prompt") + List messages = new(); + if (request.Params?.Name == "simple_prompt") { messages.Add(new PromptMessage() { @@ -281,10 +281,10 @@ internal class Program } }); } - else if (request?.Name == "complex_prompt") + else if (request.Params?.Name == "complex_prompt") { - string temperature = request.Arguments?["temperature"]?.ToString() ?? "unknown"; - string style = request.Arguments?["style"]?.ToString() ?? "unknown"; + string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown"; + string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown"; messages.Add(new PromptMessage() { Role = Role.User, @@ -316,7 +316,7 @@ internal class Program } else { - throw new McpServerException($"Unknown prompt: {request?.Name}"); + throw new McpServerException($"Unknown prompt: {request.Params?.Name}"); } return Task.FromResult(new GetPromptResult() @@ -329,11 +329,11 @@ internal class Program #region Sampling #endregion - Console.WriteLine("Server initialized."); + Log.Logger.Information("Server initialized."); await server.StartAsync(); - Console.WriteLine("Server started."); + Log.Logger.Information("Server started."); // Run until process is stopped by the client (parent process) while (true) diff --git a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs index 5d53957..a01271c 100644 --- a/tests/mcpdotnet.Tests/ClientIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/ClientIntegrationTests.cs @@ -6,7 +6,6 @@ using McpDotNet.Protocol.Types; namespace McpDotNet.Tests; -[Trait("Execution", "Manual")] public class ClientIntegrationTests : IClassFixture { private readonly ClientIntegrationTestFixture _fixture; diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs new file mode 100644 index 0000000..262e32d --- /dev/null +++ b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsHandlerTests.cs @@ -0,0 +1,138 @@ +using McpDotNet.Configuration; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; + +namespace McpDotNet.Tests; + +public class McpServerBuilderExtensionsHandlerTests +{ + private readonly Mock _builder; + private readonly ServiceCollection _services; + + public McpServerBuilderExtensionsHandlerTests() + { + _services = new ServiceCollection(); + _builder = new Mock(); + _builder.SetupGet(b => b.Services).Returns(_services); + } + + [Fact] + public void WithListToolsHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new ListToolsResult()); + + _builder.Object.WithListToolsHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.ListToolsHandler); + } + + [Fact] + public void WithCallToolHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new CallToolResponse()); + + _builder.Object.WithCallToolHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.CallToolHandler); + } + + [Fact] + public void WithListPromptsHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new ListPromptsResult()); + + _builder.Object.WithListPromptsHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.ListPromptsHandler); + } + + [Fact] + public void WithGetPromptHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new GetPromptResult()); + + _builder.Object.WithGetPromptHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.GetPromptHandler); + } + + [Fact] + public void WithListResourcesHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new ListResourcesResult()); + + _builder.Object.WithListResourcesHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.ListResourcesHandler); + } + + [Fact] + public void WithReadResourceHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new ReadResourceResult()); + + _builder.Object.WithReadResourceHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.ReadResourceHandler); + } + + [Fact] + public void WithGetCompletionHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new CompleteResult()); + + _builder.Object.WithGetCompletionHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.GetCompletionHandler); + } + + [Fact] + public void WithSubscribeToResourcesHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.CompletedTask; + + _builder.Object.WithSubscribeToResourcesHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.SubscribeToResourcesHandler); + } + + [Fact] + public void WithUnsubscribeFromResourcesHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.CompletedTask; + + _builder.Object.WithUnsubscribeFromResourcesHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.UnsubscribeFromResourcesHandler); + } +} diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs new file mode 100644 index 0000000..8bf1e14 --- /dev/null +++ b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsToolsTests.cs @@ -0,0 +1,163 @@ +using McpDotNet.Configuration; +using McpDotNet.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; + +namespace McpDotNet.Tests; + +public class McpServerBuilderExtensionsToolsTests +{ + private readonly Mock _builder; + private readonly ServiceCollection _services; + + public McpServerBuilderExtensionsToolsTests() + { + _services = new ServiceCollection(); + _builder = new Mock(); + _builder.SetupGet(b => b.Services).Returns(_services); + } + + [Fact] + public void Adds_Tools_To_Server() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.NotNull(options.ListToolsHandler); + Assert.NotNull(options.CallToolHandler); + } + + [Fact] + public async Task Can_List_Registered_Tool() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.ListToolsHandler!(new(Mock.Of(), new()), CancellationToken.None); + Assert.NotNull(result); + Assert.NotEmpty(result.Tools); + + var tool = result.Tools[0]; + Assert.Equal("Echo", tool.Name); + Assert.Equal("Echoes the input back to the client.", tool.Description); + Assert.NotNull(tool.InputSchema); + Assert.Equal("object", tool.InputSchema.Type); + Assert.NotNull(tool.InputSchema.Properties); + Assert.NotEmpty(tool.InputSchema.Properties); + Assert.Contains("message", tool.InputSchema.Properties); + Assert.Equal("string", tool.InputSchema.Properties["message"].Type); + Assert.Equal("the echoes message", tool.InputSchema.Properties["message"].Description); + Assert.NotNull(tool.InputSchema.Required); + Assert.NotEmpty(tool.InputSchema.Required); + Assert.Contains("message", tool.InputSchema.Required); + + tool = result.Tools[1]; + Assert.Equal("double_echo", tool.Name); + Assert.Equal("Echoes the input back to the client.", tool.Description); + } + + [Fact] + public async Task Can_Call_Registered_Tool() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + var result = await options.CallToolHandler!(new(Mock.Of(), 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); + } + + [Fact] + public async Task Can_Call_Registered_Tool_With_Dependency_Injection() + { + bool dependencyInjectionCalled = false; + _services.AddTransient(sp => + { + dependencyInjectionCalled = true; + return Mock.Of(); + }); + + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoToolWithDi)); + + 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); + } + + [Fact] + public async Task Throws_Exception_On_Unknown_Tool() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + var serviceProvider = _services.BuildServiceProvider(); + 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); + } + + [Fact] + public async Task Throws_Exception_Missing_Parameter() + { + McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool)); + + 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); + } + + private static class EchoTool + { + [McpTool(Description = "Echoes the input back to the client.")] + public static string Echo([McpParameter(true, Description = "the echoes message")] string message) + { + return "hello " + message; + } + + [McpTool(Name = "double_echo", Description = "Echoes the input back to the client.")] + public static string Echo2([McpParameter(true)] string message) + { + return "hello hello" + message; + } + } + + public interface IDependendService + { + } + + private class EchoToolWithDi + { + public EchoToolWithDi(IDependendService service) + { + } + + [McpTool(Description = "Echoes the input back to the client.")] + public Task Echo([McpParameter(true)] string message) + { + return Task.FromResult("hello " + message); + } + } +} diff --git a/tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs new file mode 100644 index 0000000..2ea4cca --- /dev/null +++ b/tests/mcpdotnet.Tests/McpServerBuilderExtensionsTransportsTests.cs @@ -0,0 +1,23 @@ +using McpDotNet.Configuration; +using McpDotNet.Protocol.Transport; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace McpDotNet.Tests; + +public class McpServerBuilderExtensionsTransportsTests +{ + [Fact] + public void WithStdioServerTransport_Sets_Transport() + { + var services = new ServiceCollection(); + var builder = new Mock(); + builder.SetupGet(b => b.Services).Returns(services); + + builder.Object.WithStdioServerTransport(); + + var transportType = services.FirstOrDefault(s => s.ServiceType == typeof(IServerTransport)); + Assert.NotNull(transportType); + Assert.Equal(typeof(StdioServerTransport), transportType.ImplementationType); + } +} diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs index 55fa6e2..73a25ba 100644 --- a/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs +++ b/tests/mcpdotnet.Tests/ServerIntegrationTestFixture.cs @@ -31,11 +31,14 @@ public class ServerIntegrationTestFixture : IDisposable TransportType = TransportTypes.StdIo, TransportOptions = new Dictionary { - ["command"] = "TestServer.exe", + ["command"] = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet", // Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink } }; + if (!OperatingSystem.IsWindows()) + DefaultConfig.TransportOptions["arguments"] = "TestServer.dll"; + // Inject the mock transport into the factory Factory = new McpClientFactory( [DefaultConfig], diff --git a/tests/mcpdotnet.Tests/ServerIntegrationTests.cs b/tests/mcpdotnet.Tests/ServerIntegrationTests.cs index 00b9038..496a790 100644 --- a/tests/mcpdotnet.Tests/ServerIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/ServerIntegrationTests.cs @@ -1,6 +1,4 @@ using McpDotNet.Client; -using McpDotNet.Configuration; -using McpDotNet.Protocol.Messages; using McpDotNet.Protocol.Types; namespace McpDotNet.Tests; @@ -18,7 +16,7 @@ public class ServerIntegrationTests : IClassFixturefalse true + McpDotNet.Tests @@ -31,7 +32,10 @@ - + + PreserveNewest + + PreserveNewest