Address some static analysis and TODOs (#12)

This commit is contained in:
Stephen Toub 2025-03-20 23:09:38 -04:00 • committed by GitHub
commit 33b9582199
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 132 additions and 123 deletions

View file

@ -1,3 +1,5 @@
using ModelContextProtocol.Utils;
namespace System.Collections.Generic;
internal static class CollectionExtensions
@ -9,10 +11,7 @@ internal static class CollectionExtensions
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key, TValue defaultValue)
{
if (dictionary is null)
{
throw new ArgumentNullException(nameof(dictionary));
}
Throw.IfNull(dictionary);
return dictionary.TryGetValue(key, out TValue? value) ? value : defaultValue;
}

View file

@ -0,0 +1,36 @@
using ModelContextProtocol.Utils;
using System.Buffers;
using System.Runtime.InteropServices;
namespace System.IO;
internal static class StreamExtensions
{
public static ValueTask WriteAsync(this Stream stream, ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken)
{
Throw.IfNull(stream);
if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> segment))
{
return new ValueTask(stream.WriteAsync(segment.Array, segment.Offset, segment.Count, cancellationToken));
}
else
{
return WriteAsyncCore(stream, buffer, cancellationToken);
static async ValueTask WriteAsyncCore(Stream stream, ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken)
{
byte[] array = ArrayPool<byte>.Shared.Rent(buffer.Length);
try
{
buffer.Span.CopyTo(array);
await stream.WriteAsync(array, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(array);
}
}
}
}
}

View file

@ -4,7 +4,21 @@ internal static class TextReaderExtensions
{
public static Task<string> ReadLineAsync(this TextReader reader, CancellationToken cancellationToken)
{
_ = cancellationToken;
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled<string>(cancellationToken);
}
return reader.ReadLineAsync();
}
public static Task<string> ReadToEndAsync(this TextReader reader, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled<string>(cancellationToken);
}
return reader.ReadToEndAsync();
}
}

View file

