Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions samples/ProtectedMcpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
{
options.ResourceMetadata = new()
{
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
ResourceDocumentation = "https://docs.example.com/api/weather",
AuthorizationServers = { inMemoryOAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,29 @@ private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
return false;
}

var deriveResourceUriBuilder = new UriBuilder(Request.Scheme, Request.Host.Host)
// Build the derived resource string directly without trailing slash
var scheme = Request.Scheme;
var host = Request.Host.Host;
var port = Request.Host.Port;
var path = $"{Request.PathBase}{resourceSuffix}".TrimEnd('/');

string derivedResource;
if (port.HasValue && !IsDefaultPort(scheme, port.Value))
{
Path = $"{Request.PathBase}{resourceSuffix}",
};

if (Request.Host.Port is not null)
derivedResource = $"{scheme}://{host}:{port.Value}{path}";
}
else
{
deriveResourceUriBuilder.Port = Request.Host.Port.Value;
derivedResource = $"{scheme}://{host}{path}";
}

return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
return await HandleResourceMetadataRequestAsync(derivedResource);
}

private static bool IsDefaultPort(string scheme, int port)
{
return (scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && port == 80) ||
(scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && port == 443);
}

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

private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
private async Task<bool> HandleResourceMetadataRequestAsync(string? derivedResource = null)
{
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResourceUri);
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResource);

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

resourceMetadata.Resource ??= derivedResourceUri;
resourceMetadata.Resource ??= derivedResource;

if (resourceMetadata.Resource is null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
// Try to refresh the access token if it is invalid and we have a refresh token.
if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken)
{
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
return (accessToken, true);
}

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

// Convert string URIs to Uri objects for the selector
List<Uri> authServerUris = [];
foreach (var serverUriString in availableAuthorizationServers)
{
if (!Uri.TryCreate(serverUriString, UriKind.Absolute, out var serverUri))
{
ThrowFailedToHandleUnauthorizedResponse($"Invalid authorization server URI: '{serverUriString}'. Available servers: {string.Join(", ", availableAuthorizationServers)}");
}
authServerUris.Add(serverUri);
}

// Select authorization server using configured strategy
var selectedAuthServer = _authServerSelector(availableAuthorizationServers);
var selectedAuthServer = _authServerSelector(authServerUris);

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

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

private async Task<string?> RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
private async Task<string?> RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
Dictionary<string, string> formFields = new()
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["resource"] = resourceUri.ToString(),
["resource"] = resourceUri,
};

using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
Expand Down Expand Up @@ -443,7 +454,7 @@ private Uri BuildAuthorizationUrl(
["response_type"] = "code",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
["resource"] = resourceUri.ToString(),
["resource"] = resourceUri,
};

var scope = GetScopeParameter(protectedResourceMetadata);
Expand Down Expand Up @@ -487,7 +498,7 @@ private async Task<string> ExchangeCodeForTokenAsync(
["code"] = authorizationCode,
["redirect_uri"] = _redirectUri.ToString(),
["code_verifier"] = codeVerifier,
["resource"] = resourceUri.ToString(),
["resource"] = resourceUri,
};

using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
Expand Down Expand Up @@ -659,7 +670,7 @@ private async Task PerformDynamicClientRegistrationAsync(
}
}

private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
{
if (protectedResourceMetadata.Resource is null)
{
Expand Down Expand Up @@ -732,6 +743,27 @@ private static string NormalizeUri(Uri uri)
return builder.ToString();
}

/// <summary>
/// Normalizes a URI string for consistent comparison.
/// </summary>
/// <param name="uriString">The URI string to normalize.</param>
/// <returns>
/// A normalized string representation of the URI. If the string is a valid absolute URI,
/// it is parsed and normalized (scheme, host, port, and path without trailing slash).
/// If the string is not a valid absolute URI, only the trailing slash is removed.
/// </returns>
private static string NormalizeUri(string uriString)
{
// Parse the string as a URI to normalize it
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
{
// If it's not a valid URI, return the string with trailing slash removed
return uriString.TrimEnd('/');
}

return NormalizeUri(uri);
}

/// <summary>
/// Responds to a 401 challenge by parsing the WWW-Authenticate header, fetching the resource metadata,
/// verifying the resource match, and returning the metadata if valid.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;

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

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

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

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

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

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

/// <summary>
/// Gets or sets a value indicating whether there is protected resource support for mutual-TLS client certificate-bound access tokens.
Expand Down Expand Up @@ -195,13 +201,13 @@ public sealed class ProtectedResourceMetadata
/// <summary>
/// Creates a deep copy of this <see cref="ProtectedResourceMetadata"/> instance, optionally overriding the Resource property.
/// </summary>
/// <param name="derivedResourceUri">Optional URI to use for the Resource property if the original Resource is null.</param>
/// <param name="derivedResource">Optional resource URI string to use for the Resource property if the original Resource is null.</param>
/// <returns>A new instance of <see cref="ProtectedResourceMetadata"/> with cloned values.</returns>
public ProtectedResourceMetadata Clone(Uri? derivedResourceUri = null)
public ProtectedResourceMetadata Clone(string? derivedResource = null)
{
return new ProtectedResourceMetadata
{
Resource = Resource ?? derivedResourceUri,
Resource = Resource ?? derivedResource,
AuthorizationServers = [.. AuthorizationServers],
BearerMethodsSupported = [.. BearerMethodsSupported],
ScopesSupported = [.. ScopesSupported],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ public AuthEventTests(ITestOutputHelper outputHelper)
// Dynamically provide the resource metadata
context.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(McpServerUrl),
AuthorizationServers = { new Uri(OAuthServerUrl) },
Resource = McpServerUrl,
AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
await Task.CompletedTask;
Expand Down Expand Up @@ -124,8 +124,8 @@ public async Task ResourceMetadataEndpoint_ReturnsCorrectMetadata_FromEvent()
);

Assert.NotNull(metadata);
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
Assert.Equal(McpServerUrl, metadata.Resource);
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
Assert.Contains("mcp:tools", metadata.ScopesSupported);
}

Expand All @@ -140,8 +140,8 @@ public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
// Set initial metadata
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(McpServerUrl),
AuthorizationServers = { new Uri(OAuthServerUrl) },
Resource = McpServerUrl,
AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:basic"],
};

Expand Down Expand Up @@ -175,8 +175,8 @@ public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
);

Assert.NotNull(metadata);
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
Assert.Equal(McpServerUrl, metadata.Resource);
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
Assert.Contains("mcp:basic", metadata.ScopesSupported);
Assert.Contains("mcp:tools", metadata.ScopesSupported);
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);
Expand Down
Loading