Fix process crash when testing Stderr (#1449)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
70442e9351
commit
2e7209c35a
13 changed files with 254 additions and 148 deletions
|
|
@ -10,15 +10,17 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
|||
private readonly StdioClientTransportOptions _options;
|
||||
private readonly Process _process;
|
||||
private readonly Queue<string> _stderrRollingLog;
|
||||
private readonly DataReceivedEventHandler _errorHandler;
|
||||
private int _cleanedUp = 0;
|
||||
private readonly int? _processId;
|
||||
|
||||
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
|
||||
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
|
||||
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
|
||||
{
|
||||
_options = options;
|
||||
_process = process;
|
||||
_stderrRollingLog = stderrRollingLog;
|
||||
_errorHandler = errorHandler;
|
||||
try { _processId = process.Id; } catch { }
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
|||
{
|
||||
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
|
||||
// for the I/O error, we should throw an exception for that instead.
|
||||
if (await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false) is Exception processExitException)
|
||||
if (await GetUnexpectedExitExceptionAsync().ConfigureAwait(false) is Exception processExitException)
|
||||
{
|
||||
throw processExitException;
|
||||
}
|
||||
|
|
@ -45,15 +47,32 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
|||
/// <inheritdoc/>
|
||||
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Only clean up once.
|
||||
// Only run the full stdio cleanup once (handler detach, process kill, etc.).
|
||||
// If another call is already handling cleanup, cancel the shutdown token
|
||||
// to unblock it (e.g. if it's stuck in WaitForExitAsync) and let it
|
||||
// call SetDisconnected with full StdioClientCompletionDetails.
|
||||
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
|
||||
{
|
||||
CancelShutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
|
||||
// so create an exception with details about that.
|
||||
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
|
||||
error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false);
|
||||
|
||||
// Ensure all pending ErrorDataReceived events are drained before detaching
|
||||
// the handler. GetUnexpectedExitExceptionAsync does this when HasExited is
|
||||
// true, but there is a narrow window on Linux where the process has closed
|
||||
// stdout (causing EOF in ReadMessagesAsync) yet hasn't been fully reaped,
|
||||
// so HasExited returns false and the drain is skipped. An unconditional
|
||||
// wait here covers that gap. When the drain already happened above, the
|
||||
// call returns immediately.
|
||||
await WaitForProcessExitAsync().ConfigureAwait(false);
|
||||
|
||||
// Detach the stderr handler so no further ErrorDataReceived events
|
||||
// are dispatched during or after process disposal.
|
||||
_process.ErrorDataReceived -= _errorHandler;
|
||||
|
||||
// Terminate the server process (or confirm it already exited), then build
|
||||
// and publish strongly-typed completion details while the process handle
|
||||
|
|
@ -78,7 +97,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
|||
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)
|
||||
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync()
|
||||
{
|
||||
if (!StdioClientTransport.HasExited(_process))
|
||||
{
|
||||
|
|
@ -86,18 +105,8 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
|||
}
|
||||
|
||||
Debug.Assert(StdioClientTransport.HasExited(_process));
|
||||
try
|
||||
{
|
||||
// The process has exited, but we still need to ensure stderr has been flushed.
|
||||
// WaitForExitAsync only waits for exit; it does not guarantee that all
|
||||
// ErrorDataReceived events have been dispatched. The synchronous WaitForExit()
|
||||
// (no arguments) does ensure that, so call it after WaitForExitAsync completes.
|
||||
#if NET
|
||||
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
#endif
|
||||
_process.WaitForExit();
|
||||
}
|
||||
catch { }
|
||||
|
||||
await WaitForProcessExitAsync().ConfigureAwait(false);
|
||||
|
||||
string errorMessage = "MCP server process exited unexpectedly";
|
||||
|
||||
|
|
@ -122,6 +131,35 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
|||
return new IOException(errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the process to exit within <see cref="StdioClientTransportOptions.ShutdownTimeout"/>
|
||||
/// and flushes pending <see cref="Process.ErrorDataReceived"/> events.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On .NET, <c>Process.WaitForExitAsync</c> also waits for asynchronous output readers
|
||||
/// to complete, ensuring all events have been dispatched. On .NET Framework,
|
||||
/// <see cref="Process.WaitForExit(int)"/> does not guarantee this; the parameterless
|
||||
/// <see cref="Process.WaitForExit()"/> overload is needed to flush the event queue.
|
||||
/// This method is idempotent—calling it after the process has already been waited on
|
||||
/// returns immediately.
|
||||
/// </remarks>
|
||||
private async ValueTask WaitForProcessExitAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
#if NET
|
||||
using var timeoutCts = new CancellationTokenSource(_options.ShutdownTimeout);
|
||||
await _process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
#else
|
||||
if (_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
|
||||
{
|
||||
_process.WaitForExit();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
|
||||
{
|
||||
StdioClientCompletionDetails details = new()
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
|
||||
Process? process = null;
|
||||
bool processStarted = false;
|
||||
DataReceivedEventHandler? errorHandler = null;
|
||||
|
||||
string command = _options.Command;
|
||||
IList<string>? arguments = _options.Arguments;
|
||||
|
|
@ -136,7 +137,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
// few lines in a rolling log for use in exceptions.
|
||||
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
|
||||
Queue<string> stderrRollingLog = new(MaxStderrLength);
|
||||
process.ErrorDataReceived += (sender, args) =>
|
||||
errorHandler = (sender, args) =>
|
||||
{
|
||||
string? data = args.Data;
|
||||
if (data is not null)
|
||||
|
|
@ -151,11 +152,22 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
stderrRollingLog.Enqueue(data);
|
||||
}
|
||||
|
||||
_options.StandardErrorLines?.Invoke(data);
|
||||
try
|
||||
{
|
||||
_options.StandardErrorLines?.Invoke(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Prevent exceptions in the user callback from propagating
|
||||
// to the background thread that dispatches ErrorDataReceived,
|
||||
// which would crash the process.
|
||||
LogStderrCallbackFailed(logger, endpointName, ex);
|
||||
}
|
||||
|
||||
LogReadStderr(logger, endpointName, data);
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
|
||||
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
|
||||
|
|
@ -193,7 +205,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
|
||||
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, errorHandler, _loggerFactory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -201,6 +213,11 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
|
||||
try
|
||||
{
|
||||
if (process is not null && errorHandler is not null)
|
||||
{
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
}
|
||||
|
||||
DisposeProcess(process, processStarted, _options.ShutdownTimeout);
|
||||
}
|
||||
catch (Exception ex2)
|
||||
|
|
@ -228,18 +245,6 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
process.KillTree(shutdownTimeout);
|
||||
}
|
||||
|
||||
// Ensure all redirected stderr/stdout events have been dispatched
|
||||
// before disposing. Only the no-arg WaitForExit() guarantees this;
|
||||
// WaitForExit(int) (as used by KillTree) does not.
|
||||
// This should not hang: either the process already exited on its own
|
||||
// (no child processes holding handles), or KillTree killed the entire
|
||||
// process tree. If it does take too long, the test infrastructure's
|
||||
// own timeout will catch it.
|
||||
if (!processRunning && HasExited(process))
|
||||
{
|
||||
process.WaitForExit();
|
||||
}
|
||||
|
||||
// Invoke the callback while the process handle is still valid,
|
||||
// e.g. to read ExitCode before Dispose() invalidates it.
|
||||
beforeDispose?.Invoke();
|
||||
|
|
@ -299,6 +304,9 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")]
|
||||
private static partial void LogReadStderr(ILogger logger, string endpointName, string data);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} StandardErrorLines callback failed.")]
|
||||
private static partial void LogStderrCallbackFailed(ILogger logger, string endpointName, Exception exception);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} started server process with PID {ProcessId}.")]
|
||||
private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ internal class StreamClientSessionTransport : TransportBase
|
|||
private readonly TextReader _serverOutput;
|
||||
private readonly Stream _serverInputStream;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private CancellationTokenSource? _shutdownCts = new();
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
private Task? _readTask;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -97,8 +97,16 @@ internal class StreamClientSessionTransport : TransportBase
|
|||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DisposeAsync() =>
|
||||
CleanupAsync(cancellationToken: CancellationToken.None);
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await CleanupAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Ensure the channel is always completed after disposal, even if CleanupAsync
|
||||
// returned early because another caller (e.g. ReadMessagesAsync) was already
|
||||
// running cleanup. SetDisconnected is idempotent—if the channel was already
|
||||
// completed by the other cleanup path, this is a no-op.
|
||||
SetDisconnected();
|
||||
}
|
||||
|
||||
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -168,15 +176,20 @@ internal class StreamClientSessionTransport : TransportBase
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the shutdown token to signal that the transport is shutting down,
|
||||
/// without performing any other cleanup.
|
||||
/// </summary>
|
||||
protected void CancelShutdown()
|
||||
{
|
||||
_shutdownCts.Cancel();
|
||||
}
|
||||
|
||||
protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LogTransportShuttingDown(Name);
|
||||
|
||||
if (Interlocked.Exchange(ref _shutdownCts, null) is { } shutdownCts)
|
||||
{
|
||||
await shutdownCts.CancelAsync().ConfigureAwait(false);
|
||||
shutdownCts.Dispose();
|
||||
}
|
||||
await _shutdownCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (Interlocked.Exchange(ref _readTask, null) is Task readTask)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -82,24 +82,29 @@ public class ClientConformanceTests
|
|||
|
||||
var process = new Process { StartInfo = startInfo };
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
// Protect callbacks with try/catch to prevent ITestOutputHelper from
|
||||
// throwing on a background thread if events arrive after the test completes.
|
||||
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
_output.WriteLine(e.Data);
|
||||
try { _output.WriteLine(e.Data); } catch { }
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
_output.WriteLine(e.Data);
|
||||
try { _output.WriteLine(e.Data); } catch { }
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
|
@ -112,6 +117,8 @@ public class ClientConformanceTests
|
|||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
return (
|
||||
Success: false,
|
||||
Output: outputBuilder.ToString(),
|
||||
|
|
@ -119,6 +126,9 @@ public class ClientConformanceTests
|
|||
);
|
||||
}
|
||||
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
|
||||
var output = outputBuilder.ToString();
|
||||
var error = errorBuilder.ToString();
|
||||
var success = process.ExitCode == 0 || HasOnlyWarnings(output, error);
|
||||
|
|
|
|||
|
|
@ -136,24 +136,29 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
|
||||
var process = new Process { StartInfo = startInfo };
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
// Protect callbacks with try/catch to prevent ITestOutputHelper from
|
||||
// throwing on a background thread if events arrive after the test completes.
|
||||
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
output.WriteLine(e.Data);
|
||||
try { output.WriteLine(e.Data); } catch { }
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
output.WriteLine(e.Data);
|
||||
try { output.WriteLine(e.Data); } catch { }
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
|
@ -166,6 +171,8 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
return (
|
||||
Success: false,
|
||||
Output: outputBuilder.ToString(),
|
||||
|
|
@ -173,6 +180,9 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
|||
);
|
||||
}
|
||||
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
|
||||
return (
|
||||
Success: process.ExitCode == 0,
|
||||
Output: outputBuilder.ToString(),
|
||||
|
|
|
|||
|
|
@ -105,85 +105,96 @@ var clientTransport = new HttpClientTransport(new()
|
|||
OAuth = oauthOptions,
|
||||
}, loggerFactory: consoleLoggerFactory);
|
||||
|
||||
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
|
||||
|
||||
bool success = true;
|
||||
|
||||
switch (scenario)
|
||||
try
|
||||
{
|
||||
case "tools_call":
|
||||
{
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
|
||||
|
||||
// Call the "add_numbers" tool
|
||||
var toolName = "add_numbers";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "a", 5 },
|
||||
{ "b", 10 }
|
||||
});
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
case "elicitation-sep1034-client-defaults":
|
||||
{
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
var toolName = "test_client_elicitation_defaults";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
case "sse-retry":
|
||||
{
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
var toolName = "test_reconnection";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
case "auth/scope-step-up":
|
||||
{
|
||||
// Just testing that we can authenticate and list tools
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
bool success = true;
|
||||
|
||||
// Call the "test_tool" tool
|
||||
var toolName = "test-tool";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "foo", "bar" },
|
||||
});
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
case "auth/scope-retry-limit":
|
||||
switch (scenario)
|
||||
{
|
||||
// Try to list tools - this triggers the auth flow that always fails with 403.
|
||||
// The test validates the client doesn't retry indefinitely.
|
||||
try
|
||||
case "tools_call":
|
||||
{
|
||||
await mcpClient.ListToolsAsync();
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
|
||||
// Call the "add_numbers" tool
|
||||
var toolName = "add_numbers";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "a", 5 },
|
||||
{ "b", 10 }
|
||||
});
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
case "elicitation-sep1034-client-defaults":
|
||||
{
|
||||
Console.WriteLine($"Expected auth failure: {ex.Message}");
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
var toolName = "test_client_elicitation_defaults";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "sse-retry":
|
||||
{
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
var toolName = "test_reconnection";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
case "auth/scope-step-up":
|
||||
{
|
||||
// Just testing that we can authenticate and list tools
|
||||
var tools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
|
||||
// Call the "test_tool" tool
|
||||
var toolName = "test-tool";
|
||||
Console.WriteLine($"Calling tool: {toolName}");
|
||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "foo", "bar" },
|
||||
});
|
||||
success &= !(result.IsError == true);
|
||||
break;
|
||||
}
|
||||
case "auth/scope-retry-limit":
|
||||
{
|
||||
// Try to list tools - this triggers the auth flow that always fails with 403.
|
||||
// The test validates the client doesn't retry indefinitely.
|
||||
try
|
||||
{
|
||||
await mcpClient.ListToolsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Expected auth failure: {ex.Message}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// No extra processing for other scenarios
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// No extra processing for other scenarios
|
||||
break;
|
||||
|
||||
// Exit code 0 on success, 1 on failure
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Report the error to stderr and exit with a non-zero code rather than
|
||||
// crashing the process with an unhandled exception. An unhandled exception
|
||||
// generates a crash dump which can abort the parent test host.
|
||||
Console.Error.WriteLine($"Conformance client failed: {ex}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Exit code 0 on success, 1 on failure
|
||||
return success ? 0 : 1;
|
||||
|
||||
// Copied from ProtectedMcpClient sample
|
||||
// Simulate a user opening the browser and logging in
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ namespace ModelContextProtocol.Tests.Configuration;
|
|||
public class McpServerBuilderExtensionsTransportsTests
|
||||
{
|
||||
[Fact]
|
||||
public void WithStdioServerTransport_Sets_Transport()
|
||||
public void WithStdioServerTransport_Registers_Transport()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddMcpServer().WithStdioServerTransport();
|
||||
|
||||
var transportServiceType = services.FirstOrDefault(s => s.ServiceType == typeof(ITransport));
|
||||
Assert.NotNull(transportServiceType);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
Assert.IsType<StdioServerTransport>(serviceProvider.GetRequiredService<ITransport>());
|
||||
// Verify StdioServerTransport is registered for ITransport, but don't resolve it —
|
||||
// doing so opens Console.OpenStandardInput() which permanently blocks a thread pool
|
||||
// thread on the test host's stdin. StdioServerTransport should only be used in a
|
||||
// dedicated child process, not in-process.
|
||||
var transportDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ITransport));
|
||||
Assert.NotNull(transportDescriptor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -181,9 +181,10 @@ public class McpServerOptionsSetupTests
|
|||
};
|
||||
})
|
||||
.WithResources<SimpleResourceType>()
|
||||
.WithStdioServerTransport();
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||
|
||||
var options = services.BuildServiceProvider().GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
await using var sp = services.BuildServiceProvider();
|
||||
var options = sp.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
|
||||
// The options should preserve the user's manually set capabilities
|
||||
Assert.NotNull(options.Capabilities?.Resources);
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ public class McpServerResourceCapabilityIssueReproTests : ClientServerTestBase
|
|||
};
|
||||
})
|
||||
.WithResources<LiveResources>()
|
||||
.WithStdioServerTransport();
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
|
||||
// Verify capabilities are preserved
|
||||
|
|
@ -122,15 +122,15 @@ public class McpServerResourceCapabilityIssueReproTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ResourcesCapability_IsCreated_WhenOnlyResourcesAreProvided()
|
||||
public async Task ResourcesCapability_IsCreated_WhenOnlyResourcesAreProvided()
|
||||
{
|
||||
// Test that ResourcesCapability is created even without handlers or manual setting
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddMcpServer()
|
||||
.WithResources<LiveResources>()
|
||||
.WithStdioServerTransport();
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
|
||||
// Resources are registered
|
||||
|
|
|
|||
|
|
@ -15,29 +15,29 @@ public class McpServerLoggingLevelTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCreateServerWithLoggingLevelHandler()
|
||||
public async Task CanCreateServerWithLoggingLevelHandler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
provider.GetRequiredService<McpServer>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddingLoggingLevelHandlerSetsLoggingCapability()
|
||||
public async Task AddingLoggingLevelHandlerSetsLoggingCapability()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
var server = provider.GetRequiredService<McpServer>();
|
||||
|
||||
|
|
@ -46,12 +46,12 @@ public class McpServerLoggingLevelTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerWithoutCallingLoggingLevelHandlerDoesNotSetLoggingCapability()
|
||||
public async Task ServerWithoutCallingLoggingLevelHandlerDoesNotSetLoggingCapability()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddMcpServer()
|
||||
.WithStdioServerTransport();
|
||||
var provider = services.BuildServiceProvider();
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetRequiredService<McpServer>();
|
||||
Assert.Null(server.ServerOptions.Capabilities?.Logging);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ public partial class McpServerResourceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCreateServerWithResource()
|
||||
public async Task CanCreateServerWithResource()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||
.WithListResourcesHandler(async (ctx, ct) =>
|
||||
{
|
||||
return new ListResourcesResult
|
||||
|
|
@ -58,19 +58,19 @@ public partial class McpServerResourceTests
|
|||
};
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
provider.GetRequiredService<McpServer>();
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void CanCreateServerWithResourceTemplates()
|
||||
public async Task CanCreateServerWithResourceTemplates()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||
.WithListResourceTemplatesHandler(async (ctx, ct) =>
|
||||
{
|
||||
return new ListResourceTemplatesResult
|
||||
|
|
@ -94,17 +94,17 @@ public partial class McpServerResourceTests
|
|||
};
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
provider.GetRequiredService<McpServer>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatingReadHandlerWithNoListHandlerSucceeds()
|
||||
public async Task CreatingReadHandlerWithNoListHandlerSucceeds()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||
.WithReadResourceHandler(async (ctx, ct) =>
|
||||
{
|
||||
return new ReadResourceResult
|
||||
|
|
@ -117,7 +117,7 @@ public partial class McpServerResourceTests
|
|||
}]
|
||||
};
|
||||
});
|
||||
var sp = services.BuildServiceProvider();
|
||||
await using var sp = services.BuildServiceProvider();
|
||||
|
||||
sp.GetRequiredService<McpServer>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,17 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
|
|||
Assert.Contains(id, sb.ToString());
|
||||
}
|
||||
|
||||
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
|
||||
public async Task CreateAsync_StdErrCallbackThrows_DoesNotCrashProcess()
|
||||
{
|
||||
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
|
||||
new(new() { Command = "cmd", Arguments = ["/c", "echo fail >&2 & exit /b 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory) :
|
||||
new(new() { Command = "sh", Arguments = ["-c", "echo fail >&2; exit 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory);
|
||||
|
||||
// Should throw IOException for the failed server, not crash the host process.
|
||||
await Assert.ThrowsAnyAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("argument with spaces")]
|
||||
|
|
|
|||
|
|
@ -27,8 +27,11 @@ public class StdioServerTransportTests : LoggedTest
|
|||
[Fact]
|
||||
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
|
||||
{
|
||||
// Act
|
||||
await using var transport = new StdioServerTransport(_serverOptions);
|
||||
// Use StreamServerTransport with Stream.Null rather than StdioServerTransport.
|
||||
// StdioServerTransport opens Console.OpenStandardInput() which permanently
|
||||
// blocks a thread pool thread on the test host's stdin. StdioServerTransport
|
||||
// should only be instantiated in a dedicated child process.
|
||||
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null, _serverOptions.ServerInfo?.Name);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(transport);
|
||||
|
|
|
|||
Loading…
Reference in a new issue