Class: MCP::Client::OAuth::Flow

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp/client/oauth/flow.rb

Overview

Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow. Driven by MCP::Client::HTTP on a 401 response. The user-facing surface is Provider; this class consumes a Provider plus signal data extracted from the failing response (resource_metadata URL, scope challenge).

Defined Under Namespace

Classes: AuthorizationError, InvalidGrantError

Instance Method Summary collapse

Constructor Details

#initialize(provider:, http_client_factory: nil) ⇒ Flow

Returns a new instance of Flow.



26
27
28
29
# File 'lib/mcp/client/oauth/flow.rb', line 26

def initialize(provider:, http_client_factory: nil)
  @provider = provider
  @http_client_factory = http_client_factory || -> { default_http_client }
end

Instance Method Details

#client_credentials_client_infoObject

Reads the pre-registered credentials for the client_credentials grant directly from the provider's stored client_information, rather than going through ensure_client_registered (which targets the authorization-code flow and reaches for Provider-only methods like client_metadata and client_id_metadata_document_url). The grant is for confidential clients, so a missing client_id is a clean configuration error, not a fallback to dynamic registration.



131
132
133
134
135
136
137
138
139
# File 'lib/mcp/client/oauth/flow.rb', line 131

def client_credentials_client_info
  info = @provider.client_information
  unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
    raise AuthorizationError,
      "Cannot run the client_credentials grant: the provider has no stored `client_id`."
  end

  info
end

#ensure_client_credentials_issuer!(client_info, as_metadata:) ⇒ Object

Per SEP-2352, static machine-to-machine credentials are bound to their authorization server the same way registered ones are: when the stored client_information records an issuer that differs from the current authorization server, surface an error instead of silently sending another server's credentials (the spec's "SHOULD surface an error"; the TypeScript SDK raises AuthorizationServerMismatchError here). Re-registration is not an option for the client_credentials grant - the credentials are pre-registered, not DCR results - so the operator must update the stored credentials. Credentials without a recorded issuer keep working unchanged.

Raises:



149
150
151
152
153
154
155
156
157
158
# File 'lib/mcp/client/oauth/flow.rb', line 149

def ensure_client_credentials_issuer!(client_info, as_metadata:)
  stored_issuer = client_info_required_value(client_info, "issuer")
  return if stored_issuer.nil?
  return if stored_issuer == ["issuer"]

  raise AuthorizationError,
    "Stored client credentials are bound to a different authorization server " \
      "(stored issuer #{stored_issuer.inspect}, current #{["issuer"].inspect}); " \
      "refusing to send them to the current authorization server (SEP-2352)."
end

#refresh!(server_url:, resource_metadata_url: nil) ⇒ Object

Exchanges the saved refresh_token for a fresh access token (RFC 6749 Section 6). Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security checks before talking to it.

Returns :refreshed on success. Raises AuthorizationError when the provider has no refresh token, no client information, or when the token endpoint refuses the refresh request. https://www.rfc-editor.org/rfc/rfc6749#section-6

Raises:



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/mcp/client/oauth/flow.rb', line 197

def refresh!(server_url:, resource_metadata_url: nil)
  refresh_token = read_token("refresh_token")
  raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token

  stored_client_info = @provider.client_information
  have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")

  # A CIMD-configured provider stores no `client_information` on purpose
  # (the CIMD URL is re-resolved against the live AS metadata on every flow).
  # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
  have_cimd_url = !@provider..nil?

  unless have_stored_client_info || have_cimd_url
    raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
  end

  if 
    ensure_secure_url!(, label: "WWW-Authenticate resource_metadata URL")
  end

  prm, authorization_server = locate_authorization_server(
    server_url: server_url,
    resource_metadata_url: ,
  )

  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))

   = (authorization_server: authorization_server, legacy: prm.nil?)

  client_info = if have_stored_client_info
    # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
    # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
    ensure_refreshable_client_information!(stored_client_info, as_metadata: )
    stored_client_info
  elsif ["client_id_metadata_document_supported"] == true
    { "client_id" => @provider. }
  else
    raise AuthorizationError,
      "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
        "`client_id_metadata_document_supported: true`."
  end

  new_tokens = exchange_refresh_token(
    as_metadata: ,
    client_info: client_info,
    refresh_token: refresh_token,
    resource: resource,
  )

  @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
  :refreshed
end

#run!(server_url:, resource_metadata_url: nil, scope: nil) ⇒ Object

