diff --git a/lib/mcp_client/auth.rb b/lib/mcp_client/auth.rb index 8f4652f..fecfad2 100644 --- a/lib/mcp_client/auth.rb +++ b/lib/mcp_client/auth.rb @@ -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] List of valid redirect URIs # @param token_endpoint_auth_method [String] Authentication method for token endpoint # @param grant_types [Array] Supported grant types # @param response_types [Array] 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, 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 @@ -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 @@ -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 diff --git a/lib/mcp_client/auth/oauth_provider.rb b/lib/mcp_client/auth/oauth_provider.rb index 388e78a..1680e07 100644 --- a/lib/mcp_client/auth/oauth_provider.rb +++ b/lib/mcp_client/auth/oauth_provider.rb @@ -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 @@ -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| @@ -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 diff --git a/spec/lib/mcp_client/auth/oauth_provider_spec.rb b/spec/lib/mcp_client/auth/oauth_provider_spec.rb index c1bdcfc..8a6a0d7 100644 --- a/spec/lib/mcp_client/auth/oauth_provider_spec.rb +++ b/spec/lib/mcp_client/auth/oauth_provider_spec.rb @@ -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 @@ -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 diff --git a/spec/lib/mcp_client/auth_spec.rb b/spec/lib/mcp_client/auth_spec.rb index 717578f..b5fd23d 100644 --- a/spec/lib/mcp_client/auth_spec.rb +++ b/spec/lib/mcp_client/auth_spec.rb @@ -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