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
37 changes: 33 additions & 4 deletions lib/mcp_client/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,38 @@ def self.from_h(data)

# OAuth client metadata for registration and authorization
class ClientMetadata
attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope
attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope,
:client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts

# @param redirect_uris [Array<String>] List of valid redirect URIs
# @param token_endpoint_auth_method [String] Authentication method for token endpoint
# @param grant_types [Array<String>] Supported grant types
# @param response_types [Array<String>] Supported response types
# @param scope [String, nil] Requested scope
# @param client_name [String, nil] Human-readable client name
# @param client_uri [String, nil] URL of the client home page
# @param logo_uri [String, nil] URL of the client logo
# @param tos_uri [String, nil] URL of the client terms of service
# @param policy_uri [String, nil] URL of the client privacy policy
# @param contacts [Array<String>, nil] List of contact emails for the client
# rubocop:disable Metrics/ParameterLists
def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
grant_types: %w[authorization_code refresh_token],
response_types: ['code'], scope: nil)
response_types: ['code'], scope: nil,
client_name: nil, client_uri: nil, logo_uri: nil,
tos_uri: nil, policy_uri: nil, contacts: nil)
# rubocop:enable Metrics/ParameterLists
@redirect_uris = redirect_uris
@token_endpoint_auth_method = token_endpoint_auth_method
@grant_types = grant_types
@response_types = response_types
@scope = scope
@client_name = client_name
@client_uri = client_uri
@logo_uri = logo_uri
@tos_uri = tos_uri
@policy_uri = policy_uri
@contacts = contacts
end

# Convert to hash for HTTP requests
Expand All @@ -111,7 +128,13 @@ def to_h
token_endpoint_auth_method: @token_endpoint_auth_method,
grant_types: @grant_types,
response_types: @response_types,
scope: @scope
scope: @scope,
client_name: @client_name,
client_uri: @client_uri,
logo_uri: @logo_uri,
tos_uri: @tos_uri,
policy_uri: @policy_uri,
contacts: @contacts
}.compact
end
end
Expand Down Expand Up @@ -180,7 +203,13 @@ def self.build_metadata_from_hash(metadata_data)
grant_types: metadata_data[:grant_types] || metadata_data['grant_types'] ||
%w[authorization_code refresh_token],
response_types: metadata_data[:response_types] || metadata_data['response_types'] || ['code'],
scope: metadata_data[:scope] || metadata_data['scope']
scope: metadata_data[:scope] || metadata_data['scope'],
client_name: metadata_data[:client_name] || metadata_data['client_name'],
client_uri: metadata_data[:client_uri] || metadata_data['client_uri'],
logo_uri: metadata_data[:logo_uri] || metadata_data['logo_uri'],
tos_uri: metadata_data[:tos_uri] || metadata_data['tos_uri'],
policy_uri: metadata_data[:policy_uri] || metadata_data['policy_uri'],
contacts: metadata_data[:contacts] || metadata_data['contacts']
)
end

Expand Down
17 changes: 14 additions & 3 deletions lib/mcp_client/auth/oauth_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ class OAuthProvider
# @param scope [String, nil] OAuth scope
# @param logger [Logger, nil] Optional logger
# @param storage [Object, nil] Storage backend for tokens and client info
def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil)
# @param client_metadata [Hash] Extra OIDC client metadata fields for DCR registration.
# Supported keys: :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
client_metadata: {})
self.server_url = server_url
self.redirect_uri = redirect_uri
self.scope = scope
self.logger = logger || Logger.new($stdout, level: Logger::WARN)
self.storage = storage || MemoryStorage.new
@extra_client_metadata = client_metadata
@http_client = create_http_client
end

Expand Down Expand Up @@ -333,7 +337,8 @@ def register_client(server_metadata)
token_endpoint_auth_method: 'none', # Public client
grant_types: %w[authorization_code refresh_token],
response_types: ['code'],
scope: scope
scope: scope,
**@extra_client_metadata
)

response = @http_client.post(server_metadata.registration_endpoint) do |req|
Expand All @@ -355,7 +360,13 @@ def register_client(server_metadata)
token_endpoint_auth_method: data['token_endpoint_auth_method'] || 'none',
grant_types: data['grant_types'] || %w[authorization_code refresh_token],
response_types: data['response_types'] || ['code'],
scope: data['scope']
scope: data['scope'],
client_name: data['client_name'],
client_uri: data['client_uri'],
logo_uri: data['logo_uri'],
tos_uri: data['tos_uri'],
policy_uri: data['policy_uri'],
contacts: data['contacts']
)

# Warn if server changed redirect_uri
Expand Down
114 changes: 114 additions & 0 deletions spec/lib/mcp_client/auth/oauth_provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
provider = described_class.new(server_url: server_url)
expect(provider.redirect_uri).to eq('http://localhost:8080/callback')
end

it 'accepts extra client_metadata' do
provider = described_class.new(
server_url: server_url,
client_metadata: { client_name: 'My App', contacts: ['dev@example.com'] }
)
extra = provider.instance_variable_get(:@extra_client_metadata)
expect(extra).to eq(client_name: 'My App', contacts: ['dev@example.com'])
end

it 'defaults extra client_metadata to empty hash' do
provider = described_class.new(server_url: server_url)
extra = provider.instance_variable_get(:@extra_client_metadata)
expect(extra).to eq({})
end
end

describe 'OAuth discovery URL generation' do
Expand Down Expand Up @@ -276,4 +291,103 @@
expect(logger).to have_received(:warn).with(expected_message)
end
end

describe '#register_client with extra client_metadata' do
let(:storage_instance) { MCPClient::Auth::OAuthProvider::MemoryStorage.new }
let(:logger) { instance_double('Logger') }
let(:extra_metadata) do
{
client_name: 'My MCP App',
client_uri: 'https://myapp.example.com',
logo_uri: 'https://myapp.example.com/logo.png',
tos_uri: 'https://myapp.example.com/tos',
policy_uri: 'https://myapp.example.com/privacy',
contacts: ['admin@myapp.example.com']
}
end
let(:provider) do
described_class.new(
server_url: 'https://mcp.example.com',
redirect_uri: redirect_uri,
scope: 'read',
logger: logger,
storage: storage_instance,
client_metadata: extra_metadata
)
end
let(:http_client) { double('Faraday::Connection') }
let(:server_metadata) do
instance_double(
'MCPClient::Auth::ServerMetadata',
registration_endpoint: 'https://auth.example.com/register'
)
end
let(:registration_response_body) do
{
'client_id' => 'new-client-id',
'client_name' => 'My MCP App',
'client_uri' => 'https://myapp.example.com',
'logo_uri' => 'https://myapp.example.com/logo.png',
'tos_uri' => 'https://myapp.example.com/tos',
'policy_uri' => 'https://myapp.example.com/privacy',
'contacts' => ['admin@myapp.example.com'],
'redirect_uris' => [redirect_uri]
}.to_json
end
let(:sent_bodies) { [] }

before do
allow(logger).to receive(:debug)
allow(logger).to receive(:warn)

provider.instance_variable_set(:@http_client, http_client)
response = instance_double('Faraday::Response', success?: true, status: 200, body: registration_response_body)

allow(http_client).to receive(:post) do |_url, &block|
request = Struct.new(:headers, :body).new({}, nil)
block.call(request)
sent_bodies << JSON.parse(request.body)
response
end
end

it 'sends extra metadata fields in DCR request' do
provider.send(:register_client, server_metadata)

body = sent_bodies.first
expect(body['client_name']).to eq('My MCP App')
expect(body['client_uri']).to eq('https://myapp.example.com')
expect(body['logo_uri']).to eq('https://myapp.example.com/logo.png')
expect(body['tos_uri']).to eq('https://myapp.example.com/tos')
expect(body['policy_uri']).to eq('https://myapp.example.com/privacy')
expect(body['contacts']).to eq(['admin@myapp.example.com'])
end