Runs the full discovery, registration, authorization, and token exchange flow. On success, persists tokens via the provider and returns :authorized.

Raises:



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/mcp/client/oauth/flow.rb', line 33

def run!(server_url:, resource_metadata_url: nil, scope: nil)
  # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
  # first thing we contact in the OAuth flow, so it has to clear the same
  # Communication Security bar as the OAuth endpoints downstream.
  if 
    ensure_secure_url!(, label: "WWW-Authenticate resource_metadata URL")
  end

  prm, authorization_server = locate_authorization_server(
    server_url: server_url,
    resource_metadata_url: ,
  )

  # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
  # both the authorization and token requests. When PRM advertises a `resource`,
  # it MUST identify the same MCP server we are talking to; otherwise we are
  # being redirected to credentials minted for a different audience.
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))

   = (authorization_server: authorization_server, legacy: prm.nil?)

  case provider_authorization_flow
  when :client_credentials
    return run_client_credentials!(as_metadata: , prm: prm, resource: resource, scope: scope)
  when :jwt_bearer
    return run_jwt_bearer!(as_metadata: , prm: prm, resource: resource, scope: scope)
  end

  ensure_pkce_supported!()

  client_info = ensure_client_registered(as_metadata: )

  effective_scope = resolve_scope(scope: scope, prm: prm || {})
  effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: )
  pkce = PKCE.generate
  state = SecureRandom.urlsafe_base64(32)

  authorization_url = build_authorization_url(
    as_metadata: ,
    client_id: client_info_required_value(client_info, "client_id"),
    scope: effective_scope,
    state: state,
    code_challenge: pkce[:code_challenge],
    resource: resource,
  )

  @provider.redirect_handler.call(authorization_url)
  callback_result = Array(@provider.callback_handler.call)
  code, returned_state, returned_iss = callback_result
  raise AuthorizationError, "Authorization callback did not return an authorization code." unless code

  unless states_match?(returned_state, state)
    raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
  end

  validate_authorization_response_issuer!(
    as_metadata: ,
    iss: returned_iss,
    iss_provided: callback_result.length >= 3,
  )

  tokens = exchange_authorization_code(
    as_metadata: ,
    client_info: client_info,
    code: code,
    code_verifier: pkce[:code_verifier],
    resource: resource,
  )

  @provider.save_tokens(tokens)
  :authorized
end

#run_client_credentials!(as_metadata:, prm:, resource:, scope:) ⇒ Object

Runs the OAuth 2.1 client_credentials grant (machine-to-machine, no user interaction) and persists the resulting token. Shares the same discovery and security checks as run!; the only difference is the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request, and no offline_access augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). The pre-registered client_id / client_secret come from the provider's stored client_information. https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization



112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/mcp/client/oauth/flow.rb', line 112

def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
  client_info = client_credentials_client_info
  ensure_client_credentials_issuer!(client_info, as_metadata: )

  form = { "grant_type" => "client_credentials" }
  effective_scope = resolve_scope(scope: scope, prm: prm)
  form["scope"] = effective_scope if effective_scope
  form["resource"] = resource if resource

  tokens = post_to_token_endpoint(as_metadata: , client_info: client_info, form: form)
  @provider.save_tokens(tokens)
  :authorized
end

#run_jwt_bearer!(as_metadata:, prm:, resource:, scope:) ⇒ Object

Runs the RFC 7523 jwt-bearer grant for the SEP-990 Enterprise Managed Authorization extension: the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via IDJAGTokenExchange), which is presented at the token endpoint with client_secret_basic authentication. Shares the same discovery and security checks as run!; like client_credentials, there is no PKCE, redirect, or authorization request. The assertion's audience is the issuer identifier that ensure_issuer_matches! validated. https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/mcp/client/oauth/flow.rb', line 166

def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
  client_info = @provider.client_information
  unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
    raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
  end

  assertion = @provider.jwt_bearer_assertion(audience: ["issuer"], resource: resource)
  if assertion.nil? || assertion.to_s.empty?
    raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
  end

  form = {
    "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "assertion" => assertion,
  }
  effective_scope = resolve_scope(scope: scope, prm: prm)
  form["scope"] = effective_scope if effective_scope
  form["resource"] = resource if resource

  tokens = post_to_token_endpoint(as_metadata: , client_info: client_info, form: form)
  @provider.save_tokens(tokens)
  :authorized
end