Skip to content

Commit 5162de1

Browse files
.Net: Sample using OAuth to access a protected MCP server (#12680)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄
1 parent dd8244f commit 5162de1

File tree

4 files changed

+361
-0
lines changed

4 files changed

+361
-0
lines changed

dotnet/SK-dotnet.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<Project Path="samples/Demos/FunctionInvocationApproval/FunctionInvocationApproval.csproj" />
3939
<Project Path="samples/Demos/HomeAutomation/HomeAutomation.csproj" />
4040
<Project Path="samples/Demos/ModelContextProtocolPlugin/ModelContextProtocolPlugin.csproj" />
41+
<Project Path="samples/Demos/ModelContextProtocolPluginAuth/ModelContextProtocolPluginAuth.csproj" />
4142
<Project Path="samples/Demos/OllamaFunctionCalling/OllamaFunctionCalling.csproj" />
4243
<Project Path="samples/Demos/OnnxSimpleRAG/OnnxSimpleRAG.csproj" />
4344
<Project Path="samples/Demos/OpenAIRealtime/OpenAIRealtime.csproj" />
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
9+
<NoWarn>$(NoWarn);CA2249;CS0612;SKEXP0001;VSTHRD111;CA2007;RCS1263</NoWarn>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="ModelContextProtocol" />
14+
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
15+
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
16+
<PackageReference Include="Microsoft.Extensions.Logging.Debug" />
17+
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<ProjectReference Include="..\..\..\src\Agents\Abstractions\Agents.Abstractions.csproj" />
22+
<ProjectReference Include="..\..\..\src\Agents\Core\Agents.Core.csproj" />
23+
<ProjectReference Include="..\..\..\src\Connectors\Connectors.AzureOpenAI\Connectors.AzureOpenAI.csproj" />
24+
<ProjectReference Include="..\..\..\src\SemanticKernel.Abstractions\SemanticKernel.Abstractions.csproj" />
25+
<ProjectReference Include="..\..\..\src\SemanticKernel.Core\SemanticKernel.Core.csproj" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics;
4+
using System.Net;
5+
using System.Text;
6+
using System.Web;
7+
using Microsoft.Extensions.Configuration;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
using Microsoft.SemanticKernel;
11+
using Microsoft.SemanticKernel.Agents;
12+
using Microsoft.SemanticKernel.Connectors.OpenAI;
13+
using ModelContextProtocol.Client;
14+
15+
var config = new ConfigurationBuilder()
16+
.AddUserSecrets<Program>()
17+
.AddEnvironmentVariables()
18+
.Build();
19+
20+
if (config["OpenAI:ApiKey"] is not { } apiKey)
21+
{
22+
Console.Error.WriteLine("Please provide a valid OpenAI:ApiKey to run this sample. See the associated README.md for more details.");
23+
return;
24+
}
25+
26+
// We can customize a shared HttpClient with a custom handler if desired
27+
using var sharedHandler = new SocketsHttpHandler
28+
{
29+
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
30+
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
31+
};
32+
using var httpClient = new HttpClient(sharedHandler);
33+
34+
var consoleLoggerFactory = LoggerFactory.Create(builder =>
35+
{
36+
builder.AddConsole();
37+
});
38+
39+
// Create SSE client transport for the MCP server
40+
var serverUrl = "http://localhost:7071/";
41+
var transport = new SseClientTransport(new()
42+
{
43+
Endpoint = new Uri(serverUrl),
44+
Name = "Secure Weather Client",
45+
OAuth = new()
46+
{
47+
ClientName = "ProtectedMcpClient",
48+
RedirectUri = new Uri("http://localhost:1179/callback"),
49+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
50+
}
51+
}, httpClient, consoleLoggerFactory);
52+
53+
// Create an MCPClient for the protected MCP server
54+
await using var mcpClient = await McpClientFactory.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
55+
56+
// Retrieve the list of tools available on the GitHub server
57+
var tools = await mcpClient.ListToolsAsync().ConfigureAwait(false);
58+
foreach (var tool in tools)
59+
{
60+
Console.WriteLine($"{tool.Name}: {tool.Description}");
61+
}
62+
63+
// Prepare and build kernel with the MCP tools as Kernel functions
64+
var builder = Kernel.CreateBuilder();
65+
builder.Services
66+
.AddLogging(c => c.AddDebug().SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace))
67+
.AddOpenAIChatCompletion(
68+
modelId: config["OpenAI:ChatModelId"] ?? "gpt-4o-mini",
69+
apiKey: apiKey);
70+
Kernel kernel = builder.Build();
71+
kernel.Plugins.AddFromFunctions("WeatherApi", tools.Select(aiFunction => aiFunction.AsKernelFunction()));
72+
73+
// Enable automatic function calling
74+
OpenAIPromptExecutionSettings executionSettings = new()
75+
{
76+
Temperature = 0,
77+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true })
78+
};
79+
80+
// Test using weather tools
81+
var prompt = "Get current weather alerts for New York?";
82+
var result = await kernel.InvokePromptAsync(prompt, new(executionSettings)).ConfigureAwait(false);
83+
Console.WriteLine($"\n\n{prompt}\n{result}");
84+
85+
// Define the agent
86+
ChatCompletionAgent agent = new()
87+
{
88+
Instructions = "Answer questions about weather alerts for US states.",
89+
Name = "WeatherAgent",
90+
Kernel = kernel,
91+
Arguments = new KernelArguments(executionSettings),
92+
};
93+
94+
// Respond to user input, invoking functions where appropriate.
95+
ChatMessageContent response = await agent.InvokeAsync("Get the current weather alerts for Washington?").FirstAsync();
96+
Console.WriteLine($"\n\nResponse from WeatherAgent:\n{response.Content}");
97+
98+
/// <summary>
99+
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
100+
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
101+
/// </summary>
102+
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
103+
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
104+
/// <param name="cancellationToken">The cancellation token.</param>
105+
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
106+
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
107+
{
108+
Console.WriteLine("Starting OAuth authorization flow...");
109+
Console.WriteLine($"Opening browser to: {authorizationUrl}");
110+
111+
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
112+
if (!listenerPrefix.EndsWith("/", StringComparison.InvariantCultureIgnoreCase))
113+
{
114+
listenerPrefix += "/";
115+
}
116+
117+
using var listener = new HttpListener();
118+
listener.Prefixes.Add(listenerPrefix);
119+
120+
try
121+
{
122+
listener.Start();
123+
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
124+
125+
OpenBrowser(authorizationUrl);
126+
127+
var context = await listener.GetContextAsync();
128+
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
129+
var code = query["code"];
130+
var error = query["error"];
131+
132+
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
133+
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
134+
context.Response.ContentLength64 = buffer.Length;
135+
context.Response.ContentType = "text/html";
136+
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
137+
context.Response.Close();
138+
139+
if (!string.IsNullOrEmpty(error))
140+
{
141+
Console.WriteLine($"Auth error: {error}");
142+
return null;
143+
}
144+
145+
if (string.IsNullOrEmpty(code))
146+
{
147+
Console.WriteLine("No authorization code received");
148+
return null;
149+
}
150+
151+
Console.WriteLine("Authorization code received successfully.");
152+
return code;
153+
}
154+
catch (Exception ex)
155+
{
156+
Console.WriteLine($"Error getting auth code: {ex.Message}");
157+
return null;
158+
}
159+
finally
160+
{
161+
if (listener.IsListening)
162+
{
163+
listener.Stop();
164+
}
165+
}
166+
}
167+
168+
/// <summary>
169+
/// Opens the specified URL in the default browser.
170+
/// </summary>
171+
/// <param name="url">The URL to open.</param>
172+
static void OpenBrowser(Uri url)
173+
{
174+
try
175+
{
176+
var psi = new ProcessStartInfo
177+
{
178+
FileName = url.ToString(),
179+
UseShellExecute = true
180+
};
181+
Process.Start(psi);
182+
}
183+
catch (Exception ex)
184+
{
185+
Console.WriteLine($"Error opening browser. {ex.Message}");
186+
Console.WriteLine($"Please manually open this URL: {url}");
187+
}
188+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Model Context Protocol Sample
2+
3+
This example demonstrates how to use tools from a protected Model Context Protocol server with Semantic Kernel.
4+
5+
MCP is an open protocol that standardizes how applications provide context to LLMs.
6+
7+
For information on Model Context Protocol (MCP) please refer to the [documentation](https://modelcontextprotocol.io/introduction).
8+
9+
The sample shows:
10+
11+
1. How to connect to a protected MCP Server using OAuth 2.0 authentication
12+
1. How to implement a custom OAuth authorization flow with browser-based authentication
13+
1. Retrieve the list of tools the MCP Server makes available
14+
1. Convert the MCP tools to Semantic Kernel functions so they can be added to a Kernel instance
15+
1. Invoke the tools from Semantic Kernel using function calling
16+
17+
## Installing Prerequisites
18+
19+
- A self-signed certificate to enable HTTPS use in development, see [dotnet dev-certs](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-dev-certs)
20+
- .NET 9.0 or later
21+
- A running TestOAuthServer (for OAuth authentication), see [Start the Test OAuth Server](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/ProtectedMCPClient#step-1-start-the-test-oauth-server)
22+
- A running ProtectedMCPServer (for MCP services), see [Start the Protected MCP Server](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/ProtectedMCPClient#step-2-start-the-protected-mcp-server)
23+
24+
## Configuring Secrets or Environment Variables
25+
26+
The example requires credentials to access OpenAI.
27+
28+
If you have set up those credentials as secrets within Secret Manager or through environment variables for other samples from the solution in which this project is found, they will be re-used.
29+
30+
### To set your secrets with Secret Manager
31+
32+
```text
33+
cd dotnet/samples/Demos/ModelContextProtocolPluginAuth
34+
35+
dotnet user-secrets init
36+
37+
dotnet user-secrets set "OpenAI:ChatModelId" "..."
38+
dotnet user-secrets set "OpenAI:ApiKey" "..."
39+
"..."
40+
```
41+
42+
### To set your secrets with environment variables
43+
44+
Use these names:
45+
46+
```text
47+
# OpenAI
48+
OpenAI__ChatModelId
49+
OpenAI__ApiKey
50+
```
51+
52+
## Setup and Running
53+
54+
### Step 1: Start the Test OAuth Server
55+
56+
First, you need to start the TestOAuthServer which provides OAuth authentication:
57+
58+
```bash
59+
cd <MCP CSHARP-SDK>\tests\ModelContextProtocol.TestOAuthServer
60+
dotnet run --framework net9.0
61+
```
62+
63+
The OAuth server will start at `https://localhost:7029`
64+
65+
### Step 2: Start the Protected MCP Server
66+
67+
Next, start the ProtectedMCPServer which provides the weather tools:
68+
69+
```bash
70+
cd <MCP CSHARP-SDK>\samples\ProtectedMCPServer
71+
dotnet run
72+
```
73+
74+
The protected server will start at `http://localhost:7071`
75+
76+
### Step 3: Run the ModelContextProtocolPluginAuth sample
77+
78+
Finally, run this client:
79+
80+
```bash
81+
dotnet run
82+
```
83+
84+
## What Happens
85+
86+
1. The client attempts to connect to the protected MCP server at `http://localhost:7071`
87+
2. The server responds with OAuth metadata indicating authentication is required
88+
3. The client initiates OAuth 2.0 authorization code flow:
89+
- Opens a browser to the authorization URL at the OAuth server
90+
- Starts a local HTTP listener on `http://localhost:1179/callback` to receive the authorization code
91+
- Exchanges the authorization code for an access token
92+
4. The client uses the access token to authenticate with the MCP server
93+
5. The client lists available tools and calls the `GetAlerts` tool for New York state
94+
95+
The following diagram outlines an example OAuth flow:
96+
97+
```mermaid
98+
sequenceDiagram
99+
participant Client as Client
100+
participant Server as MCP Server (Resource Server)
101+
participant AuthServer as Authorization Server
102+
103+
Client->>Server: MCP request without access token
104+
Server-->>Client: HTTP 401 Unauthorized with WWW-Authenticate header
105+
Note over Client: Analyze and delegate tasks
106+
Client->>Server: GET /.well-known/oauth-protected-resource
107+
Server-->>Client: Resource metadata with authorization server URL
108+
Note over Client: Validate RS metadata, build AS metadata URL
109+
Client->>AuthServer: GET /.well-known/oauth-authorization-server
110+
AuthServer-->>Client: Authorization server metadata
111+
Note over Client,AuthServer: OAuth 2.0 authorization flow happens here
112+
Client->>AuthServer: Token request
113+
AuthServer-->>Client: Access token
114+
Client->>Server: MCP request with access token
115+
Server-->>Client: MCP response
116+
Note over Client,Server: MCP communication continues with valid token
117+
```
118+
119+
## OAuth Configuration
120+
121+
The client is configured with:
122+
- **Client ID**: `demo-client`
123+
- **Client Secret**: `demo-secret`
124+
- **Redirect URI**: `http://localhost:1179/callback`
125+
- **OAuth Server**: `https://localhost:7029`
126+
- **Protected Resource**: `http://localhost:7071`
127+
128+
## Available Tools
129+
130+
Once authenticated, the client can access weather tools including:
131+
- **GetAlerts**: Get weather alerts for a US state
132+
- **GetForecast**: Get weather forecast for a location (latitude/longitude)
133+
134+
## Troubleshooting
135+
136+
- Ensure the ASP.NET Core dev certificate is trusted.
137+
```
138+
dotnet dev-certs https --clean
139+
dotnet dev-certs https --trust
140+
```
141+
- Ensure all three services are running in the correct order
142+
- Check that ports 7029, 7071, and 1179 are available
143+
- If the browser doesn't open automatically, copy the authorization URL from the console and open it manually
144+
- Make sure to allow the OAuth server's self-signed certificate in your browser

0 commit comments

Comments
 (0)