Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c7183f6
Exploratory implementation
localden Apr 23, 2025
389fb3d
Fix JSON reference issues
localden Apr 23, 2025
ddca6cc
Remove error codes - we don't use those
localden Apr 23, 2025
99a417f
Update sample to use proper SSE transport definition
localden Apr 23, 2025
53c1151
Stub for server implementation
localden Apr 23, 2025
3e9462c
HTTP for local testing
localden Apr 23, 2025
ecc40ab
Tinkering with test logic
localden Apr 23, 2025
4c7a578
Iterating on the changes
localden Apr 24, 2025
d339973
Testing client configuration
localden Apr 24, 2025
bdee0e3
Update to make sure naming is consistent
localden Apr 24, 2025
b0d9932
No need to keep track of this
localden Apr 24, 2025
e6c1995
Updated logc
localden Apr 24, 2025
2f44765
Update with proper token logic
localden Apr 24, 2025
bf9f63e
Cleanup of unused declarations
localden Apr 24, 2025
3fd7681
Remove handler from transport definition
localden May 1, 2025
9bf4ea3
Amend middleware logic
localden May 1, 2025
400f191
Merge branch 'main' into localden/auth
localden May 1, 2025
fd60a1c
Trim implementation
localden May 1, 2025
f699f77
Cleanup
localden May 1, 2025
dc8f3a1
Bit more cleanup here.
localden May 1, 2025
7c2e177
Remove test that is no longer relevant
localden May 1, 2025
c88e473
Use URI properly
localden May 2, 2025
4d37991
Functional cleanup
localden May 2, 2025
5489d65
Merge branch 'main' into localden/auth
localden May 2, 2025
084590d
Update ProtectedResourceMetadataTests.cs
localden May 2, 2025
f9f7c9d
Update ProtectedResourceMetadataTests.cs
localden May 2, 2025
3676c0e
Update for consistency
localden May 2, 2025
950a3c4
Cleanup
localden May 2, 2025
e8b3e0d
Cleanup
localden May 2, 2025
e80bad2
Update Program.cs
localden May 2, 2025
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,7 @@ docs/api

# Rider
.idea/
.idea_modules/
.idea_modules/

# Specs
.specs/
14 changes: 14 additions & 0 deletions samples/AuthorizationExample/AuthorizationExample.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
</ItemGroup>

</Project>
81 changes: 81 additions & 0 deletions samples/AuthorizationExample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Diagnostics;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;

namespace AuthorizationExample;

/// <summary>
/// Example demonstrating how to use the MCP C# SDK with OAuth authorization.
/// </summary>
public class Program
{
public static async Task Main(string[] args)
{
// Define the MCP server endpoint that requires OAuth authentication
var serverEndpoint = new Uri("https://example.com/mcp");

// Set up the SSE transport with authorization support
var transportOptions = new SseClientTransportOptions
{
Endpoint = serverEndpoint,

// Provide a callback to handle the authorization flow
AuthorizeCallback = async (clientMetadata) =>
{
Console.WriteLine("Authentication required. Opening browser for authorization...");

// In a real app, you'd likely have a local HTTP server to receive the callback
// This is just a simplified example
Console.WriteLine("Once you've authorized in the browser, enter the code and redirect URI:");
Console.Write("Code: ");
var code = Console.ReadLine() ?? "";
Console.Write("Redirect URI: ");
var redirectUri = Console.ReadLine() ?? "http://localhost:8888/callback";

return (redirectUri, code);
}

// Alternatively, use the built-in local server handler:
// AuthorizeCallback = SseClientTransport.CreateLocalServerAuthorizeCallback(
// openBrowser: async (url) =>
// {
// // Open the URL in the user's default browser
// Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
// }
// )
};

try
{
// Create the client with authorization-enabled transport
var transport = new SseClientTransport(transportOptions);
var client = await McpClient.CreateAsync(transport);

// Use the MCP client normally - authorization is handled automatically
// If the server returns a 401 Unauthorized response, the authorization flow will be triggered
var result = await client.PingAsync();
Console.WriteLine($"Server ping successful: {result.ServerInfo.Name} {result.ServerInfo.Version}");

// Example tool call
var weatherPrompt = "What's the weather like today?";
var weatherResult = await client.CompletionCompleteAsync(
new CompletionCompleteRequestBuilder(weatherPrompt).Build());

Console.WriteLine($"Response: {weatherResult.Content.Text}");
}
catch (McpAuthorizationException authEx)
{
Console.WriteLine($"Authorization error: {authEx.Message}");
Console.WriteLine($"Resource: {authEx.ResourceUri}");
Console.WriteLine($"Auth server: {authEx.AuthorizationServerUri}");
}
catch (McpException mcpEx)
{
Console.WriteLine($"MCP error: {mcpEx.Message} (Error code: {mcpEx.ErrorCode})");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error: {ex.Message}");
}
}
}
77 changes: 77 additions & 0 deletions src/ModelContextProtocol/McpAuthorizationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace ModelContextProtocol;

/// <summary>
/// Represents an exception that is thrown when an authorization or authentication error occurs in MCP.
/// </summary>
/// <remarks>
/// This exception is thrown when the client fails to authenticate with an MCP server that requires
/// authentication, such as when the OAuth authorization flow fails or when the server rejects the provided credentials.
/// </remarks>
public class McpAuthorizationException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpAuthorizationException"/> class.
/// </summary>
public McpAuthorizationException()
: base("Authorization failed", McpErrorCode.Unauthorized)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="McpAuthorizationException"/> class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public McpAuthorizationException(string message)
: base(message, McpErrorCode.Unauthorized)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="McpAuthorizationException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
public McpAuthorizationException(string message, Exception? innerException)
: base(message, innerException, McpErrorCode.Unauthorized)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="McpAuthorizationException"/> class with a specified error message and error code.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="errorCode">The MCP error code. Should be either <see cref="McpErrorCode.Unauthorized"/> or <see cref="McpErrorCode.AuthenticationFailed"/>.</param>
public McpAuthorizationException(string message, McpErrorCode errorCode)
: base(message, errorCode)
{
if (errorCode != McpErrorCode.Unauthorized && errorCode != McpErrorCode.AuthenticationFailed)
{
throw new ArgumentException($"Error code must be either {nameof(McpErrorCode.Unauthorized)} or {nameof(McpErrorCode.AuthenticationFailed)}", nameof(errorCode));
}
}

/// <summary>
/// Initializes a new instance of the <see cref="McpAuthorizationException"/> class with a specified error message, inner exception, and error code.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
/// <param name="errorCode">The MCP error code. Should be either <see cref="McpErrorCode.Unauthorized"/> or <see cref="McpErrorCode.AuthenticationFailed"/>.</param>
public McpAuthorizationException(string message, Exception? innerException, McpErrorCode errorCode)
: base(message, innerException, errorCode)
{
if (errorCode != McpErrorCode.Unauthorized && errorCode != McpErrorCode.AuthenticationFailed)
{
throw new ArgumentException($"Error code must be either {nameof(McpErrorCode.Unauthorized)} or {nameof(McpErrorCode.AuthenticationFailed)}", nameof(errorCode));
}
}

/// <summary>
/// Gets or sets the resource that requires authorization.
/// </summary>
public string? ResourceUri { get; set; }

/// <summary>
/// Gets or sets the authorization server URI that should be used for authentication.
/// </summary>
public string? AuthorizationServerUri { get; set; }
}
16 changes: 16 additions & 0 deletions src/ModelContextProtocol/McpErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,20 @@ public enum McpErrorCode
/// This error is used when the endpoint encounters an unexpected condition that prevents it from fulfilling the request.
/// </remarks>
InternalError = -32603,

/// <summary>
/// Indicates that the client is not authorized to access the requested resource.
/// </summary>
/// <remarks>
/// This error is returned when the client lacks the necessary credentials or permissions to access a resource.
/// </remarks>
Unauthorized = -32401,

/// <summary>
/// Indicates that the authentication process failed.
/// </summary>
/// <remarks>
/// This error is returned when the client provides invalid or expired credentials, or when the authentication flow fails.
/// </remarks>
AuthenticationFailed = -32402,
}
91 changes: 91 additions & 0 deletions src/ModelContextProtocol/Protocol/Auth/AuthorizationContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Diagnostics;

namespace ModelContextProtocol.Protocol.Auth;

/// <summary>
/// Represents the context for authorization in an MCP client session.
/// </summary>
internal class AuthorizationContext
{
/// <summary>
/// Gets or sets the resource metadata.
/// </summary>
public ResourceMetadata? ResourceMetadata { get; set; }

/// <summary>
/// Gets or sets the authorization server metadata.
/// </summary>
public AuthorizationServerMetadata? AuthorizationServerMetadata { get; set; }

/// <summary>
/// Gets or sets the client registration response.
/// </summary>
public ClientRegistrationResponse? ClientRegistration { get; set; }

/// <summary>
/// Gets or sets the token response.
/// </summary>
public TokenResponse? TokenResponse { get; set; }

/// <summary>
/// Gets or sets the code verifier for PKCE.
/// </summary>
public string? CodeVerifier { get; set; }

/// <summary>
/// Gets or sets the redirect URI used in the authorization flow.
/// </summary>
public string? RedirectUri { get; set; }

/// <summary>
/// Gets or sets the time when the access token was issued.
/// </summary>
public DateTimeOffset? TokenIssuedAt { get; set; }

/// <summary>
/// Gets a value indicating whether the access token is valid.
/// </summary>
public bool HasValidToken => TokenResponse != null &&
(TokenResponse.ExpiresIn == null ||
TokenIssuedAt != null &&
DateTimeOffset.UtcNow < TokenIssuedAt.Value.AddSeconds(TokenResponse.ExpiresIn.Value - 60)); // 1 minute buffer

/// <summary>
/// Gets the access token for authentication.
/// </summary>
/// <returns>The access token if available, otherwise null.</returns>
public string? GetAccessToken()
{
if (!HasValidToken)
{
return null;
}

// Since HasValidToken checks that TokenResponse isn't null, we should never have null here,
// but we'll add an explicit null check to satisfy the compiler
return TokenResponse?.AccessToken;
}

/// <summary>
/// Gets a value indicating whether a refresh token is available for refreshing the access token.
/// </summary>
public bool CanRefreshToken => TokenResponse?.RefreshToken != null &&
ClientRegistration != null &&
AuthorizationServerMetadata != null;

/// <summary>
/// Validates the URL of a resource against the resource URL from the metadata.
/// </summary>
/// <param name="resourceUrl">The URL to validate.</param>
/// <returns>True if the URLs match, otherwise false.</returns>
public bool ValidateResourceUrl(string resourceUrl)
{
if (ResourceMetadata == null || string.IsNullOrEmpty(ResourceMetadata.Resource))
{
return false;
}

// Resource URL must match exactly
return string.Equals(resourceUrl, ResourceMetadata.Resource, StringComparison.OrdinalIgnoreCase);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol.Auth;

/// <summary>
/// Represents OAuth 2.0 authorization server metadata as defined in RFC 8414.
/// </summary>
internal class AuthorizationServerMetadata
{
/// <summary>
/// Gets or sets the authorization endpoint URL.
/// </summary>
[JsonPropertyName("authorization_endpoint")]
public required string AuthorizationEndpoint { get; set; }

/// <summary>
/// Gets or sets the token endpoint URL.
/// </summary>
[JsonPropertyName("token_endpoint")]
public required string TokenEndpoint { get; set; }

/// <summary>
/// Gets or sets the client registration endpoint URL.
/// </summary>
[JsonPropertyName("registration_endpoint")]
public string? RegistrationEndpoint { get; set; }

/// <summary>
/// Gets or sets the token revocation endpoint URL.
/// </summary>
[JsonPropertyName("revocation_endpoint")]
public string? RevocationEndpoint { get; set; }

/// <summary>
/// Gets or sets the response types supported by the authorization server.
/// </summary>
[JsonPropertyName("response_types_supported")]
public string[]? ResponseTypesSupported { get; set; } = ["code"];

/// <summary>
/// Gets or sets the grant types supported by the authorization server.
/// </summary>
[JsonPropertyName("grant_types_supported")]
public string[]? GrantTypesSupported { get; set; } = ["authorization_code", "refresh_token"];

/// <summary>
/// Gets or sets the token endpoint authentication methods supported by the authorization server.
/// </summary>
[JsonPropertyName("token_endpoint_auth_methods_supported")]
public string[]? TokenEndpointAuthMethodsSupported { get; set; } = ["client_secret_basic"];

/// <summary>
/// Gets or sets the code challenge methods supported by the authorization server.
/// </summary>
[JsonPropertyName("code_challenge_methods_supported")]
public string[]? CodeChallengeMethodsSupported { get; set; } = ["S256"];

/// <summary>
/// Gets or sets the issuer identifier.
/// </summary>
[JsonPropertyName("issuer")]
public string? Issuer { get; set; }

/// <summary>
/// Gets or sets the scopes supported by the authorization server.
/// </summary>
[JsonPropertyName("scopes_supported")]
public string[]? ScopesSupported { get; set; }
}
Loading
Loading