diff --git a/lib/ruby_llm/mcp/auth/discoverer.rb b/lib/ruby_llm/mcp/auth/discoverer.rb index ad007de..ee065c3 100644 --- a/lib/ruby_llm/mcp/auth/discoverer.rb +++ b/lib/ruby_llm/mcp/auth/discoverer.rb @@ -29,6 +29,7 @@ def discover(server_url, resource_metadata_url: nil) # then fall back to direct auth server metadata discovery for compatibility. server_metadata = try_protected_resource_discovery(server_url, resource_metadata_url: resource_metadata_url) server_metadata ||= try_authorization_server_discovery(server_url) + server_metadata ||= try_legacy_authorization_server_discovery(server_url) server_metadata ||= cached server_metadata ||= create_default_metadata(server_url) @@ -51,6 +52,24 @@ def try_authorization_server_discovery(server_url) ) end + # Try legacy MCP direct auth server discovery (pre-2025-06) + # by treating the MCP server origin as the OAuth issuer. + # @param server_url [String] MCP server URL + # @return [ServerMetadata, nil] server metadata or nil + def try_legacy_authorization_server_discovery(server_url) + base_url = UrlBuilder.get_authorization_base_url(server_url) + return nil if base_url == server_url + + logger.debug("Trying legacy oauth-authorization-server discovery with base URL: #{base_url}") + urls = UrlBuilder.build_discovery_urls(base_url, :authorization_server) + fetch_first_server_metadata( + urls, + context: "legacy oauth-authorization-server discovery", + expected_issuer: base_url, + enforce_issuer_match: false + ) + end + # Try oauth-protected-resource discovery (delegation pattern) # @param server_url [String] MCP server URL # @param resource_metadata_url [String, nil] explicit resource metadata URL from WWW-Authenticate @@ -114,12 +133,17 @@ def create_default_metadata(server_url) # Fetch OAuth server metadata # @param url [String] discovery URL # @return [ServerMetadata] server metadata - def fetch_server_metadata(url, expected_issuer:) + def fetch_server_metadata(url, expected_issuer:, enforce_issuer_match: true) logger.debug("Fetching server metadata from #{url}") response = http_client.get(url) data = HttpResponseHandler.handle_response(response, context: "Server metadata fetch") - validate_server_metadata!(data, expected_issuer: expected_issuer, source_url: url) + validate_server_metadata!( + data, + expected_issuer: expected_issuer, + source_url: url, + enforce_issuer_match: enforce_issuer_match + ) ServerMetadata.new( issuer: data["issuer"], @@ -155,10 +179,14 @@ def fetch_resource_metadata(url, expected_resource:) # @param urls [Array] discovery URLs in priority order # @param context [String] log context # @return [ServerMetadata, nil] first metadata result or nil - def fetch_first_server_metadata(urls, context:, expected_issuer:) + def fetch_first_server_metadata(urls, context:, expected_issuer:, enforce_issuer_match: true) urls.each do |url| logger.debug("Trying #{context} URL: #{url}") - return fetch_server_metadata(url, expected_issuer: expected_issuer) + return fetch_server_metadata( + url, + expected_issuer: expected_issuer, + enforce_issuer_match: enforce_issuer_match + ) rescue StandardError => e logger.debug("#{context} failed for #{url}: #{e.message}") end @@ -170,7 +198,7 @@ def fetch_first_server_metadata(urls, context:, expected_issuer:) # @param expected_issuer [String] issuer identifier used to build discovery URLs # @param source_url [String] discovery URL used for fetching metadata # @raise [Errors::TransportError] when issuer is missing or mismatched - def validate_server_metadata!(data, expected_issuer:, source_url:) + def validate_server_metadata!(data, expected_issuer:, source_url:, enforce_issuer_match: true) issuer = data["issuer"] unless issuer.is_a?(String) && !issuer.empty? raise Errors::TransportError.new( @@ -179,6 +207,7 @@ def validate_server_metadata!(data, expected_issuer:, source_url:) end return if issuer == expected_issuer + return warn_legacy_issuer_mismatch(expected_issuer, issuer, source_url) unless enforce_issuer_match raise Errors::TransportError.new( message: "Server metadata fetch failed: issuer '#{issuer}' did not match expected issuer " \ @@ -186,6 +215,20 @@ def validate_server_metadata!(data, expected_issuer:, source_url:) ) end + # Legacy MCP compatibility can return metadata whose issuer differs from the + # MCP server origin. Accept it with warning so endpoint metadata still works. + # @param expected_issuer [String] + # @param issuer [String] + # @param source_url [String] + # @return [nil] + def warn_legacy_issuer_mismatch(expected_issuer, issuer, source_url) + logger.info( + "Legacy OAuth discovery issuer mismatch accepted: expected '#{expected_issuer}', got '#{issuer}' " \ + "from #{source_url}" + ) + nil + end + # Validate RFC 9728 resource matching rules before metadata is trusted. # @param data [Hash] metadata response body # @param expected_resource [String] resource identifier used for discovery/request diff --git a/spec/ruby_llm/mcp/auth/discoverer_spec.rb b/spec/ruby_llm/mcp/auth/discoverer_spec.rb index fc7dd40..184834e 100644 --- a/spec/ruby_llm/mcp/auth/discoverer_spec.rb +++ b/spec/ruby_llm/mcp/auth/discoverer_spec.rb @@ -405,6 +405,36 @@ end end + context "when protected resource discovery fails for a path-based MCP endpoint" do + let(:server_url) { "https://mcp.atlassian.com/v1/sse" } + let(:metadata_response) do + { + "issuer" => "https://cf.mcp.atlassian.com", + "authorization_endpoint" => "https://mcp.atlassian.com/v1/authorize", + "token_endpoint" => "https://cf.mcp.atlassian.com/v1/token" + } + end + + before do + allow(http_client).to receive(:get).and_return(httpx_error_response("Not found")) + + metadata_resp = instance_double(HTTPX::Response, status: 200, body: metadata_response.to_json) + allow(http_client).to receive(:get) + .with("https://mcp.atlassian.com/.well-known/oauth-authorization-server") + .and_return(metadata_resp) + end + + it "falls back to legacy base URL auth server discovery" do + result = discoverer.discover(server_url) + + expect(result).to be_a(RubyLLM::MCP::Auth::ServerMetadata) + expect(result.issuer).to eq("https://cf.mcp.atlassian.com") + expect(result.authorization_endpoint).to eq("https://mcp.atlassian.com/v1/authorize") + expect(result.token_endpoint).to eq("https://cf.mcp.atlassian.com/v1/token") + expect(logger).to have_received(:info).with(/Legacy OAuth discovery issuer mismatch accepted/) + end + end + context "when all discovery methods fail" do before do allow(http_client).to receive(:get).and_return(httpx_error_response("Connection failed"))