* Initial plan * Fix race condition in StdioClientSessionTransport stderr handling Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Simplify WaitForExit logic as suggested in code review Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Skip flaky test on Mono and revert incorrect fix Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
131 lines
5.1 KiB
C#
131 lines
5.1 KiB
C#
using ModelContextProtocol.Client;
|
|
using ModelContextProtocol.Protocol;
|
|
using ModelContextProtocol.Tests.Utils;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
|
|
namespace ModelContextProtocol.Tests.Transport;
|
|
|
|
public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
|
{
|
|
public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime;
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_ValidProcessInvalidServer_Throws()
|
|
{
|
|
string id = Guid.NewGuid().ToString("N");
|
|
|
|
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
|
|
new(new() { Command = "cmd", Arguments = ["/c", $"echo {id} >&2 & exit /b 1"] }, LoggerFactory) :
|
|
new(new() { Command = "sh", Arguments = ["-c", $"echo {id} >&2; exit 1"] }, LoggerFactory);
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
|
|
public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked()
|
|
{
|
|
string id = Guid.NewGuid().ToString("N");
|
|
|
|
int count = 0;
|
|
StringBuilder sb = new();
|
|
Action<string> stdErrCallback = line =>
|
|
{
|
|
Assert.NotNull(line);
|
|
lock (sb)
|
|
{
|
|
sb.AppendLine(line);
|
|
count++;
|
|
}
|
|
};
|
|
|
|
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
|
|
new(new() { Command = "cmd", Arguments = ["/c", $"echo {id} >&2 & exit /b 1"], StandardErrorLines = stdErrCallback }, LoggerFactory) :
|
|
new(new() { Command = "sh", Arguments = ["-c", $"echo {id} >&2; exit 1"], StandardErrorLines = stdErrCallback }, LoggerFactory);
|
|
|
|
await Assert.ThrowsAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
|
|
|
Assert.InRange(count, 1, int.MaxValue);
|
|
Assert.Contains(id, sb.ToString());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("argument with spaces")]
|
|
[InlineData("&")]
|
|
[InlineData("|")]
|
|
[InlineData(">")]
|
|
[InlineData("<")]
|
|
[InlineData("^")]
|
|
[InlineData(" & ")]
|
|
[InlineData(" | ")]
|
|
[InlineData(" > ")]
|
|
[InlineData(" < ")]
|
|
[InlineData(" ^ ")]
|
|
[InlineData("& ")]
|
|
[InlineData("| ")]
|
|
[InlineData("> ")]
|
|
[InlineData("< ")]
|
|
[InlineData("^ ")]
|
|
[InlineData(" &")]
|
|
[InlineData(" |")]
|
|
[InlineData(" >")]
|
|
[InlineData(" <")]
|
|
[InlineData(" ^")]
|
|
[InlineData("^&<>|")]
|
|
[InlineData("^&<>| ")]
|
|
[InlineData(" ^&<>|")]
|
|
[InlineData("\t^&<>")]
|
|
[InlineData("^&\t<>")]
|
|
[InlineData("ls /tmp | grep foo.txt > /dev/null")]
|
|
[InlineData("let rec Y f x = f (Y f) x")]
|
|
[InlineData("value with \"quotes\" and spaces")]
|
|
[InlineData("C:\\Program Files\\Test App\\app.dll")]
|
|
[InlineData("C:\\EndsWithBackslash\\")]
|
|
[InlineData("--already-looks-like-flag")]
|
|
[InlineData("-starts-with-dash")]
|
|
[InlineData("name=value=another")]
|
|
[InlineData("$(echo injected)")]
|
|
[InlineData("value-with-\"quotes\"-and-\\backslashes\\")]
|
|
[InlineData("http://localhost:1234/callback?foo=1&bar=2")]
|
|
public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue)
|
|
{
|
|
if (PlatformDetection.IsMonoRuntime && cliArgumentValue?.EndsWith("\\") is true)
|
|
{
|
|
Assert.Skip("mono runtime does not handle arguments ending with backslash correctly.");
|
|
}
|
|
|
|
string cliArgument = $"--cli-arg={cliArgumentValue}";
|
|
|
|
StdioClientTransportOptions options = new()
|
|
{
|
|
Name = "TestServer",
|
|
Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch
|
|
{
|
|
(true, _) => "mono",
|
|
(_, true) => "TestServer.exe",
|
|
_ => "dotnet",
|
|
},
|
|
Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch
|
|
{
|
|
(true, _) => ["TestServer.exe", cliArgument],
|
|
(_, true) => [cliArgument],
|
|
_ => ["TestServer.dll", cliArgument],
|
|
},
|
|
};
|
|
|
|
var transport = new StdioClientTransport(options, LoggerFactory);
|
|
|
|
// Act: Create client (handshake) and list tools to ensure full round trip works with the argument present.
|
|
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
|
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(tools);
|
|
Assert.NotEmpty(tools);
|
|
|
|
var result = await client.CallToolAsync("echoCliArg", cancellationToken: TestContext.Current.CancellationToken);
|
|
var content = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
|
|
Assert.Equal(cliArgumentValue ?? "", content.Text);
|
|
}
|
|
}
|