@ -1,3 +1,4 @@
using ModelContextProtocol.Utils;
using System.Runtime.InteropServices;
namespace System.IO;
@ -6,10 +7,7 @@ internal static class TextWriterExtensions
{
public static async Task WriteLineAsync(this TextWriter writer, ReadOnlyMemory<char> value, CancellationToken cancellationToken)
{
if (writer is null)
{
throw new ArgumentNullException(nameof(writer));
}
Throw.IfNull(writer);
if (value.IsEmpty)
{

View file

@ -1,13 +1,12 @@
using ModelContextProtocol.Utils;
namespace System.Net.Http;
internal static class HttpClientExtensions
{
public static async Task<Stream> ReadAsStreamAsync(this HttpContent content, CancellationToken cancellationToken)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
Throw.IfNull(content);
cancellationToken.ThrowIfCancellationRequested();
return await content.ReadAsStreamAsync();
@ -15,10 +14,7 @@ internal static class HttpClientExtensions
public static async Task<string> ReadAsStringAsync(this HttpContent content, CancellationToken cancellationToken)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
Throw.IfNull(content);
cancellationToken.ThrowIfCancellationRequested();
return await content.ReadAsStringAsync();

View file

@ -1,13 +1,12 @@
using ModelContextProtocol.Utils;
namespace System.Threading.Tasks;
internal static class CancellationTokenSourceExtensions
{
public static Task CancelAsync(this CancellationTokenSource cancellationTokenSource)
{
if (cancellationTokenSource is null)
{
throw new ArgumentNullException(nameof(cancellationTokenSource));
}
Throw.IfNull(cancellationTokenSource);
cancellationTokenSource.Cancel();
return Task.CompletedTask;

View file

@ -0,0 +1,17 @@
using System.Runtime.CompilerServices;
namespace System.Threading;
/// <summary>
/// await default(ForceYielding) to provide the same behavior as
/// await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding).
/// </summary>
internal readonly struct ForceYielding : INotifyCompletion, ICriticalNotifyCompletion
{
public ForceYielding GetAwaiter() => this;
public bool IsCompleted => false;
public void OnCompleted(Action continuation) => ThreadPool.QueueUserWorkItem(a => ((Action)a!)(), continuation);
public void UnsafeOnCompleted(Action continuation) => ThreadPool.UnsafeQueueUserWorkItem(a => ((Action)a!)(), continuation);
public void GetResult() { }
}

View file

@ -1,3 +1,5 @@
using ModelContextProtocol.Utils;
namespace System.Threading.Tasks;
internal static class TaskExtensions
@ -15,10 +17,7 @@ internal static class TaskExtensions
public static async Task WaitAsync(this Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
if (task is null)
{
throw new ArgumentNullException(nameof(task));
}
Throw.IfNull(task);
if (timeout < TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan)
{

View file

@ -6,8 +6,6 @@ using Microsoft.Extensions.AI;
using System.Runtime.CompilerServices;
using System.Text.Json;
#pragma warning disable CA1508 // Avoid dead conditional code
namespace ModelContextProtocol.Client;
/// <summary>
@ -439,7 +437,7 @@ public static class McpClientExtensions
}
private static JsonRpcRequest CreateRequest(string method, Dictionary<string, object?>? parameters) =>
new JsonRpcRequest
new()
{
Method = method,
Params = parameters

View file

@ -30,10 +30,7 @@ public static partial class McpServerBuilderExtensions
/// <param name="builder">The builder instance.</param>
public static IMcpServerBuilder WithHttpListenerSseServerTransport(this IMcpServerBuilder builder)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
Throw.IfNull(builder);
builder.Services.AddSingleton<IServerTransport, HttpListenerSseServerTransport>();
builder.Services.AddHostedService<McpServerHostedService>();

View file

@ -2,6 +2,7 @@
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Utils;
namespace ModelContextProtocol.Configuration;
@ -9,10 +10,7 @@ internal sealed class McpServerOptionsSetup(IOptions<McpServerHandlers> serverHa
{
public void Configure(McpServerOptions options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
Throw.IfNull(options);
var assemblyName = Assembly.GetEntryAssembly()?.GetName();
options.ServerInfo = new Implementation

View file

@ -11,9 +11,12 @@ namespace ModelContextProtocol.Protocol.Transport;
[ExcludeFromCodeCoverage]
internal class HttpListenerServerProvider : IDisposable
{
private static readonly byte[] s_accepted = "Accepted"u8.ToArray();
private const string SseEndpoint = "/sse";
private const string MessageEndpoint = "/message";
private readonly int _port;
private readonly string _sseEndpoint = "/sse";
private readonly string _messageEndpoint = "/message";
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Func<string, CancellationToken, bool>? _messageHandler;
@ -31,7 +34,7 @@ internal class HttpListenerServerProvider : IDisposable
public Task<string> GetSseEndpointUri()
{
return Task.FromResult($"http://localhost:{_port}{_sseEndpoint}");
return Task.FromResult($"http://localhost:{_port}{SseEndpoint}");
}
public Task InitializeMessageHandler(Func<string, CancellationToken, bool> messageHandler)
@ -131,12 +134,12 @@ internal class HttpListenerServerProvider : IDisposable
throw new McpServerException("Request is null");
// Handle SSE connection
if (request.HttpMethod == "GET" && request.Url?.LocalPath == _sseEndpoint)
if (request.HttpMethod == "GET" && request.Url?.LocalPath == SseEndpoint)
{
await HandleSseConnectionAsync(context, cancellationToken).ConfigureAwait(false);
}
// Handle message POST
else if (request.HttpMethod == "POST" && request.Url?.LocalPath == _messageEndpoint)
else if (request.HttpMethod == "POST" && request.Url?.LocalPath == MessageEndpoint)
{
await HandleMessageAsync(context, cancellationToken).ConfigureAwait(false);
}
@ -167,9 +170,6 @@ internal class HttpListenerServerProvider : IDisposable
response.Headers.Add("Cache-Control", "no-cache");
response.Headers.Add("Connection", "keep-alive");
// Create a unique ID for this client
var clientId = Guid.NewGuid().ToString();
// Get the output stream and create a StreamWriter
var outputStream = response.OutputStream;
_streamWriter = new StreamWriter(outputStream, Encoding.UTF8) { AutoFlush = true };
@ -179,7 +179,7 @@ internal class HttpListenerServerProvider : IDisposable
{
// Immediately send the "endpoint" event with the POST URL
await _streamWriter.WriteLineAsync("event: endpoint").ConfigureAwait(false);
await _streamWriter.WriteLineAsync($"data: {_messageEndpoint}").ConfigureAwait(false);
await _streamWriter.WriteLineAsync($"data: {MessageEndpoint}").ConfigureAwait(false);
await _streamWriter.WriteLineAsync().ConfigureAwait(false); // blank line to end an SSE message
await _streamWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
@ -222,10 +222,7 @@ internal class HttpListenerServerProvider : IDisposable
string requestBody;
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
// TODO: Add cancellation token and netstandard2.0 support (polyfill?)
#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods
requestBody = await reader.ReadToEndAsync().ConfigureAwait(false);
#pragma warning restore CA2016 // Forward the 'CancellationToken' parameter to methods
requestBody = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
}
// Process the message asynchronously
@ -233,12 +230,9 @@ internal class HttpListenerServerProvider : IDisposable
{
// Return 202 Accepted
response.StatusCode = 202;
// Write "accepted" response
// TODO: Use WriteAsync, add cancellation token and netstandard2.0 support (polyfill?)
byte[] buffer = Encoding.UTF8.GetBytes("Accepted");
#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'
await response.OutputStream.WriteAsync(s_accepted, cancellationToken).ConfigureAwait(false);
}
else
{

View file

@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Utils.Json;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils;
namespace ModelContextProtocol.Protocol.Transport;
@ -160,15 +161,8 @@ public sealed class HttpListenerSseServerTransport : TransportBase, IServerTrans
/// <summary>Validates the <paramref name="serverOptions"/> and extracts from it the server name to use.</summary>
private static string GetServerName(McpServerOptions serverOptions)
{
if (serverOptions is null)
{
throw new ArgumentNullException(nameof(serverOptions));
}
if (serverOptions.ServerInfo is null)
{
throw new ArgumentNullException($"{nameof(serverOptions)}.{nameof(serverOptions.ServerInfo)}");
}
Throw.IfNull(serverOptions);
Throw.IfNull(serverOptions.ServerInfo);
return serverOptions.ServerInfo.Name;
}

View file

@ -8,8 +8,6 @@ using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
#pragma warning disable CA2213 // Disposable fields should be disposed
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>

View file

@ -8,9 +8,6 @@ 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
namespace ModelContextProtocol.Protocol.Transport;
/// <summary>

View file

@ -87,7 +87,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
#if NET
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
#else
await Task.Yield(); // TODO: Fix this
await default(ForceYielding);
#endif
try
{

View file

@ -21,7 +21,7 @@ internal static class Throw
{
if (arg is null || arg.AsSpan().IsWhiteSpace())
{
ThrowArgumentNullOrWhiteSpaceException(arg);
ThrowArgumentNullOrWhiteSpaceException(parameterName);
}
}

View file

@ -45,7 +45,7 @@ internal static class Program
GetCompletionHandler = ConfigureCompletion(),
};
var loggerFactory = CreateLoggerFactory();
using var loggerFactory = CreateLoggerFactory();
await using IMcpServer server = McpServerFactory.Create(new StdioServerTransport("TestServer", loggerFactory), options, loggerFactory);
Log.Logger.Information("Server initialized.");
@ -266,7 +266,7 @@ internal static class Program
};
}
private static HashSet<string> _subscribedResources = new();
private static readonly HashSet<string> _subscribedResources = new();
private static readonly object _subscribedResourcesLock = new();
private static ResourcesCapability ConfigureResources()

View file

@ -29,7 +29,7 @@ internal class Program
{
Console.WriteLine("Starting server...");
McpServerOptions options = new McpServerOptions()
McpServerOptions options = new()
{
ServerInfo = new Implementation() { Name = "TestServer", Version = "1.0.0" },
Capabilities = new ServerCapabilities()
@ -236,11 +236,10 @@ internal class Program
{
throw new McpServerException("Missing required argument 'uri'");
}
ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri);
if (contents is null)
{
ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ??
throw new McpServerException("Resource not found");
}
return Task.FromResult(new ReadResourceResult()
{
Contents = [contents]
@ -347,7 +346,7 @@ internal class Program
},
};
var loggerFactory = CreateLoggerFactory();
using var loggerFactory = CreateLoggerFactory();
server = McpServerFactory.Create(new HttpListenerSseServerTransport("TestServer", 3001, loggerFactory), options, loggerFactory);
Console.WriteLine("Server initialized.");
@ -357,10 +356,7 @@ internal class Program
Console.WriteLine("Server started.");
// Run until process is stopped by the client (parent process)
while (true)
{
await Task.Delay(1000);
}
await Task.Delay(Timeout.Infinite);
}
const string MCP_TINY_IMAGE =

View file

@ -220,18 +220,17 @@ public class McpServerBuilderExtensionsToolsTests
Assert.Contains("'NotRegisteredTool'", exception.Message);
}
// TODO: https://github.com/dotnet/extensions/issues/6124
//[Fact]
//public async Task Throws_Exception_Missing_Parameter()
//{
// _builder.Object.WithTools(typeof(EchoTool));
[Fact(Skip = "https://github.com/dotnet/extensions/issues/6124")]
public async Task Throws_Exception_Missing_Parameter()
{
_builder.Object.WithTools(typeof(EchoTool));
// var serviceProvider = _services.BuildServiceProvider();
// var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
// var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "Echo" }), CancellationToken.None));
// Assert.Equal("Missing required argument 'message'.", exception.Message);
//}
var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "Echo" }), CancellationToken.None));
Assert.Equal("Missing required argument 'message'.", exception.Message);
}
[Fact]
public void Throws_Exception_For_Null_Types()
@ -357,26 +356,6 @@ public class McpServerBuilderExtensionsToolsTests
}
}
public interface IDependendService
{
}
private class EchoToolWithDi
{
public EchoToolWithDi(IDependendService service)
{
}
[McpTool, Description("Echoes the input back to the client.")]
#pragma warning disable CA1822 // Mark members as static
public Task<string> Echo(string message)
#pragma warning restore CA1822 // Mark members as static
{
return Task.FromResult("hello " + message);
}
}
public class ComplexObject
{
public string? Name { get; set; }

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