Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions IdentityServer/v7/McpDemo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
keys/
15 changes: 15 additions & 0 deletions IdentityServer/v7/McpDemo/McpDemo.Client/McpDemo.Client.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
</ItemGroup>

</Project>
160 changes: 160 additions & 0 deletions IdentityServer/v7/McpDemo/McpDemo.Client/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Web;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;

var mcpServerUrl = "https://localhost:7141";

Console.WriteLine("Protected MCP Client");
Console.WriteLine($"Connecting to server at {mcpServerUrl}...");
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 HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(mcpServerUrl),
Name = "Weather MCP Client",
OAuth = new ClientOAuthOptions
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new DynamicClientRegistrationOptions
{
ClientName = "ProtectedMcpClient"
},
// Odd that this config is required. I would expect the client to read the supported scopes from the MCP server's
// protected resource metadata and use the scopes listed there in its dynamic client registration request.
Scopes = ["mcp:tools"]
},
}, httpClient, consoleLoggerFactory);

var client = await McpClient.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"] = "NY" });

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)
{
// Validate the URI scheme - only allow safe protocols
if (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps)
{
Console.WriteLine($"Error: Only HTTP and HTTPS URLs are allowed.");
return;
}

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}");
}
}
25 changes: 25 additions & 0 deletions IdentityServer/v7/McpDemo/McpDemo.IdentityServer/Config.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Duende.IdentityServer.Models;

namespace McpDemo.IdentityServer;

public static class Config
{
public static IEnumerable<IdentityResource> IdentityResources =>
[
new IdentityResources.OpenId(),
new IdentityResources.Profile()
];

public static IEnumerable<ApiResource> ApiResources =>
[
new("https://localhost:7141/", "MCP Server")
{
Scopes = { "mcp:tools" }
}
];

public static IEnumerable<ApiScope> ApiScopes =>
[
new("mcp:tools")
];
}
129 changes: 129 additions & 0 deletions IdentityServer/v7/McpDemo/McpDemo.IdentityServer/HostingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Globalization;
using Duende.IdentityServer;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Configuration.Validation.DynamicClientRegistration;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using Serilog.Filters;

namespace McpDemo.IdentityServer;

internal static class HostingExtensions
{
public static WebApplicationBuilder ConfigureLogging(this WebApplicationBuilder builder)
{
// Write most logs to the console but diagnostic data to a file.
// See https://docs.duendesoftware.com/identityserver/diagnostics/data
builder.Host.UseSerilog((ctx, lc) =>
{
lc.WriteTo.Logger(consoleLogger =>
{
consoleLogger.WriteTo.Console(
outputTemplate:
"[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}",
formatProvider: CultureInfo.InvariantCulture);
if (builder.Environment.IsDevelopment())
{
consoleLogger.Filter.ByExcluding(Matching.FromSource("Duende.IdentityServer.Diagnostics.Summary"));
}
});
if (builder.Environment.IsDevelopment())
{
lc.WriteTo.Logger(fileLogger =>
{
fileLogger
.WriteTo.File("./diagnostics/diagnostic.log", rollingInterval: RollingInterval.Day,
fileSizeLimitBytes: 1024 * 1024 * 10, // 10 MB
rollOnFileSizeLimit: true,
outputTemplate:
"[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}",
formatProvider: CultureInfo.InvariantCulture)
.Filter
.ByIncludingOnly(Matching.FromSource("Duende.IdentityServer.Diagnostics.Summary"));
}).Enrich.FromLogContext().ReadFrom.Configuration(ctx.Configuration);
}
});
return builder;
}

public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.Services.AddRazorPages();

var isBuilder = builder.Services.AddIdentityServer(options =>
{
// this will add the default dynamic client registration endpoint to the discovery/metadatada documents
options.Discovery.DynamicClientRegistration.RegistrationEndpointMode = RegistrationEndpointMode.Inferred;

options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;

// Use a large chunk size for diagnostic logs in development where it will be redirected to a local file
if (builder.Environment.IsDevelopment())
{
options.Diagnostics.ChunkSize = 1024 * 1024 * 10; // 10 MB
}
})
.AddTestUsers(TestUsers.Users)
.AddLicenseSummary();

// in-memory, code config
isBuilder.AddInMemoryIdentityResources(Config.IdentityResources);
isBuilder.AddInMemoryApiScopes(Config.ApiScopes);
// since this will use DCR, we do not need any pre-configured clients
isBuilder.AddInMemoryClients([]);
isBuilder.AddInMemoryApiResources(Config.ApiResources);

builder.Services.AddIdentityServerConfiguration(_ => { })
// in memory is being used here to keep the demo simple. in a real scenario, a persistent storage
// mechanism is needed for client registrations to persist across application restarts
.AddInMemoryClientConfigurationStore();

builder.Services.AddAuthentication()
.AddOpenIdConnect("oidc", "Sign-in with demo.duendesoftware.com", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.SignOutScheme = IdentityServerConstants.SignoutScheme;
options.SaveTokens = true;

options.Authority = "https://demo.duendesoftware.com";
options.ClientId = "interactive.confidential";
options.ClientSecret = "secret";
options.ResponseType = "code";

options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
});

return builder.Build();
}

public static WebApplication ConfigurePipeline(this WebApplication app)
{
app.UseSerilogRequestLogging();

if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();

app.MapRazorPages()
.RequireAuthorization();

app.MapDynamicClientRegistration();

return app;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Duende.IdentityServer" version="7.4.0-rc.1" />
<PackageReference Include="Duende.IdentityServer.Configuration" Version="7.4.0-rc.1" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>


</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@page
@model McpDemo.IdentityServer.Pages.Account.AccessDeniedModel
@{
}
<div class="row">
<div class="col">
<h1>Access Denied</h1>
<p>You do not have permission to access that resource.</p>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace McpDemo.IdentityServer.Pages.Account;

public class AccessDeniedModel : PageModel
{
public void OnGet()
{
}
}
Loading
Loading