Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/ModelContextProtocol.Core/Client/StdioClientTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,13 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =

LogTransportProcessStarted(logger, endpointName, process.Id);

process.BeginErrorReadLine();
// Suppress ExecutionContext flow so the Process's internal async
// stderr reader thread doesn't capture the caller's ambient context
// (e.g. AsyncLocal values from test infrastructure or HTTP request state).
using (ExecutionContext.SuppressFlow())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is undesirable—the stderr handler is a long-lived background I/O callback that shouldn't inherit ambient state from whichever call site happened to create the transport.

While I kind of agree with this, I also don't want to be muddying every bit of code that kicks off a potentially longish running task with ExecutionContext.SuppressFlow(). And if we do, I would like some clear cut guidance on what counts as a long running thread that shouldn't be allow to hold on to the execution context.

{
process.BeginErrorReadLine();
}

return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, errorHandler, _loggerFactory);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;

namespace ModelContextProtocol.Tests.Transport;

Expand Down Expand Up @@ -60,6 +61,47 @@ public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked()
}

[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
public async Task CreateAsync_StdErrCallback_DoesNotCaptureCallerAsyncLocal()
{
var asyncLocal = new AsyncLocal<string>();
asyncLocal.Value = "caller-context";

string? capturedValue = "not-set";
var received = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
new(new()
{
Command = "cmd",
Arguments = ["/c", "echo test >&2 & exit /b 1"],
StandardErrorLines = _ =>
{
capturedValue = asyncLocal.Value;
received.TrySetResult(true);
}
}, LoggerFactory) :
new(new()
{
Command = "sh",
Arguments = ["-c", "echo test >&2; exit 1"],
StandardErrorLines = _ =>
{
capturedValue = asyncLocal.Value;
received.TrySetResult(true);
}
}, LoggerFactory);

await Assert.ThrowsAnyAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

// Wait for the stderr callback to fire.
await received.Task.WaitAsync(TestContext.Current.CancellationToken);

// The callback should NOT see the caller's AsyncLocal value because
// ExecutionContext flow is suppressed for the stderr reader thread.
Assert.Null(capturedValue);
}

[Fact(Skip= "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
public async Task CreateAsync_StdErrCallbackThrows_DoesNotCrashProcess()
{
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
Expand Down
Loading