diff --git a/.gitignore b/.gitignore
index 74e9613..f53e0f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,6 @@ demo_token.json
## misc
AuthTokens/
Sample/AuthTokens/
+
+.nuget/
+temp/
\ No newline at end of file
diff --git a/MCPify/Core/Auth/IAccessTokenValidator.cs b/MCPify/Core/Auth/IAccessTokenValidator.cs
deleted file mode 100644
index 519e47c..0000000
--- a/MCPify/Core/Auth/IAccessTokenValidator.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace MCPify.Core.Auth;
-
-///
-/// Interface for validating access tokens.
-///
-public interface IAccessTokenValidator
-{
- ///
- /// Validates an access token and returns the validation result.
- ///
- /// The access token to validate.
- /// Optional expected audience value. If null, audience validation is skipped.
- /// Cancellation token.
- /// A containing the validation outcome and extracted claims.
- Task ValidateAsync(string token, string? expectedAudience, CancellationToken cancellationToken = default);
-}
diff --git a/MCPify/Core/Auth/JwtAccessTokenValidator.cs b/MCPify/Core/Auth/JwtAccessTokenValidator.cs
deleted file mode 100644
index c6f8dec..0000000
--- a/MCPify/Core/Auth/JwtAccessTokenValidator.cs
+++ /dev/null
@@ -1,203 +0,0 @@
-using System.Text;
-using System.Text.Json;
-
-namespace MCPify.Core.Auth;
-
-///
-/// JWT access token validator that parses and validates JWT tokens without signature verification.
-/// This is suitable for tokens that have already been cryptographically validated by the authorization server.
-/// Performs expiration, audience, and scope claim extraction.
-///
-public class JwtAccessTokenValidator : IAccessTokenValidator
-{
- private readonly TokenValidationOptions _options;
- private static readonly string[] ScopeClaimNames = { "scope", "scp", "scopes" };
-
- public JwtAccessTokenValidator(TokenValidationOptions options)
- {
- _options = options;
- }
-
- public Task ValidateAsync(string token, string? expectedAudience, CancellationToken cancellationToken = default)
- {
- try
- {
- var parts = token.Split('.');
- if (parts.Length < 2)
- {
- return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token is not a valid JWT format"));
- }
-
- var payloadJson = Base64UrlDecode(parts[1]);
- using var doc = JsonDocument.Parse(payloadJson);
- var root = doc.RootElement;
-
- // Extract claims
- var subject = GetStringClaim(root, "sub");
- var issuer = GetStringClaim(root, "iss");
- var audiences = GetAudienceClaim(root);
- var scopes = GetScopeClaim(root);
- var expiresAt = GetExpirationClaim(root);
-
- // Validate expiration
- if (expiresAt.HasValue)
- {
- var now = DateTimeOffset.UtcNow;
- if (expiresAt.Value.Add(_options.ClockSkew) < now)
- {
- return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token has expired"));
- }
- }
-
- // Validate audience if requested
- if (_options.ValidateAudience && !string.IsNullOrEmpty(expectedAudience))
- {
- if (audiences.Count == 0 || !audiences.Any(a => string.Equals(a, expectedAudience, StringComparison.OrdinalIgnoreCase)))
- {
- return Task.FromResult(TokenValidationResult.Failure("invalid_token", $"Token audience does not match expected value: {expectedAudience}"));
- }
- }
-
- return Task.FromResult(TokenValidationResult.Success(
- scopes: scopes,
- subject: subject,
- audiences: audiences,
- issuer: issuer,
- expiresAt: expiresAt
- ));
- }
- catch (JsonException)
- {
- return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token payload is not valid JSON"));
- }
- catch (FormatException)
- {
- return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token payload is not valid Base64URL"));
- }
- catch (Exception ex)
- {
- return Task.FromResult(TokenValidationResult.Failure("invalid_token", $"Token validation failed: {ex.Message}"));
- }
- }
-
- private static string? GetStringClaim(JsonElement root, string claimName)
- {
- if (root.TryGetProperty(claimName, out var claim) && claim.ValueKind == JsonValueKind.String)
- {
- return claim.GetString();
- }
- return null;
- }
-
- private List GetAudienceClaim(JsonElement root)
- {
- if (!root.TryGetProperty("aud", out var audClaim))
- {
- return new List();
- }
-
- if (audClaim.ValueKind == JsonValueKind.String)
- {
- var value = audClaim.GetString();
- return value != null ? new List { value } : new List();
- }
-
- if (audClaim.ValueKind == JsonValueKind.Array)
- {
- var audiences = new List();
- foreach (var item in audClaim.EnumerateArray())
- {
- if (item.ValueKind == JsonValueKind.String)
- {
- var value = item.GetString();
- if (value != null)
- {
- audiences.Add(value);
- }
- }
- }
- return audiences;
- }
-
- return new List();
- }
-
- private List GetScopeClaim(JsonElement root)
- {
- // Try the configured claim name first, then fall back to common alternatives
- var claimNamesToTry = new List { _options.ScopeClaimName };
- foreach (var name in ScopeClaimNames)
- {
- if (!claimNamesToTry.Contains(name, StringComparer.OrdinalIgnoreCase))
- {
- claimNamesToTry.Add(name);
- }
- }
-
- foreach (var claimName in claimNamesToTry)
- {
- if (!root.TryGetProperty(claimName, out var scopeClaim))
- {
- continue;
- }
-
- if (scopeClaim.ValueKind == JsonValueKind.String)
- {
- var value = scopeClaim.GetString();
- if (!string.IsNullOrEmpty(value))
- {
- // Scopes are space-separated per RFC 6749
- return value.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
- }
- }
-
- if (scopeClaim.ValueKind == JsonValueKind.Array)
- {
- var scopes = new List();
- foreach (var item in scopeClaim.EnumerateArray())
- {
- if (item.ValueKind == JsonValueKind.String)
- {
- var value = item.GetString();
- if (!string.IsNullOrEmpty(value))
- {
- scopes.Add(value);
- }
- }
- }
- return scopes;
- }
- }
-
- return new List();
- }
-
- private static DateTimeOffset? GetExpirationClaim(JsonElement root)
- {
- if (!root.TryGetProperty("exp", out var expClaim))
- {
- return null;
- }
-
- if (expClaim.ValueKind == JsonValueKind.Number)
- {
- var unixTime = expClaim.GetInt64();
- return DateTimeOffset.FromUnixTimeSeconds(unixTime);
- }
-
- return null;
- }
-
- private static byte[] Base64UrlDecode(string input)
- {
- var output = input.Replace('-', '+').Replace('_', '/');
- switch (output.Length % 4)
- {
- case 0: break;
- case 2: output += "=="; break;
- case 3: output += "="; break;
- default: throw new FormatException("Illegal base64url string!");
- }
- return Convert.FromBase64String(output);
- }
-}
diff --git a/MCPify/Core/Auth/ScopeRequirement.cs b/MCPify/Core/Auth/ScopeRequirement.cs
index f6dc697..d4cecc7 100644
--- a/MCPify/Core/Auth/ScopeRequirement.cs
+++ b/MCPify/Core/Auth/ScopeRequirement.cs
@@ -1,99 +1,69 @@
using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Authorization;
namespace MCPify.Core.Auth;
///
-/// Defines scope requirements for a tool or endpoint.
+/// Authorization requirement that enforces the presence of a matching OAuth scope.
+/// Supports simple glob-style patterns ("*" and "?").
///
-public class ScopeRequirement
+public sealed class ScopeRequirement : IAuthorizationRequirement, IAuthorizationRequirementData
{
- ///
- /// Tool name pattern to match. Supports wildcards:
- /// - '*' matches any sequence of characters
- /// - '?' matches any single character
- /// Examples: "admin_*", "api_get_*", "tool_name"
- ///
- public required string Pattern { get; init; }
+ private string _pattern = "*";
///
- /// Scopes that must ALL be present in the token.
- /// If empty, only is checked.
+ /// Initializes a new instance of the class.
///
- public List RequiredScopes { get; init; } = new();
+ public ScopeRequirement()
+ {
+ }
///
- /// At least ONE of these scopes must be present in the token.
- /// If empty, only is checked.
+ /// Initializes a new instance of the class.
///
- public List AnyOfScopes { get; init; } = new();
-
- private Regex? _compiledPattern;
+ /// The scope pattern that must match the granted scopes.
+ public ScopeRequirement(string pattern)
+ {
+ Pattern = pattern;
+ }
///
- /// Checks if this requirement applies to the given tool name.
+ /// Gets or sets the scope pattern required for the protected resource.
+ /// The pattern supports '*' (zero or more characters) and '?' (single character) wildcards.
///
- public bool Matches(string toolName)
+ public string Pattern
{
- _compiledPattern ??= CompilePattern(Pattern);
- return _compiledPattern.IsMatch(toolName);
+ get => _pattern;
+ init => _pattern = string.IsNullOrWhiteSpace(value) ? "*" : value;
}
///
- /// Validates that the provided scopes satisfy this requirement.
+ /// Evaluates whether the supplied scope value satisfies this requirement.
///
- /// Scopes from the access token.
- /// True if the scopes satisfy the requirement, false otherwise.
- public bool IsSatisfiedBy(IEnumerable tokenScopes)
+ /// The scope value to evaluate.
+ public bool Matches(string? scope)
{
- var scopeSet = new HashSet(tokenScopes, StringComparer.OrdinalIgnoreCase);
-
- // Check RequiredScopes - all must be present
- if (RequiredScopes.Count > 0)
+ if (scope is null)
{
- foreach (var required in RequiredScopes)
- {
- if (!scopeSet.Contains(required))
- {
- return false;
- }
- }
+ return false;
}
- // Check AnyOfScopes - at least one must be present
- if (AnyOfScopes.Count > 0)
+ if (_pattern == "*")
{
- var hasAny = AnyOfScopes.Any(s => scopeSet.Contains(s));
- if (!hasAny)
- {
- return false;
- }
+ return true;
}
- return true;
- }
+ var comparison = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
+ var regexPattern = "^" + Regex.Escape(_pattern)
+ .Replace("\\*", ".*")
+ .Replace("\\?", ".") + "$";
- ///
- /// Gets all scopes that are required by this requirement (for error messages).
- ///
- public IEnumerable GetAllRequiredScopes()
- {
- foreach (var scope in RequiredScopes)
- {
- yield return scope;
- }
- foreach (var scope in AnyOfScopes)
- {
- yield return scope;
- }
+ return Regex.IsMatch(scope, regexPattern, comparison);
}
- private static Regex CompilePattern(string pattern)
+ ///
+ public IEnumerable GetRequirements()
{
- // Escape regex special characters except * and ?
- var escaped = Regex.Escape(pattern)
- .Replace("\\*", ".*")
- .Replace("\\?", ".");
-
- return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ yield return this;
}
}
diff --git a/MCPify/Core/Auth/ScopeRequirementHandler.cs b/MCPify/Core/Auth/ScopeRequirementHandler.cs
new file mode 100644
index 0000000..0cc6e0b
--- /dev/null
+++ b/MCPify/Core/Auth/ScopeRequirementHandler.cs
@@ -0,0 +1,75 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+
+namespace MCPify.Core.Auth;
+
+///
+/// Evaluates instances against the authenticated user's scopes.
+///
+public sealed class ScopeRequirementHandler : AuthorizationHandler
+{
+ private static readonly string[] ScopeClaimTypes =
+ {
+ "scope",
+ "scp",
+ "http://schemas.microsoft.com/identity/claims/scope"
+ };
+
+ ///
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement)
+ {
+ if (context.User is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ var scopes = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var claim in context.User.Claims)
+ {
+ if (!ScopeClaimTypes.Contains(claim.Type))
+ {
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(claim.Value))
+ {
+ continue;
+ }
+
+ foreach (var entry in SplitScopes(claim.Value))
+ {
+ scopes.Add(entry);
+ }
+ }
+
+ if (scopes.Any(requirement.Matches))
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static IEnumerable SplitScopes(string value)
+ {
+ if (value.Contains(' '))
+ {
+ foreach (var part in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ yield return part;
+ }
+ }
+ else if (value.Contains(','))
+ {
+ foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ yield return part;
+ }
+ }
+ else
+ {
+ yield return value.Trim();
+ }
+ }
+}
diff --git a/MCPify/Core/Auth/ScopeRequirementStore.cs b/MCPify/Core/Auth/ScopeRequirementStore.cs
deleted file mode 100644
index 13e39a5..0000000
--- a/MCPify/Core/Auth/ScopeRequirementStore.cs
+++ /dev/null
@@ -1,145 +0,0 @@
-namespace MCPify.Core.Auth;
-
-///
-/// Registry for scope requirements with pattern matching.
-/// Determines required scopes for tools based on configured patterns.
-///
-public class ScopeRequirementStore
-{
- private readonly List _requirements;
- private readonly TokenValidationOptions _options;
- private readonly OAuthConfigurationStore? _oauthStore;
-
- public ScopeRequirementStore(IEnumerable requirements, TokenValidationOptions options, OAuthConfigurationStore? oauthStore = null)
- {
- _requirements = requirements.ToList();
- _options = options;
- _oauthStore = oauthStore;
- }
-
- ///
- /// Gets all scope requirements that apply to the given tool name.
- ///
- /// The name of the tool.
- /// All matching scope requirements.
- public IEnumerable GetRequirementsForTool(string toolName)
- {
- return _requirements.Where(r => r.Matches(toolName));
- }
-
- ///
- /// Validates that the provided scopes satisfy all requirements for the given tool.
- ///
- /// The name of the tool.
- /// Scopes from the access token.
- /// A validation result with missing scopes if validation fails.
- public ScopeValidationResult ValidateScopesForTool(string toolName, IEnumerable tokenScopes)
- {
- var scopeList = tokenScopes.ToList();
- var scopeSet = new HashSet(scopeList, StringComparer.OrdinalIgnoreCase);
- var missingScopes = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- // Check default required scopes first
- foreach (var defaultScope in _options.DefaultRequiredScopes)
- {
- if (!scopeSet.Contains(defaultScope))
- {
- missingScopes.Add(defaultScope);
- }
- }
-
- // Check OAuth-configured scopes if enabled
- if (_options.RequireOAuthConfiguredScopes && _oauthStore != null)
- {
- var oauthScopes = _oauthStore.GetConfigurations()
- .SelectMany(c => c.Scopes.Keys)
- .Distinct(StringComparer.OrdinalIgnoreCase);
-
- foreach (var oauthScope in oauthScopes)
- {
- if (!scopeSet.Contains(oauthScope))
- {
- missingScopes.Add(oauthScope);
- }
- }
- }
-
- // Check tool-specific requirements
- var matchingRequirements = GetRequirementsForTool(toolName).ToList();
- foreach (var requirement in matchingRequirements)
- {
- if (!requirement.IsSatisfiedBy(scopeList))
- {
- // Add all scopes from this requirement to missing list
- foreach (var scope in requirement.GetAllRequiredScopes())
- {
- if (!scopeSet.Contains(scope))
- {
- missingScopes.Add(scope);
- }
- }
- }
- }
-
- if (missingScopes.Count > 0)
- {
- return ScopeValidationResult.Failure(missingScopes.ToList());
- }
-
- return ScopeValidationResult.Success();
- }
-
- ///
- /// Gets all required scopes for a tool (for WWW-Authenticate header).
- ///
- public IEnumerable GetRequiredScopesForTool(string toolName)
- {
- var scopes = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- // Add default scopes
- foreach (var scope in _options.DefaultRequiredScopes)
- {
- scopes.Add(scope);
- }
-
- // Add OAuth-configured scopes if enabled
- if (_options.RequireOAuthConfiguredScopes && _oauthStore != null)
- {
- foreach (var config in _oauthStore.GetConfigurations())
- {
- foreach (var scope in config.Scopes.Keys)
- {
- scopes.Add(scope);
- }
- }
- }
-
- // Add tool-specific scopes
- foreach (var requirement in GetRequirementsForTool(toolName))
- {
- foreach (var scope in requirement.GetAllRequiredScopes())
- {
- scopes.Add(scope);
- }
- }
-
- return scopes;
- }
-}
-
-///
-/// Result of scope validation.
-///
-public class ScopeValidationResult
-{
- public bool IsValid { get; init; }
- public IReadOnlyList MissingScopes { get; init; } = Array.Empty();
-
- public static ScopeValidationResult Success() => new() { IsValid = true };
-
- public static ScopeValidationResult Failure(IReadOnlyList missingScopes) => new()
- {
- IsValid = false,
- MissingScopes = missingScopes
- };
-}
diff --git a/MCPify/Core/Auth/TokenValidationOptions.cs b/MCPify/Core/Auth/TokenValidationOptions.cs
deleted file mode 100644
index 0af80a7..0000000
--- a/MCPify/Core/Auth/TokenValidationOptions.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-namespace MCPify.Core.Auth;
-
-///
-/// Configuration options for token validation behavior.
-/// Token validation is opt-in for backward compatibility.
-///
-public class TokenValidationOptions
-{
- ///
- /// When true, enables JWT token validation including expiration, audience, and scope checks.
- /// Defaults to false for backward compatibility.
- ///
- public bool EnableJwtValidation { get; set; } = false;
-
- ///
- /// When true, validates that the token's 'aud' claim matches the expected audience (resource URL).
- /// Only applies when is true.
- ///
- public bool ValidateAudience { get; set; } = false;
-
- ///
- /// When true, validates that the token contains required scopes for the requested operation.
- /// Only applies when is true.
- ///
- public bool ValidateScopes { get; set; } = false;
-
- ///
- /// The expected audience value for token validation.
- /// If not set, defaults to the resource URL.
- ///
- public string? ExpectedAudience { get; set; }
-
- ///
- /// Default scopes required for all endpoints when scope validation is enabled.
- /// Specific endpoints can override this with .
- ///
- public List DefaultRequiredScopes { get; set; } = new();
-
- ///
- /// When true, automatically requires all scopes defined in
- /// from the in addition to .
- /// Defaults to false for backward compatibility.
- ///
- public bool RequireOAuthConfiguredScopes { get; set; } = false;
-
- ///
- /// The claim name used for scopes in the JWT token.
- /// Common values: "scope", "scp", "scopes".
- /// Defaults to "scope".
- ///
- public string ScopeClaimName { get; set; } = "scope";
-
- ///
- /// Allowed clock skew for token expiration validation.
- /// Defaults to 5 minutes.
- ///
- public TimeSpan ClockSkew { get; set; } = TimeSpan.FromMinutes(5);
-}
diff --git a/MCPify/Core/Auth/TokenValidationResult.cs b/MCPify/Core/Auth/TokenValidationResult.cs
deleted file mode 100644
index bcf4a20..0000000
--- a/MCPify/Core/Auth/TokenValidationResult.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-namespace MCPify.Core.Auth;
-
-///
-/// Result of access token validation.
-///
-public class TokenValidationResult
-{
- ///
- /// Whether the token is valid.
- ///
- public bool IsValid { get; init; }
-
- ///
- /// Error code when validation fails (e.g., "invalid_token", "expired_token").
- ///
- public string? ErrorCode { get; init; }
-
- ///
- /// Human-readable description of the error.
- ///
- public string? ErrorDescription { get; init; }
-
- ///
- /// Scopes extracted from the token.
- ///
- public IReadOnlyList Scopes { get; init; } = Array.Empty();
-
- ///
- /// The subject (sub) claim from the token.
- ///
- public string? Subject { get; init; }
-
- ///
- /// The audiences (aud) claim from the token.
- ///
- public IReadOnlyList Audiences { get; init; } = Array.Empty();
-
- ///
- /// The issuer (iss) claim from the token.
- ///
- public string? Issuer { get; init; }
-
- ///
- /// Token expiration time if present.
- ///
- public DateTimeOffset? ExpiresAt { get; init; }
-
- ///
- /// Creates a successful validation result.
- ///
- public static TokenValidationResult Success(
- IReadOnlyList? scopes = null,
- string? subject = null,
- IReadOnlyList? audiences = null,
- string? issuer = null,
- DateTimeOffset? expiresAt = null)
- {
- return new TokenValidationResult
- {
- IsValid = true,
- Scopes = scopes ?? Array.Empty(),
- Subject = subject,
- Audiences = audiences ?? Array.Empty(),
- Issuer = issuer,
- ExpiresAt = expiresAt
- };
- }
-
- ///
- /// Creates a failed validation result.
- ///
- public static TokenValidationResult Failure(string errorCode, string errorDescription)
- {
- return new TokenValidationResult
- {
- IsValid = false,
- ErrorCode = errorCode,
- ErrorDescription = errorDescription
- };
- }
-}
diff --git a/MCPify/Core/McpContextAccessor.cs b/MCPify/Core/McpContextAccessor.cs
index 00a653a..c97685a 100644
--- a/MCPify/Core/McpContextAccessor.cs
+++ b/MCPify/Core/McpContextAccessor.cs
@@ -1,6 +1,3 @@
-using System.Threading;
-using System.Threading.Tasks;
-
namespace MCPify.Core;
public interface IMcpContextAccessor
@@ -12,94 +9,9 @@ public interface IMcpContextAccessor
public class McpContextAccessor : IMcpContextAccessor
{
- private static readonly System.Threading.AsyncLocal _mcpContextCurrent = new System.Threading.AsyncLocal();
-
- public string? SessionId
- {
- get => _mcpContextCurrent.Value?.Context?.SessionId;
- set
- {
- var holder = _mcpContextCurrent.Value;
- if (holder == null)
- {
- holder = new McpContextHolder();
- _mcpContextCurrent.Value = holder;
- }
-
- if (holder.Context == null)
- {
- holder.Context = new McpContext();
- }
- holder.Context.SessionId = value;
- }
- }
-
- public string? ConnectionId
- {
- get => _mcpContextCurrent.Value?.Context?.ConnectionId;
- set
- {
- var holder = _mcpContextCurrent.Value;
- if (holder == null)
- {
- holder = new McpContextHolder();
- _mcpContextCurrent.Value = holder;
- }
-
- if (holder.Context == null)
- {
- holder.Context = new McpContext();
- }
- holder.Context.ConnectionId = value;
- }
- }
-
- public string? AccessToken
- {
- get => _mcpContextCurrent.Value?.Context?.AccessToken;
- set
- {
- var holder = _mcpContextCurrent.Value;
- if (holder == null)
- {
- holder = new McpContextHolder();
- _mcpContextCurrent.Value = holder;
- }
-
- if (holder.Context == null)
- {
- holder.Context = new McpContext();
- }
- holder.Context.AccessToken = value;
- }
- }
-
- internal static McpContext? CurrentContext
- {
- get => _mcpContextCurrent.Value?.Context;
- set
- {
- var holder = _mcpContextCurrent.Value;
- if (holder != null)
- {
- holder.Context = value;
- }
- else
- {
- _mcpContextCurrent.Value = new McpContextHolder { Context = value };
- }
- }
- }
+ public string? SessionId { get; set; }
- public class McpContext
- {
- public string? SessionId { get; set; }
- public string? ConnectionId { get; set; }
- public string? AccessToken { get; set; }
- }
+ public string? ConnectionId { get; set; }
- private class McpContextHolder
- {
- public McpContext? Context;
- }
+ public string? AccessToken { get; set; }
}
\ No newline at end of file
diff --git a/MCPify/Core/McpifyOptions.cs b/MCPify/Core/McpifyOptions.cs
index ecd9867..8254d47 100644
--- a/MCPify/Core/McpifyOptions.cs
+++ b/MCPify/Core/McpifyOptions.cs
@@ -1,6 +1,6 @@
+using MCPify.Core.Auth;
using MCPify.OpenApi;
using MCPify.Schema;
-using MCPify.Core.Auth;
using Microsoft.AspNetCore.Http;
namespace MCPify.Core;
@@ -70,17 +70,6 @@ public class McpifyOptions
///
public List OAuthConfigurations { get; set; } = new();
- ///
- /// Configuration for JWT token validation behavior.
- /// When null or EnableJwtValidation is false, token validation is skipped for backward compatibility.
- ///
- public TokenValidationOptions? TokenValidation { get; set; }
-
- ///
- /// Per-tool scope requirements. Patterns support wildcards (* and ?).
- /// These are checked when is true.
- ///
- public List ScopeRequirements { get; set; } = new();
}
///
diff --git a/MCPify/Hosting/McpAuthenticationOptionsSetup.cs b/MCPify/Hosting/McpAuthenticationOptionsSetup.cs
new file mode 100644
index 0000000..dc89e3d
--- /dev/null
+++ b/MCPify/Hosting/McpAuthenticationOptionsSetup.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MCPify.Core;
+using MCPify.Core.Auth;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.AspNetCore.Authentication;
+using ModelContextProtocol.Authentication;
+
+namespace MCPify.Hosting;
+
+internal sealed class McpAuthenticationOptionsSetup : IConfigureNamedOptions
+{
+ public void Configure(McpAuthenticationOptions options)
+ => Configure(McpAuthenticationDefaults.AuthenticationScheme, options);
+
+ public void Configure(string? name, McpAuthenticationOptions options)
+ {
+ var previousHandler = options.Events.OnResourceMetadataRequest;
+
+ options.Events.OnResourceMetadataRequest = async context =>
+ {
+ if (previousHandler != null)
+ {
+ await previousHandler(context);
+ }
+
+ if (context.ResourceMetadata is null)
+ {
+ context.ResourceMetadata = BuildMetadata(context.HttpContext);
+ }
+ };
+ }
+
+ private static ProtectedResourceMetadata? BuildMetadata(HttpContext httpContext)
+ {
+ var services = httpContext.RequestServices;
+ var options = services.GetService();
+ var oauthStore = services.GetService();
+
+ if (options is null && oauthStore is null)
+ {
+ return null;
+ }
+
+ var resourceUri = ResolveResourceUri(options?.ResourceUrlOverride, httpContext);
+
+ var metadata = new ProtectedResourceMetadata
+ {
+ Resource = resourceUri,
+ };
+
+ if (oauthStore != null)
+ {
+ PopulateAuthorizationMetadata(metadata, oauthStore);
+ }
+
+ return metadata;
+ }
+
+ private static Uri? ResolveResourceUri(string? overrideUrl, HttpContext httpContext)
+ {
+ if (!string.IsNullOrWhiteSpace(overrideUrl) && Uri.TryCreate(overrideUrl, UriKind.Absolute, out var overrideUri))
+ {
+ return overrideUri;
+ }
+
+ return new Uri($"{httpContext.Request.Scheme}://{httpContext.Request.Host}");
+ }
+
+ private static void PopulateAuthorizationMetadata(ProtectedResourceMetadata metadata, OAuthConfigurationStore store)
+ {
+ var authorizationServers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var scopes = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var configuration in store.GetConfigurations())
+ {
+ foreach (var scope in configuration.Scopes.Keys)
+ {
+ scopes.Add(scope);
+ }
+
+ if (configuration.AuthorizationServers.Count > 0)
+ {
+ foreach (var server in configuration.AuthorizationServers)
+ {
+ if (!string.IsNullOrWhiteSpace(server) && Uri.TryCreate(server, UriKind.Absolute, out var serverUri))
+ {
+ authorizationServers.Add(serverUri.ToString());
+ }
+ }
+
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(configuration.AuthorizationUrl) && Uri.TryCreate(configuration.AuthorizationUrl, UriKind.Absolute, out var authorizationUri))
+ {
+ authorizationServers.Add(new Uri(authorizationUri.GetLeftPart(UriPartial.Authority)).ToString());
+ }
+ }
+
+ metadata.AuthorizationServers = authorizationServers
+ .Select(server => new Uri(server))
+ .ToList();
+
+ metadata.ScopesSupported = scopes.Count > 0
+ ? scopes.ToList()
+ : new List();
+ }
+}
diff --git a/MCPify/Hosting/McpContextMiddleware.cs b/MCPify/Hosting/McpContextMiddleware.cs
deleted file mode 100644
index 6d2e7d0..0000000
--- a/MCPify/Hosting/McpContextMiddleware.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using MCPify.Core;
-using MCPify.Core.Session;
-using Microsoft.AspNetCore.Http;
-using System.Threading.Tasks;
-
-namespace MCPify.Hosting;
-
-public class McpContextMiddleware
-{
- private readonly RequestDelegate _next;
-
- public McpContextMiddleware(RequestDelegate next)
- {
- _next = next;
- }
-
- public async Task InvokeAsync(HttpContext httpContext, IMcpContextAccessor accessor, McpifyOptions options, ISessionMap sessionMap)
- {
- // 1. Try custom resolver from options
- string? sessionHandle = null;
- if (options.SessionIdResolver != null)
- {
- sessionHandle = options.SessionIdResolver(httpContext);
- }
-
- // 2. If null, try to retrieve sessionId from HttpContext.Items (Standard MCP Transport fallback)
- if (string.IsNullOrEmpty(sessionHandle) && httpContext.Items.TryGetValue("McpSessionId", out var sessionIdObj) && sessionIdObj is string sessionIdItem)
- {
- sessionHandle = sessionIdItem;
- }
-
- // 3. Cookie Fallback: Check for McpSessionId cookie
- if (string.IsNullOrEmpty(sessionHandle) && httpContext.Request.Cookies.TryGetValue("McpSessionId", out var cookieSessionId))
- {
- sessionHandle = cookieSessionId;
- }
-
- // 4. If still null, generate a Temporary In-Memory Session Handle
- if (string.IsNullOrEmpty(sessionHandle))
- {
- sessionHandle = Guid.NewGuid().ToString("N");
- // Store it back so downstream can see it if needed
- httpContext.Items["McpSessionId"] = sessionHandle;
-
- // Auto-Issue Cookie for HTTP clients
- httpContext.Response.Cookies.Append("McpSessionId", sessionHandle, new CookieOptions
- {
- HttpOnly = true,
- Secure = httpContext.Request.IsHttps,
- SameSite = SameSiteMode.Lax,
- Expires = DateTimeOffset.UtcNow.AddDays(7)
- });
- }
-
- // Store the handle specifically for the Login/Upgrade process
- httpContext.Items["McpSessionHandle"] = sessionHandle;
-
- // 4. Resolve the actual identity (Principal) from the map
- // If logged in, this returns the User ID. If not, it returns the sessionHandle (Temp ID).
- accessor.SessionId = sessionMap.ResolvePrincipal(sessionHandle);
-
- if (httpContext.Items.TryGetValue("McpConnectionId", out var connectionIdObj) && connectionIdObj is string connectionId)
- {
- accessor.ConnectionId = connectionId;
- }
-
- // Set the current context for AsyncLocal to ensure it's available downstream
- McpContextAccessor.CurrentContext = new McpContextAccessor.McpContext
- {
- SessionId = accessor.SessionId,
- ConnectionId = accessor.ConnectionId,
- AccessToken = accessor.AccessToken
- };
-
- try
- {
- await _next(httpContext);
- }
- finally
- {
- // Clear the context to prevent leakage to other requests
- McpContextAccessor.CurrentContext = null;
- }
- }
-}
\ No newline at end of file
diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs
deleted file mode 100644
index 0d50ef2..0000000
--- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs
+++ /dev/null
@@ -1,238 +0,0 @@
-using MCPify.Core;
-using MCPify.Core.Auth;
-using Microsoft.Net.Http.Headers;
-
-namespace MCPify.Hosting;
-
-public class McpOAuthAuthenticationMiddleware
-{
- private readonly RequestDelegate _next;
-
- public const string TokenValidationResultKey = "McpTokenValidationResult";
-
- public McpOAuthAuthenticationMiddleware(RequestDelegate next)
- {
- _next = next;
- }
-
- public async Task InvokeAsync(HttpContext context)
- {
- var path = context.Request.Path;
- if (path.StartsWithSegments("/.well-known") ||
- path.StartsWithSegments("/swagger") ||
- path.StartsWithSegments("/health") ||
- path.StartsWithSegments("/connect") ||
- path.StartsWithSegments("/auth"))
- {
- await _next(context);
- return;
- }
-
- var options = context.RequestServices.GetService();
- var oauthStore = context.RequestServices.GetService();
-
- var oauthConfigurations = oauthStore?.GetConfigurations().ToList() ?? [];
- var validationOptions = options?.TokenValidation;
- var tokenValidationEnabled = validationOptions?.EnableJwtValidation == true;
-
- var challengeScopes = BuildChallengeScopes(oauthConfigurations, validationOptions);
- var authRequired = oauthConfigurations.Count > 0 || tokenValidationEnabled;
-
- if (!authRequired)
- {
- await _next(context);
- return;
- }
-
- var resourceUrl = GetResourceUrl(context, options);
- var accessor = context.RequestServices.GetService();
-
- if (!TryGetBearerToken(context, out var token))
- {
- await WriteChallengeResponse(context, resourceUrl, challengeScopes, null, null);
- return;
- }
-
- if (accessor != null)
- {
- accessor.AccessToken = token;
- }
-
- if (tokenValidationEnabled && validationOptions != null)
- {
- var validator = context.RequestServices.GetService();
- if (validator != null)
- {
- var expectedAudience = validationOptions.ValidateAudience
- ? (validationOptions.ExpectedAudience ?? resourceUrl)
- : null;
-
- var validationResult = await validator.ValidateAsync(token, expectedAudience, context.RequestAborted);
- context.Items[TokenValidationResultKey] = validationResult;
-
- if (!validationResult.IsValid)
- {
- await WriteChallengeResponse(context, resourceUrl, challengeScopes,
- validationResult.ErrorCode ?? "invalid_token",
- validationResult.ErrorDescription ?? "Token validation failed");
- return;
- }
-
- if (validationOptions.ValidateScopes)
- {
- var scopeStore = context.RequestServices.GetService();
- if (scopeStore != null)
- {
- var scopeResult = scopeStore.ValidateScopesForTool("*", validationResult.Scopes);
- if (!scopeResult.IsValid)
- {
- await WriteInsufficientScopeResponse(context, resourceUrl, scopeResult.MissingScopes);
- return;
- }
- }
- }
- }
- }
-
- await _next(context);
- }
-
- private static string GetResourceUrl(HttpContext context, McpifyOptions? options)
- {
- var resourceUrl = options?.ResourceUrlOverride;
- if (string.IsNullOrWhiteSpace(resourceUrl))
- {
- resourceUrl = options?.LocalEndpoints?.BaseUrlOverride;
- }
-
- if (string.IsNullOrWhiteSpace(resourceUrl))
- {
- resourceUrl = $"{context.Request.Scheme}://{context.Request.Host}";
- }
-
- return resourceUrl.TrimEnd('/');
- }
-
- private static IReadOnlyList BuildChallengeScopes(
- IReadOnlyCollection configurations,
- TokenValidationOptions? validationOptions)
- {
- var defaultScopes = validationOptions?.DefaultRequiredScopes;
- var hasDefaultScopes = defaultScopes is { Count: > 0 };
-
- if (configurations.Count == 0 && !hasDefaultScopes)
- {
- return Array.Empty();
- }
-
- var scopes = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var configuration in configurations)
- {
- foreach (var scope in configuration.Scopes.Keys)
- {
- scopes.Add(scope);
- }
- }
-
- if (hasDefaultScopes && defaultScopes != null)
- {
- foreach (var scope in defaultScopes)
- {
- scopes.Add(scope);
- }
- }
-
- return scopes.ToList();
- }
-
- private static bool TryGetBearerToken(HttpContext context, out string token)
- {
- token = string.Empty;
- string? authorization = context.Request.Headers[HeaderNames.Authorization];
-
- if (string.IsNullOrEmpty(authorization) ||
- !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- token = authorization.Substring("Bearer ".Length).Trim();
- return !string.IsNullOrEmpty(token);
- }
-
- private static Task WriteChallengeResponse(
- HttpContext context,
- string resourceUrl,
- IReadOnlyList scopes,
- string? errorCode,
- string? errorDescription)
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource";
- context.Response.Headers[HeaderNames.WWWAuthenticate] =
- BuildWwwAuthenticateHeader(metadataUrl, scopes, errorCode, errorDescription);
- return Task.CompletedTask;
- }
-
- private static Task WriteInsufficientScopeResponse(
- HttpContext context,
- string resourceUrl,
- IReadOnlyList requiredScopes)
- {
- var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource";
-
- context.Response.StatusCode = StatusCodes.Status403Forbidden;
-
- var parts = new List
- {
- "Bearer",
- $"error=\"insufficient_scope\"",
- $"error_description=\"The access token does not have the required scope(s)\"",
- $"resource_metadata=\"{metadataUrl}\""
- };
-
- if (requiredScopes.Count > 0)
- {
- parts.Add($"scope=\"{string.Join(" ", requiredScopes)}\"");
- }
-
- context.Response.Headers[HeaderNames.WWWAuthenticate] = string.Join(", ", parts);
- return Task.CompletedTask;
- }
-
- private static string BuildWwwAuthenticateHeader(
- string metadataUrl,
- IReadOnlyList scopes,
- string? errorCode,
- string? errorDescription)
- {
- if (string.IsNullOrEmpty(errorCode) && string.IsNullOrEmpty(errorDescription) && scopes.Count == 0)
- {
- return $"Bearer resource_metadata=\"{metadataUrl}\"";
- }
-
- var parts = new List(4)
- {
- $"Bearer resource_metadata=\"{metadataUrl}\""
- };
-
- if (!string.IsNullOrEmpty(errorCode))
- {
- parts.Add($"error=\"{errorCode}\"");
- }
-
- if (!string.IsNullOrEmpty(errorDescription))
- {
- var escapedDescription = errorDescription.Replace("\"", "\\\"");
- parts.Add($"error_description=\"{escapedDescription}\"");
- }
-
- if (scopes.Count > 0)
- {
- parts.Add($"scope=\"{string.Join(" ", scopes)}\"");
- }
-
- return string.Join(", ", parts);
- }
-}
diff --git a/MCPify/Hosting/McpifyEndpointExtensions.cs b/MCPify/Hosting/McpifyEndpointExtensions.cs
index 9837f3b..cf564f9 100644
--- a/MCPify/Hosting/McpifyEndpointExtensions.cs
+++ b/MCPify/Hosting/McpifyEndpointExtensions.cs
@@ -1,11 +1,13 @@
using MCPify.Core;
using MCPify.Core.Auth;
+using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using ModelContextProtocol.Server;
using MCPify.Endpoints;
using MCPify.Tools;
using MCPify.Schema;
+using System.Linq;
namespace MCPify.Hosting;
@@ -101,7 +103,12 @@ string BaseUrlProvider()
if (options.Transport == McpTransportType.Http)
{
- app.MapMcp(path);
+ var mcpRoute = app.MapMcp(path);
+ var oauthStore = services.GetService();
+ if (oauthStore?.GetConfigurations().Any() == true)
+ {
+ mcpRoute.RequireAuthorization();
+ }
}
// Map OAuth Protected Resource Metadata
diff --git a/MCPify/Hosting/McpifyServiceExtensions.cs b/MCPify/Hosting/McpifyServiceExtensions.cs
index a211c98..7cb80f4 100644
--- a/MCPify/Hosting/McpifyServiceExtensions.cs
+++ b/MCPify/Hosting/McpifyServiceExtensions.cs
@@ -7,10 +7,15 @@
using ModelContextProtocol.Server;
using System.Net.Http;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using MCPify.Core.Auth;
using MCPify.Core.Session;
using System.IO;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
namespace MCPify.Hosting;
@@ -103,35 +108,23 @@ public static IServiceCollection AddMcpify(
}
services.AddSingleton(oauthStore);
- // Register token validation services if enabled
- if (opts.TokenValidation != null)
- {
- services.AddSingleton(opts.TokenValidation);
+ services.AddSingleton();
- if (opts.TokenValidation.EnableJwtValidation)
- {
- services.AddSingleton(sp =>
- new JwtAccessTokenValidator(sp.GetRequiredService()));
- }
+ services.TryAddEnumerable(ServiceDescriptor.Transient, McpAuthenticationOptionsSetup>());
+ services.TryAddEnumerable(ServiceDescriptor.Transient, McpAuthenticationOptionsSetup>());
- // Register scope requirement store with access to OAuth configurations
- services.AddSingleton(sp =>
- new ScopeRequirementStore(
- opts.ScopeRequirements,
- sp.GetRequiredService(),
- sp.GetService()));
- }
- else
- {
- // Register empty token validation options for when validation is not configured
- var defaultOptions = new TokenValidationOptions();
- services.AddSingleton(defaultOptions);
- services.AddSingleton(sp =>
- new ScopeRequirementStore(
- opts.ScopeRequirements,
- defaultOptions,
- sp.GetService()));
- }
+ services.AddAuthentication(options =>
+ {
+ options.DefaultScheme = McpAuthenticationDefaults.AuthenticationScheme;
+ options.DefaultAuthenticateScheme = McpAuthenticationDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
+ })
+ .AddScheme(
+ McpAuthenticationDefaults.AuthenticationScheme,
+ _ => { })
+ .AddScheme(
+ "Bearer",
+ _ => { });
return services;
}
@@ -174,23 +167,4 @@ public static IServiceCollection AddMcpifyTestTool(this IServiceCollection servi
return services;
}
- ///
- /// Adds the MCP context middleware to the pipeline. This is required for accessing session and connection information.
- ///
- /// The instance.
- /// The instance.
- public static IApplicationBuilder UseMcpifyContext(this IApplicationBuilder builder)
- {
- return builder.UseMiddleware();
- }
-
- ///
- /// Adds the MCP OAuth authentication middleware to the pipeline. This handles token validation and challenges for protected endpoints.
- ///
- /// The instance.
- /// The instance.
- public static IApplicationBuilder UseMcpifyOAuth(this IApplicationBuilder builder)
- {
- return builder.UseMiddleware();
- }
}
diff --git a/MCPify/Hosting/SessionAwareToolDecorator.cs b/MCPify/Hosting/SessionAwareToolDecorator.cs
index 05b7fbc..8a40d0a 100644
--- a/MCPify/Hosting/SessionAwareToolDecorator.cs
+++ b/MCPify/Hosting/SessionAwareToolDecorator.cs
@@ -1,8 +1,7 @@
+using System.Linq;
using System.Text.Json;
-using System.Text.Json.Nodes;
using MCPify.Core;
using MCPify.Core.Session;
-using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -24,72 +23,7 @@ public SessionAwareToolDecorator(McpServerTool innerTool, IServiceProvider servi
_serviceProvider = serviceProvider;
}
- // Delegate ProtocolTool property to the inner tool but inject sessionId into schema
- public override Tool ProtocolTool
- {
- get
- {
- var original = _innerTool.ProtocolTool;
-
- // Skip modification for 'connect' tool as it doesn't need a session ID
- if (original.Name.Equals("connect", StringComparison.OrdinalIgnoreCase))
- {
- return original;
- }
-
- // If the schema is empty or not an object, just return original to be safe
- if (original.InputSchema.ValueKind == JsonValueKind.Undefined ||
- original.InputSchema.ValueKind == JsonValueKind.Null)
- {
- return original;
- }
-
- try
- {
- // Parse the original schema to a mutable Node
- var jsonNode = JsonNode.Parse(original.InputSchema.GetRawText());
- if (jsonNode is JsonObject jsonObj)
- {
- // Ensure 'type' is set to 'object' (Required for valid MCP schema)
- if (!jsonObj.ContainsKey("type"))
- {
- jsonObj["type"] = "object";
- }
-
- // Ensure 'properties' object exists
- if (!jsonObj.ContainsKey("properties"))
- {
- jsonObj["properties"] = new JsonObject();
- }
-
- var properties = jsonObj["properties"] as JsonObject;
- if (properties != null && !properties.ContainsKey("sessionId"))
- {
- // Inject sessionId property
- properties["sessionId"] = new JsonObject
- {
- ["type"] = "string",
- ["description"] = "The Session ID to maintain context across requests. Retrieve this via 'connect' tool."
- };
- }
-
- // We return a NEW Tool object with the modified schema
- return new Tool
- {
- Name = original.Name,
- Description = original.Description,
- InputSchema = JsonSerializer.SerializeToElement(jsonObj)
- };
- }
- }
- catch
- {
- // Fallback if parsing fails
- }
-
- return original;
- }
- }
+ public override Tool ProtocolTool => _innerTool.ProtocolTool;
// Delegate Metadata to the inner tool
public override IReadOnlyList