Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ The test projects include shared infrastructure in `tests/Common/Utils/` that mo

Use `TestConstants.DefaultTimeout` (60 seconds) rather than hardcoded values. CI machines are often slower than developer workstations, and short timeouts cause flaky failures.

For direct client connections to controlled test servers that respond to discovery, use `TestMcpClient.CreateAsync`.
It disables the separate discovery probe timeout while retaining the overall `TestConstants.DefaultTimeout` initialization deadline.
If configuring client options, start with `TestMcpClient.CreateOptions()` before applying test-specific settings; explicitly supplied options are otherwise preserved unchanged.
Do not use these defaults in discovery-timeout tests or with servers that silently ignore discovery, since those rely on the probe timeout for fallback.
For a mock that only implements the legacy initialize handshake, explicitly select its supported protocol version instead of waiting for discovery to time out.

```csharp
// Good
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
Expand Down
24 changes: 24 additions & 0 deletions tests/Common/Utils/TestMcpClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;

namespace ModelContextProtocol.Tests.Utils;

/// <summary>
/// Creates clients for controlled test servers that respond to discovery, including with a fallback error.
/// Do not use these defaults with servers that silently ignore discovery or in discovery-timeout tests.
/// </summary>
internal static class TestMcpClient
{
public static McpClientOptions CreateOptions() => new()
{
DiscoverProbeTimeout = Timeout.InfiniteTimeSpan,
InitializationTimeout = TestConstants.DefaultTimeout,
};

public static Task<McpClient> CreateAsync(
IClientTransport clientTransport,
McpClientOptions? clientOptions = null,
ILoggerFactory? loggerFactory = null,
CancellationToken cancellationToken = default) =>
McpClient.CreateAsync(clientTransport, clientOptions ?? CreateOptions(), loggerFactory, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Common\Utils\TestConstants.cs" Link="TestConstants.cs" />
<Compile Include="..\Common\Utils\TestMcpClient.cs" Link="TestMcpClient.cs" />

<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using ModelContextProtocol.Extensions.Apps;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;

Pipe clientToServerPipe = new(), serverToClientPipe = new();
Expand All @@ -18,7 +19,7 @@
_ = server.RunAsync();

// Connect a client using a stream-based transport over the same in-memory pipe.
await using McpClient client = await McpClient.CreateAsync(
await using McpClient client = await TestMcpClient.CreateAsync(
new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream()));

// List all tools.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,8 @@ private async Task<McpClient> ConnectAsync()
Endpoint = new("http://localhost:5000"),
}, HttpClient, LoggerFactory);

// This server supports discovery; slow startup must not trigger legacy fallback.
return await McpClient.CreateAsync(transport, new()
{
DiscoverProbeTimeout = Timeout.InfiniteTimeSpan,
InitializationTimeout = TestConstants.DefaultTimeout,
}, cancellationToken: TestContext.Current.CancellationToken, loggerFactory: LoggerFactory);
return await TestMcpClient.CreateAsync(transport,
cancellationToken: TestContext.Current.CancellationToken, loggerFactory: LoggerFactory);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using Moq;
using System.Security.Claims;

Expand All @@ -30,7 +31,7 @@ public async Task WithTasks_CanCallToolOverHttp()
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Expand Down Expand Up @@ -67,7 +68,7 @@ public async Task WithTasks_AfterOrdinaryFilter_RunsFilter()
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Expand Down Expand Up @@ -120,7 +121,7 @@ [new Claim(ClaimTypes.NameIdentifier, "test-user")],
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Expand Down Expand Up @@ -166,7 +167,7 @@ public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTask
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Expand Down Expand Up @@ -217,7 +218,7 @@ public async Task WithTasks_ReauthorizesToolChangedByOrdinaryFilter()
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ await StartServerAsync(async context =>
}, HttpClient, LoggerFactory);

// Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server.
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
await using var client = await TestMcpClient.CreateAsync(transport,
loggerFactory: LoggerFactory, cancellationToken: ct);

Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion);
Expand Down Expand Up @@ -308,7 +308,7 @@ await StartServerAsync(async context =>
}, HttpClient, LoggerFactory);

// Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server.
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
await using var client = await TestMcpClient.CreateAsync(transport,
loggerFactory: LoggerFactory, cancellationToken: ct);

Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
Expand Down Expand Up @@ -354,10 +354,10 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest,

var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
{
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
}, loggerFactory: LoggerFactory, cancellationToken: ct);
var clientOptions = TestMcpClient.CreateOptions();
clientOptions.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion;
await using var client = await McpClient.CreateAsync(transport, clientOptions,
loggerFactory: LoggerFactory, cancellationToken: ct);
});

Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode);
Expand Down Expand Up @@ -408,9 +408,8 @@ await WriteJsonRpcErrorAsync(

var exception = await Assert.ThrowsAsync<HttpRequestException>(async () =>
{
await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
new McpClientOptions(),
loggerFactory: LoggerFactory,
cancellationToken: ct);
});
Expand Down Expand Up @@ -481,10 +480,10 @@ await StartServerAsync(async context =>
Endpoint = new("http://localhost:5000/mcp"),
}, HttpClient, LoggerFactory);

await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
}, loggerFactory: LoggerFactory, cancellationToken: ct);
var clientOptions = TestMcpClient.CreateOptions();
clientOptions.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion;
await using var client = await McpClient.CreateAsync(transport, clientOptions,
loggerFactory: LoggerFactory, cancellationToken: ct);

await client.ListToolsAsync(cancellationToken: ct);

Expand Down Expand Up @@ -541,10 +540,10 @@ await StartServerAsync(async context =>
OwnsSession = false,
}, HttpClient, LoggerFactory);

await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
}, loggerFactory: LoggerFactory, cancellationToken: ct);
var clientOptions = TestMcpClient.CreateOptions();
clientOptions.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion;
await using var client = await McpClient.CreateAsync(transport, clientOptions,
loggerFactory: LoggerFactory, cancellationToken: ct);

Assert.Equal("", discoverSessionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
Expand Down Expand Up @@ -109,7 +110,8 @@ private Task<McpClient> ConnectClientAsync(string? protocolVersion = null, Actio

// A null ProtocolVersion prefers 2026-07-28 and probes with server/discover before considering a
// fallback to the initialize handshake. Pinning an older version forces the initialize handshake.
var clientOptions = new McpClientOptions { ProtocolVersion = protocolVersion };
var clientOptions = TestMcpClient.CreateOptions();
clientOptions.ProtocolVersion = protocolVersion;
configureClient?.Invoke(clientOptions);
return McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ private async Task<McpClient> ConnectDefaultClientAsync(Action<McpClientOptions>

// Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes
// with server/discover before falling back to an initialize handshake.
var clientOptions = new McpClientOptions();
var clientOptions = TestMcpClient.CreateOptions();
configureClient?.Invoke(clientOptions);
return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
}
Expand Down
79 changes: 78 additions & 1 deletion tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
Expand Down Expand Up @@ -37,11 +38,87 @@ protected async Task<McpClient> ConnectAsync(
TransportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse,
}, HttpClient, LoggerFactory);

var clientOptions = new McpClientOptions();
var clientOptions = TestMcpClient.CreateOptions();
configureClient?.Invoke(clientOptions);
return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
}

[Fact]
public async Task ConnectAsync_SlowDiscovery_PreservesModernProtocol()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);

var discovery = new AsyncGate();
var timeProvider = new FakeTimeProvider();
await using var app = await StartServerWithGatedDiscoveryAsync(discovery);

var connecting = ConnectAsync(configureClient: options =>
{
options.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion;
options.TimeProvider = timeProvider;
});
await discovery.WaitUntilEnteredAsync(connecting);
timeProvider.Advance(new McpClientOptions().DiscoverProbeTimeout * 2);
discovery.Release.SetResult();

await using var client = await connecting.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ConnectAsync_Discovery_RespectsConfiguredDeadline(bool customProbeTimeout)
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);

var discovery = new AsyncGate();
var timeProvider = new FakeTimeProvider();
var probeTimeout = new McpClientOptions().DiscoverProbeTimeout;
await using var app = await StartServerWithGatedDiscoveryAsync(discovery);

var connecting = ConnectAsync(configureClient: options =>
{
options.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion;
options.TimeProvider = timeProvider;
if (customProbeTimeout)
{
options.DiscoverProbeTimeout = probeTimeout;
}
});
await discovery.WaitUntilEnteredAsync(connecting);
timeProvider.Advance(customProbeTimeout ? probeTimeout : TestConstants.DefaultTimeout);

if (customProbeTimeout)
{
await Assert.ThrowsAsync<McpException>(() => connecting.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
}
else
{
var exception = await Assert.ThrowsAsync<TimeoutException>(() => connecting.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
Assert.Equal("Initialization timed out", exception.Message);
}
}

private async Task<WebApplication> StartServerWithGatedDiscoveryAsync(AsyncGate discovery)
{
Builder.Services.AddMcpServer()
.WithHttpTransport(ConfigureStateless)
.WithMessageFilters(filters => filters.AddIncomingFilter(next => async (context, cancellationToken) =>
{
if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ServerDiscover })
{
await discovery.WaitAsync(cancellationToken);
}
await next(context, cancellationToken);
}));

var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}

[Fact]
public async Task MapMcp_ThrowsInvalidOperationException_IfWithHttpTransportIsNotCalled()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Net.Http.Json;

Expand Down Expand Up @@ -55,7 +56,7 @@ public async Task CanAuthenticate_WithResourceMetadataFromEvent()
LoggerFactory
);

await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken
Expand Down Expand Up @@ -94,7 +95,7 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
LoggerFactory
);

await using var client = await McpClient.CreateAsync(
await using var client = await TestMcpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken
Expand Down
Loading
Loading