Skip to content

Commit ab5daed

Browse files
committed
Fall back to well-known URL in client if 401 response is missing resource_metadata parameter
- Automatically infer resource URI by default in McpAuthenticationHandler - Fix matching absolute resource URI in McpAuthenticationHandler if specified - Add MockLoggerProvider to LoggedTest.cs
1 parent 136755f commit ab5daed

17 files changed

Lines changed: 466 additions & 116 deletions

File tree

samples/ProtectedMcpServer/Program.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@
5656
{
5757
options.ResourceMetadata = new()
5858
{
59-
Resource = new Uri(serverUrl),
6059
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
6160
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
6261
ScopesSupported = ["mcp:tools"],

src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs

Lines changed: 89 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
1313
/// </summary>
1414
public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
1515
{
16+
private const string DefaultResourceMetadataPath = "/.well-known/oauth-protected-resource";
17+
private static readonly PathString DefaultResourceMetadataPrefix = new(DefaultResourceMetadataPath);
18+
1619
/// <summary>
1720
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
1821
/// </summary>
@@ -27,65 +30,119 @@ public McpAuthenticationHandler(
2730
/// <inheritdoc />
2831
public async Task<bool> HandleRequestAsync()
2932
{
30-
// Check if the request is for the resource metadata endpoint
31-
string requestPath = Request.Path.Value ?? string.Empty;
32-
33-
string expectedMetadataPath = Options.ResourceMetadataUri?.ToString() ?? string.Empty;
34-
if (Options.ResourceMetadataUri != null && !Options.ResourceMetadataUri.IsAbsoluteUri)
33+
if (Options.ResourceMetadataUri is Uri configuredUri)
3534
{
36-
// For relative URIs, it's just the path component.
37-
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
35+
return await HandleConfiguredResourceMetadataRequestAsync(configuredUri);
3836
}
3937

40-
// If the path doesn't match, let the request continue through the pipeline
41-
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
38+
return await HandleDefaultResourceMetadataRequestAsync();
39+
}
40+
41+
private async Task<bool> HandleConfiguredResourceMetadataRequestAsync(Uri resourceMetadataUri)
42+
{
43+
if (!IsConfiguredEndpointRequest(resourceMetadataUri))
4244
{
4345
return false;
4446
}
4547

4648
return await HandleResourceMetadataRequestAsync();
4749
}
4850

49-
/// <summary>
50-
/// Gets the base URL from the current request, including scheme, host, and path base.
51-
/// </summary>
52-
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
51+
private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
52+
{
53+
if (!Request.Path.StartsWithSegments(DefaultResourceMetadataPrefix, out var resourceSuffix))
54+
{
55+
return false;
56+
}
57+
58+
var deriveResourceUriBuilder = new UriBuilder(Request.Scheme, Request.Host.Host)
59+
{
60+
Path = $"{Request.PathBase}{resourceSuffix}",
61+
Port = Request.Host.Port ?? (Request.Scheme == "https" ? 443 : 80),
62+
};
63+
64+
return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
65+
}
5366

5467
/// <summary>
5568
/// Gets the absolute URI for the resource metadata endpoint.
5669
/// </summary>
5770
private string GetAbsoluteResourceMetadataUri()
5871
{
59-
var resourceMetadataUri = Options.ResourceMetadataUri;
72+
if (Options.ResourceMetadataUri is Uri resourceMetadataUri)
73+
{
74+
if (resourceMetadataUri.IsAbsoluteUri)
75+
{
76+
return resourceMetadataUri.ToString();
77+
}
78+
79+
var seperator = resourceMetadataUri.OriginalString.StartsWith("/") ? "" : "/";
80+
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{seperator}{resourceMetadataUri.OriginalString}";
81+
}
82+
83+
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{DefaultResourceMetadataPath}{Request.Path}";
84+
}
85+
86+
private bool IsConfiguredEndpointRequest(Uri resourceMetadataUri)
87+
{
88+
var expectedPath = GetConfiguredResourceMetadataPath(resourceMetadataUri);
6089

61-
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
90+
if (!string.Equals(Request.Path.Value, expectedPath, StringComparison.OrdinalIgnoreCase))
91+
{
92+
return false;
93+
}
6294

63-
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
95+
if (!resourceMetadataUri.IsAbsoluteUri)
6496
{
65-
return currentPath;
97+
return true;
6698
}
6799

68-
// For relative URIs, combine with the base URL
69-
string baseUrl = GetBaseUrl();
70-
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
100+
if (!Request.Host.HasValue)
101+
{
102+
return false;
103+
}
71104

72-
if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
105+
if (!string.Equals(Request.Host.Host, resourceMetadataUri.Host, StringComparison.OrdinalIgnoreCase))
73106
{
74-
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
107+
Logger.LogWarning(
108+
"Resource metadata request host '{RequestHost}' did not match configured host '{ConfiguredHost}'.",
109+
Request.Host.Value,
110+
resourceMetadataUri.Host);
111+
return false;
75112
}
76113

77-
return absoluteUri.ToString();
114+
if (!string.Equals(Request.Scheme, resourceMetadataUri.Scheme, StringComparison.OrdinalIgnoreCase))
115+
{
116+
Logger.LogWarning(
117+
"Resource metadata request scheme '{RequestScheme}' did not match configured scheme '{ConfiguredScheme}'.",
118+
Request.Scheme,
119+
resourceMetadataUri.Scheme);
120+
return false;
121+
}
122+
123+
return true;
78124
}
79125

80-
private async Task<bool> HandleResourceMetadataRequestAsync()
126+
private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
81127
{
82-
var resourceMetadata = Options.ResourceMetadata;
128+
if (resourceMetadataUri.IsAbsoluteUri)
129+
{
130+
return resourceMetadataUri.AbsolutePath;
131+
}
132+
133+
var path = resourceMetadataUri.OriginalString;
134+
return path.StartsWith("/") ? path : $"/{path}";
135+
}
136+
137+
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
138+
{
139+
var resourceMetadata = CloneResourceMetadata(Options.ResourceMetadata, derivedResourceUri);
83140

84141
if (Options.Events.OnResourceMetadataRequest is not null)
85142
{
86143
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
87144
{
88-
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
145+
ResourceMetadata = resourceMetadata,
89146
};
90147

91148
await Options.Events.OnResourceMetadataRequest(context);
@@ -109,13 +166,13 @@ private async Task<bool> HandleResourceMetadataRequestAsync()
109166
resourceMetadata = context.ResourceMetadata;
110167
}
111168

112-
if (resourceMetadata == null)
169+
if (resourceMetadata is null)
113170
{
114-
throw new InvalidOperationException(
115-
"ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest."
116-
);
171+
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
117172
}
118173

174+
resourceMetadata.Resource ??= derivedResourceUri;
175+
119176
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
120177
return true;
121178
}
@@ -142,7 +199,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
142199
return base.HandleChallengeAsync(properties);
143200
}
144201

145-
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
202+
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata, Uri? derivedResourceUri = null)
146203
{
147204
if (resourceMetadata is null)
148205
{
@@ -151,7 +208,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
151208

152209
return new ProtectedResourceMetadata
153210
{
154-
Resource = resourceMetadata.Resource,
211+
Resource = resourceMetadata.Resource ?? derivedResourceUri,
155212
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
156213
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
157214
ScopesSupported = [.. resourceMetadata.ScopesSupported],
@@ -167,5 +224,4 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
167224
DpopBoundAccessTokensRequired = resourceMetadata.DpopBoundAccessTokensRequired
168225
};
169226
}
170-
171227
}

src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationOptions.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,13 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
88
/// </summary>
99
public class McpAuthenticationOptions : AuthenticationSchemeOptions
1010
{
11-
private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative);
12-
1311
/// <summary>
1412
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
1513
/// </summary>
1614
public McpAuthenticationOptions()
1715
{
1816
// "Bearer" is JwtBearerDefaults.AuthenticationScheme, but we don't have a reference to the JwtBearer package here.
1917
ForwardAuthenticate = "Bearer";
20-
ResourceMetadataUri = DefaultResourceMetadataUri;
2118
Events = new McpAuthenticationEvents();
2219
}
2320

@@ -35,8 +32,10 @@ public McpAuthenticationOptions()
3532
/// </summary>
3633
/// <remarks>
3734
/// This URI is included in the WWW-Authenticate header when a 401 response is returned.
35+
/// When <see langword="null"/>, the handler automatically uses the default
36+
/// <c>/.well-known/oauth-protected-resource/&lt;resource-path&gt;</c> endpoint that mirrors the requested resource path.
3837
/// </remarks>
39-
public Uri ResourceMetadataUri { get; set; }
38+
public Uri? ResourceMetadataUri { get; set; }
4039

4140
/// <summary>
4241
/// Gets or sets the protected resource metadata.

0 commit comments

Comments
 (0)