Skip to content

Commit cdf88e7

Browse files
authored
Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)
1 parent cd8e5d5 commit cdf88e7

File tree

65 files changed

+4207
-126
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+4207
-126
lines changed

Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919

2020
<!-- Product dependencies LTS -->
2121
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
22+
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
2223
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
2324
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
2425
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
2526
</ItemGroup>
2627

2728
<!-- Product dependencies .NET 9 -->
2829
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
30+
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(System9Version)" />
31+
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
2932
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(System9Version)" />
3033
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(System9Version)" />
3134
<PackageVersion Include="System.IO.Pipelines" Version="$(System9Version)" />

ModelContextProtocol.slnx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
<Project Path="samples/AspNetCoreSseServer/AspNetCoreSseServer.csproj" />
1313
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
1414
<Project Path="samples/EverythingServer/EverythingServer.csproj" />
15+
<Project Path="samples/ProtectedMCPClient/ProtectedMCPClient.csproj" />
16+
<Project Path="samples/ProtectedMCPServer/ProtectedMCPServer.csproj" />
1517
<Project Path="samples/QuickstartClient/QuickstartClient.csproj" />
1618
<Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" />
1719
<Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" />
@@ -33,6 +35,7 @@
3335
</Folder>
3436
<Folder Name="/tests/">
3537
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
38+
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
3639
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
3740
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
3841
<Project Path="tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj" />

