Skip to content

Commit 8b89c2d

Browse files
committed
Pop the browser open - implement that in the SDK
1 parent 9582111 commit 8b89c2d

File tree

3 files changed

+282
-33
lines changed

3 files changed

+282
-33
lines changed

samples/SecureWeatherClient/Program.cs

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,26 @@ namespace SecureWeatherClient;
88

99
class Program
1010
{
11-
// The URI for our OAuth redirect - in a real app, this would be a registered URI or a local server
12-
private static readonly Uri RedirectUri = new("http://localhost:1170/oauth-callback");
13-
1411
static async Task Main(string[] args)
1512
{
1613
Console.WriteLine("MCP Secure Weather Client with OAuth Authentication");
1714
Console.WriteLine("==================================================");
1815
Console.WriteLine();
1916

17+
// Create the authorization config with HTTP listener
18+
var authConfig = new AuthorizationConfig
19+
{
20+
ClientId = "04f79824-ab56-4511-a7cb-d7deaea92dc0",
21+
Scopes = ["User.Read"]
22+
}.UseHttpListener(hostname: "localhost", listenPort: 1170);
23+
2024
// Create an HTTP client with OAuth handling
2125
var oauthHandler = new OAuthDelegatingHandler(
22-
clientId: "04f79824-ab56-4511-a7cb-d7deaea92dc0",
23-
redirectUri: RedirectUri,
24-
clientName: "SecureWeatherClient",
25-
scopes: ["weather.read"],
26-
authorizationHandler: HandleAuthorizationRequestAsync)
26+
redirectUri: authConfig.RedirectUri,
27+
clientId: authConfig.ClientId,
28+
clientName: authConfig.ClientName,
29+
scopes: authConfig.Scopes,
30+
authorizationHandler: authConfig.AuthorizationHandler)
2731
{
2832
// The OAuth handler needs an inner handler
2933
InnerHandler = new HttpClientHandler()
@@ -42,6 +46,9 @@ static async Task Main(string[] args)
4246

4347
Console.WriteLine();
4448
Console.WriteLine($"Connecting to weather server at {serverUrl}...");
49+
Console.WriteLine("When prompted for authorization, a browser window will open automatically.");
50+
Console.WriteLine("Complete the authentication in the browser, and this application will continue automatically.");
51+
Console.WriteLine();
4552

4653
try
4754
{
@@ -68,6 +75,15 @@ static async Task Main(string[] args)
6875

6976
Console.WriteLine($"Found {tools.Count} tools on the server.");
7077
Console.WriteLine();
78+
79+
// Call the protected-data tool which requires authentication
80+
if (tools.Any(t => t.Name == "protected-data"))
81+
{
82+
Console.WriteLine("Calling protected-data tool...");
83+
var result = await client.CallToolAsync("protected-data");
84+
Console.WriteLine("Result: " + result.Content[0].Text);
85+
Console.WriteLine();
86+
}
7187
}
7288
catch (Exception ex)
7389
{
@@ -81,29 +97,4 @@ static async Task Main(string[] args)
8197
Console.WriteLine("Press any key to exit...");
8298
Console.ReadKey();
8399
}
84-
85-
/// <summary>
86-
/// Handles the OAuth authorization request by showing the URL to the user and getting the authorization code.
87-
/// In a real application, this would launch a browser and listen for the callback.
88-
/// </summary>
89-
private static Task<string> HandleAuthorizationRequestAsync(Uri authorizationUri)
90-
{
91-
Console.WriteLine();
92-
Console.WriteLine("Authentication Required");
93-
Console.WriteLine("======================");
94-
Console.WriteLine();
95-
Console.WriteLine("Please open the following URL in your browser to authenticate:");
96-
Console.WriteLine(authorizationUri);
97-
Console.WriteLine();
98-
Console.WriteLine("After authentication, you will be redirected to a page with a code.");
99-
Console.WriteLine("Please enter the code parameter from the URL:");
100-
101-
var authorizationCode = Console.ReadLine();
102-
if (string.IsNullOrWhiteSpace(authorizationCode))
103-
{
104-
throw new InvalidOperationException("Authorization code is required.");
105-
}
106-
107-
return Task.FromResult(authorizationCode);
108-
}
109100
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Runtime.InteropServices;
4+
using System.Threading.Tasks;
5+
6+
namespace ModelContextProtocol.Auth;
7+
8+
/// <summary>
9+
/// Extension methods for <see cref="AuthorizationConfig"/>.
10+
/// </summary>
11+
public static class AuthorizationConfigExtensions
12+
{
13+
/// <summary>
14+
/// Configures the authorization config to use an HTTP listener for the OAuth authorization code flow.
15+
/// </summary>
16+
/// <param name="config">The authorization configuration to modify.</param>
17+
/// <param name="openBrowser">Optional function to open a browser. If not provided, a default implementation will be used.</param>
18+
/// <param name="hostname">The hostname to listen on. Defaults to "localhost".</param>
19+
/// <param name="listenPort">The port to listen on. Defaults to 8888.</param>
20+
/// <param name="redirectPath">The redirect path for the HTTP listener. Defaults to "/callback".</param>
21+
/// <returns>The modified authorization configuration for chaining.</returns>
22+
/// <remarks>
23+
/// <para>
24+
/// This method configures the authorization configuration to use an HTTP listener for the OAuth
25+
/// authorization code flow. When authorization is required, the listener will automatically:
26+
/// </para>
27+
/// <list type="bullet">
28+
/// <item>Start an HTTP listener on the specified hostname and port</item>
29+
/// <item>Open the user's browser to the authorization URL</item>
30+
/// <item>Wait for the authorization code to be received via the redirect URI</item>
31+
/// <item>Return the authorization code to the SDK to complete the flow</item>
32+
/// </list>
33+
/// <para>
34+
/// This provides a seamless authorization experience without requiring manual user intervention
35+
/// to copy/paste authorization codes.
36+
/// </para>
37+
/// </remarks>
38+
public static AuthorizationConfig UseHttpListener(
39+
this AuthorizationConfig config,
40+
Func<string, Task>? openBrowser = null,
41+
string hostname = "localhost",
42+
int listenPort = 8888,
43+
string redirectPath = "/callback")
44+
{
45+
// Set the redirect URI
46+
config.RedirectUri = new Uri($"http://{hostname}:{listenPort}{redirectPath}");
47+
48+
// Use default browser-opening implementation if none provided
49+
openBrowser ??= DefaultOpenBrowser;
50+
51+
// Configure the handler
52+
config.AuthorizationHandler = OAuthAuthorizationHelpers.CreateHttpListenerCallback(
53+
openBrowser,
54+
hostname,
55+
listenPort,
56+
redirectPath);
57+
58+
return config;
59+
}
60+
61+
/// <summary>
62+
/// Default implementation to open a URL in the default browser.
63+
/// </summary>
64+
private static Task DefaultOpenBrowser(string url)
65+
{
66+
try
67+
{
68+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
69+
{
70+
// On Windows, use the built-in Process.Start for URLs
71+
Process.Start(new ProcessStartInfo
72+
{
73+
FileName = url,
74+
UseShellExecute = true
75+
});
76+
}
77+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
78+
{
79+
// On Linux, use xdg-open
80+
Process.Start("xdg-open", url);
81+
}
82+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
83+
{
84+
// On macOS, use open
85+
Process.Start("open", url);
86+
}
87+
else
88+
{
89+
// Fallback for other platforms
90+
throw new NotSupportedException("Automatic browser opening is not supported on this platform.");
91+
}
92+
93+
return Task.CompletedTask;
94+
}
95+
catch (Exception ex)
96+
{
97+
return Task.FromException(new InvalidOperationException($"Failed to open browser: {ex.Message}", ex));
98+
}
99+
}
100+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System;
2+
using System.Net;
3+
using System.Text;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace ModelContextProtocol.Auth;
8+
9+
/// <summary>
10+
/// Provides helper methods for handling OAuth authorization.
11+
/// </summary>
12+
public static class OAuthAuthorizationHelpers
13+
{
14+
/// <summary>
15+
/// Creates an HTTP listener callback for handling OAuth 2.0 authorization code flow.
16+
/// </summary>
17+
/// <param name="openBrowser">A function that opens a browser with the given URL.</param>
18+
/// <param name="hostname">The hostname to listen on. Defaults to "localhost".</param>
19+
/// <param name="listenPort">The port to listen on. Defaults to 8888.</param>
20+
/// <param name="redirectPath">The redirect path for the HTTP listener. Defaults to "/callback".</param>
21+
/// <returns>
22+
/// A function that takes an authorization URI and returns a task that resolves to the authorization code.
23+
/// </returns>
24+
public static Func<Uri, Task<string>> CreateHttpListenerCallback(
25+
Func<string, Task> openBrowser,
26+
string hostname = "localhost",
27+
int listenPort = 8888,
28+
string redirectPath = "/callback")
29+
{
30+
return async (Uri authorizationUri) =>
31+
{
32+
string redirectUri = $"http://{hostname}:{listenPort}{redirectPath}";
33+
34+
// Add the redirect_uri parameter to the authorization URI if it's not already present
35+
string authUrl = authorizationUri.ToString();
36+
if (!authUrl.Contains("redirect_uri="))
37+
{
38+
var separator = authUrl.Contains("?") ? "&" : "?";
39+
authUrl = $"{authUrl}{separator}redirect_uri={WebUtility.UrlEncode(redirectUri)}";
40+
}
41+
42+
var authCodeTcs = new TaskCompletionSource<string>();
43+
44+
// Ensure the path has a trailing slash for the HttpListener prefix
45+
string listenerPrefix = $"http://{hostname}:{listenPort}{redirectPath}";
46+
if (!listenerPrefix.EndsWith("/"))
47+
{
48+
listenerPrefix += "/";
49+
}
50+
51+
using var listener = new HttpListener();
52+
listener.Prefixes.Add(listenerPrefix);
53+
54+
// Start the listener BEFORE opening the browser
55+
try
56+
{
57+
listener.Start();
58+
}
59+
catch (HttpListenerException ex)
60+
{
61+
throw new InvalidOperationException($"Failed to start HTTP listener on {listenerPrefix}: {ex.Message}");
62+
}
63+
64+
// Create a cancellation token source with a timeout
65+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
66+
67+
_ = Task.Run(async () =>
68+
{
69+
try
70+
{
71+
// GetContextAsync doesn't accept a cancellation token, so we need to handle cancellation manually
72+
var contextTask = listener.GetContextAsync();
73+
var completedTask = await Task.WhenAny(contextTask, Task.Delay(Timeout.Infinite, cts.Token));
74+
75+
if (completedTask == contextTask)
76+
{
77+
var context = await contextTask;
78+
var request = context.Request;
79+
var response = context.Response;
80+
81+
string? code = request.QueryString["code"];
82+
string? error = request.QueryString["error"];
83+
string html;
84+
string? resultCode = null;
85+
86+
if (!string.IsNullOrEmpty(error))
87+
{
88+
html = $"<html><body><h1>Authorization Failed</h1><p>Error: {WebUtility.HtmlEncode(error)}</p></body></html>";
89+
}
90+
else if (string.IsNullOrEmpty(code))
91+
{
92+
html = "<html><body><h1>Authorization Failed</h1><p>No authorization code received.</p></body></html>";
93+
}
94+
else
95+
{
96+
html = "<html><body><h1>Authorization Successful</h1><p>You may now close this window.</p></body></html>";
97+
resultCode = code;
98+
}
99+
100+
try
101+
{
102+
// Send response to browser
103+
byte[] buffer = Encoding.UTF8.GetBytes(html);
104+
response.ContentType = "text/html";
105+
response.ContentLength64 = buffer.Length;
106+
response.OutputStream.Write(buffer, 0, buffer.Length);
107+
108+
// IMPORTANT: Explicitly close the response to ensure it's fully sent
109+
response.Close();
110+
111+
// Now that we've finished processing the browser response,
112+
// we can safely signal completion or failure with the auth code
113+
if (resultCode != null)
114+
{
115+
authCodeTcs.TrySetResult(resultCode);
116+
}
117+
else if (!string.IsNullOrEmpty(error))
118+
{
119+
authCodeTcs.TrySetException(new InvalidOperationException($"Authorization failed: {error}"));
120+
}
121+
else
122+
{
123+
authCodeTcs.TrySetException(new InvalidOperationException("No authorization code received"));
124+
}
125+
}
126+
catch (Exception ex)
127+
{
128+
authCodeTcs.TrySetException(new InvalidOperationException($"Error processing browser response: {ex.Message}"));
129+
}
130+
}
131+
}
132+
catch (Exception ex)
133+
{
134+
authCodeTcs.TrySetException(ex);
135+
}
136+
});
137+
138+
// Now open the browser AFTER the listener is started
139+
await openBrowser(authUrl);
140+
141+
try
142+
{
143+
// Use a timeout to avoid hanging indefinitely
144+
string authCode = await authCodeTcs.Task.WaitAsync(cts.Token);
145+
return authCode;
146+
}
147+
catch (OperationCanceledException)
148+
{
149+
throw new InvalidOperationException("Authorization timed out after 5 minutes.");
150+
}
151+
finally
152+
{
153+
// Ensure the listener is stopped when we're done
154+
listener.Stop();
155+
}
156+
};
157+
}
158+
}

0 commit comments

Comments
 (0)