Skip to content

Commit 4bed2a8

Browse files
authored
Merge branch 'main' into copilot/fix-flaky-diagnostic-test
2 parents bb39e15 + 6a02c5a commit 4bed2a8

File tree

19 files changed

+358
-168
lines changed

19 files changed

+358
-168
lines changed

.github/copilot-instructions.md

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,39 @@ The SDK consists of three main packages:
8585
- Test servers in `tests/ModelContextProtocol.Test*Server/` for integration scenarios
8686
- Filter manual tests with `[Trait("Execution", "Manual")]` - these require external dependencies
8787

88-
### Test Infrastructure and Helpers
89-
- **LoggedTest**: Base class for tests that need logging output captured to xUnit test output
90-
- Provides `ILoggerFactory` and `ITestOutputHelper` for test logging
91-
- Use when debugging or when tests need to verify log output
92-
- **TestServerTransport**: In-memory transport for testing client-server interactions without network I/O
93-
- **MockLoggerProvider**: For capturing and asserting on log messages
94-
- **XunitLoggerProvider**: Routes `ILogger` output to xUnit's `ITestOutputHelper`
95-
- **KestrelInMemoryTransport** (AspNetCore.Tests): In-memory Kestrel connection for HTTP transport testing without network stack
96-
97-
### Test Best Practices
98-
- Inherit from `LoggedTest` for tests needing logging infrastructure
99-
- Use `TestServerTransport` for in-memory client-server testing
100-
- Mock external dependencies (filesystem, HTTP clients) rather than calling real services
101-
- Use `CancellationTokenSource` with timeouts to prevent hanging tests
102-
- Dispose resources properly (servers, clients, transports) using `IDisposable` or `await using`
103-
- Run tests with: `dotnet test --filter '(Execution!=Manual)'`
88+
### Test Base Classes
89+
- **`LoggedTest`**: Base class that wires up `ILoggerFactory` with `XunitLoggerProvider` (test output) and `MockLoggerProvider` (log assertions). Inherit from this for any test needing logging.
90+
- **`ClientServerTestBase`**: Sets up in-memory client/server pair via `Pipe`. Override `ConfigureServices` to register tools/prompts/resources, then call `CreateMcpClientForServer()`. Handles async disposal automatically.
91+
- **`KestrelInMemoryTest`** (AspNetCore tests): Hosts ASP.NET Core with in-memory transport — no ports needed.
92+
- **`TestServerTransport`**: In-memory mock transport for testing client logic without a real server.
93+
94+
### Transport Selection in Tests
95+
- **Never use `WithStdioServerTransport()` in unit tests.** It reads from the test host's stdin, which cannot be closed, permanently leaking a thread pool thread per test.
96+
- For DI-only tests: `WithStreamServerTransport(Stream.Null, Stream.Null)`
97+
- For client/server interaction: inherit `ClientServerTestBase`
98+
- For client-only logic: use `TestServerTransport`
99+
- For HTTP/SSE: inherit `KestrelInMemoryTest`
100+
- For process lifecycle tests: `StdioClientTransport` (only when testing actual process behavior)
101+
102+
### Resource Management
103+
- **Always `await using` the `ServiceProvider`** when MCP server services are registered — `McpServerImpl` only implements `IAsyncDisposable`. Synchronous `using` throws at runtime.
104+
- **Always dispose clients and servers** — use `await using var client = ...`
105+
- **Use `TestContext.Current.CancellationToken`** for async MCP calls so xUnit can cancel on timeout.
106+
107+
### Timeouts
108+
- **Always use `TestConstants.DefaultTimeout`** (60s) instead of hardcoded values. CI machines are slower than dev workstations.
109+
- For HTTP polling operations use `TestConstants.HttpClientPollingTimeout` (2s).
110+
111+
### Synchronization
112+
- **Never use `Task.Delay` for synchronization.** Use `TaskCompletionSource`, `SemaphoreSlim`, or `Channel` so tests don't depend on timing.
113+
114+
### Background Logging
115+
- `ITestOutputHelper.WriteLine` throws after the test method returns. Background threads (process event handlers, async continuations) can outlive the test, causing unhandled exceptions that crash the test host.
116+
- Route logging through `LoggedTest.LoggerFactory``XunitLoggerProvider` already catches post-test exceptions.
117+
- If calling `ITestOutputHelper` directly from an event handler, wrap in try/catch for `InvalidOperationException`.
118+
119+
### Parallelism
120+
- Tests run in parallel by default. Apply `[Collection(nameof(DisableParallelization))]` to test classes that touch global state (e.g., `ActivitySource` listeners).
104121

105122
## Build and Development
106123

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
4848
steps:
4949
- name: Checkout repository
50-
uses: actions/checkout@v4
50+
uses: actions/checkout@v6
5151

5252
# Add any setup steps before running the `github/codeql-action/init` action.
5353
# This includes steps like installing compilers or runtimes (`actions/setup-node`

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ on:
3232

3333
jobs:
3434
build-all-configs:
35+
# Don't run scheduled/release triggers on forks; allow manual workflow_dispatch
36+
if: ${{ github.repository == 'modelcontextprotocol/csharp-sdk' || github.event_name == 'workflow_dispatch' }}
3537
strategy:
3638
matrix:
3739
os: [ubuntu-latest, windows-latest, macos-latest]

CONTRIBUTING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,70 @@ dotnet test tests/ModelContextProtocol.Tests/
6565

6666
Tools like Visual Studio, JetBrains Rider, and VS Code also provide integrated test runners that can be used to run and debug individual tests.
6767

68+
### Writing Tests
69+
70+
The test projects include shared infrastructure in `tests/Common/Utils/` that most tests build on. Familiarize yourself with these helpers before writing new tests.
71+
72+
#### Test base classes
73+
74+
- **`LoggedTest`** — Base class that wires up `ILoggerFactory` with both `XunitLoggerProvider` (routes to test output) and `MockLoggerProvider` (captures logs for assertions). Inherit from this for any test that needs logging.
75+
- **`ClientServerTestBase`** — Sets up an in-memory client/server pair connected via `Pipe` with proper async disposal. Override `ConfigureServices` to register tools, prompts, and resources, then call `CreateMcpClientForServer()` to get a connected client.
76+
- **`KestrelInMemoryTest`** (ASP.NET Core tests) — Hosts an ASP.NET Core server with in-memory transport so HTTP/SSE tests run without allocating ports.
77+
78+
#### Choosing a transport
79+
80+
| Scenario | Transport | Why |
81+
|---|---|---|
82+
| Unit tests that only need DI | `WithStreamServerTransport(Stream.Null, Stream.Null)` | No threads blocked, no process spawned |
83+
| Client/server interaction tests | `ClientServerTestBase` (uses `Pipe`) | Full bidirectional MCP, in-process |
84+
| Client-only logic | `TestServerTransport` | In-memory mock that auto-responds to standard MCP requests |
85+
| HTTP/SSE integration | `KestrelInMemoryTest` | Real HTTP stack, no network |
86+
| External process tests | `StdioClientTransport` | Only when testing actual process lifecycle |
87+
88+
> **Do not** use `WithStdioServerTransport()` in unit tests. The stdio server transport reads from the test host process's standard input, which the test does not own and cannot close. This means the transport's background read loop can never terminate, permanently leaking a thread pool thread per test. Use `WithStreamServerTransport(Stream.Null, Stream.Null)` for tests that only need the DI container.
89+
90+
#### Resource management
91+
92+
- **Always `await using` the `ServiceProvider`** when MCP server services are registered — `McpServerImpl` only implements `IAsyncDisposable`, not `IDisposable`. A synchronous `using` will throw at runtime, and skipping disposal leaks transports and background threads.
93+
- **Use `TestContext.Current.CancellationToken`** when calling async MCP methods so that xUnit can cancel the test on timeout rather than hanging.
94+
- **Dispose clients and servers** explicitly. Prefer `await using var client = ...` over relying on finalizers. `ClientServerTestBase` handles this if you inherit from it.
95+
96+
#### Timeouts
97+
98+
Use `TestConstants.DefaultTimeout` (60 seconds) rather than hardcoded values. CI machines are often slower than developer workstations, and short timeouts cause flaky failures.
99+
100+
```csharp
101+
// Good
102+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
103+
cts.CancelAfter(TestConstants.DefaultTimeout);
104+
await client.CallToolAsync("my-tool", args, cts.Token);
105+
106+
// Bad — too short for CI
107+
cts.CancelAfter(TimeSpan.FromSeconds(5));
108+
```
109+
110+
#### Synchronization
111+
112+
Avoid `Task.Delay` for synchronization. Use explicit signaling primitives (`TaskCompletionSource`, `SemaphoreSlim`, `Channel`) so tests don't depend on timing. If a producer/consumer test writes events for a streaming reader, use a `TaskCompletionSource` to confirm the reader is active before writing.
113+
114+
#### Background logging
115+
116+
`ITestOutputHelper.WriteLine` throws after the test method returns. Background threads (process event handlers, async continuations) can outlive the test. This manifests as unhandled exceptions that crash the test host. Two mitigations:
117+
118+
1. **`XunitLoggerProvider`** already catches these exceptions. Route logging through `LoggedTest.LoggerFactory` rather than calling `ITestOutputHelper` directly from callbacks.
119+
2. **If you must call `ITestOutputHelper` from an event handler**, wrap it in a try/catch:
120+
```csharp
121+
process.ErrorDataReceived += (s, e) =>
122+
{
123+
try { testOutputHelper.WriteLine(e.Data); }
124+
catch (InvalidOperationException) { }
125+
};
126+
```
127+
128+
#### Parallelism
129+
130+
Tests run in parallel by default. If a test class touches global state (e.g., `ActivitySource` listeners in diagnostics tests), apply `[Collection(nameof(DisableParallelization))]` to run it sequentially.
131+
68132
### Building the Documentation
69133

