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
70 changes: 0 additions & 70 deletions MCPify.sln

This file was deleted.

7 changes: 7 additions & 0 deletions MCPify.slnx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="MCPify/MCPify.csproj" />
<Project Path="Sample/MCPify.Sample.csproj" />
<Project Path="Tests/MCPify.Tests/MCPify.Tests.csproj" />
</Solution>
5 changes: 3 additions & 2 deletions MCPify/Core/Auth/OAuth2Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ namespace MCPify.Core.Auth;

public class OAuth2Configuration
{
public List<string> AuthorizationServers { 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<string, string> Scopes { get; set; } = new();
public string FlowType { get; set; } = string.Empty;
public string TokenUrl { get; set; } = string.Empty;
}
6 changes: 6 additions & 0 deletions MCPify/Core/McpifyOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ public class McpifyOptions
/// </summary>
public LocalEndpointsOptions? LocalEndpoints { get; set; }

/// <summary>
/// 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.
/// </summary>
public string? ResourceUrlOverride { get; set; }

/// <summary>
/// Configuration for importing external APIs via OpenAPI/Swagger as MCP tools.
/// </summary>
Expand Down
11 changes: 8 additions & 3 deletions MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 32 additions & 16 deletions MCPify/Hosting/McpifyEndpointExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,17 +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;

namespace MCPify.Hosting;

Expand Down Expand Up @@ -120,20 +114,42 @@ string BaseUrlProvider()
}

var addresses = server.Features.Get<IServerAddressesFeature>()?.Addresses;
var resourceUrl = opts.LocalEndpoints?.BaseUrlOverride ?? addresses?.FirstOrDefault() ?? Constants.DefaultBaseUrl;
var resourceUrl = opts.ResourceUrlOverride;
if (string.IsNullOrWhiteSpace(resourceUrl))
{
resourceUrl = opts.LocalEndpoints?.BaseUrlOverride;
}

// Extract potential issuer URLs from AuthorizationUrl
var issuers = configs.Select(c =>
if (string.IsNullOrWhiteSpace(resourceUrl))
{
if (Uri.TryCreate(c.AuthorizationUrl, UriKind.Absolute, out var uri))
resourceUrl = addresses?.FirstOrDefault();
}

resourceUrl = (string.IsNullOrWhiteSpace(resourceUrl) ? Constants.DefaultBaseUrl : resourceUrl).TrimEnd('/');

static IEnumerable<string> 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))
{
return uri.GetLeftPart(UriPartial.Authority);
yield return uri.GetLeftPart(UriPartial.Authority);
}
return null;
})
.Where(x => x != null)
.Distinct()
.ToList();
}

// Prefer explicitly configured authorization servers, fall back to derived authorities.
var issuers = configs
.SelectMany(ResolveAuthorizationServers)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();

return Results.Ok(new
{
Expand Down
1 change: 1 addition & 0 deletions Sample/Extensions/DemoServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
53 changes: 29 additions & 24 deletions Tests/MCPify.Tests/Integration/LoginToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,14 @@ public async Task LoginTool_ShouldPollAndReturnSuccess_WhenTokenAppears()
{
// Arrange
var services = new ServiceCollection();

var mockTokenStoreArg = new Mock<ISecureTokenStore>();
var mockAccessorArg = new Mock<IMcpContextAccessor>();

var mockAuth = new Mock<OAuthAuthorizationCodeAuthentication>(
"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<string>()))
.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<IMcpContextAccessor>(accessor);
services.AddSingleton<OAuthAuthorizationCodeAuthentication>(auth);
services.AddSingleton<ISecureTokenStore>(tokenStore);
services.AddSingleton<LoginTool>();

Expand Down Expand Up @@ -90,20 +83,14 @@ public async Task LoginTool_ShouldTimeout_WhenNoTokenAppears()
{
// Arrange
var services = new ServiceCollection();
var mockTokenStoreArg = new Mock<ISecureTokenStore>();
var mockAccessorArg = new Mock<IMcpContextAccessor>();

var mockAuth = new Mock<OAuthAuthorizationCodeAuthentication>(
"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<string>()))
.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<IMcpContextAccessor>(accessor);
services.AddSingleton<OAuthAuthorizationCodeAuthentication>(auth);
services.AddSingleton<ISecureTokenStore>(tokenStore);
services.AddSingleton<LoginTool>();

Expand All @@ -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";
}
}
68 changes: 65 additions & 3 deletions Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,6 +32,11 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured()
{
var authUrl = "https://auth.example.com/authorize";
var tokenUrl = "https://auth.example.com/token";
var authorizationServers = new[]
{
"https://auth.example.com/login/oauth",
"https://auth-backup.example.com/login/oauth"
};

using var host = await CreateHostAsync(services =>
{
Expand All @@ -39,6 +45,7 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured()
{
AuthorizationUrl = authUrl,
TokenUrl = tokenUrl,
AuthorizationServers = authorizationServers.ToList(),
Scopes = new Dictionary<string, string> { { "scope1", "desc" } }
});
});
Expand All @@ -50,11 +57,63 @@ public async Task GetMetadata_ReturnsMetadata_WhenOAuthConfigured()

var metadata = await response.Content.ReadFromJsonAsync<ProtectedResourceMetadata>();
Assert.NotNull(metadata);
Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers);
Assert.Equal(authorizationServers.OrderBy(server => server), metadata!.AuthorizationServers.OrderBy(server => server));
Assert.Contains("scope1", metadata.ScopesSupported);
}

private async Task<IHost> CreateHostAsync(Action<IServiceProvider>? configure = null)
[Fact]
public async Task GetMetadata_UsesResourceOverride_WhenConfigured()
{
var publicUrl = "https://public.example.com";

using var host = await CreateHostAsync(services =>
{
var store = services.GetRequiredService<OAuthConfigurationStore>();
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<ProtectedResourceMetadata>();
Assert.NotNull(metadata);
Assert.Equal(publicUrl, metadata!.Resource);
}

[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<OAuthConfigurationStore>();
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<ProtectedResourceMetadata>();
Assert.NotNull(metadata);
Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers);
}

private async Task<IHost> CreateHostAsync(Action<IServiceProvider>? configure = null, Action<McpifyOptions>? configureOptions = null)
{
return await new HostBuilder()
.ConfigureWebHost(webBuilder =>
Expand All @@ -63,7 +122,10 @@ private async Task<IHost> CreateHostAsync(Action<IServiceProvider>? configure =
.UseTestServer()
.ConfigureServices(services =>
{
services.AddMcpify(options => { });
services.AddMcpify(options =>
{
configureOptions?.Invoke(options);
});
services.AddLogging();
services.AddRouting();
})
Expand Down
Loading