Skip to content

Commit dbf91bc

Browse files
Copilothalter73
andauthored
Change ProtectedResourceMetadata URI properties to strings and build resource strings directly (#1264)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> Co-authored-by: Stephen Halter <halter73@gmail.com>
1 parent b4c7ded commit dbf91bc

9 files changed

Lines changed: 209 additions & 55 deletions

File tree

samples/ProtectedMcpServer/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
{
5757
options.ResourceMetadata = new()
5858
{
59-
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
60-
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
59+
ResourceDocumentation = "https://docs.example.com/api/weather",
60+
AuthorizationServers = { inMemoryOAuthServerUrl },
6161
ScopesSupported = ["mcp:tools"],
6262
};
6363
});

src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,29 @@ private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
5656
return false;
5757
}
5858

59-
var deriveResourceUriBuilder = new UriBuilder(Request.Scheme, Request.Host.Host)
59+
// Build the derived resource string directly without trailing slash
60+
var scheme = Request.Scheme;
61+
var host = Request.Host.Host;
62+
var port = Request.Host.Port;
63+
var path = $"{Request.PathBase}{resourceSuffix}".TrimEnd('/');
64+
65+
string derivedResource;
66+
if (port.HasValue && !IsDefaultPort(scheme, port.Value))
6067
{
61-
Path = $"{Request.PathBase}{resourceSuffix}",
62-
};
63-
64-
if (Request.Host.Port is not null)
68+
derivedResource = $"{scheme}://{host}:{port.Value}{path}";
69+
}
70+
else
6571
{
66-
deriveResourceUriBuilder.Port = Request.Host.Port.Value;
72+
derivedResource = $"{scheme}://{host}{path}";
6773
}
6874

69-
return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
75+
return await HandleResourceMetadataRequestAsync(derivedResource);
76+
}
77+
78+
private static bool IsDefaultPort(string scheme, int port)
79+
{
80+
return (scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && port == 80) ||
81+
(scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && port == 443);
7082
}
7183

7284
/// <summary>
@@ -128,9 +140,9 @@ private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
128140
return path.StartsWith('/') ? path : $"/{path}";
129141
}
130142

131-
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
143+
private async Task<bool> HandleResourceMetadataRequestAsync(string? derivedResource = null)
132144
{
133-
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResourceUri);
145+
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResource);
134146

135147
if (Options.Events.OnResourceMetadataRequest is not null)
136148
{
@@ -165,7 +177,7 @@ private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResource
165177
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
166178
}
167179

168-
resourceMetadata.Resource ??= derivedResourceUri;
180+
resourceMetadata.Resource ??= derivedResource;
169181

170182
if (resourceMetadata.Resource is null)
171183
{

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
154154
// Try to refresh the access token if it is invalid and we have a refresh token.
155155
if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken)
156156
{
157-
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
157+
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
158158
return (accessToken, true);
159159
}
160160

@@ -243,15 +243,26 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
243243
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
244244
}
245245

246+
// Convert string URIs to Uri objects for the selector
247+
List<Uri> authServerUris = [];
248+
foreach (var serverUriString in availableAuthorizationServers)
249+
{
250+
if (!Uri.TryCreate(serverUriString, UriKind.Absolute, out var serverUri))
251+
{
252+
ThrowFailedToHandleUnauthorizedResponse($"Invalid authorization server URI: '{serverUriString}'. Available servers: {string.Join(", ", availableAuthorizationServers)}");
253+
}
254+
authServerUris.Add(serverUri);
255+
}
256+
246257
// Select authorization server using configured strategy
247-
var selectedAuthServer = _authServerSelector(availableAuthorizationServers);
258+
var selectedAuthServer = _authServerSelector(authServerUris);
248259

249260
if (selectedAuthServer is null)
250261
{
251262
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selection returned null. Available servers: {string.Join(", ", availableAuthorizationServers)}");
252263
}
253264

254-
if (!availableAuthorizationServers.Contains(selectedAuthServer))
265+
if (!authServerUris.Contains(selectedAuthServer))
255266
{
256267
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selector returned a server not in the available list: {selectedAuthServer}. Available servers: {string.Join(", ", availableAuthorizationServers)}");
257268
}
@@ -387,13 +398,13 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri
387398
}
388399
}
389400

390-
private async Task<string?> RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
401+
private async Task<string?> RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
391402
{
392403
Dictionary<string, string> formFields = new()
393404
{
394405
["grant_type"] = "refresh_token",
395406
["refresh_token"] = refreshToken,
396-
["resource"] = resourceUri.ToString(),
407+
["resource"] = resourceUri,
397408
};
398409

399410
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
@@ -443,7 +454,7 @@ private Uri BuildAuthorizationUrl(
443454
["response_type"] = "code",
444455
["code_challenge"] = codeChallenge,
445456
["code_challenge_method"] = "S256",
446-
["resource"] = resourceUri.ToString(),
457+
["resource"] = resourceUri,
447458
};
448459

449460
var scope = GetScopeParameter(protectedResourceMetadata);
@@ -487,7 +498,7 @@ private async Task<string> ExchangeCodeForTokenAsync(
487498
["code"] = authorizationCode,
488499
["redirect_uri"] = _redirectUri.ToString(),
489500
["code_verifier"] = codeVerifier,
490-
["resource"] = resourceUri.ToString(),
501+
["resource"] = resourceUri,
491502
};
492503

493504
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
@@ -659,7 +670,7 @@ private async Task PerformDynamicClientRegistrationAsync(
659670
}
660671
}
661672

662-
private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
673+
private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
663674
{
664675
if (protectedResourceMetadata.Resource is null)
665676
{
@@ -732,6 +743,27 @@ private static string NormalizeUri(Uri uri)
732743
return builder.ToString();
733744
}
734745

746+
/// <summary>
747+
/// Normalizes a URI string for consistent comparison.
748+
/// </summary>
749+
/// <param name="uriString">The URI string to normalize.</param>
750+
/// <returns>
751+
/// A normalized string representation of the URI. If the string is a valid absolute URI,
752+
/// it is parsed and normalized (scheme, host, port, and path without trailing slash).
753+
/// If the string is not a valid absolute URI, only the trailing slash is removed.
754+
/// </returns>
755+
private static string NormalizeUri(string uriString)
756+
{
757+
// Parse the string as a URI to normalize it
758+
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
759+
{
760+
// If it's not a valid URI, return the string with trailing slash removed
761+
return uriString.TrimEnd('/');
762+
}
763+
764+
return NormalizeUri(uri);
765+
}
766+
735767
/// <summary>
736768
/// Responds to a 401 challenge by parsing the WWW-Authenticate header, fetching the resource metadata,
737769
/// verifying the resource match, and returning the metadata if valid.

src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Text.Json.Serialization;
23

34
namespace ModelContextProtocol.Authentication;
@@ -20,7 +21,8 @@ public sealed class ProtectedResourceMetadata
2021
/// <b>Resource</b> must be explicitly set. Automatic inference only works with the default endpoint pattern.
2122
/// </remarks>
2223
[JsonPropertyName("resource")]
23-
public Uri? Resource { get; set; }
24+
[StringSyntax(StringSyntaxAttribute.Uri)]
25+
public string? Resource { get; set; }
2426

2527
/// <summary>
2628
/// Gets or sets the list of authorization server URIs.
@@ -33,7 +35,7 @@ public sealed class ProtectedResourceMetadata
3335
/// OPTIONAL.
3436
/// </remarks>
3537
[JsonPropertyName("authorization_servers")]
36-
public List<Uri> AuthorizationServers { get; set; } = [];
38+
public List<string> AuthorizationServers { get; set; } = [];
3739

3840
/// <summary>
3941
/// Gets or sets the supported bearer token methods.
@@ -69,7 +71,8 @@ public sealed class ProtectedResourceMetadata
6971
/// that the resource server uses to sign resource responses. This URL MUST use the HTTPS scheme.
7072
/// </remarks>
7173
[JsonPropertyName("jwks_uri")]
72-
public Uri? JwksUri { get; set; }
74+
[StringSyntax(StringSyntaxAttribute.Uri)]
75+
public string? JwksUri { get; set; }
7376

7477
/// <summary>
7578
/// Gets or sets the list of the JWS signing algorithms supported by the protected resource for signing resource responses.
@@ -105,7 +108,8 @@ public sealed class ProtectedResourceMetadata
105108
/// OPTIONAL.
106109
/// </remarks>
107110
[JsonPropertyName("resource_documentation")]
108-
public Uri? ResourceDocumentation { get; set; }
111+
[StringSyntax(StringSyntaxAttribute.Uri)]
112+
public string? ResourceDocumentation { get; set; }
109113

110114
/// <summary>
111115
/// Gets or sets the URL of a page containing human-readable information about the protected resource's requirements.
@@ -117,7 +121,8 @@ public sealed class ProtectedResourceMetadata
117121
/// OPTIONAL.
118122
/// </remarks>
119123
[JsonPropertyName("resource_policy_uri")]
120-
public Uri? ResourcePolicyUri { get; set; }
124+
[StringSyntax(StringSyntaxAttribute.Uri)]
125+
public string? ResourcePolicyUri { get; set; }
121126

122127
/// <summary>
123128
/// Gets or sets the URL of a page containing human-readable information about the protected resource's terms of service.
@@ -126,7 +131,8 @@ public sealed class ProtectedResourceMetadata
126131
/// OPTIONAL. The value of this field MAY be internationalized.
127132
/// </remarks>
128133
[JsonPropertyName("resource_tos_uri")]
129-
public Uri? ResourceTosUri { get; set; }
134+
[StringSyntax(StringSyntaxAttribute.Uri)]
135+
public string? ResourceTosUri { get; set; }
130136

131137
/// <summary>
132138
/// Gets or sets a value indicating whether there is protected resource support for mutual-TLS client certificate-bound access tokens.
@@ -195,13 +201,13 @@ public sealed class ProtectedResourceMetadata
195201
/// <summary>
196202
/// Creates a deep copy of this <see cref="ProtectedResourceMetadata"/> instance, optionally overriding the Resource property.
197203
/// </summary>
198-
/// <param name="derivedResourceUri">Optional URI to use for the Resource property if the original Resource is null.</param>
204+
/// <param name="derivedResource">Optional resource URI string to use for the Resource property if the original Resource is null.</param>
199205
/// <returns>A new instance of <see cref="ProtectedResourceMetadata"/> with cloned values.</returns>
200-
public ProtectedResourceMetadata Clone(Uri? derivedResourceUri = null)
206+
public ProtectedResourceMetadata Clone(string? derivedResource = null)
201207
{
202208
return new ProtectedResourceMetadata
203209
{
204-
Resource = Resource ?? derivedResourceUri,
210+
Resource = Resource ?? derivedResource,
205211
AuthorizationServers = [.. AuthorizationServers],
206212
BearerMethodsSupported = [.. BearerMethodsSupported],
207213
ScopesSupported = [.. ScopesSupported],

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ public AuthEventTests(ITestOutputHelper outputHelper)
2525
// Dynamically provide the resource metadata
2626
context.ResourceMetadata = new ProtectedResourceMetadata
2727
{
28-
Resource = new Uri(McpServerUrl),
29-
AuthorizationServers = { new Uri(OAuthServerUrl) },
28+
Resource = McpServerUrl,
29+
AuthorizationServers = { OAuthServerUrl },
3030
ScopesSupported = ["mcp:tools"],
3131
};
3232
await Task.CompletedTask;
@@ -124,8 +124,8 @@ public async Task ResourceMetadataEndpoint_ReturnsCorrectMetadata_FromEvent()
124124
);
125125

126126
Assert.NotNull(metadata);
127-
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
128-
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
127+
Assert.Equal(McpServerUrl, metadata.Resource);
128+
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
129129
Assert.Contains("mcp:tools", metadata.ScopesSupported);
130130
}
131131

@@ -140,8 +140,8 @@ public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
140140
// Set initial metadata
141141
options.ResourceMetadata = new ProtectedResourceMetadata
142142
{
143-
Resource = new Uri(McpServerUrl),
144-
AuthorizationServers = { new Uri(OAuthServerUrl) },
143+
Resource = McpServerUrl,
144+
AuthorizationServers = { OAuthServerUrl },
145145
ScopesSupported = ["mcp:basic"],
146146
};
147147

@@ -175,8 +175,8 @@ public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
175175
);
176176

177177
Assert.NotNull(metadata);
178-
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
179-
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
178+
Assert.Equal(McpServerUrl, metadata.Resource);
179+
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
180180
Assert.Contains("mcp:basic", metadata.ScopesSupported);
181181
Assert.Contains("mcp:tools", metadata.ScopesSupported);
182182
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);

0 commit comments

Comments
 (0)