Skip to content

Commit 8b73a42

Browse files
Copilotstephentoubhalter73
authored
Remove private reflection from tests (#1107)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: stephentoub <[email protected]> Co-authored-by: halter73 <[email protected]>
1 parent aaffd71 commit 8b73a42

File tree

5 files changed

+93
-148
lines changed

5 files changed

+93
-148
lines changed

src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
130130

131131
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
132132
{
133-
var resourceMetadata = CloneResourceMetadata(Options.ResourceMetadata, derivedResourceUri);
133+
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResourceUri);
134134

135135
if (Options.Events.OnResourceMetadataRequest is not null)
136136
{
@@ -192,32 +192,6 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
192192
return base.HandleChallengeAsync(properties);
193193
}
194194

195-
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata, Uri? derivedResourceUri = null)
196-
{
197-
if (resourceMetadata is null)
198-
{
199-
return null;
200-
}
201-
202-
return new ProtectedResourceMetadata
203-
{
204-
Resource = resourceMetadata.Resource ?? derivedResourceUri,
205-
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
206-
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
207-
ScopesSupported = [.. resourceMetadata.ScopesSupported],
208-
JwksUri = resourceMetadata.JwksUri,
209-
ResourceSigningAlgValuesSupported = resourceMetadata.ResourceSigningAlgValuesSupported is not null ? [.. resourceMetadata.ResourceSigningAlgValuesSupported] : null,
210-
ResourceName = resourceMetadata.ResourceName,
211-
ResourceDocumentation = resourceMetadata.ResourceDocumentation,
212-
ResourcePolicyUri = resourceMetadata.ResourcePolicyUri,
213-
ResourceTosUri = resourceMetadata.ResourceTosUri,
214-
TlsClientCertificateBoundAccessTokens = resourceMetadata.TlsClientCertificateBoundAccessTokens,
215-
AuthorizationDetailsTypesSupported = resourceMetadata.AuthorizationDetailsTypesSupported is not null ? [.. resourceMetadata.AuthorizationDetailsTypesSupported] : null,
216-
DpopSigningAlgValuesSupported = resourceMetadata.DpopSigningAlgValuesSupported is not null ? [.. resourceMetadata.DpopSigningAlgValuesSupported] : null,
217-
DpopBoundAccessTokensRequired = resourceMetadata.DpopBoundAccessTokensRequired
218-
};
219-
}
220-
221195
[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request host did not match configured host '{ConfiguredHost}'.")]
222196
private static partial void LogResourceMetadataHostMismatch(ILogger logger, string configuredHost);
223197

src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,30 @@ public sealed class ProtectedResourceMetadata
191191
/// </summary>
192192
[JsonIgnore]
193193
internal string? WwwAuthenticateScope { get; set; }
194+
195+
/// <summary>
196+
/// Creates a deep copy of this <see cref="ProtectedResourceMetadata"/> instance, optionally overriding the Resource property.
197+
/// </summary>
198+
/// <param name="derivedResourceUri">Optional URI to use for the Resource property if the original Resource is null.</param>
199+
/// <returns>A new instance of <see cref="ProtectedResourceMetadata"/> with cloned values.</returns>
200+
public ProtectedResourceMetadata Clone(Uri? derivedResourceUri = null)
201+
{
202+
return new ProtectedResourceMetadata
203+
{
204+
Resource = Resource ?? derivedResourceUri,
205+
AuthorizationServers = [.. AuthorizationServers],
206+
BearerMethodsSupported = [.. BearerMethodsSupported],
207+
ScopesSupported = [.. ScopesSupported],
208+
JwksUri = JwksUri,
209+
ResourceSigningAlgValuesSupported = ResourceSigningAlgValuesSupported is not null ? [.. ResourceSigningAlgValuesSupported] : null,
210+
ResourceName = ResourceName,
211+
ResourceDocumentation = ResourceDocumentation,
212+
ResourcePolicyUri = ResourcePolicyUri,
213+
ResourceTosUri = ResourceTosUri,
214+
TlsClientCertificateBoundAccessTokens = TlsClientCertificateBoundAccessTokens,
215+
AuthorizationDetailsTypesSupported = AuthorizationDetailsTypesSupported is not null ? [.. AuthorizationDetailsTypesSupported] : null,
216+
DpopSigningAlgValuesSupported = DpopSigningAlgValuesSupported is not null ? [.. DpopSigningAlgValuesSupported] : null,
217+
DpopBoundAccessTokensRequired = DpopBoundAccessTokensRequired
218+
};
219+
}
194220
}

tests/Common/Utils/TestServerTransport.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,12 @@ private async Task WriteMessageAsync(JsonRpcMessage message, CancellationToken c
9191
{
9292
await _messageChannel.Writer.WriteAsync(message, cancellationToken);
9393
}
94+
95+
/// <summary>
96+
/// Sends a message from the client to the server (simulating client-to-server communication).
97+
/// </summary>
98+
public async Task SendClientMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
99+
{
100+
await _messageChannel.Writer.WriteAsync(message, cancellationToken);
101+
}
94102
}

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 0 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
using ModelContextProtocol.Client;
1010
using ModelContextProtocol.Server;
1111
using System.Net;
12-
using System.Reflection;
1312
using System.Security.Claims;
1413
using Xunit.Sdk;
1514

@@ -751,105 +750,4 @@ await McpClient.CreateAsync(
751750

752751
Assert.Contains("does not match", ex.Message);
753752
}
754-
755-
[Fact]
756-
public void CloneResourceMetadataClonesAllProperties()
757-
{
758-
var propertyNames = typeof(ProtectedResourceMetadata).GetProperties().Select(property => property.Name).ToList();
759-
760-
// Set metadata properties to non-default values to verify they're copied.
761-
var metadata = new ProtectedResourceMetadata
762-
{
763-
Resource = new Uri("https://example.com/resource"),
764-
AuthorizationServers = [new Uri("https://auth1.example.com"), new Uri("https://auth2.example.com")],
765-
BearerMethodsSupported = ["header", "body", "query"],
766-
ScopesSupported = ["read", "write", "admin"],
767-
JwksUri = new Uri("https://example.com/.well-known/jwks.json"),
768-
ResourceSigningAlgValuesSupported = ["RS256", "ES256"],
769-
ResourceName = "Test Resource",
770-
ResourceDocumentation = new Uri("https://docs.example.com"),
771-
ResourcePolicyUri = new Uri("https://example.com/policy"),
772-
ResourceTosUri = new Uri("https://example.com/terms"),
773-
TlsClientCertificateBoundAccessTokens = true,
774-
AuthorizationDetailsTypesSupported = ["payment_initiation", "account_information"],
775-
DpopSigningAlgValuesSupported = ["RS256", "PS256"],
776-
DpopBoundAccessTokensRequired = true
777-
};
778-
779-
// Use reflection to call the internal CloneResourceMetadata method
780-
var handlerType = typeof(McpAuthenticationHandler);
781-
var cloneMethod = handlerType.GetMethod("CloneResourceMetadata", BindingFlags.Static | BindingFlags.NonPublic);
782-
Assert.NotNull(cloneMethod);
783-
784-
var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata, null]);
785-
Assert.NotNull(clonedMetadata);
786-
787-
// Ensure the cloned metadata is not the same instance
788-
Assert.NotSame(metadata, clonedMetadata);
789-
790-
// Verify Resource property
791-
Assert.Equal(metadata.Resource, clonedMetadata.Resource);
792-
Assert.True(propertyNames.Remove(nameof(metadata.Resource)));
793-
794-
// Verify AuthorizationServers list is cloned and contains the same values
795-
Assert.NotSame(metadata.AuthorizationServers, clonedMetadata.AuthorizationServers);
796-
Assert.Equal(metadata.AuthorizationServers, clonedMetadata.AuthorizationServers);
797-
Assert.True(propertyNames.Remove(nameof(metadata.AuthorizationServers)));
798-
799-
// Verify BearerMethodsSupported list is cloned and contains the same values
800-
Assert.NotSame(metadata.BearerMethodsSupported, clonedMetadata.BearerMethodsSupported);
801-
Assert.Equal(metadata.BearerMethodsSupported, clonedMetadata.BearerMethodsSupported);
802-
Assert.True(propertyNames.Remove(nameof(metadata.BearerMethodsSupported)));
803-
804-
// Verify ScopesSupported list is cloned and contains the same values
805-
Assert.NotSame(metadata.ScopesSupported, clonedMetadata.ScopesSupported);
806-
Assert.Equal(metadata.ScopesSupported, clonedMetadata.ScopesSupported);
807-
Assert.True(propertyNames.Remove(nameof(metadata.ScopesSupported)));
808-
809-
// Verify JwksUri property
810-
Assert.Equal(metadata.JwksUri, clonedMetadata.JwksUri);
811-
Assert.True(propertyNames.Remove(nameof(metadata.JwksUri)));
812-
813-
// Verify ResourceSigningAlgValuesSupported list is cloned (nullable list)
814-
Assert.NotSame(metadata.ResourceSigningAlgValuesSupported, clonedMetadata.ResourceSigningAlgValuesSupported);
815-
Assert.Equal(metadata.ResourceSigningAlgValuesSupported, clonedMetadata.ResourceSigningAlgValuesSupported);
816-
Assert.True(propertyNames.Remove(nameof(metadata.ResourceSigningAlgValuesSupported)));
817-
818-
// Verify ResourceName property
819-
Assert.Equal(metadata.ResourceName, clonedMetadata.ResourceName);
820-
Assert.True(propertyNames.Remove(nameof(metadata.ResourceName)));
821-
822-
// Verify ResourceDocumentation property
823-
Assert.Equal(metadata.ResourceDocumentation, clonedMetadata.ResourceDocumentation);
824-
Assert.True(propertyNames.Remove(nameof(metadata.ResourceDocumentation)));
825-
826-
// Verify ResourcePolicyUri property
827-
Assert.Equal(metadata.ResourcePolicyUri, clonedMetadata.ResourcePolicyUri);
828-
Assert.True(propertyNames.Remove(nameof(metadata.ResourcePolicyUri)));
829-
830-
// Verify ResourceTosUri property
831-
Assert.Equal(metadata.ResourceTosUri, clonedMetadata.ResourceTosUri);
832-
Assert.True(propertyNames.Remove(nameof(metadata.ResourceTosUri)));
833-
834-
// Verify TlsClientCertificateBoundAccessTokens property
835-
Assert.Equal(metadata.TlsClientCertificateBoundAccessTokens, clonedMetadata.TlsClientCertificateBoundAccessTokens);
836-
Assert.True(propertyNames.Remove(nameof(metadata.TlsClientCertificateBoundAccessTokens)));
837-
838-
// Verify AuthorizationDetailsTypesSupported list is cloned (nullable list)
839-
Assert.NotSame(metadata.AuthorizationDetailsTypesSupported, clonedMetadata.AuthorizationDetailsTypesSupported);
840-
Assert.Equal(metadata.AuthorizationDetailsTypesSupported, clonedMetadata.AuthorizationDetailsTypesSupported);
841-
Assert.True(propertyNames.Remove(nameof(metadata.AuthorizationDetailsTypesSupported)));
842-
843-
// Verify DpopSigningAlgValuesSupported list is cloned (nullable list)
844-
Assert.NotSame(metadata.DpopSigningAlgValuesSupported, clonedMetadata.DpopSigningAlgValuesSupported);
845-
Assert.Equal(metadata.DpopSigningAlgValuesSupported, clonedMetadata.DpopSigningAlgValuesSupported);
846-
Assert.True(propertyNames.Remove(nameof(metadata.DpopSigningAlgValuesSupported)));
847-
848-
// Verify DpopBoundAccessTokensRequired property
849-
Assert.Equal(metadata.DpopBoundAccessTokensRequired, clonedMetadata.DpopBoundAccessTokensRequired);
850-
Assert.True(propertyNames.Remove(nameof(metadata.DpopBoundAccessTokensRequired)));
851-
852-
// Ensure we've checked every property. When new properties get added, we'll have to update this test along with the CloneResourceMetadata implementation.
853-
Assert.Empty(propertyNames);
854-
}
855753
}

