diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index ecef8e15e..c376f932c 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -492,6 +492,7 @@ private Uri BuildAuthorizationUrl(
}
var scope = GetScopeParameter(protectedResourceMetadata);
+ scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
if (!string.IsNullOrEmpty(scope))
{
queryParamsDictionary["scope"] = scope!;
@@ -726,6 +727,37 @@ private async Task PerformDynamicClientRegistrationAsync(
return _configuredScopes;
}
+ ///
+ /// Augments the scope parameter with offline_access if the authorization server advertises it in
+ /// scopes_supported and it is not already present. This signals to OIDC-flavored authorization servers
+ /// that the client desires a refresh token, per SEP-2207.
+ ///
+ private static string? AugmentScopeWithOfflineAccess(string? scope, AuthorizationServerMetadata authServerMetadata)
+ {
+ const string OfflineAccess = "offline_access";
+
+ if (authServerMetadata.ScopesSupported?.Contains(OfflineAccess) is not true)
+ {
+ return scope;
+ }
+
+ if (scope is null)
+ {
+ return OfflineAccess;
+ }
+
+ // Check if offline_access is already in the scope string (space-separated tokens).
+ foreach (var token in scope.Split(' '))
+ {
+ if (token == OfflineAccess)
+ {
+ return scope;
+ }
+ }
+
+ return scope + " " + OfflineAccess;
+ }
+
///
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index c4979fb10..4f6e0ce94 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -1261,4 +1261,108 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
+
+ [Fact]
+ public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt()
+ {
+ TestOAuthServer.IncludeOfflineAccessInMetadata = true;
+ await using var app = await StartMcpServerAsync();
+
+ string? requestedScope = null;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ {
+ var query = QueryHelpers.ParseQuery(uri.Query);
+ requestedScope = query["scope"].ToString();
+ return HandleAuthorizationUrlAsync(uri, redirect, ct);
+ },
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(requestedScope);
+ Assert.Contains("offline_access", requestedScope!.Split(' '));
+ }
+
+ [Fact]
+ public async Task AuthorizationFlow_DoesNotAppendOfflineAccess_WhenServerDoesNotAdvertiseIt()
+ {
+ // IncludeOfflineAccessInMetadata defaults to false, so the AS will not advertise offline_access.
+ await using var app = await StartMcpServerAsync();
+
+ string? requestedScope = null;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ {
+ var query = QueryHelpers.ParseQuery(uri.Query);
+ requestedScope = query["scope"].ToString();
+ return HandleAuthorizationUrlAsync(uri, redirect, ct);
+ },
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(requestedScope);
+ Assert.DoesNotContain("offline_access", requestedScope!.Split(' '));
+ }
+
+ [Fact]
+ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPresent()
+ {
+ TestOAuthServer.IncludeOfflineAccessInMetadata = true;
+
+ // Configure the PRM to already include offline_access in its scopes.
+ Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "offline_access"];
+ });
+
+ await using var app = await StartMcpServerAsync();
+
+ string? requestedScope = null;
+
+ await using var transport = new HttpClientTransport(new()
+ {
+ Endpoint = new(McpServerUrl),
+ OAuth = new()
+ {
+ ClientId = "demo-client",
+ ClientSecret = "demo-secret",
+ RedirectUri = new Uri("http://localhost:1179/callback"),
+ AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ {
+ var query = QueryHelpers.ParseQuery(uri.Query);
+ requestedScope = query["scope"].ToString();
+ return HandleAuthorizationUrlAsync(uri, redirect, ct);
+ },
+ },
+ }, HttpClient, LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(requestedScope);
+ var scopeTokens = requestedScope!.Split(' ');
+ Assert.Single(scopeTokens, t => t == "offline_access");
+ }
}
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index e882ecbef..a65c5e4ab 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -78,6 +78,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
///
public bool ExpectResource { get; set; } = true;
+ ///
+ /// Gets or sets a value indicating whether the authorization server advertises support for
+ /// offline_access in its scopes_supported metadata. This simulates an OIDC-flavored
+ /// authorization server that issues refresh tokens when the client requests the offline_access scope.
+ ///
+ ///
+ /// The default value is false.
+ ///
+ public bool IncludeOfflineAccessInMetadata { get; set; }
+
public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray();
@@ -188,7 +198,9 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
ResponseTypesSupported = ["code"],
SubjectTypesSupported = ["public"],
IdTokenSigningAlgValuesSupported = ["RS256"],
- ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
+ ScopesSupported = IncludeOfflineAccessInMetadata
+ ? ["openid", "profile", "email", "mcp:tools", "offline_access"]
+ : ["openid", "profile", "email", "mcp:tools"],
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
CodeChallengeMethodsSupported = ["S256"],