Skip to content

Commit 9b783d7

Browse files
Mike KistlerCopilottarekgh
authored
Implementation for SEP-2350 Client-side scope accumulation in step-up authorization (modelcontextprotocol#1591)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com>
1 parent 4dd58c1 commit 9b783d7

4 files changed

Lines changed: 511 additions & 12 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
name: run-conformance-from-branch
3+
description: Run MCP conformance tests in the C# SDK against a conformance branch (including forks) instead of the published npm version, then restore pinned dependencies.
4+
compatibility: Requires npm, node, and dotnet SDK. Uses the csharp-sdk repo package.json/package-lock.json and tests/ModelContextProtocol.AspNetCore.Tests.
5+
---
6+
7+
# Run Conformance From Branch
8+
9+
Run C# SDK conformance tests against an unpublished `modelcontextprotocol/conformance` branch (including branches in forks).
10+
11+
## Use Cases
12+
13+
- Validate a conformance PR before it is published to npm
14+
- Validate C# SDK behavior against a fork with custom scenario changes
15+
- Reproduce failures caused by conformance changes
16+
17+
## Safety / Repo Hygiene
18+
19+
1. Start from a clean git state.
20+
2. Commit or stash local changes first.
21+
3. Restore pinned dependencies when done (`npm ci`).
22+
23+
## Inputs
24+
25+
- **Source type**: `upstream-branch` or `fork-branch`
26+
- **Source locator**:
27+
- Upstream branch: `modelcontextprotocol/conformance#<branch>`
28+
- Fork branch: `<owner>/conformance#<branch>`
29+
- **Scenario** (optional): e.g. `auth/scope-step-up`
30+
31+
## Workflows
32+
33+
### A) Install directly from GitHub branch (upstream or fork)
34+
35+
From `csharp-sdk` root:
36+
37+
```bash
38+
npm install --no-save @modelcontextprotocol/conformance@github:<owner>/conformance#<branch>
39+
```
40+
41+
Examples:
42+
43+
```bash
44+
npm install --no-save @modelcontextprotocol/conformance@github:modelcontextprotocol/conformance#main
45+
npm install --no-save @modelcontextprotocol/conformance@github:myuser/conformance#sep-2350-check
46+
```
47+
48+
## Run Tests
49+
50+
### Run client conformance tests with dotnet test filter:
51+
52+
```bash
53+
dotnet test tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ClientConformanceTests"
54+
```
55+
56+
### Run server conformance tests with dotnet test filter:
57+
58+
```bash
59+
dotnet test tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ServerConformanceTests"
60+
```
61+
62+
## Reporting
63+
64+
Always report:
65+
66+
1. Installed conformance source (`npm ls @modelcontextprotocol/conformance --depth=0`)
67+
2. Scenario results (pass/fail/warnings)
68+
3. Any new check IDs observed (for traceability)
69+
70+
## Cleanup / Restore
71+
72+
Return repo to pinned dependency state:
73+
74+
```bash
75+
npm ci
76+
```

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
4848
private string? _tokenEndpointAuthMethod;
4949
private ITokenCache _tokenCache;
5050
private AuthorizationServerMetadata? _authServerMetadata;
51+
// The accumulated scope set lives for this provider's lifetime and is intentionally not keyed by
52+
// resource or authorization server. This is safe today because one ClientOAuthProvider is created
53+
// per HttpClientTransport, i.e. per endpoint/resource. If a provider were ever reused across
54+
// multiple resources or auth servers, accumulated scopes could be sent to a server that rejects
55+
// them (invalid_scope). Accumulation is scoped per "resource and operation" combination (SEP-2350).
56+
private readonly HashSet<string> _accumulatedScopes = new(StringComparer.Ordinal);
57+
private readonly object _scopeAccumulatorLock = new();
58+
private bool _hasAttemptedStepUp;
5159

5260
/// <summary>
5361
/// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
@@ -245,6 +253,28 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
245253
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
246254
}
247255

256+
// SEP-2350: A step-up may legitimately introduce new scopes, so at least one interactive
257+
// (re-)authorization attempt is always allowed. However, once a step-up has already been
258+
// attempted, a subsequent insufficient_scope challenge that introduces no scope beyond those
259+
// already requested cannot make progress by re-running authorization. Treat that repeated,
260+
// unproductive challenge as a permanent authorization failure instead of prompting the user
261+
// again for the same resource and operation combination.
262+
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
263+
{
264+
bool introducesNewScopes = ChallengeIntroducesNewScopes(protectedResourceMetadata);
265+
lock (_scopeAccumulatorLock)
266+
{
267+
if (_hasAttemptedStepUp && !introducesNewScopes)
268+
{
269+
ThrowFailedToHandleUnauthorizedResponse(
270+
"A repeated insufficient_scope challenge added no scope beyond those already requested, " +
271+
"so step-up authorization cannot satisfy the request.");
272+
}
273+
274+
_hasAttemptedStepUp = true;
275+
}
276+
}
277+
248278
// Convert string URIs to Uri objects for the selector
249279
List<Uri> authServerUris = [];
250280
foreach (var serverUriString in availableAuthorizationServers)
@@ -729,17 +759,93 @@ private async Task PerformDynamicClientRegistrationAsync(
729759
}
730760

731761
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
762+
{
763+
// Determine the scopes for the current operation from the challenge or metadata.
764+
var currentOperationScopes = GetCurrentOperationScopes(protectedResourceMetadata);
765+
766+
if (currentOperationScopes.Count == 0)
767+
{
768+
lock (_scopeAccumulatorLock)
769+
{
770+
// If we have previously requested scopes but nothing new, return the accumulated set.
771+
return _accumulatedScopes.Count > 0
772+
? string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal))
773+
: null;
774+
}
775+
}
776+
777+
// Per SEP-2350: Compute the union of previously requested scopes and newly challenged scopes
778+
// to avoid losing permissions needed for other operations during step-up authorization.
779+
// Note: the accumulator stores only server-challenged / scopes_supported / configured scopes.
780+
// offline_access (AugmentScopeWithOfflineAccess) and any ScopeSelector are applied per request
781+
// in ComputeEffectiveScope and are intentionally not accumulated, so the selector always sees
782+
// the full union and the operation stays idempotent.
783+
lock (_scopeAccumulatorLock)
784+
{
785+
foreach (var scope in currentOperationScopes)
786+
{
787+
_accumulatedScopes.Add(scope);
788+
}
789+
790+
// Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3).
791+
return string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal));
792+
}
793+
}
794+
795+
/// <summary>
796+
/// Determines the scopes required for the current operation, preferring the <c>WWW-Authenticate</c>
797+
/// challenge scope, then <c>scopes_supported</c> from the protected resource metadata, then the
798+
/// configured scopes. Returns the individual scope tokens so callers can compare and accumulate them
799+
/// without re-joining and re-splitting. This does not mutate the accumulated scope set.
800+
/// </summary>
801+
private IReadOnlyList<string> GetCurrentOperationScopes(ProtectedResourceMetadata protectedResourceMetadata)
732802
{
733803
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
734804
{
735-
return protectedResourceMetadata.WwwAuthenticateScope;
805+
return SplitScopes(protectedResourceMetadata.WwwAuthenticateScope!);
736806
}
737-
else if (protectedResourceMetadata.ScopesSupported.Count > 0)
807+
808+
var scopesSupported = protectedResourceMetadata.ScopesSupported;
809+
if (scopesSupported.Count > 0)
738810
{
739-
return string.Join(" ", protectedResourceMetadata.ScopesSupported);
811+
// scopes_supported is already a list of individual scopes; avoid join/split round-tripping.
812+
return scopesSupported as IReadOnlyList<string> ?? [.. scopesSupported];
740813
}
741814

742-
return _configuredScopes;
815+
return _configuredScopes is null ? [] : SplitScopes(_configuredScopes);
816+
}
817+
818+
private static string[] SplitScopes(string scopes) =>
819+
scopes.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
820+
821+
/// <summary>
822+
/// Returns <see langword="true"/> if the current challenge requires at least one scope that has not
823+
/// already been requested in a previous (re-)authorization. The caller combines this with step-up
824+
/// attempt tracking: per SEP-2350, a step-up that adds a new scope is always allowed, but once a
825+
/// step-up has been attempted, a later challenge that adds no new scope is treated as a permanent
826+
/// failure because re-running interactive authorization cannot make progress.
827+
/// </summary>
828+
private bool ChallengeIntroducesNewScopes(ProtectedResourceMetadata protectedResourceMetadata)
829+
{
830+
var currentOperationScopes = GetCurrentOperationScopes(protectedResourceMetadata);
831+
if (currentOperationScopes.Count == 0)
832+
{
833+
// No concrete scope to request, so a re-authorization cannot add anything new.
834+
return false;
835+
}
836+
837+
lock (_scopeAccumulatorLock)
838+
{
839+
foreach (var scope in currentOperationScopes)
840+
{
841+
if (!_accumulatedScopes.Contains(scope))
842+
{
843+
return true;
844+
}
845+
}
846+
}
847+
848+
return false;
743849
}
744850

745851
/// <summary>

src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ public sealed class ProtectedResourceMetadata
190190
/// The scopes included in the WWW-Authenticate challenge MAY match scopes_supported, be a subset or superset of it,
191191
/// or an alternative collection that is neither a strict subset nor superset. Clients MUST NOT assume any particular
192192
/// set relationship between the challenged scope set and scopes_supported. Clients MUST treat the scopes provided
193-
/// in the challenge as authoritative for satisfying the current request.
193+
/// in the challenge as authoritative for the current operation. When re-authorizing, clients SHOULD include these
194+
/// scopes alongside any previously granted scopes to avoid losing permissions needed for other operations (SEP-2350).
194195
///
195196
/// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
196197
/// </summary>

0 commit comments

Comments
 (0)