Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)

This commit is contained in:
Den Delimarsky 🌺 2025-07-03 14:39:28 -07:00 • committed by GitHub
commit cdf88e7f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 4207 additions and 126 deletions

View file

@ -0,0 +1,407 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using System.Net;
using System.Reflection;
using Xunit.Sdk;
namespace ModelContextProtocol.AspNetCore.Tests;
public class AuthTests : KestrelInMemoryTest, IAsyncDisposable
{
private const string McpServerUrl = "http://localhost:5000";
private const string OAuthServerUrl = "https://localhost:7029";
private readonly CancellationTokenSource _testCts = new();
private readonly TestOAuthServer.Program _testOAuthServer;
private readonly Task _testOAuthRunTask;
private Uri? _lastAuthorizationUri;
public AuthTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
// Let the HandleAuthorizationUrlAsync take a look at the Location header
SocketsHttpHandler.AllowAutoRedirect = false;
// The dev cert may not be installed on the CI, but AddJwtBearer requires an HTTPS backchannel by default.
// The easiest workaround is to disable cert validation for testing purposes.
SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
_testOAuthServer = new TestOAuthServer.Program(XunitLoggerProvider, KestrelInMemoryTransport);
_testOAuthRunTask = _testOAuthServer.RunServerAsync(cancellationToken: _testCts.Token);
Builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Backchannel = HttpClient;
options.Authority = OAuthServerUrl;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = McpServerUrl,
ValidIssuer = OAuthServerUrl,
NameClaimType = "name",
RoleClaimType = "roles"
};
})
.AddMcp(options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(McpServerUrl),
AuthorizationServers = { new Uri(OAuthServerUrl) },
ScopesSupported = ["mcp:tools"]
};
});
Builder.Services.AddAuthorization();
}
public async ValueTask DisposeAsync()
{
_testCts.Cancel();
try
{
await _testOAuthRunTask;
}
catch (OperationCanceledException)
{
}
finally
{
_testCts.Dispose();
}
}
[Fact]
public async Task CanAuthenticate()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
}, HttpClient, LoggerFactory);
var httpEx = await Assert.ThrowsAsync<HttpRequestException>(async () => await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(HttpStatusCode.Unauthorized, httpEx.StatusCode);
}
[Fact]
public async Task CannotAuthenticate_WithUnregisteredClient()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "unregistered-demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
// The EqualException is thrown by HandleAuthorizationUrlAsync when the /authorize request gets a 400
var equalEx = await Assert.ThrowsAsync<EqualException>(async () => await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task CanAuthenticate_WithDynamicClientRegistration()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
Scopes = ["mcp:tools"]
},
}, HttpClient, LoggerFactory);
await using var client = await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task CanAuthenticate_WithTokenRefresh()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "test-refresh-client",
ClientSecret = "test-refresh-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
// The test-refresh-client should get an expired token first,
// then automatically refresh it to get a working token
await using var client = await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(_testOAuthServer.HasIssuedRefreshToken);
}
[Fact]
public async Task CanAuthenticate_WithExtraParams()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
["custom_param"] = "custom_value",
}
},
}, HttpClient, LoggerFactory);
await using var client = await McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(_lastAuthorizationUri?.Query);
Assert.Contains("custom_param=custom_value", _lastAuthorizationUri?.Query);
}
[Fact]
public async Task CannotOverrideExistingParameters_WithExtraParams()
{
Builder.Services.AddMcpServer().WithHttpTransport();
await using var app = Builder.Build();
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new SseClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
["redirect_uri"] = "custom_value",
}
},
}, HttpClient, LoggerFactory);
await Assert.ThrowsAsync<ArgumentException>(() => McpClientFactory.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public void CloneResourceMetadataClonesAllProperties()
{
var propertyNames = typeof(ProtectedResourceMetadata).GetProperties().Select(property => property.Name).ToList();
// Set metadata properties to non-default values to verify they're copied.
var metadata = new ProtectedResourceMetadata
{
Resource = new Uri("https://example.com/resource"),
AuthorizationServers = [new Uri("https://auth1.example.com"), new Uri("https://auth2.example.com")],
BearerMethodsSupported = ["header", "body", "query"],
ScopesSupported = ["read", "write", "admin"],
JwksUri = new Uri("https://example.com/.well-known/jwks.json"),
ResourceSigningAlgValuesSupported = ["RS256", "ES256"],
ResourceName = "Test Resource",
ResourceDocumentation = new Uri("https://docs.example.com"),
ResourcePolicyUri = new Uri("https://example.com/policy"),
ResourceTosUri = new Uri("https://example.com/terms"),
TlsClientCertificateBoundAccessTokens = true,
AuthorizationDetailsTypesSupported = ["payment_initiation", "account_information"],
DpopSigningAlgValuesSupported = ["RS256", "PS256"],
DpopBoundAccessTokensRequired = true
};
// Use reflection to call the internal CloneResourceMetadata method
var handlerType = typeof(McpAuthenticationHandler);
var cloneMethod = handlerType.GetMethod("CloneResourceMetadata", BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(cloneMethod);
var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata]);
Assert.NotNull(clonedMetadata);
// Ensure the cloned metadata is not the same instance
Assert.NotSame(metadata, clonedMetadata);
// Verify Resource property
Assert.Equal(metadata.Resource, clonedMetadata.Resource);
Assert.True(propertyNames.Remove(nameof(metadata.Resource)));
// Verify AuthorizationServers list is cloned and contains the same values
Assert.NotSame(metadata.AuthorizationServers, clonedMetadata.AuthorizationServers);
Assert.Equal(metadata.AuthorizationServers, clonedMetadata.AuthorizationServers);
Assert.True(propertyNames.Remove(nameof(metadata.AuthorizationServers)));
// Verify BearerMethodsSupported list is cloned and contains the same values
Assert.NotSame(metadata.BearerMethodsSupported, clonedMetadata.BearerMethodsSupported);
Assert.Equal(metadata.BearerMethodsSupported, clonedMetadata.BearerMethodsSupported);
Assert.True(propertyNames.Remove(nameof(metadata.BearerMethodsSupported)));
// Verify ScopesSupported list is cloned and contains the same values
Assert.NotSame(metadata.ScopesSupported, clonedMetadata.ScopesSupported);
Assert.Equal(metadata.ScopesSupported, clonedMetadata.ScopesSupported);
Assert.True(propertyNames.Remove(nameof(metadata.ScopesSupported)));
// Verify JwksUri property
Assert.Equal(metadata.JwksUri, clonedMetadata.JwksUri);
Assert.True(propertyNames.Remove(nameof(metadata.JwksUri)));
// Verify ResourceSigningAlgValuesSupported list is cloned (nullable list)
Assert.NotSame(metadata.ResourceSigningAlgValuesSupported, clonedMetadata.ResourceSigningAlgValuesSupported);
Assert.Equal(metadata.ResourceSigningAlgValuesSupported, clonedMetadata.ResourceSigningAlgValuesSupported);
Assert.True(propertyNames.Remove(nameof(metadata.ResourceSigningAlgValuesSupported)));
// Verify ResourceName property
Assert.Equal(metadata.ResourceName, clonedMetadata.ResourceName);
Assert.True(propertyNames.Remove(nameof(metadata.ResourceName)));
// Verify ResourceDocumentation property
Assert.Equal(metadata.ResourceDocumentation, clonedMetadata.ResourceDocumentation);
Assert.True(propertyNames.Remove(nameof(metadata.ResourceDocumentation)));
// Verify ResourcePolicyUri property
Assert.Equal(metadata.ResourcePolicyUri, clonedMetadata.ResourcePolicyUri);
Assert.True(propertyNames.Remove(nameof(metadata.ResourcePolicyUri)));
// Verify ResourceTosUri property
Assert.Equal(metadata.ResourceTosUri, clonedMetadata.ResourceTosUri);
Assert.True(propertyNames.Remove(nameof(metadata.ResourceTosUri)));
// Verify TlsClientCertificateBoundAccessTokens property
Assert.Equal(metadata.TlsClientCertificateBoundAccessTokens, clonedMetadata.TlsClientCertificateBoundAccessTokens);
Assert.True(propertyNames.Remove(nameof(metadata.TlsClientCertificateBoundAccessTokens)));
// Verify AuthorizationDetailsTypesSupported list is cloned (nullable list)
Assert.NotSame(metadata.AuthorizationDetailsTypesSupported, clonedMetadata.AuthorizationDetailsTypesSupported);
Assert.Equal(metadata.AuthorizationDetailsTypesSupported, clonedMetadata.AuthorizationDetailsTypesSupported);
Assert.True(propertyNames.Remove(nameof(metadata.AuthorizationDetailsTypesSupported)));
// Verify DpopSigningAlgValuesSupported list is cloned (nullable list)
Assert.NotSame(metadata.DpopSigningAlgValuesSupported, clonedMetadata.DpopSigningAlgValuesSupported);
Assert.Equal(metadata.DpopSigningAlgValuesSupported, clonedMetadata.DpopSigningAlgValuesSupported);
Assert.True(propertyNames.Remove(nameof(metadata.DpopSigningAlgValuesSupported)));
// Verify DpopBoundAccessTokensRequired property
Assert.Equal(metadata.DpopBoundAccessTokensRequired, clonedMetadata.DpopBoundAccessTokensRequired);
Assert.True(propertyNames.Remove(nameof(metadata.DpopBoundAccessTokensRequired)));
// Ensure we've checked every property. When new properties get added, we'll have to update this test along with the CloneResourceMetadata implementation.
Assert.Empty(propertyNames);
}
private async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken)
{
_lastAuthorizationUri = authorizationUri;
var redirectResponse = await HttpClient.GetAsync(authorizationUri, cancellationToken);
Assert.Equal(HttpStatusCode.Redirect, redirectResponse.StatusCode);
var location = redirectResponse.Headers.Location;
if (location is not null && !string.IsNullOrEmpty(location.Query))
{
var queryParams = QueryHelpers.ParseQuery(location.Query);
return queryParams["code"];
}
return null;
}
}