70134
This project uses [DocFX](https://dotnet.github.io/docfx/) to generate its conceptual and reference documentation.

docs/concepts/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Install the SDK and build your first MCP client and server.
1717
| [Ping](ping/ping.md) | Learn how to verify connection health using the ping mechanism. |
1818
| [Progress tracking](progress/progress.md) | Learn how to track progress for long-running operations through notification messages. |
1919
| [Cancellation](cancellation/cancellation.md) | Learn how to cancel in-flight MCP requests using cancellation tokens and notifications. |
20-
| [Pagination](pagination/pagination.md) | Learn how to use cursor-based pagination when listing tools, prompts, and resources. |
20+
| [Tasks](tasks/tasks.md) | Learn how to use task-based execution for long-running operations that can be polled for status and results. |
2121

2222
### Client Features
2323

@@ -36,6 +36,7 @@ Install the SDK and build your first MCP client and server.
3636
| [Prompts](prompts/prompts.md) | Learn how to implement and consume reusable prompt templates with rich content types. |
3737
| [Completions](completions/completions.md) | Learn how to implement argument auto-completion for prompts and resource templates. |
3838
| [Logging](logging/logging.md) | Learn how to implement logging in MCP servers and how clients can consume log messages. |
39+
| [Pagination](pagination/pagination.md) | Learn how to use cursor-based pagination when listing tools, prompts, and resources. |
3940
| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. |
4041
| [HTTP Context](httpcontext/httpcontext.md) | Learn how to access the underlying `HttpContext` for a request. |
4142
| [MCP Server Handler Filters](filters.md) | Learn how to add filters to the handler pipeline. Filters let you wrap the original handler with additional functionality. |

docs/concepts/toc.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ items:
1717
uid: progress
1818
- name: Cancellation
1919
uid: cancellation
20-
- name: Pagination
21-
uid: pagination
2220
- name: Tasks
2321
uid: tasks
2422
- name: Client Features
@@ -41,6 +39,8 @@ items:
4139
uid: completions
4240
- name: Logging
4341
uid: logging
42+
- name: Pagination
43+
uid: pagination
4444
- name: HTTP Context
4545
uid: httpcontext
4646
- name: Filters

src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
1010
private readonly StdioClientTransportOptions _options;
1111
private readonly Process _process;
1212
private readonly Queue<string> _stderrRollingLog;
13+
private readonly DataReceivedEventHandler _errorHandler;
1314
private int _cleanedUp = 0;
1415
private readonly int? _processId;
1516

16-
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
17+
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
1718
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
1819
{
1920
_options = options;
2021
_process = process;
2122
_stderrRollingLog = stderrRollingLog;
23+
_errorHandler = errorHandler;
2224
try { _processId = process.Id; } catch { }
2325
}
2426

@@ -33,7 +35,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
3335
{
3436
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
3537
// for the I/O error, we should throw an exception for that instead.
36-
if (await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false) is Exception processExitException)
38+
if (await GetUnexpectedExitExceptionAsync().ConfigureAwait(false) is Exception processExitException)
3739
{
3840
throw processExitException;
3941
}
@@ -45,15 +47,32 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
4547
/// <inheritdoc/>
4648
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
4749
{
48-
// Only clean up once.
50+
// Only run the full stdio cleanup once (handler detach, process kill, etc.).
51+
// If another call is already handling cleanup, cancel the shutdown token
52+
// to unblock it (e.g. if it's stuck in WaitForExitAsync) and let it
53+
// call SetDisconnected with full StdioClientCompletionDetails.
4954
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
5055
{
56+
CancelShutdown();
5157
return;
5258
}
5359

5460
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
5561
// so create an exception with details about that.
56-
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
62+
error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false);
63+
64+
// Ensure all pending ErrorDataReceived events are drained before detaching
65+
// the handler. GetUnexpectedExitExceptionAsync does this when HasExited is
66+
// true, but there is a narrow window on Linux where the process has closed
67+
// stdout (causing EOF in ReadMessagesAsync) yet hasn't been fully reaped,
68+
// so HasExited returns false and the drain is skipped. An unconditional
69+
// wait here covers that gap. When the drain already happened above, the
70+
// call returns immediately.
71+
await WaitForProcessExitAsync().ConfigureAwait(false);
72+
73+
// Detach the stderr handler so no further ErrorDataReceived events
74+
// are dispatched during or after process disposal.
75+
_process.ErrorDataReceived -= _errorHandler;
5776

5877
// Terminate the server process (or confirm it already exited), then build
5978
// and publish strongly-typed completion details while the process handle
@@ -78,26 +97,16 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
7897
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
7998
}
8099

81-
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)
100+
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync()
82101
{
83102
if (!StdioClientTransport.HasExited(_process))
84103
{
85104
return null;
86105
}
87106

88107
Debug.Assert(StdioClientTransport.HasExited(_process));
89-
try
90-
{
91-
// The process has exited, but we still need to ensure stderr has been flushed.
92-
// WaitForExitAsync only waits for exit; it does not guarantee that all
93-
// ErrorDataReceived events have been dispatched. The synchronous WaitForExit()
94-
// (no arguments) does ensure that, so call it after WaitForExitAsync completes.
95-
#if NET
96-
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
97-
#endif
98-
_process.WaitForExit();
99-
}
100-
catch { }
108+
109+
await WaitForProcessExitAsync().ConfigureAwait(false);
101110

102111
string errorMessage = "MCP server process exited unexpectedly";
103112

@@ -122,6 +131,35 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
122131
return new IOException(errorMessage);
123132
}
124133

134+
/// <summary>
135+
/// Waits for the process to exit within <see cref="StdioClientTransportOptions.ShutdownTimeout"/>
136+
/// and flushes pending <see cref="Process.ErrorDataReceived"/> events.
137+
/// </summary>
138+
/// <remarks>
139+
/// On .NET, <c>Process.WaitForExitAsync</c> also waits for asynchronous output readers
140+
/// to complete, ensuring all events have been dispatched. On .NET Framework,
141+
/// <see cref="Process.WaitForExit(int)"/> does not guarantee this; the parameterless
142+
/// <see cref="Process.WaitForExit()"/> overload is needed to flush the event queue.
143+
/// This method is idempotent—calling it after the process has already been waited on
144+
/// returns immediately.
145+
/// </remarks>
146+
private async ValueTask WaitForProcessExitAsync()
147+
{
148+
try
149+
{
150+
#if NET
151+
using var timeoutCts = new CancellationTokenSource(_options.ShutdownTimeout);
152+
await _process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
153+
#else
154+
if (_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
155+
{
156+
_process.WaitForExit();
157+
}
158+
#endif
159+
}
160+
catch { }
161+
}
162+
125163
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
126164
{
127165
StdioClientCompletionDetails details = new()

0 commit comments

Comments
 (0)