Skip to content

Commit cbb504f

Browse files
committed
Shift listener implementation out of the SDK
1 parent 4d0a126 commit cbb504f

File tree

2 files changed

+182
-67
lines changed

2 files changed

+182
-67
lines changed

samples/ProtectedMCPClient/Program.cs

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
using ModelContextProtocol.Authentication;
22
using ModelContextProtocol.Client;
3+
using System;
4+
using System.Diagnostics;
5+
using System.Net;
6+
using System.Text;
7+
using System.Web;
38

49
namespace ProtectedMCPClient;
510

@@ -18,18 +23,19 @@ static async Task Main(string[] args)
1823
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
1924
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
2025
};
21-
26+
2227
var httpClient = new HttpClient(sharedHandler);
23-
24-
// Create the token provider with our custom HttpClient,
25-
// letting the AuthorizationHelpers be created automatically
28+
// Create the token provider with our custom HttpClient and authorization URL handler
2629
var tokenProvider = new GenericOAuthProvider(
2730
new Uri(serverUrl),
2831
httpClient,
2932
null, // AuthorizationHelpers will be created automatically
3033
clientId: "6ad97b5f-7a7b-413f-8603-7a3517d4adb8",
34+
clientSecret: "", // No secret needed for this client
3135
redirectUri: new Uri("http://localhost:1179/callback"),
32-
scopes: ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"]
36+
scopes: ["api://167b4284-3f92-4436-92ed-38b38f83ae08/weather.read"],
37+
logger: null,
38+
authorizationUrlHandler: HandleAuthorizationUrlAsync
3339
);
3440

3541
Console.WriteLine();
@@ -48,7 +54,7 @@ static async Task Main(string[] args)
4854
transportOptions,
4955
tokenProvider
5056
);
51-
57+
5258
var client = await McpClientFactory.CreateAsync(transport);
5359

5460
var tools = await client.ListToolsAsync();
@@ -82,12 +88,96 @@ static async Task Main(string[] args)
8288
Console.WriteLine($"Inner error: {ex.InnerException.Message}");
8389
}
8490

85-
#if DEBUG
91+
#if DEBUG
8692
Console.WriteLine($"Stack trace: {ex.StackTrace}");
87-
#endif
93+
#endif
8894
}
89-
9095
Console.WriteLine("Press any key to exit...");
9196
Console.ReadKey();
9297
}
98+
99+
/// <summary>
100+
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
101+
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
102+
/// </summary>
103+
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
104+
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
105+
/// <param name="cancellationToken">The cancellation token.</param>
106+
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
107+
private static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
108+
{
109+
Console.WriteLine("Starting OAuth authorization flow...");
110+
Console.WriteLine($"Opening browser to: {authorizationUrl}");
111+
112+
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
113+
if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/";
114+
115+
using var listener = new HttpListener();
116+
listener.Prefixes.Add(listenerPrefix);
117+
118+
try
119+
{
120+
listener.Start();
121+
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
122+
123+
OpenBrowser(authorizationUrl);
124+
125+
var context = await listener.GetContextAsync();
126+
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
127+
var code = query["code"];
128+
var error = query["error"];
129+
130+
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
131+
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
132+
context.Response.ContentLength64 = buffer.Length;
133+
context.Response.ContentType = "text/html";
134+
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
135+
context.Response.Close();
136+
137+
if (!string.IsNullOrEmpty(error))
138+
{
139+
Console.WriteLine($"Auth error: {error}");
140+
return null;
141+
}
142+
143+
if (string.IsNullOrEmpty(code))
144+
{
145+
Console.WriteLine("No authorization code received");
146+
return null;
147+
}
148+
149+
Console.WriteLine("Authorization code received successfully.");
150+
return code;
151+
}
152+
catch (Exception ex)
153+
{
154+
Console.WriteLine($"Error getting auth code: {ex.Message}");
155+
return null;
156+
}
157+
finally
158+
{
159+
if (listener.IsListening) listener.Stop();
160+
}
161+
}
162+
163+
/// <summary>
164+
/// Opens the specified URL in the default browser.
165+
/// </summary>
166+
/// <param name="url">The URL to open.</param>
167+
private static void OpenBrowser(Uri url)
168+
{
169+
try
170+
{
171+
var psi = new ProcessStartInfo
172+
{
173+
FileName = url.ToString(),
174+
UseShellExecute = true
175+
};
176+
Process.Start(psi);
177+
}
178+
catch (Exception ex)
179+
{
180+
Console.WriteLine($"Error opening browser. {ex.Message}");
181+
}
182+
}
93183
}

src/ModelContextProtocol.Core/Authentication/GenericOAuthProvider.cs

Lines changed: 83 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@
88

99
namespace ModelContextProtocol.Authentication;
1010

