Address some static analysis and TODOs (#12)
This commit is contained in:
parent
3de19d38e4
commit
33b9582199
24 changed files with 132 additions and 123 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
36
src/Common/Polyfills/System/IO/StreamExtensions.cs
Normal file
36
src/Common/Polyfills/System/IO/StreamExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
17
src/Common/Polyfills/System/Threading/ForceYielding.cs
Normal file
17
src/Common/Polyfills/System/Threading/ForceYielding.cs
Normal 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() { }
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ internal static class Throw
|
|||
{
|
||||
if (arg is null || arg.AsSpan().IsWhiteSpace())
|
||||
{
|
||||
ThrowArgumentNullOrWhiteSpaceException(arg);
|
||||
ThrowArgumentNullOrWhiteSpaceException(parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue