Skip to content

Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) #377

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 206 commits into from
Jul 3, 2025

Conversation

localden
Copy link
Collaborator

@localden localden commented May 2, 2025

Implements the authorization flow for clients and servers, per specification. Instead of re-implementing everything from scratch, this follows the suggestions from #349 and uses the native ASP.NET Core constructs to handle post-discovery steps server-side.

Developer experience

Server

using System.Net.Http.Headers;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Types.Authentication;
using ProtectedMCPServer.Tools;

var builder = WebApplication.CreateBuilder(args);

var serverUrl = "http://localhost:7071/";
var tenantId = "a2213e1c-e51e-4304-9a0d-effe57f31655";
var instance = "https://login.microsoftonline.com/";

builder.Services.AddAuthentication(options =>
{
    options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.Authority = $"{instance}{tenantId}/v2.0";
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidAudience = "167b4284-3f92-4436-92ed-38b38f83ae08",
        ValidIssuer = $"{instance}{tenantId}/v2.0",
        NameClaimType = "name",
        RoleClaimType = "roles"
    };

    options.MetadataAddress = $"{instance}{tenantId}/v2.0/.well-known/openid-configuration";

    options.Events = new JwtBearerEvents
    {
        OnTokenValidated = context =>
        {
            var name = context.Principal?.Identity?.Name ?? "unknown";
            var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
            Console.WriteLine($"Token validated for: {name} ({email})");
            return Task.CompletedTask;
        },
        OnAuthenticationFailed = context =>
        {
            Console.WriteLine($"Authentication failed: {context.Exception.Message}");
            return Task.CompletedTask;
        },
        OnChallenge = context =>
        {
            Console.WriteLine($"Challenging client to authenticate with Entra ID");
            return Task.CompletedTask;
        }
    };
})
.AddMcp(options =>
{
    options.ResourceMetadataProvider = context => 
    {
        var metadata = new ProtectedResourceMetadata
        {
            BearerMethodsSupported = { "header" },
            ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
            AuthorizationServers = { new Uri($"{instance}{tenantId}/v2.0") }
        };

        metadata.ScopesSupported.AddRange(new[] {
            "api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read" 
        });
        
        return metadata;
    };
});

builder.Services.AddAuthorization();

builder.Services.AddHttpContextAccessor();
builder.Services.AddMcpServer()
.WithTools<WeatherTools>()
.WithHttpTransport();

builder.Services.AddSingleton(_ =>
{
    var client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") };
    client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
    return client;
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// Use the default MCP policy name that we've configured
app.MapMcp().RequireAuthorization();

Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
Console.WriteLine($"PRM Document URL: {serverUrl}.well-known/oauth-protected-resource");
Console.WriteLine("Press Ctrl+C to stop the server");

app.Run(serverUrl);

HTTP context in tools

.AddHttpContextAccessor is used to ensure that tools can access the HTTP context (such as the authorization header contents).

Tools that want to use the HTTP context will need to amend their signatures to include a reference to IHttpContextAccessor, like this:

[McpServerTool, Description("Get weather alerts for a US state.")]
public static async Task<string> GetAlerts(
    HttpClient client,
    IHttpContextAccessor httpContextAccessor,
    [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)

Client

using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;

namespace ProtectedMCPClient;

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Protected MCP Weather Server");
        Console.WriteLine();

        var serverUrl = "http://localhost:7071/sse";

        var tokenProvider = new BasicOAuthAuthorizationProvider(
            new Uri(serverUrl), 
            clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8",
            redirectUri: new Uri("http://localhost:1179/callback"),
            scopes: ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"]
        );

        Console.WriteLine();
        Console.WriteLine($"Connecting to weather server at {serverUrl}...");

        try
        {
            var transportOptions = new SseClientTransportOptions
            {
                Endpoint = new Uri(serverUrl),
                Name = "Secure Weather Client"
            };

            var transport = new SseClientTransport(transportOptions, tokenProvider);
            var client = await McpClientFactory.CreateAsync(transport);

            var tools = await client.ListToolsAsync();
            if (tools.Count == 0)
            {
                Console.WriteLine("No tools available on the server.");
                return;
            }

            Console.WriteLine($"Found {tools.Count} tools on the server.");
            Console.WriteLine();

            if (tools.Any(t => t.Name == "GetAlerts"))
            {
                Console.WriteLine("Calling GetAlerts tool...");

                var result = await client.CallToolAsync(
                    "GetAlerts",
                    new Dictionary<string, object?> { { "state", "WA" } }
                );

                Console.WriteLine("Result: " + result.Content[0].Text);
                Console.WriteLine();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
            if (ex.InnerException != null)
            {
                Console.WriteLine($"Inner error: {ex.InnerException.Message}");
            }

            #if DEBUG
            Console.WriteLine($"Stack trace: {ex.StackTrace}");
            #endif
        }

        Console.WriteLine("Press any key to exit...");
        Console.ReadKey();
    }
}

Fixes #521

@localden localden marked this pull request as draft May 2, 2025 06:38
@anktsrkr
Copy link

anktsrkr commented Jul 1, 2025

Hi!
I was trying to configure https://mcp.atlassian.com/v1/sse which seems like following 2025-03-26 specification, but it gives me The WWW-Authenticate header does not contain a resource_metadata parameter and this is obvious error for the latest spec but since it is using old one IMO, it should continue with authorization server metadata.

I tried by passing ProtocolVersion in McpClientOptions but it is not considered in PerformOAuthAuthorizationAsync so it ended up calling ExtractProtectedResourceMetadata and failed.

Below is the sc to show how mcp-remote handles it -
image

and I think as first party SDK this should also consider old specs

Thanks,
Ankit S

@localden
Copy link
Collaborator Author

localden commented Jul 3, 2025

Hello! I see typescript sdk has already supported, when will you plan to merge?

Aiming for a merge in the next couple of days. Need to run a few extra tests.

@anktsrkr
Copy link

anktsrkr commented Jul 3, 2025

@localden I was trying to integrate with Auth0, and having some issue. I am using Client Id / Secrect so Dynamic Client Registration is off.

As per the documentation authorization-code-flow-with-pkce we should pass audience however from ClientOAuthOptions there is no option.

As of now, my soln is like below, which works -

ClientOAuthOptions.cs
public Dictionary<string,string>? ExtraParams { get; set; } // Added

BuildAuthorizationUrl in ClientOAuthProvider.cs

 if (_extraParams?.Count>0)
 {
     foreach (var keyValuePair in _extraParams)
     {
         queryParams[keyValuePair.Key] = keyValuePair.Value;

     }
 }

ExchangeCodeForTokenAsync in ClientOAuthProvider.cs

 Dictionary<string, string> contentDictionary = new Dictionary<string, string>
{
    ["grant_type"] = "authorization_code",
    ["code"] = authorizationCode,
    ["redirect_uri"] = _redirectUri.ToString(),
    ["client_id"] = GetClientIdOrThrow(),
    ["code_verifier"] = codeVerifier,
    ["client_secret"] = _clientSecret ?? string.Empty,
    ["resource"] = protectedResourceMetadata.Resource.ToString(),
};

if (_extraParams?.Count >0)
{
    contentDictionary = [.. contentDictionary, .. _extraParams];
}

essentially add the above block whereever required.

and finally from Client

var sseClientTransportOptions = new SseClientTransport(new SseClientTransportOptions
	{
		Endpoint = new(model.Url),
		TransportMode = HttpTransportMode.Sse,
		AdditionalHeaders = model.Headers.ToDictionary(x => x.Key, x => x.Value),
		OAuth = new()
		{
			ClientId = "my_client_id",
			ClientSecret = "my_client_secret,
			RedirectUri = new Uri("https://localhost:7020/callback"),
			AuthorizationRedirectDelegate = OAuthHandler.HandleAuthorizationUrlAsync,
			ExtraParams = new Dictionary<string, string>
			{
				{"audience", "my_audience"}
			}
		}
	});

potentially, ExtraParams could be used for state param as well. Is there any other way which already support but I missed?

Another question in slightly different note, How do I get AccessToken and RefreshToken which we received from auth server for that McpTool. My scenario is - User will authenticate MCPtool in client but tool will be invoked from SignalR hub using SK, I would like to pass the same accesstoken in header which i received from client

@halter73
Copy link
Contributor

halter73 commented Jul 3, 2025

@anktsrkr ASP.NET Core has something similar to your proposed ExtraParams in the form of OpenIdConnectOptions.AdditionalAuthorizationParameters. This isn't included in the /token request. However, looking at the auth0 docs here, it appears that the "audience" parameter is only needed for this request. Will that work for you? I don't want to add parameters to every request if it's unnecessary, and it feels better to give more fine grain controls. We could add AdditionalTokenExchangeParameters later if it's truly helpful.

Another question in slightly different note, How do I get AccessToken and RefreshToken which we received from auth server for that McpTool. My scenario is - User will authenticate MCPtool in client but tool will be invoked from SignalR hub using SK, I would like to pass the same accesstoken in header which i received from client

You can use IHttpContextAccessor in Stateless Streamable HTTP mode to read the request headers inside a tool call. We plan to make IHttpContextAccessor work for stateful sessions as well, but that's not working yet. You can follow #365 for more updates on this.

halter73 added 2 commits July 3, 2025 12:34
- This takes inspiration from ASP.NET Core's OAuthOptions.AdditionalAuthorizationParameters
@halter73 halter73 merged commit cdf88e7 into main Jul 3, 2025
11 checks passed
@halter73 halter73 deleted the localden/experimental branch July 3, 2025 21:39
@anktsrkr
Copy link

anktsrkr commented Jul 3, 2025

@halter73 thanks for considering and i could see it is now added, just tested works well!

Hi! I was trying to configure https://mcp.atlassian.com/v1/sse which seems like following 2025-03-26 specification, but it gives me The WWW-Authenticate header does not contain a resource_metadata parameter and this is obvious error for the latest spec but since it is using old one IMO, it should continue with authorization server metadata.

I tried by passing ProtocolVersion in McpClientOptions but it is not considered in PerformOAuthAuthorizationAsync so it ended up calling ExtractProtectedResourceMetadata and failed.

Below is the sc to show how mcp-remote handles it - image

and I think as first party SDK this should also consider old specs

Thanks, Ankit S

Regarding the above do you have any feedback?

I modified the code to ignore if RM is not available and worked, so wanted to check whether it is something we have in backlog ?

Btw Huge round of applause for merging it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add requirement for RFC8707 Implement changes to support 2025-03-26 spec