Skip to content

OAuth discovery should distinguish discovered authorization metadata from synthesized legacy endpoints #1037

Description

@tsarlandie-oai

Summary

AuthorizationManager::discover_metadata() currently returns a successful
AuthorizationMetadata result even when every discovery request fails and the
server provides no evidence that it supports OAuth.

In that situation, rmcp synthesizes /authorize, /token, and /register
endpoints from the MCP server's base URL. The resulting value is
indistinguishable from metadata actually discovered from an authorization
server.

Legacy endpoint fallback is useful for the
auth/2025-03-26-oauth-endpoint-fallback conformance scenario, but callers also
use discover_metadata() to determine whether a server supports OAuth at all.
Those callers can no longer distinguish an unauthenticated server from a legacy
OAuth server.

Current behavior

After protected-resource metadata and authorization-server metadata discovery
both fail, rmcp does the following:

pub async fn discover_metadata(&self) -> Result<AuthorizationMetadata, AuthError> {
    if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? {
        return Ok(metadata);
    }

    if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? {
        return Ok(metadata);
    }

    Ok(Self::legacy_authorization_metadata(&self.base_url))
}

The synthesized result is equivalent to:

AuthorizationMetadata {
    authorization_endpoint: "https://example.com/authorize".into(),
    token_endpoint: "https://example.com/token".into(),
    registration_endpoint: Some("https://example.com/register".into()),
    ..Default::default()
}

The upstream test
discover_metadata_falls_back_to_legacy_default_endpoints explicitly asserts
that five HTTP 404 responses still produce Ok(AuthorizationMetadata).

Why this is a problem

An application that checks whether authentication is available or required
might reasonably write:

match authorization_manager.discover_metadata().await {
    Ok(metadata) => show_oauth_login(metadata),
    Err(AuthError::NoAuthorizationSupport) => connect_without_oauth(),
    Err(error) => report_discovery_error(error),
}

Before the legacy fallback was reintroduced, an MCP server with no OAuth
metadata reached NoAuthorizationSupport. It now reaches the OAuth-login path,
even though no authorization server, OAuth metadata, or functioning OAuth
endpoint was discovered.

Observable consequences include:

  • Public MCP servers can incorrectly appear to require an interactive login.
  • Clients can issue unnecessary authorization, token, or registration requests
    to guessed endpoints.
  • Applications cannot distinguish verified discovery from an unverified
    compatibility fallback.
  • Consumers must reverse-engineer rmcp's current synthesized metadata shape to
    recover the missing information. Such checks are brittle and can silently
    break when rmcp changes its fallback representation.

For example, a downstream workaround currently has to identify the exact
combination of /authorize, /token, /register, absent issuer, absent
optional metadata fields, and empty extension fields. This information should
be provided explicitly by the SDK instead.

Expected behavior

Callers should be able to distinguish at least these outcomes:

  1. Authorization metadata was actually discovered.
  2. No authorization metadata was found, but legacy endpoints were synthesized
    as a compatibility fallback.
  3. No authorization support was found.

The legacy fallback should remain available where required for older MCP OAuth
flows and conformance. The request is to expose its provenance or make its use
explicit, not necessarily to remove it.

Possible API designs

Option A: Expose discovery provenance

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationMetadataSource {
    ProtectedResourceMetadata,
    AuthorizationServerMetadata,
    LegacyEndpointFallback,
}

pub struct DiscoveredAuthorizationMetadata {
    pub metadata: AuthorizationMetadata,
    pub source: AuthorizationMetadataSource,
}

impl AuthorizationManager {
    pub async fn discover_metadata_with_source(
        &self,
    ) -> Result<DiscoveredAuthorizationMetadata, AuthError> {
        // ...
    }
}

Existing discover_metadata() behavior could remain unchanged for API and
conformance compatibility, while callers that need accurate capability
detection use the provenance-aware method.

Option B: Add strict versus legacy-compatible discovery modes

pub enum OAuthDiscoveryPolicy {
    Strict,
    AllowLegacyEndpointFallback,
}

impl AuthorizationManager {
    pub async fn discover_metadata_with_policy(
        &self,
        policy: OAuthDiscoveryPolicy,
    ) -> Result<AuthorizationMetadata, AuthError> {
        // ...
    }
}

With Strict, absent metadata should return
AuthError::NoAuthorizationSupport; with AllowLegacyEndpointFallback, the
current behavior can be retained.

A dedicated discover_metadata_strict() method would also address this use
case.

Option C: Only synthesize endpoints when OAuth is otherwise indicated

Another possibility is to require evidence of authorization support, such as a
relevant bearer challenge, or an explicitly requested OAuth login before
constructing legacy endpoints. This would avoid treating a server that returns
only 404 responses as OAuth-capable.

This option should be evaluated against the existing legacy conformance
scenario; explicit provenance or an opt-in policy may be more broadly useful.

Regression history

The SDK previously removed guessed authorization metadata in
PR #507:

// No valid authorization metadata found - return error instead of guessing
// OAuth endpoints must be discovered from the server, not constructed by the client
Err(AuthError::NoAuthorizationSupport)

The fallback was reintroduced in
PR #960 to satisfy
client conformance, including
auth/2025-03-26-oauth-endpoint-fallback.

Preserving that compatibility makes sense, but silently collapsing real
discovery and guessed fallback into the same successful result creates a
regression for callers that use discovery to determine authentication support.

Suggested regression coverage

  • A server that returns only 404 responses is distinguishable from a server
    that publishes real authorization metadata.
  • Strict/provenance-aware discovery identifies the all-404 case as unsupported
    or synthesized, rather than verified OAuth support.
  • The existing auth/2025-03-26-oauth-endpoint-fallback conformance scenario
    continues to pass when legacy fallback is explicitly enabled or consumed.
  • Existing callers of discover_metadata() retain compatibility if the new
    API is introduced additively.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is not working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions