From 90dfc35fbd8c16cb01cd5db582fb95b2ccb15fbb Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 18 Mar 2025 03:56:44 -0700 Subject: [PATCH] Add AspNetCoreSseServer sample (#81) * Add AspNetCoreSseMcpTransport sample * Fix build errors after rebase * React to McpServerFactory changes * Make Utf8JsonWriter non thread static * Fix test errors caused by not rebasing the HttpLister changes on factory overhaul commit --- .gitignore | 2 +- mcpdotnet.sln | 7 + .../AspNetCoreSseServer.csproj | 17 + .../McpEndpointRouteBuilderExtensions.cs | 62 +++ samples/AspNetCoreSseServer/Program.cs | 11 + .../Properties/launchSettings.json | 23 + .../SseServerStreamTransport.cs | 84 ++++ samples/AspNetCoreSseServer/Tools/EchoTool.cs | 14 + .../Tools/SampleLlmTool.cs | 51 ++ .../appsettings.Development.json | 8 + samples/AspNetCoreSseServer/appsettings.json | 9 + samples/TestServerWithHosting/Program.cs | 5 +- .../McpServerBuilderExtensions.Transports.cs | 3 + .../Configuration/McpServerOptionsSetup.cs | 26 ++ .../McpServerServiceCollectionExtension.cs | 53 +-- .../Transport/HttpListenerServerProvider.cs | 6 +- .../Transport/StdioServerTransport.cs | 19 + src/mcpdotnet/Server/McpServer.cs | 13 +- src/mcpdotnet/Server/McpServerFactory.cs | 2 +- src/mcpdotnet/Server/McpServerHandlers.cs | 34 +- src/mcpdotnet/Server/McpServerOptions.cs | 14 +- src/mcpdotnet/mcpdotnet.csproj | 1 + tests/mcpdotnet.TestSseServer/Program.cs | 439 +++++++++--------- .../mcpdotnet.Tests/Server/McpServerTests.cs | 13 +- .../SseServerIntegrationTestFixture.cs | 11 +- .../SseServerIntegrationTests.cs | 64 ++- 26 files changed, 647 insertions(+), 344 deletions(-) create mode 100644 samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj create mode 100644 samples/AspNetCoreSseServer/McpEndpointRouteBuilderExtensions.cs create mode 100644 samples/AspNetCoreSseServer/Program.cs create mode 100644 samples/AspNetCoreSseServer/Properties/launchSettings.json create mode 100644 samples/AspNetCoreSseServer/SseServerStreamTransport.cs create mode 100644 samples/AspNetCoreSseServer/Tools/EchoTool.cs create mode 100644 samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs create mode 100644 samples/AspNetCoreSseServer/appsettings.Development.json create mode 100644 samples/AspNetCoreSseServer/appsettings.json create mode 100644 src/mcpdotnet/Configuration/McpServerOptionsSetup.cs diff --git a/.gitignore b/.gitignore index 5e03da8..1e5489a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Cake tools -[Tt]ools/ +/[Tt]ools/ # Build output [Bb]uildArtifacts/ diff --git a/mcpdotnet.sln b/mcpdotnet.sln index 09efe66..818e0e8 100644 --- a/mcpdotnet.sln +++ b/mcpdotnet.sln @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{2A77AF5C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mcpdotnet.TestSseServer", "tests\mcpdotnet.TestSseServer\mcpdotnet.TestSseServer.csproj", "{79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreSseServer", "samples\AspNetCoreSseServer\AspNetCoreSseServer.csproj", "{B6F42305-423F-56FF-090F-B7263547F924}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +63,10 @@ Global {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Debug|Any CPU.Build.0 = Debug|Any CPU {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Release|Any CPU.ActiveCfg = Release|Any CPU {79B94BF9-E557-33DB-3F19-B2C7D9BF8C56}.Release|Any CPU.Build.0 = Release|Any CPU + {B6F42305-423F-56FF-090F-B7263547F924}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6F42305-423F-56FF-090F-B7263547F924}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6F42305-423F-56FF-090F-B7263547F924}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6F42305-423F-56FF-090F-B7263547F924}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -71,6 +77,7 @@ Global {CA0BB450-1903-2F92-A68D-1285976551D6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {7C229573-A085-4ECC-8131-958CDA2BE731} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {6499876E-2F76-44A8-B6EB-5B889C6E9B7F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {B6F42305-423F-56FF-090F-B7263547F924} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89} diff --git a/samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj b/samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj new file mode 100644 index 0000000..5fc6fc9 --- /dev/null +++ b/samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/samples/AspNetCoreSseServer/McpEndpointRouteBuilderExtensions.cs b/samples/AspNetCoreSseServer/McpEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..7b5060f --- /dev/null +++ b/samples/AspNetCoreSseServer/McpEndpointRouteBuilderExtensions.cs @@ -0,0 +1,62 @@ +using McpDotNet.Protocol.Messages; +using McpDotNet.Server; +using McpDotNet.Utils.Json; +using Microsoft.Extensions.Options; + +namespace AspNetCoreSseServer; + +public static class McpEndpointRouteBuilderExtensions +{ + public static IEndpointConventionBuilder MapMcpSse(this IEndpointRouteBuilder endpoints) + { + IMcpServer? server = null; + SseServerStreamTransport? transport = null; + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); + var mcpServerOptions = endpoints.ServiceProvider.GetRequiredService>(); + + var routeGroup = endpoints.MapGroup(""); + + routeGroup.MapGet("/sse", async (HttpResponse response, CancellationToken requestAborted) => + { + await using var localTransport = transport = new SseServerStreamTransport(response.Body); + await using var localServer = server = McpServerFactory.Create(transport, mcpServerOptions.Value, loggerFactory, endpoints.ServiceProvider); + + await localServer.StartAsync(requestAborted); + + response.Headers.ContentType = "text/event-stream"; + response.Headers.CacheControl = "no-cache"; + + try + { + await transport.RunAsync(requestAborted); + } + catch (OperationCanceledException) when (requestAborted.IsCancellationRequested) + { + // RequestAborted always triggers when the client disconnects before a complete response body is written, + // but this is how SSE connections are typically closed. + } + }); + + routeGroup.MapPost("/message", async (HttpContext context) => + { + if (transport is null) + { + await Results.BadRequest("Connect to the /sse endpoint before sending messages.").ExecuteAsync(context); + return; + } + + var message = await context.Request.ReadFromJsonAsync(JsonSerializerOptionsExtensions.DefaultOptions, context.RequestAborted); + if (message is null) + { + await Results.BadRequest("No message in request body.").ExecuteAsync(context); + return; + } + + await transport.OnMessageReceivedAsync(message, context.RequestAborted); + context.Response.StatusCode = StatusCodes.Status202Accepted; + await context.Response.WriteAsync("Accepted"); + }); + + return routeGroup; + } +} diff --git a/samples/AspNetCoreSseServer/Program.cs b/samples/AspNetCoreSseServer/Program.cs new file mode 100644 index 0000000..1a4d9a3 --- /dev/null +++ b/samples/AspNetCoreSseServer/Program.cs @@ -0,0 +1,11 @@ +using McpDotNet; +using AspNetCoreSseServer; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddMcpServer().WithTools(); +var app = builder.Build(); + +app.MapGet("/", () => "Hello World!"); +app.MapMcpSse(); + +app.Run(); diff --git a/samples/AspNetCoreSseServer/Properties/launchSettings.json b/samples/AspNetCoreSseServer/Properties/launchSettings.json new file mode 100644 index 0000000..d1d3370 --- /dev/null +++ b/samples/AspNetCoreSseServer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7133;http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/AspNetCoreSseServer/SseServerStreamTransport.cs b/samples/AspNetCoreSseServer/SseServerStreamTransport.cs new file mode 100644 index 0000000..c8d5331 --- /dev/null +++ b/samples/AspNetCoreSseServer/SseServerStreamTransport.cs @@ -0,0 +1,84 @@ +using System.Buffers; +using System.Net.ServerSentEvents; +using System.Text.Json; +using System.Threading.Channels; +using McpDotNet.Protocol.Messages; +using McpDotNet.Protocol.Transport; +using McpDotNet.Utils.Json; + +namespace AspNetCoreSseServer; + +public class SseServerStreamTransport(Stream sseResponseStream) : ITransport +{ + private readonly Channel _incomingChannel = CreateSingleItemChannel(); + private readonly Channel> _outgoingSseChannel = CreateSingleItemChannel>(); + + private Task? _sseWriteTask; + private Utf8JsonWriter? _jsonWriter; + + public bool IsConnected => _sseWriteTask?.IsCompleted == false; + + public Task RunAsync(CancellationToken cancellationToken) + { + void WriteJsonRpcMessageToBuffer(SseItem item, IBufferWriter writer) + { + if (item.EventType == "endpoint") + { + writer.Write("/message"u8); + return; + } + + JsonSerializer.Serialize(GetUtf8JsonWriter(writer), item.Data, JsonSerializerOptionsExtensions.DefaultOptions); + } + + // The very first SSE event isn't really an IJsonRpcMessage, but there's no API to write a single item of a different type, + // so we fib and special-case the "endpoint" event type in the formatter. + _outgoingSseChannel.Writer.TryWrite(new SseItem(null, "endpoint")); + + var sseItems = _outgoingSseChannel.Reader.ReadAllAsync(cancellationToken); + return _sseWriteTask = SseFormatter.WriteAsync(sseItems, sseResponseStream, WriteJsonRpcMessageToBuffer, cancellationToken); + } + + public ChannelReader MessageReader => _incomingChannel.Reader; + + public ValueTask DisposeAsync() + { + _incomingChannel.Writer.TryComplete(); + _outgoingSseChannel.Writer.TryComplete(); + return new ValueTask(_sseWriteTask ?? Task.CompletedTask); + } + + public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) => + _outgoingSseChannel.Writer.WriteAsync(new SseItem(message), cancellationToken).AsTask(); + + public Task OnMessageReceivedAsync(IJsonRpcMessage message, CancellationToken cancellationToken) + { + if (!IsConnected) + { + throw new McpTransportException("Transport is not connected"); + } + + return _incomingChannel.Writer.WriteAsync(message, cancellationToken).AsTask(); + } + + private static Channel CreateSingleItemChannel() => + Channel.CreateBounded(new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = false, + }); + + private Utf8JsonWriter GetUtf8JsonWriter(IBufferWriter writer) + { + if (_jsonWriter is null) + { + _jsonWriter = new Utf8JsonWriter(writer); + } + else + { + _jsonWriter.Reset(writer); + } + + return _jsonWriter; + } +} diff --git a/samples/AspNetCoreSseServer/Tools/EchoTool.cs b/samples/AspNetCoreSseServer/Tools/EchoTool.cs new file mode 100644 index 0000000..25c9f22 --- /dev/null +++ b/samples/AspNetCoreSseServer/Tools/EchoTool.cs @@ -0,0 +1,14 @@ +using McpDotNet.Server; +using System.ComponentModel; + +namespace TestServerWithHosting.Tools; + +[McpToolType] +public static class EchoTool +{ + [McpTool, Description("Echoes the input back to the client.")] + public static string Echo(string message) + { + return "hello " + message; + } +} diff --git a/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs b/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs new file mode 100644 index 0000000..a49669f --- /dev/null +++ b/samples/AspNetCoreSseServer/Tools/SampleLlmTool.cs @@ -0,0 +1,51 @@ +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using System.ComponentModel; + +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("sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")] + public async Task SampleLLM( + [Description("The prompt to send to the LLM")] string prompt, + [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/samples/AspNetCoreSseServer/appsettings.Development.json b/samples/AspNetCoreSseServer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/AspNetCoreSseServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/AspNetCoreSseServer/appsettings.json b/samples/AspNetCoreSseServer/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/AspNetCoreSseServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/TestServerWithHosting/Program.cs b/samples/TestServerWithHosting/Program.cs index f1bdf7c..007a85b 100644 --- a/samples/TestServerWithHosting/Program.cs +++ b/samples/TestServerWithHosting/Program.cs @@ -8,7 +8,7 @@ Log.Logger = new LoggerConfiguration() rollingInterval: RollingInterval.Day, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Debug() - .WriteTo.Console() + .WriteTo.Console(standardErrorFromLevel: Serilog.Events.LogEventLevel.Verbose) .CreateLogger(); try @@ -19,8 +19,7 @@ try builder.Services.AddSerilog(); builder.Services.AddMcpServer() .WithStdioServerTransport() - .WithTools() - .WithCallToolHandler((r, ct) => Task.FromResult(new McpDotNet.Protocol.Types.CallToolResponse())); + .WithTools(); var app = builder.Build(); diff --git a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs index 2a44d40..147b9aa 100644 --- a/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs +++ b/src/mcpdotnet/Configuration/McpServerBuilderExtensions.Transports.cs @@ -1,4 +1,5 @@ using McpDotNet.Configuration; +using McpDotNet.Hosting; using McpDotNet.Protocol.Transport; using McpDotNet.Utils; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +20,7 @@ public static partial class McpServerBuilderExtensions Throw.IfNull(builder); builder.Services.AddSingleton(); + builder.Services.AddHostedService(); return builder; } @@ -34,6 +36,7 @@ public static partial class McpServerBuilderExtensions } builder.Services.AddSingleton(); + builder.Services.AddHostedService(); return builder; } } diff --git a/src/mcpdotnet/Configuration/McpServerOptionsSetup.cs b/src/mcpdotnet/Configuration/McpServerOptionsSetup.cs new file mode 100644 index 0000000..50bc89d --- /dev/null +++ b/src/mcpdotnet/Configuration/McpServerOptionsSetup.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using McpDotNet.Protocol.Types; +using McpDotNet.Server; +using Microsoft.Extensions.Options; + +namespace McpDotNet.Configuration; + +internal sealed class McpServerOptionsSetup(IOptions serverHandlers) : IConfigureOptions +{ + public void Configure(McpServerOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var assemblyName = Assembly.GetEntryAssembly()?.GetName(); + options.ServerInfo = new Implementation + { + Name = assemblyName?.Name ?? "McpServer", + Version = assemblyName?.Version?.ToString() ?? "1.0.0", + }; + + serverHandlers.Value.OverwriteWithSetHandlers(options); + } +} diff --git a/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs b/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs index 79b1776..e90ed1a 100644 --- a/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs +++ b/src/mcpdotnet/Configuration/McpServerServiceCollectionExtension.cs @@ -1,12 +1,9 @@ using McpDotNet.Configuration; -using McpDotNet.Hosting; using McpDotNet.Protocol.Transport; -using McpDotNet.Protocol.Types; using McpDotNet.Server; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using System.Reflection; namespace McpDotNet; @@ -23,54 +20,22 @@ public static class McpServerServiceCollectionExtension /// public static IMcpServerBuilder AddMcpServer(this IServiceCollection services, Action? configureOptions = null) { - var options = CreateDefaultServerOptions(); - configureOptions?.Invoke(options); - - return AddMcpServer(services, options); - } - - /// - /// Adds the MCP server to the service collection with the provided options. - /// - /// - /// - /// - public static IMcpServerBuilder AddMcpServer(this IServiceCollection services, McpServerOptions serverOptions) - { - services.AddSingleton(serverOptions); - services.AddHostedService(); - services.AddOptions(); services.AddSingleton(services => { IServerTransport serverTransport = services.GetRequiredService(); - McpServerOptions options = services.GetRequiredService(); + IOptions options = services.GetRequiredService>(); ILoggerFactory? loggerFactory = services.GetService(); - if (services.GetService>() is { } handlersOptions) - { - options = handlersOptions.Value.OverwriteWithSetHandlers(options); - } - - return McpServerFactory.Create(serverTransport, options, loggerFactory, services); + return McpServerFactory.Create(serverTransport, options.Value, loggerFactory, services); }); + services.AddOptions(); + services.AddTransient, McpServerOptionsSetup>(); + if (configureOptions is not null) + { + services.Configure(configureOptions); + } + return new DefaultMcpServerBuilder(services); } - - private static McpServerOptions CreateDefaultServerOptions() - { - var assemblyName = Assembly.GetEntryAssembly()?.GetName(); - - return new McpServerOptions() - { - ServerInfo = new Implementation() { Name = assemblyName?.Name ?? "McpServer", Version = assemblyName?.Version?.ToString() ?? "1.0.0" }, - Capabilities = new ServerCapabilities() - { - Tools = new(), - Resources = new(), - Prompts = new(), - }, - ProtocolVersion = "2024-11-05" - }; - } } diff --git a/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs b/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs index a9fc8d2..e253359 100644 --- a/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs +++ b/src/mcpdotnet/Protocol/Transport/HttpListenerServerProvider.cs @@ -235,10 +235,10 @@ internal class HttpListenerServerProvider : IDisposable response.StatusCode = 202; // Write "accepted" response // TODO: Use WriteAsync, add cancellation token and netstandard2.0 support (polyfill?) -#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods byte[] buffer = Encoding.UTF8.GetBytes("Accepted"); - response.OutputStream.Write(buffer, 0, buffer.Length); -#pragma warning restore CA2016 // Forward the 'CancellationToken' parameter to methods +#pragma warning disable CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); +#pragma warning restore CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' } else { diff --git a/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs b/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs index 17585ff..46785ca 100644 --- a/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs +++ b/src/mcpdotnet/Protocol/Transport/StdioServerTransport.cs @@ -6,6 +6,7 @@ using McpDotNet.Utils; using McpDotNet.Utils.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; #pragma warning disable CA2208 // Instantiate argument exceptions correctly #pragma warning disable CA2213 // Disposable fields should be disposed @@ -47,6 +48,24 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport { } + /// + /// Initializes a new instance of the class, using + /// and for input and output streams. + /// + /// The server options. + /// Optional logger factory used for logging employed by the transport. + /// is or contains a null name. + /// + /// + /// By default, no logging is performed. If a is supplied, it must not log + /// to , as that will interfere with the transport's output. + /// + /// + public StdioServerTransport(IOptions serverOptions, ILoggerFactory? loggerFactory = null) + : this(GetServerName(serverOptions.Value), loggerFactory) + { + } + /// /// Initializes a new instance of the class, using /// and for input and output streams. diff --git a/src/mcpdotnet/Server/McpServer.cs b/src/mcpdotnet/Server/McpServer.cs index e0bd9d7..b1308bb 100644 --- a/src/mcpdotnet/Server/McpServer.cs +++ b/src/mcpdotnet/Server/McpServer.cs @@ -12,7 +12,7 @@ namespace McpDotNet.Server; /// internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer { - private readonly IServerTransport _serverTransport; + private readonly IServerTransport? _serverTransport; private readonly McpServerOptions _options; private volatile bool _isInitializing; private readonly ILogger _logger; @@ -26,12 +26,12 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer /// Logger factory to use for logging /// Optional service provider to use for dependency injection /// - public McpServer(IServerTransport transport, McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider) + public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider) : base(transport, loggerFactory) { Throw.IfNull(options); - _serverTransport = transport; + _serverTransport = transport as IServerTransport; _options = options; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; ServerInstructions = options.ServerInstructions; @@ -86,8 +86,11 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer { CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - // Start listening for messages - await _serverTransport.StartListeningAsync(CancellationTokenSource.Token).ConfigureAwait(false); + if (_serverTransport is not null) + { + // Start listening for messages + await _serverTransport.StartListeningAsync(CancellationTokenSource.Token).ConfigureAwait(false); + } // Start processing messages MessageProcessingTask = ProcessMessagesAsync(CancellationTokenSource.Token); diff --git a/src/mcpdotnet/Server/McpServerFactory.cs b/src/mcpdotnet/Server/McpServerFactory.cs index a8e37d2..bc5a95b 100644 --- a/src/mcpdotnet/Server/McpServerFactory.cs +++ b/src/mcpdotnet/Server/McpServerFactory.cs @@ -23,7 +23,7 @@ public static class McpServerFactory /// is . /// is . public static IMcpServer Create( - IServerTransport serverTransport, + ITransport serverTransport, McpServerOptions serverOptions, ILoggerFactory? loggerFactory = null, IServiceProvider? serviceProvider = null) diff --git a/src/mcpdotnet/Server/McpServerHandlers.cs b/src/mcpdotnet/Server/McpServerHandlers.cs index a4edc98..94e33f4 100644 --- a/src/mcpdotnet/Server/McpServerHandlers.cs +++ b/src/mcpdotnet/Server/McpServerHandlers.cs @@ -57,7 +57,7 @@ public sealed class McpServerHandlers /// /// /// - internal McpServerOptions OverwriteWithSetHandlers(McpServerOptions options) + internal void OverwriteWithSetHandlers(McpServerOptions options) { PromptsCapability? promptsCapability = options.Capabilities?.Prompts; if (ListPromptsHandler is not null || GetPromptHandler is not null) @@ -118,22 +118,20 @@ public sealed class McpServerHandlers }; } - return options with - { - GetCompletionHandler = GetCompletionHandler ?? options.GetCompletionHandler, - Capabilities = options.Capabilities is null ? - new() - { - Prompts = promptsCapability, - Resources = resourcesCapability, - Tools = toolsCapability, - } : - options.Capabilities with - { - Prompts = promptsCapability, - Resources = resourcesCapability, - Tools = toolsCapability, - }, - }; + options.Capabilities = options.Capabilities is null ? + new() + { + Prompts = promptsCapability, + Resources = resourcesCapability, + Tools = toolsCapability, + } : + options.Capabilities with + { + Prompts = promptsCapability, + Resources = resourcesCapability, + Tools = toolsCapability, + }; + + options.GetCompletionHandler = GetCompletionHandler ?? options.GetCompletionHandler; } } diff --git a/src/mcpdotnet/Server/McpServerOptions.cs b/src/mcpdotnet/Server/McpServerOptions.cs index 40bfa4f..ad109c5 100644 --- a/src/mcpdotnet/Server/McpServerOptions.cs +++ b/src/mcpdotnet/Server/McpServerOptions.cs @@ -9,36 +9,36 @@ namespace McpDotNet.Server; /// protocol version. /// See the protocol specification for details on capability negotiation /// -public record McpServerOptions +public class McpServerOptions { /// /// Information about this server implementation. /// - public required Implementation ServerInfo { get; init; } + public required Implementation ServerInfo { get; set; } /// /// Server capabilities to advertise to the server. /// - public ServerCapabilities? Capabilities { get; init; } + public ServerCapabilities? Capabilities { get; set; } /// /// Protocol version to request from the server. /// - public string ProtocolVersion { get; init; } = "2024-11-05"; + public string ProtocolVersion { get; set; } = "2024-11-05"; /// /// Timeout for initialization sequence. /// - public TimeSpan InitializationTimeout { get; init; } = TimeSpan.FromSeconds(60); + public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(60); /// /// Optional server instructions to send to clients /// - public string ServerInstructions { get; init; } = string.Empty; + public string ServerInstructions { get; set; } = string.Empty; /// /// Gets or sets the handler for get completion requests. /// [JsonIgnore] - public Func, CancellationToken, Task>? GetCompletionHandler { get; init; } + public Func, CancellationToken, Task>? GetCompletionHandler { get; set; } } diff --git a/src/mcpdotnet/mcpdotnet.csproj b/src/mcpdotnet/mcpdotnet.csproj index 9e67c34..4e1b538 100644 --- a/src/mcpdotnet/mcpdotnet.csproj +++ b/src/mcpdotnet/mcpdotnet.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/mcpdotnet.TestSseServer/Program.cs b/tests/mcpdotnet.TestSseServer/Program.cs index 438a3a8..dd77be8 100644 --- a/tests/mcpdotnet.TestSseServer/Program.cs +++ b/tests/mcpdotnet.TestSseServer/Program.cs @@ -41,12 +41,10 @@ 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 HttpListenerSseServerTransport("TestServer", 3001, loggerFactory), options, loggerFactory); - IMcpServer server = factory.CreateServer(); + IMcpServer? server = null; - Console.WriteLine("Server object created, registering handlers."); + Console.WriteLine("Registering handlers."); #region Helped method static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100) @@ -70,85 +68,6 @@ internal class Program } #endregion - #region Tools - server.SetListToolsHandler((request, cancellationToken) => - { - return Task.FromResult(new ListToolsResult() - { - Tools = - [ - new Tool() - { - Name = "echo", - Description = "Echoes the input back to the client.", - InputSchema = new JsonSchema() - { - Type = "object", - Properties = new Dictionary() - { - ["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." } - } - }, - }, - new Tool() - { - Name = "sampleLLM", - Description = "Samples from an LLM using MCP's sampling feature.", - InputSchema = new JsonSchema() - { - Type = "object", - Properties = new Dictionary() - { - ["prompt"] = new JsonSchemaProperty() { Type = "string", Description = "The prompt to send to the LLM" }, - ["maxTokens"] = new JsonSchemaProperty() { Type = "number", Description = "Maximum number of tokens to generate" } - } - }, - } - ] - }); - }); - - server.SetCallToolHandler(async (request, cancellationToken) => - { - if (request.Params is null) - { - throw new McpServerException("Missing required parameter 'name'"); - } - if (request.Params.Name == "echo") - { - 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" }] - }; - } - else if (request.Params.Name == "sampleLLM") - { - 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'"); - } - var sampleResult = await server.RequestSamplingAsync(CreateRequestSamplingParams(prompt?.ToString() ?? "", "sampleLLM", Convert.ToInt32(maxTokens?.ToString())), - cancellationToken); - - return new CallToolResponse() - { - Content = [new Content() { Text = $"LLM sampling result: {sampleResult.Content.Text}", Type = "text" }] - }; - } - else - { - throw new McpServerException($"Unknown tool: {request.Params.Name}"); - } - }); - #endregion - - #region Resources List resources = new(); List resourceContents = new(); for (int i = 0; i < 100; ++i) @@ -189,156 +108,236 @@ internal class Program const int pageSize = 10; - server.SetListResourcesHandler((request, cancellationToken) => + options.Capabilities = new() { - int startIndex = 0; - var requestParams = request.Params ?? new(); - if (requestParams.Cursor is not null) + Tools = new() { - try + ListToolsHandler = (request, cancellationToken) => { - var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(requestParams.Cursor)); - startIndex = Convert.ToInt32(startIndexAsString); - } - catch - { - throw new McpServerException("Invalid cursor"); - } - } - - int endIndex = Math.Min(startIndex + pageSize, resources.Count); - string? nextCursor = null; - - if (endIndex < resources.Count) - { - nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString())); - } - return Task.FromResult(new ListResourcesResult() - { - NextCursor = nextCursor, - Resources = resources.GetRange(startIndex, endIndex - startIndex) - }); - }); - - server.SetReadResourceHandler((request, cancellationToken) => - { - if (request.Params?.Uri is null) - { - throw new McpServerException("Missing required argument 'uri'"); - } - ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri); - if (contents is null) - { - throw new McpServerException("Resource not found"); - } - return Task.FromResult(new ReadResourceResult() - { - Contents = [contents] - }); - }); - #endregion - - #region Prompts - server.SetListPromptsHandler((request, cancellationToken) => - { - return Task.FromResult(new ListPromptsResult() - { - Prompts = [ - new Prompt() + return Task.FromResult(new ListToolsResult() { - Name = "simple_prompt", - Description = "A prompt without arguments" - }, - new Prompt() - { - Name = "complex_prompt", - Description = "A prompt with arguments", - Arguments = new() - { - new PromptArgument() + Tools = + [ + new Tool() { - Name = "temperature", - Description = "Temperature setting", - Required = true + Name = "echo", + Description = "Echoes the input back to the client.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." } + } + }, }, - new PromptArgument() + new Tool() { - Name = "style", - Description = "Output style", - Required = false + Name = "sampleLLM", + Description = "Samples from an LLM using MCP's sampling feature.", + InputSchema = new JsonSchema() + { + Type = "object", + Properties = new Dictionary() + { + ["prompt"] = new JsonSchemaProperty() { Type = "string", Description = "The prompt to send to the LLM" }, + ["maxTokens"] = new JsonSchemaProperty() { Type = "number", Description = "Maximum number of tokens to generate" } + } + }, } + ] + }); + }, + CallToolHandler = async (request, cancellationToken) => + { + if (request.Params is null) + { + throw new McpServerException("Missing required parameter 'name'"); + } + if (request.Params.Name == "echo") + { + 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" }] + }; + } + else if (request.Params.Name == "sampleLLM") + { + 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'"); + } + var sampleResult = await server!.RequestSamplingAsync(CreateRequestSamplingParams(prompt?.ToString() ?? "", "sampleLLM", Convert.ToInt32(maxTokens?.ToString())), + cancellationToken); + + return new CallToolResponse() + { + Content = [new Content() { Text = $"LLM sampling result: {sampleResult.Content.Text}", Type = "text" }] + }; + } + else + { + throw new McpServerException($"Unknown tool: {request.Params.Name}"); + } + } + }, + Resources = new() + { + ListResourcesHandler = (request, cancellationToken) => + { + int startIndex = 0; + var requestParams = request.Params ?? new(); + if (requestParams.Cursor is not null) + { + try + { + var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(requestParams.Cursor)); + startIndex = Convert.ToInt32(startIndexAsString); + } + catch + { + throw new McpServerException("Invalid cursor"); } } - ] - }); - }); + + int endIndex = Math.Min(startIndex + pageSize, resources.Count); + string? nextCursor = null; + + if (endIndex < resources.Count) + { + nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(endIndex.ToString())); + } + return Task.FromResult(new ListResourcesResult() + { + NextCursor = nextCursor, + Resources = resources.GetRange(startIndex, endIndex - startIndex) + }); + }, + ReadResourceHandler =(request, cancellationToken) => + { + if (request.Params?.Uri is null) + { + throw new McpServerException("Missing required argument 'uri'"); + } + ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri); + if (contents is null) + { + throw new McpServerException("Resource not found"); + } + return Task.FromResult(new ReadResourceResult() + { + Contents = [contents] + }); + } + }, + Prompts = new() + { + ListPromptsHandler = (request, cancellationToken) => + { + return Task.FromResult(new ListPromptsResult() + { + Prompts = [ + new Prompt() + { + Name = "simple_prompt", + Description = "A prompt without arguments" + }, + new Prompt() + { + Name = "complex_prompt", + Description = "A prompt with arguments", + Arguments = new() + { + new PromptArgument() + { + Name = "temperature", + Description = "Temperature setting", + Required = true + }, + new PromptArgument() + { + Name = "style", + Description = "Output style", + Required = false + } + } + } + ] + }); + }, + GetPromptHandler = (request, cancellationToken) => + { + if (request.Params is null) + { + throw new McpServerException("Missing required parameter 'name'"); + } + List messages = new(); + if (request.Params.Name == "simple_prompt") + { + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = "This is a simple prompt without arguments." + } + }); + } + else if (request.Params.Name == "complex_prompt") + { + string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown"; + string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown"; + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "text", + Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}" + } + }); + messages.Add(new PromptMessage() + { + Role = Role.Assistant, + Content = new Content() + { + Type = "text", + Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?" + } + }); + messages.Add(new PromptMessage() + { + Role = Role.User, + Content = new Content() + { + Type = "image", + Data = MCP_TINY_IMAGE, + MimeType = "image/png" + } + }); + } + else + { + throw new McpServerException($"Unknown prompt: {request.Params.Name}"); + } - server.SetGetPromptHandler((request, cancellationToken) => - { - if (request.Params is null) - { - throw new McpServerException("Missing required parameter 'name'"); - } - List messages = new(); - if (request.Params.Name == "simple_prompt") - { - messages.Add(new PromptMessage() - { - Role = Role.User, - Content = new Content() + return Task.FromResult(new GetPromptResult() { - Type = "text", - Text = "This is a simple prompt without arguments." - } - }); - } - else if (request.Params.Name == "complex_prompt") - { - string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown"; - string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown"; - messages.Add(new PromptMessage() - { - Role = Role.User, - Content = new Content() - { - Type = "text", - Text = $"This is a complex prompt with arguments: temperature={temperature}, style={style}" - } - }); - messages.Add(new PromptMessage() - { - Role = Role.Assistant, - Content = new Content() - { - Type = "text", - Text = "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?" - } - }); - messages.Add(new PromptMessage() - { - Role = Role.User, - Content = new Content() - { - Type = "image", - Data = MCP_TINY_IMAGE, - MimeType = "image/png" - } - }); - } - else - { - throw new McpServerException($"Unknown prompt: {request.Params.Name}"); - } + Messages = messages + }); + } + }, + }; - return Task.FromResult(new GetPromptResult() - { - Messages = messages - }); - }); - #endregion - - #region Sampling - #endregion + var loggerFactory = CreateLoggerFactory(); + server = McpServerFactory.Create(new HttpListenerSseServerTransport("TestServer", 3001, loggerFactory), options, loggerFactory); Console.WriteLine("Server initialized."); diff --git a/tests/mcpdotnet.Tests/Server/McpServerTests.cs b/tests/mcpdotnet.Tests/Server/McpServerTests.cs index e8edec0..53579bf 100644 --- a/tests/mcpdotnet.Tests/Server/McpServerTests.cs +++ b/tests/mcpdotnet.Tests/Server/McpServerTests.cs @@ -280,9 +280,9 @@ public class McpServerTests await Can_Handle_Requests( serverCapabilities: null, method: "completion/complete", - configureOptions: options => options with + configureOptions: options => { - GetCompletionHandler = (request, ct) => + options.GetCompletionHandler = (request, ct) => Task.FromResult(new CompleteResult { Completion = new() @@ -291,7 +291,7 @@ public class McpServerTests Total = 2, HasMore = true } - }) + }); }, assertResult: response => { @@ -518,14 +518,11 @@ public class McpServerTests await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, "tools/call", "CallTool handler not configured"); } - private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Func? configureOptions, Action assertResult) + private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action? configureOptions, Action assertResult) { await using var transport = new TestServerTransport(); var options = CreateOptions(serverCapabilities); - if (configureOptions is not null) - { - options = configureOptions(options); - } + configureOptions?.Invoke(options); await using var server = new McpServer(transport, options, _loggerFactory.Object, _serviceProvider); diff --git a/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs b/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs index 05fd988..daec16d 100644 --- a/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs +++ b/tests/mcpdotnet.Tests/SseServerIntegrationTestFixture.cs @@ -11,7 +11,6 @@ public class SseServerIntegrationTestFixture : IDisposable private Process _process; public ILoggerFactory LoggerFactory { get; } - public McpClientFactory Factory { get; } public McpClientOptions DefaultOptions { get; } public McpServerConfig DefaultConfig { get; } @@ -24,7 +23,6 @@ public class SseServerIntegrationTestFixture : IDisposable DefaultOptions = new() { ClientInfo = new() { Name = "IntegrationTestClient", Version = "1.0.0" }, - Capabilities = new() { Sampling = new(), Roots = new() } }; DefaultConfig = new McpServerConfig @@ -36,13 +34,6 @@ public class SseServerIntegrationTestFixture : IDisposable Location = "http://localhost:3001/sse" }; - // Inject the mock transport into the factory - Factory = new McpClientFactory( - [DefaultConfig], - DefaultOptions, - LoggerFactory - ); - Start(); } @@ -70,7 +61,7 @@ public class SseServerIntegrationTestFixture : IDisposable { try { - var client = Factory.GetClientAsync("test_server").Result; + var client = McpClientFactory.CreateAsync(DefaultConfig, DefaultOptions, loggerFactory: LoggerFactory).GetAwaiter().GetResult(); client.DisposeAsync().AsTask().Wait(); LoggerFactory?.Dispose(); } diff --git a/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs b/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs index 76b7e53..ec94803 100644 --- a/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs +++ b/tests/mcpdotnet.Tests/SseServerIntegrationTests.cs @@ -12,13 +12,21 @@ public class SseServerIntegrationTests : IClassFixture GetClientAsync(McpClientOptions? options = null) + { + return McpClientFactory.CreateAsync( + _fixture.DefaultConfig, + options ?? _fixture.DefaultOptions, + loggerFactory: _fixture.LoggerFactory); + } + [Fact] public async Task ConnectAndPing_Sse_TestServer() { // Arrange // Act - var client = await _fixture.Factory.GetClientAsync("test_server"); + var client = await GetClientAsync(); await client.PingAsync(CancellationToken.None); // Assert @@ -31,7 +39,7 @@ public class SseServerIntegrationTests : IClassFixture @@ -80,7 +88,7 @@ public class SseServerIntegrationTests : IClassFixture allResources = []; string? cursor = null; @@ -102,7 +110,7 @@ public class SseServerIntegrationTests : IClassFixture { { "temperature", "0.7" }, @@ -186,7 +194,7 @@ public class SseServerIntegrationTests : IClassFixture(() => client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None)); } @@ -194,26 +202,34 @@ public class SseServerIntegrationTests : IClassFixture + var options = _fixture.DefaultOptions with { - samplingHandlerCalls++; - return new CreateMessageResult + Capabilities = new() { - Model = "test-model", - Role = "assistant", - Content = new Content + Sampling = new() { - Type = "text", - Text = "Test response" + SamplingHandler = async (_, _) => + { + samplingHandlerCalls++; + return new CreateMessageResult + { + Model = "test-model", + Role = "assistant", + Content = new Content + { + Type = "text", + Text = "Test response" + } + }; + } } - }; - }); + } + }; + var client = await GetClientAsync(options); #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously // Call the server's sampleLLM tool which should trigger our sampling handler