tests/ModelContextProtocol.Tests/Server/McpServerTests.cs

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,18 @@ public async Task SampleAsync_Should_Throw_Exception_If_Client_Does_Not_Support_
128128
// Arrange
129129
await using var transport = new TestServerTransport();
130130
await using var server = McpServer.Create(transport, _options, LoggerFactory);
131-
SetClientCapabilities(server, new ClientCapabilities());
131+
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
132+
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
132133

133134
var action = async () => await server.SampleAsync(
134135
new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 },
135136
CancellationToken.None);
136137

137138
// Act & Assert
138139
await Assert.ThrowsAsync<InvalidOperationException>(action);
140+
141+
await transport.DisposeAsync();
142+
await runTask;
139143
}
140144

141145
[Fact]
@@ -144,9 +148,8 @@ public async Task SampleAsync_Should_SendRequest()
144148
// Arrange
145149
await using var transport = new TestServerTransport();
146150
await using var server = McpServer.Create(transport, _options, LoggerFactory);
147-
SetClientCapabilities(server, new ClientCapabilities { Sampling = new SamplingCapability() });
148-
149151
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
152+
await InitializeServerAsync(transport, new ClientCapabilities { Sampling = new SamplingCapability() }, TestContext.Current.CancellationToken);
150153

151154
// Act
152155
var result = await server.SampleAsync(
@@ -155,8 +158,10 @@ public async Task SampleAsync_Should_SendRequest()
155158

156159
Assert.NotNull(result);
157160
Assert.NotEmpty(transport.SentMessages);
158-
Assert.IsType<JsonRpcRequest>(transport.SentMessages[0]);
159-
Assert.Equal(RequestMethods.SamplingCreateMessage, ((JsonRpcRequest)transport.SentMessages[0]).Method);
161+
// First message is the initialize response, second is the sampling request
162+
Assert.True(transport.SentMessages.Count >= 2, "Expected at least 2 messages (initialize response and sampling request)");
163+
var samplingRequest = Assert.IsType<JsonRpcRequest>(transport.SentMessages[1]);
164+
Assert.Equal(RequestMethods.SamplingCreateMessage, samplingRequest.Method);
160165

161166
await transport.DisposeAsync();
162167
await runTask;
@@ -168,12 +173,16 @@ public async Task RequestRootsAsync_Should_Throw_Exception_If_Client_Does_Not_Su
168173
// Arrange
169174
await using var transport = new TestServerTransport();
170175
await using var server = McpServer.Create(transport, _options, LoggerFactory);
171-
SetClientCapabilities(server, new ClientCapabilities());
176+
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
177+
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
172178

173179
// Act & Assert
174180
await Assert.ThrowsAsync<InvalidOperationException>(async () => await server.RequestRootsAsync(
175181
new ListRootsRequestParams(),
176182
CancellationToken.None));
183+
184+
await transport.DisposeAsync();
185+
await runTask;
177186
}
178187