View file

@ -118,7 +118,7 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
Assert.Null(result1.IsError);
Assert.Null(result2.IsError);
Assert.Null(result3.IsError);
var textContent1 = Assert.Single(result1.Content.OfType<TextContentBlock>());
var textContent2 = Assert.Single(result2.Content.OfType<TextContentBlock>());
var textContent3 = Assert.Single(result3.Content.OfType<TextContentBlock>());
@ -267,10 +267,10 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
},
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
},
cancellationToken: TestContext.Current.CancellationToken);
// assert
@ -288,7 +288,7 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
for (int i = 0; i < 4; i++)
{
var client = (i % 2 == 0) ? client1 : client2;
var result = await client.CallToolAsync(
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
{

View file

@ -20,7 +20,7 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
await app.StartAsync(TestContext.Current.CancellationToken);
using var response = await HttpClient.GetAsync($"http://localhost{pattern}/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
using var response = await HttpClient.GetAsync($"http://localhost:5000{pattern}/sse", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
using var sseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
using var sseStreamReader = new StreamReader(sseStream, System.Text.Encoding.UTF8);

View file

@ -56,7 +56,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await using var mcpClient = await ConnectAsync("/", new()
{
Endpoint = new Uri("http://localhost/"),
Endpoint = new("http://localhost:5000/"),
TransportMode = HttpTransportMode.AutoDetect
});
@ -82,7 +82,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await using var mcpClient = await ConnectAsync("/", new()
{
Endpoint = new Uri("http://localhost/"),
Endpoint = new("http://localhost:5000/"),
TransportMode = HttpTransportMode.AutoDetect
});
@ -110,7 +110,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await using var mcpClient = await ConnectAsync("/sse", new()
{
Endpoint = new Uri("http://localhost/sse"),
Endpoint = new("http://localhost:5000/sse"),
TransportMode = HttpTransportMode.AutoDetect
});
@ -138,7 +138,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await using var mcpClient = await ConnectAsync(transportOptions: new()
{
Endpoint = new Uri("http://localhost/sse"),
Endpoint = new("http://localhost:5000/sse"),
TransportMode = HttpTransportMode.Sse
});
@ -171,14 +171,16 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await app.StartAsync(TestContext.Current.CancellationToken);
await using var mcpClient = await ConnectAsync(clientOptions: new()
await using (var mcpClient = await ConnectAsync(clientOptions: new()
{
ProtocolVersion = "2025-03-26",
});
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
}))
{
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
}
// The header should be included in the GET request, the initialized notification, and the tools/list call.
Assert.Equal(3, protocolVersionHeaderValues.Count);
// The header should be included in the GET request, the initialized notification, the tools/list call, and the delete request.
Assert.NotEmpty(protocolVersionHeaderValues);
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
}
}

View file

@ -33,7 +33,7 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
await using var transport = new SseClientTransport(transportOptions ?? new SseClientTransportOptions
{
Endpoint = new Uri($"http://localhost{path}"),
Endpoint = new Uri($"http://localhost:5000{path}"),
TransportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse,
}, HttpClient, LoggerFactory);

View file

@ -34,6 +34,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Logging" />
@ -56,6 +57,7 @@
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
<ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" />
<ProjectReference Include="..\ModelContextProtocol.TestOAuthServer\ModelContextProtocol.TestOAuthServer.csproj" />
</ItemGroup>
</Project>

View file

@ -17,7 +17,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
{
private readonly SseClientTransportOptions DefaultTransportOptions = new()
{
Endpoint = new Uri("http://localhost/sse"),
Endpoint = new("http://localhost:5000/sse"),
Name = "In-memory SSE Client",
};
@ -197,7 +197,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
var sseOptions = new SseClientTransportOptions
{
Endpoint = new Uri("http://localhost/sse"),
Endpoint = new("http://localhost:5000/sse"),
Name = "In-memory SSE Client",
AdditionalHeaders = new Dictionary<string, string>
{
@ -224,7 +224,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
var sseOptions = new SseClientTransportOptions
{
Endpoint = new Uri("http://localhost/sse"),
Endpoint = new("http://localhost:5000/sse"),
Name = "In-memory SSE Client",
AdditionalHeaders = new Dictionary<string, string>()
{
@ -251,7 +251,7 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
response.Headers.ContentType = "text/event-stream";
await using var transport = new SseResponseStreamTransport(response.Body, "http://localhost/message");
await using var transport = new SseResponseStreamTransport(response.Body, "http://localhost:5000/message");
session = transport;
try

View file

@ -3,6 +3,7 @@ using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.TestSseServer;
using System.Net;
namespace ModelContextProtocol.AspNetCore.Tests;
@ -19,7 +20,7 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
private SseClientTransportOptions DefaultTransportOptions { get; set; } = new()
{
Endpoint = new("http://localhost/"),
Endpoint = new("http://localhost:5000/"),
};
public SseServerIntegrationTestFixture()
@ -28,14 +29,14 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
{
ConnectCallback = (context, token) =>
{
var connection = _inMemoryTransport.CreateConnection();
var connection = _inMemoryTransport.CreateConnection(new DnsEndPoint("localhost", 5000));
return new(connection.ClientStream);
},
};
HttpClient = new HttpClient(socketsHttpHandler)
{
BaseAddress = new("http://localhost/"),
BaseAddress = new("http://localhost:5000/"),
};
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _inMemoryTransport, _stopCts.Token);

View file

@ -10,7 +10,7 @@ public class SseServerIntegrationTests(SseServerIntegrationTestFixture fixture,
{
protected override SseClientTransportOptions ClientTransportOptions => new()
{
Endpoint = new Uri("http://localhost/sse"),
Endpoint = new("http://localhost:5000/sse"),
Name = "In-memory SSE Client",
};

View file

@ -7,7 +7,7 @@ public class StatelessServerIntegrationTests(SseServerIntegrationTestFixture fix
{
protected override SseClientTransportOptions ClientTransportOptions => new()
{
Endpoint = new Uri("http://localhost/stateless"),
Endpoint = new("http://localhost:5000/stateless"),
Name = "In-memory Streamable HTTP Client",
TransportMode = HttpTransportMode.StreamableHttp,
};

View file

@ -16,7 +16,7 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
private readonly SseClientTransportOptions DefaultTransportOptions = new()
{
Endpoint = new Uri("http://localhost/"),
Endpoint = new("http://localhost:5000/"),
Name = "In-memory Streamable HTTP Client",
TransportMode = HttpTransportMode.StreamableHttp,
};

View file

@ -114,7 +114,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
await using var transport = new SseClientTransport(new()
{
Endpoint = new("http://localhost/mcp"),
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
@ -134,7 +134,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
await using var transport = new SseClientTransport(new()
{
Endpoint = new("http://localhost/mcp"),
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
@ -160,7 +160,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
await using var transport = new SseClientTransport(new()
{
Endpoint = new("http://localhost/mcp"),
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);

View file

@ -13,7 +13,7 @@ public class StreamableHttpServerIntegrationTests(SseServerIntegrationTestFixtur
protected override SseClientTransportOptions ClientTransportOptions => new()
{
Endpoint = new Uri("http://localhost/"),
Endpoint = new("http://localhost:5000/"),
Name = "In-memory Streamable HTTP Client",
TransportMode = HttpTransportMode.StreamableHttp,
};

View file

@ -8,8 +8,6 @@ namespace ModelContextProtocol.AspNetCore.Tests.Utils;
public class KestrelInMemoryTest : LoggedTest
{
private readonly KestrelInMemoryTransport _inMemoryTransport = new();
public KestrelInMemoryTest(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
@ -17,17 +15,16 @@ public class KestrelInMemoryTest : LoggedTest
// or a helper that does the same every test. But clear out the existing socket transport to avoid potential port conflicts.
Builder = WebApplication.CreateSlimBuilder();
Builder.Services.RemoveAll<IConnectionListenerFactory>();
Builder.Services.AddSingleton<IConnectionListenerFactory>(_inMemoryTransport);
Builder.Services.AddSingleton<IConnectionListenerFactory>(KestrelInMemoryTransport);
Builder.Services.AddSingleton(XunitLoggerProvider);
HttpClient = new HttpClient(new SocketsHttpHandler
SocketsHttpHandler.ConnectCallback = (context, token) =>
{
ConnectCallback = (context, token) =>
{
var connection = _inMemoryTransport.CreateConnection();
return new(connection.ClientStream);
},
})
var connection = KestrelInMemoryTransport.CreateConnection(context.DnsEndPoint);
return new(connection.ClientStream);
};
HttpClient = new HttpClient(SocketsHttpHandler)
{
BaseAddress = new Uri("http://localhost:5000/"),
Timeout = TimeSpan.FromSeconds(10),
@ -38,6 +35,10 @@ public class KestrelInMemoryTest : LoggedTest
public HttpClient HttpClient { get; }
public SocketsHttpHandler SocketsHttpHandler { get; } = new();
public KestrelInMemoryTransport KestrelInMemoryTransport { get; } = new();
public override void Dispose()
{
HttpClient.Dispose();

View file

@ -1,50 +1,59 @@
using Microsoft.AspNetCore.Connections;
using System.Collections.Concurrent;
using System.Net;
using System.Threading.Channels;
namespace ModelContextProtocol.AspNetCore.Tests.Utils;
public sealed class KestrelInMemoryTransport : IConnectionListenerFactory, IConnectionListener
public sealed class KestrelInMemoryTransport : IConnectionListenerFactory
{
private readonly Channel<ConnectionContext> _acceptQueue = Channel.CreateUnbounded<ConnectionContext>();
private EndPoint? _endPoint;
// socket accept queues keyed by listen port.
private readonly ConcurrentDictionary<int, Channel<ConnectionContext>> _acceptQueues = [];
public EndPoint EndPoint => _endPoint ?? throw new InvalidOperationException("EndPoint is not set. Call BindAsync first.");
public KestrelInMemoryConnection CreateConnection()
public KestrelInMemoryConnection CreateConnection(EndPoint endpoint)
{
var connection = new KestrelInMemoryConnection();
_acceptQueue.Writer.TryWrite(connection);
GetAcceptQueue(endpoint).Writer.TryWrite(connection);
return connection;
}
public async ValueTask<ConnectionContext?> AcceptAsync(CancellationToken cancellationToken = default)
{
if (await _acceptQueue.Reader.WaitToReadAsync(cancellationToken))
public ValueTask<IConnectionListener> BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default) =>
new(new KestrelInMemoryListener(endpoint, GetAcceptQueue(endpoint)));
private Channel<ConnectionContext> GetAcceptQueue(EndPoint endpoint) =>
_acceptQueues.GetOrAdd(GetEndpointPort(endpoint), _ => Channel.CreateUnbounded<ConnectionContext>());
private static int GetEndpointPort(EndPoint endpoint) =>
endpoint switch
{
while (_acceptQueue.Reader.TryRead(out var item))
DnsEndPoint dnsEndpoint => dnsEndpoint.Port,
IPEndPoint ipEndpoint => ipEndpoint.Port,
_ => throw new InvalidOperationException($"Unexpected endpoint type: '{endpoint.GetType()}'"),
};
private sealed class KestrelInMemoryListener(EndPoint endpoint, Channel<ConnectionContext> acceptQueue) : IConnectionListener
{
public EndPoint EndPoint => endpoint;
public async ValueTask<ConnectionContext?> AcceptAsync(CancellationToken cancellationToken = default)
{
if (await acceptQueue.Reader.WaitToReadAsync(cancellationToken))
{
return item;
while (acceptQueue.Reader.TryRead(out var item))
{
return item;
}
}
return null;
}
return null;
}
public ValueTask UnbindAsync(CancellationToken cancellationToken = default)
{
acceptQueue.Writer.TryComplete();
return default;
}
public ValueTask<IConnectionListener> BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default)
{
_endPoint = endpoint;
return new ValueTask<IConnectionListener>(this);
}
public ValueTask DisposeAsync()
{
return UnbindAsync(default);
}
public ValueTask UnbindAsync(CancellationToken cancellationToken = default)
{
_acceptQueue.Writer.TryComplete();
return default;
public ValueTask DisposeAsync() => UnbindAsync(CancellationToken.None);
}
}