Skip to content

feat(auth): add initial authorization scope selection - #1352

Open
zamoshchin-openai wants to merge 6 commits into
modelcontextprotocol:mainfrom
zamoshchin-openai:dev/alexz/rmcp-initial-login-scopes
Open

zamoshchin-openai wants to merge 6 commits into
modelcontextprotocol:mainfrom
zamoshchin-openai:dev/alexz/rmcp-initial-login-scopes

Conversation

@zamoshchin-openai

@zamoshchin-openai zamoshchin-openai commented Oct 13, 2026 •

Copy link
Copy Markdown

An MCP server can challenge an initial request with scope="mcp.read" while its protected-resource metadata advertises scopes_supported: ["mcp.read", "mcp.write"]. Using select_scopes() for that login requests both scopes, even though the challenge only asks for read access. This can introduce unnecessary consent or an invalid_scope rejection when the client is not permitted to request the additional scope.

The MCP scope-selection guidance recommends preferring the initial challenge and using protected-resource metadata when that challenge provides no scopes. RMCP already discovers this information, but its existing selector intentionally accumulates scopes and its discovery result does not expose the separate scope sources. Clients therefore lack a way to choose initial-login precedence using the existing discovery APIs.

Add the opt-in AuthorizationManager::select_initial_scopes() API so callers can use that precedence without implementing discovery themselves or changing select_scopes() for existing consumers. It prefers the initial resource challenge, then protected-resource metadata, using only caller-provided defaults when neither supplies scopes. It deduplicates scopes and preserves offline_access handling. AuthorizationRequest::with_initial_scope_selection() exposes the same opt-in through OAuthState and AuthorizationSession; automatic authorization keeps the existing policy by default, and nonempty explicit scopes retain precedence.

Initial scopes are recorded separately so scope-only challenges work, later metadata challenges cannot replace the resource request's scopes, and repeated discovery does not reuse stale initial scopes.

Validation:

  • cargo +1.97.1 test -p rmcp --lib --features auth,client,reqwest transport::auth::tests:: --quiet: 228 passed.
  • cargo +1.97.1 test -p rmcp --doc --features auth,client,reqwest select_initial_scopes --quiet: passed.
  • cargo +1.96 clippy -p rmcp --all-targets --all-features -- -D warnings: passed.
  • cargo +nightly fmt --all -- --check: passed.

@github-actions github-actions Bot added T-core Core library changes T-transport Transport layer changes labels Oct 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@bolinfest is on triage rotation this week and is the reviewer for this one.

Automated classification did not run for this one, so it needs a full manual pass.

Automated triage. Labels are a first pass and the assigned maintainer has the final say.

@zamoshchin-openai
zamoshchin-openai marked this pull request as ready for review October 13, 2026 22:30
@zamoshchin-openai
zamoshchin-openai requested a review from a team as a code owner October 13, 2026 22:30
@zamoshchin-openai

Copy link
Copy Markdown
Author

@stefanoamorelli, was the scope union in #888 intended to apply to initial login too, or mainly to step-up authorization?

For example, an initial challenge requesting mcp.read currently also requests mcp.write if the resource metadata lists both. My reading of the MCP spec is that initial login should prefer the challenge scopes.

This PR adds a separate initial-login selector to preserve the existing union behavior. Would you prefer that approach, or changing the existing selector?

/// # Ok(())
/// # }
/// ```
pub async fn select_initial_scopes(&self, default_scopes: &[&str]) -> Vec<String> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuthState::start_authorization resolves the metadata internally, then calls select_scopes. That means callers using the state machine can't access this API without handling AuthorizationManager themselves.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added AuthorizationRequest::with_initial_scope_selection() so state-machine callers can opt in directly. Existing defaults and explicit scopes remain unchanged.

Comment thread crates/rmcp/src/transport/auth.rs Outdated
scopes = self.initial_resource_scopes.read().await.clone();
}
if scopes.is_empty() {
scopes = self.select_fallback_scopes(default_scopes);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the API is new and opt-in, is there a compatibility reason to keep the AS fallback?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed—removed the AS fallback, preserving caller defaults and refresh-token handling.

Comment thread crates/rmcp/src/transport/auth.rs Outdated
Comment on lines +1143 to +1146
/// Initial resource challenge only; None means the resource has not been observed yet.
initial_challenge_scopes: RwLock<Option<Vec<String>>>,
/// Protected resource scopes from the current discovery, including an empty result.
initial_resource_scopes: RwLock<Vec<String>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With two locks, select_initial_scopes could read a challenge from one discovery and resource scopes after a concurrent reset. Also, the two-line reset is duplicated in resolve_metadata and resolve_metadata_from_challenge. Would it make sense to use a single lock, so resetting and reading are each a single step?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grouped both fields under one lock, with a shared reset and a single read.

Comment on lines +7999 to +8003
assert!(
manager
.select_scopes(None, &[])
.contains(&"metadata:read".to_string())
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks a second behavior. If that regresses, it would be reported as an initial-scope failure. Moving it into its own test keeps each test focused on one assertion.

Suggested change
assert!(
manager
.select_scopes(None, &[])
.contains(&"metadata:read".to_string())
);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the legacy-selector assertion into its own compatibility test, preserving coverage.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants