Skip to content

Commit 9702079

Browse files
authored
Fall back to well-known URL in client if 401 response is missing resource_metadata parameter (#1054)
1 parent d99f882 commit 9702079

File tree

19 files changed

+497
-134
lines changed

19 files changed

+497
-134
lines changed

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: 94 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
1111
/// Represents an authentication handler for MCP protocol that adds resource metadata to challenge responses
1212
/// and handles resource metadata endpoint requests.
1313
/// </summary>
14-
public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
14+
public partial 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>
@@ -25,67 +28,114 @@ public McpAuthenticationHandler(
2528
}
2629

2730
/// <inheritdoc />
28-
public async Task<bool> HandleRequestAsync()
31+
public 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 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 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+
};
62+
63+
if (Request.Host.Port is not null)
64+
{
65+
deriveResourceUriBuilder.Port = Request.Host.Port.Value;
66+
}
67+
68+
return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
69+
}
5370

5471
/// <summary>
5572
/// Gets the absolute URI for the resource metadata endpoint.
5673
/// </summary>
5774
private string GetAbsoluteResourceMetadataUri()
5875
{
59-
var resourceMetadataUri = Options.ResourceMetadataUri;
76+
if (Options.ResourceMetadataUri is Uri resourceMetadataUri)
77+
{
78+
if (resourceMetadataUri.IsAbsoluteUri)
79+
{
80+
return resourceMetadataUri.ToString();
81+
}
82+
83+
var separator = resourceMetadataUri.OriginalString.StartsWith('/') ? "" : "/";
84+
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{separator}{resourceMetadataUri.OriginalString}";
85+
}
86+
87+
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{DefaultResourceMetadataPath}{Request.Path}";
88+
}
89+
90+
private bool IsConfiguredEndpointRequest(Uri resourceMetadataUri)
91+
{
92+
var expectedPath = GetConfiguredResourceMetadataPath(resourceMetadataUri);
93+
94+
if (!string.Equals(Request.Path.Value, expectedPath, StringComparison.OrdinalIgnoreCase))
95+
{
96+
return false;
97+
}
98+
99+
if (!resourceMetadataUri.IsAbsoluteUri)
100+
{
101+
return true;
102+
}
60103

61-
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
104+
if (!string.Equals(Request.Host.Host, resourceMetadataUri.Host, StringComparison.OrdinalIgnoreCase))
105+
{
106+
LogResourceMetadataHostMismatch(Logger, resourceMetadataUri.Host);
107+
return false;
108+
}
62109

63-
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
110+
if (!string.Equals(Request.Scheme, resourceMetadataUri.Scheme, StringComparison.OrdinalIgnoreCase))
64111
{
65-
return currentPath;
112+
LogResourceMetadataSchemeMismatch(Logger, resourceMetadataUri.Scheme);
113+
return false;
66114
}
67115

68-
// For relative URIs, combine with the base URL
69-
string baseUrl = GetBaseUrl();
70-
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
116+
return true;
117+
}
71118

72-
if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
119+
private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
120+
{
121+
if (resourceMetadataUri.IsAbsoluteUri)
73122
{
74-
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
123+
return resourceMetadataUri.AbsolutePath;
75124
}
76125

77-
return absoluteUri.ToString();
126+
var path = resourceMetadataUri.OriginalString;
127+
return path.StartsWith('/') ? path : $"/{path}";
78128
}
79129

80-
private async Task<bool> HandleResourceMetadataRequestAsync()
130+
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
81131
{
82-
var resourceMetadata = Options.ResourceMetadata;
132+
var resourceMetadata = CloneResourceMetadata(Options.ResourceMetadata, derivedResourceUri);
83133

84134
if (Options.Events.OnResourceMetadataRequest is not null)
85135
{
86136
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
87137
{
88-
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
138+
ResourceMetadata = resourceMetadata,
89139
};
90140

91141
await Options.Events.OnResourceMetadataRequest(context);
@@ -109,11 +159,16 @@ private async Task<bool> HandleResourceMetadataRequestAsync()
109159
resourceMetadata = context.ResourceMetadata;
110160
}
111161

112-
if (resourceMetadata == null)
162+
if (resourceMetadata is null)
163+
{
164+
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
165+
}
166+
167+
resourceMetadata.Resource ??= derivedResourceUri;
168+
169+
if (resourceMetadata.Resource 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.Resource could not be determined. Please set McpAuthenticationOptions.ResourceMetadata.Resource or avoid setting a custom McpAuthenticationOptions.ResourceMetadataUri.");
117172
}
118173

119174
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
@@ -142,7 +197,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
142197
return base.HandleChallengeAsync(properties);
143198
}
144199

145-
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
200+
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata, Uri? derivedResourceUri = null)
146201
{
147202
if (resourceMetadata is null)
148203
{
@@ -151,7 +206,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
151206

152207
return new ProtectedResourceMetadata
153208
{
154-
Resource = resourceMetadata.Resource,
209+
Resource = resourceMetadata.Resource ?? derivedResourceUri,
155210
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
156211
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
157212
ScopesSupported = [.. resourceMetadata.ScopesSupported],
@@ -168,4 +223,9 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
168223
};
169224
}
170225

226+
[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request host did not match configured host '{ConfiguredHost}'.")]
227+
private static partial void LogResourceMetadataHostMismatch(ILogger logger, string configuredHost);
228+
229+
[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request scheme did not match configured scheme '{ConfiguredScheme}'.")]
230+
private static partial void LogResourceMetadataSchemeMismatch(ILogger logger, string configuredScheme);
171231
}

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)