mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 05:35:40 +08:00
Adds three new configurable fields to MCP server OAuth:
- `oauth_authorization_params` — JSON object merged into authorization
requests (e.g. `{"access_type":"offline"}` for Google APIs)
- `oauth_token_params` — JSON object merged into token exchange and
refresh requests (e.g. `{"audience":"..."}` for resource indicators)
- `oauth_require_refresh_token` — fails OAuth if the provider does not
return a refresh token, surfacing misconfiguration early
The OAuth flow is also improved in several ways:
- Reads `token_endpoint_auth_methods_supported` from discovery metadata
and negotiates the correct client authentication method
(client_secret_basic, client_secret_post, or none)
- Validates client registration requirements before starting the flow,
giving actionable error messages when dynamic registration is
unavailable
- Null values in custom params remove default parameters, allowing
overrides like removing the `resource` indicator
Additionally, the MCP client now passes through tool result errors
(isError: true) instead of raising exceptions, so the AI can see
and reason about tool-level failures.
150 lines
5.4 KiB
Ruby
Vendored
150 lines
5.4 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module DiscourseAi
|
|
module Mcp
|
|
class OAuthDiscovery
|
|
Result =
|
|
Struct.new(
|
|
:resource,
|
|
:resource_metadata_url,
|
|
:issuer,
|
|
:authorization_endpoint,
|
|
:token_endpoint,
|
|
:revocation_endpoint,
|
|
:registration_endpoint,
|
|
:token_endpoint_auth_methods_supported,
|
|
keyword_init: true,
|
|
)
|
|
|
|
class << self
|
|
def discover!(server, challenge_header: nil)
|
|
resource_metadata_url =
|
|
challenge_parameters(challenge_header)["resource_metadata"] ||
|
|
default_well_known_url(server.url, "oauth-protected-resource")
|
|
|
|
resource_metadata = get_json!(server, resource_metadata_url)
|
|
issuer =
|
|
Array(resource_metadata["authorization_servers"]).first ||
|
|
resource_metadata["authorization_server"]
|
|
if issuer.blank?
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.errors.oauth_discovery_failed")
|
|
end
|
|
|
|
auth_server_metadata =
|
|
get_json!(server, default_well_known_url(issuer, "oauth-authorization-server"))
|
|
authorization_endpoint = auth_server_metadata["authorization_endpoint"].presence
|
|
token_endpoint = auth_server_metadata["token_endpoint"].presence
|
|
|
|
if authorization_endpoint.blank? || token_endpoint.blank?
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.errors.oauth_discovery_failed")
|
|
end
|
|
|
|
validate_discovered_url!(authorization_endpoint)
|
|
validate_discovered_url!(token_endpoint)
|
|
|
|
revocation_endpoint = auth_server_metadata["revocation_endpoint"].presence
|
|
validate_discovered_url!(revocation_endpoint) if revocation_endpoint.present?
|
|
|
|
registration_endpoint = auth_server_metadata["registration_endpoint"].presence
|
|
validate_discovered_url!(registration_endpoint) if registration_endpoint.present?
|
|
|
|
Result.new(
|
|
resource: resource_metadata["resource"].presence || server.url,
|
|
resource_metadata_url: resource_metadata_url,
|
|
issuer: auth_server_metadata["issuer"].presence || issuer,
|
|
authorization_endpoint: authorization_endpoint,
|
|
token_endpoint: token_endpoint,
|
|
revocation_endpoint: revocation_endpoint,
|
|
registration_endpoint: registration_endpoint,
|
|
token_endpoint_auth_methods_supported:
|
|
Array(auth_server_metadata["token_endpoint_auth_methods_supported"]).presence,
|
|
)
|
|
end
|
|
|
|
def challenge_parameters(header)
|
|
value = header.to_s[/Bearer\s+(.+)\z/i, 1]
|
|
return {} if value.blank?
|
|
|
|
value
|
|
.scan(/([a-zA-Z_]+)="([^"]*)"|([a-zA-Z_]+)=([^,\s]+)/)
|
|
.each_with_object({}) do |parts, hash|
|
|
key = parts[0] || parts[2]
|
|
parsed_value = parts[1] || parts[3]
|
|
hash[key] = parsed_value if key.present?
|
|
end
|
|
end
|
|
|
|
def default_well_known_url(raw_url, suffix)
|
|
uri = AiMcpServer.parse_public_uri(raw_url)
|
|
if uri.nil?
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.invalid_url_not_https")
|
|
end
|
|
|
|
path = uri.path.to_s
|
|
path = "" if path == "/"
|
|
path = path.sub(%r{/\z}, "")
|
|
|
|
duplicated = uri.dup
|
|
duplicated.path = "/.well-known/#{suffix}#{path}"
|
|
duplicated.query = nil
|
|
duplicated.to_s
|
|
end
|
|
|
|
private
|
|
|
|
def get_json!(server, url)
|
|
uri = AiMcpServer.parse_public_uri(url)
|
|
if uri.nil?
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.invalid_url_not_https")
|
|
end
|
|
|
|
AiMcpServer.validate_hostname_public!(uri.hostname)
|
|
|
|
response = nil
|
|
FinalDestination::HTTP.start(
|
|
uri.hostname,
|
|
uri.port,
|
|
use_ssl: true,
|
|
open_timeout: server.timeout_seconds,
|
|
read_timeout: server.timeout_seconds,
|
|
) do |http|
|
|
request = FinalDestination::HTTP::Get.new(uri.request_uri)
|
|
request["Accept"] = "application/json"
|
|
request["User-Agent"] = DiscourseAi::Mcp::Client::USER_AGENT
|
|
response = http.request(request)
|
|
end
|
|
|
|
if response.code.to_i != 200
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t(
|
|
"discourse_ai.mcp_servers.errors.oauth_discovery_failed_with_status",
|
|
status: response.code.to_i,
|
|
)
|
|
end
|
|
|
|
JSON.parse(response.body.presence || "{}")
|
|
rescue JSON::ParserError
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.errors.oauth_discovery_failed")
|
|
end
|
|
|
|
def validate_discovered_url!(url)
|
|
uri = AiMcpServer.parse_public_uri(url)
|
|
if uri.nil?
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.errors.oauth_discovery_failed")
|
|
end
|
|
|
|
AiMcpServer.validate_hostname_public!(uri.hostname)
|
|
rescue FinalDestination::SSRFError, SocketError, URI::InvalidURIError
|
|
raise DiscourseAi::Mcp::Client::Error,
|
|
I18n.t("discourse_ai.mcp_servers.errors.oauth_discovery_failed")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|