Remove 'Sse' from AspNetCoreSseServer sample name (#665)
This commit is contained in:
parent
da86d52241
commit
5e5b1af29b
17 changed files with 22 additions and 23 deletions
21
samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj
Normal file
21
samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishAot>true</PublishAot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
28
samples/AspNetCoreMcpServer/Program.cs
Normal file
28
samples/AspNetCoreMcpServer/Program.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using AspNetCoreMcpServer.Tools;
|
||||
using AspNetCoreMcpServer.Resources;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<EchoTool>()
|
||||
.WithTools<SampleLlmTool>()
|
||||
.WithResources<SimpleResourceType>();
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithTracing(b => b.AddSource("*")
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation())
|
||||
.WithMetrics(b => b.AddMeter("*")
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation())
|
||||
.WithLogging()
|
||||
.UseOtlpExporter();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
app.Run();
|
||||
23
samples/AspNetCoreMcpServer/Properties/launchSettings.json
Normal file
23
samples/AspNetCoreMcpServer/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:3001",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server",
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7133;http://localhost:3001",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"OTEL_SERVICE_NAME": "aspnetcore-mcp-server",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs
Normal file
11
samples/AspNetCoreMcpServer/Resources/SimpleResourceType.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AspNetCoreMcpServer.Resources;
|
||||
|
||||
[McpServerResourceType]
|
||||
public class SimpleResourceType
|
||||
{
|
||||
[McpServerResource, Description("A direct text resource")]
|
||||
public static string DirectTextResource() => "This is a direct resource";
|
||||
}
|
||||
14
samples/AspNetCoreMcpServer/Tools/EchoTool.cs
Normal file
14
samples/AspNetCoreMcpServer/Tools/EchoTool.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AspNetCoreMcpServer.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class EchoTool
|
||||
{
|
||||
[McpServerTool, Description("Echoes the input back to the client.")]
|
||||
public static string Echo(string message)
|
||||
{
|
||||
return "hello " + message;
|
||||
}
|
||||
}
|
||||
31
samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs
Normal file
31
samples/AspNetCoreMcpServer/Tools/SampleLlmTool.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AspNetCoreMcpServer.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// This tool uses dependency injection and async method
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public sealed class SampleLlmTool
|
||||
{
|
||||
[McpServerTool(Name = "sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
|
||||
public static async Task<string> SampleLLM(
|
||||
IMcpServer thisServer,
|
||||
[Description("The prompt to send to the LLM")] string prompt,
|
||||
[Description("Maximum number of tokens to generate")] int maxTokens,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatOptions options = new()
|
||||
{
|
||||
Instructions = "You are a helpful test server.",
|
||||
MaxOutputTokens = maxTokens,
|
||||
Temperature = 0.7f,
|
||||
};
|
||||
|
||||
var samplingResponse = await thisServer.AsSamplingChatClient().GetResponseAsync(prompt, options, cancellationToken);
|
||||
|
||||
return $"LLM sampling result: {samplingResponse}";
|
||||
}
|
||||
}
|
||||
8
samples/AspNetCoreMcpServer/appsettings.Development.json
Normal file
8
samples/AspNetCoreMcpServer/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
samples/AspNetCoreMcpServer/appsettings.json
Normal file
9
samples/AspNetCoreMcpServer/appsettings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
148
samples/ProtectedMcpClient/Program.cs
Normal file
148
samples/ProtectedMcpClient/Program.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
|
||||
var serverUrl = "http://localhost:7071/";
|
||||
|
||||
Console.WriteLine("Protected MCP Client");
|
||||
Console.WriteLine($"Connecting to weather server at {serverUrl}...");
|
||||
Console.WriteLine();
|
||||
|
||||
// We can customize a shared HttpClient with a custom handler if desired
|
||||
var sharedHandler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
var httpClient = new HttpClient(sharedHandler);
|
||||
|
||||
var consoleLoggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.AddConsole();
|
||||
});
|
||||
|
||||
var transport = new SseClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri(serverUrl),
|
||||
Name = "Secure Weather Client",
|
||||
OAuth = new()
|
||||
{
|
||||
ClientName = "ProtectedMcpClient",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
}
|
||||
}, httpClient, consoleLoggerFactory);
|
||||
|
||||
var client = await McpClientFactory.CreateAsync(transport, loggerFactory: consoleLoggerFactory);
|
||||
|
||||
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 == "get_alerts"))
|
||||
{
|
||||
Console.WriteLine("Calling get_alerts tool...");
|
||||
|
||||
var result = await client.CallToolAsync(
|
||||
"get_alerts",
|
||||
new Dictionary<string, object?> { { "state", "WA" } }
|
||||
);
|
||||
|
||||
Console.WriteLine("Result: " + ((TextContentBlock)result.Content[0]).Text);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
|
||||
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
|
||||
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine("Starting OAuth authorization flow...");
|
||||
Console.WriteLine($"Opening browser to: {authorizationUrl}");
|
||||
|
||||
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
|
||||
if (!listenerPrefix.EndsWith("/")) listenerPrefix += "/";
|
||||
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add(listenerPrefix);
|
||||
|
||||
try
|
||||
{
|
||||
listener.Start();
|
||||
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
|
||||
|
||||
OpenBrowser(authorizationUrl);
|
||||
|
||||
var context = await listener.GetContextAsync();
|
||||
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
|
||||
var code = query["code"];
|
||||
var error = query["error"];
|
||||
|
||||
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(responseHtml);
|
||||
context.Response.ContentLength64 = buffer.Length;
|
||||
context.Response.ContentType = "text/html";
|
||||
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
|
||||
context.Response.Close();
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
Console.WriteLine($"Auth error: {error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
Console.WriteLine("No authorization code received");
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.WriteLine("Authorization code received successfully.");
|
||||
return code;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error getting auth code: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (listener.IsListening) listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the specified URL in the default browser.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to open.</param>
|
||||
static void OpenBrowser(Uri url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = url.ToString(),
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error opening browser. {ex.Message}");
|
||||
Console.WriteLine($"Please manually open this URL: {url}");
|
||||
}
|
||||
}
|
||||
18
samples/ProtectedMcpClient/ProtectedMcpClient.csproj
Normal file
18
samples/ProtectedMcpClient/ProtectedMcpClient.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
93
samples/ProtectedMcpClient/README.md
Normal file
93
samples/ProtectedMcpClient/README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Protected MCP Client Sample
|
||||
|
||||
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.
|
||||
|
||||
## Overview
|
||||
|
||||
The Protected MCP Client sample shows how to:
|
||||
- Connect to an OAuth-protected MCP server
|
||||
- Handle OAuth 2.0 authorization code flow
|
||||
- Use custom authorization redirect handling
|
||||
- Call protected MCP tools with authentication
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 9.0 or later
|
||||
- A running TestOAuthServer (for OAuth authentication)
|
||||
- A running ProtectedMcpServer (for MCP services)
|
||||
|
||||
## Setup and Running
|
||||
|
||||
### Step 1: Start the Test OAuth Server
|
||||
|
||||
First, you need to start the TestOAuthServer which provides OAuth authentication:
|
||||
|
||||
```bash
|
||||
cd tests\ModelContextProtocol.TestOAuthServer
|
||||
dotnet run --framework net9.0
|
||||
```
|
||||
|
||||
The OAuth server will start at `https://localhost:7029`
|
||||
|
||||
### Step 2: Start the Protected MCP Server
|
||||
|
||||
Next, start the ProtectedMcpServer which provides the weather tools:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMcpServer
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The protected server will start at `http://localhost:7071`
|
||||
|
||||
### Step 3: Run the Protected MCP Client
|
||||
|
||||
Finally, run this client:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMcpClient
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## What Happens
|
||||
|
||||
1. The client attempts to connect to the protected MCP server at `http://localhost:7071`
|
||||
2. The server responds with OAuth metadata indicating authentication is required
|
||||
3. The client initiates OAuth 2.0 authorization code flow:
|
||||
- Opens a browser to the authorization URL at the OAuth server
|
||||
- Starts a local HTTP listener on `http://localhost:1179/callback` to receive the authorization code
|
||||
- Exchanges the authorization code for an access token
|
||||
4. The client uses the access token to authenticate with the MCP server
|
||||
5. The client lists available tools and calls the `GetAlerts` tool for Washington state
|
||||
|
||||
## OAuth Configuration
|
||||
|
||||
The client is configured with:
|
||||
- **Client ID**: `demo-client`
|
||||
- **Client Secret**: `demo-secret`
|
||||
- **Redirect URI**: `http://localhost:1179/callback`
|
||||
- **OAuth Server**: `https://localhost:7029`
|
||||
- **Protected Resource**: `http://localhost:7071`
|
||||
|
||||
## Available Tools
|
||||
|
||||
Once authenticated, the client can access weather tools including:
|
||||
- **GetAlerts**: Get weather alerts for a US state
|
||||
- **GetForecast**: Get weather forecast for a location (latitude/longitude)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the ASP.NET Core dev certificate is trusted.
|
||||
```
|
||||
dotnet dev-certs https --clean
|
||||
dotnet dev-certs https --trust
|
||||
```
|
||||
- Ensure all three services are running in the correct order
|
||||
- Check that ports 7029, 7071, and 1179 are available
|
||||
- If the browser doesn't open automatically, copy the authorization URL from the console and open it manually
|
||||
- Make sure to allow the OAuth server's self-signed certificate in your browser
|
||||
|
||||
## Key Files
|
||||
|
||||
- `Program.cs`: Main client application with OAuth flow implementation
|
||||
- `ProtectedMcpClient.csproj`: Project file with dependencies
|
||||
93
samples/ProtectedMcpServer/Program.cs
Normal file
93
samples/ProtectedMcpServer/Program.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ModelContextProtocol.AspNetCore.Authentication;
|
||||
using ProtectedMcpServer.Tools;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var serverUrl = "http://localhost:7071/";
|
||||
var inMemoryOAuthServerUrl = "https://localhost:7029";
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
// Configure to validate tokens from our in-memory OAuth server
|
||||
options.Authority = inMemoryOAuthServerUrl;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidAudience = serverUrl, // Validate that the audience matches the resource metadata as suggested in RFC 8707
|
||||
ValidIssuer = inMemoryOAuthServerUrl,
|
||||
NameClaimType = "name",
|
||||
RoleClaimType = "roles"
|
||||
};
|
||||
|
||||
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.ResourceMetadata = new()
|
||||
{
|
||||
Resource = new Uri(serverUrl),
|
||||
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
|
||||
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
|
||||
ScopesSupported = ["mcp:tools"],
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddMcpServer()
|
||||
.WithTools<WeatherTools>()
|
||||
.WithHttpTransport();
|
||||
|
||||
// Configure HttpClientFactory for weather.gov API
|
||||
builder.Services.AddHttpClient("WeatherApi", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weather.gov");
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0"));
|
||||
});
|
||||
|
||||
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($"Using in-memory OAuth server at {inMemoryOAuthServerUrl}");
|
||||
Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
|
||||
Console.WriteLine("Press Ctrl+C to stop the server");
|
||||
|
||||
app.Run(serverUrl);
|
||||
12
samples/ProtectedMcpServer/Properties/launchSettings.json
Normal file
12
samples/ProtectedMcpServer/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"profiles": {
|
||||
"ProtectedMcpServer": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:7071"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
samples/ProtectedMcpServer/ProtectedMcpServer.csproj
Normal file
15
samples/ProtectedMcpServer/ProtectedMcpServer.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>783daef3-9c45-408d-a1d3-7caf44724f39</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
125
samples/ProtectedMcpServer/README.md
Normal file
125
samples/ProtectedMcpServer/README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Protected MCP Server Sample
|
||||
|
||||
This sample demonstrates how to create an MCP server that requires OAuth 2.0 authentication to access its tools and resources. The server provides weather-related tools protected by JWT bearer token authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
The Protected MCP Server sample shows how to:
|
||||
- Create an MCP server with OAuth 2.0 protection
|
||||
- Configure JWT bearer token authentication
|
||||
- Implement protected MCP tools and resources
|
||||
- Integrate with ASP.NET Core authentication and authorization
|
||||
- Provide OAuth resource metadata for client discovery
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 9.0 or later
|
||||
- A running TestOAuthServer (for OAuth authentication)
|
||||
|
||||
## Setup and Running
|
||||
|
||||
### Step 1: Start the Test OAuth Server
|
||||
|
||||
First, you need to start the TestOAuthServer which issues access tokens:
|
||||
|
||||
```bash
|
||||
cd tests\ModelContextProtocol.TestOAuthServer
|
||||
dotnet run --framework net9.0
|
||||
```
|
||||
|
||||
The OAuth server will start at `https://localhost:7029`
|
||||
|
||||
### Step 2: Start the Protected MCP Server
|
||||
|
||||
Run this protected server:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMcpServer
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The protected server will start at `http://localhost:7071`
|
||||
|
||||
### Step 3: Test with Protected MCP Client
|
||||
|
||||
You can test the server using the ProtectedMcpClient sample:
|
||||
|
||||
```bash
|
||||
cd samples\ProtectedMcpClient
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## What the Server Provides
|
||||
|
||||
### Protected Resources
|
||||
|
||||
- **MCP Endpoint**: `http://localhost:7071/` (requires authentication)
|
||||
- **OAuth Resource Metadata**: `http://localhost:7071/.well-known/oauth-protected-resource`
|
||||
|
||||
### Available Tools
|
||||
|
||||
The server provides weather-related tools that require authentication:
|
||||
|
||||
1. **GetAlerts**: Get weather alerts for a US state
|
||||
- Parameter: `state` (string) - 2-letter US state abbreviation
|
||||
- Example: `GetAlerts` with `state: "WA"`
|
||||
|
||||
2. **GetForecast**: Get weather forecast for a location
|
||||
- Parameters:
|
||||
- `latitude` (double) - Latitude coordinate
|
||||
- `longitude` (double) - Longitude coordinate
|
||||
- Example: `GetForecast` with `latitude: 47.6062, longitude: -122.3321`
|
||||
|
||||
### Authentication Configuration
|
||||
|
||||
The server is configured to:
|
||||
- Accept JWT bearer tokens from the OAuth server at `https://localhost:7029`
|
||||
- Validate token audience as `demo-client`
|
||||
- Require tokens to have appropriate scopes (`mcp:tools`)
|
||||
- Provide OAuth resource metadata for client discovery
|
||||
|
||||
## Architecture
|
||||
|
||||
The server uses:
|
||||
- **ASP.NET Core** for hosting and HTTP handling
|
||||
- **JWT Bearer Authentication** for token validation
|
||||
- **MCP Authentication Extensions** for OAuth resource metadata
|
||||
- **HttpClient** for calling the weather.gov API
|
||||
- **Authorization** to protect MCP endpoints
|
||||
|
||||
## Configuration Details
|
||||
|
||||
- **Server URL**: `http://localhost:7071`
|
||||
- **OAuth Server**: `https://localhost:7029`
|
||||
- **Demo Client ID**: `demo-client`
|
||||
|
||||
## Testing Without Client
|
||||
|
||||
You can test the server directly using HTTP tools:
|
||||
|
||||
1. Get an access token from the OAuth server
|
||||
2. Include the token in the `Authorization: Bearer <token>` header
|
||||
3. Make requests to the MCP endpoints
|
||||
|
||||
## External Dependencies
|
||||
|
||||
The weather tools use the National Weather Service API at `api.weather.gov` to fetch real weather data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the ASP.NET Core dev certificate is trusted.
|
||||
```
|
||||
dotnet dev-certs https --clean
|
||||
dotnet dev-certs https --trust
|
||||
```
|
||||
- Ensure the TestOAuthServer is running first
|
||||
- Check that port 7071 is available
|
||||
- Verify the OAuth server is accessible at `https://localhost:7029`
|
||||
- Check console output for authentication events and errors
|
||||
|
||||
## Key Files
|
||||
|
||||
- `Program.cs`: Server setup with authentication and MCP configuration
|
||||
- `Tools/WeatherTools.cs`: Weather tool implementations
|
||||
- `Tools/HttpClientExt.cs`: HTTP client extensions
|
||||
- `Properties/launchSettings.json`: Development launch configuration
|
||||
13
samples/ProtectedMcpServer/Tools/HttpClientExt.cs
Normal file
13
samples/ProtectedMcpServer/Tools/HttpClientExt.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
|
||||
internal static class HttpClientExt
|
||||
{
|
||||
public static async Task<JsonDocument> ReadJsonDocumentAsync(this HttpClient client, string requestUri)
|
||||
{
|
||||
using var response = await client.GetAsync(requestUri);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
}
|
||||
}
|
||||
67
samples/ProtectedMcpServer/Tools/WeatherTools.cs
Normal file
67
samples/ProtectedMcpServer/Tools/WeatherTools.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ProtectedMcpServer.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class WeatherTools
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public WeatherTools(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get weather alerts for a US state.")]
|
||||
public async Task<string> GetAlerts(
|
||||
[Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("WeatherApi");
|
||||
using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}");
|
||||
var jsonElement = jsonDocument.RootElement;
|
||||
var alerts = jsonElement.GetProperty("features").EnumerateArray();
|
||||
|
||||
if (!alerts.Any())
|
||||
{
|
||||
return "No active alerts for this state.";
|
||||
}
|
||||
|
||||
return string.Join("\n--\n", alerts.Select(alert =>
|
||||
{
|
||||
JsonElement properties = alert.GetProperty("properties");
|
||||
return $"""
|
||||
Event: {properties.GetProperty("event").GetString()}
|
||||
Area: {properties.GetProperty("areaDesc").GetString()}
|
||||
Severity: {properties.GetProperty("severity").GetString()}
|
||||
Description: {properties.GetProperty("description").GetString()}
|
||||
Instruction: {properties.GetProperty("instruction").GetString()}
|
||||
""";
|
||||
}));
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get weather forecast for a location.")]
|
||||
public async Task<string> GetForecast(
|
||||
[Description("Latitude of the location.")] double latitude,
|
||||
[Description("Longitude of the location.")] double longitude)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("WeatherApi");
|
||||
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
|
||||
using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl);
|
||||
var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
|
||||
?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}");
|
||||
|
||||
using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl);
|
||||
var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray();
|
||||
|
||||
return string.Join("\n---\n", periods.Select(period => $"""
|
||||
{period.GetProperty("name").GetString()}
|
||||
Temperature: {period.GetProperty("temperature").GetInt32()}°F
|
||||
Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
|
||||
Forecast: {period.GetProperty("detailedForecast").GetString()}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue