idsOptions)
+ {
+ if (!idsOptions.Value.Enabled)
+ return NotFound();
+
+ var request = await _interaction.GetAuthorizationContextAsync(userCode);
+ if (request is null)
+ return Content(BuildCodeEntryHtml("Invalid or expired code."), "text/html");
+
+ var result = await _signInManager.PasswordSignInAsync(username, password, isPersistent: false, lockoutOnFailure: false);
+ if (!result.Succeeded)
+ {
+ return Content(BuildLoginHtml(userCode, "Invalid username or password."), "text/html");
+ }
+
+ // Grant consent for all requested scopes
+ var consent = new Duende.IdentityServer.Models.ConsentResponse
+ {
+ ScopesValuesConsented = request.ValidatedResources.RawScopeValues,
+ };
+ await _interaction.HandleRequestAsync(userCode, consent);
+
+ return Content(BuildSuccessHtml(), "text/html");
+ }
+
+ // ── HTML templates ──────────────────────────────────────────────────
+
+ private static string BuildCodeEntryHtml(string? error = null)
+ {
+ var errorBlock = error is not null
+ ? $"""{System.Net.WebUtility.HtmlEncode(error)}
"""
+ : "";
+
+ return $$"""
+
+ Device Authorization — MCP Server
+
+
+
+
MCP Server — Device Authorization
+
Enter the code displayed in your terminal
+ {{errorBlock}}
+
+
+
+ """;
+ }
+
+ private static string BuildLoginHtml(string userCode, string? error = null)
+ {
+ var errorBlock = error is not null
+ ? $"""{System.Net.WebUtility.HtmlEncode(error)}
"""
+ : "";
+
+ return $$"""
+
+ Sign In — MCP Server
+
+
+
+
Sign In to MCP Server
+
Authenticate to authorize the device
+ {{errorBlock}}
+
+
+
+ """;
+ }
+
+ private static string BuildSuccessHtml()
+ {
+ return """
+
+ Authorized — MCP Server
+
+
+
+
✅
+
Device Authorized
+
You can close this window and return to your terminal.
+
+
+ """;
+ }
+}
diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerConfig.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerConfig.cs
new file mode 100644
index 0000000..c35dfd6
--- /dev/null
+++ b/src/McpServer.Support.Mcp/Identity/IdentityServerConfig.cs
@@ -0,0 +1,82 @@
+using Duende.IdentityServer.Models;
+using DuendeClient = Duende.IdentityServer.Models.Client;
+
+namespace McpServer.Support.Mcp.Identity;
+
+///
+/// Static IdentityServer resource and client definitions for the MCP Server.
+///
+internal static class IdentityServerConfig
+{
+ public static IEnumerable GetIdentityResources() =>
+ [
+ new IdentityResources.OpenId(),
+ new IdentityResources.Profile(),
+ new IdentityResources.Email(),
+ new IdentityResource("roles", "User roles", ["role", "realm_roles"]),
+ ];
+
+ public static IEnumerable GetApiScopes(IdentityServerOptions options) =>
+ [
+ new ApiScope(options.ApiScopeName, "MCP Server API")
+ {
+ UserClaims = ["role", "realm_roles", "preferred_username"],
+ },
+ ];
+
+ public static IEnumerable GetApiResources(IdentityServerOptions options) =>
+ [
+ new ApiResource(options.ApiResourceName, "MCP Server API")
+ {
+ Scopes = { options.ApiScopeName },
+ UserClaims = ["role", "realm_roles", "preferred_username"],
+ },
+ ];
+
+ public static IEnumerable GetClients(IdentityServerOptions options) =>
+ [
+ // Machine-to-machine client for agents and services
+ new DuendeClient
+ {
+ ClientId = "mcp-agent",
+ ClientName = "MCP Agent Client",
+ AllowedGrantTypes = GrantTypes.ClientCredentials,
+ ClientSecrets = { new Secret("mcp-agent-secret".Sha256()) },
+ AllowedScopes = { options.ApiScopeName },
+ },
+
+ // Interactive client for CLI tools (Device Authorization + Password flows)
+ new DuendeClient
+ {
+ ClientId = "mcp-director",
+ ClientName = "MCP Director CLI",
+ AllowedGrantTypes =
+ {
+ "urn:ietf:params:oauth:grant-type:device_code",
+ GrantType.ResourceOwnerPassword,
+ },
+ RequireClientSecret = false,
+ AllowedScopes = { "openid", "profile", "email", "roles", options.ApiScopeName },
+ AllowOfflineAccess = true,
+ AccessTokenLifetime = 3600,
+ RefreshTokenUsage = TokenUsage.ReUse,
+ RefreshTokenExpiration = TokenExpiration.Sliding,
+ SlidingRefreshTokenLifetime = 86400,
+ },
+
+ // Web/SPA client for pairing UI and browser-based access
+ new DuendeClient
+ {
+ ClientId = "mcp-web",
+ ClientName = "MCP Web Client",
+ AllowedGrantTypes = GrantTypes.Code,
+ RequirePkce = true,
+ RequireClientSecret = false,
+ RedirectUris = { "http://localhost:7147/auth/callback", "https://localhost:7147/auth/callback" },
+ PostLogoutRedirectUris = { "http://localhost:7147/", "https://localhost:7147/" },
+ AllowedCorsOrigins = { "http://localhost:7147", "https://localhost:7147" },
+ AllowedScopes = { "openid", "profile", "email", "roles", options.ApiScopeName },
+ AllowOfflineAccess = true,
+ },
+ ];
+}
diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerExtensions.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerExtensions.cs
new file mode 100644
index 0000000..19f44df
--- /dev/null
+++ b/src/McpServer.Support.Mcp/Identity/IdentityServerExtensions.cs
@@ -0,0 +1,76 @@
+using Duende.IdentityServer;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace McpServer.Support.Mcp.Identity;
+
+///
+/// Extension methods to register the embedded IdentityServer in the MCP Server host.
+///
+internal static class IdentityServerExtensions
+{
+ public static IServiceCollection AddMcpIdentityServer(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ var options = configuration.GetSection(IdentityServerOptions.SectionName).Get()
+ ?? new IdentityServerOptions();
+
+ if (!options.Enabled)
+ return services;
+
+ var identityConnectionString = options.ConnectionString;
+ if (string.IsNullOrWhiteSpace(identityConnectionString))
+ {
+ // Default to SQL Server LocalDB
+ identityConnectionString = $"Server=(localdb)\\MSSQLLocalDB;Database={options.DatabaseName};Trusted_Connection=True;MultipleActiveResultSets=true";
+ }
+
+ // ASP.NET Core Identity backed by SQL Server
+ services.AddDbContext(opts =>
+ opts.UseSqlServer(identityConnectionString));
+
+ services.AddIdentity(opts =>
+ {
+ opts.Password.RequireDigit = false;
+ opts.Password.RequiredLength = 4;
+ opts.Password.RequireNonAlphanumeric = false;
+ opts.Password.RequireUppercase = false;
+ opts.Password.RequireLowercase = false;
+ opts.User.RequireUniqueEmail = false;
+ })
+ .AddEntityFrameworkStores()
+ .AddDefaultTokenProviders();
+
+ // Duende IdentityServer
+ var isBuilder = services.AddIdentityServer(idsvr =>
+ {
+ if (!string.IsNullOrWhiteSpace(options.IssuerUri))
+ idsvr.IssuerUri = options.IssuerUri;
+
+ idsvr.EmitStaticAudienceClaim = true;
+ idsvr.UserInteraction.DeviceVerificationUrl = "/device";
+ idsvr.UserInteraction.DeviceVerificationUserCodeParameter = "userCode";
+ })
+ .AddAspNetIdentity()
+ .AddInMemoryIdentityResources(IdentityServerConfig.GetIdentityResources())
+ .AddInMemoryApiScopes(IdentityServerConfig.GetApiScopes(options))
+ .AddInMemoryApiResources(IdentityServerConfig.GetApiResources(options))
+ .AddInMemoryClients(IdentityServerConfig.GetClients(options));
+
+ return services;
+ }
+
+ public static WebApplication UseMcpIdentityServer(this WebApplication app)
+ {
+ var options = app.Configuration.GetSection(IdentityServerOptions.SectionName).Get()
+ ?? new IdentityServerOptions();
+
+ if (!options.Enabled)
+ return app;
+
+ app.UseIdentityServer();
+
+ return app;
+ }
+}
diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerOptions.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerOptions.cs
new file mode 100644
index 0000000..59bb1c8
--- /dev/null
+++ b/src/McpServer.Support.Mcp/Identity/IdentityServerOptions.cs
@@ -0,0 +1,38 @@
+namespace McpServer.Support.Mcp.Identity;
+
+///
+/// Configuration options for the embedded IdentityServer instance.
+/// Bound from Mcp:IdentityServer configuration section.
+///
+public sealed class IdentityServerOptions
+{
+ /// Configuration section path.
+ public const string SectionName = "Mcp:IdentityServer";
+
+ /// Whether the embedded IdentityServer is enabled. Default: false.
+ public bool Enabled { get; set; }
+
+ /// The issuer URI for tokens. Defaults to the server's base URL.
+ public string IssuerUri { get; set; } = "";
+
+ /// SQL Server connection string for identity data. When empty, defaults to LocalDB.
+ public string ConnectionString { get; set; } = "";
+
+ /// Database name for the identity store (used in default LocalDB connection string).
+ public string DatabaseName { get; set; } = "McpIdentity";
+
+ /// Whether to seed default clients and resources on startup.
+ public bool SeedDefaults { get; set; } = true;
+
+ /// Default admin username seeded on first run.
+ public string DefaultAdminUser { get; set; } = "admin";
+
+ /// Default admin password seeded on first run. Change after initial setup.
+ public string DefaultAdminPassword { get; set; } = "McpAdmin1!";
+
+ /// API scope name for the MCP Server API.
+ public string ApiScopeName { get; set; } = "mcp-api";
+
+ /// API resource name.
+ public string ApiResourceName { get; set; } = "mcp-server-api";
+}
diff --git a/src/McpServer.Support.Mcp/Identity/IdentityServerSeeder.cs b/src/McpServer.Support.Mcp/Identity/IdentityServerSeeder.cs
new file mode 100644
index 0000000..3a1a333
--- /dev/null
+++ b/src/McpServer.Support.Mcp/Identity/IdentityServerSeeder.cs
@@ -0,0 +1,75 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace McpServer.Support.Mcp.Identity;
+
+///
+/// Seeds the IdentityServer databases with default configuration and an admin user on first run.
+///
+internal static class IdentityServerSeeder
+{
+ public static async Task SeedAsync(IServiceProvider services, IdentityServerOptions options)
+ {
+ using var scope = services.CreateScope();
+ var sp = scope.ServiceProvider;
+
+ // Create Identity database schema (no migrations assembly — use EnsureCreated)
+ var identityDb = sp.GetRequiredService();
+ await identityDb.Database.EnsureCreatedAsync();
+
+ // Seed default admin user
+ var userManager = sp.GetRequiredService>();
+ var roleManager = sp.GetRequiredService>();
+
+ foreach (var roleName in new[] { "admin", "agent-manager" })
+ {
+ if (!await roleManager.RoleExistsAsync(roleName))
+ await roleManager.CreateAsync(new IdentityRole(roleName));
+ }
+
+ var adminUser = await userManager.FindByNameAsync(options.DefaultAdminUser);
+ if (adminUser is null)
+ {
+ adminUser = new McpUser
+ {
+ UserName = options.DefaultAdminUser,
+ Email = $"{options.DefaultAdminUser}@localhost",
+ EmailConfirmed = true,
+ DisplayName = "MCP Administrator",
+ };
+ var result = await userManager.CreateAsync(adminUser, options.DefaultAdminPassword);
+ if (result.Succeeded)
+ {
+ await userManager.AddToRolesAsync(adminUser, ["admin", "agent-manager"]);
+ }
+ }
+
+ // Seed additional users
+ await EnsureUserAsync(userManager, "plbyrd", "plbyrd", "P.L. Byrd", ["admin", "agent-manager"]);
+ }
+
+ private static async Task EnsureUserAsync(
+ UserManager userManager,
+ string userName,
+ string password,
+ string displayName,
+ string[] roles)
+ {
+ var existing = await userManager.FindByNameAsync(userName);
+ if (existing is not null)
+ return;
+
+ var user = new McpUser
+ {
+ UserName = userName,
+ Email = $"{userName}@localhost",
+ EmailConfirmed = true,
+ DisplayName = displayName,
+ };
+ var result = await userManager.CreateAsync(user, password);
+ if (result.Succeeded)
+ {
+ await userManager.AddToRolesAsync(user, roles);
+ }
+ }
+}
diff --git a/src/McpServer.Support.Mcp/Identity/McpIdentityDbContext.cs b/src/McpServer.Support.Mcp/Identity/McpIdentityDbContext.cs
new file mode 100644
index 0000000..b1e473f
--- /dev/null
+++ b/src/McpServer.Support.Mcp/Identity/McpIdentityDbContext.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace McpServer.Support.Mcp.Identity;
+
+///
+/// Identity DbContext for IdentityServer user management.
+/// Uses a separate SQLite database from the main MCP data store.
+///
+public sealed class McpIdentityDbContext : IdentityDbContext
+{
+ /// Initializes a new instance with the specified options.
+ public McpIdentityDbContext(DbContextOptions options)
+ : base(options) { }
+}
diff --git a/src/McpServer.Support.Mcp/Identity/McpUser.cs b/src/McpServer.Support.Mcp/Identity/McpUser.cs
new file mode 100644
index 0000000..f571bd4
--- /dev/null
+++ b/src/McpServer.Support.Mcp/Identity/McpUser.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace McpServer.Support.Mcp.Identity;
+
+///
+/// Application user for IdentityServer authentication.
+///
+public sealed class McpUser : IdentityUser
+{
+ /// Display name shown in tokens and UI.
+ public string? DisplayName { get; set; }
+}
diff --git a/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj b/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj
index 9083f6f..285caa2 100644
--- a/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj
+++ b/src/McpServer.Support.Mcp/McpServer.Support.Mcp.csproj
@@ -17,11 +17,15 @@