Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 16 additions & 4 deletions lib/mcp_client/auth/oauth_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class OAuthProvider
# @!attribute [rw] redirect_uri
# @return [String] OAuth redirect URI
# @!attribute [rw] scope
# @return [String, nil] OAuth scope
# @return [String, Symbol, nil] OAuth scope (use :all for all server-supported scopes)
# @!attribute [rw] logger
# @return [Logger] Logger instance
# @!attribute [rw] storage
Expand All @@ -27,7 +27,7 @@ class OAuthProvider
# Initialize OAuth provider
# @param server_url [String] The MCP server URL (used as OAuth resource parameter)
# @param redirect_uri [String] OAuth redirect URI (default: http://localhost:8080/callback)
# @param scope [String, nil] OAuth scope
# @param scope [String, Symbol, nil] OAuth scope (use :all for all server-supported scopes)
# @param logger [Logger, nil] Optional logger
# @param storage [Object, nil] Storage backend for tokens and client info
# @param client_metadata [Hash] Extra OIDC client metadata fields for DCR registration.
Expand Down Expand Up @@ -62,6 +62,14 @@ def access_token
refresh_token(token) if token.refresh_token
end

# Return the scopes supported by the authorization server
# Discovers server metadata and returns the scopes_supported list.
# @return [Array<String>] supported scopes, or empty array if not advertised
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
def supported_scopes
@supported_scopes ||= discover_authorization_server.scopes_supported || []
end

# Start OAuth authorization flow
# @return [String] Authorization URL to redirect user to
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
Expand Down Expand Up @@ -332,12 +340,14 @@ def get_or_register_client(server_metadata)
def register_client(server_metadata)
logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")

resolved_scope = scope == :all ? supported_scopes.join(' ') : scope

metadata = ClientMetadata.new(
redirect_uris: [redirect_uri],
token_endpoint_auth_method: 'none', # Public client
grant_types: %w[authorization_code refresh_token],
response_types: ['code'],
scope: scope,
scope: resolved_scope,
**@extra_client_metadata
)

Expand Down Expand Up @@ -407,11 +417,13 @@ def build_authorization_url(server_metadata, client_info, pkce, state)
# Use the redirect_uri that was actually registered
registered_redirect_uri = client_info.metadata.redirect_uris.first

resolved_scope = scope == :all ? supported_scopes.join(' ') : scope

params = {
response_type: 'code',
client_id: client_info.client_id,
redirect_uri: registered_redirect_uri,
scope: scope,
scope: resolved_scope,
state: state,
code_challenge: pkce.code_challenge,
code_challenge_method: pkce.code_challenge_method,
Expand Down
96 changes: 96 additions & 0 deletions spec/lib/mcp_client/auth/oauth_provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,102 @@
end
end

describe '#supported_scopes' do
let(:server_metadata) do
MCPClient::Auth::ServerMetadata.new(
issuer: 'https://mcp.example.com',
authorization_endpoint: 'https://mcp.example.com/authorize',
token_endpoint: 'https://mcp.example.com/token',
scopes_supported: %w[read write admin]
)
end

before do
allow(oauth_provider).to receive(:discover_authorization_server).and_return(server_metadata)
end

it 'returns the scopes from server metadata' do
expect(oauth_provider.supported_scopes).to eq(%w[read write admin])
end

it 'memoizes the result' do
oauth_provider.supported_scopes
oauth_provider.supported_scopes
expect(oauth_provider).to have_received(:discover_authorization_server).once
end

context 'when scopes_supported is nil' do
let(:server_metadata) do
MCPClient::Auth::ServerMetadata.new(
issuer: 'https://mcp.example.com',
authorization_endpoint: 'https://mcp.example.com/authorize',
token_endpoint: 'https://mcp.example.com/token',
scopes_supported: nil
)
end

it 'returns an empty array' do
expect(oauth_provider.supported_scopes).to eq([])
end
end
end

describe 'scope: :all' do
let(:server_metadata) do
MCPClient::Auth::ServerMetadata.new(
issuer: 'https://mcp.example.com',
authorization_endpoint: 'https://mcp.example.com/authorize',
token_endpoint: 'https://mcp.example.com/token',
registration_endpoint: 'https://mcp.example.com/register',
scopes_supported: %w[read write admin]
)
end

let(:client_metadata) do
MCPClient::Auth::ClientMetadata.new(
redirect_uris: [redirect_uri],
token_endpoint_auth_method: 'none'
)
end

let(:client_info) do
MCPClient::Auth::ClientInfo.new(
client_id: 'client123',
metadata: client_metadata
)
end

let(:provider) do
described_class.new(
server_url: server_url,
redirect_uri: redirect_uri,
scope: :all,
logger: logger,
storage: storage
)
end

before do
allow(storage).to receive(:get_server_metadata).and_return(server_metadata)
allow(storage).to receive(:set_server_metadata)
allow(storage).to receive(:get_client_info).and_return(client_info)
allow(storage).to receive(:set_pkce)
allow(storage).to receive(:set_state)
end

it 'resolves :all to all supported scopes in the authorization URL' do
auth_url = provider.start_authorization_flow
uri = URI.parse(auth_url)
params = URI.decode_www_form(uri.query).to_h
expect(params['scope']).to eq('read write admin')
end

it 'does not mutate @scope' do
provider.start_authorization_flow
expect(provider.scope).to eq(:all)
end
end

describe '#exchange_authorization_code' do
let(:storage_instance) { MCPClient::Auth::OAuthProvider::MemoryStorage.new }
let(:logger) { instance_double('Logger') }
Expand Down
Loading