11+
/// <summary>
12+
/// Represents a method that handles the OAuth authorization URL and returns the authorization code.
13+
/// </summary>
14+
/// <param name="authorizationUrl">The authorization URL that the user needs to visit.</param>
15+
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
16+
/// <param name="cancellationToken">The cancellation token.</param>
17+
/// <returns>A task that represents the asynchronous operation. The task result contains the authorization code if successful, or null if the operation failed or was cancelled.</returns>
18+
/// <remarks>
19+
/// <para>
20+
/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled.
21+
/// Implementers can choose to:
22+
/// </para>
23+
/// <list type="bullet">
24+
/// <item><description>Start a local HTTP server and open a browser (default behavior)</description></item>
25+
/// <item><description>Display the authorization URL to the user for manual handling</description></item>
26+
/// <item><description>Integrate with a custom UI or authentication flow</description></item>
27+
/// <item><description>Use a different redirect mechanism altogether</description></item>
28+
/// </list>
29+
/// <para>
30+
/// The implementation should handle user interaction to visit the authorization URL and extract
31+
/// the authorization code from the callback. The authorization code is typically provided as
32+
/// a query parameter in the redirect URI callback.
33+
/// </para>
34+
/// </remarks>
35+
public delegate Task<string?> AuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken);
36+
1137
/// <summary>
1238
/// A generic implementation of an OAuth authorization provider for MCP. This does not do any advanced token
1339
/// protection or caching - it acquires a token and server metadata and holds it in memory.
@@ -27,15 +53,14 @@ public class GenericOAuthProvider : IMcpCredentialProvider
2753
private readonly HttpClient _httpClient; private readonly AuthorizationHelpers _authorizationHelpers;
2854
private readonly ILogger _logger;
2955
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
56+
private readonly AuthorizationUrlHandler _authorizationUrlHandler;
3057

3158
// Lazy-initialized shared HttpClient for when no client is provided
3259
private static readonly Lazy<HttpClient> _defaultHttpClient = new(() => new HttpClient());
3360

