Enable netfx testing. (#588)

* Enable netfx testing.

* Skip impacted tests and re-enable netfx testing.

* Remove redundant comment.

* Remove unnecessary project configuration.

* Simplify test server copying.
This commit is contained in:
Eirik Tsarpalis 2025-07-08 18:50:37 +03:00 • committed by GitHub
commit aa75f6f37e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1940 additions and 53 deletions

View file

@ -60,7 +60,7 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(System9Version)" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(System9Version)" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="OpenTelemetry" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.12.0" />
@ -75,8 +75,8 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="$(System10Version)" />
<PackageVersion Include="xunit.v3" Version="2.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
<PackageVersion Include="xunit.v3" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
</ItemGroup>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
namespace System.IO;
internal static class TextReaderExtensions
{
public static ValueTask<string> ReadLineAsync(this TextReader reader, CancellationToken cancellationToken)
{
if (reader is CancellableStreamReader cancellableReader)
{
return cancellableReader.ReadLineAsync(cancellationToken)!;
}
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask<string>(reader.ReadLineAsync());
}
}

View file

@ -0,0 +1,317 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
#nullable enable
namespace System.Text
{
internal ref partial struct ValueStringBuilder
{
private char[]? _arrayToReturnToPool;
private Span<char> _chars;
private int _pos;
public ValueStringBuilder(Span<char> initialBuffer)
{
_arrayToReturnToPool = null;
_chars = initialBuffer;
_pos = 0;
}
public ValueStringBuilder(int initialCapacity)
{
_arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
_chars = _arrayToReturnToPool;
_pos = 0;
}
public int Length
{
get => _pos;
set
{
Debug.Assert(value >= 0);
Debug.Assert(value <= _chars.Length);
_pos = value;
}
}
public int Capacity => _chars.Length;
public void EnsureCapacity(int capacity)
{
// This is not expected to be called this with negative capacity
Debug.Assert(capacity >= 0);
// If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception.
if ((uint)capacity > (uint)_chars.Length)
Grow(capacity - _pos);
}
/// <summary>
/// Get a pinnable reference to the builder.
/// Does not ensure there is a null char after <see cref="Length"/>
/// This overload is pattern matched in the C# 7.3+ compiler so you can omit
/// the explicit method call, and write eg "fixed (char* c = builder)"
/// </summary>
public ref char GetPinnableReference()
{
return ref MemoryMarshal.GetReference(_chars);
}
/// <summary>
/// Get a pinnable reference to the builder.
/// </summary>
/// <param name="terminate">Ensures that the builder has a null char after <see cref="Length"/></param>
public ref char GetPinnableReference(bool terminate)
{
if (terminate)
{
EnsureCapacity(Length + 1);
_chars[Length] = '\0';
}
return ref MemoryMarshal.GetReference(_chars);
}
public ref char this[int index]
{
get
{
Debug.Assert(index < _pos);
return ref _chars[index];
}
}
public override string ToString()
{
string s = _chars.Slice(0, _pos).ToString();
Dispose();
return s;
}
/// <summary>Returns the underlying storage of the builder.</summary>
public Span<char> RawChars => _chars;
/// <summary>
/// Returns a span around the contents of the builder.
/// </summary>
/// <param name="terminate">Ensures that the builder has a null char after <see cref="Length"/></param>
public ReadOnlySpan<char> AsSpan(bool terminate)
{
if (terminate)
{
EnsureCapacity(Length + 1);
_chars[Length] = '\0';
}
return _chars.Slice(0, _pos);
}
public ReadOnlySpan<char> AsSpan() => _chars.Slice(0, _pos);
public ReadOnlySpan<char> AsSpan(int start) => _chars.Slice(start, _pos - start);
public ReadOnlySpan<char> AsSpan(int start, int length) => _chars.Slice(start, length);
public bool TryCopyTo(Span<char> destination, out int charsWritten)
{
if (_chars.Slice(0, _pos).TryCopyTo(destination))
{
charsWritten = _pos;
Dispose();
return true;
}
else
{
charsWritten = 0;
Dispose();
return false;
}
}
public void Insert(int index, char value, int count)
{
if (_pos > _chars.Length - count)
{
Grow(count);
}
int remaining = _pos - index;
_chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
_chars.Slice(index, count).Fill(value);
_pos += count;
}
public void Insert(int index, string? s)
{
if (s == null)
{
return;
}
int count = s.Length;
if (_pos > (_chars.Length - count))
{
Grow(count);
}
int remaining = _pos - index;
_chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
s
#if !NET
.AsSpan()
#endif
.CopyTo(_chars.Slice(index));
_pos += count;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Append(char c)
{
int pos = _pos;
Span<char> chars = _chars;
if ((uint)pos < (uint)chars.Length)
{
chars[pos] = c;
_pos = pos + 1;
}
else
{
GrowAndAppend(c);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Append(string? s)
{
if (s == null)
{
return;
}
int pos = _pos;
if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc.
{
_chars[pos] = s[0];
_pos = pos + 1;
}
else
{
AppendSlow(s);
}
}
private void AppendSlow(string s)
{
int pos = _pos;
if (pos > _chars.Length - s.Length)
{
Grow(s.Length);
}
s
#if !NET
.AsSpan()
#endif
.CopyTo(_chars.Slice(pos));
_pos += s.Length;
}
public void Append(char c, int count)
{
if (_pos > _chars.Length - count)
{
Grow(count);
}
Span<char> dst = _chars.Slice(_pos, count);
for (int i = 0; i < dst.Length; i++)
{
dst[i] = c;
}
_pos += count;
}
public void Append(scoped ReadOnlySpan<char> value)
{
int pos = _pos;
if (pos > _chars.Length - value.Length)
{
Grow(value.Length);
}
value.CopyTo(_chars.Slice(_pos));
_pos += value.Length;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Span<char> AppendSpan(int length)
{
int origPos = _pos;
if (origPos > _chars.Length - length)
{
Grow(length);
}
_pos = origPos + length;
return _chars.Slice(origPos, length);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void GrowAndAppend(char c)
{
Grow(1);
Append(c);
}
/// <summary>
/// Resize the internal buffer either by doubling current buffer size or
/// by adding <paramref name="additionalCapacityBeyondPos"/> to
/// <see cref="_pos"/> whichever is greater.
/// </summary>
/// <param name="additionalCapacityBeyondPos">
/// Number of chars requested beyond current position.
/// </param>
[MethodImpl(MethodImplOptions.NoInlining)]
private void Grow(int additionalCapacityBeyondPos)
{
Debug.Assert(additionalCapacityBeyondPos > 0);
Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed.");
const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength
// Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try
// to double the size if possible, bounding the doubling to not go beyond the max array length.
int newCapacity = (int)Math.Max(
(uint)(_pos + additionalCapacityBeyondPos),
Math.Min((uint)_chars.Length * 2, ArrayMaxLength));
// Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative.
// This could also go negative if the actual required length wraps around.
char[] poolArray = ArrayPool<char>.Shared.Rent(newCapacity);
_chars.Slice(0, _pos).CopyTo(poolArray);
char[]? toReturn = _arrayToReturnToPool;
_chars = _arrayToReturnToPool = poolArray;
if (toReturn != null)
{
ArrayPool<char>.Shared.Return(toReturn);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Dispose()
{
char[]? toReturn = _arrayToReturnToPool;
this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again
if (toReturn != null)
{
ArrayPool<char>.Shared.Return(toReturn);
}
}
}
}

View file

@ -1,6 +1,7 @@
using ModelContextProtocol;
using System.Buffers;
using System.Runtime.InteropServices;
using System.Text;
namespace System.IO;
@ -33,4 +34,31 @@ internal static class StreamExtensions
}
}
}
public static ValueTask<int> ReadAsync(this Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
{
Throw.IfNull(stream);
if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> segment))
{
return new ValueTask<int>(stream.ReadAsync(segment.Array, segment.Offset, segment.Count, cancellationToken));
}
else
{
return ReadAsyncCore(stream, buffer, cancellationToken);
static async ValueTask<int> ReadAsyncCore(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
{
byte[] array = ArrayPool<byte>.Shared.Rent(buffer.Length);
try
{
int bytesRead = await stream.ReadAsync(array, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
array.AsSpan(0, bytesRead).CopyTo(buffer.Span);
return bytesRead;
}
finally
{
ArrayPool<byte>.Shared.Return(array);
}
}
}
}
}

View file

@ -1,10 +0,0 @@
namespace System.IO;
internal static class TextReaderExtensions
{
public static Task<string> ReadLineAsync(this TextReader reader, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return reader.ReadLineAsync();
}
}

View file

@ -25,6 +25,15 @@ internal static class Throw
}
}
public static void IfNegative(int arg, [CallerArgumentExpression(nameof(arg))] string? parameterName = null)
{
if (arg < 0)
{
Throw(parameterName);
static void Throw(string? parameterName) => throw new ArgumentOutOfRangeException(parameterName, "must not be negative.");
}
}
[DoesNotReturn]
private static void ThrowArgumentNullOrWhiteSpaceException(string? parameterName)
{

View file

@ -13,7 +13,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
private int _cleanedUp = 0;
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory)
: base(process.StandardInput, process.StandardOutput, endpointName, loggerFactory)
: base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
{
_process = process;
_options = options;

View file

@ -69,8 +69,6 @@ public sealed partial class StdioClientTransport : IClientTransport
{
LogTransportConnecting(logger, endpointName);
UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
ProcessStartInfo startInfo = new()
{
FileName = command,
@ -80,10 +78,10 @@ public sealed partial class StdioClientTransport : IClientTransport
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory,
StandardOutputEncoding = noBomUTF8,
StandardErrorEncoding = noBomUTF8,
StandardOutputEncoding = StreamClientSessionTransport.NoBomUtf8Encoding,
StandardErrorEncoding = StreamClientSessionTransport.NoBomUtf8Encoding,
#if NET
StandardInputEncoding = noBomUTF8,
StandardInputEncoding = StreamClientSessionTransport.NoBomUtf8Encoding,
#endif
};
@ -164,7 +162,7 @@ public sealed partial class StdioClientTransport : IClientTransport
Encoding originalInputEncoding = Console.InputEncoding;
try
{
Console.InputEncoding = noBomUTF8;
Console.InputEncoding = StreamClientSessionTransport.NoBomUtf8Encoding;
processStarted = process.Start();
}
finally

View file

@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using System.Text;
using System.Text.Json;
namespace ModelContextProtocol.Client;
@ -7,6 +8,8 @@ namespace ModelContextProtocol.Client;
/// <summary>Provides the client side of a stream-based session transport.</summary>
internal class StreamClientSessionTransport : TransportBase
{
internal static UTF8Encoding NoBomUtf8Encoding { get; } = new(encoderShouldEmitUTF8Identifier: false);
private readonly TextReader _serverOutput;
private readonly TextWriter _serverInput;
private readonly SemaphoreSlim _sendLock = new(1, 1);
@ -54,6 +57,43 @@ internal class StreamClientSessionTransport : TransportBase
readTask.Start();
}
/// <summary>
/// Initializes a new instance of the <see cref="StreamClientSessionTransport"/> class.
/// </summary>
/// <param name="serverInput">
/// The server's input stream. Messages written to this stream will be sent to the server.
/// </param>
/// <param name="serverOutput">
/// The server's output stream. Messages read from this stream will be received from the server.
/// </param>
/// <param name="encoding">
/// The encoding used for reading and writing messages from the input and output streams. Defaults to UTF-8 without BOM if null.
/// </param>
/// <param name="endpointName">
/// A name that identifies this transport endpoint in logs.
/// </param>
/// <param name="loggerFactory">
/// Optional factory for creating loggers. If null, a NullLogger will be used.
/// </param>
/// <remarks>
/// This constructor starts a background task to read messages from the server output stream.
/// The transport will be marked as connected once initialized.
/// </remarks>
public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory)
: this(
new StreamWriter(serverInput, encoding ?? NoBomUtf8Encoding),
#if NET
new StreamReader(serverOutput, encoding ?? NoBomUtf8Encoding),
#else
new CancellableStreamReader(serverOutput, encoding ?? NoBomUtf8Encoding),
#endif
endpointName,
loggerFactory)
{
Throw.IfNull(serverInput);
Throw.IfNull(serverOutput);
}
/// <inheritdoc/>
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
{

View file

@ -47,8 +47,9 @@ public sealed class StreamClientTransport : IClientTransport
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<ITransport>(new StreamClientSessionTransport(
new StreamWriter(_serverInput),
new StreamReader(_serverOutput),
_serverInput,
_serverOutput,
encoding: null,
"Client (stream)",
_loggerFactory));
}

View file

@ -7,6 +7,7 @@
<PackageId>ModelContextProtocol.Core</PackageId>
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
@ -20,6 +21,7 @@
<!-- Dependencies only needed by netstandard2.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<Compile Include="..\Common\Polyfills\**\*.cs" />
<Compile Include="..\Common\CancellableStreamReader\**\*.cs" />
<PackageReference Include="Microsoft.Bcl.Memory" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
<PackageReference Include="System.Text.Json" />

View file

@ -17,7 +17,7 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
/// </summary>
public McpServerPrimitiveCollection(IEqualityComparer<string>? keyComparer = null)
{
_primitives = new(keyComparer);
_primitives = new(keyComparer ?? EqualityComparer<string>.Default);
}
/// <summary>Occurs when the collection is changed.</summary>

View file

@ -46,7 +46,11 @@ public class StreamServerTransport : TransportBase
_logger = loggerFactory?.CreateLogger(GetType()) ?? NullLogger.Instance;
#if NET
_inputReader = new StreamReader(inputStream, Encoding.UTF8);
#else
_inputReader = new CancellableStreamReader(inputStream, Encoding.UTF8);
#endif
_outputStream = outputStream;
SetConnected();

View file

@ -1,4 +1,6 @@
namespace ModelContextProtocol.Tests.Utils;
using System.Net.Http;
namespace ModelContextProtocol.Tests.Utils;
public class MockHttpHandler : HttpMessageHandler
{

View file

@ -0,0 +1,15 @@
namespace System.Diagnostics;
public static class ProcessExtensions
{
public static async Task WaitForExitAsync(this Process process, TimeSpan timeout)
{
#if NET
using var shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
shutdownCts.CancelAfter(timeout);
await process.WaitForExitAsync(shutdownCts.Token);
#else
process.WaitForExit(milliseconds: (int)timeout.TotalMilliseconds);
#endif
}
}

View file

@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>

View file

@ -25,14 +25,14 @@ public class ClientIntegrationTestFixture
TestServerTransportOptions = new()
{
Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "TestServer.exe" : "dotnet",
Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "TestServer.exe" : PlatformDetection.IsMonoRuntime ? "mono" : "dotnet",
Name = "TestServer",
};
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Change to Arguments to "mcp-server-everything" if you want to run the server locally after creating a symlink
TestServerTransportOptions.Arguments = ["TestServer.dll"];
TestServerTransportOptions.Arguments = [PlatformDetection.IsMonoRuntime ? "TestServer.exe" : "TestServer.dll"];
}
}

View file

@ -606,7 +606,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
McpClientTool progressTool = tools.First(t => t.Name == "sends_progress_notifications");
TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource<bool> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
int remainingNotifications = 10;
ConcurrentQueue<ProgressNotificationParams> notifications = new();
@ -618,7 +618,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
notifications.Enqueue(pn);
if (Interlocked.Decrement(ref remainingNotifications) == 0)
{
tcs.SetResult();
tcs.SetResult(true);
}
}

View file

@ -49,7 +49,7 @@ public class EverythingSseServerFixture : IAsyncDisposable
using var stopProcess = Process.Start(stopInfo)
?? throw new InvalidOperationException($"Could not stop process for {stopInfo.FileName} with '{stopInfo.Arguments}'.");
await stopProcess.WaitForExitAsync();
await stopProcess.WaitForExitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex)
{
@ -60,6 +60,7 @@ public class EverythingSseServerFixture : IAsyncDisposable
private static bool CheckIsDockerAvailable()
{
#if NET
try
{
ProcessStartInfo processStartInfo = new()
@ -78,5 +79,9 @@ public class EverythingSseServerFixture : IAsyncDisposable
{
return false;
}
#else
// Do not run docker tests using .NET framework.
return false;
#endif
}
}

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