Skip to content

Commit 8807e95

Browse files
ericstjCopilot
andcommitted
Suppress ExecutionContext flow in BeginErrorReadLine
Process.BeginErrorReadLine() captures the caller's ExecutionContext, causing AsyncLocal values to leak to the background stderr reader thread. 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. Wrap BeginErrorReadLine() in ExecutionContext.SuppressFlow() so the stderr reader gets a clean context. Add a test that sets an AsyncLocal before creating the transport and verifies the stderr callback does NOT see the value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d4c588b commit 8807e95

2 files changed

Lines changed: 49 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Client/StdioClientTransport.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,13 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
191191

192192
LogTransportProcessStarted(logger, endpointName, process.Id);
193193

194-
process.BeginErrorReadLine();
194+
// Suppress ExecutionContext flow so the Process's internal async
195+
// stderr reader thread doesn't capture the caller's ambient context
196+
// (e.g. AsyncLocal values from test infrastructure or HTTP request state).
197+
using (ExecutionContext.SuppressFlow())
198+
{
199+
process.BeginErrorReadLine();
200+
}
195201

196202
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
197203
}

tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Runtime.InteropServices;
66
using System.Text;
77
using System.Text.Json;
8+
using System.Threading;
89

910
namespace ModelContextProtocol.Tests.Transport;
1011

@@ -59,6 +60,47 @@ public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked()
5960
Assert.Contains(id, sb.ToString());
6061
}
6162

63+
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
64+
public async Task CreateAsync_StdErrCallback_DoesNotCaptureCallerAsyncLocal()
65+
{
66+
var asyncLocal = new AsyncLocal<string>();
67+
asyncLocal.Value = "caller-context";
68+
69+
string? capturedValue = "not-set";
70+
var received = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
71+
72+
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
73+
new(new()
74+
{
75+
Command = "cmd",
76+
Arguments = ["/c", "echo test >&2 & exit /b 1"],
77+
StandardErrorLines = _ =>
78+
{
79+
capturedValue = asyncLocal.Value;
80+
received.TrySetResult(true);
81+
}
82+
}, LoggerFactory) :
83+
new(new()
84+
{
85+
Command = "sh",
86+
Arguments = ["-c", "echo test >&2; exit 1"],
87+
StandardErrorLines = _ =>
88+
{
89+
capturedValue = asyncLocal.Value;
90+
received.TrySetResult(true);
91+
}
92+
}, LoggerFactory);
93+
94+
await Assert.ThrowsAnyAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
95+
96+
// Wait for the stderr callback to fire.
97+
await received.Task.WaitAsync(TestContext.Current.CancellationToken);
98+
99+
// The callback should NOT see the caller's AsyncLocal value because
100+
// ExecutionContext flow is suppressed for the stderr reader thread.
101+
Assert.Null(capturedValue);
102+
}
103+
62104
[Theory]
63105
[InlineData(null)]
64106
[InlineData("argument with spaces")]

0 commit comments

Comments
 (0)