3461
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
3562
private TokenContainer? _token;
36-
private AuthorizationServerMetadata? _authServerMetadata;
37-
38-
/// <summary>
63+
private AuthorizationServerMetadata? _authServerMetadata; /// <summary>
3964
/// Initializes a new instance of the <see cref="GenericOAuthProvider"/> class.
4065
/// </summary>
4166
/// <param name="serverUrl">The MCP server URL.</param>
@@ -54,7 +79,34 @@ public GenericOAuthProvider(
5479
string clientSecret = "",
5580
Uri? redirectUri = null,
5681
IEnumerable<string>? scopes = null,
57-
ILogger<GenericOAuthProvider>? logger = null) : this(serverUrl, httpClient, authorizationHelpers, clientId, clientSecret, redirectUri, scopes, logger, null)
82+
ILogger<GenericOAuthProvider>? logger = null)
83+
: this(serverUrl, httpClient, authorizationHelpers, clientId, clientSecret, redirectUri, scopes, logger, null, null)
84+
{
85+
}
86+
87+
/// <summary>
88+
/// Initializes a new instance of the <see cref="GenericOAuthProvider"/> class with a custom authorization URL handler.
89+
/// </summary>
90+
/// <param name="serverUrl">The MCP server URL.</param>
91+
/// <param name="httpClient">The HTTP client to use for OAuth requests. If null, a default HttpClient will be used.</param>
92+
/// <param name="authorizationHelpers">The authorization helpers.</param>
93+
/// <param name="clientId">OAuth client ID.</param>
94+
/// <param name="clientSecret">OAuth client secret.</param>
95+
/// <param name="redirectUri">OAuth redirect URI.</param>
96+
/// <param name="scopes">OAuth scopes.</param>
97+
/// <param name="logger">The logger instance. If null, a NullLogger will be used.</param>
98+
/// <param name="authorizationUrlHandler">Custom handler for processing the OAuth authorization URL. If null, uses the default HTTP listener approach.</param>
99+
public GenericOAuthProvider(
100+
Uri serverUrl,
101+
HttpClient? httpClient,
102+
AuthorizationHelpers? authorizationHelpers,
103+
string clientId,
104+
string clientSecret,
105+
Uri? redirectUri,
106+
IEnumerable<string>? scopes,
107+
ILogger<GenericOAuthProvider>? logger,
108+
AuthorizationUrlHandler? authorizationUrlHandler)
109+
: this(serverUrl, httpClient, authorizationHelpers, clientId, clientSecret, redirectUri, scopes, logger, null, authorizationUrlHandler)
58110
{
59111
} /// <summary>
60112
/// Initializes a new instance of the <see cref="GenericOAuthProvider"/> class with explicit authorization server selection.
@@ -68,6 +120,7 @@ public GenericOAuthProvider(
68120
/// <param name="scopes">OAuth scopes.</param>
69121
/// <param name="logger">The logger instance. If null, a NullLogger will be used.</param>
70122
/// <param name="authServerSelector">Function to select which authorization server to use from available servers. If null, uses default selection strategy.</param>
123+
/// <param name="authorizationUrlHandler">Custom handler for processing the OAuth authorization URL. If null, uses the default HTTP listener approach.</param>
71124
/// <exception cref="ArgumentNullException">Thrown when serverUrl is null.</exception>
72125
public GenericOAuthProvider(
73126
Uri serverUrl,
@@ -78,7 +131,8 @@ public GenericOAuthProvider(
78131
Uri? redirectUri,
79132
IEnumerable<string>? scopes,
80133
ILogger<GenericOAuthProvider>? logger,
81-
Func<IReadOnlyList<Uri>, Uri?>? authServerSelector)
134+
Func<IReadOnlyList<Uri>, Uri?>? authServerSelector,
135+
AuthorizationUrlHandler? authorizationUrlHandler)
82136
{
83137
if (serverUrl == null) throw new ArgumentNullException(nameof(serverUrl));
84138

@@ -94,17 +148,35 @@ public GenericOAuthProvider(
94148

95149
// Set up authorization server selection strategy
96150
_authServerSelector = authServerSelector ?? DefaultAuthServerSelector;
97-
}
98-
99-
/// <summary>
151+
152+
// Set up authorization URL handler (use default if not provided)
153+
_authorizationUrlHandler = authorizationUrlHandler ?? DefaultAuthorizationUrlHandler;
154+
} /// <summary>
100155
/// Default authorization server selection strategy that selects the first available server.
101156
/// </summary>
102157
/// <param name="availableServers">List of available authorization servers.</param>
103-
/// <returns>The selected authorization server, or null if none are available.</returns>
158+
/// <returns>The selected authorization server, or null if none are available.</returns>
104159
private static Uri? DefaultAuthServerSelector(IReadOnlyList<Uri> availableServers)
105160
{
106161
return availableServers.FirstOrDefault();
107162
}
163+
164+
/// <summary>
165+
/// Default authorization URL handler that displays the URL to the user for manual input.
166+
/// </summary>
167+
/// <param name="authorizationUrl">The authorization URL to handle.</param>
168+
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
169+
/// <param name="cancellationToken">The cancellation token.</param>
170+
/// <returns>The authorization code entered by the user, or null if none was provided.</returns>
171+
private Task<string?> DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
172+
{
173+
Console.WriteLine($"Please open the following URL in your browser to authorize the application:");
174+
Console.WriteLine($"{authorizationUrl}");
175+
Console.WriteLine();
176+
Console.Write("Enter the authorization code from the redirect URL: ");
177+
var authorizationCode = Console.ReadLine();
178+
return Task.FromResult<string?>(authorizationCode);
179+
}
108180

109181
/// <inheritdoc />
110182
public IEnumerable<string> SupportedSchemes => new[] { BearerScheme };
@@ -371,56 +443,9 @@ private Uri BuildAuthorizationUrl(AuthorizationServerMetadata authServerMetadata
371443
};
372444
return uriBuilder.Uri;
373445
}
374-
375-
private async Task<string?> GetAuthorizationCodeAsync(Uri authorizationUrl, CancellationToken cancellationToken)
446+
private async Task<string?> GetAuthorizationCodeAsync(Uri authorizationUrl, CancellationToken cancellationToken)
376447
{
377-
var listenerPrefix = _redirectUri.GetLeftPart(UriPartial.Authority);
378-
if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/";
379-
380-
using var listener = new System.Net.HttpListener();
381-
listener.Prefixes.Add(listenerPrefix);
382-
383-
try
384-
{
385-
listener.Start();
386-
387-
OpenBrowser(authorizationUrl);
388-
389-
var context = await listener.GetContextAsync();
390-
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
391-
var code = query["code"];
392-
var error = query["error"];
393-
394-
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
395-
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
396-
context.Response.ContentLength64 = buffer.Length;
397-
context.Response.ContentType = "text/html";
398-
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
399-
context.Response.Close();
400-
401-
if (!string.IsNullOrEmpty(error))
402-
{
403-
_logger.LogError("Auth error: {Error}", error);
404-
return null;
405-
}
406-
407-
if (string.IsNullOrEmpty(code))
408-
{
409-
_logger.LogError("No authorization code received");
410-
return null;
411-
}
412-
413-
return code;
414-
}
415-
catch (Exception ex)
416-
{
417-
_logger.LogError(ex, "Error getting auth code");
418-
return null;
419-
}
420-
finally
421-
{
422-
if (listener.IsListening) listener.Stop();
423-
}
448+
return await _authorizationUrlHandler(authorizationUrl, _redirectUri, cancellationToken);
424449
}
425450

426451
private async Task<TokenContainer?> ExchangeCodeForTokenAsync(

0 commit comments

Comments
 (0)