Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)
This commit is contained in:
parent
cd8e5d5f7f
commit
cdf88e7f5d
15 changed files with 4207 additions and 126 deletions
407
tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs
Normal file
407
tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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?>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue