From dc21924b380df7842328ffef813baf408ef7ff73 Mon Sep 17 00:00:00 2001 From: Erwin Kramer Date: Tue, 13 Jan 2026 14:20:43 +0100 Subject: [PATCH 1/7] solving https://github.com/abdebek/MCPify/issues/10 --- MCPify/Core/Auth/OAuth2Configuration.cs | 5 +- MCPify/Hosting/McpifyEndpointExtensions.cs | 31 +++++++---- .../Integration/LoginToolTests.cs | 53 ++++++++++--------- .../Integration/OAuthMetadataEndpointTests.cs | 28 +++++++++- 4 files changed, 79 insertions(+), 38 deletions(-) diff --git a/MCPify/Core/Auth/OAuth2Configuration.cs b/MCPify/Core/Auth/OAuth2Configuration.cs index 32b50fa..6cca59d 100644 --- a/MCPify/Core/Auth/OAuth2Configuration.cs +++ b/MCPify/Core/Auth/OAuth2Configuration.cs @@ -2,9 +2,10 @@ namespace MCPify.Core.Auth; public class OAuth2Configuration { + public string? AuthorizationServer { get; set; } public string AuthorizationUrl { get; set; } = string.Empty; - public string TokenUrl { get; set; } = string.Empty; + public string FlowType { get; set; } = string.Empty; public string? RefreshUrl { get; set; } public Dictionary Scopes { get; set; } = new(); - public string FlowType { get; set; } = string.Empty; + public string TokenUrl { get; set; } = string.Empty; } diff --git a/MCPify/Hosting/McpifyEndpointExtensions.cs b/MCPify/Hosting/McpifyEndpointExtensions.cs index 9a304a7..eeab1b7 100644 --- a/MCPify/Hosting/McpifyEndpointExtensions.cs +++ b/MCPify/Hosting/McpifyEndpointExtensions.cs @@ -12,6 +12,7 @@ using MCPify.Schema; using System.Net.Http; using Microsoft.AspNetCore.Http; +using System; namespace MCPify.Hosting; @@ -122,18 +123,26 @@ string BaseUrlProvider() var addresses = server.Features.Get()?.Addresses; var resourceUrl = opts.LocalEndpoints?.BaseUrlOverride ?? addresses?.FirstOrDefault() ?? Constants.DefaultBaseUrl; - // Extract potential issuer URLs from AuthorizationUrl - var issuers = configs.Select(c => - { - if (Uri.TryCreate(c.AuthorizationUrl, UriKind.Absolute, out var uri)) + // Prefer explicitly configured authorization servers, fall back to derived authorities. + var issuers = configs + .Select(c => { - return uri.GetLeftPart(UriPartial.Authority); - } - return null; - }) - .Where(x => x != null) - .Distinct() - .ToList(); + if (!string.IsNullOrWhiteSpace(c.AuthorizationServer)) + { + return c.AuthorizationServer; + } + + if (Uri.TryCreate(c.AuthorizationUrl, UriKind.Absolute, out var uri)) + { + return uri.GetLeftPart(UriPartial.Authority); + } + + return null; + }) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); return Results.Ok(new { diff --git a/Tests/MCPify.Tests/Integration/LoginToolTests.cs b/Tests/MCPify.Tests/Integration/LoginToolTests.cs index 12e563b..15f429a 100644 --- a/Tests/MCPify.Tests/Integration/LoginToolTests.cs +++ b/Tests/MCPify.Tests/Integration/LoginToolTests.cs @@ -44,21 +44,14 @@ public async Task LoginTool_ShouldPollAndReturnSuccess_WhenTokenAppears() { // Arrange var services = new ServiceCollection(); - - var mockTokenStoreArg = new Mock(); - var mockAccessorArg = new Mock(); - - var mockAuth = new Mock( - "client", "http://auth", "http://token", "scope", - mockTokenStoreArg.Object, mockAccessorArg.Object, - null, null, "http://callback", null, false, null, null, false); - - mockAuth.Setup(x => x.BuildAuthorizationUrl(It.IsAny())) - .Returns("http://auth/authorize?foo=bar"); var tokenStore = new InMemoryTokenStore(); - - services.AddSingleton(mockAuth.Object); + var accessor = new MockMcpContextAccessor { SessionId = "default" }; + var auth = new StubOAuthAuthorization(tokenStore, accessor); + + services.AddSingleton(accessor); + services.AddSingleton(accessor); + services.AddSingleton(auth); services.AddSingleton(tokenStore); services.AddSingleton(); @@ -90,20 +83,14 @@ public async Task LoginTool_ShouldTimeout_WhenNoTokenAppears() { // Arrange var services = new ServiceCollection(); - var mockTokenStoreArg = new Mock(); - var mockAccessorArg = new Mock(); - - var mockAuth = new Mock( - "client", "http://auth", "http://token", "scope", - mockTokenStoreArg.Object, mockAccessorArg.Object, - null, null, "http://callback", null, false, null, null, false); - - mockAuth.Setup(x => x.BuildAuthorizationUrl(It.IsAny())) - .Returns("http://auth/authorize?foo=bar"); var tokenStore = new InMemoryTokenStore(); + var accessor = new MockMcpContextAccessor { SessionId = "default" }; + var auth = new StubOAuthAuthorization(tokenStore, accessor); - services.AddSingleton(mockAuth.Object); + services.AddSingleton(accessor); + services.AddSingleton(accessor); + services.AddSingleton(auth); services.AddSingleton(tokenStore); services.AddSingleton(); @@ -127,4 +114,22 @@ public async Task LoginTool_ShouldTimeout_WhenNoTokenAppears() Assert.DoesNotContain("Login successful", textContent.Text); Assert.Contains("http://auth/authorize?foo=bar", textContent.Text); } + + private sealed class StubOAuthAuthorization : OAuthAuthorizationCodeAuthentication + { + public StubOAuthAuthorization(ISecureTokenStore store, IMcpContextAccessor accessor) + : base( + "client", + "http://auth", + "http://token", + "scope", + store, + accessor, + redirectUri: "http://callback", + stateSecret: "test-secret") + { + } + + public override string BuildAuthorizationUrl(string sessionId) => "http://auth/authorize?foo=bar"; + } } \ No newline at end of file diff --git a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs index bfc9395..9f72275 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs @@ -31,6 +31,7 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() { var authUrl = "https://auth.example.com/authorize"; var tokenUrl = "https://auth.example.com/token"; + var authorizationServer = "https://auth.example.com/login/oauth"; using var host = await CreateHostAsync(services => { @@ -39,6 +40,7 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() { AuthorizationUrl = authUrl, TokenUrl = tokenUrl, + AuthorizationServer = authorizationServer, Scopes = new Dictionary { { "scope1", "desc" } } }); }); @@ -50,10 +52,34 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() var metadata = await response.Content.ReadFromJsonAsync(); Assert.NotNull(metadata); - Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers); + Assert.Contains(authorizationServer, metadata!.AuthorizationServers); Assert.Contains("scope1", metadata.ScopesSupported); } + [Fact] + public async Task GetMetadata_FallsBackToAuthorizationUrlAuthority_WhenAuthorizationServerMissing() + { + var authUrl = "https://auth.example.com/oauth2/v2.0/authorize"; + + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = authUrl + }); + }); + + var client = host.GetTestClient(); + + var response = await client.GetAsync("/.well-known/oauth-protected-resource"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var metadata = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(metadata); + Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers); + } + private async Task CreateHostAsync(Action? configure = null) { return await new HostBuilder() From 97c575f8a278e9fdf509e0d4db7ad24b16986136 Mon Sep 17 00:00:00 2001 From: Erwin Kramer Date: Tue, 13 Jan 2026 15:34:58 +0100 Subject: [PATCH 2/7] fix https://github.com/abdebek/MCPify/issues/11 --- MCPify/Core/McpifyOptions.cs | 6 ++++ .../McpOAuthAuthenticationMiddleware.cs | 11 ++++-- MCPify/Hosting/McpifyEndpointExtensions.cs | 13 ++++++- Sample/Extensions/DemoServiceExtensions.cs | 1 + .../Integration/OAuthMetadataEndpointTests.cs | 35 +++++++++++++++++-- .../Integration/OAuthMiddlewareTests.cs | 31 ++++++++++++++-- 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/MCPify/Core/McpifyOptions.cs b/MCPify/Core/McpifyOptions.cs index 8396086..5ba59d2 100644 --- a/MCPify/Core/McpifyOptions.cs +++ b/MCPify/Core/McpifyOptions.cs @@ -21,6 +21,12 @@ public class McpifyOptions /// public LocalEndpointsOptions? LocalEndpoints { get; set; } + /// + /// Explicit URL advertised to MCP clients for OAuth resource metadata and challenges. + /// Allows publishing a proxy-facing URL that differs from the server's listen address. + /// + public string? ResourceUrlOverride { get; set; } + /// /// Configuration for importing external APIs via OpenAPI/Swagger as MCP tools. /// diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs index ed57d3f..ffd3885 100644 --- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs +++ b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs @@ -46,10 +46,15 @@ public async Task InvokeAsync(HttpContext context) if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { // Challenge - var resourceUrl = $"{context.Request.Scheme}://{context.Request.Host}"; - if (options?.LocalEndpoints?.BaseUrlOverride != null) + var resourceUrl = options?.ResourceUrlOverride; + if (string.IsNullOrWhiteSpace(resourceUrl)) { - resourceUrl = options.LocalEndpoints.BaseUrlOverride; + resourceUrl = options?.LocalEndpoints?.BaseUrlOverride; + } + + if (string.IsNullOrWhiteSpace(resourceUrl)) + { + resourceUrl = $"{context.Request.Scheme}://{context.Request.Host}"; } // Ensure resourceUrl does not end with slash for concatenation consistency, though URLs handle it. diff --git a/MCPify/Hosting/McpifyEndpointExtensions.cs b/MCPify/Hosting/McpifyEndpointExtensions.cs index eeab1b7..9bc6503 100644 --- a/MCPify/Hosting/McpifyEndpointExtensions.cs +++ b/MCPify/Hosting/McpifyEndpointExtensions.cs @@ -121,7 +121,18 @@ string BaseUrlProvider() } var addresses = server.Features.Get()?.Addresses; - var resourceUrl = opts.LocalEndpoints?.BaseUrlOverride ?? addresses?.FirstOrDefault() ?? Constants.DefaultBaseUrl; + var resourceUrl = opts.ResourceUrlOverride; + if (string.IsNullOrWhiteSpace(resourceUrl)) + { + resourceUrl = opts.LocalEndpoints?.BaseUrlOverride; + } + + if (string.IsNullOrWhiteSpace(resourceUrl)) + { + resourceUrl = addresses?.FirstOrDefault(); + } + + resourceUrl = (string.IsNullOrWhiteSpace(resourceUrl) ? Constants.DefaultBaseUrl : resourceUrl).TrimEnd('/'); // Prefer explicitly configured authorization servers, fall back to derived authorities. var issuers = configs diff --git a/Sample/Extensions/DemoServiceExtensions.cs b/Sample/Extensions/DemoServiceExtensions.cs index f961417..8f53b4d 100644 --- a/Sample/Extensions/DemoServiceExtensions.cs +++ b/Sample/Extensions/DemoServiceExtensions.cs @@ -141,6 +141,7 @@ public static IServiceCollection AddDemoMcpify(this IServiceCollection services, services.AddMcpify(options => { options.Transport = transport; + options.ResourceUrlOverride = baseUrl; // Expose the local API (which is now the "Real" API) options.LocalEndpoints = new() diff --git a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs index 9f72275..0342fd9 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs @@ -56,6 +56,34 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() Assert.Contains("scope1", metadata.ScopesSupported); } + [Fact] + public async Task GetMetadata_UsesResourceOverride_WhenConfigured() + { + var publicUrl = "https://public.example.com"; + + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/oauth2/v2.0/authorize", + TokenUrl = "https://auth.example.com/oauth2/v2.0/token" + }); + }, options => + { + options.ResourceUrlOverride = publicUrl; + }); + + var client = host.GetTestClient(); + + var response = await client.GetAsync("/.well-known/oauth-protected-resource"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var metadata = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(metadata); + Assert.Equal(publicUrl, metadata!.Resource); + } + [Fact] public async Task GetMetadata_FallsBackToAuthorizationUrlAuthority_WhenAuthorizationServerMissing() { @@ -80,7 +108,7 @@ public async Task GetMetadata_FallsBackToAuthorizationUrlAuthority_WhenAuthoriza Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers); } - private async Task CreateHostAsync(Action? configure = null) + private async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) { return await new HostBuilder() .ConfigureWebHost(webBuilder => @@ -89,7 +117,10 @@ private async Task CreateHostAsync(Action? configure = .UseTestServer() .ConfigureServices(services => { - services.AddMcpify(options => { }); + services.AddMcpify(options => + { + configureOptions?.Invoke(options); + }); services.AddLogging(); services.AddRouting(); }) diff --git a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs index 242031a..37653ac 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs @@ -32,6 +32,30 @@ public async Task Request_Returns401_WhenNoToken_And_OAuthConfigured() Assert.Contains("resource_metadata_url", authHeader); } + [Fact] + public async Task Request_Challenge_UsesResourceOverride() + { + var publicUrl = "https://proxy.example.com"; + + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + }, options => + { + options.ResourceUrlOverride = publicUrl; + }); + + var client = host.GetTestClient(); + + var response = await client.GetAsync("/mcp"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var authHeader = response.Headers.WwwAuthenticate.ToString(); + Assert.Contains($"resource=\"{publicUrl}\"", authHeader); + Assert.Contains($"resource_metadata_url=\"{publicUrl}/.well-known/oauth-protected-resource\"", authHeader); + } + [Fact] public async Task Request_Returns200_WhenTokenPresent() { @@ -60,7 +84,7 @@ public async Task Request_Returns200_WhenNoOAuthConfigured() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task CreateHostAsync(Action? configure = null) + private async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) { return await new HostBuilder() .ConfigureWebHost(webBuilder => @@ -69,7 +93,10 @@ private async Task CreateHostAsync(Action? configure = .UseTestServer() .ConfigureServices(services => { - services.AddMcpify(options => { }); + services.AddMcpify(options => + { + configureOptions?.Invoke(options); + }); services.AddLogging(); }) .Configure(app => From 7c8c91d8aa462cfbf2a47b8d33cadf4e59334697 Mon Sep 17 00:00:00 2001 From: Erwin Kramer Date: Tue, 13 Jan 2026 16:31:19 +0100 Subject: [PATCH 3/7] plural authorizationServers --- MCPify.slnx | 11 ++++++ MCPify/Core/Auth/OAuth2Configuration.cs | 2 +- MCPify/Hosting/McpifyEndpointExtensions.cs | 36 +++++++++---------- .../Integration/OAuthMetadataEndpointTests.cs | 11 ++++-- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/MCPify.slnx b/MCPify.slnx index af45f94..7da721a 100644 --- a/MCPify.slnx +++ b/MCPify.slnx @@ -1,3 +1,14 @@ + + + + + + + + + + + diff --git a/MCPify/Core/Auth/OAuth2Configuration.cs b/MCPify/Core/Auth/OAuth2Configuration.cs index 6cca59d..628279f 100644 --- a/MCPify/Core/Auth/OAuth2Configuration.cs +++ b/MCPify/Core/Auth/OAuth2Configuration.cs @@ -2,7 +2,7 @@ namespace MCPify.Core.Auth; public class OAuth2Configuration { - public string? AuthorizationServer { get; set; } + public List AuthorizationServers { get; set; } = []; public string AuthorizationUrl { get; set; } = string.Empty; public string FlowType { get; set; } = string.Empty; public string? RefreshUrl { get; set; } diff --git a/MCPify/Hosting/McpifyEndpointExtensions.cs b/MCPify/Hosting/McpifyEndpointExtensions.cs index 9bc6503..c01e37f 100644 --- a/MCPify/Hosting/McpifyEndpointExtensions.cs +++ b/MCPify/Hosting/McpifyEndpointExtensions.cs @@ -1,18 +1,11 @@ using MCPify.Core; using MCPify.Core.Auth; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using MCPify.Endpoints; using MCPify.Tools; using MCPify.Schema; -using System.Net.Http; -using Microsoft.AspNetCore.Http; -using System; namespace MCPify.Hosting; @@ -134,24 +127,27 @@ string BaseUrlProvider() resourceUrl = (string.IsNullOrWhiteSpace(resourceUrl) ? Constants.DefaultBaseUrl : resourceUrl).TrimEnd('/'); - // Prefer explicitly configured authorization servers, fall back to derived authorities. - var issuers = configs - .Select(c => + static IEnumerable ResolveAuthorizationServers(OAuth2Configuration config) + { + if (config.AuthorizationServers.Count > 0) { - if (!string.IsNullOrWhiteSpace(c.AuthorizationServer)) + foreach (var server in config.AuthorizationServers) { - return c.AuthorizationServer; + yield return server; } - if (Uri.TryCreate(c.AuthorizationUrl, UriKind.Absolute, out var uri)) - { - return uri.GetLeftPart(UriPartial.Authority); - } + yield break; + } - return null; - }) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Select(x => x!.Trim()) + if (Uri.TryCreate(config.AuthorizationUrl, UriKind.Absolute, out var uri)) + { + yield return uri.GetLeftPart(UriPartial.Authority); + } + } + + // Prefer explicitly configured authorization servers, fall back to derived authorities. + var issuers = configs + .SelectMany(ResolveAuthorizationServers) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); diff --git a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs index 0342fd9..c483dfa 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Linq; using MCPify.Core; using MCPify.Core.Auth; using MCPify.Hosting; @@ -31,7 +32,11 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() { var authUrl = "https://auth.example.com/authorize"; var tokenUrl = "https://auth.example.com/token"; - var authorizationServer = "https://auth.example.com/login/oauth"; + var authorizationServers = new[] + { + "https://auth.example.com/login/oauth", + "https://auth-backup.example.com/login/oauth" + }; using var host = await CreateHostAsync(services => { @@ -40,7 +45,7 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() { AuthorizationUrl = authUrl, TokenUrl = tokenUrl, - AuthorizationServer = authorizationServer, + AuthorizationServers = authorizationServers.ToList(), Scopes = new Dictionary { { "scope1", "desc" } } }); }); @@ -52,7 +57,7 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured() var metadata = await response.Content.ReadFromJsonAsync(); Assert.NotNull(metadata); - Assert.Contains(authorizationServer, metadata!.AuthorizationServers); + Assert.Equal(authorizationServers.OrderBy(server => server), metadata!.AuthorizationServers.OrderBy(server => server)); Assert.Contains("scope1", metadata.ScopesSupported); } From ab6d92ed1e7b165ab71864349c8b623edd7e32cc Mon Sep 17 00:00:00 2001 From: Erwin Kramer Date: Tue, 13 Jan 2026 16:46:55 +0100 Subject: [PATCH 4/7] fully replace sln with a working slnx --- MCPify.sln | 70 ----------------------------------------------------- MCPify.slnx | 8 ++---- 2 files changed, 2 insertions(+), 76 deletions(-) delete mode 100644 MCPify.sln diff --git a/MCPify.sln b/MCPify.sln deleted file mode 100644 index aebcd99..0000000 --- a/MCPify.sln +++ /dev/null @@ -1,70 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPify", "MCPify\MCPify.csproj", "{02D8DD50-79F6-473D-A181-FD2D0BAF5F63}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sample", "Sample", "{4F9DEAE5-078F-E77A-2E4A-FEB6FFE226FF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPify.Sample", "Sample\MCPify.Sample.csproj", "{A413000E-F6BC-4872-AE44-E5F256B95695}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPify.Tests", "Tests\MCPify.Tests\MCPify.Tests.csproj", "{C1666E11-EB7E-44DC-8176-03741810F656}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Debug|Any CPU.Build.0 = Debug|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Debug|x64.ActiveCfg = Debug|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Debug|x64.Build.0 = Debug|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Debug|x86.ActiveCfg = Debug|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Debug|x86.Build.0 = Debug|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Release|Any CPU.ActiveCfg = Release|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Release|Any CPU.Build.0 = Release|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Release|x64.ActiveCfg = Release|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Release|x64.Build.0 = Release|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Release|x86.ActiveCfg = Release|Any CPU - {02D8DD50-79F6-473D-A181-FD2D0BAF5F63}.Release|x86.Build.0 = Release|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Debug|x64.ActiveCfg = Debug|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Debug|x64.Build.0 = Debug|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Debug|x86.ActiveCfg = Debug|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Debug|x86.Build.0 = Debug|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Release|Any CPU.Build.0 = Release|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Release|x64.ActiveCfg = Release|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Release|x64.Build.0 = Release|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Release|x86.ActiveCfg = Release|Any CPU - {A413000E-F6BC-4872-AE44-E5F256B95695}.Release|x86.Build.0 = Release|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Debug|x64.ActiveCfg = Debug|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Debug|x64.Build.0 = Debug|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Debug|x86.ActiveCfg = Debug|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Debug|x86.Build.0 = Debug|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Release|Any CPU.Build.0 = Release|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Release|x64.ActiveCfg = Release|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Release|x64.Build.0 = Release|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Release|x86.ActiveCfg = Release|Any CPU - {C1666E11-EB7E-44DC-8176-03741810F656}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {A413000E-F6BC-4872-AE44-E5F256B95695} = {4F9DEAE5-078F-E77A-2E4A-FEB6FFE226FF} - {C1666E11-EB7E-44DC-8176-03741810F656} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - EndGlobalSection -EndGlobal diff --git a/MCPify.slnx b/MCPify.slnx index 7da721a..b2b7eb1 100644 --- a/MCPify.slnx +++ b/MCPify.slnx @@ -4,11 +4,7 @@ - - - - - - + + From 4f5d3995e1dc22b15d469913b439c74172f37192 Mon Sep 17 00:00:00 2001 From: Erwin Date: Fri, 16 Jan 2026 23:43:48 +0100 Subject: [PATCH 5/7] allow `OAuth2Configuration` list in `McpifyOptions` --- MCPify/Core/McpifyOptions.cs | 5 +++++ MCPify/Hosting/McpifyServiceExtensions.cs | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/MCPify/Core/McpifyOptions.cs b/MCPify/Core/McpifyOptions.cs index c769539..0b728c0 100644 --- a/MCPify/Core/McpifyOptions.cs +++ b/MCPify/Core/McpifyOptions.cs @@ -64,6 +64,11 @@ public class McpifyOptions /// Defaults to which detects headless environments at runtime. /// public BrowserLaunchBehavior LoginBrowserBehavior { get; set; } = BrowserLaunchBehavior.Auto; + + /// + /// Optional list of OAuth2 configurations to be added to the OAuthConfigurationStore. + /// + public List OAuthConfigurations { get; set; } = new(); } /// diff --git a/MCPify/Hosting/McpifyServiceExtensions.cs b/MCPify/Hosting/McpifyServiceExtensions.cs index 23a57c1..b79f0a2 100644 --- a/MCPify/Hosting/McpifyServiceExtensions.cs +++ b/MCPify/Hosting/McpifyServiceExtensions.cs @@ -95,7 +95,13 @@ public static IServiceCollection AddMcpify( }); services.AddSingleton(); - services.AddSingleton(); + + var oauthStore = new OAuthConfigurationStore(); + foreach (var config in opts.OAuthConfigurations) + { + oauthStore.AddConfiguration(config); + } + services.AddSingleton(oauthStore); return services; } From 2dc1be818438efe1c17806b7380f7be0fb32b5ad Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 17 Jan 2026 00:18:44 +0100 Subject: [PATCH 6/7] If any config has AuthorizationServers, only use those --- MCPify/Hosting/McpifyEndpointExtensions.cs | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/MCPify/Hosting/McpifyEndpointExtensions.cs b/MCPify/Hosting/McpifyEndpointExtensions.cs index c01e37f..9837f3b 100644 --- a/MCPify/Hosting/McpifyEndpointExtensions.cs +++ b/MCPify/Hosting/McpifyEndpointExtensions.cs @@ -129,27 +129,29 @@ string BaseUrlProvider() static IEnumerable ResolveAuthorizationServers(OAuth2Configuration config) { - if (config.AuthorizationServers.Count > 0) - { - foreach (var server in config.AuthorizationServers) - { - yield return server; - } - - yield break; - } - if (Uri.TryCreate(config.AuthorizationUrl, UriKind.Absolute, out var uri)) { yield return uri.GetLeftPart(UriPartial.Authority); } } - // Prefer explicitly configured authorization servers, fall back to derived authorities. - var issuers = configs - .SelectMany(ResolveAuthorizationServers) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + // If any config has AuthorizationServers, only use those; otherwise, fall back to AuthorizationUrl authority. + List issuers; + if (configs.Any(c => c.AuthorizationServers?.Any() == true)) + { + issuers = configs + .Where(c => c.AuthorizationServers?.Any() == true) + .SelectMany(c => c.AuthorizationServers) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + else + { + issuers = configs + .SelectMany(ResolveAuthorizationServers) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } return Results.Ok(new { From 218616ccb12bcbfcb510696b63f53fe8a42c796d Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 17 Jan 2026 00:53:37 +0100 Subject: [PATCH 7/7] metadata instead of metadata_url --- MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs | 2 +- Sample/README.md | 2 +- Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs index ffd3885..ed5c35c 100644 --- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs +++ b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs @@ -63,7 +63,7 @@ public async Task InvokeAsync(HttpContext context) context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers[HeaderNames.WWWAuthenticate] = - $"Bearer realm=\"MCPify\", resource=\"{resourceUrl}\", resource_metadata_url=\"{metadataUrl}\""; + $"Bearer realm=\"MCPify\", resource=\"{resourceUrl}\", resource_metadata=\"{metadataUrl}\""; return; } diff --git a/Sample/README.md b/Sample/README.md index 3a874f6..bf13419 100644 --- a/Sample/README.md +++ b/Sample/README.md @@ -80,7 +80,7 @@ To run the server in HTTP mode (using Server-Sent Events): This sample demonstrates how clients can authenticate with MCPify using OAuth 2.0 Authorization Code flow. -1. **Discover Authentication**: When an unauthenticated client attempts to use a protected tool (e.g., `api_secrets_get`), MCPify will respond with a `401 Unauthorized` HTTP status code and a `WWW-Authenticate` header, including `resource_metadata_url`. The client should then fetch this metadata. +1. **Discover Authentication**: When an unauthenticated client attempts to use a protected tool (e.g., `api_secrets_get`), MCPify will respond with a `401 Unauthorized` HTTP status code and a `WWW-Authenticate` header, including `resource_metadata`. The client should then fetch this metadata. 2. **Initiate Login**: The client (e.g., Claude Desktop) will call the `login_auth_code_pkce` tool provided by MCPify. This tool returns an authorization URL. 3. **User Authorization**: The user opens the authorization URL in a browser, logs in (using the OpenIddict provider in this sample), and grants consent. 4. **Callback and Token Exchange**: After user authorization, the browser redirects to MCPify's callback endpoint (`/auth/callback`). MCPify handles the code exchange and stores the token securely for the specific session. diff --git a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs index 37653ac..04cfe31 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs @@ -29,7 +29,7 @@ public async Task Request_Returns401_WhenNoToken_And_OAuthConfigured() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains("WWW-Authenticate", response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)).Keys); var authHeader = response.Headers.WwwAuthenticate.ToString(); - Assert.Contains("resource_metadata_url", authHeader); + Assert.Contains("resource_metadata", authHeader); } [Fact] @@ -53,7 +53,7 @@ public async Task Request_Challenge_UsesResourceOverride() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); var authHeader = response.Headers.WwwAuthenticate.ToString(); Assert.Contains($"resource=\"{publicUrl}\"", authHeader); - Assert.Contains($"resource_metadata_url=\"{publicUrl}/.well-known/oauth-protected-resource\"", authHeader); + Assert.Contains($"resource_metadata=\"{publicUrl}/.well-known/oauth-protected-resource\"", authHeader); } [Fact]