179188
[Fact]
@@ -182,17 +191,19 @@ public async Task RequestRootsAsync_Should_SendRequest()
182191
// Arrange
183192
await using var transport = new TestServerTransport();
184193
await using var server = McpServer.Create(transport, _options, LoggerFactory);
185-
SetClientCapabilities(server, new ClientCapabilities { Roots = new RootsCapability() });
186194
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
195+
await InitializeServerAsync(transport, new ClientCapabilities { Roots = new RootsCapability() }, TestContext.Current.CancellationToken);
187196

188197
// Act
189198
var result = await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None);
190199

191200
// Assert
192201
Assert.NotNull(result);
193202
Assert.NotEmpty(transport.SentMessages);
194-
Assert.IsType<JsonRpcRequest>(transport.SentMessages[0]);
195-
Assert.Equal(RequestMethods.RootsList, ((JsonRpcRequest)transport.SentMessages[0]).Method);
203+
// First message is the initialize response, second is the roots request
204+
Assert.True(transport.SentMessages.Count >= 2, "Expected at least 2 messages (initialize response and roots request)");
205+
var rootsRequest = Assert.IsType<JsonRpcRequest>(transport.SentMessages[1]);
206+
Assert.Equal(RequestMethods.RootsList, rootsRequest.Method);
196207