it 'parses extra metadata fields from server response' do
client_info = provider.send(:register_client, server_metadata)

expect(client_info.metadata.client_name).to eq('My MCP App')
expect(client_info.metadata.client_uri).to eq('https://myapp.example.com')
expect(client_info.metadata.logo_uri).to eq('https://myapp.example.com/logo.png')
expect(client_info.metadata.tos_uri).to eq('https://myapp.example.com/tos')
expect(client_info.metadata.policy_uri).to eq('https://myapp.example.com/privacy')
expect(client_info.metadata.contacts).to eq(['admin@myapp.example.com'])
end

it 'omits nil extra fields from DCR request' do
minimal_provider = described_class.new(
server_url: 'https://mcp.example.com',
redirect_uri: redirect_uri,
logger: logger,
storage: storage_instance
)
minimal_provider.instance_variable_set(:@http_client, http_client)

minimal_provider.send(:register_client, server_metadata)

body = sent_bodies.first
expect(body).not_to have_key('client_name')
expect(body).not_to have_key('contacts')
end
end
end
56 changes: 56 additions & 0 deletions spec/lib/mcp_client/auth_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,62 @@
scope: 'read write'
)
expect(hash).not_to have_key(:client_id)
expect(hash).not_to have_key(:client_name)
expect(hash).not_to have_key(:logo_uri)
end

it 'includes extra OIDC fields when provided' do
metadata = described_class.new(
redirect_uris: ['http://localhost:8080/callback'],
client_name: 'My App',
client_uri: 'https://myapp.example.com',
logo_uri: 'https://myapp.example.com/logo.png',
tos_uri: 'https://myapp.example.com/tos',
policy_uri: 'https://myapp.example.com/privacy',
contacts: ['admin@myapp.example.com']
)
hash = metadata.to_h
expect(hash[:client_name]).to eq('My App')
expect(hash[:client_uri]).to eq('https://myapp.example.com')
expect(hash[:logo_uri]).to eq('https://myapp.example.com/logo.png')
expect(hash[:tos_uri]).to eq('https://myapp.example.com/tos')
expect(hash[:policy_uri]).to eq('https://myapp.example.com/privacy')
expect(hash[:contacts]).to eq(['admin@myapp.example.com'])
end
end

describe '.from_h (via ClientInfo.build_metadata_from_hash)' do
it 'round-trips extra fields through ClientInfo serialization' do
metadata = described_class.new(
redirect_uris: ['http://localhost:8080/callback'],
client_name: 'Test Client',
client_uri: 'https://test.example.com',
logo_uri: 'https://test.example.com/logo.png',
tos_uri: 'https://test.example.com/tos',
policy_uri: 'https://test.example.com/policy',
contacts: ['dev@test.example.com']
)
client_info = MCPClient::Auth::ClientInfo.new(client_id: 'cid', metadata: metadata)
restored = MCPClient::Auth::ClientInfo.from_h(client_info.to_h)

expect(restored.metadata.client_name).to eq('Test Client')
expect(restored.metadata.client_uri).to eq('https://test.example.com')
expect(restored.metadata.logo_uri).to eq('https://test.example.com/logo.png')
expect(restored.metadata.tos_uri).to eq('https://test.example.com/tos')
expect(restored.metadata.policy_uri).to eq('https://test.example.com/policy')
expect(restored.metadata.contacts).to eq(['dev@test.example.com'])
end

it 'round-trips extra fields with string keys' do
hash = {
'redirect_uris' => ['http://localhost/cb'],
'client_name' => 'String Key Client',
'contacts' => ['a@b.com']
}
restored = MCPClient::Auth::ClientInfo.build_metadata_from_hash(hash)

expect(restored.client_name).to eq('String Key Client')
expect(restored.contacts).to eq(['a@b.com'])
end
end
end
Expand Down
Loading