samples/ProtectedMCPClient/Program.cs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
using Microsoft.Extensions.Logging;
2+
using ModelContextProtocol.Client;
3+
using ModelContextProtocol.Protocol;
4+
using System.Diagnostics;
5+
using System.Net;
6+
using System.Text;
7+
using System.Web;
8+
9+
var serverUrl = "http://localhost:7071/";
10+
11+
Console.WriteLine("Protected MCP Client");
12+
Console.WriteLine($"Connecting to weather server at {serverUrl}...");
13+
Console.WriteLine();
14+
15+
// We can customize a shared HttpClient with a custom handler if desired
16+
var sharedHandler = new SocketsHttpHandler
17+
{
18+
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
19+
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
20+
};
21+
var httpClient = new HttpClient(sharedHandler);
22+
23+
var consoleLoggerFactory = LoggerFactory.Create(builder =>
24+
{
25+
builder.AddConsole();
26+
});
27+
28+
var transport = new SseClientTransport(new()
29+
{
30+
Endpoint = new Uri(serverUrl),
31+
Name = "Secure Weather Client",
32+
OAuth = new()
33+
{
34+
ClientName = "ProtectedMcpClient",
35+
RedirectUri = new Uri("http://localhost:1179/callback"),
36+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
37+
}
38+
}, httpClient, consoleLoggerFactory);
39+
40+
var client = await McpClientFactory.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
41+
42+
var tools = await client.ListToolsAsync();
43+
if (tools.Count == 0)
44+
{
45+
Console.WriteLine("No tools available on the server.");
46+
return;
47+
}
48+
49+
Console.WriteLine($"Found {tools.Count} tools on the server.");
50+
Console.WriteLine();
51+
52+
if (tools.Any(t => t.Name == "get_alerts"))
53+
{
54+
Console.WriteLine("Calling get_alerts tool...");
55+
56+
var result = await client.CallToolAsync(
57+
"get_alerts",
58+
new Dictionary<string, object?> { { "state", "WA" } }
59+
);
60+
61+
Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text);
62+
Console.WriteLine();
63+
}
64+
65+
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
66+
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
67+
/// </summary>
68+
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
69+
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
70+
/// <param name="cancellationToken">The cancellation token.</param>
71+
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
72+
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
73+
{
74+
Console.WriteLine("Starting OAuth authorization flow...");
75+
Console.WriteLine($"Opening browser to: {authorizationUrl}");
76+
77+
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
78+
if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/";
79+
80+
using var listener = new HttpListener();
81+
listener.Prefixes.Add(listenerPrefix);
82+
83+
try
84+
{
85+
listener.Start();
86+
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
87+
88+
OpenBrowser(authorizationUrl);
89+
90+
var context = await listener.GetContextAsync();
91+
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
92+
var code = query["code"];
93+
var error = query["error"];
94+
95+
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
96+
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
97+
context.Response.ContentLength64 = buffer.Length;
98+
context.Response.ContentType = "text/html";
99+
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
100+
context.Response.Close();
101+
102+
if (!string.IsNullOrEmpty(error))
103+
{
104+
Console.WriteLine($"Auth error: {error}");
105+
return null;
106+
}
107+
108+
if (string.IsNullOrEmpty(code))
109+
{
110+
Console.WriteLine("No authorization code received");
111+
return null;
112+
}
113+
114+
Console.WriteLine("Authorization code received successfully.");
115+
return code;
116+
}
117+
catch (Exception ex)
118+
{
119+
Console.WriteLine($"Error getting auth code: {ex.Message}");
120+
return null;
121+
}
122+
finally
123+
{
124+
if (listener.IsListening) listener.Stop();
125+
}
126+
}
127+
128+
/// <summary>
129+
/// Opens the specified URL in the default browser.
130+
/// </summary>
131+
/// <param name="url">The URL to open.</param>
132+
static void OpenBrowser(Uri url)
133+
{
134+
try
135+
{
136+
var psi = new ProcessStartInfo
137+
{
138+
FileName = url.ToString(),
139+
UseShellExecute = true
140+
};
141+
Process.Start(psi);
142+
}
143+
catch (Exception ex)
144+
{
145+
Console.WriteLine($"Error opening browser. {ex.Message}");
146+
Console.WriteLine($"Please manually open this URL: {url}");
147+
}
148+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net9.0</TargetFramework>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
16+
</ItemGroup>
17+
18+
</Project>

samples/ProtectedMCPClient/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Protected MCP Client Sample
2+
3+
This sample demonstrates how to create an MCP client that connects to a protected MCP server using OAuth 2.0 authentication. The client implements a custom OAuth authorization flow with browser-based authentication.
4+
5+
## Overview
6+
7+
The Protected MCP Client sample shows how to:
8+
- Connect to an OAuth-protected MCP server
9+
- Handle OAuth 2.0 authorization code flow
10+
- Use custom authorization redirect handling
11+
- Call protected MCP tools with authentication
12+
13+
## Prerequisites
14+
15+
- .NET 9.0 or later
16+
- A running TestOAuthServer (for OAuth authentication)
17+
- A running ProtectedMCPServer (for MCP services)
18+
19+
## Setup and Running
20+
21+
### Step 1: Start the Test OAuth Server
22+
23+
First, you need to start the TestOAuthServer which provides OAuth authentication:
24+
25+
```bash
26+
cd tests\ModelContextProtocol.TestOAuthServer
27+
dotnet run --framework net9.0
28+
```
29+
30+
The OAuth server will start at `https://localhost:7029`
31+
32+
### Step 2: Start the Protected MCP Server
33+
34+
Next, start the ProtectedMCPServer which provides the weather tools:
35+
36+
```bash
37+
cd samples\ProtectedMCPServer
38+
dotnet run
39+
```
40+
41+
The protected server will start at `http://localhost:7071`
42+
43+
### Step 3: Run the Protected MCP Client
44+
45+
Finally, run this client:
46+
47+
```bash
48+
cd samples\ProtectedMCPClient
49+
dotnet run
50+
```
51+
52+
## What Happens
53+
54+
1. The client attempts to connect to the protected MCP server at `http://localhost:7071`
55+
2. The server responds with OAuth metadata indicating authentication is required
56+
3. The client initiates OAuth 2.0 authorization code flow:
57+
- Opens a browser to the authorization URL at the OAuth server
58+
- Starts a local HTTP listener on `http://localhost:1179/callback` to receive the authorization code
59+
- Exchanges the authorization code for an access token
60+
4. The client uses the access token to authenticate with the MCP server
61+
5. The client lists available tools and calls the `GetAlerts` tool for Washington state
62+
63+
## OAuth Configuration
64+
65+
The client is configured with:
66+
- **Client ID**: `demo-client`
67+
- **Client Secret**: `demo-secret`
68+
- **Redirect URI**: `http://localhost:1179/callback`
69+
- **OAuth Server**: `https://localhost:7029`
70+
- **Protected Resource**: `http://localhost:7071`
71+
72+
## Available Tools
73+
74+
Once authenticated, the client can access weather tools including:
75+
- **GetAlerts**: Get weather alerts for a US state
76+
- **GetForecast**: Get weather forecast for a location (latitude/longitude)
77+
78+
## Troubleshooting
79+
80+
- Ensure the ASP.NET Core dev certificate is trusted.
81+
```
82+
dotnet dev-certs https --clean
83+
dotnet dev-certs https --trust
84+
```
85+
- Ensure all three services are running in the correct order
86+
- Check that ports 7029, 7071, and 1179 are available
87+
- If the browser doesn't open automatically, copy the authorization URL from the console and open it manually
88+
- Make sure to allow the OAuth server's self-signed certificate in your browser
89+
90+
## Key Files
91+
92+
- `Program.cs`: Main client application with OAuth flow implementation
93+
- `ProtectedMCPClient.csproj`: Project file with dependencies

samples/ProtectedMCPServer/Program.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using Microsoft.AspNetCore.Authentication.JwtBearer;
2+
using Microsoft.IdentityModel.Tokens;
3+
using ModelContextProtocol.AspNetCore.Authentication;
4+
using ProtectedMCPServer.Tools;
5+
using System.Net.Http.Headers;
6+
using System.Security.Claims;
7+
8+
var builder = WebApplication.CreateBuilder(args);
9+
10+
var serverUrl = "http://localhost:7071/";
11+
var inMemoryOAuthServerUrl = "https://localhost:7029";
12+
13+
builder.Services.AddAuthentication(options =>
14+
{
15+
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
16+
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
17+
})
18+
.AddJwtBearer(options =>
19+
{
20+
// Configure to validate tokens from our in-memory OAuth server
21+
options.Authority = inMemoryOAuthServerUrl;
22+
options.TokenValidationParameters = new TokenValidationParameters
23+
{
24+
ValidateIssuer = true,
25+
ValidateAudience = true,
26+
ValidateLifetime = true,
27+
ValidateIssuerSigningKey = true,
28+
ValidAudience = serverUrl, // Validate that the audience matches the resource metadata as suggested in RFC 8707
29+
ValidIssuer = inMemoryOAuthServerUrl,
30+
NameClaimType = "name",
31+
RoleClaimType = "roles"
32+
};
33+
34+
options.Events = new JwtBearerEvents
35+
{
36+
OnTokenValidated = context =>
37+
{
38+
var name = context.Principal?.Identity?.Name ?? "unknown";
39+
var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
40+
Console.WriteLine($"Token validated for: {name} ({email})");
41+
return Task.CompletedTask;
42+
},
43+
OnAuthenticationFailed = context =>
44+
{
45+
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
46+
return Task.CompletedTask;
47+
},
48+
OnChallenge = context =>
49+
{
50+
Console.WriteLine($"Challenging client to authenticate with Entra ID");
51+
return Task.CompletedTask;
52+
}
53+
};
54+
})
55+
.AddMcp(options =>
56+
{
57+
options.ResourceMetadata = new()
58+
{
59+
Resource = new Uri(serverUrl),
60+
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
61+
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
62+
ScopesSupported = ["mcp:tools"],
63+
};
64+
});
65+
66+
builder.Services.AddAuthorization();
67+
68+
builder.Services.AddHttpContextAccessor();
69+
builder.Services.AddMcpServer()
70+
.WithTools<WeatherTools>()
71+
.WithHttpTransport();
72+
73+
// Configure HttpClientFactory for weather.gov API
74+
builder.Services.AddHttpClient("WeatherApi", client =>
75+
{
76+
client.BaseAddress = new Uri("https://api.weather.gov");
77+
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
78+
});
79+
80+
var app = builder.Build();
81+
82+
app.UseAuthentication();
83+
app.UseAuthorization();
84+
85+
// Use the default MCP policy name that we've configured
86+
app.MapMcp().RequireAuthorization();
87+
88+
Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
89+
Console.WriteLine($"Using in-memory OAuth server at {inMemoryOAuthServerUrl}");
90+
Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
91+
Console.WriteLine("Press Ctrl+C to stop the server");
92+
93+
app.Run(serverUrl);

0 commit comments

Comments
 (0)