197208
await transport.DisposeAsync();
198209
await runTask;
@@ -204,12 +215,16 @@ public async Task ElicitAsync_Should_Throw_Exception_If_Client_Does_Not_Support_
204215
// Arrange
205216
await using var transport = new TestServerTransport();
206217
await using var server = McpServer.Create(transport, _options, LoggerFactory);
207-
SetClientCapabilities(server, new ClientCapabilities());
218+
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
219+
await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken);
208220

209221
// Act & Assert
210222
await Assert.ThrowsAsync<InvalidOperationException>(async () => await server.ElicitAsync(
211223
new ElicitRequestParams { Message = "" },
212224
CancellationToken.None));
225+
226+
await transport.DisposeAsync();
227+
await runTask;
213228
}
214229

215230
[Fact]
@@ -218,23 +233,25 @@ public async Task ElicitAsync_Should_SendRequest()
218233
// Arrange
219234
await using var transport = new TestServerTransport();
220235
await using var server = McpServer.Create(transport, _options, LoggerFactory);
221-
SetClientCapabilities(server, new ClientCapabilities
236+
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
237+
await InitializeServerAsync(transport, new ClientCapabilities
222238
{
223239
Elicitation = new()
224240
{
225241
Form = new(),
226242
},
227-
});
228-
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
243+
}, TestContext.Current.CancellationToken);
229244

230245
// Act
231246
var result = await server.ElicitAsync(new ElicitRequestParams { Message = "", RequestedSchema = new() }, CancellationToken.None);
232247

233248
// Assert
234249
Assert.NotNull(result);
235250
Assert.NotEmpty(transport.SentMessages);
236-
Assert.IsType<JsonRpcRequest>(transport.SentMessages[0]);
237-
Assert.Equal(RequestMethods.ElicitationCreate, ((JsonRpcRequest)transport.SentMessages[0]).Method);
251+
// First message is the initialize response, second is the elicit request
252+
Assert.True(transport.SentMessages.Count >= 2, "Expected at least 2 messages (initialize response and elicit request)");
253+
var elicitRequest = Assert.IsType<JsonRpcRequest>(transport.SentMessages[1]);
254+
Assert.Equal(RequestMethods.ElicitationCreate, elicitRequest.Method);
238255

239256
await transport.DisposeAsync();
240257
await runTask;
@@ -844,11 +861,33 @@ public async Task Can_SendMessage_Before_RunAsync()
844861
Assert.Same(logNotification, transport.SentMessages[0]);
845862
}
846863

847-
private static void SetClientCapabilities(McpServer server, ClientCapabilities capabilities)
864+
private static async Task InitializeServerAsync(TestServerTransport transport, ClientCapabilities capabilities, CancellationToken cancellationToken = default)
848865
{
849-
FieldInfo? field = server.GetType().GetField("_clientCapabilities", BindingFlags.NonPublic | BindingFlags.Instance);
850-
Assert.NotNull(field);
851-
field.SetValue(server, capabilities);
866+
var initializeRequest = new JsonRpcRequest
867+
{
868+
Id = new RequestId("init-1"),
869+
Method = RequestMethods.Initialize,
870+
Params = JsonSerializer.SerializeToNode(new InitializeRequestParams
871+
{
872+
ProtocolVersion = "2024-11-05",
873+
Capabilities = capabilities,
874+
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }
875+
}, McpJsonUtilities.DefaultOptions)
876+
};
877+
878+
var tcs = new TaskCompletionSource<bool>();
879+
transport.OnMessageSent = (message) =>
880+
{
881+
if (message is JsonRpcResponse response && response.Id == initializeRequest.Id)
882+
{
883+
tcs.TrySetResult(true);
884+
}
885+
};
886+
887+
await transport.SendClientMessageAsync(initializeRequest, cancellationToken);
888+
889+
// Wait for the initialize response to be sent
890+
await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
852891
}
853892

854893
private sealed class TestServerForIChatClient(bool supportsSampling) : McpServer

0 commit comments

Comments
 (0)