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
53 changes: 48 additions & 5 deletions lib/ruby_llm/mcp/auth/discoverer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -155,10 +179,14 @@ def fetch_resource_metadata(url, expected_resource:)
# @param urls [Array<String>] 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
Expand All @@ -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(
Expand All @@ -179,13 +207,28 @@ 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 " \
"'#{expected_issuer}' for #{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
Expand Down
30 changes: 30 additions & 0 deletions spec/ruby_llm/mcp/auth/discoverer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down